[REF] mail: move channel commands to discuss folder

Part of task-3265211

closes odoo/odoo#120429

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2023-05-05 14:54:57 +02:00
parent 6c881fda04
commit e6b3323237
14 changed files with 294 additions and 204 deletions
@@ -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);
},
/**
+1 -1
View File
@@ -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(() => {
@@ -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;
@@ -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 =
@@ -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}
*/
@@ -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
@@ -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);
},
});
@@ -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,
};
},
});
@@ -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);
},
});
@@ -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({
@@ -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 😊");
});
@@ -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");
}
);
@@ -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({