[IMP] mail: reintroduce channel mentions, canned responses, commands

task-2280308

closes odoo/odoo#58167

X-original-commit: b63d0d2ee5f3bff24a5e1e1cd962d59bebef082d
Related: odoo/enterprise#13393
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Julien Giannone
2020-09-21 21:12:22 +00:00
committed by Sébastien Theys
parent 043dcba4e0
commit 9fc3fe1dba
33 changed files with 2034 additions and 421 deletions
@@ -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;
},
});
+2 -1
View File
@@ -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',
@@ -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')) {
@@ -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(
@@ -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,
@@ -261,6 +261,13 @@ class Composer extends Component {
this._postMessage();
}
/**
* @private
*/
_onComposerSuggestionClicked() {
this.focus();
}
/**
* @private
*/
@@ -311,7 +318,7 @@ class Composer extends Component {
*/
_onKeydown(ev) {
if (ev.key === 'Escape') {
if (isEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions')) {
if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) {
return;
}
if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
@@ -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"
@@ -274,56 +274,56 @@ QUnit.test('add emoji replaces (keyboard) text selection', async function (asser
await nextAnimationFrame();
});
QUnit.test('display partner mention suggestions on typing "@"', async function (assert) {
QUnit.test('display canned response suggestions on typing ":"', async function (assert) {
assert.expect(2);
this.data['res.partner'].records.push({
email: "testpartnert@odoo.com",
this.data['mail.shortcode'].records.push({
id: 11,
name: "TestPartner",
source: "hello",
substitution: "Hello! How are you?",
});
this.data['res.users'].records.push({ partner_id: 11 });
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerTextInput_mentionDropdownPropositionList',
"mention suggestions list should not be present"
'.o_ComposerSuggestionList_list',
"Canned responses suggestions list should not be present"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@");
document.execCommand('insertText', false, ":");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.hasClass(
document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'),
document.querySelector('.o_ComposerSuggestionList_list'),
'show',
"should display mention suggestions on typing '@'"
"should display canned response suggestions on typing ':'"
);
});
QUnit.test('mention a partner', async function (assert) {
QUnit.test('use a canned response', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({
email: "testpartnert@odoo.com",
this.data['mail.shortcode'].records.push({
id: 11,
name: "TestPartner",
source: "hello",
substitution: "Hello! How are you?",
});
this.data['res.users'].records.push({ partner_id: 11 });
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerTextInput_mentionDropdownPropositionList',
"mention suggestions list should not be present"
'.o_ComposerSuggestionList_list',
"canned response suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
@@ -332,7 +332,7 @@ QUnit.test('mention a partner', async function (assert) {
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@Test");
document.execCommand('insertText', false, ":");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -342,36 +342,36 @@ QUnit.test('mention a partner', async function (assert) {
});
assert.containsOnce(
document.body,
'.o_PartnerMentionSuggestion',
"should have a mention suggestion"
'.o_ComposerSuggestion',
"should have a canned response suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_PartnerMentionSuggestion').click()
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"@TestPartner ",
"text content of composer should have mentioned partner + additional whitespace afterwards"
"Hello! How are you? ",
"text content of composer should have canned response + additional whitespace afterwards"
);
});
QUnit.test('mention a partner after some text', async function (assert) {
QUnit.test('use a canned response some text', async function (assert) {
assert.expect(5);
this.data['res.partner'].records.push({
email: "testpartnert@odoo.com",
this.data['mail.shortcode'].records.push({
id: 11,
name: "TestPartner",
source: "hello",
substitution: "Hello! How are you?",
});
this.data['res.users'].records.push({ partner_id: 11 });
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerTextInput_mentionDropdownPropositionList',
"mention suggestions list should not be present"
'.o_ComposerSuggestion',
"canned response suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
@@ -388,7 +388,7 @@ QUnit.test('mention a partner after some text', async function (assert) {
"text content of composer should have content"
);
await afterNextRender(() =>
document.execCommand('insertText', false, "@Test")
document.execCommand('insertText', false, ":")
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -398,11 +398,609 @@ QUnit.test('mention a partner after some text', async function (assert) {
});
assert.containsOnce(
document.body,
'.o_PartnerMentionSuggestion',
'.o_ComposerSuggestion',
"should have a canned response suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"bluhbluh Hello! How are you? ",
"text content of composer should have previous content + canned response substitution + additional whitespace afterwards"
);
});
QUnit.test('add an emoji after a canned response', async function (assert) {
assert.expect(5);
this.data['mail.shortcode'].records.push({
id: 11,
source: "hello",
substitution: "Hello! How are you?",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"canned response suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, ":");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a canned response suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"Hello! How are you? ",
"text content of composer should have previous content + canned response substitution + additional whitespace afterwards"
);
// select emoji
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonEmojis').click()
);
await afterNextRender(() =>
document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"Hello! How are you? 😊",
"text content of composer should have previous canned response substitution and selected emoji just after"
);
// ensure popover is closed
await nextAnimationFrame();
});
QUnit.test('display channel mention suggestions on typing "#"', async function (assert) {
assert.expect(2);
this.data['mail.channel'].records.push({
id: 7,
name: "General",
public: "groups",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"channel mention suggestions list should not be present"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "#");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.hasClass(
document.querySelector('.o_ComposerSuggestionList_list'),
'show',
"should display channel mention suggestions on typing '#'"
);
});
QUnit.test('mention a channel', async function (assert) {
assert.expect(4);
this.data['mail.channel'].records.push({
id: 7,
name: "General",
public: "groups",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"channel mention suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "#");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a channel mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"#General ",
"text content of composer should have mentioned channel + additional whitespace afterwards"
);
});
QUnit.test('mention a channel after some text', async function (assert) {
assert.expect(5);
this.data['mail.channel'].records.push({
id: 7,
name: "General",
public: "groups",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"channel mention suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
await afterNextRender(() =>
document.execCommand('insertText', false, "bluhbluh ")
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"bluhbluh ",
"text content of composer should have content"
);
await afterNextRender(() =>
document.execCommand('insertText', false, "#")
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a channel mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"bluhbluh #General ",
"text content of composer should have previous content + mentioned channel + additional whitespace afterwards"
);
});
QUnit.test('add an emoji after a channel mention', async function (assert) {
assert.expect(5);
this.data['mail.channel'].records.push({
id: 7,
name: "General",
public: "groups",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"mention suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "#");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a channel mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"#General ",
"text content of composer should have previous content + mentioned channel + additional whitespace afterwards"
);
// select emoji
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonEmojis').click()
);
await afterNextRender(() =>
document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"#General 😊",
"text content of composer should have previous channel mention and selected emoji just after"
);
// ensure popover is closed
await nextAnimationFrame();
});
QUnit.test('display command suggestions on typing "/"', async function (assert) {
assert.expect(2);
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"command suggestions list should not be present"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "/");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.hasClass(
document.querySelector('.o_ComposerSuggestionList_list'),
'show',
"should display command suggestions on typing '/'"
);
});
QUnit.test('use a command', async function (assert) {
assert.expect(4);
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"command suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "/");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a command suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"/who ",
"text content of composer should have used command + additional whitespace afterwards"
);
});
QUnit.test('use a command after some text', async function (assert) {
assert.expect(5);
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"command suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
await afterNextRender(() =>
document.execCommand('insertText', false, "bluhbluh ")
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"bluhbluh ",
"text content of composer should have content"
);
await afterNextRender(() =>
document.execCommand('insertText', false, "/")
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a command suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"bluhbluh /who ",
"text content of composer should have previous content + used command + additional whitespace afterwards"
);
});
QUnit.test('add an emoji after a command', async function (assert) {
assert.expect(5);
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"command suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "/");
});
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a command suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"/who ",
"text content of composer should have previous content + used command + additional whitespace afterwards"
);
// select emoji
await afterNextRender(() =>
document.querySelector('.o_Composer_buttonEmojis').click()
);
await afterNextRender(() =>
document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"/who 😊",
"text content of composer should have previous command and selected emoji just after"
);
// ensure popover is closed
await nextAnimationFrame();
});
QUnit.test('display partner mention suggestions on typing "@"', async function (assert) {
assert.expect(3);
this.data['res.partner'].records.push({
id: 11,
email: "testpartner@odoo.com",
name: "TestPartner",
});
this.data['res.partner'].records.push({
id: 12,
email: "testpartner2@odoo.com",
name: "TestPartner2",
});
this.data['res.users'].records.push({
partner_id: 11,
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"mention suggestions list should not be present"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.hasClass(
document.querySelector('.o_ComposerSuggestionList_list'),
'show',
"should display mention suggestions on typing '@'"
);
assert.containsOnce(
document.body,
'.dropdown-divider',
"should have a separator"
);
});
QUnit.test('mention a partner', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({
email: "testpartner@odoo.com",
name: "TestPartner",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestionList_list',
"mention suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "T");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "e");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_PartnerMentionSuggestion').click()
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
"@TestPartner ",
"text content of composer should have mentioned partner + additional whitespace afterwards"
);
});
QUnit.test('mention a partner after some text', async function (assert) {
assert.expect(5);
this.data['res.partner'].records.push({
email: "testpartner@odoo.com",
name: "TestPartner",
});
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerSuggestion',
"mention suggestions list should not be present"
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"",
"text content of composer should be empty initially"
);
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
await afterNextRender(() =>
document.execCommand('insertText', false, "bluhbluh ")
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value,
"bluhbluh ",
"text content of composer should have content"
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "T");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "e");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
});
assert.containsOnce(
document.body,
'.o_ComposerSuggestion',
"should have a mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
@@ -415,18 +1013,16 @@ QUnit.test('add an emoji after a partner mention', async function (assert) {
assert.expect(5);
this.data['res.partner'].records.push({
email: "testpartnert@odoo.com",
id: 11,
email: "testpartner@odoo.com",
name: "TestPartner",
});
this.data['res.users'].records.push({ partner_id: 11 });
await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
assert.containsNone(
document.body,
'.o_ComposerTextInput_mentionDropdownPropositionList',
'.o_ComposerSuggestion',
"mention suggestions list should not be present"
);
assert.strictEqual(
@@ -436,9 +1032,17 @@ QUnit.test('add an emoji after a partner mention', async function (assert) {
);
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "@Test");
});
await afterNextRender(() => {
document.execCommand('insertText', false, "@");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "T");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keyup'));
document.execCommand('insertText', false, "e");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -446,11 +1050,11 @@ QUnit.test('add an emoji after a partner mention', async function (assert) {
});
assert.containsOnce(
document.body,
'.o_PartnerMentionSuggestion',
'.o_ComposerSuggestion',
"should have a mention suggestion"
);
await afterNextRender(() =>
document.querySelector('.o_PartnerMentionSuggestion').click()
document.querySelector('.o_ComposerSuggestion').click()
);
assert.strictEqual(
document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
@@ -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;
});
@@ -2,13 +2,13 @@
// Layout
// ------------------------------------------------------------------
.o_PartnerMentionSuggestion {
.o_ComposerSuggestion {
display: flex;
width: map-get($sizes, 100);
padding: map-get($spacers, 2) map-get($spacers, 4);
}
.o_PartnerMentionSuggestion_name {
.o_ComposerSuggestion_part1 {
padding-inline-end: map-get($spacers, 2);
}
@@ -16,11 +16,11 @@
// Style
// ------------------------------------------------------------------
.o_PartnerMentionSuggestion_email {
.o_ComposerSuggestion_part1 {
font-weight: $font-weight-bold;
}
.o_ComposerSuggestion_part2 {
font-style: italic;
color: $gray-600;
}
.o_PartnerMentionSuggestion_name {
font-weight: $font-weight-bold;
}
@@ -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>
@@ -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"
);
});
});
});
});
});
@@ -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"
);
});
});
});
});
});
@@ -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"
);
});
});
});
});
});
@@ -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"
);
@@ -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;
});
@@ -0,0 +1,11 @@
// ------------------------------------------------------------------
// Layout
// ------------------------------------------------------------------
.o_ComposerSuggestionList {
position: absolute;
&.o-lowPosition {
bottom: 0;
}
}
@@ -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>
@@ -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, {
@@ -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>
@@ -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(
@@ -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) {
@@ -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;
});
@@ -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>
@@ -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);
});
@@ -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);
});
+412 -116
View File
@@ -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[]}
@@ -330,12 +439,24 @@ function factory(dependencies) {
return [['unlink', this.mainSuggestedPartners]];
}
/**
* @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;
}
/**
@@ -346,6 +467,16 @@ function factory(dependencies) {
return this.attachments.some(attachment => attachment.isTemporary);
}
/**
* @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);
@@ -456,11 +603,109 @@ function factory(dependencies) {
return undefined;
}
/**
* @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
*/
async _getSuggestedPartners(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 _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.allSuggestedPartners[0]]],
activeSuggestedPartner: [['link', this.mainSuggestedPartners[0]]],
});
} else if (this.extraSuggestedPartners[0]) {
this.update({
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', {
@@ -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', {
@@ -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]],
});
}
/**
@@ -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),
});
}
/**
@@ -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' },
@@ -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,
@@ -853,6 +863,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`.
*
+10 -3
View File
@@ -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"/>