summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/im_livechat/static/src/components/composer/composer_tests.js9
-rw-r--r--addons/mail/__manifest__.py3
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.js2
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js13
-rw-r--r--addons/mail/static/src/components/chatter/chatter_tests.js4
-rw-r--r--addons/mail/static/src/components/composer/composer.js9
-rw-r--r--addons/mail/static/src/components/composer/composer.xml1
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js668
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.js101
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.scss (renamed from addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss)14
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.xml32
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_canned_response_tests.js154
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_channel_tests.js141
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_command_tests.js151
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion_partner_tests.js (renamed from addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js)67
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js74
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.scss11
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml32
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js66
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml35
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js15
-rw-r--r--addons/mail/static/src/components/message_list/message_list.js4
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js79
-rw-r--r--addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml19
-rw-r--r--addons/mail/static/src/models/canned_response/canned_response.js31
-rw-r--r--addons/mail/static/src/models/channel_command/channel_command.js33
-rw-r--r--addons/mail/static/src/models/composer/composer.js528
-rw-r--r--addons/mail/static/src/models/messaging/messaging.js10
-rw-r--r--addons/mail/static/src/models/messaging_initializer/messaging_initializer.js33
-rw-r--r--addons/mail/static/src/models/thread/thread.js6
-rw-r--r--addons/mail/static/tests/helpers/mock_models.js7
-rw-r--r--addons/mail/static/tests/helpers/mock_server.js70
-rw-r--r--addons/mail/views/assets.xml13
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("#[^ ]+(?= |&nbsp;|$)", '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("@"+'[^ ]+(?= |&nbsp;|$)', 'g'));
+ const inputMentions = body.match(new RegExp("(@|#)" + '[^ ]+(?= |&nbsp;|$)', '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"/>