From 759a66dd2130402024b8145e813db70eaa5d496b Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 10 Oct 2023 09:34:36 +0530 Subject: [PATCH] feat: Add the ability to send attachment in new conversation (#7913) --- .../scss/widgets/_conversation-view.scss | 4 + .../dashboard/assets/scss/widgets/_modal.scss | 4 - app/javascript/dashboard/components/Modal.vue | 2 +- .../components/widgets/AttachmentsPreview.vue | 8 +- .../components/widgets/WootWriter/Editor.vue | 2 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 22 +- .../widgets/conversation/ReplyBox.vue | 25 ++ .../WhatsappTemplates/TemplateParser.vue | 2 +- .../dashboard/i18n/locale/en/contact.json | 4 + .../conversation/contact/ContactInfo.vue | 2 + .../conversation/contact/ConversationForm.vue | 139 +++++++++- .../conversation/contact/NewConversation.vue | 10 +- .../store/modules/contactConversations.js | 80 +++++- .../contactConversations/actions.spec.js | 242 +++++++++++++++++- app/javascript/shared/constants/busEvents.js | 1 + 15 files changed, 487 insertions(+), 60 deletions(-) diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index f6fae6737..e538299aa 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -152,6 +152,10 @@ &.is-image { @apply rounded-lg; + + .message__mail-head { + @apply px-4 py-2; + } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index f20ba72b1..1f0641c6e 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -4,10 +4,6 @@ @apply flex items-center justify-center bg-modal dark:bg-modal z-[9990] h-full left-0 fixed top-0 w-full; } -.modal--close { - @apply absolute right-2 rtl:right-[unset] rtl:left-2 top-2; -} - .page-top-bar { @apply px-8 pt-9 pb-0; diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index 7276cb0e1..8c9a6f6e6 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -16,7 +16,7 @@ color-scheme="secondary" icon="dismiss" variant="clear" - class="modal--close" + class="absolute ltr:right-2 rtl:left-2 top-2 z-10" @click="close" /> diff --git a/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue b/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue index 586a9c1f2..0a1c80fe6 100644 --- a/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue +++ b/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue @@ -1,11 +1,9 @@ @@ -190,6 +248,11 @@ import { INBOX_TYPES } from 'shared/mixins/inboxMixin'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; import { getInboxSource } from 'dashboard/helper/inbox'; import { required, requiredIf } from 'vuelidate/lib/validators'; +import inboxMixin from 'shared/mixins/inboxMixin'; +import FileUpload from 'vue-upload-component'; +import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview'; +import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; +import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import { appendSignature, removeSignature, @@ -204,9 +267,11 @@ export default { CannedResponse, WhatsappTemplates, InboxDropdownItem, + FileUpload, + AttachmentPreview, MessageSignatureMissingAlert, }, - mixins: [alertMixin, uiSettingsMixin], + mixins: [alertMixin, uiSettingsMixin, inboxMixin, fileUploadMixin], props: { contact: { type: Object, @@ -228,6 +293,7 @@ export default { ccEmails: '', targetInbox: {}, whatsappTemplateSelected: false, + attachedFiles: [], }; }, validations: { @@ -246,6 +312,7 @@ export default { uiFlags: 'contacts/getUIFlags', conversationsUiFlags: 'contactConversations/getUIFlags', currentUser: 'getCurrentUser', + globalConfig: 'globalConfig/get', messageSignature: 'getMessageSignature', }), sendWithSignature() { @@ -255,7 +322,7 @@ export default { signatureToApply() { return this.messageSignature; }, - emailMessagePayload() { + newMessagePayload() { const payload = { inboxId: this.targetInbox.id, sourceId: this.targetInbox.sourceId, @@ -264,6 +331,12 @@ export default { 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; } @@ -327,6 +400,15 @@ export default { hasWhatsappTemplates() { return !!this.selectedInbox.inbox?.message_templates; }, + hasAttachments() { + return this.attachedFiles.length; + }, + inbox() { + return this.targetInbox; + }, + allowedFileTypes() { + return ALLOWED_FILE_TYPES; + }, }, watch: { message(value) { @@ -358,6 +440,33 @@ export default { } } }, + 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(itemIndex) { + this.attachedFiles = this.attachedFiles.filter( + (item, index) => itemIndex !== index + ); + }, onCancel() { this.$emit('cancel'); }, @@ -381,15 +490,19 @@ export default { return payload; }, onFormSubmit() { + const isFromWhatsApp = false; this.$v.$touch(); if (this.$v.$invalid) { return; } - this.createConversation(this.emailMessagePayload); + this.createConversation({ + payload: this.newMessagePayload, + isFromWhatsApp, + }); }, - async createConversation(payload) { + async createConversation({ payload, isFromWhatsApp }) { try { - const data = await this.onSubmit(payload); + const data = await this.onSubmit(payload, isFromWhatsApp); const action = { type: 'link', to: `/app/accounts/${data.account_id}/conversations/${data.id}`, @@ -413,8 +526,9 @@ export default { this.whatsappTemplateSelected = val; }, async onSendWhatsAppReply(messagePayload) { + const isFromWhatsApp = true; const payload = this.prepareWhatsAppMessagePayload(messagePayload); - await this.createConversation(payload); + await this.createConversation({ payload, isFromWhatsApp }); }, inboxReadableIdentifier(inbox) { return `${inbox.name} (${inbox.channel_type})`; @@ -453,6 +567,17 @@ export default { } } +.file-uploads { + @apply text-start; +} +.multiselect-wrap--small.has-multi-select-error { + ::v-deep { + .multiselect__tags { + @apply border-red-500; + } + } +} + ::v-deep { .mention--box { @apply left-0 m-auto right-0 top-auto h-fit; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue index 10fa74d3f..b7527c096 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/NewConversation.vue @@ -49,11 +49,11 @@ export default { onSuccess() { this.$emit('cancel'); }, - async onSubmit(contactItem) { - const data = await this.$store.dispatch( - 'contactConversations/create', - contactItem - ); + async onSubmit(params, isFromWhatsApp) { + const data = await this.$store.dispatch('contactConversations/create', { + params, + isFromWhatsApp, + }); return data; }, }, diff --git a/app/javascript/dashboard/store/modules/contactConversations.js b/app/javascript/dashboard/store/modules/contactConversations.js index 8ba70214b..266f20dcf 100644 --- a/app/javascript/dashboard/store/modules/contactConversations.js +++ b/app/javascript/dashboard/store/modules/contactConversations.js @@ -3,6 +3,64 @@ import * as types from '../mutation-types'; import ContactAPI from '../../api/contacts'; import ConversationApi from '../../api/conversations'; +export const createMessagePayload = (payload, message) => { + const { content, cc_emails, bcc_emails } = message; + payload.append('message[content]', content); + if (cc_emails) payload.append('message[cc_emails]', cc_emails); + if (bcc_emails) payload.append('message[bcc_emails]', bcc_emails); +}; + +export const createConversationPayload = ({ params, contactId, files }) => { + const { inboxId, message, sourceId, mailSubject, assigneeId } = params; + const payload = new FormData(); + + if (message) { + createMessagePayload(payload, message); + } + + if (files && files.length > 0) { + files.forEach(file => payload.append('message[attachments][]', file)); + } + + payload.append('inbox_id', inboxId); + payload.append('contact_id', contactId); + payload.append('source_id', sourceId); + payload.append('additional_attributes[mail_subject]', mailSubject); + payload.append('assignee_id', assigneeId); + + return payload; +}; + +export const createWhatsAppConversationPayload = ({ params }) => { + const { inboxId, message, contactId, sourceId, assigneeId } = params; + + const payload = { + inbox_id: inboxId, + contact_id: contactId, + source_id: sourceId, + message, + assignee_id: assigneeId, + }; + + return payload; +}; + +const setNewConversationPayload = ({ + isFromWhatsApp, + params, + contactId, + files, +}) => { + if (isFromWhatsApp) { + return createWhatsAppConversationPayload({ params }); + } + return createConversationPayload({ + params, + contactId, + files, + }); +}; + const state = { records: {}, uiFlags: { @@ -20,27 +78,25 @@ export const getters = { }; export const actions = { - create: async ({ commit }, params) => { + create: async ({ commit }, { params, isFromWhatsApp }) => { commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true, }); - const { inboxId, message, contactId, sourceId, mailSubject, assigneeId } = - params; + const { contactId, files } = params; try { - const { data } = await ConversationApi.create({ - inbox_id: inboxId, - contact_id: contactId, - source_id: sourceId, - additional_attributes: { - mail_subject: mailSubject, - }, - message, - assignee_id: assigneeId, + const payload = setNewConversationPayload({ + isFromWhatsApp, + params, + contactId, + files, }); + + const { data } = await ConversationApi.create(payload); commit(types.default.ADD_CONTACT_CONVERSATION, { id: contactId, data, }); + return data; } catch (error) { throw new Error(error); diff --git a/app/javascript/dashboard/store/modules/specs/contactConversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contactConversations/actions.spec.js index 0de32311b..9a36dd321 100644 --- a/app/javascript/dashboard/store/modules/specs/contactConversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contactConversations/actions.spec.js @@ -1,5 +1,10 @@ import axios from 'axios'; -import { actions } from '../../contactConversations'; +import { + actions, + createMessagePayload, + createConversationPayload, + createWhatsAppConversationPayload, +} from '../../contactConversations'; import * as types from '../../../mutation-types'; import conversationList from './fixtures'; @@ -44,16 +49,84 @@ describe('#actions', () => { await actions.create( { commit }, { - inboxId: 1, - message: { content: 'hi' }, - contactId: 4, - sourceId: 5, - mailSubject: 'Mail Subject', + params: { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + sourceId: 5, + mailSubject: 'Mail Subject', + assigneeId: 6, + files: [], + }, + isFromWhatsApp: false, + } + ); + expect(commit.mock.calls).toEqual([ + [types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }], + [ + types.default.ADD_CONTACT_CONVERSATION, + { id: 4, data: conversationList[0] }, + ], + [ + types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, + { isCreating: false }, + ], + ]); + }); + it('sends correct actions with files if API is success', async () => { + axios.post.mockResolvedValue({ data: conversationList[0] }); + await actions.create( + { commit }, + { + params: { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + sourceId: 5, + mailSubject: 'Mail Subject', + assigneeId: 6, + files: ['file1.pdf', 'file2.jpg'], + }, + isFromWhatsApp: false, + } + ); + expect(commit.mock.calls).toEqual([ + [types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }], + [ + types.default.ADD_CONTACT_CONVERSATION, + { id: 4, data: conversationList[0] }, + ], + [ + types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, + { isCreating: false }, + ], + ]); + }); + it('sends correct actions actions if API is success for whatsapp conversation', async () => { + axios.post.mockResolvedValue({ data: conversationList[0] }); + await actions.create( + { commit }, + { + params: { + inboxId: 1, + message: { + content: 'hi', + template_params: { + name: 'hello_world', + category: 'MARKETING', + language: 'en_US', + processed_params: {}, + }, + }, + contactId: 4, + sourceId: 5, + assigneeId: 6, + }, + isFromWhatsApp: true, } ); expect(commit.mock.calls).toEqual([ [types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }], - [ types.default.ADD_CONTACT_CONVERSATION, { id: 4, data: conversationList[0] }, @@ -66,16 +139,46 @@ describe('#actions', () => { }); it('sends correct actions if API is error', async () => { axios.post.mockRejectedValue({ message: 'Incorrect header' }); - await expect( actions.create( { commit }, { - inboxId: 1, - message: { content: 'hi' }, - contactId: 4, - sourceId: 5, - mailSubject: 'Mail Subject', + params: { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + assigneeId: 6, + sourceId: 5, + mailSubject: 'Mail Subject', + }, + isFromWhatsApp: false, + } + ) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }], + [ + types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, + { isCreating: false }, + ], + ]); + }); + it('sends correct actions with files if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.create( + { commit }, + { + params: { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + sourceId: 5, + mailSubject: 'Mail Subject', + assigneeId: 6, + files: ['file1.pdf', 'file2.jpg'], + }, + isFromWhatsApp: false, } ) ).rejects.toThrow(Error); @@ -89,3 +192,116 @@ describe('#actions', () => { }); }); }); + +describe('createMessagePayload', () => { + it('creates message payload with cc and bcc emails', () => { + const payload = new FormData(); + const message = { + content: 'Test message content', + cc_emails: 'cc@example.com', + bcc_emails: 'bcc@example.com', + }; + + createMessagePayload(payload, message); + + expect(payload.get('message[content]')).toBe(message.content); + expect(payload.get('message[cc_emails]')).toBe(message.cc_emails); + expect(payload.get('message[bcc_emails]')).toBe(message.bcc_emails); + }); + + it('creates message payload without cc and bcc emails', () => { + const payload = new FormData(); + const message = { + content: 'Test message content', + }; + + createMessagePayload(payload, message); + + expect(payload.get('message[content]')).toBe(message.content); + expect(payload.get('message[cc_emails]')).toBeNull(); + expect(payload.get('message[bcc_emails]')).toBeNull(); + }); +}); + +describe('createConversationPayload', () => { + it('creates conversation payload with message and attachments', () => { + const options = { + params: { + inboxId: '1', + message: { + content: 'Test message content', + }, + sourceId: '12', + mailSubject: 'Test Subject', + assigneeId: '123', + }, + contactId: '23', + files: ['file1.pdf', 'file2.jpg'], + }; + + const payload = createConversationPayload(options); + + expect(payload.get('message[content]')).toBe( + options.params.message.content + ); + expect(payload.get('inbox_id')).toBe(options.params.inboxId); + expect(payload.get('contact_id')).toBe(options.contactId); + expect(payload.get('source_id')).toBe(options.params.sourceId); + expect(payload.get('additional_attributes[mail_subject]')).toBe( + options.params.mailSubject + ); + expect(payload.get('assignee_id')).toBe(options.params.assigneeId); + expect(payload.getAll('message[attachments][]')).toEqual(options.files); + }); + + it('creates conversation payload with message and without attachments', () => { + const options = { + params: { + inboxId: '1', + message: { + content: 'Test message content', + }, + sourceId: '12', + mailSubject: 'Test Subject', + assigneeId: '123', + }, + contactId: '23', + }; + + const payload = createConversationPayload(options); + + expect(payload.get('message[content]')).toBe( + options.params.message.content + ); + expect(payload.get('inbox_id')).toBe(options.params.inboxId); + expect(payload.get('contact_id')).toBe(options.contactId); + expect(payload.get('source_id')).toBe(options.params.sourceId); + expect(payload.get('additional_attributes[mail_subject]')).toBe( + options.params.mailSubject + ); + expect(payload.get('assignee_id')).toBe(options.params.assigneeId); + expect(payload.getAll('message[attachments][]')).toEqual([]); + }); +}); + +describe('createWhatsAppConversationPayload', () => { + it('creates conversation payload with message', () => { + const options = { + params: { + inboxId: '1', + message: { + content: 'Test message content', + }, + sourceId: '12', + assigneeId: '123', + }, + }; + + const payload = createWhatsAppConversationPayload(options); + + expect(payload.message).toBe(options.params.message); + expect(payload.inbox_id).toBe(options.params.inboxId); + expect(payload.source_id).toBe(options.params.sourceId); + expect(payload.assignee_id).toBe(options.params.assigneeId); + }); +}); diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js index 6c9a21072..6edf96fcd 100644 --- a/app/javascript/shared/constants/busEvents.js +++ b/app/javascript/shared/constants/busEvents.js @@ -10,4 +10,5 @@ export const BUS_EVENTS = { ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL', WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT', SHOW_TOAST: 'newToastMessage', + NEW_CONVERSATION_MODAL: 'newConversationModal', };