diff --git a/addons/web/static/src/webclient/commands/command_palette.scss b/addons/web/static/src/webclient/commands/command_palette.scss index 4212c800162..3225f4015a7 100644 --- a/addons/web/static/src/webclient/commands/command_palette.scss +++ b/addons/web/static/src/webclient/commands/command_palette.scss @@ -88,6 +88,11 @@ padding: 0 0.65rem; } } + + .o_app_icon { + border-radius: 4%; + height: 1.8em; + } } } diff --git a/addons/web/static/src/webclient/commands/command_palette.xml b/addons/web/static/src/webclient/commands/command_palette.xml index 5537f14664a..aa32710a7f3 100644 --- a/addons/web/static/src/webclient/commands/command_palette.xml +++ b/addons/web/static/src/webclient/commands/command_palette.xml @@ -53,4 +53,11 @@ + +
+ + +
+
+ diff --git a/addons/web/static/src/webclient/commands/menu_providers.js b/addons/web/static/src/webclient/commands/menu_providers.js new file mode 100644 index 00000000000..55218a13bd4 --- /dev/null +++ b/addons/web/static/src/webclient/commands/menu_providers.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { computeAppsAndMenuItems } from "@web/webclient/menu_service"; +import { fuzzyLookup } from "@web/core/utils/search"; + +const { Component } = owl; + +class AppIconCommand extends Component {} +AppIconCommand.template = "web.AppIconCommand"; + +const commandCategoryRegistry = registry.category("command_categories"); +commandCategoryRegistry.add("apps", { namespace: "/" }, { sequence: 10 }); +commandCategoryRegistry.add("menu_items", { namespace: "/" }, { sequence: 20 }); + +const commandEmptyMessageRegistry = registry.category("command_empty_list"); +commandEmptyMessageRegistry.add("/", _lt("No menu found")); + +const commandProviderRegistry = registry.category("command_provider"); +commandProviderRegistry.add("menu", { + namespace: "/", + async provide(env, options) { + const result = []; + const menuService = env.services.menu; + let { apps, menuItems } = computeAppsAndMenuItems(menuService.getMenuAsTree("root")); + if (options.searchValue !== "") { + apps = fuzzyLookup(options.searchValue, apps, (menu) => menu.label); + + fuzzyLookup(options.searchValue, menuItems, (menu) => + (menu.parents + " / " + menu.label).split("/").reverse().join("/") + ).forEach((menu) => { + result.push({ + action() { + menuService.selectMenu(menu); + }, + category: "menu_items", + name: menu.parents + " / " + menu.label, + }); + }); + } + + apps.forEach((menu) => { + const prefix = "data:image/png;base64,"; + const webIconData = menu.webIconData.startsWith(prefix) + ? menu.webIconData + : prefix + menu.webIconData.replace(/\s/g, ""); + result.push({ + Component: AppIconCommand, + action() { + menuService.selectMenu(menu); + }, + category: "apps", + name: menu.label, + props: { + webIconData: webIconData, + }, + }); + }); + + return result; + }, +}); diff --git a/addons/web/static/src/webclient/menu_service.js b/addons/web/static/src/webclient/menu_service.js index 4a8433fd1ea..8be6daf208f 100644 --- a/addons/web/static/src/webclient/menu_service.js +++ b/addons/web/static/src/webclient/menu_service.js @@ -6,6 +6,68 @@ import { session } from "@web/session"; const loadMenusUrl = `/web/webclient/load_menus`; +/** + * Traverses the given menu tree, executes the given callback for each node with + * the node itself and the list of its ancestors as arguments. + * + * @param {Object} tree tree of menus as exported by the menus service + * @param {Function} cb + * @param {[Object]} [parents] the ancestors of the tree root, if any + */ +function traverseMenuTree(tree, cb, parents = []) { + cb(tree, parents); + tree.childrenTree.forEach((c) => traverseMenuTree(c, cb, parents.concat([tree]))); +} + +/** + * Computes the "apps" and "menuItems" from a given menu tree. + * + * @param {Object} menuTree tree of menus as exported by the menus service + * @returns {Object} with keys "apps" and "menuItems" (HomeMenu props) + */ +export function computeAppsAndMenuItems(menuTree) { + const apps = []; + const menuItems = []; + traverseMenuTree(menuTree, (menuItem, parents) => { + if (!menuItem.id || !menuItem.actionID) { + return; + } + const isApp = menuItem.id === menuItem.appID; + const item = { + parents: parents + .slice(1) + .map((p) => p.name) + .join(" / "), + label: menuItem.name, + id: menuItem.id, + xmlid: menuItem.xmlid, + actionID: menuItem.actionID, + appID: menuItem.appID, + }; + if (isApp) { + if (menuItem.webIconData) { + item.webIconData = menuItem.webIconData; + } else { + const [iconClass, color, backgroundColor] = (menuItem.webIcon || "").split(","); + if (backgroundColor !== undefined) { + // Could split in three parts? + item.webIcon = { iconClass, color, backgroundColor }; + } else { + item.webIconData = "/web_enterprise/static/img/default_icon_app.png"; + } + } + } else { + item.menuID = parents[1].id; + } + if (isApp) { + apps.push(item); + } else { + menuItems.push(item); + } + }); + return { apps, menuItems }; +} + function makeFetchLoadMenus() { const cacheHashes = session.cache_hashes; let loadMenusHash = cacheHashes.load_menus || new Date().getTime().toString(); diff --git a/addons/web/static/tests/webclient/commands/menu_provider_tests.js b/addons/web/static/tests/webclient/commands/menu_provider_tests.js new file mode 100644 index 00000000000..1265cd01831 --- /dev/null +++ b/addons/web/static/tests/webclient/commands/menu_provider_tests.js @@ -0,0 +1,136 @@ +/** @odoo-module **/ + +import { createWebClient, getActionManagerServerData } from "@web/../tests/webclient/helpers"; +import { browser } from "@web/core/browser/browser"; +import { registry } from "@web/core/registry"; +import { click, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils"; +import { editSearchBar } from "./command_service_tests"; + +let serverData; +QUnit.module("Menu Command Provider", { + async beforeEach() { + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + const commandCategoryRegistry = registry.category("command_categories"); + commandCategoryRegistry.add("apps", { namespace: "/" }, { sequence: 10 }); + commandCategoryRegistry.add("menu_items", { namespace: "/" }, { sequence: 20 }); + serverData = getActionManagerServerData(); + serverData.menus = { + root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" }, + 0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" }, + 1: { id: 1, children: [], name: "Contact", appID: 1, actionID: 1001, xmlid: "menu_1" }, + 2: { + id: 2, + children: [3, 4], + name: "Sales", + appID: 2, + actionID: 1002, + xmlid: "menu_2", + }, + 3: { + id: 3, + children: [], + name: "Info", + appID: 2, + actionID: 1003, + xmlid: "menu_3", + }, + 4: { + id: 4, + children: [], + name: "Report", + appID: 2, + actionID: 1004, + xmlid: "menu_4", + }, + }; + serverData.actions[1003] = { + id: 1003, + tag: "__test__client__action__", + target: "main", + type: "ir.actions.client", + params: { description: "Info" }, + }; + serverData.actions[1004] = { + id: 1004, + tag: "__test__client__action__", + target: "main", + type: "ir.actions.client", + params: { description: "Report" }, + }; + }, + afterEach() {}, +}); + +QUnit.test("displays only apps if the search value is '/'", async (assert) => { + const webClient = await createWebClient({ serverData }); + assert.containsNone(webClient, ".o_menu_brand"); + + triggerHotkey("control+k"); + await nextTick(); + await editSearchBar("/"); + assert.containsOnce(webClient, ".o_command_palette"); + assert.containsOnce(webClient, ".o_command_category"); + assert.containsN(webClient, ".o_command", 2); + assert.deepEqual( + [...webClient.el.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Contact", "Sales"] + ); +}); + +QUnit.test("displays apps and menu items if the search value is not only '/'", async (assert) => { + const webClient = await createWebClient({ serverData }); + + triggerHotkey("control+k"); + await nextTick(); + await editSearchBar("/sal"); + assert.containsOnce(webClient, ".o_command_palette"); + assert.containsN(webClient, ".o_command", 3); + assert.deepEqual( + [...webClient.el.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Sales", "Sales / Info", "Sales / Report"] + ); +}); + +QUnit.test("opens an app", async (assert) => { + const webClient = await createWebClient({ serverData }); + assert.containsNone(webClient, ".o_menu_brand"); + + triggerHotkey("control+k"); + await nextTick(); + await editSearchBar("/"); + assert.containsOnce(webClient, ".o_command_palette"); + + triggerHotkey("enter"); + await nextTick(); + await nextTick(); + assert.strictEqual(webClient.el.querySelector(".o_menu_brand").textContent, "Contact"); + assert.strictEqual( + webClient.el.querySelector(".test_client_action").textContent, + " ClientAction_Id 1" + ); +}); + +QUnit.test("opens a menu items", async (assert) => { + const webClient = await createWebClient({ serverData }); + assert.containsNone(webClient, ".o_menu_brand"); + + triggerHotkey("control+k"); + await nextTick(); + await editSearchBar("/sal"); + assert.containsOnce(webClient, ".o_command_palette"); + assert.containsN(webClient, ".o_command_category", 2); + + click(webClient.el, "#o_command_2"); + await nextTick(); + await nextTick(); + assert.strictEqual(webClient.el.querySelector(".o_menu_brand").textContent, "Sales"); + assert.strictEqual( + webClient.el.querySelector(".test_client_action").textContent, + " ClientAction_Report" + ); +});