diff --git a/addons/im_livechat/static/tests/suggestions_tests.js b/addons/im_livechat/static/tests/suggestions_tests.js new file mode 100644 index 00000000000..201183b662e --- /dev/null +++ b/addons/im_livechat/static/tests/suggestions_tests.js @@ -0,0 +1,31 @@ +/* @odoo-module */ + +import { Command } from "@mail/../tests/helpers/command"; +import { insertText, start, startServer } from "@mail/../tests/helpers/test_utils"; + +QUnit.module("suggestion"); + +QUnit.test("Suggestions are shown after delimiter was used in text (:)", async (assert) => { + const pyEnv = await startServer(); + pyEnv["mail.shortcode"].create({ + source: "hello", + substitution: "Hello dear customer, how may I help you?", + }); + const channelId = pyEnv["discuss.channel"].create({ + anonymous_name: "Visitor", + channel_type: "livechat", + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: pyEnv.publicPartnerId }), + ], + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await insertText(".o-mail-Composer-input", ":"); + assert.containsOnce($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", ")"); + assert.containsNone($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", " "); + await insertText(".o-mail-Composer-input", ":"); + assert.containsOnce($, ".o-mail-Composer-suggestion:contains(hello)"); +}); diff --git a/addons/mail/static/src/core/common/suggestion_hook.js b/addons/mail/static/src/core/common/suggestion_hook.js index e4fe0ba2dd8..7b530313d1b 100644 --- a/addons/mail/static/src/core/common/suggestion_hook.js +++ b/addons/mail/static/src/core/common/suggestion_hook.js @@ -8,6 +8,8 @@ import { useService } from "@web/core/utils/hooks"; export function useSuggestion() { const comp = useComponent(); const sequential = useSequential(); + let pendingFetchCount = 0; + /** @type {import("@mail/core/common/suggestion_service").SuggestionService} */ const suggestionService = useService("mail.suggestion"); const self = { clearRawMentions() { @@ -38,10 +40,21 @@ export function useSuggestion() { if (self.search.position !== undefined && self.search.position < selectionStart) { candidatePositions.push(self.search.position); } - // consider the char before the current cursor position if the + // consider the chars before the current cursor position if the // current delimiter is no longer valid (or if there is none) - if (selectionStart > 0) { - candidatePositions.push(selectionStart - 1); + let numberOfSpaces = 0; + for (let index = selectionStart - 1; index >= 0; --index) { + if (/\s/.test(content[index])) { + numberOfSpaces++; + if (numberOfSpaces === 2) { + // The consideration stops after the second space since + // a majority of partners have a two-word name. This + // removes the need to check for mentions following a + // delimiter used earlier in the content. + break; + } + } + candidatePositions.push(index); } const supportedDelimiters = suggestionService.getSupportedDelimiters(self.thread); for (const candidatePosition of candidatePositions) { @@ -109,7 +122,7 @@ export function useSuggestion() { count: 0, items: undefined, }), - update() { + update({ clearSearchOnEmpty = true } = {}) { if (!self.search.delimiter) { return; } @@ -120,7 +133,11 @@ export function useSuggestion() { ); const { type, mainSuggestions, extraSuggestions = [] } = suggestions; if (!mainSuggestions.length && !extraSuggestions.length) { - self.state.items = undefined; + if (clearSearchOnEmpty) { + self.clearSearch(); + } else { + self.state.items = undefined; + } return; } // arbitrary limit to avoid displaying too many elements at once @@ -136,21 +153,26 @@ export function useSuggestion() { }; useEffect( () => { - self.update(); + self.update({ clearSearchOnEmpty: false }); sequential(async () => { if (self.search.position === undefined || !self.search.delimiter) { return; // ignore obsolete call } - await suggestionService.fetchSuggestions(self.search, { - thread: self.thread, - onFetched() { - if (owl.status(comp) === "destroyed") { - return; - } - self.update(); - }, - }); - self.update(); + pendingFetchCount++; + try { + await suggestionService.fetchSuggestions(self.search, { + thread: self.thread, + onFetched() { + if (owl.status(comp) === "destroyed") { + return; + } + self.update({ clearSearchOnEmpty: pendingFetchCount === 0 }); + }, + }); + } finally { + pendingFetchCount--; + } + self.update({ clearSearchOnEmpty: pendingFetchCount === 0 }); }); }, () => { diff --git a/addons/mail/static/tests/composer/composer_tests.js b/addons/mail/static/tests/composer/composer_tests.js index 63eb5e0269c..318b71d820d 100644 --- a/addons/mail/static/tests/composer/composer_tests.js +++ b/addons/mail/static/tests/composer/composer_tests.js @@ -481,6 +481,7 @@ QUnit.test("leave command on channel", async (assert) => { assert.hasClass($(".o-mail-DiscussSidebarChannel:contains(general)"), "o-active"); await insertText(".o-mail-Composer-input", "/leave"); await afterNextRender(() => triggerHotkey("Enter")); + await afterNextRender(() => triggerHotkey("Enter")); assert.containsNone($, ".o-mail-DiscussSidebarChannel:contains(general)"); assert.containsOnce($, ".o-mail-Discuss:contains(No conversation selected.)"); assert.verifySteps(["You unsubscribed from general."]); @@ -528,6 +529,7 @@ QUnit.test("leave command on chat", async (assert) => { assert.hasClass($(".o-mail-DiscussSidebarChannel:contains(Chuck Norris)"), "o-active"); await insertText(".o-mail-Composer-input", "/leave"); await afterNextRender(() => triggerHotkey("Enter")); + await afterNextRender(() => triggerHotkey("Enter")); assert.containsNone($, ".o-mail-DiscussSidebarChannel:contains(Chuck Norris)"); assert.containsOnce($, ".o-mail-Discuss:contains(No conversation selected.)"); assert.verifySteps(["You unpinned your conversation with Chuck Norris"]); diff --git a/addons/mail/static/tests/discuss/core/suggestion_tests.js b/addons/mail/static/tests/discuss/core/suggestion_tests.js index 84cb5c91cae..d4ed9a40ac5 100644 --- a/addons/mail/static/tests/discuss/core/suggestion_tests.js +++ b/addons/mail/static/tests/discuss/core/suggestion_tests.js @@ -133,19 +133,24 @@ QUnit.test("Sort partner suggestions by recent chats", async (assert) => { QUnit.test("suggestion are shown after deleting a character", async (assert) => { const pyEnv = await startServer(); + const partnerId = pyEnv["res.partner"].create({ name: "John Doe" }); const channelId = pyEnv["discuss.channel"].create({ name: "General", channel_type: "channel", + channel_member_ids: [ + Command.create({ partner_id: pyEnv.currentPartnerId }), + Command.create({ partner_id: partnerId }), + ], }); const { openDiscuss } = await start(); await openDiscuss(channelId); - await insertText(".o-mail-Composer-input", "/"); - await insertText(".o-mail-Composer-input", "hee"); - assert.containsNone($, ".o-mail-Composer-suggestion:contains(help)"); + await insertText(".o-mail-Composer-input", "@"); + await insertText(".o-mail-Composer-input", "John Da"); + assert.containsNone($, ".o-mail-Composer-suggestion:contains(John Doe)"); // Simulate pressing backspace await afterNextRender(() => { const textarea = document.querySelector(".o-mail-Composer-input"); textarea.value = textarea.value.slice(0, -1); }); - assert.containsOnce($, ".o-mail-Composer-suggestion:contains(help)"); + assert.containsOnce($, ".o-mail-Composer-suggestion:contains(John Doe)"); }); diff --git a/addons/mail/static/tests/suggestion/suggestion_tests.js b/addons/mail/static/tests/suggestion/suggestion_tests.js index 9c96b525504..4939867b3b2 100644 --- a/addons/mail/static/tests/suggestion/suggestion_tests.js +++ b/addons/mail/static/tests/suggestion/suggestion_tests.js @@ -187,3 +187,31 @@ QUnit.test("Channel suggestions do not crash after rpc returns", async (assert) await deferred; assert.verifySteps(["get_mention_suggestions"]); }); + +QUnit.test("Suggestions are shown after delimiter was used in text (@)", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await insertText(".o-mail-Composer-input", "@"); + assert.containsOnce($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", "NonExistingUser"); + assert.containsNone($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", " "); + await insertText(".o-mail-Composer-input", "@"); + assert.containsOnce($, ".o-mail-Composer-suggestion:contains(Mitchell Admin)"); +}); + +QUnit.test("Suggestions are shown after delimiter was used in text (#)", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "General" }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await insertText(".o-mail-Composer-input", "#"); + assert.containsOnce($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", "NonExistingChannel"); + assert.containsNone($, ".o-mail-Composer-suggestion"); + await insertText(".o-mail-Composer-input", " "); + await insertText(".o-mail-Composer-input", "#"); + assert.containsOnce($, ".o-mail-Composer-suggestion:contains(General)"); +});