From 8291c84cc3a201d495de8ef770f645b496d446cd Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:09:10 +0530 Subject: [PATCH] feat: Use new compose conversation in conversation sidebar (#11085) # Pull Request Template ## Description This PR includes the implementation of the new Compose Conversation form in the conversation sidebar, replacing the old one. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/4312e20a63714eb892d7b5cd0dcda893?sid=9bd5254e-2b1f-462c-b2c1-a3048a111683 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../NewConversation/ComposeConversation.vue | 79 ++- .../components/ActionButtons.vue | 16 + .../components/ComposeNewConversationForm.vue | 2 +- .../components/ContactSelector.vue | 4 +- .../components/InboxSelector.vue | 2 +- .../components/WhatsAppOptions.vue | 2 +- .../components/WhatsappTemplateParser.vue | 2 +- .../components/widgets/InboxDropdownItem.vue | 76 --- .../conversation/contact/ContactInfo.vue | 60 +- .../conversation/contact/ConversationForm.vue | 638 ------------------ .../conversation/contact/NewConversation.vue | 71 -- .../contact/WhatsappTemplates.vue | 54 -- 12 files changed, 113 insertions(+), 893 deletions(-) delete mode 100644 app/javascript/dashboard/components/widgets/InboxDropdownItem.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/conversation/contact/ConversationForm.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue delete mode 100644 app/javascript/dashboard/routes/dashboard/conversation/contact/WhatsappTemplates.vue diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 93b0baa3d..610d11dab 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -27,8 +27,14 @@ const props = defineProps({ type: String, default: null, }, + isModal: { + type: Boolean, + default: false, + }, }); +const emit = defineEmits(['close']); + const store = useStore(); const { t } = useI18n(); @@ -61,6 +67,8 @@ const directUploadsEnabled = computed( const activeContact = computed(() => contactById.value(props.contactId)); const composePopoverClass = computed(() => { + if (props.isModal) return ''; + return props.alignPosition === 'right' ? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]' : 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]'; @@ -131,9 +139,14 @@ const clearSelectedContact = () => { const closeCompose = () => { showComposeNewConversation.value = false; - selectedContact.value = null; + if (!props.contactId) { + // If contactId is passed as prop + // Then don't allow to remove the selected contact + selectedContact.value = null; + } targetInbox.value = null; resetContacts(); + emit('close'); }; const createConversation = async ({ payload, isFromWhatsApp }) => { @@ -182,7 +195,15 @@ watch( ); const handleClickOutside = () => { + if (!showComposeNewConversation.value) return; + showComposeNewConversation.value = false; + emit('close'); +}; + +const onModalBackdropClick = () => { + if (!props.isModal) return; + handleClickOutside(); }; onMounted(() => resetContacts()); @@ -205,7 +226,7 @@ useKeyboardEvents(keyboardEvents); v-on-click-outside="[ handleClickOutside, // Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785 - // This will prevent closing the compose conversation modal when the editor Create link popup is open. + // This will prevent closing the compose conversation modal when the editor Create link popup is open { ignore: ['div.ProseMirror-prompt'] }, ]" class="relative" @@ -218,29 +239,37 @@ useKeyboardEvents(keyboardEvents); :is-open="showComposeNewConversation" :toggle="toggle" /> - + :class="{ + 'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0': + isModal, + }" + @click.self="onModalBackdropClick" + > + + diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 8d421a5aa..90cb67c4f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -232,4 +232,20 @@ useKeyboardEvents(keyboardEvents); .emoji-dialog::before { @apply hidden; } + +// The tag inside the file-upload component overlaps the button due to its position. +// This causes the button's hover state to not work, as it's positioned below the label (z-index). +// Increasing the button's z-index would break the file upload functionality. +// This style ensures the label remains clickable while preserving the button's hover effect. +:deep() { + .file-uploads.file-uploads-html5 { + label { + @apply hover:cursor-pointer; + } + + &:hover button { + @apply dark:bg-n-solid-2 bg-n-alpha-2; + } + } +} diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 41ff88976..4d44b4d42 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => { { {{ @@ -123,6 +124,7 @@ const handleInput = value => { }} { {{ targetInboxLabel }} diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue index 8d32509b8..b8fd1429a 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue @@ -84,7 +84,7 @@ const handleSendMessage = template => { /> diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue index 85f69a665..f3b8a26da 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue @@ -106,7 +106,7 @@ onMounted(() => { {{ diff --git a/app/javascript/dashboard/components/widgets/InboxDropdownItem.vue b/app/javascript/dashboard/components/widgets/InboxDropdownItem.vue deleted file mode 100644 index e2a9a9d46..000000000 --- a/app/javascript/dashboard/components/widgets/InboxDropdownItem.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - {{ name }} - - - {{ inboxIdentifier || computedInboxType }} - - - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index 0de55a92e..b423ecfc6 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -7,8 +7,8 @@ import ContactInfoRow from './ContactInfoRow.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import SocialIcons from './SocialIcons.vue'; import EditContact from './EditContact.vue'; -import NewConversation from './NewConversation.vue'; import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue'; +import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import NextButton from 'dashboard/components-next/button/Button.vue'; @@ -25,8 +25,8 @@ export default { ContactInfoRow, EditContact, Thumbnail, + ComposeConversation, SocialIcons, - NewConversation, ContactMergeModal, }, props: { @@ -49,7 +49,6 @@ export default { data() { return { showEditModal: false, - showConversationModal: false, showMergeModal: false, showDeleteModal: false, }; @@ -92,17 +91,29 @@ export default { return ` ${this.contact.name}?`; }, }, + watch: { + 'contact.id': { + handler(id) { + this.$store.dispatch('contacts/fetchContactableInbox', id); + }, + immediate: true, + }, + }, methods: { dynamicTime, toggleEditModal() { this.showEditModal = !this.showEditModal; }, - toggleConversationModal() { - this.showConversationModal = !this.showConversationModal; - emitter.emit( - BUS_EVENTS.NEW_CONVERSATION_MODAL, - this.showConversationModal - ); + openComposeConversationModal(toggleFn) { + toggleFn(); + // Flag to prevent triggering drag n drop, + // When compose modal is active + emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true); + }, + closeComposeConversationModal() { + // Flag to enable drag n drop, + // When compose modal is closed + emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false); }, toggleDeleteModal() { this.showDeleteModal = !this.showDeleteModal; @@ -113,7 +124,6 @@ export default { }, closeDelete() { this.showDeleteModal = false; - this.showConversationModal = false; this.showEditModal = false; }, findCountryFlag(countryCode, cityAndCountry) { @@ -250,14 +260,22 @@ export default { - + + + + + - -import { ref } from 'vue'; -// constants & helpers -import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; -import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; -import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox'; - -// store -import { mapGetters } from 'vuex'; - -// composables -import { useUISettings } from 'dashboard/composables/useUISettings'; -import { useAlert } from 'dashboard/composables'; -import { required, requiredIf } from '@vuelidate/validators'; -import { useVuelidate } from '@vuelidate/core'; - -// mixins -import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; -import inboxMixin from 'shared/mixins/inboxMixin'; - -// components -import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; -import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue'; -import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue'; -import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue'; -import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue'; -import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; -import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; -import FileUpload from 'vue-upload-component'; -import WhatsappTemplates from './WhatsappTemplates.vue'; - -import { - appendSignature, - removeSignature, -} from 'dashboard/helper/editorHelper'; - -export default { - components: { - Thumbnail, - WootMessageEditor, - ReplyEmailHead, - CannedResponse, - WhatsappTemplates, - InboxDropdownItem, - FileUpload, - AttachmentPreview, - MessageSignatureMissingAlert, - }, - mixins: [inboxMixin, fileUploadMixin], - props: { - contact: { - type: Object, - default: () => ({}), - }, - onSubmit: { - type: Function, - default: () => {}, - }, - }, - emits: ['cancel', 'success'], - setup() { - const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } = - useUISettings(); - const v$ = useVuelidate(); - const uploadAttachment = ref(false); - - return { - fetchSignatureFlagFromUISettings, - setSignatureFlagForInbox, - v$, - uploadAttachment, - }; - }, - data() { - return { - name: '', - subject: '', - message: '', - showCannedResponseMenu: false, - cannedResponseSearchKey: '', - bccEmails: '', - ccEmails: '', - targetInbox: {}, - whatsappTemplateSelected: false, - attachedFiles: [], - }; - }, - validations() { - return { - subject: { - required: requiredIf(this.isAnEmailInbox), - }, - message: { - required, - }, - targetInbox: { - required, - }, - }; - }, - computed: { - ...mapGetters({ - uiFlags: 'contacts/getUIFlags', - conversationsUiFlags: 'contactConversations/getUIFlags', - currentUser: 'getCurrentUser', - globalConfig: 'globalConfig/get', - messageSignature: 'getMessageSignature', - inboxesList: 'inboxes/getInboxes', - }), - sendWithSignature() { - return this.fetchSignatureFlagFromUISettings(this.channelType); - }, - signatureToApply() { - return this.messageSignature; - }, - newMessagePayload() { - const payload = { - inboxId: this.targetInbox.id, - sourceId: this.targetInbox.sourceId, - contactId: this.contact.id, - message: { content: this.message }, - mailSubject: this.subject, - assigneeId: this.currentUser.id, - }; - - if (this.attachedFiles && this.attachedFiles.length) { - payload.files = []; - this.setAttachmentPayload(payload); - } - - if (this.ccEmails) { - payload.message.cc_emails = this.ccEmails; - } - - if (this.bccEmails) { - payload.message.bcc_emails = this.bccEmails; - } - return payload; - }, - selectedInbox: { - get() { - const inboxList = this.contact.contact_inboxes || []; - const selectedContactInbox = inboxList.find( - inbox => inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id - ); - - if (!selectedContactInbox) { - return { inbox: {} }; - } - - // Find the matching inbox from the inboxesList - const matchingInbox = - this.inboxesList.find( - item => item.id === selectedContactInbox.inbox?.id - ) || {}; - - // The entire inbox payload is not available in this object, so we need to patch it from the store - return { - ...selectedContactInbox, - inbox: { - ...matchingInbox, - ...selectedContactInbox.inbox, - sourceId: selectedContactInbox.source_id || matchingInbox.sourceId, - }, - }; - }, - set(value) { - this.targetInbox = value.inbox; - }, - }, - showNoInboxAlert() { - if (!this.contact.contact_inboxes) { - return false; - } - return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes; - }, - isSignatureEnabledForInbox() { - return this.isAnEmailInbox && this.sendWithSignature; - }, - signatureToggleTooltip() { - return this.sendWithSignature - ? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP') - : this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP'); - }, - - inboxes() { - const inboxList = this.contact.contact_inboxes || []; - if (!inboxList.length) return []; - - return inboxList.map(inbox => { - const matchingInbox = - this.inboxesList.find(item => item.id === inbox.inbox?.id) || {}; - - // Create merged object with a clear property order - return { - ...matchingInbox, - ...inbox.inbox, - sourceId: inbox.source_id, - }; - }); - }, - isAnEmailInbox() { - return ( - this.selectedInbox && - this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL - ); - }, - isAnWebWidgetInbox() { - return ( - this.selectedInbox && - this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB - ); - }, - isEmailOrWebWidgetInbox() { - return this.isAnEmailInbox || this.isAnWebWidgetInbox; - }, - hasWhatsappTemplates() { - return !!this.selectedInbox.inbox?.message_templates; - }, - hasAttachments() { - return this.attachedFiles.length; - }, - inbox() { - return this.targetInbox; - }, - allowedFileTypes() { - return ALLOWED_FILE_TYPES; - }, - }, - watch: { - message(value) { - this.hasSlashCommand = value[0] === '/' && !this.isEmailOrWebWidgetInbox; - const hasNextWord = value.includes(' '); - const isShortCodeActive = this.hasSlashCommand && !hasNextWord; - if (isShortCodeActive) { - this.cannedResponseSearchKey = value.substring(1); - this.showCannedResponseMenu = true; - } else { - this.cannedResponseSearchKey = ''; - this.showCannedResponseMenu = false; - } - }, - targetInbox() { - this.setSignature(); - }, - }, - mounted() { - this.setSignature(); - }, - methods: { - setSignature() { - if (this.messageSignature) { - if (this.isSignatureEnabledForInbox) { - this.message = appendSignature(this.message, this.signatureToApply); - } else { - this.message = removeSignature(this.message, this.signatureToApply); - } - } - }, - setAttachmentPayload(payload) { - this.attachedFiles.forEach(attachment => { - if (this.globalConfig.directUploadsEnabled) { - payload.files.push(attachment.blobSignedId); - } else { - payload.files.push(attachment.resource.file); - } - }); - }, - attachFile({ blob, file }) { - const reader = new FileReader(); - reader.readAsDataURL(file.file); - reader.onloadend = () => { - this.attachedFiles.push({ - currentChatId: this.contact.id, - resource: blob || file, - isPrivate: this.isPrivate, - thumb: reader.result, - blobSignedId: blob ? blob.signed_id : undefined, - }); - }; - }, - removeAttachment(attachments) { - this.attachedFiles = attachments; - }, - onCancel() { - this.$emit('cancel'); - }, - onSuccess() { - this.$emit('success'); - }, - replaceTextWithCannedResponse(message) { - this.message = message; - }, - toggleCannedMenu(value) { - this.showCannedMenu = value; - }, - prepareWhatsAppMessagePayload({ message: content, templateParams }) { - const payload = { - inboxId: this.targetInbox.id, - sourceId: this.targetInbox.sourceId, - contactId: this.contact.id, - message: { content, template_params: templateParams }, - assigneeId: this.currentUser.id, - }; - return payload; - }, - onFormSubmit() { - const isFromWhatsApp = false; - this.v$.$touch(); - if (this.v$.$invalid) { - return; - } - this.createConversation({ - payload: this.newMessagePayload, - isFromWhatsApp, - }); - }, - async createConversation({ payload, isFromWhatsApp }) { - try { - const data = await this.onSubmit(payload, isFromWhatsApp); - const action = { - type: 'link', - to: `/app/accounts/${data.account_id}/conversations/${data.id}`, - message: this.$t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'), - }; - this.onSuccess(); - useAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action); - } catch (error) { - if (error instanceof ExceptionWithMessage) { - useAlert(error.data); - } else { - useAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE')); - } - } - }, - - toggleWaTemplate(val) { - this.whatsappTemplateSelected = val; - }, - async onSendWhatsAppReply(messagePayload) { - const isFromWhatsApp = true; - const payload = this.prepareWhatsAppMessagePayload(messagePayload); - await this.createConversation({ payload, isFromWhatsApp }); - }, - inboxReadableIdentifier(inbox) { - return `${inbox.name} (${inbox.channel_type})`; - }, - computedInboxSource(inbox) { - if (!inbox.channel_type) return ''; - const classByType = getInboxSource( - inbox.channel_type, - inbox.phone_number, - inbox - ); - return classByType; - }, - toggleMessageSignature() { - this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); - this.setSignature(); - }, - }, -}; - - - - - - - - {{ $t('NEW_CONVERSATION.NO_INBOX') }} - - - - - - - {{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }} - - - - - - - {{ $t('NEW_CONVERSATION.FORM.INBOX.PLACEHOLDER') }} - - - - - - - - - - {{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }} - - - - - - {{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }} - - - - {{ contact.name }} - - - - - - - - - {{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }} - - - {{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }} - - - - - - - - - - - - {{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }} - - - - - - - - - - - - - {{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }} - - - - - - {{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }} - - - {{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }} - - - - - - {{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.SELECT') }} - - - {{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.HELP_TEXT') }} - - - - - - - - - - - - - {{ $t('NEW_CONVERSATION.FORM.CANCEL') }} - - - {{ $t('NEW_CONVERSATION.FORM.SUBMIT') }} - - - - - - - - {{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }} - - - - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue deleted file mode 100644 index 728d4954b..000000000 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/WhatsappTemplates.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/WhatsappTemplates.vue deleted file mode 100644 index fce9456d9..000000000 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/WhatsappTemplates.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - -
- {{ inboxIdentifier || computedInboxType }} -
- {{ $t('NEW_CONVERSATION.NO_INBOX') }} -