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 b42ee5526..09d449271 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -12,7 +12,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 @@ @@ -169,6 +233,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'; export default { components: { @@ -178,8 +247,10 @@ export default { CannedResponse, WhatsappTemplates, InboxDropdownItem, + FileUpload, + AttachmentPreview, }, - mixins: [alertMixin], + mixins: [alertMixin, inboxMixin, fileUploadMixin], props: { contact: { type: Object, @@ -201,6 +272,7 @@ export default { ccEmails: '', targetInbox: {}, whatsappTemplateSelected: false, + attachedFiles: [], }; }, validations: { @@ -219,8 +291,9 @@ export default { uiFlags: 'contacts/getUIFlags', conversationsUiFlags: 'contactConversations/getUIFlags', currentUser: 'getCurrentUser', + globalConfig: 'globalConfig/get', }), - emailMessagePayload() { + newMessagePayload() { const payload = { inboxId: this.targetInbox.id, sourceId: this.targetInbox.sourceId, @@ -229,6 +302,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; } @@ -284,6 +363,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) { @@ -300,6 +388,33 @@ export default { }, }, methods: { + 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'); }, @@ -320,6 +435,10 @@ export default { message: { content, template_params: templateParams }, assigneeId: this.currentUser.id, }; + if (this.attachedFiles && this.attachedFiles.length) { + payload.files = []; + this.setAttachmentPayload(payload); + } return payload; }, onFormSubmit() { @@ -327,7 +446,7 @@ export default { if (this.$v.$invalid) { return; } - this.createConversation(this.emailMessagePayload); + this.createConversation(this.newMessagePayload); }, async createConversation(payload) { try { @@ -389,6 +508,18 @@ 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/store/modules/contactConversations.js b/app/javascript/dashboard/store/modules/contactConversations.js index a696a3e75..94158735e 100644 --- a/app/javascript/dashboard/store/modules/contactConversations.js +++ b/app/javascript/dashboard/store/modules/contactConversations.js @@ -3,6 +3,34 @@ 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; +}; + const state = { records: {}, uiFlags: { @@ -24,29 +52,17 @@ export const actions = { 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 = createConversationPayload({ 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..8524f7bce 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,9 @@ import axios from 'axios'; -import { actions } from '../../contactConversations'; +import { + actions, + createMessagePayload, + createConversationPayload, +} from '../../contactConversations'; import * as types from '../../../mutation-types'; import conversationList from './fixtures'; @@ -49,6 +53,35 @@ describe('#actions', () => { contactId: 4, sourceId: 5, mailSubject: 'Mail Subject', + assigneeId: 6, + files: [], + } + ); + 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 }, + { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + sourceId: 5, + assigneeId: 6, + mailSubject: 'Mail Subject', + files: [new File([], 'file1')], } ); expect(commit.mock.calls).toEqual([ @@ -74,6 +107,7 @@ describe('#actions', () => { inboxId: 1, message: { content: 'hi' }, contactId: 4, + assigneeId: 6, sourceId: 5, mailSubject: 'Mail Subject', } @@ -87,5 +121,121 @@ describe('#actions', () => { ], ]); }); + it('sends correct actions with files if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + + await expect( + actions.create( + { commit }, + { + inboxId: 1, + message: { content: 'hi' }, + contactId: 4, + assigneeId: 6, + sourceId: 5, + mailSubject: 'Mail Subject', + files: [new File([], 'file1')], + } + ) + ).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 }, + ], + ]); + }); + }); +}); + +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([]); }); }); 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', };