[FIX] mail: allow composer text input to send message with ctrl+enter

Also allow meta+enter

task-2296863

closes odoo/odoo#57050

X-original-commit: 6bad3666be3d3631eaadae409d0cabcdd02661a8
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Xavier Dubuc
2020-09-03 15:01:39 +00:00
committed by Alexandre Kühn
parent b840bf3e9f
commit 09c77f6c89
10 changed files with 500 additions and 53 deletions
@@ -14,9 +14,9 @@
t-att-class="{ 'o-bordered': chatter.hasExternalBorder }"
composerLocalId="chatter.thread.composer.localId"
hasMentionSuggestionsBelowPosition="true"
hasTextInputSendOnEnterEnabled="false"
isCompact="false"
isExpandable="true"
textInputSendShortcuts="['ctrl-enter', 'meta-enter']"
t-on-o-message-posted="_onComposerMessagePosted"
t-ref="composer"
/>
@@ -342,6 +342,111 @@ QUnit.test('should not display user notification messages in chatter', async fun
);
});
QUnit.test('post message with "CTRL-Enter" keyboard shortcut', async function (assert) {
assert.expect(2);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
threadModel: 'res.partner',
});
await this.createChatterComponent({ chatter });
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in chatter"
);
await afterNextRender(() =>
document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have single message in chatter after posting message from pressing 'CTRL-Enter' in text input of composer"
);
});
QUnit.test('post message with "META-Enter" keyboard shortcut', async function (assert) {
assert.expect(2);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
threadModel: 'res.partner',
});
await this.createChatterComponent({ chatter });
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in chatter"
);
await afterNextRender(() =>
document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer"
);
});
QUnit.test('do not post message with "Enter" keyboard shortcut', async function (assert) {
// Note that test doesn't assert Enter makes a newline, because this
// default browser cannot be simulated with just dispatching
// programmatically crafted events...
assert.expect(2);
this.data['res.partner'].records.push({ id: 100 });
await this.start();
const chatter = this.env.models['mail.chatter'].create({
threadId: 100,
threadModel: 'res.partner',
});
await this.createChatterComponent({ chatter });
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in chatter"
);
await afterNextRender(() =>
document.querySelector('.o_ChatterTopbar_buttonSendMessage').click()
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsNone(
document.body,
'.o_Message',
"should still not have any message in mailing channel after pressing 'Enter' in text input of composer"
);
});
});
});
});
@@ -261,6 +261,13 @@ class Composer extends Component {
this._postMessage();
}
/**
* @private
*/
_onComposerTextInputSendShortcut() {
this._postMessage();
}
/**
* Called when some files have been dropped in the dropzone.
*
@@ -340,13 +347,6 @@ class Composer extends Component {
await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files);
}
/**
* @private
*/
_onTextInputKeydownEnter() {
this._postMessage();
}
}
Object.assign(Composer, {
@@ -357,7 +357,6 @@ Object.assign(Composer, {
hasDiscardButton: false,
hasFollowers: false,
hasSendButton: true,
hasTextInputSendOnEnterEnabled: true,
hasThreadName: false,
hasThreadTyping: false,
isCompact: true,
@@ -381,7 +380,6 @@ Object.assign(Composer, {
optional: true,
},
hasSendButton: Boolean,
hasTextInputSendOnEnterEnabled: Boolean,
hasThreadName: Boolean,
hasThreadTyping: Boolean,
showAttachmentsExtensions: {
@@ -403,6 +401,15 @@ Object.assign(Composer, {
},
isCompact: Boolean,
isExpandable: Boolean,
/**
* If set, keyboard shortcuts from text input to send message.
* If not set, will use default values from `ComposerTextInput`.
*/
textInputSendShortcuts: {
type: Array,
element: String,
optional: true,
},
},
template: 'mail.Composer',
});
@@ -75,9 +75,9 @@
}"
composerLocalId="composer.localId"
hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition"
hasSendOnEnterEnabled="props.hasTextInputSendOnEnterEnabled"
isCompact="props.isCompact"
t-on-o-keydown-enter="_onTextInputKeydownEnter"
sendShortcuts="props.textInputSendShortcuts"
t-on-o-composer-text-input-send-shortcut="_onComposerTextInputSendShortcut"
t-on-paste="_onPasteTextInput"
t-ref="textInput"
/>
@@ -590,6 +590,116 @@ QUnit.test('composer: paste attachments', async function (assert) {
);
});
QUnit.test('send message when enter is pressed while holding ctrl key (this shortcut is available)', async function (assert) {
// Note that test doesn't assert ENTER makes no newline, because this
// default browser cannot be simulated with just dispatching
// programmatically crafted events...
assert.expect(5);
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
assert.step('message_post');
}
return this._super(...arguments);
},
});
const thread = this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
);
await this.createComposerComponent(thread.composer, {
textInputSendShortcuts: ['ctrl-enter'],
});
// Type message
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "test message");
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"test message",
"should have inserted text content in editable"
);
await afterNextRender(() => {
const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(enterEvent);
});
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"test message",
"should have inserted text content in editable as message has not been posted"
);
// Send message with ctrl+enter
await afterNextRender(() =>
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' }))
);
assert.verifySteps(['message_post']);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"should have no content in composer input as message has been posted"
);
});
QUnit.test('send message when enter is pressed while holding meta key (this shortcut is available)', async function (assert) {
// Note that test doesn't assert ENTER makes no newline, because this
// default browser cannot be simulated with just dispatching
// programmatically crafted events...
assert.expect(5);
this.data['mail.channel'].records.push({ id: 20 });
await this.start({
async mockRPC(route, args) {
if (args.method === 'message_post') {
assert.step('message_post');
}
return this._super(...arguments);
},
});
const thread = this.env.models['mail.thread'].find(thread =>
thread.id === 20 &&
thread.model === 'mail.channel'
);
await this.createComposerComponent(thread.composer, {
textInputSendShortcuts: ['meta-enter'],
});
// Type message
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "test message");
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"test message",
"should have inserted text content in editable"
);
await afterNextRender(() => {
const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(enterEvent);
});
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"test message",
"should have inserted text content in editable as message has not been posted"
);
// Send message with meta+enter
await afterNextRender(() =>
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', metaKey: true }))
);
assert.verifySteps(['message_post']);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"should have no content in composer input as message has been posted"
);
});
QUnit.test('composer text input cleared on message post', async function (assert) {
assert.expect(4);
@@ -610,8 +720,10 @@ QUnit.test('composer text input cleared on message post', async function (assert
);
await this.createComposerComponent(thread.composer);
// Type message
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "test message");
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "test message");
});
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"test message",
@@ -620,8 +732,7 @@ QUnit.test('composer text input cleared on message post', async function (assert
// Send message
await afterNextRender(() =>
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.strictEqual(
@@ -691,8 +802,7 @@ QUnit.test('composer inputs cleared on message post in composer of a mailing cha
// Send message
await afterNextRender(() =>
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.strictEqual(
@@ -956,7 +1066,7 @@ QUnit.test('composer: send button is disabled if attachment upload is not finish
);
});
QUnit.test('warning when attempting to post message when attachments are still uploading', async function (assert) {
QUnit.test('warning on send with shortcut when attempting to post message with still-uploading attachments', async function (assert) {
assert.expect(7);
await this.start({
@@ -991,7 +1101,9 @@ QUnit.test('warning when attempting to post message when attachments are still u
id: 20,
model: 'res.partner',
});
await this.createComposerComponent(thread.composer);
await this.createComposerComponent(thread.composer, {
textInputSendShortcuts: ['enter'],
});
const file = await createFile({
content: 'hello, world',
contentType: 'text/plain',
@@ -241,18 +241,41 @@ class ComposerTextInput extends Component {
_onKeydownTextareaEnter(ev) {
if (this.composer.hasSuggestedPartners) {
ev.preventDefault();
} else {
if (!this.props.hasSendOnEnterEnabled) {
return;
}
if (ev.shiftKey) {
return;
}
if (this.env.messaging.device.isMobile) {
return;
}
this.trigger('o-keydown-enter');
return;
}
if (
this.props.sendShortcuts.includes('ctrl-enter') &&
!ev.altKey &&
ev.ctrlKey &&
!ev.metaKey &&
!ev.shiftKey
) {
this.trigger('o-composer-text-input-send-shortcut');
ev.preventDefault();
return;
}
if (
this.props.sendShortcuts.includes('enter') &&
!ev.altKey &&
!ev.ctrlKey &&
!ev.metaKey &&
!ev.shiftKey &&
!this.env.messaging.device.isMobile
) {
this.trigger('o-composer-text-input-send-shortcut');
ev.preventDefault();
return;
}
if (
this.props.sendShortcuts.includes('meta-enter') &&
!ev.altKey &&
!ev.ctrlKey &&
ev.metaKey &&
!ev.shiftKey
) {
this.trigger('o-composer-text-input-send-shortcut');
ev.preventDefault();
return;
}
}
@@ -341,13 +364,27 @@ Object.assign(ComposerTextInput, {
components,
defaultProps: {
hasMentionSuggestionsBelowPosition: false,
hasSendOnEnterEnabled: true,
sendShortcuts: [],
},
props: {
composerLocalId: String,
hasMentionSuggestionsBelowPosition: Boolean,
hasSendOnEnterEnabled: Boolean,
isCompact: Boolean,
/**
* Keyboard shortcuts from text input to send message.
*/
sendShortcuts: {
type: Array,
element: String,
validate: prop => {
for (const shortcut of prop) {
if (!['ctrl-enter', 'enter', 'meta-enter'].includes(shortcut)) {
return false;
}
}
return true;
},
},
},
template: 'mail.ComposerTextInput',
});
@@ -94,6 +94,7 @@
hasCurrentPartnerAvatar="!env.messaging.device.isMobile"
hasDiscardButton="true"
hasThreadName="true"
textInputSendShortcuts="['ctrl-enter', 'meta-enter']"
t-on-o-message-posted="_onReplyingToMessageMessagePosted"
t-ref="composer"
/>
@@ -312,11 +312,12 @@ QUnit.test('"reply to" composer should log note if message replied to is a note'
"Send button text should be 'Log'"
);
await afterNextRender(() => {
document.execCommand('insertText', false, "Test");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
});
await afterNextRender(() =>
document.execCommand('insertText', false, "Test")
);
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
});
@@ -363,11 +364,12 @@ QUnit.test('"reply to" composer should send message if message replied to is not
"Send button text should be 'Send'"
);
await afterNextRender(() => {
document.execCommand('insertText', false, "Test");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
});
await afterNextRender(() =>
document.execCommand('insertText', false, "Test")
);
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
});
@@ -2846,8 +2846,10 @@ QUnit.test('post a simple message', async function (assert) {
);
// insert some HTML in editable
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"Test",
@@ -2855,8 +2857,7 @@ QUnit.test('post a simple message', async function (assert) {
);
await afterNextRender(() =>
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }))
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.strictEqual(
@@ -2887,6 +2888,187 @@ QUnit.test('post a simple message', async function (assert) {
);
});
QUnit.test('post message on non-mailing channel with "Enter" keyboard shortcut', async function (assert) {
assert.expect(2);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20, mass_mailing: false });
await this.start({
discuss: {
params: {
default_active_id: 'mail.channel_20',
},
},
});
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in channel"
);
// insert some HTML in editable
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have single message in channel after posting message from pressing 'Enter' in text input of composer"
);
});
QUnit.test('do not post message on non-mailing channel with "SHIFT-Enter" keyboard shortcut', async function (assert) {
// Note that test doesn't assert SHIFT-Enter makes a newline, because this
// default browser cannot be simulated with just dispatching
// programmatically crafted events...
assert.expect(2);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
await this.start({
discuss: {
params: {
default_active_id: 'mail.channel_20',
},
},
});
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in channel"
);
// insert some HTML in editable
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter", shiftKey: true });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsNone(
document.body,
'.o_Message',
"should still not have any message in channel after pressing 'Shift-Enter' in text input of composer"
);
});
QUnit.test('post message on mailing channel with "CTRL-Enter" keyboard shortcut', async function (assert) {
assert.expect(2);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
await this.start({
discuss: {
params: {
default_active_id: 'mail.channel_20',
},
},
});
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in channel"
);
// insert some HTML in editable
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { ctrlKey: true, key: "Enter" });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have single message in channel after posting message from pressing 'CTRL-Enter' in text input of composer"
);
});
QUnit.test('post message on mailing channel with "META-Enter" keyboard shortcut', async function (assert) {
assert.expect(2);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
await this.start({
discuss: {
params: {
default_active_id: 'mail.channel_20',
},
},
});
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in channel"
);
// insert some HTML in editable
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter", metaKey: true });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsOnce(
document.body,
'.o_Message',
"should now have single message in channel after posting message from pressing 'META-Enter' in text input of composer"
);
});
QUnit.test('do not post message on mailing channel with "Enter" keyboard shortcut', async function (assert) {
// Note that test doesn't assert Enter makes a newline, because this
// default browser cannot be simulated with just dispatching
// programmatically crafted events...
assert.expect(2);
// channel expected to be found in the sidebar
// with a random unique id that will be referenced in the test
this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
await this.start({
discuss: {
params: {
default_active_id: 'mail.channel_20',
},
},
});
assert.containsNone(
document.body,
'.o_Message',
"should not have any message initially in mailing channel"
);
// insert some HTML in editable
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "Test");
});
await afterNextRender(() => {
const kevt = new window.KeyboardEvent('keydown', { key: "Enter" });
document.querySelector('.o_ComposerTextInput_textarea').dispatchEvent(kevt);
});
assert.containsNone(
document.body,
'.o_Message',
"should still not have any message in mailing channel after pressing 'Enter' in text input of composer"
);
});
QUnit.test('rendering of inbox message', async function (assert) {
// AKU TODO: kinda message specific test
assert.expect(7);
@@ -3205,11 +3387,12 @@ QUnit.test('reply to message from inbox (message linked to document)', async fun
"composer text input should be auto-focus"
);
await afterNextRender(() => {
document.execCommand('insertText', false, "Test");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
});
await afterNextRender(() =>
document.execCommand('insertText', false, "Test")
);
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonSend').click()
);
assert.verifySteps(['message_post']);
assert.notOk(
document.querySelector('.o_Composer'),
@@ -30,11 +30,11 @@
composerLocalId="threadView.thread.composer.localId"
hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar"
hasSendButton="props.hasComposerSendButton"
hasTextInputSendOnEnterEnabled="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? false : undefined"
hasThreadTyping="props.hasComposerThreadTyping"
isCompact="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? false : undefined"
showAttachmentsExtensions="props.showComposerAttachmentsExtensions"
showAttachmentsFilenames="props.showComposerAttachmentsFilenames"
textInputSendShortcuts="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? ['ctrl-enter', 'meta-enter'] : ['enter']"
t-ref="composer"
/>
</t>