[FIX] mail: prevent composer to send message if pending uploads

task-2317862

closes odoo/odoo#56303

X-original-commit: 3b5cd048f1dfa18869e95b173b4232b4bb189025
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Xavier Dubuc
2020-08-21 12:57:44 +00:00
committed by Alexandre Kühn
parent f25b730b2f
commit 0263acee7f
6 changed files with 203 additions and 10 deletions
@@ -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();
}
@@ -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"
>
@@ -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)"
);
});
});
});
});
@@ -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,
}),
@@ -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.
@@ -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,