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 | |
| 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>
33 files changed, 2024 insertions, 411 deletions
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 @@ -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> diff --git a/addons/mail/static/src/models/canned_response/canned_response.js b/addons/mail/static/src/models/canned_response/canned_response.js new file mode 100644 index 00000000000..19375fd7ccd --- /dev/null +++ b/addons/mail/static/src/models/canned_response/canned_response.js @@ -0,0 +1,31 @@ +odoo.define('mail/static/src/models/canned_response/canned_response.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class CannedResponse extends dependencies['mail.model'] {} + + CannedResponse.fields = { + id: attr(), + /** + * The keyword to use a specific canned response. + */ + source: attr(), + /** + * The canned response itself which will replace the keyword previously + * entered. + */ + substitution: attr(), + }; + + CannedResponse.modelName = 'mail.canned_response'; + + return CannedResponse; +} + +registerNewModel('mail.canned_response', factory); + +}); diff --git a/addons/mail/static/src/models/channel_command/channel_command.js b/addons/mail/static/src/models/channel_command/channel_command.js new file mode 100644 index 00000000000..0eef37791a6 --- /dev/null +++ b/addons/mail/static/src/models/channel_command/channel_command.js @@ -0,0 +1,33 @@ +odoo.define('mail/static/src/models/channel_command/channel_command.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ChannelCommand extends dependencies['mail.model'] {} + + ChannelCommand.fields = { + /** + * FIXME use this value task-2343850 + */ + channel_types: attr(), + /** + * The command that will be executed. + */ + help: attr(), + /** + * The keyword to use a specific command. + */ + name: attr(), + }; + + ChannelCommand.modelName = 'mail.channel_command'; + + return ChannelCommand; +} + +registerNewModel('mail.channel_command', factory); + +}); diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js index 230541c77ef..e2a7ab4fb83 100644 --- a/addons/mail/static/src/models/composer/composer.js +++ b/addons/mail/static/src/models/composer/composer.js @@ -20,14 +20,78 @@ function factory(dependencies) { // Public //---------------------------------------------------------------------- - closeMentionSuggestions() { + closeSuggestions() { + if (this.activeSuggestedRecordName) { + this.update({ + [this.activeSuggestedRecordName]: [['unlink']], + }); + } + if (this.extraSuggestedRecordsListName) { + this.update({ + [this.extraSuggestedRecordsListName]: [['unlink-all']], + }); + } + if (this.mainSuggestedRecordsListName) { + this.update({ + [this.mainSuggestedRecordsListName]: [['unlink-all']], + }); + } this.update({ - activeSuggestedPartner: [['unlink']], - extraSuggestedPartners: [['unlink-all']], - mainSuggestedPartners: [['unlink-all']], + activeSuggestedRecordName: "", + extraSuggestedRecordsListName: "", + mainSuggestedRecordsListName: "", + suggestionDelimiter: "", }); } + detectSuggestionDelimiter() { + const lastInputChar = this.textInputContent.substring(this.textInputCursorStart - 1, this.textInputCursorStart); + const suggestionDelimiters = ['@', ':', '#', '/']; + if (suggestionDelimiters.includes(lastInputChar) && !this.hasSuggestions) { + this.update({ suggestionDelimiter: lastInputChar }); + } + const mentionKeyword = this._validateMentionKeyword(false); + if (mentionKeyword !== false) { + switch (this.suggestionDelimiter) { + case '@': + this.update({ + activeSuggestedRecordName: "activeSuggestedPartner", + extraSuggestedRecordsListName: "extraSuggestedPartners", + mainSuggestedRecordsListName: "mainSuggestedPartners", + suggestionModelName: "mail.partner", + }); + this._updateSuggestedPartners(mentionKeyword); + break; + case ':': + this.update({ + activeSuggestedRecordName: "activeSuggestedCannedResponse", + mainSuggestedRecordsListName: "suggestedCannedResponses", + suggestionModelName: "mail.canned_response", + }); + this._updateSuggestedCannedResponses(mentionKeyword); + break; + case '/': + this.update({ + activeSuggestedRecordName: "activeSuggestedChannelCommand", + mainSuggestedRecordsListName: "suggestedChannelCommands", + suggestionModelName: "mail.channel_command", + }); + this._updateSuggestedChannelCommands(mentionKeyword); + break; + case '#': + this.update({ + activeSuggestedRecordName: "activeSuggestedChannel", + mainSuggestedRecordsListName: "suggestedChannels", + suggestionModelName: "mail.thread", + }); + this._updateSuggestedChannels(mentionKeyword); + break; + } + } else { + this.closeSuggestions(); + } + } + /** * Hides the composer, which only makes sense if the composer is * currently used as a Discuss Inbox reply composer. @@ -56,25 +120,51 @@ function factory(dependencies) { }); } - /** - * @param {mail.partner} partner - */ - insertMentionedPartner(partner) { + insertSuggestion() { const cursorPosition = this.textInputCursorStart; - const textLeft = this.textInputContent.substring( + let textLeft = this.textInputContent.substring( 0, - this.textInputContent.substring(0, cursorPosition).lastIndexOf('@') + 1 + this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter) + 1 ); - const textRight = this.textInputContent.substring( + let textRight = this.textInputContent.substring( cursorPosition, this.textInputContent.length ); - const partnerName = partner.name.replace(/ /g, '\u00a0'); + if (this.suggestionDelimiter === ':') { + textLeft = this.textInputContent.substring( + 0, + this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter) + ); + textRight = this.textInputContent.substring( + cursorPosition, + this.textInputContent.length + ); + } + let recordReplacement = ""; + switch (this.activeSuggestedRecordName) { + case 'activeSuggestedCannedResponse': + recordReplacement = this[this.activeSuggestedRecordName].substitution; + break; + case 'activeSuggestedChannel': + recordReplacement = this[this.activeSuggestedRecordName].name; + this.update({ + mentionedChannels: [['link', this[this.activeSuggestedRecordName]]], + }); + break; + case 'activeSuggestedChannelCommand': + recordReplacement = this[this.activeSuggestedRecordName].name; + break; + case 'activeSuggestedPartner': + recordReplacement = this[this.activeSuggestedRecordName].name.replace(/ /g, '\u00a0'); + this.update({ + mentionedPartners: [['link', this[this.activeSuggestedRecordName]]], + }); + break; + } this.update({ - mentionedPartners: [['link', partner]], - textInputContent: textLeft + partnerName + ' ' + textRight, - textInputCursorEnd: textLeft.length + partnerName.length + 1, - textInputCursorStart: textLeft.length + partnerName.length + 1, + textInputContent: textLeft + recordReplacement + ' ' + textRight, + textInputCursorEnd: textLeft.length + recordReplacement.length + 1, + textInputCursorStart: textLeft.length + recordReplacement.length + 1, }); } @@ -150,6 +240,7 @@ function factory(dependencies) { let postData = { attachment_ids: this.attachments.map(attachment => attachment.id), body, + channel_ids: this.mentionedChannels.map(channel => channel.id), context: { mail_post_autofollow: true, }, @@ -223,71 +314,91 @@ function factory(dependencies) { } } - setFirstSuggestedPartnerActive() { - if (!this.allSuggestedPartners[0]) { - return; + setFirstSuggestionActive() { + if (!this[this.mainSuggestedRecordsListName][0]) { + if (!this[this.extraSuggestedRecordsListName][0]) { + return; + } + this.update({ + [this.activeSuggestedRecordName]: [['link', this[this.extraSuggestedRecordsListName][0]]], + }); + } else { + this.update({ + [this.activeSuggestedRecordName]: [['link', this[this.mainSuggestedRecordsListName][0]]], + }); } - this.update({ - activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], - }); } - setLastSuggestedPartnerActive() { - if (this.allSuggestedPartners.length === 0) { - return; + setLastSuggestionActive() { + if (this[this.extraSuggestedRecordsListName].length === 0) { + if (this[this.mainSuggestedRecordsListName].length === 0) { + return; + } + this.update({ + [this.activeSuggestedRecordName]: [[ + 'link', + this[this.mainSuggestedRecordsListName][this[this.mainSuggestedRecordsListName].length - 1] + ]], + }); } this.update({ - activeSuggestedPartner: [[ + [this.activeSuggestedRecordName]: [[ 'link', - this.allSuggestedPartners[this.allSuggestedPartners.length - 1] + this[this.extraSuggestedRecordsListName][this[this.extraSuggestedRecordsListName].length - 1] ]], }); } - setNextSuggestedPartnerActive() { - if (this.allSuggestedPartners.length === 0) { + setNextSuggestionActive() { + const fullList = this.extraSuggestedRecordsListName ? + this[this.mainSuggestedRecordsListName].concat(this[this.extraSuggestedRecordsListName]) : + this[this.mainSuggestedRecordsListName]; + if (fullList.length === 0) { return; } - const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex( - suggestedPartner => suggestedPartner === this.activeSuggestedPartner + const activeElementIndex = fullList.findIndex( + suggestion => suggestion === this[this.activeSuggestedRecordName] ); - if (activeSuggestedPartnerIndex !== this.allSuggestedPartners.length - 1) { + if (activeElementIndex !== fullList.length - 1) { this.update({ - activeSuggestedPartner: [[ + [this.activeSuggestedRecordName]: [[ 'link', - this.allSuggestedPartners[activeSuggestedPartnerIndex + 1] + fullList[activeElementIndex + 1] ]], }); } else { this.update({ - activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], + [this.activeSuggestedRecordName]: [['link', fullList[0]]], }); } } - setPreviousSuggestedPartnerActive() { - if (this.allSuggestedPartners.length === 0) { + setPreviousSuggestionActive() { + const fullList = this.extraSuggestedRecordsListName ? + this[this.mainSuggestedRecordsListName].concat(this[this.extraSuggestedRecordsListName]) : + this[this.mainSuggestedRecordsListName]; + if (fullList.length === 0) { return; } - const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex( - suggestedPartner => suggestedPartner === this.activeSuggestedPartner + const activeElementIndex = fullList.findIndex( + suggestion => suggestion === this[this.activeSuggestedRecordName] ); - if (activeSuggestedPartnerIndex === -1) { + if (activeElementIndex === -1) { this.update({ - activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]] + [this.activeSuggestedRecordName]: [['link', fullList[0]]] }); - } else if (activeSuggestedPartnerIndex !== 0) { + } else if (activeElementIndex !== 0) { this.update({ - activeSuggestedPartner: [[ + [this.activeSuggestedRecordName]: [[ 'link', - this.allSuggestedPartners[activeSuggestedPartnerIndex - 1] + fullList[activeElementIndex - 1] ]], }); } else { this.update({ - activeSuggestedPartner: [[ + [this.activeSuggestedRecordName]: [[ 'link', - this.allSuggestedPartners[this.allSuggestedPartners.length - 1] + fullList[fullList.length - 1] ]], }); } @@ -299,10 +410,10 @@ function factory(dependencies) { /** * @private - * @returns {mail.partner[]} + * @returns {mail.model} */ - _computeAllSuggestedPartners() { - return [['replace', this.mainSuggestedPartners.concat(this.extraSuggestedPartners)]]; + _computeActiveSuggestedRecord() { + return this[this.activeSuggestedRecordName]; } /** @@ -319,9 +430,7 @@ function factory(dependencies) { /** * Ensure extraSuggestedPartners does not contain any partner already * present in mainSuggestedPartners. This is necessary for the - * consistency of suggestion list because allSuggestedPartners is the - * union of both but without duplicates (because it is a relational - * field). + * consistency of suggestion list. * * @private * @returns {mail.partner[]} @@ -332,10 +441,22 @@ function factory(dependencies) { /** * @private + * @returns {mail.model[]} + */ + _computeExtraSuggestedRecordsList() { + return this.extraSuggestedRecordsListName + ? this[this.extraSuggestedRecordsListName] + : []; + } + + /** + * @private * @return {boolean} */ - _computeHasSuggestedPartners() { - return this.allSuggestedPartners.length > 0; + _computeHasSuggestions() { + const hasMainSuggestedRecordsList = this.mainSuggestedRecordsListName ? this[this.mainSuggestedRecordsListName].length > 0 : false; + const hasExtraSuggestedRecordsList = this.extraSuggestedRecordsListName ? this[this.extraSuggestedRecordsListName].length > 0 : false; + return hasMainSuggestedRecordsList || hasExtraSuggestedRecordsList; } /** @@ -347,6 +468,16 @@ function factory(dependencies) { } /** + * @private + * @returns {mail.model[]} + */ + _computeMainSuggestedRecordsList() { + return this.mainSuggestedRecordsListName + ? this[this.mainSuggestedRecordsListName] + : []; + } + + /** * Detects if mentioned partners are still in the composer text input content * and removes them if not. * @@ -370,18 +501,26 @@ function factory(dependencies) { } /** - * Detects if mentions suggestions should be displayed when user is typing - * and searches partners based on user's research. + * Detects if mentioned channels are still in the composer text input content + * and removes them if not. * * @private + * @returns {mail.partner[]} */ - _detectDelimiter() { - const mentionKeyword = this._validateMentionKeyword('@', false); - if (mentionKeyword !== false) { - this._getSuggestedPartners(mentionKeyword); - } else { - this.closeMentionSuggestions(); + _computeMentionedChannels() { + const inputMentions = this.textInputContent.match( + new RegExp("#[^ ]+(?= | |$)", 'g') + ) || []; + const unmentionedChannels = []; + for (const channel of this.mentionedChannels) { + let inputMention = inputMentions.find(item => { + return item === ("#" + channel.name).replace(/ /g, '\u00a0'); + }); + if (!inputMention) { + unmentionedChannels.push(channel); + } } + return [['unlink', unmentionedChannels]]; } /** @@ -413,28 +552,36 @@ function factory(dependencies) { * @returns {string} */ _generateMentionsLinks(body) { - if (this.mentionedPartners.length === 0) { + if (this.mentionedPartners.length === 0 && this.mentionedChannels.length === 0) { return body; } - const inputMentions = body.match(new RegExp("@"+'[^ ]+(?= | |$)', 'g')); + const inputMentions = body.match(new RegExp("(@|#)" + '[^ ]+(?= | |$)', 'g')); const substrings = []; let startIndex = 0; - for (const i in inputMentions) { - const match = inputMentions[i]; + for (const match of inputMentions) { + const suggestionDelimiter = match[0]; const matchName = owl.utils.escape(match.substring(1).replace(new RegExp('\u00a0', 'g'), ' ')); const endIndex = body.indexOf(match, startIndex) + match.length; - const partner = this.mentionedPartners.find(partner => - partner.name === matchName + let field = "mentionedPartners"; + let model = "res.partner"; + let cssClass = "o_mail_redirect"; + if (suggestionDelimiter === "#") { + field = "mentionedChannels"; + model = "mail.channel"; + cssClass = "o_channel_redirect"; + } + const mention = this[field].find(mention => + mention.name === matchName ); - let mentionLink = "@" + matchName; - if (partner) { + let mentionLink = suggestionDelimiter + matchName; + if (mention) { const baseHREF = this.env.session.url('/web'); - const href = `href='${baseHREF}#model=res.partner&id=${partner.id}'`; - const attClass = `class='o_mail_redirect'`; - const dataOeId = `data-oe-id='${partner.id}'`; - const dataOeModel = `data-oe-model='res.partner'`; + const href = `href='${baseHREF}#model=${model}&id=${mention.id}'`; + const attClass = `class='${cssClass}'`; + const dataOeId = `data-oe-id='${mention.id}'`; + const dataOeModel = `data-oe-model='${model}'`; const target = `target='_blank'`; - mentionLink = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target} >@${matchName}</a>`; + mentionLink = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target} >${suggestionDelimiter}${matchName}</a>`; } substrings.push(body.substring(startIndex, body.indexOf(match, startIndex))); substrings.push(mentionLink); @@ -458,9 +605,107 @@ function factory(dependencies) { /** * @private + */ + _reset() { + this.closeSuggestions(); + this.update({ + attachments: [['unlink-all']], + mentionedChannels: [['unlink-all']], + mentionedPartners: [['unlink-all']], + subjectContent: "", + textInputContent: '', + textInputCursorEnd: 0, + textInputCursorStart: 0, + }); + } + + /** + * @private + * @param {string} mentionKeyword + */ + _updateSuggestedCannedResponses(mentionKeyword) { + this.update({ + suggestedCannedResponses: [['replace', this.env.messaging.cannedResponses.filter( + cannedResponse => cannedResponse.source.includes(mentionKeyword) + )]], + }); + + if (this.suggestedCannedResponses[0]) { + this.update({ + activeSuggestedCannedResponse: [['link', this.suggestedCannedResponses[0]]], + }); + } else { + this.update({ + activeSuggestedCannedResponse: [['unlink']], + }); + } + } + + /** + * @private * @param {string} mentionKeyword */ - async _getSuggestedPartners(mentionKeyword) { + async _updateSuggestedChannels(mentionKeyword) { + const mentions = await this.async(() => this.env.services.rpc( + { + model: 'mail.channel', + method: 'get_mention_suggestions', + kwargs: { + limit: 8, + search: mentionKeyword, + }, + }, + { shadow: true } + )); + + this.update({ + suggestedChannels: [[ + 'insert-and-replace', + mentions.map(data => + this.env.models['mail.thread'].convertData(data)) + ]], + }); + + if (this.suggestedChannels[0]) { + this.update({ + activeSuggestedChannel: [['link', this.suggestedChannels[0]]], + }); + } else { + this.update({ + activeSuggestedChannel: [['unlink']], + }); + } + } + + /** + * @param {string} mentionKeyword + */ + _updateSuggestedChannelCommands(mentionKeyword) { + this.update({ + suggestedChannelCommands: [[ + 'replace', + this.env.messaging.commands.filter( + command => command.name.includes(mentionKeyword) + ) + ]], + }); + + if (this.suggestedChannelCommands[0]) { + this.update({ + activeSuggestedChannelCommand: [['link', this.suggestedChannelCommands[0]]], + }); + } else { + this.update({ + activeSuggestedChannelCommand: [['unlink']], + }); + } + } + + /** + * @private + * @param {string} mentionKeyword + */ + async _updateSuggestedPartners(mentionKeyword) { const mentions = await this.async(() => this.env.services.rpc( { model: 'res.partner', @@ -489,9 +734,13 @@ function factory(dependencies) { ]], }); - if (this.allSuggestedPartners[0]) { + if (this.mainSuggestedPartners[0]) { + this.update({ + activeSuggestedPartner: [['link', this.mainSuggestedPartners[0]]], + }); + } else if (this.extraSuggestedPartners[0]) { this.update({ - activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], + activeSuggestedPartner: [['link', this.extraSuggestedPartners[0]]], }); } else { this.update({ @@ -501,46 +750,30 @@ function factory(dependencies) { } /** - * @private - */ - _reset() { - this.closeMentionSuggestions(); - this.update({ - attachments: [['unlink-all']], - mentionedPartners: [['unlink-all']], - subjectContent: "", - textInputContent: "", - textInputCursorStart: 0, - textInputCursorEnd: 0, - }); - } - - /** - * Validates user's current typing as a correct mention keyword - * in order to trigger mentions suggestions display. - * Returns the mention keyword without the delimiter if it has been validated - * and false if not + * Validates user's current typing as a correct mention keyword in order + * to trigger mentions suggestions display. + * Returns the mention keyword without the suggestion delimiter if it + * has been validated and false if not. * * @private - * @param {string} delimiter * @param {boolean} beginningOnly * @returns {string|boolean} */ - _validateMentionKeyword(delimiter, beginningOnly) { + _validateMentionKeyword(beginningOnly) { const leftString = this.textInputContent.substring(0, this.textInputCursorStart); - // use position before delimiter because there should be whitespaces - // or line feed/carriage return before the delimiter - const beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; - if (beginningOnly && beforeDelimiterPosition > 0) { + // use position before suggestion delimiter because there should be whitespaces + // or line feed/carriage return before the suggestion delimiter + const beforeSuggestionDelimiterPosition = leftString.lastIndexOf(this.suggestionDelimiter) - 1; + if (beginningOnly && beforeSuggestionDelimiterPosition > 0) { return false; } let searchStr = this.textInputContent.substring( - beforeDelimiterPosition, + beforeSuggestionDelimiterPosition, this.textInputCursorStart ); - // regex string start with delimiter or whitespace then delimiter - const pattern = "^"+delimiter+"|^\\s"+delimiter; + // regex string start with suggestion delimiter or whitespace then suggestion delimiter + const pattern = "^" + this.suggestionDelimiter + "|^\\s" + this.suggestionDelimiter; const regexStart = new RegExp(pattern, 'g'); // trim any left whitespaces or the left line feed/ carriage return // at the beginning of the string @@ -548,7 +781,7 @@ function factory(dependencies) { if (regexStart.test(searchStr) && searchStr.length) { searchStr = searchStr.replace(pattern, ''); return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr) - ? searchStr.replace(delimiter, '') + ? searchStr.replace(this.suggestionDelimiter, '') : false; } return false; @@ -556,14 +789,23 @@ function factory(dependencies) { } Composer.fields = { + activeSuggestedCannedResponse: many2one('mail.canned_response'), + activeSuggestedChannel: many2one('mail.thread'), + activeSuggestedChannelCommand: many2one('mail.channel_command'), activeSuggestedPartner: many2one('mail.partner'), - allSuggestedPartners: many2many('mail.partner', { - compute: '_computeAllSuggestedPartners', + activeSuggestedRecord: attr({ + compute: '_computeActiveSuggestedRecord', dependencies: [ - 'extraSuggestedPartners', - 'mainSuggestedPartners', + 'activeSuggestedCannedResponse', + 'activeSuggestedChannel', + 'activeSuggestedChannelCommand', + 'activeSuggestedPartner', + 'activeSuggestedRecordName', ], }), + activeSuggestedRecordName: attr({ + default: "", + }), attachments: many2many('mail.attachment', { inverse: 'composers', }), @@ -601,6 +843,20 @@ function factory(dependencies) { 'mainSuggestedPartners', ], }), + extraSuggestedRecordsList: attr({ + compute: '_computeExtraSuggestedRecordsList', + dependencies: [ + 'extraSuggestedPartners', + 'extraSuggestedRecordsListName', + ], + }), + /** + * Allows to have different model types of mentions through a dynamic process + * RPC can provide 2 lists and the second is defined as "extra" + */ + extraSuggestedRecordsListName: attr({ + default: "", + }), /** * This field determines whether some attachments linked to this * composer are being uploaded. @@ -615,10 +871,16 @@ function factory(dependencies) { hasFocus: attr({ default: false, }), - hasSuggestedPartners: attr({ - compute: '_computeHasSuggestedPartners', + hasSuggestions: attr({ + compute: '_computeHasSuggestions', dependencies: [ - 'allSuggestedPartners', + 'extraSuggestedRecordsListName', + 'extraSuggestedPartners', + 'mainSuggestedRecordsListName', + 'mainSuggestedPartners', + 'suggestedCannedResponses', + 'suggestedChannelCommands', + 'suggestedChannels', ], default: false, }), @@ -628,7 +890,28 @@ function factory(dependencies) { isLog: attr({ default: false, }), + mainSuggestedRecordsList: attr({ + compute: '_computeMainSuggestedRecordsList', + dependencies: [ + 'mainSuggestedPartners', + 'mainSuggestedRecordsListName', + 'suggestedCannedResponses', + 'suggestedChannelCommands', + 'suggestedChannels', + ], + }), + /** + * Allows to have different model types of mentions through a dynamic process + * RPC can provide 2 lists and the first is defined as "main" + */ + mainSuggestedRecordsListName: attr({ + default: "", + }), mainSuggestedPartners: many2many('mail.partner'), + mentionedChannels: many2many('mail.thread', { + compute: '_computeMentionedChannels', + dependencies: ['textInputContent'], + }), mentionedPartners: many2many('mail.partner', { compute: '_computeMentionedPartners', dependencies: ['textInputContent'], @@ -666,13 +949,26 @@ function factory(dependencies) { subjectContent: attr({ default: "", }), + suggestedCannedResponses: many2many('mail.canned_response'), + suggestedChannelCommands: many2many('mail.channel_command'), + suggestedChannels: many2many('mail.thread'), + /** + * Special character used to trigger different kinds of suggestions + * such as canned responses (:), channels (#), commands (/) and partners (@) + */ + suggestionDelimiter: attr({ + default: "", + }), + suggestionModelName: attr({ + default: "", + }), textInputContent: attr({ default: "", }), - textInputCursorStart: attr({ + textInputCursorEnd: attr({ default: 0, }), - textInputCursorEnd: attr({ + textInputCursorStart: attr({ default: 0, }), thread: one2one('mail.thread', { diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js index c215d2e5774..a7f7fcf0b6a 100644 --- a/addons/mail/static/src/models/messaging/messaging.js +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -2,7 +2,7 @@ odoo.define('mail/static/src/models/messaging/messaging.js', function (require) 'use strict'; const { registerNewModel } = require('mail/static/src/model/model_core.js'); -const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); function factory(dependencies) { @@ -158,17 +158,13 @@ function factory(dependencies) { } Messaging.fields = { - cannedResponses: attr({ - default: {}, - }), + cannedResponses: one2many('mail.canned_response'), chatWindowManager: one2one('mail.chat_window_manager', { default: [['create']], inverse: 'messaging', isCausal: true, }), - commands: attr({ - default: {}, - }), + commands: one2many('mail.channel_command'), currentPartner: one2one('mail.partner'), currentUser: one2one('mail.user'), device: one2one('mail.device', { diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js index 4daa33bb374..b258e5502a3 100644 --- a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -128,20 +128,12 @@ function factory(dependencies) { /** * @private - * @param {Object[]} shortcodes + * @param {Object[]} cannedResponsesData */ - _initCannedResponses(shortcodes) { - const messaging = this.messaging; - const cannedResponses = shortcodes - .map(s => { - const { id, source, substitution } = s; - return { id, source, substitution }; - }) - .reduce((obj, cr) => { - obj[cr.id] = cr; - return obj; - }, {}); - messaging.update({ cannedResponses }); + _initCannedResponses(cannedResponsesData) { + this.messaging.update({ + cannedResponses: [['insert', cannedResponsesData]], + }); } /** @@ -172,18 +164,9 @@ function factory(dependencies) { * @param {Object[]} commandsData */ _initCommands(commandsData) { - const messaging = this.messaging; - const commands = commandsData - .map(command => { - return Object.assign({ - id: command.name, - }, command); - }) - .reduce((obj, command) => { - obj[command.id] = command; - return obj; - }, {}); - messaging.update({ commands }); + this.messaging.update({ + commands: [['insert', commandsData]], + }); } /** diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index 6eaed09ad8b..5304130345e 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -561,7 +561,11 @@ function factory(dependencies) { return; } this.update({ pendingSeenMessageId: messageId }); - return this.env.models['mail.thread'].performRpcChannelSeen({ ids: [this.id], lastMessageId: messageId }); + return this.env.models['mail.thread'].performRpcChannelSeen({ + ids: [this.id], + // commands have fake message id that is not integer + lastMessageId: Math.floor(messageId), + }); } /** diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js index d46a205d558..83ac74008a3 100644 --- a/addons/mail/static/tests/helpers/mock_models.js +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -170,6 +170,13 @@ class MockModels { }, records: [], }, + 'mail.shortcode': { + fields: { + source: { type: 'char' }, + substitution: { type: 'char' }, + }, + records: [], + }, 'res.country': { fields: { code: { string: "Code", type: 'char' }, diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 28618fcd6cd..02862b9a5e5 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -233,8 +233,13 @@ MockServer.include({ return this._mockMailMessageUnstarAll(); } // res.partner methods - if (args.model === 'res.partner' && args.method === 'get_mention_suggestions') { - return this._mockResPartnerGetMentionSuggestions(args); + if (args.method === 'get_mention_suggestions') { + if (args.model === 'mail.channel') { + return this._mockMailChannelGetMentionSuggestions(args); + } + if (args.model === 'res.partner') { + return this._mockResPartnerGetMentionSuggestions(args); + } } if (args.model === 'res.partner' && args.method === 'im_search') { const name = args.args[0] || args.kwargs.search; @@ -388,6 +393,8 @@ MockServer.include({ const mailFailures = this._mockMailMessageMessageFetchFailed(); + const shortcodes = this._getRecords('mail.shortcode', []); + const starredCounter = this._getRecords('mail.message', [ ['starred_partner_ids', 'in', this.currentPartnerId], ]).length; @@ -398,7 +405,11 @@ MockServer.include({ channel_direct_message: directMessageInfos, channel_private_group: privateGroupInfos, }, - commands: [], + commands: [{ + channel_types: ["channel", "chat"], + help: "List users in the current channel", + name: "who", + }], current_partner: currentPartnerFormat, current_user_id: this.currentUserId, mail_failures: mailFailures, @@ -409,7 +420,7 @@ MockServer.include({ needaction_inbox_counter, partner_root: partnerRootFormat, public_partner: publicPartnerFormat, - shortcodes: [], + shortcodes, starred_counter: starredCounter, }; }, @@ -681,7 +692,6 @@ MockServer.include({ // is supposed to return this existing chat. But the mock is currently // always creating a new chat, because no test is relying on receiving // an existing chat. - const id = this._mockCreate('mail.channel', { channel_type: 'chat', mass_mailing: false, @@ -854,6 +864,56 @@ MockServer.include({ throw new Error(`mail/mock_server: the route execute_command doesn't implement the command "${commandName}"`); }, /** + * Simulates `get_mention_suggestions` on `mail.channel`. + * + * @private + * @returns {Array[]} + */ + _mockMailChannelGetMentionSuggestions(args) { + const search = args.kwargs.search || ''; + const limit = args.kwargs.limit || 8; + + /** + * Returns the given list of channels after filtering it according to + * the logic of the Python method `get_mention_suggestions` for the + * given search term. The result is truncated to the given limit and + * formatted as expected by the original method. + * + * @param {Object[]} channels + * @param {string} search + * @param {integer} limit + * @returns {Object[]} + */ + const mentionSuggestionsFilter = function (channels, search, limit) { + const matchingChannels = channels + .filter(channel => { + // no search term is considered as return all + if (!search) { + return true; + } + // otherwise name or email must match search term + if (channel.name && channel.name.includes(search)) { + return true; + } + return false; + }).map(channel => { + // expected format + return { + id: channel.id, + name: channel.name, + public: channel.public, + }; + }); + // reduce results to max limit + matchingChannels.length = Math.min(matchingChannels.length, limit); + return matchingChannels; + }; + + const mentionSuggestions = mentionSuggestionsFilter(this.data['mail.channel'].records, search, limit); + + return mentionSuggestions; + }, + /** * Simulates `message_post` on `mail.channel`. * * For simplicity this mock handles a simple case in regard to moderation: diff --git a/addons/mail/views/assets.xml b/addons/mail/views/assets.xml index e1744fe11e7..e1326cc0e09 100644 --- a/addons/mail/views/assets.xml +++ b/addons/mail/views/assets.xml @@ -77,6 +77,8 @@ <script type="text/javascript" src="/mail/static/src/components/composer/composer.js"></script> <script type="text/javascript" src="/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js"/> <script type="text/javascript" src="/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.js"/> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion.js"/> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js"/> <script type="text/javascript" src="/mail/static/src/components/composer_text_input/composer_text_input.js"></script> <script type="text/javascript" src="/mail/static/src/components/dialog/dialog.js"></script> <script type="text/javascript" src="/mail/static/src/components/dialog_manager/dialog_manager.js"></script> @@ -109,7 +111,6 @@ <script type="text/javascript" src="/mail/static/src/components/notification_popover/notification_popover.js"></script> <script type="text/javascript" src="/mail/static/src/components/notification_request/notification_request.js"/> <script type="text/javascript" src="/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js"></script> - <script type="text/javascript" src="/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_icon/thread_icon.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_preview/thread_preview.js"></script> @@ -124,6 +125,8 @@ <script type="text/javascript" src="/mail/static/src/models/activity_type/activity_type.js"></script> <script type="text/javascript" src="/mail/static/src/models/attachment/attachment.js"></script> <script type="text/javascript" src="/mail/static/src/models/attachment_viewer/attachment_viewer.js"></script> + <script type="text/javascript" src="/mail/static/src/models/canned_response/canned_response.js"/> + <script type="text/javascript" src="/mail/static/src/models/channel_command/channel_command.js"/> <script type="text/javascript" src="/mail/static/src/models/chat_window/chat_window.js"></script> <script type="text/javascript" src="/mail/static/src/models/chat_window_manager/chat_window_manager.js"></script> <script type="text/javascript" src="/mail/static/src/models/chatter/chatter.js"></script> @@ -188,6 +191,8 @@ <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer/composer.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer_suggested_recipient_list/composer_suggested_recipient_list.scss"/> + <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer_suggestion/composer_suggestion.scss"/> + <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/composer_text_input/composer_text_input.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/dialog/dialog.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/discuss/discuss.scss"/> @@ -214,7 +219,6 @@ <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/notification_popover/notification_popover.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/notification_request/notification_request.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss"/> - <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/thread_icon/thread_icon.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss"/> <link rel="stylesheet" type="text/scss" href="/mail/static/src/components/thread_preview/thread_preview.scss"/> @@ -270,6 +274,10 @@ <script type="text/javascript" src="/mail/static/src/components/chatter/chatter_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/composer/composer_tests.js"></script> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js"/> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js"/> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js"/> + <script type="text/javascript" src="/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js"/> <script type="text/javascript" src="/mail/static/src/components/dialog_manager/dialog_manager_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_inbox_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/discuss/tests/discuss_moderation_tests.js"></script> @@ -286,7 +294,6 @@ <script type="text/javascript" src="/mail/static/src/components/notification_list/notification_list_notification_group_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/notification_list/notification_list_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js"></script> - <script type="text/javascript" src="/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_icon/thread_icon_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_needaction_preview/thread_needaction_preview_tests.js"></script> <script type="text/javascript" src="/mail/static/src/components/thread_preview/thread_preview_tests.js"/> |
