summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/composer/composer_tests.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/components/composer/composer_tests.js')
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js2153
1 files changed, 2153 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js
new file mode 100644
index 00000000..a4ff5978
--- /dev/null
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -0,0 +1,2153 @@
+odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ dropFiles,
+ nextAnimationFrame,
+ pasteFiles,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+const {
+ file: {
+ createFile,
+ inputFiles,
+ },
+ makeTestPromise,
+} = require('web.test_utils');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer', {}, function () {
+QUnit.module('composer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const props = Object.assign({ composerLocalId: composer.localId }, otherProps);
+ await createRootComponent(this, components.Composer, {
+ props,
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('composer text input: basic rendering when posting a message', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer').length,
+ 1,
+ "should have composer in discuss thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer_textInput').length,
+ 1,
+ "should have text input inside discuss thread composer"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'),
+ "composer text input of composer should be a ComposerTextIput component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ComposerTextInput_textarea`).length,
+ 1,
+ "should have editable part inside composer text input"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).placeholder,
+ "Send a message to followers...",
+ "should have 'Send a message to followers...' as placeholder composer text input"
+ );
+});
+
+QUnit.test('composer text input: basic rendering when logging note', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: true }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer').length,
+ 1,
+ "should have composer in discuss thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer_textInput').length,
+ 1,
+ "should have text input inside discuss thread composer"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'),
+ "composer text input of composer should be a ComposerTextIput component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ComposerTextInput_textarea`).length,
+ 1,
+ "should have editable part inside composer text input"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).placeholder,
+ "Log an internal note...",
+ "should have 'Log an internal note...' as placeholder in composer text input if composer is log"
+ );
+});
+
+QUnit.test('composer text input: basic rendering when linked thread is a mail.channel', async function (assert) {
+ assert.expect(5);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer').length,
+ 1,
+ "should have composer in discuss thread"
+ );
+ assert.strictEqual(
+ document.querySelectorAll('.o_Composer_textInput').length,
+ 1,
+ "should have text input inside discuss thread composer"
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'),
+ "composer text input of composer should be a ComposerTextIput component"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_ComposerTextInput_textarea`).length,
+ 1,
+ "should have editable part inside composer text input"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).placeholder,
+ "Write something...",
+ "should have 'Write something...' as placeholder in composer text input if composer is for a 'mail.channel'"
+ );
+});
+
+QUnit.test('mailing channel composer: basic rendering', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be rendered, with proper mass_mailing
+ // value and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerTextInput',
+ "Composer should have a text input"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_subjectInput',
+ "Composer should have a subject input"
+ );
+});
+
+QUnit.test('add an emoji', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ 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,
+ "😊",
+ "emoji should be inserted in the composer text input"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add an emoji after some text', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ 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,
+ "Blabla😊",
+ "emoji should be inserted after the text"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`);
+ await afterNextRender(() => {
+ composerTextInputTextArea.focus();
+ document.execCommand('insertText', false, "Blabla");
+ });
+ assert.strictEqual(
+ composerTextInputTextArea.value,
+ "Blabla",
+ "composer text input should have text only initially"
+ );
+
+ // simulate selection of all the content by keyboard
+ composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length);
+
+ // 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,
+ "😊",
+ "whole text selection should have been replaced by emoji"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+ await nextAnimationFrame();
+});
+
+QUnit.test('display canned response suggestions on typing ":"', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "Canned responses suggestions list should not be present"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, ":");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.hasClass(
+ document.querySelector('.o_ComposerSuggestionList_list'),
+ 'show',
+ "should display canned response suggestions on typing ':'"
+ );
+});
+
+QUnit.test('use a canned response', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestionList_list',
+ "canned response suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, ":");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should have a canned response suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "Hello! How are you? ",
+ "text content of composer should have canned response + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('use a canned response some text', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "canned response suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ await afterNextRender(() =>
+ document.execCommand('insertText', false, "bluhbluh ")
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "bluhbluh ",
+ "text content of composer should have content"
+ );
+ await afterNextRender(() => {
+ document.execCommand('insertText', false, ":");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should have a canned response suggestion"
+ );
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
+ "bluhbluh Hello! How are you? ",
+ "text content of composer should have previous content + canned response substitution + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a canned response', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.shortcode'].records.push({
+ id: 11,
+ source: "hello",
+ substitution: "Hello! How are you?",
+ });
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "canned response suggestions list should not be present"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "text content of composer should be empty initially"
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, ":");
+ 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, "#");
+ 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, "#");
+ 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, "#");
+ 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);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.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('do not send typing notification on typing "/" command', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.channel_command'].records.push({
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ 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.verifySteps([], "No rpc done");
+});
+
+QUnit.test('do not send typing notification on typing after selecting suggestion from "/" command', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ this.data['mail.channel_command'].records.push({
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ 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'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, " is user?");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.verifySteps([], "No rpc done");
+});
+
+QUnit.test('use a command for a specific channel type', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.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, "/");
+ 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("channel with no commands should not prompt any command suggestions on typing /", async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ channel_type: 'chat', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "bla bla bla",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ document.execCommand('insertText', false, "/");
+ const composer_text_input = document.querySelector('.o_ComposerTextInput_textarea');
+ composer_text_input.dispatchEvent(new window.KeyboardEvent('keydown'));
+ composer_text_input.dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should not prompt (command) suggestion after typing / (reason: no channel commands in chat channels)"
+ );
+});
+
+QUnit.test('command suggestion should only open if command is the first character', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.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, "/");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerSuggestion',
+ "should not have a command suggestion"
+ );
+});
+
+QUnit.test('add an emoji after a command', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ channel_type: 'channel', id: 20 });
+ this.data['mail.channel_command'].records.push(
+ {
+ channel_types: ['channel'],
+ help: "List users in the current channel",
+ name: "who",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.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, "/");
+ 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_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/, " "),
+ "bluhbluh @TestPartner ",
+ "text content of composer should have previous content + mentioned partner + additional whitespace afterwards"
+ );
+});
+
+QUnit.test('add an emoji after a partner mention', 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"
+ );
+ 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/, " "),
+ "@TestPartner ",
+ "text content of composer should have previous content + mentioned partner + 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/, " "),
+ "@TestPartner 😊",
+ "text content of composer should have previous mention and selected emoji just after"
+ );
+ // ensure popover is closed
+ await nextAnimationFrame();
+});
+
+QUnit.test('composer: add an attachment', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' });
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_attachmentList'),
+ "should have an attachment list"
+ );
+ assert.ok(
+ document.querySelector(`.o_Composer .o_Attachment`),
+ "should have an attachment"
+ );
+});
+
+QUnit.test('composer: drop attachments', async function (assert) {
+ assert.expect(4);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ }),
+ await createFile({
+ content: 'hello, worlduh',
+ contentType: 'text/plain',
+ name: 'text2.txt',
+ }),
+ ];
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have a drop zone"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should have no attachment before files are dropped"
+ );
+
+ await afterNextRender(() =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ files
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 2,
+ "should have 2 attachments in the composer after files dropped"
+ );
+
+ await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer')));
+ await afterNextRender(async () =>
+ dropFiles(
+ document.querySelector('.o_Composer_dropZone'),
+ [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text3.txt',
+ })
+ ]
+ )
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 3,
+ "should have 3 attachments in the box after files dropped"
+ );
+});
+
+QUnit.test('composer: paste attachments', async function (assert) {
+ assert.expect(2);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const files = [
+ await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ })
+ ];
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 0,
+ "should not have any attachment in the composer before paste"
+ );
+
+ await afterNextRender(() =>
+ pasteFiles(document.querySelector('.o_ComposerTextInput'), files)
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Composer .o_Attachment`).length,
+ 1,
+ "should have 1 attachment in the composer after paste"
+ );
+});
+
+QUnit.test('send message when enter is pressed while holding ctrl key (this shortcut is available)', async function (assert) {
+ // Note that test doesn't assert ENTER makes no newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['ctrl-enter'],
+ });
+ // Type message
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable as message has not been posted"
+ );
+
+ // Send message with ctrl+enter
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input as message has been posted"
+ );
+});
+
+QUnit.test('send message when enter is pressed while holding meta key (this shortcut is available)', async function (assert) {
+ // Note that test doesn't assert ENTER makes no newline, because this
+ // default browser cannot be simulated with just dispatching
+ // programmatically crafted events...
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['meta-enter'],
+ });
+ // Type message
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable as message has not been posted"
+ );
+
+ // Send message with meta+enter
+ await afterNextRender(() =>
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', metaKey: true }))
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input as message has been posted"
+ );
+});
+
+QUnit.test('composer text input cleared on message post', async function (assert) {
+ assert.expect(4);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ // Send message
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+});
+
+QUnit.test('composer inputs cleared on message post in composer of a mailing channel', async function (assert) {
+ assert.expect(10);
+
+ // channel that is expected to be rendered, with proper mass_mailing
+ // value and a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20, mass_mailing: true });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ assert.ok(
+ 'body' in args.kwargs,
+ "body should be posted with the message"
+ );
+ assert.strictEqual(
+ args.kwargs.body,
+ "test message",
+ "posted body should be the one typed in text input"
+ );
+ assert.ok(
+ 'subject' in args.kwargs,
+ "subject should be posted with the message"
+ );
+ assert.strictEqual(
+ args.kwargs.subject,
+ "test subject",
+ "posted subject should be the one typed in subject input"
+ );
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "test message",
+ "should have inserted text content in editable"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Composer_subjectInput`).focus();
+ document.execCommand('insertText', false, "test subject");
+ });
+ assert.strictEqual(
+ document.querySelector(`.o_Composer_subjectInput`).value,
+ "test subject",
+ "should have inserted text content in input"
+ );
+
+ // Send message
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.verifySteps(['message_post']);
+ assert.strictEqual(
+ document.querySelector(`.o_ComposerTextInput_textarea`).value,
+ "",
+ "should have no content in composer input after posting message"
+ );
+ assert.strictEqual(
+ document.querySelector(`.o_Composer_subjectInput`).value,
+ "",
+ "should have no content in composer subject input after posting message"
+ );
+});
+
+QUnit.test('composer with thread typing notification status', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_threadTextualTypingStatus',
+ "Composer should have a thread textual typing status bar"
+ );
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "By default, thread textual typing status bar should be empty"
+ );
+});
+
+QUnit.test('current partner notify is typing to other thread members', async function (assert) {
+ assert.expect(2);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+});
+
+QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) {
+ assert.expect(3);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner typing status"
+ );
+
+ await nextAnimationFrame();
+ assert.strictEqual(
+ document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent,
+ "",
+ "Thread textual typing status bar should not display current partner is typing"
+ );
+});
+
+QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) {
+ assert.expect(4);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ await this.env.testUtils.advanceTime(5 * 1000);
+ assert.verifySteps(
+ ['notify_typing:false'],
+ "should have notified current partner no longer is typing (inactive for 5 seconds)"
+ );
+});
+
+QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) {
+ assert.expect(4);
+
+ // channel that is expected to be rendered
+ // with a random unique id that will be referenced in the test
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ hasTimeControl: true,
+ async mockRPC(route, args) {
+ if (args.method === 'notify_typing') {
+ assert.step(`notify_typing:${args.kwargs.is_typing}`);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, { hasThreadTyping: true });
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is typing"
+ );
+
+ // simulate current partner typing a character every 2.5 seconds for 50 seconds straight.
+ let totalTimeElapsed = 0;
+ const elapseTickTime = 2.5 * 1000;
+ while (totalTimeElapsed < 50 * 1000) {
+ document.execCommand('insertText', false, "a");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }));
+ totalTimeElapsed += elapseTickTime;
+ await this.env.testUtils.advanceTime(elapseTickTime);
+ }
+
+ assert.verifySteps(
+ ['notify_typing:true'],
+ "should have notified current partner is still typing after 50s of straight typing"
+ );
+});
+
+QUnit.test('composer: send button is disabled if attachment upload is not finished', async function (assert) {
+ assert.expect(8);
+
+ const attachmentUploadedPromise = makeTestPromise();
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ await attachmentUploadedPromise;
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have an attachment after a file has been input"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed is being uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should be displayed"
+ );
+ assert.ok(
+ !!document.querySelector('.o_Composer_buttonSend').attributes.disabled,
+ "composer send button should be disabled as attachment is not yet uploaded"
+ );
+
+ // simulates attachment finishes uploading
+ await afterNextRender(() => attachmentUploadedPromise.resolve());
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed should be uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should still be present"
+ );
+ assert.ok(
+ !document.querySelector('.o_Composer_buttonSend').attributes.disabled,
+ "composer send button should be enabled as attachment is now uploaded"
+ );
+});
+
+QUnit.test('warning on send with shortcut when attempting to post message with still-uploading attachments', async function (assert) {
+ assert.expect(7);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates attachment is never finished uploading
+ await new Promise(() => {});
+ }
+ return res;
+ },
+ services: {
+ notification: {
+ notify(params) {
+ assert.strictEqual(
+ params.message,
+ "Please wait while the file is uploading.",
+ "notification content should be about the uploading file"
+ );
+ assert.strictEqual(
+ params.type,
+ 'warning',
+ "notification should be a warning"
+ );
+ assert.step('notification');
+ }
+ }
+ },
+ });
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['enter'],
+ });
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment.o-temporary',
+ "attachment displayed is being uploaded"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonSend',
+ "composer send button should be displayed"
+ );
+
+ // Try to send message
+ document
+ .querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' }));
+ assert.verifySteps(
+ ['notification'],
+ "should have triggered a notification for inability to post message at the moment (some attachments are still being uploaded)"
+ );
+});
+
+QUnit.test('remove an attachment from composer does not need any confirmation', async function (assert) {
+ assert.expect(3);
+
+ await this.start();
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_attachmentList',
+ "should have an attachment list"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should have only one attachment"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Attachment_asideItemUnlink').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should not have any attachment left after unlinking the only one"
+ );
+});
+
+QUnit.test('remove an uploading attachment', async function (assert) {
+ assert.expect(4);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates uploading indefinitely
+ await new Promise(() => {});
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_attachmentList',
+ "should have an attachment list"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should have only one attachment"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer .o_Attachment.o-temporary',
+ "should have an uploading attachment"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Attachment_asideItemUnlink').click());
+ assert.containsNone(
+ document.body,
+ '.o_Composer .o_Attachment',
+ "should not have any attachment left after unlinking temporary one"
+ );
+});
+
+QUnit.test('remove an uploading attachment aborts upload', async function (assert) {
+ assert.expect(1);
+
+ await this.start({
+ async mockFetch(resource, init) {
+ const res = this._super(...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ // simulates uploading indefinitely
+ await new Promise(() => {});
+ }
+ return res;
+ }
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file = await createFile({
+ content: 'hello, world',
+ contentType: 'text/plain',
+ name: 'text.txt',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file]
+ )
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment',
+ "should contain an attachment"
+ );
+ const attachmentLocalId = document.querySelector('.o_Attachment').dataset.attachmentLocalId;
+
+ await this.afterEvent({
+ eventName: 'o-attachment-upload-abort',
+ func: () => {
+ document.querySelector('.o_Attachment_asideItemUnlink').click();
+ },
+ message: "attachment upload request should have been aborted",
+ predicate: ({ attachment }) => {
+ return attachment.localId === attachmentLocalId;
+ },
+ });
+});
+
+QUnit.test("basic rendering when sending a message to the followers and thread doesn't have a name", async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ composer: [['create', { isLog: false }]],
+ id: 20,
+ model: 'res.partner',
+ });
+ await this.createComposerComponent(thread.composer, { hasFollowers: true });
+ assert.strictEqual(
+ document.querySelector('.o_Composer_followers').textContent.replace(/\s+/g, ''),
+ "To:Followersofthisdocument",
+ "Composer should display \"To: Followers of this document\" if the thread as no name."
+ );
+});
+
+QUnit.test('send message only once when button send is clicked twice quickly', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Composer_buttonSend`).click();
+ document.querySelector(`.o_Composer_buttonSend`).click();
+ });
+ assert.verifySteps(
+ ['message_post'],
+ "The message has been posted only once"
+ );
+});
+
+QUnit.test('send message only once when enter is pressed twice quickly', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 20 });
+ await this.start({
+ async mockRPC(route, args) {
+ if (args.method === 'message_post') {
+ assert.step('message_post');
+ }
+ return this._super(...arguments);
+ },
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer, {
+ textInputSendShortcuts: ['enter'],
+ });
+ // Type message
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "test message");
+ });
+ await afterNextRender(() => {
+ const enterEvent = new window.KeyboardEvent('keydown', { key: 'Enter' });
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(enterEvent);
+ });
+ assert.verifySteps(
+ ['message_post'],
+ "The message has been posted only once"
+ );
+});
+
+QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) {
+ // Uploading multiple files uploads attachments one at a time, this test
+ // ensures that there is no crash when an attachment is destroyed before its
+ // upload started.
+ assert.expect(1);
+
+ // Promise to block attachment uploading
+ const uploadPromise = makeTestPromise();
+ await this.start({
+ async mockFetch(resource) {
+ const _super = this._super.bind(this, ...arguments);
+ if (resource === '/web/binary/upload_attachment') {
+ await uploadPromise;
+ }
+ return _super();
+ },
+ });
+ const composer = this.env.models['mail.composer'].create();
+ await this.createComposerComponent(composer);
+ const file1 = await createFile({
+ name: 'text1.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ const file2 = await createFile({
+ name: 'text2.txt',
+ content: 'hello, world',
+ contentType: 'text/plain',
+ });
+ await afterNextRender(() =>
+ inputFiles(
+ document.querySelector('.o_FileUploader_input'),
+ [file1, file2]
+ )
+ );
+ await afterNextRender(() => {
+ Array.from(document.querySelectorAll('div'))
+ .find(el => el.textContent === 'text2.txt')
+ .closest('.o_Attachment')
+ .querySelector('.o_Attachment_asideItemUnlink')
+ .click();
+ }
+ );
+ // Simulates the completion of the upload of the first attachment
+ uploadPromise.resolve();
+ assert.containsOnce(
+ document.body,
+ '.o_Attachment:contains("text1.txt")',
+ "should only have the first attachment after cancelling the second attachment"
+ );
+});
+
+});
+});
+});
+
+});