From 2c17c95eabff49892321dde0caca0d7edf07ecc8 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 11 Sep 2024 09:44:13 +0530 Subject: [PATCH] feat: Add the ability to paste images to editor (#10072) --- .../widgets/WootWriter/FullEditor.vue | 22 +++- .../helper/specs/uploadHelper.spec.js | 103 ++++++++++++------ .../dashboard/helper/uploadHelper.js | 35 ++++-- .../dashboard/i18n/locale/en/helpCenter.json | 1 + .../modules/helpCenterArticles/actions.js | 7 +- .../helpCenterArticles/specs/action.spec.js | 29 ++++- package.json | 2 +- yarn.lock | 8 +- 8 files changed, 160 insertions(+), 47 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 754182207..634d2a392 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -8,6 +8,7 @@ import { EditorState, Selection, } from '@chatwoot/prosemirror-schema'; +import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; @@ -55,7 +56,7 @@ export default { return { editorView: null, state: undefined, - plugins: [], + plugins: [imagePastePlugin(this.handleImageUpload)], }; }, computed: { @@ -76,6 +77,7 @@ export default { this.reloadState(); }, }, + created() { this.state = createState( this.value, @@ -95,6 +97,24 @@ export default { openFileBrowser() { this.$refs.imageUploadInput.click(); }, + async handleImageUpload(url) { + try { + const fileUrl = await this.$store.dispatch( + 'articles/uploadExternalImage', + { + portalSlug: this.$route.params.portalSlug, + url, + } + ); + + return fileUrl; + } catch (error) { + useAlert( + this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR') + ); + return ''; + } + }, onFileChange() { const file = this.$refs.imageUploadInput.files[0]; diff --git a/app/javascript/dashboard/helper/specs/uploadHelper.spec.js b/app/javascript/dashboard/helper/specs/uploadHelper.spec.js index 470364ed8..3217388f5 100644 --- a/app/javascript/dashboard/helper/specs/uploadHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/uploadHelper.spec.js @@ -1,52 +1,93 @@ -import { uploadFile } from '../uploadHelper'; import axios from 'axios'; +import { uploadExternalImage, uploadFile } from '../uploadHelper'; global.axios = axios; vi.mock('axios'); -describe('#Upload Helpers', () => { +describe('Upload Helpers', () => { afterEach(() => { - // Cleaning up the mock after each test axios.post.mockReset(); }); - it('should send a POST request with correct data', async () => { - const mockFile = new File(['dummy content'], 'example.png', { - type: 'image/png', + describe('uploadFile', () => { + it('should send a POST request with correct data', async () => { + const mockFile = new File(['dummy content'], 'example.png', { + type: 'image/png', + }); + const mockResponse = { + data: { + file_url: 'https://example.com/fileUrl', + blob_key: 'blobKey123', + blob_id: 'blobId456', + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + + const result = await uploadFile(mockFile, '1602'); + + expect(axios.post).toHaveBeenCalledWith( + '/api/v1/accounts/1602/upload', + expect.any(FormData), + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + + expect(result).toEqual({ + fileUrl: 'https://example.com/fileUrl', + blobKey: 'blobKey123', + blobId: 'blobId456', + }); }); - const mockResponse = { - data: { - file_url: 'https://example.com/fileUrl', - blob_key: 'blobKey123', - blob_id: 'blobId456', - }, - }; - axios.post.mockResolvedValueOnce(mockResponse); + it('should handle errors', async () => { + const mockFile = new File(['dummy content'], 'example.png', { + type: 'image/png', + }); + const mockError = new Error('Failed to upload'); - const result = await uploadFile(mockFile, '1602'); + axios.post.mockRejectedValueOnce(mockError); - expect(axios.post).toHaveBeenCalledWith( - '/api/v1/accounts/1602/upload', - expect.any(FormData), - { headers: { 'Content-Type': 'multipart/form-data' } } - ); - - expect(result).toEqual({ - fileUrl: 'https://example.com/fileUrl', - blobKey: 'blobKey123', - blobId: 'blobId456', + await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload'); }); }); - it('should handle errors', async () => { - const mockFile = new File(['dummy content'], 'example.png', { - type: 'image/png', + describe('uploadExternalImage', () => { + it('should send a POST request with correct data', async () => { + const mockUrl = 'https://example.com/image.jpg'; + const mockResponse = { + data: { + file_url: 'https://example.com/fileUrl', + blob_key: 'blobKey123', + blob_id: 'blobId456', + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + + const result = await uploadExternalImage(mockUrl, '1602'); + + expect(axios.post).toHaveBeenCalledWith( + '/api/v1/accounts/1602/upload', + { external_url: mockUrl }, + { headers: { 'Content-Type': 'application/json' } } + ); + + expect(result).toEqual({ + fileUrl: 'https://example.com/fileUrl', + blobKey: 'blobKey123', + blobId: 'blobId456', + }); }); - const mockError = new Error('Failed to upload'); - axios.post.mockRejectedValueOnce(mockError); + it('should handle errors', async () => { + const mockUrl = 'https://example.com/image.jpg'; + const mockError = new Error('Failed to upload'); - await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload'); + axios.post.mockRejectedValueOnce(mockError); + + await expect(uploadExternalImage(mockUrl)).rejects.toThrow( + 'Failed to upload' + ); + }); }); }); diff --git a/app/javascript/dashboard/helper/uploadHelper.js b/app/javascript/dashboard/helper/uploadHelper.js index 2a1b9d9cd..6dc1069ce 100644 --- a/app/javascript/dashboard/helper/uploadHelper.js +++ b/app/javascript/dashboard/helper/uploadHelper.js @@ -19,26 +19,47 @@ const HEADERS = { * The function uses FormData to wrap the file and axios to send the request. * * @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element). + * @param {string} accountId - The account ID. * @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error. */ export async function uploadFile(file, accountId) { - // Create a new FormData instance. - let formData = new FormData(); - if (!accountId) { accountId = window.location.pathname.split('/')[3]; } // Append the file to the FormData instance under the key 'attachment'. + let formData = new FormData(); formData.append('attachment', file); - // Use axios to send a POST request to the upload endpoint. const { data } = await axios.post( `/api/${API_VERSION}/accounts/${accountId}/upload`, formData, - { - headers: HEADERS, - } + { headers: HEADERS } + ); + + return { + fileUrl: data.file_url, + blobKey: data.blob_key, + blobId: data.blob_id, + }; +} + +/** + * Uploads an image from an external URL. + * + * @param {string} url - The external URL of the image. + * @param {string} accountId - The account ID. + * @returns {Promise} A promise that resolves with the server's response. + */ +export async function uploadExternalImage(url, accountId) { + if (!accountId) { + accountId = window.location.pathname.split('/')[3]; + } + + const { data } = await axios.post( + `/api/${API_VERSION}/accounts/${accountId}/upload`, + { external_url: url }, + { headers: { 'Content-Type': 'application/json' } } ); return { diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 5d77c21aa..30f3440e5 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -41,6 +41,7 @@ "UPLOADING": "Uploading...", "SUCCESS": "Image uploaded successfully", "ERROR": "Error while uploading image", + "UN_AUTHORIZED_ERROR": "You are not authorized to upload images", "ERROR_FILE_SIZE": "Image size should be less than {size}MB", "ERROR_FILE_FORMAT": "Image format should be jpg, jpeg or png", "ERROR_FILE_DIMENSIONS": "Image dimensions should be less than 2000 x 2000" diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js index aa9e5242b..de6147fa8 100644 --- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js +++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js @@ -1,5 +1,5 @@ import articlesAPI from 'dashboard/api/helpCenter/articles'; -import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper'; import { throwErrorMessage } from 'dashboard/store/utils/api'; import types from '../../mutation-types'; @@ -132,6 +132,11 @@ export const actions = { return fileUrl; }, + uploadExternalImage: async (_, { url }) => { + const { fileUrl } = await uploadExternalImage(url); + return fileUrl; + }, + reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => { try { await articlesAPI.reorderArticles({ diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js index d68dc809a..ffdb98e15 100644 --- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js +++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; -import { actions } from '../actions'; +import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper'; import * as types from '../../../mutation-types'; -import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { actions } from '../actions'; vi.mock('dashboard/helper/uploadHelper'); @@ -211,4 +211,29 @@ describe('#actions', () => { ); }); }); + + describe('uploadExternalImage', () => { + it('should upload the image from external URL and return the fileUrl', async () => { + const mockUrl = 'https://example.com/image.jpg'; + const mockFileUrl = 'https://uploaded.example.com/image.jpg'; + uploadExternalImage.mockResolvedValueOnce({ fileUrl: mockFileUrl }); + + // When + const result = await actions.uploadExternalImage({}, { url: mockUrl }); + + // Then + expect(uploadExternalImage).toHaveBeenCalledWith(mockUrl); + expect(result).toBe(mockFileUrl); + }); + + it('should throw an error if the upload fails', async () => { + const mockUrl = 'https://example.com/image.jpg'; + const mockError = new Error('Upload failed'); + uploadExternalImage.mockRejectedValueOnce(mockError); + + await expect( + actions.uploadExternalImage({}, { url: mockUrl }) + ).rejects.toThrow('Upload failed'); + }); + }); }); diff --git a/package.json b/package.json index d528ce4c7..cb166001e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "dependencies": { "@braid/vue-formulate": "^2.5.2", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.0.16", + "@chatwoot/prosemirror-schema": "1.0.17", "@chatwoot/utils": "^0.0.25", "@hcaptcha/vue-hcaptcha": "^0.3.2", "@june-so/analytics-next": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index b76fa7494..81b3505bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,10 +2914,10 @@ hotkeys-js "3.8.7" lit "2.2.6" -"@chatwoot/prosemirror-schema@1.0.16": - version "1.0.16" - resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.16.tgz#63cfd4cff0f138466a95fedba5d237409a0b418e" - integrity sha512-qFPHPrfkG14vB0C/8+kF6huAvz4c2h6s07WKznZqUZ5CgJYd+fESQ49CScOYwOTO6luV4ghILv9kAVRjiaenOQ== +"@chatwoot/prosemirror-schema@1.0.17": + version "1.0.17" + resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.17.tgz#5c04a94c8200b303d943369b2534730d5ba15a64" + integrity sha512-3hwBSMdb5a+Ilct0fNSlAvQu/9+pnyI/FoGshVRC9Hn1tCdZdS7LUlxOuDweta3HneVclhE+SxJN5lM33v5WGw== dependencies: markdown-it-sup "^1.0.0" prosemirror-commands "^1.1.4"