From e6b33232375a0bf28dc63ebe8d2465c5be97f4ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Fri, 5 May 2023 11:44:27 +0000 Subject: [PATCH] [REF] mail: move channel commands to discuss folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of task-3265211 closes odoo/odoo#120429 Signed-off-by: Alexandre Kühn (aku) --- .../src/composer/suggestion_service_patch.js | 2 +- addons/mail/static/src/composer/composer.js | 2 +- .../static/src/composer/suggestion_hook.js | 19 +++-- .../static/src/composer/suggestion_service.js | 69 +--------------- .../mail/static/src/core/message_service.js | 14 ---- addons/mail/static/src/core/thread_service.js | 7 -- .../src/{core => discuss}/channel_commands.js | 0 .../mail/static/src/discuss/composer_patch.js | 29 +++++++ .../src/discuss/suggestion_service_patch.js | 82 +++++++++++++++++++ .../src/discuss/thread_service_patch.js | 29 +++++++ .../static/tests/composer/composer_tests.js | 58 ------------- .../static/tests/discuss/composer_tests.js | 75 +++++++++++++++++ .../static/tests/discuss/suggestion_tests.js | 64 +++++++++++++++ .../tests/suggestion/suggestion_tests.js | 48 ----------- 14 files changed, 294 insertions(+), 204 deletions(-) rename addons/mail/static/src/{core => discuss}/channel_commands.js (100%) create mode 100644 addons/mail/static/src/discuss/composer_patch.js create mode 100644 addons/mail/static/src/discuss/suggestion_service_patch.js create mode 100644 addons/mail/static/src/discuss/thread_service_patch.js create mode 100644 addons/mail/static/tests/discuss/composer_tests.js create mode 100644 addons/mail/static/tests/discuss/suggestion_tests.js diff --git a/addons/im_livechat/static/src/composer/suggestion_service_patch.js b/addons/im_livechat/static/src/composer/suggestion_service_patch.js index 83adf2ba677..e0c74592276 100644 --- a/addons/im_livechat/static/src/composer/suggestion_service_patch.js +++ b/addons/im_livechat/static/src/composer/suggestion_service_patch.js @@ -8,7 +8,7 @@ import { _t } from "@web/core/l10n/translation"; patch(SuggestionService.prototype, "im_livechat", { getSupportedDelimiters(thread) { return thread?.type === "livechat" - ? [...this._super(...arguments), ":"] + ? [...this._super(...arguments), [":"]] : this._super(...arguments); }, /** diff --git a/addons/mail/static/src/composer/composer.js b/addons/mail/static/src/composer/composer.js index 45ae3ccbc02..7b0728ad0d0 100644 --- a/addons/mail/static/src/composer/composer.js +++ b/addons/mail/static/src/composer/composer.js @@ -179,7 +179,7 @@ export class Composer extends Component { } onInput(ev) { - if (!this.typingNotified && !ev.target.value.startsWith("/")) { + if (!this.typingNotified && ev.target.value) { this.notifyIsTyping(); this.typingNotified = true; browser.setTimeout(() => { diff --git a/addons/mail/static/src/composer/suggestion_hook.js b/addons/mail/static/src/composer/suggestion_hook.js index e696e8fc547..aa704d9baf1 100644 --- a/addons/mail/static/src/composer/suggestion_hook.js +++ b/addons/mail/static/src/composer/suggestion_hook.js @@ -38,18 +38,20 @@ export function useSuggestion() { if (selectionStart > 0) { candidatePositions.push(selectionStart - 1); } + const supportedDelimiters = suggestionService.getSupportedDelimiters( + comp.props.composer.thread + ); for (const candidatePosition of candidatePositions) { if (candidatePosition < 0 || candidatePosition >= content.length) { continue; } const candidateChar = content[candidatePosition]; - if (candidateChar === "/" && candidatePosition !== 0) { - continue; - } if ( - !suggestionService - .getSupportedDelimiters(comp.props.composer.thread) - .includes(candidateChar) + !supportedDelimiters.find( + ([delimiter, allowedPosition]) => + delimiter === candidateChar && + (allowedPosition === undefined || allowedPosition === candidatePosition) + ) ) { continue; } @@ -124,10 +126,11 @@ export function useSuggestion() { { thread: comp.props.composer.thread }, true ); - if (!suggestions) { + const { type, mainSuggestions, extraSuggestions = [] } = suggestions; + if (!mainSuggestions.length && !extraSuggestions.length) { + self.clearSearch(); return; } - const { type, mainSuggestions, extraSuggestions = [] } = suggestions; // arbitrary limit to avoid displaying too many elements at once // ideally a load more mechanism should be introduced const limit = 8; diff --git a/addons/mail/static/src/composer/suggestion_service.js b/addons/mail/static/src/composer/suggestion_service.js index 3d89726da61..31743792df3 100644 --- a/addons/mail/static/src/composer/suggestion_service.js +++ b/addons/mail/static/src/composer/suggestion_service.js @@ -3,8 +3,6 @@ import { cleanTerm } from "@mail/utils/format"; import { registry } from "@web/core/registry"; -const commandRegistry = registry.category("discuss.channel_commands"); - export class SuggestionService { constructor(env, services) { this.orm = services.orm; @@ -19,7 +17,7 @@ export class SuggestionService { } getSupportedDelimiters(thread) { - return ["@", "#", "/"]; + return [["@"], ["#"]]; } async fetchSuggestions({ delimiter, term }, { thread, onFetched } = {}) { @@ -32,8 +30,6 @@ export class SuggestionService { case "#": this.fetchThreads(cleanedSearchTerm).then(onFetched); break; - case "/": - break; } } @@ -81,7 +77,7 @@ export class SuggestionService { * Returns suggestions that match the given search term from specified type. * * @param {Object} [param0={}] - * @param {String} [param0.delimiter] can be one one of the following: ["@", "#", "/"] + * @param {String} [param0.delimiter] can be one one of the following: ["@", "#"] * @param {String} [param0.term] * @param {Object} [options={}] * @param {Integer} [options.thread] prioritize and/or restrict @@ -96,8 +92,6 @@ export class SuggestionService { } case "#": return this.searchChannelSuggestions(cleanedSearchTerm, thread, sort); - case "/": - return this.searchChannelCommand(cleanedSearchTerm, thread, sort); } return { type: undefined, @@ -106,65 +100,6 @@ export class SuggestionService { }; } - searchChannelCommand(cleanedSearchTerm, thread, sort) { - if (!thread.isChannel) { - // channel commands are channel specific - return; - } - const commands = commandRegistry - .getEntries() - .filter(([name, command]) => { - if (!cleanTerm(name).includes(cleanedSearchTerm)) { - return false; - } - if (command.channel_types) { - return command.channel_types.includes(thread.type); - } - return true; - }) - .map(([name, command]) => { - return { - channel_types: command.channel_types, - help: command.help, - id: command.id, - name, - }; - }); - const sortFunc = (c1, c2) => { - if (c1.channel_types && !c2.channel_types) { - return -1; - } - if (!c1.channel_types && c2.channel_types) { - return 1; - } - const cleanedName1 = cleanTerm(c1.name || ""); - const cleanedName2 = cleanTerm(c2.name || ""); - if ( - cleanedName1.startsWith(cleanedSearchTerm) && - !cleanedName2.startsWith(cleanedSearchTerm) - ) { - return -1; - } - if ( - !cleanedName1.startsWith(cleanedSearchTerm) && - cleanedName2.startsWith(cleanedSearchTerm) - ) { - return 1; - } - if (cleanedName1 < cleanedName2) { - return -1; - } - if (cleanedName1 > cleanedName2) { - return 1; - } - return c1.id - c2.id; - }; - return { - type: "ChannelCommand", - mainSuggestions: sort ? commands.sort(sortFunc) : commands, - }; - } - searchPartnerSuggestions(cleanedSearchTerm, thread, sort) { let partners; const isNonPublicChannel = diff --git a/addons/mail/static/src/core/message_service.js b/addons/mail/static/src/core/message_service.js index e78d6ca2366..c758c22aca0 100644 --- a/addons/mail/static/src/core/message_service.js +++ b/addons/mail/static/src/core/message_service.js @@ -10,8 +10,6 @@ import { LinkPreview } from "./link_preview_model"; import { NotificationGroup } from "./notification_group_model"; import { assignDefined, createLocalId } from "../utils/misc"; -const commandRegistry = registry.category("discuss.channel_commands"); - const { DateTime } = luxon; export class MessageService { @@ -66,18 +64,6 @@ export class MessageService { }); } - getCommandFromText(thread, content) { - if (content.startsWith("/")) { - const firstWord = content.substring(1).split(/\s/)[0]; - const command = commandRegistry.get(firstWord, false); - if (command) { - return command.channel_types?.includes(thread.type) || thread.isChannel - ? command - : false; - } - } - } - /** * @returns {number} */ diff --git a/addons/mail/static/src/core/thread_service.js b/addons/mail/static/src/core/thread_service.js index a3665209186..b76676fe150 100644 --- a/addons/mail/static/src/core/thread_service.js +++ b/addons/mail/static/src/core/thread_service.js @@ -879,13 +879,6 @@ export class ThreadService { * @param {string} body */ async post(thread, body, { attachments = [], isNote = false, parentId, rawMentions }) { - const command = this.store.user - ? this.messageService.getCommandFromText(thread, body) - : undefined; - if (command) { - await this.executeCommand(thread, command, body); - return; - } let tmpMsg; const subtype = isNote ? "mail.mt_note" : "mail.mt_comment"; const validMentions = this.store.user diff --git a/addons/mail/static/src/core/channel_commands.js b/addons/mail/static/src/discuss/channel_commands.js similarity index 100% rename from addons/mail/static/src/core/channel_commands.js rename to addons/mail/static/src/discuss/channel_commands.js diff --git a/addons/mail/static/src/discuss/composer_patch.js b/addons/mail/static/src/discuss/composer_patch.js new file mode 100644 index 00000000000..cfb47839b3d --- /dev/null +++ b/addons/mail/static/src/discuss/composer_patch.js @@ -0,0 +1,29 @@ +/* @odoo-module */ + +import { Composer } from "@mail/composer/composer"; +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +const commandRegistry = registry.category("discuss.channel_commands"); + +patch(Composer.prototype, "discuss", { + /** + * @override + */ + onInput(ev) { + if (this.thread?.model === "discuss.channel" && ev.target.value.startsWith("/")) { + const [firstWord] = ev.target.value.substring(1).split(/\s/); + const command = commandRegistry.get(firstWord, false); + if ( + ev.target.value === "/" || // suggestions not yet started + this.hasSuggestions || + (command && + (!command.channel_types || command.channel_types.includes(this.thread.type))) + ) { + this.stopTyping(); + return; + } + } + this._super(ev); + }, +}); diff --git a/addons/mail/static/src/discuss/suggestion_service_patch.js b/addons/mail/static/src/discuss/suggestion_service_patch.js new file mode 100644 index 00000000000..f48b3a17594 --- /dev/null +++ b/addons/mail/static/src/discuss/suggestion_service_patch.js @@ -0,0 +1,82 @@ +/* @odoo-module */ + +import { SuggestionService } from "@mail/composer/suggestion_service"; +import { cleanTerm } from "@mail/utils/format"; +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +const commandRegistry = registry.category("discuss.channel_commands"); + +patch(SuggestionService.prototype, "discuss", { + getSupportedDelimiters(thread) { + const res = this._super(thread); + return thread?.model === "discuss.channel" ? [...res, ["/", 0]] : res; + }, + /** + * @override + */ + searchSuggestions({ delimiter, term }, { thread } = {}, sort = false) { + if (delimiter === "/") { + return this.searchChannelCommand(cleanTerm(term), thread, sort); + } + return this._super(...arguments); + }, + searchChannelCommand(cleanedSearchTerm, thread, sort) { + if (!thread.isChannel) { + // channel commands are channel specific + return; + } + const commands = commandRegistry + .getEntries() + .filter(([name, command]) => { + if (!cleanTerm(name).includes(cleanedSearchTerm)) { + return false; + } + if (command.channel_types) { + return command.channel_types.includes(thread.type); + } + return true; + }) + .map(([name, command]) => { + return { + channel_types: command.channel_types, + help: command.help, + id: command.id, + name, + }; + }); + const sortFunc = (c1, c2) => { + if (c1.channel_types && !c2.channel_types) { + return -1; + } + if (!c1.channel_types && c2.channel_types) { + return 1; + } + const cleanedName1 = cleanTerm(c1.name || ""); + const cleanedName2 = cleanTerm(c2.name || ""); + if ( + cleanedName1.startsWith(cleanedSearchTerm) && + !cleanedName2.startsWith(cleanedSearchTerm) + ) { + return -1; + } + if ( + !cleanedName1.startsWith(cleanedSearchTerm) && + cleanedName2.startsWith(cleanedSearchTerm) + ) { + return 1; + } + if (cleanedName1 < cleanedName2) { + return -1; + } + if (cleanedName1 > cleanedName2) { + return 1; + } + return c1.id - c2.id; + }; + return { + type: "ChannelCommand", + mainSuggestions: sort ? commands.sort(sortFunc) : commands, + }; + }, +}); diff --git a/addons/mail/static/src/discuss/thread_service_patch.js b/addons/mail/static/src/discuss/thread_service_patch.js new file mode 100644 index 00000000000..6184e3f3bef --- /dev/null +++ b/addons/mail/static/src/discuss/thread_service_patch.js @@ -0,0 +1,29 @@ +/* @odoo-module */ + +import { ThreadService } from "@mail/core/thread_service"; +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; + +const commandRegistry = registry.category("discuss.channel_commands"); + +patch(ThreadService.prototype, "discuss", { + /** + * @override + * @param {import("@mail/core/thread_model").Thread} thread + * @param {string} body + */ + async post(thread, body) { + if (thread.model === "discuss.channel" && body.startsWith("/")) { + const [firstWord] = body.substring(1).split(/\s/); + const command = commandRegistry.get(firstWord, false); + if ( + command && + (!command.channel_types || command.channel_types.includes(thread.type)) + ) { + await this.executeCommand(thread, command, body); + return; + } + } + return this._super(...arguments); + }, +}); diff --git a/addons/mail/static/tests/composer/composer_tests.js b/addons/mail/static/tests/composer/composer_tests.js index f110885778b..0406dca57bd 100644 --- a/addons/mail/static/tests/composer/composer_tests.js +++ b/addons/mail/static/tests/composer/composer_tests.js @@ -267,21 +267,6 @@ QUnit.test( } ); -QUnit.test('do not send typing notification on typing "/" command', async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ name: "channel" }); - const { openDiscuss } = await start({ - async mockRPC(route, args) { - if (route === "/discuss/channel/notify_typing") { - assert.step(`notify_typing:${args.is_typing}`); - } - }, - }); - await openDiscuss(channelId); - await insertText(".o-mail-Composer-input", "/"); - assert.verifySteps([], "No rpc done"); -}); - QUnit.test("composer text input cleared on message post", async (assert) => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "au-secours-aidez-moi" }); @@ -360,49 +345,6 @@ QUnit.test( } ); -QUnit.test( - 'do not send typing notification on typing after selecting suggestion from "/" command', - async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ name: "channel" }); - const { openDiscuss } = await start({ - async mockRPC(route, args) { - if (route === "/discuss/channel/notify_typing") { - assert.step(`notify_typing:${args.is_typing}`); - } - }, - }); - await openDiscuss(channelId); - await insertText(".o-mail-Composer-input", "/"); - await click(".o-mail-Composer-suggestion"); - await insertText(".o-mail-Composer-input", " is user?"); - assert.verifySteps([], "No rpc done"); - } -); - -QUnit.test("add an emoji after a command", async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ - name: "General", - channel_type: "channel", - }); - const { openDiscuss } = await start(); - await openDiscuss(channelId); - assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); - assert.strictEqual($(".o-mail-Composer-input").val(), ""); - await insertText(".o-mail-Composer-input", "/"); - await click(".o-mail-Composer-suggestion"); - assert.strictEqual( - $(".o-mail-Composer-input").val().replace(/\s/, " "), - "/who ", - "previous content + used command + additional whitespace afterwards" - ); - - await click("button[aria-label='Emojis']"); - await click(".o-mail-Emoji:contains(😊)"); - assert.strictEqual($(".o-mail-Composer-input").val().replace(/\s/, " "), "/who 😊"); -}); - QUnit.test("add an emoji after a partner mention", async (assert) => { const pyEnv = await startServer(); const partnerId = pyEnv["res.partner"].create({ diff --git a/addons/mail/static/tests/discuss/composer_tests.js b/addons/mail/static/tests/discuss/composer_tests.js new file mode 100644 index 00000000000..59a1a8ad03a --- /dev/null +++ b/addons/mail/static/tests/discuss/composer_tests.js @@ -0,0 +1,75 @@ +/* @odoo-module */ + +import { click, insertText, start, startServer } from "@mail/../tests/helpers/test_utils"; + +import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { Composer } from "@mail/composer/composer"; + +QUnit.module("composer", { + async beforeEach() { + // Simulate real user interactions + patchWithCleanup(Composer.prototype, { + isEventTrusted() { + return true; + }, + }); + }, +}); + +QUnit.test('do not send typing notification on typing "/" command', async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "channel" }); + const { openDiscuss } = await start({ + async mockRPC(route, args) { + if (route === "/discuss/channel/notify_typing") { + assert.step(`notify_typing:${args.is_typing}`); + } + }, + }); + await openDiscuss(channelId); + await insertText(".o-mail-Composer-input", "/"); + assert.verifySteps([], "No rpc done"); +}); + +QUnit.test( + 'do not send typing notification on typing after selecting suggestion from "/" command', + async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "channel" }); + const { openDiscuss } = await start({ + async mockRPC(route, args) { + if (route === "/discuss/channel/notify_typing") { + assert.step(`notify_typing:${args.is_typing}`); + } + }, + }); + await openDiscuss(channelId); + await insertText(".o-mail-Composer-input", "/"); + await click(".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", " is user?"); + assert.verifySteps([], "No rpc done"); + } +); + +QUnit.test("add an emoji after a command", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ + name: "General", + channel_type: "channel", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); + assert.strictEqual($(".o-mail-Composer-input").val(), ""); + await insertText(".o-mail-Composer-input", "/"); + await click(".o-mail-Composer-suggestion"); + assert.strictEqual( + $(".o-mail-Composer-input").val().replace(/\s/, " "), + "/who ", + "previous content + used command + additional whitespace afterwards" + ); + + await click("button[aria-label='Emojis']"); + await click(".o-mail-Emoji:contains(😊)"); + assert.strictEqual($(".o-mail-Composer-input").val().replace(/\s/, " "), "/who 😊"); +}); diff --git a/addons/mail/static/tests/discuss/suggestion_tests.js b/addons/mail/static/tests/discuss/suggestion_tests.js new file mode 100644 index 00000000000..f6f7d6cb244 --- /dev/null +++ b/addons/mail/static/tests/discuss/suggestion_tests.js @@ -0,0 +1,64 @@ +/* @odoo-module */ + +import { Composer } from "@mail/composer/composer"; +import { click, insertText, start, startServer } from "@mail/../tests/helpers/test_utils"; +import { patchWithCleanup } from "@web/../tests/helpers/utils"; + +QUnit.module("suggestion", { + async beforeEach() { + // Simulate real user interactions + patchWithCleanup(Composer.prototype, { + isEventTrusted() { + return true; + }, + }); + }, +}); + +QUnit.test('display command suggestions on typing "/"', async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ + name: "General", + channel_type: "channel", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); + await insertText(".o-mail-Composer-input", "/"); + assert.containsOnce($, ".o-mail-Composer-suggestionList .o-open"); +}); + +QUnit.test("use a command for a specific channel type", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ channel_type: "chat" }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); + assert.strictEqual($(".o-mail-Composer-input").val(), ""); + await insertText(".o-mail-Composer-input", "/"); + await click(".o-mail-Composer-suggestion"); + assert.strictEqual( + $(".o-mail-Composer-input").val().replace(/\s/, " "), + "/who ", + "command + additional whitespace afterwards" + ); +}); + +QUnit.test( + "command suggestion should only open if command is the first character", + async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ + name: "General", + channel_type: "channel", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); + assert.strictEqual($(".o-mail-Composer-input").val(), ""); + await insertText(".o-mail-Composer-input", "bluhbluh "); + assert.strictEqual($(".o-mail-Composer-input").val(), "bluhbluh "); + await insertText(".o-mail-Composer-input", "/"); + assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); + } +); diff --git a/addons/mail/static/tests/suggestion/suggestion_tests.js b/addons/mail/static/tests/suggestion/suggestion_tests.js index f2f087f0ace..2127ad2fab8 100644 --- a/addons/mail/static/tests/suggestion/suggestion_tests.js +++ b/addons/mail/static/tests/suggestion/suggestion_tests.js @@ -129,54 +129,6 @@ QUnit.test("select @ mention insert mention text in composer", async (assert) => assert.strictEqual($(".o-mail-Composer-input").val().trim(), "@TestPartner"); }); -QUnit.test('display command suggestions on typing "/"', async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ - name: "General", - channel_type: "channel", - }); - const { openDiscuss } = await start(); - await openDiscuss(channelId); - assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); - await insertText(".o-mail-Composer-input", "/"); - assert.containsOnce($, ".o-mail-Composer-suggestionList .o-open"); -}); - -QUnit.test("use a command for a specific channel type", async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ channel_type: "chat" }); - const { openDiscuss } = await start(); - await openDiscuss(channelId); - assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); - assert.strictEqual($(".o-mail-Composer-input").val(), ""); - await insertText(".o-mail-Composer-input", "/"); - await click(".o-mail-Composer-suggestion"); - assert.strictEqual( - $(".o-mail-Composer-input").val().replace(/\s/, " "), - "/who ", - "command + additional whitespace afterwards" - ); -}); - -QUnit.test( - "command suggestion should only open if command is the first character", - async (assert) => { - const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({ - name: "General", - channel_type: "channel", - }); - const { openDiscuss } = await start(); - await openDiscuss(channelId); - assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); - assert.strictEqual($(".o-mail-Composer-input").val(), ""); - await insertText(".o-mail-Composer-input", "bluhbluh "); - assert.strictEqual($(".o-mail-Composer-input").val(), "bluhbluh "); - await insertText(".o-mail-Composer-input", "/"); - assert.containsNone($, ".o-mail-Composer-suggestionList .o-open"); - } -); - QUnit.test('display channel mention suggestions on typing "#"', async (assert) => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({