From 9fc3fe1dba1048b25b49e83fe22b15e1e3fcb90b Mon Sep 17 00:00:00 2001 From: Julien Giannone Date: Mon, 21 Sep 2020 18:58:55 +0000 Subject: [PATCH] [IMP] mail: reintroduce channel mentions, canned responses, commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task-2280308 closes odoo/odoo#58167 X-original-commit: b63d0d2ee5f3bff24a5e1e1cd962d59bebef082d Related: odoo/enterprise#13393 Signed-off-by: Sébastien Theys (seb) --- .../src/components/composer/composer_tests.js | 9 +- addons/mail/__manifest__.py | 3 +- .../src/components/chat_window/chat_window.js | 2 +- .../chat_window_manager_tests.js | 13 +- .../src/components/chatter/chatter_tests.js | 4 +- .../src/components/composer/composer.js | 9 +- .../src/components/composer/composer.xml | 1 + .../src/components/composer/composer_tests.js | 688 ++++++++++++++++-- .../composer_suggestion.js | 101 +++ .../composer_suggestion.scss} | 14 +- .../composer_suggestion.xml | 32 + ...mposer_suggestion_canned_response_tests.js | 154 ++++ .../composer_suggestion_channel_tests.js | 141 ++++ .../composer_suggestion_command_tests.js | 151 ++++ .../composer_suggestion_partner_tests.js} | 67 +- .../composer_suggestion_list.js | 74 ++ .../composer_suggestion_list.scss | 11 + .../composer_suggestion_list.xml | 32 + .../composer_text_input.js | 66 +- .../composer_text_input.xml | 35 +- .../discuss/tests/discuss_inbox_tests.js | 15 +- .../components/message_list/message_list.js | 4 +- .../partner_mention_suggestion.js | 79 -- .../partner_mention_suggestion.xml | 19 - .../models/canned_response/canned_response.js | 31 + .../models/channel_command/channel_command.js | 33 + .../static/src/models/composer/composer.js | 528 +++++++++++--- .../static/src/models/messaging/messaging.js | 10 +- .../messaging_initializer.js | 33 +- .../mail/static/src/models/thread/thread.js | 6 +- .../mail/static/tests/helpers/mock_models.js | 7 + .../mail/static/tests/helpers/mock_server.js | 70 +- addons/mail/views/assets.xml | 13 +- 33 files changed, 2034 insertions(+), 421 deletions(-) create mode 100644 addons/mail/static/src/components/composer_suggestion/composer_suggestion.js rename addons/mail/static/src/components/{partner_mention_suggestion/partner_mention_suggestion.scss => composer_suggestion/composer_suggestion.scss} (80%) create mode 100644 addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml create mode 100644 addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js create mode 100644 addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js create mode 100644 addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js rename addons/mail/static/src/components/{partner_mention_suggestion/partner_mention_suggestion_tests.js => composer_suggestion/composer_suggestion_partner_tests.js} (55%) create mode 100644 addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js create mode 100644 addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss create mode 100644 addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml delete mode 100644 addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js delete mode 100644 addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml create mode 100644 addons/mail/static/src/models/canned_response/canned_response.js create mode 100644 addons/mail/static/src/models/channel_command/channel_command.js diff --git a/addons/im_livechat/static/src/components/composer/composer_tests.js b/addons/im_livechat/static/src/components/composer/composer_tests.js index cf488b14d97..b0559b98972 100644 --- a/addons/im_livechat/static/src/components/composer/composer_tests.js +++ b/addons/im_livechat/static/src/components/composer/composer_tests.js @@ -29,7 +29,7 @@ QUnit.module('composer_tests.js', { }; this.start = async params => { - let { env, widget } = await start(Object.assign({}, params, { + const { env, widget } = await start(Object.assign({}, params, { data: this.data, })); this.env = env; @@ -38,13 +38,6 @@ QUnit.module('composer_tests.js', { }, afterEach() { afterEach(this); - if (this.component) { - this.component.destroy(); - } - if (this.widget) { - this.widget.destroy(); - } - this.env = undefined; }, }); diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 0bfb543d904..e1d84a6b576 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -77,6 +77,8 @@ 'static/src/components/composer/composer.xml', 'static/src/components/composer_suggested_recipient/composer_suggested_recipient.xml', 'static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.xml', + 'static/src/components/composer_suggestion/composer_suggestion.xml', + 'static/src/components/composer_suggestion_list/composer_suggestion_list.xml', 'static/src/components/composer_text_input/composer_text_input.xml', 'static/src/components/dialog/dialog.xml', 'static/src/components/dialog_manager/dialog_manager.xml', @@ -109,7 +111,6 @@ 'static/src/components/notification_popover/notification_popover.xml', 'static/src/components/notification_request/notification_request.xml', 'static/src/components/partner_im_status_icon/partner_im_status_icon.xml', - 'static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml', 'static/src/components/thread_icon/thread_icon.xml', 'static/src/components/thread_needaction_preview/thread_needaction_preview.xml', 'static/src/components/thread_preview/thread_preview.xml', 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 @@ -261,6 +261,13 @@ class Composer extends Component { this._postMessage(); } + /** + * @private + */ + _onComposerSuggestionClicked() { + this.focus(); + } + /** * @private */ @@ -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,56 +274,56 @@ 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['res.partner'].records.push({ - email: "testpartnert@odoo.com", + this.data['mail.shortcode'].records.push({ id: 11, - name: "TestPartner", + source: "hello", + substitution: "Hello! How are you?", }); - 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', - "mention suggestions list should not be present" + '.o_ComposerSuggestionList_list', + "Canned responses suggestions list should not be present" ); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@"); + 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_ComposerTextInput_mentionDropdownPropositionList'), + document.querySelector('.o_ComposerSuggestionList_list'), 'show', - "should display mention suggestions on typing '@'" + "should display canned response suggestions on typing ':'" ); }); -QUnit.test('mention a partner', async function (assert) { +QUnit.test('use a canned response', async function (assert) { assert.expect(4); - this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", + this.data['mail.shortcode'].records.push({ id: 11, - name: "TestPartner", + source: "hello", + substitution: "Hello! How are you?", }); - 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', - "mention suggestions list should not be present" + '.o_ComposerSuggestionList_list', + "canned response suggestions list should not be present" ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value, @@ -332,7 +332,7 @@ QUnit.test('mention a partner', async function (assert) { ); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`).focus(); - document.execCommand('insertText', false, "@Test"); + document.execCommand('insertText', false, ":"); }); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) @@ -342,36 +342,36 @@ QUnit.test('mention a partner', async function (assert) { }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', - "should have a mention suggestion" + '.o_ComposerSuggestion', + "should have a canned response suggestion" ); await afterNextRender(() => - document.querySelector('.o_PartnerMentionSuggestion').click() + document.querySelector('.o_ComposerSuggestion').click() ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), - "@TestPartner ", - "text content of composer should have mentioned partner + additional whitespace afterwards" + "Hello! How are you? ", + "text content of composer should have canned response + additional whitespace afterwards" ); }); -QUnit.test('mention a partner after some text', async function (assert) { +QUnit.test('use a canned response some text', async function (assert) { assert.expect(5); - this.data['res.partner'].records.push({ - email: "testpartnert@odoo.com", + this.data['mail.shortcode'].records.push({ id: 11, - name: "TestPartner", + source: "hello", + substitution: "Hello! How are you?", }); - 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', - "mention suggestions list should not be present" + '.o_ComposerSuggestion', + "canned response suggestions list should not be present" ); assert.strictEqual( document.querySelector(`.o_ComposerTextInput_textarea`).value, @@ -388,7 +388,7 @@ QUnit.test('mention a partner after some text', async function (assert) { "text content of composer should have content" ); await afterNextRender(() => - document.execCommand('insertText', false, "@Test") + document.execCommand('insertText', false, ":") ); await afterNextRender(() => { document.querySelector(`.o_ComposerTextInput_textarea`) @@ -398,11 +398,609 @@ QUnit.test('mention a partner after some text', async function (assert) { }); assert.containsOnce( document.body, - '.o_PartnerMentionSuggestion', + '.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({ + id: 11, + email: "testpartner@odoo.com", + name: "TestPartner", + }); + 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_ComposerSuggestionList_list', + "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 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: "testpartner@odoo.com", + name: "TestPartner", + }); + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerSuggestionList_list', + "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, "@"); + 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_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/, " "), + "@TestPartner ", + "text content of composer should have mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a partner after some text', async function (assert) { + assert.expect(5); + + this.data['res.partner'].records.push({ + email: "testpartner@odoo.com", + name: "TestPartner", + }); + 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" + ); + 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.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_ComposerSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + 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 similarity index 80% rename from addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss rename to 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 { +.o_ComposerSuggestion_part1 { + font-weight: $font-weight-bold; +} + +.o_ComposerSuggestion_part2 { font-style: italic; color: $gray-600; } - -.o_PartnerMentionSuggestion_name { - font-weight: $font-weight-bold; -} 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 @@ + + + + + + + + + + + + + + + + + + + + + + () + + + + + + + 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 similarity index 55% rename from addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js rename to 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 @@ + + + + +
+
+ +
+ + + 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 @@
- -
-
- -
+ +