diff options
| author | Julien Giannone <jgi@odoo.com> | 2020-09-21 18:58:55 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-09-21 20:29:32 +0000 |
| commit | 8bb3b09a2abf27011e4920a9dc045fc27e1ec628 (patch) | |
| tree | de8b38bf7f933f28ae2f9d9f593d885bf8b0c676 /addons/mail/static/src/components | |
| parent | d40fd8e3e29d1954f60ebbd64a319ae564b7a639 (diff) | |
[IMP] mail: reintroduce channel mentions, canned responses, commands
task-2280308
closes odoo/odoo#58166
X-original-commit: b63d0d2ee5f3bff24a5e1e1cd962d59bebef082d
Related: odoo/enterprise#13392
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
Diffstat (limited to 'addons/mail/static/src/components')
22 files changed, 1447 insertions, 245 deletions
diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js index a5d0b2c9a20..4480be9db11 100644 --- a/addons/mail/static/src/components/chat_window/chat_window.js +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -313,7 +313,7 @@ class ChatWindow extends Component { } break; case 'Escape': - if (isEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions')) { + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { break; } if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js index e2d049d6f87..0401cef3809 100644 --- a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -653,16 +653,17 @@ QUnit.test('chat window: close on ESCAPE', async function (assert) { ); await afterNextRender(() => { - document.execCommand('insertText', false, "@test"); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keyup')); }); - assert.containsOnce( - document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', - "mention suggestion should be opened after typing @test" + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display mention suggestions on typing '@'" ); await afterNextRender(() => { @@ -671,7 +672,7 @@ QUnit.test('chat window: close on ESCAPE', async function (assert) { }); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestionList_list', "mention suggestion should be closed after pressing escape on mention suggestion" ); assert.containsOnce( diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js index e44ccd81115..348291c14cd 100644 --- a/addons/mail/static/src/components/chatter/chatter_tests.js +++ b/addons/mail/static/src/components/chatter/chatter_tests.js @@ -323,12 +323,12 @@ QUnit.test('should not display user notification messages in chatter', async fun assert.expect(1); this.data['res.partner'].records.push({ id: 100 }); - this.data['mail.message'].records = [{ + this.data['mail.message'].records.push({ id: 102, message_type: 'user_notification', model: 'res.partner', res_id: 100, - }]; + }); await this.start(); const chatter = this.env.models['mail.chatter'].create({ threadId: 100, diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index bc46cd72d1d..95a09f7820f 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -264,6 +264,13 @@ class Composer extends Component { /** * @private */ + _onComposerSuggestionClicked() { + this.focus(); + } + + /** + * @private + */ _onComposerTextInputSendShortcut() { this._postMessage(); } @@ -311,7 +318,7 @@ class Composer extends Component { */ _onKeydown(ev) { if (ev.key === 'Escape') { - if (isEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions')) { + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { return; } if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml index be947b72b3f..4aa1d9cfe99 100644 --- a/addons/mail/static/src/components/composer/composer.xml +++ b/addons/mail/static/src/components/composer/composer.xml @@ -80,6 +80,7 @@ hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition" isCompact="props.isCompact" sendShortcuts="props.textInputSendShortcuts" + t-on-o-composer-suggestion-clicked="_onComposerSuggestionClicked" t-on-o-composer-text-input-send-shortcut="_onComposerTextInputSendShortcut" t-on-paste="_onPasteTextInput" t-ref="textInput" diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js index 459e0d2c799..a3740f91f74 100644 --- a/addons/mail/static/src/components/composer/composer_tests.js +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -274,22 +274,602 @@ QUnit.test('add emoji replaces (keyboard) text selection', async function (asser await nextAnimationFrame(); }); -QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { +QUnit.test('display canned response suggestions on typing ":"', async function (assert) { + assert.expect(2); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "Canned responses suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display canned response suggestions on typing ':'" + ); +}); + +QUnit.test('use a canned response', async function (assert) { + assert.expect(4); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have canned response + additional whitespace afterwards" + ); +}); + +QUnit.test('use a canned response some text', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => + document.execCommand('insertText', false, ":") + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a canned response', async function (assert) { + assert.expect(5); + + this.data['mail.shortcode'].records.push({ + id: 11, + source: "hello", + substitution: "Hello! How are you?", + }); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, ":"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a canned response suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? ", + "text content of composer should have previous content + canned response substitution + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "Hello! How are you? 😊", + "text content of composer should have previous canned response substitution and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display channel mention suggestions on typing "#"', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display channel mention suggestions on typing '#'" + ); +}); + +QUnit.test('mention a channel', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a channel after some text', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "channel mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => + document.execCommand('insertText', false, "#") + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh #General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a channel mention', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ + id: 7, + name: "General", + public: "groups", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "#"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a channel mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General ", + "text content of composer should have previous content + mentioned channel + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "#General 😊", + "text content of composer should have previous channel mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display command suggestions on typing "/"', async function (assert) { assert.expect(2); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerSuggestionList_list'), + 'show', + "should display command suggestions on typing '/'" + ); +}); + +QUnit.test('use a command', async function (assert) { + assert.expect(4); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have used command + additional whitespace afterwards" + ); +}); + +QUnit.test('use a command after some text', async function (assert) { + assert.expect(5); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => + document.execCommand('insertText', false, "/") + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh /who ", + "text content of composer should have previous content + used command + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a command', async function (assert) { + assert.expect(5); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestion', + "command suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "/"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "should have a command suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_ComposerSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who ", + "text content of composer should have previous content + used command + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "/who 😊", + "text content of composer should have previous command and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { + assert.expect(3); + this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", id: 11, + email: "testpartner@odoo.com", name: "TestPartner", }); - this.data['res.users'].records.push({ partner_id: 11 }); + this.data['res.partner'].records.push({ + id: 12, + email: "testpartner2@odoo.com", + name: "TestPartner2", + }); + this.data['res.users'].records.push({ + partner_id: 11, + }); + await this.start(); const composer = this.env.models['mail.composer'].create(); await this.createComposerComponent(composer); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestionList_list', "mention suggestions list should not be present" ); await afterNextRender(() => { @@ -301,28 +881,31 @@ QUnit.test('display partner mention suggestions on typing "@"', async function ( .dispatchEvent(new window.KeyboardEvent('keyup')); }); assert.hasClass( - document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'), + document.querySelector('.o_ComposerSuggestionList_list'), 'show', "should display mention suggestions on typing '@'" ); + assert.containsOnce( + document.body, + '.dropdown-divider', + "should have a separator" + ); }); QUnit.test('mention a partner', async function (assert) { assert.expect(4); this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", - id: 11, + email: "testpartner@odoo.com", name: "TestPartner", }); - this.data['res.users'].records.push({ partner_id: 11 }); await this.start(); const composer = this.env.models['mail.composer'].create(); await this.createComposerComponent(composer); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestionList_list', "mention suggestions list should not be present" ); assert.strictEqual( @@ -332,9 +915,17 @@ QUnit.test('mention a partner', async function (assert) { ); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@Test"); - }); - await afterNextRender(() => { + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -342,11 +933,11 @@ QUnit.test('mention a partner', async function (assert) { }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.o_ComposerSuggestion', "should have a mention suggestion" ); await afterNextRender(() => - document.querySelector('.o_PartnerMentionSuggestion').click() + document.querySelector('.o_ComposerSuggestion').click() ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), @@ -359,18 +950,16 @@ QUnit.test('mention a partner after some text', async function (assert) { assert.expect(5); this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", - id: 11, + email: "testpartner@odoo.com", name: "TestPartner", }); - this.data['res.users'].records.push({ partner_id: 11 }); await this.start(); const composer = this.env.models['mail.composer'].create(); await this.createComposerComponent(composer); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestion', "mention suggestions list should not be present" ); assert.strictEqual( @@ -387,10 +976,19 @@ QUnit.test('mention a partner after some text', async function (assert) { "bluhbluh ", "text content of composer should have content" ); - await afterNextRender(() => - document.execCommand('insertText', false, "@Test") - ); await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -398,11 +996,11 @@ QUnit.test('mention a partner after some text', async function (assert) { }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.o_ComposerSuggestion', "should have a mention suggestion" ); await afterNextRender(() => - document.querySelector('.o_PartnerMentionSuggestion').click() + document.querySelector('.o_ComposerSuggestion').click() ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), @@ -415,18 +1013,16 @@ QUnit.test('add an emoji after a partner mention', async function (assert) { assert.expect(5); this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", - id: 11, + email: "testpartner@odoo.com", name: "TestPartner", }); - this.data['res.users'].records.push({ partner_id: 11 }); await this.start(); const composer = this.env.models['mail.composer'].create(); await this.createComposerComponent(composer); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestion', "mention suggestions list should not be present" ); assert.strictEqual( @@ -436,9 +1032,17 @@ QUnit.test('add an emoji after a partner mention', async function (assert) { ); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@Test"); - }); - await afterNextRender(() => { + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) @@ -446,11 +1050,11 @@ QUnit.test('add an emoji after a partner mention', async function (assert) { }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.o_ComposerSuggestion', "should have a mention suggestion" ); await afterNextRender(() => - document.querySelector('.o_PartnerMentionSuggestion').click() + document.querySelector('.o_ComposerSuggestion').click() ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js new file mode 100644 index 00000000000..caa324f1583 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js @@ -0,0 +1,101 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; + +const { Component } = owl; + +class ComposerSuggestion extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const record = this.env.models[props.modelName].get(props.recordLocalId); + return { + record: record ? record.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + get isCannedResponse() { + return this.props.modelName === "mail.canned_response"; + } + + get isChannel() { + return this.props.modelName === "mail.thread"; + } + + get isCommand() { + return this.props.modelName === "mail.channel_command"; + } + + get isPartner() { + return this.props.modelName === "mail.partner"; + } + + get record() { + return this.env.models[this.props.modelName].get(this.props.recordLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClick(ev) { + ev.preventDefault(); + this.composer.insertSuggestion(this.record); + this.composer.closeSuggestions(); + this.trigger('o-composer-suggestion-clicked'); + } + + /** + * @private + * @param {Event} ev + */ + _onMouseOver(ev) { + this.composer.update({ + [this.composer.activeSuggestedRecordName]: [['link', this.record]], + }); + } + +} + +Object.assign(ComposerSuggestion, { + components, + defaultProps: { + isActive: false, + }, + props: { + composerLocalId: String, + isActive: Boolean, + modelName: String, + recordLocalId: String, + }, + template: 'mail.ComposerSuggestion', +}); + +return ComposerSuggestion; + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss index b93d245972c..6516672f38f 100644 --- a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss @@ -2,13 +2,13 @@ // Layout // ------------------------------------------------------------------ -.o_PartnerMentionSuggestion { +.o_ComposerSuggestion { display: flex; width: map-get($sizes, 100); padding: map-get($spacers, 2) map-get($spacers, 4); } -.o_PartnerMentionSuggestion_name { +.o_ComposerSuggestion_part1 { padding-inline-end: map-get($spacers, 2); } @@ -16,11 +16,11 @@ // Style // ------------------------------------------------------------------ -.o_PartnerMentionSuggestion_email { - font-style: italic; - color: $gray-600; +.o_ComposerSuggestion_part1 { + font-weight: $font-weight-bold; } -.o_PartnerMentionSuggestion_name { - font-weight: $font-weight-bold; +.o_ComposerSuggestion_part2 { + font-style: italic; + color: $gray-600; } diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml new file mode 100644 index 00000000000..3397095e415 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerSuggestion" owl="1"> + <a class="o_ComposerSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver"> + <t t-if="record"> + <t t-if="isCannedResponse"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.source"/></span> + <span class="o_ComposerSuggestion_part2"><t t-esc="record.substitution"/></span> + </t> + <t t-if="isChannel"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span> + </t> + <t t-if="isCommand"> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.name"/></span> + <span class="o_ComposerSuggestion_part2"><t t-esc="record.help"/></span> + </t> + <t t-if="isPartner"> + <PartnerImStatusIcon + hasBackground="false" + partnerLocalId="record.localId" + /> + <span class="o_ComposerSuggestion_part1"><t t-esc="record.nameOrDisplayName"/></span> + <t t-if="record.email"> + <span class="o_ComposerSuggestion_part2">(<t t-esc="record.email"/>)</span> + </t> + </t> + </t> + </a> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js new file mode 100644 index 00000000000..0e0f86859a6 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js @@ -0,0 +1,154 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_canned_response_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('canned response suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Canned response suggestion should be present" + ); +}); + +QUnit.test('canned response suggestion correct data', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Canned response suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Canned response source should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "hello", + "Canned response source should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part2', + "Canned response substitution should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, + "Hello, how are you?", + "Canned response substitution should be displayed" + ); +}); + +QUnit.test('canned response suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const cannedResponse = this.env.models['mail.canned_response'].create({ + id: 7, + source: 'hello', + substitution: "Hello, how are you?", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.canned_response', + recordLocalId: cannedResponse.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Canned response suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js new file mode 100644 index 00000000000..f8409de2da9 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js @@ -0,0 +1,141 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_channel_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('channel mention suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Channel mention suggestion should be present" + ); +}); + +QUnit.test('channel mention suggestion correct data', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Channel mention suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Channel name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "General", + "Channel name should be displayed" + ); +}); + +QUnit.test('partner mention suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const channel = this.env.models['mail.thread'].create({ + id: 7, + name: "General", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.thread', + recordLocalId: channel.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Channel mention suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js new file mode 100644 index 00000000000..8bbb3d45e1e --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js @@ -0,0 +1,151 @@ +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const { + afterEach, + beforeEach, + createRootComponent, + start, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_command_tests.js', { + beforeEach() { + beforeEach(this); + + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, + target: this.widget.el, + }); + }; + + this.start = async params => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + afterEach(this); + }, +}); + +QUnit.test('command suggestion displayed', async function (assert) { + assert.expect(1); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + `.o_ComposerSuggestion`, + "Command suggestion should be present" + ); +}); + +QUnit.test('command suggestion correct data', async function (assert) { + assert.expect(5); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Command suggestion should be present" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part1', + "Command name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, + "whois", + "Command name should be displayed" + ); + assert.containsOnce( + document.body, + '.o_ComposerSuggestion_part2', + "Command help should be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, + "Displays who it is", + "Command help should be displayed" + ); +}); + +QUnit.test('command suggestion active', async function (assert) { + assert.expect(2); + + this.data['mail.channel'].records.push({ id: 20 }); + await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); + const command = this.env.models['mail.channel_command'].create({ + name: 'whois', + help: "Displays who it is", + }); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.channel_command', + recordLocalId: command.localId, + }); + + assert.containsOnce( + document.body, + '.o_ComposerSuggestion', + "Command suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_ComposerSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js index 6b0b7f9d3bf..548fd6d7963 100644 --- a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js @@ -1,8 +1,8 @@ -odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js', function (require) { +odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js', function (require) { 'use strict'; const components = { - PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), }; const { afterEach, @@ -13,14 +13,14 @@ const { QUnit.module('mail', {}, function () { QUnit.module('components', {}, function () { -QUnit.module('partner_mention_suggestion', {}, function () { -QUnit.module('partner_mention_suggestion_tests.js', { +QUnit.module('composer_suggestion', {}, function () { +QUnit.module('composer_suggestion_partner_tests.js', { beforeEach() { beforeEach(this); - this.createPartnerMentionSuggestion = async partner => { - await createRootComponent(this, components.PartnerMentionSuggestion, { - props: { isActive: true, partnerLocalId: partner.localId }, + this.createComposerSuggestion = async props => { + await createRootComponent(this, components.ComposerSuggestion, { + props, target: this.widget.el, }); }; @@ -41,17 +41,27 @@ QUnit.module('partner_mention_suggestion_tests.js', { QUnit.test('partner mention suggestion displayed', async function (assert) { assert.expect(1); + this.data['mail.channel'].records.push({ id: 20 }); await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); const partner = this.env.models['mail.partner'].create({ id: 7, im_status: 'online', name: "Demo User", }); - await this.createPartnerMentionSuggestion(partner); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); assert.containsOnce( document.body, - `.o_PartnerMentionSuggestion`, + `.o_ComposerSuggestion`, "Partner mention suggestion should be present" ); }); @@ -59,18 +69,28 @@ QUnit.test('partner mention suggestion displayed', async function (assert) { QUnit.test('partner mention suggestion correct data', async function (assert) { assert.expect(6); + this.data['mail.channel'].records.push({ id: 20 }); await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); const partner = this.env.models['mail.partner'].create({ email: "demo_user@odoo.com", id: 7, im_status: 'online', name: "Demo User", }); - await this.createPartnerMentionSuggestion(partner); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.o_ComposerSuggestion', "Partner mention suggestion should be present" ); assert.strictEqual( @@ -80,21 +100,21 @@ QUnit.test('partner mention suggestion correct data', async function (assert) { ); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion_name', + '.o_ComposerSuggestion_part1', "Partner's name should be present" ); assert.strictEqual( - document.querySelector(`.o_PartnerMentionSuggestion_name`).textContent, + document.querySelector(`.o_ComposerSuggestion_part1`).textContent, "Demo User", "Partner's name should be displayed" ); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion_email', + '.o_ComposerSuggestion_part2', "Partner's email should be present" ); assert.strictEqual( - document.querySelector(`.o_PartnerMentionSuggestion_email`).textContent, + document.querySelector(`.o_ComposerSuggestion_part2`).textContent, "(demo_user@odoo.com)", "Partner's email should be displayed" ); @@ -103,22 +123,31 @@ QUnit.test('partner mention suggestion correct data', async function (assert) { QUnit.test('partner mention suggestion active', async function (assert) { assert.expect(2); + this.data['mail.channel'].records.push({ id: 20 }); await this.start(); + const thread = this.env.models['mail.thread'].findFromIdentifyingData({ + id: 20, + model: 'mail.channel', + }); const partner = this.env.models['mail.partner'].create({ - email: "demo_user@odoo.com", id: 7, im_status: 'online', name: "Demo User", }); - await this.createPartnerMentionSuggestion(partner); + await this.createComposerSuggestion({ + composerLocalId: thread.composer.localId, + isActive: true, + modelName: 'mail.partner', + recordLocalId: partner.localId, + }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.o_ComposerSuggestion', "Partner mention suggestion should be displayed" ); assert.hasClass( - document.querySelector('.o_PartnerMentionSuggestion'), + document.querySelector('.o_ComposerSuggestion'), 'active', "should be active initially" ); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js new file mode 100644 index 00000000000..c033feb28ed --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js @@ -0,0 +1,74 @@ +odoo.define('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js', function (require) { +'use strict'; + +const components = { + ComposerSuggestion: require('mail/static/src/components/composer_suggestion/composer_suggestion.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ComposerSuggestionList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + const activeSuggestedRecord = composer + ? composer.activeSuggestedRecord + : undefined; + const extraSuggestedRecordsList = composer + ? composer.extraSuggestedRecordsList + : []; + const mainSuggestedRecordsList = composer + ? composer.mainSuggestedRecordsList + : []; + return { + activeSuggestedRecord: activeSuggestedRecord ? activeSuggestedRecord.__state : undefined, + composer: composer ? composer.__state : undefined, + extraSuggestedRecordsList: extraSuggestedRecordsList + ? extraSuggestedRecordsList.map(record => record.__state) + : [], + mainSuggestedRecordsList: mainSuggestedRecordsList + ? mainSuggestedRecordsList.map(record => record.__state) + : [], + }; + }, { + compareDepth: { + extraSuggestedRecordsList: 1, + mainSuggestedRecordsList: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + +} + +Object.assign(ComposerSuggestionList, { + components, + defaultProps: { + isBelow: false, + }, + props: { + composerLocalId: String, + isBelow: Boolean, + }, + template: 'mail.ComposerSuggestionList', +}); + +return ComposerSuggestionList; + +}); diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss new file mode 100644 index 00000000000..9cb509c2380 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss @@ -0,0 +1,11 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerSuggestionList { + position: absolute; + + &.o-lowPosition { + bottom: 0; + } +} diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml new file mode 100644 index 00000000000..e38f9cca8c7 --- /dev/null +++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerSuggestionList" owl="1"> + <div class="o_ComposerSuggestionList" t-att-class="{ 'o-lowPosition': props.isBelow }"> + <div t-att-class="{ 'dropdown': props.isBelow, 'dropup': !props.isBelow }"> + <div class="o_ComposerSuggestionList_list dropdown-menu show"> + <t t-foreach="composer.mainSuggestedRecordsList" t-as="record" t-key="record.localId"> + <ComposerSuggestion + composerLocalId="props.composerLocalId" + isActive="record === composer.activeSuggestedRecord" + modelName="composer.suggestionModelName" + recordLocalId="record.localId" + /> + </t> + <t t-if="composer.mainSuggestedRecordsList.length > 0 and composer.extraSuggestedRecordsList.length > 0"> + <div role="separator" class="dropdown-divider"/> + </t> + <t t-foreach="composer.extraSuggestedRecordsList" t-as="record" t-key="record.localId"> + <ComposerSuggestion + composerLocalId="props.composerLocalId" + isActive="record === composer.activeSuggestedRecord" + modelName="composer.suggestionModelName" + recordLocalId="record.localId" + /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js index ef21d023cb5..10cdd7ff0a7 100644 --- a/addons/mail/static/src/components/composer_text_input/composer_text_input.js +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -4,7 +4,7 @@ odoo.define('mail/static/src/components/composer_text_input/composer_text_input. const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); const components = { - PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), + ComposerSuggestionList: require('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js'), }; const { markEventHandled } = require('mail/static/src/utils/utils.js'); @@ -208,10 +208,10 @@ class ComposerTextInput extends Component { _onKeydownTextarea(ev) { switch (ev.key) { case 'Escape': - if (this.composer.hasSuggestedPartners) { + if (this.composer.hasSuggestions) { ev.preventDefault(); - this.composer.closeMentionSuggestions(); - markEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions'); + this.composer.closeSuggestions(); + markEventHandled(ev, 'ComposerTextInput.closeSuggestions'); } break; // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions @@ -222,7 +222,7 @@ class ComposerTextInput extends Component { case 'Home': case 'End': case 'Tab': - if (this.composer.hasSuggestedPartners) { + if (this.composer.hasSuggestions) { // We use preventDefault here to avoid keys native actions but actions are handled in keyUp ev.preventDefault(); } @@ -239,7 +239,7 @@ class ComposerTextInput extends Component { * @param {KeyboardEvent} ev */ _onKeydownTextareaEnter(ev) { - if (this.composer.hasSuggestedPartners) { + if (this.composer.hasSuggestions) { ev.preventDefault(); return; } @@ -292,72 +292,50 @@ class ComposerTextInput extends Component { break; // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions case 'Enter': - if (this.composer.hasSuggestedPartners) { - if (this.composer.activeSuggestedPartner) { - this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner); - this.composer.closeMentionSuggestions(); - this.focus(); - } + if (this.composer.hasSuggestions) { + this.composer.insertSuggestion(); + this.composer.closeSuggestions(); + this.focus(); } break; case 'ArrowUp': case 'PageUp': - if (this.composer.hasSuggestedPartners) { - this.composer.setPreviousSuggestedPartnerActive(); + if (this.composer.hasSuggestions) { + this.composer.setPreviousSuggestionActive(); } break; case 'ArrowDown': case 'PageDown': - if (this.composer.hasSuggestedPartners) { - this.composer.setNextSuggestedPartnerActive(); + if (this.composer.hasSuggestions) { + this.composer.setNextSuggestionActive(); } break; case 'Home': - if (this.composer.hasSuggestedPartners) { - this.composer.setFirstSuggestedPartnerActive(); + if (this.composer.hasSuggestions) { + this.composer.setFirstSuggestionActive(); } break; case 'End': - if (this.composer.hasSuggestedPartners) { - this.composer.setLastSuggestedPartnerActive(); + if (this.composer.hasSuggestions) { + this.composer.setLastSuggestionActive(); } break; case 'Tab': - if (this.composer.hasSuggestedPartners) { + if (this.composer.hasSuggestions) { if (ev.shiftKey) { - this.composer.setPreviousSuggestedPartnerActive(); + this.composer.setPreviousSuggestionActive(); } else { - this.composer.setNextSuggestedPartnerActive(); + this.composer.setNextSuggestionActive(); } } break; // Otherwise, check if a mention is typed default: this.saveStateInStore(); - this.composer._detectDelimiter(); + this.composer.detectSuggestionDelimiter(); } } - /** - * @private - * @param {Event} ev - */ - _onPartnerMentionSuggestionClicked(ev) { - this.composer.insertMentionedPartner(ev.detail.partner); - this.composer.closeMentionSuggestions(); - this.focus(); - } - - /** - * @private - * @param {MouseEvent} ev - */ - _onPartnerMentionSuggestionMouseOver(ev) { - this.composer.update({ - activeSuggestedPartner: [['link', ev.detail.partner]], - }); - } - } Object.assign(ComposerTextInput, { diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml index 32fff1289eb..a834a61f1b7 100644 --- a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -4,37 +4,10 @@ <t t-name="mail.ComposerTextInput" owl="1"> <div class="o_ComposerTextInput"> <t t-if="composer"> - <t t-if="composer.hasSuggestedPartners"> - <div class="o_ComposerTextInput_mentionDropdown" t-att-class="{ 'o-lowPosition': props.hasMentionSuggestionsBelowPosition }"> - <div - t-att-class="{ - 'dropdown': props.hasMentionSuggestionsBelowPosition, - 'dropup': !props.hasMentionSuggestionsBelowPosition, - }" - > - <div class="o_ComposerTextInput_mentionDropdownPropositionList dropdown-menu show"> - <t t-foreach="composer.mainSuggestedPartners" t-as="mainSuggestedPartner" t-key="mainSuggestedPartner.localId"> - <PartnerMentionSuggestion - isActive="mainSuggestedPartner === composer.activeSuggestedPartner" - partnerLocalId="mainSuggestedPartner.localId" - t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" - t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" - /> - </t> - <t t-if="composer.mainSuggestedPartners.length > 0 and composer.extraSuggestedPartners.length > 0"> - <div role="separator" class="dropdown-divider"/> - </t> - <t t-foreach="composer.extraSuggestedPartners" t-as="extraSuggestedPartner" t-key="extraSuggestedPartner.localId"> - <PartnerMentionSuggestion - isActive="extraSuggestedPartner === composer.activeSuggestedPartner" - partnerLocalId="extraSuggestedPartner.localId" - t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" - t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" - /> - </t> - </div> - </div> - </div> + <t t-if="composer.hasSuggestions"> + <ComposerSuggestionList + composerLocalId="props.composerLocalId" + /> </t> <textarea class="o_ComposerTextInput_textarea" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/> </t> diff --git a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js index 5f24acec192..00cd5d2c310 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js @@ -90,15 +90,26 @@ QUnit.test('reply: discard on pressing escape', async function (assert) { ); await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); document.execCommand('insertText', false, "@"); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keydown')); document.querySelector(`.o_ComposerTextInput_textarea`) .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "T"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + document.execCommand('insertText', false, "e"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); }); assert.containsOnce( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestion', "mention suggestion should be opened after typing @" ); @@ -108,7 +119,7 @@ QUnit.test('reply: discard on pressing escape', async function (assert) { }); assert.containsNone( document.body, - '.o_ComposerTextInput_mentionDropdownPropositionList', + '.o_ComposerSuggestion', "mention suggestion should be closed after pressing escape on mention suggestion" ); assert.containsOnce( diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js index 3189b97b627..bc1ab67bbfc 100644 --- a/addons/mail/static/src/components/message_list/message_list.js +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -357,8 +357,8 @@ class MessageList extends Component { */ async _adjustFromCurrentPartnerJustPostedMessage(hint) { const threadCache = this.threadView.threadCache; - const { messageId } = hint.data; - if (threadCache.isLoaded) { + if (threadCache.isLoaded && hint.data) { + const { messageId } = hint.data; const threadCacheMessageIds = threadCache.messages.map(message => message.id); if (threadCacheMessageIds.includes(messageId) && this.messageRefFromId(messageId)) { if (this.props.hasScrollAdjust) { diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js deleted file mode 100644 index 7c39f148264..00000000000 --- a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js +++ /dev/null @@ -1,79 +0,0 @@ -odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js', function (require) { -'use strict'; - -const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); - -const components = { - PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), -}; - -const { Component } = owl; - -class PartnerMentionSuggestion extends Component { - - /** - * @override - */ - constructor(...args) { - super(...args); - useStore(props => { - const partner = this.env.models['mail.partner'].get(props.partnerLocalId); - return { - partner: partner ? partner.__state : undefined, - }; - }); - } - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @returns {mail.partner} - */ - get partner() { - return this.env.models['mail.partner'].get(this.props.partnerLocalId); - } - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onClick(ev) { - // avoid following dummy href - ev.preventDefault(); - this.trigger('o-partner-mention-suggestion-clicked', { - partner: this.partner, - }); - } - - /** - * @private - * @param {Event} ev - */ - _onMouseOver(ev) { - this.trigger('o-partner-mention-suggestion-mouse-over', { - partner: this.partner, - }); - } -} - -Object.assign(PartnerMentionSuggestion, { - components, - defaultProps: { - isActive: false, - }, - props: { - isActive: Boolean, - partnerLocalId: String, - }, - template: 'mail.PartnerMentionSuggestion', -}); - -return PartnerMentionSuggestion; - -}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml deleted file mode 100644 index a45e991ab19..00000000000 --- a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates xml:space="preserve"> - - <t t-name="mail.PartnerMentionSuggestion" owl="1"> - <a class="o_PartnerMentionSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver"> - <t t-if="partner"> - <PartnerImStatusIcon - hasBackground="false" - partnerLocalId="partner.localId" - /> - <span class="o_PartnerMentionSuggestion_name"><t t-esc="partner.nameOrDisplayName"/></span> - <t t-if="partner.email"> - <span class="o_PartnerMentionSuggestion_email">(<t t-esc="partner.email"/>)</span> - </t> - </t> - </a> - </t> - -</templates> |
