diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 5c3968a0fae..afb7d811eae 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -175,9 +175,21 @@ class Composer extends Component { /** * Post a message in the composer on related thread. * + * Posting of the message could be aborted if it cannot be posted like if there are attachments + * currently uploading or if there is no text content and no attachments. + * * @private */ async _postMessage() { + if (!this.composer.canPostMessage) { + if (this.composer.hasUploadingAttachment) { + this.env.services['notification'].notify({ + message: this.env._t("Please wait while the file is uploading."), + type: 'warning', + }); + } + return; + } // TODO: take suggested recipients into account (task-2283356) await this.composer.postMessage(); // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done @@ -246,9 +258,6 @@ class Composer extends Component { * @private */ _onClickSend() { - if (!this.composer.canPostMessage) { - return; - } this._postMessage(); } @@ -335,9 +344,6 @@ class Composer extends Component { * @private */ _onTextInputKeydownEnter() { - if (!this.composer.canPostMessage) { - return; - } this._postMessage(); } diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml index 5a7a2e72345..30053cdd0f8 100644 --- a/addons/mail/static/src/components/composer/composer.xml +++ b/addons/mail/static/src/components/composer/composer.xml @@ -155,7 +155,7 @@ 'o-composer-is-compact': props.isCompact, 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, }" - t-att-disabled="(!composer.textInputContent and composer.attachments.length === 0) ? 'disabled' : ''" + t-att-disabled="!composer.canPostMessage ? 'disabled' : ''" type="button" t-on-click="_onClickSend" > diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js index 527003b6dec..8e07affcccb 100644 --- a/addons/mail/static/src/components/composer/composer_tests.js +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -21,6 +21,7 @@ const { createFile, inputFiles, }, + makeTestPromise, } = require('web.test_utils'); QUnit.module('mail', {}, function () { @@ -886,6 +887,148 @@ QUnit.test('current partner notify is typing again to other members every 50s of ); }); +QUnit.test('composer: send button is disabled if attachment upload is not finished', async function (assert) { + assert.expect(8); + + const attachmentUploadedPromise = makeTestPromise(); + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + await attachmentUploadedPromise; + } + return res; + } + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have an attachment after a file has been input" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + assert.ok( + !!document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be disabled as attachment is not yet uploaded" + ); + + // simulates attachment finishes uploading + await afterNextRender(() => attachmentUploadedPromise.resolve()); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsNone( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed should be uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should still be present" + ); + assert.ok( + !document.querySelector('.o_Composer_buttonSend').attributes.disabled, + "composer send button should be enabled as attachment is now uploaded" + ); +}); + +QUnit.test('warning when attempting to post message when attachments are still uploading', async function (assert) { + assert.expect(7); + + await this.start({ + async mockFetch(resource, init) { + const res = this._super(...arguments); + if (resource === '/web/binary/upload_attachment') { + // simulates attachment is never finished uploading + await new Promise(() => {}); + } + return res; + }, + services: { + notification: { + notify(params) { + assert.strictEqual( + params.message, + "Please wait while the file is uploading.", + "notification content should be about the uploading file" + ); + assert.strictEqual( + params.type, + 'warning', + "notification should be a warning" + ); + assert.step('notification'); + } + } + }, + }); + const thread = this.env.models['mail.thread'].create({ + composer: [['create', { isLog: false }]], + id: 20, + model: 'res.partner', + }); + await this.createComposerComponent(thread.composer); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.containsOnce( + document.body, + '.o_Attachment', + "should have only one attachment" + ); + assert.containsOnce( + document.body, + '.o_Attachment.o-temporary', + "attachment displayed is being uploaded" + ); + assert.containsOnce( + document.body, + '.o_Composer_buttonSend', + "composer send button should be displayed" + ); + + // Try to send message + document + .querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })); + assert.verifySteps( + ['notification'], + "should have triggered a notification for inability to post message at the moment (some attachments are still being uploaded)" + ); +}); + }); }); }); diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js index f2d376fd14b..421ca3061d2 100644 --- a/addons/mail/static/src/models/composer/composer.js +++ b/addons/mail/static/src/models/composer/composer.js @@ -294,7 +294,10 @@ function factory(dependencies) { * @returns {boolean} */ _computeCanPostMessage() { - return this.textInputContent || this.attachments.length !== 0; + if (!this.textInputContent && this.attachments.length === 0) { + return false; + } + return !this.hasUploadingAttachment; } /** @@ -319,6 +322,14 @@ function factory(dependencies) { return this.allSuggestedPartners.length > 0; } + /** + * @private + * @returns {boolean} + */ + _computeHasUploadingAttachment() { + return this.attachments.some(attachment => attachment.isTemporary); + } + /** * Detects if mentioned partners are still in the composer text input content * and removes them if not. @@ -540,10 +551,21 @@ function factory(dependencies) { attachments: many2many('mail.attachment', { inverse: 'composers', }), + /** + * This field watches the uploading (= temporary) status of attachments + * linked to this composer. + * + * Useful to determine whether there are some attachments that are being + * uploaded. + */ + attachmentsAreTemporary: attr({ + related: 'attachments.isTemporary', + }), canPostMessage: attr({ compute: '_computeCanPostMessage', dependencies: [ 'attachments', + 'hasUploadingAttachment', 'textInputContent', ], default: false, @@ -563,6 +585,17 @@ function factory(dependencies) { 'mainSuggestedPartners', ], }), + /** + * This field determines whether some attachments linked to this + * composer are being uploaded. + */ + hasUploadingAttachment: attr({ + compute: '_computeHasUploadingAttachment', + dependencies: [ + 'attachments', + 'attachmentsAreTemporary', + ], + }), hasFocus: attr({ default: false, }), diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js index d42c1318dfa..dfad584dfeb 100644 --- a/addons/mail/static/src/utils/test_utils.js +++ b/addons/mail/static/src/utils/test_utils.js @@ -368,6 +368,7 @@ async function createRootComponent(self, Component, { props = {}, target }) { * is set: provide data that is passed to discuss widget (= client action) as * 2nd positional argument. * @param {Object} [param0.env={}] + * @param {function} [param0.mockFetch] * @param {function} [param0.mockRPC] * @param {boolean} [param0.hasActionManager=false] if set, use * createActionManager. diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js index 06f9ce33d8a..962b9610f4b 100644 --- a/addons/web/static/tests/helpers/test_utils_mock.js +++ b/addons/web/static/tests/helpers/test_utils_mock.js @@ -333,6 +333,7 @@ function removeSrcAttribute(el, rpc) { * @param {string} [params.currentDate] * @param {Object} [params.data] * @param {boolean} [params.debug] + * @param {function} [params.mockFetch] * @param {function} [params.mockRPC] * @param {number} [params.fieldDebounce=0] the value of the DEBOUNCE attribute * of fields @@ -351,8 +352,11 @@ async function addMockEnvironmentOwl(Component, params, mockServer) { // instantiate a mockServer if not provided if (!mockServer) { let Server = MockServer; + if (params.mockFetch) { + Server = MockServer.extend({ _performFetch: params.mockFetch }); + } if (params.mockRPC) { - Server = MockServer.extend({ _performRpc: params.mockRPC }); + Server = Server.extend({ _performRpc: params.mockRPC }); } mockServer = new Server(params.data, { actions: params.actions, @@ -466,6 +470,9 @@ async function addMockEnvironmentOwl(Component, params, mockServer) { * @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo * events. * @param {Object} [params.bus] the instance of Bus that will be used (in the env) + * @param {function} [params.mockFetch] a function that will be used to override + * the _performFetch method from the mock server. It is really useful to add + * some custom fetch mocks, or to check some assertions. * @param {function} [params.mockRPC] a function that will be used to override * the _performRpc method from the mock server. It is really useful to add * some custom rpc mocks, or to check some assertions. @@ -506,8 +513,11 @@ async function addMockEnvironment(widget, params) { // instantiate mock server var Server = MockServer; + if (params.mockFetch) { + Server = MockServer.extend({ _performFetch: params.mockFetch }); + } if (params.mockRPC) { - Server = MockServer.extend({ _performRpc: params.mockRPC }); + Server = Server.extend({ _performRpc: params.mockRPC }); } var mockServer = new Server(params.data, { actions: params.actions,