[IMP] web: menu providers command in command palette

Adds the possibility to search an app/menu from the command palette .
It allows for fuzzy-searching apps and their submenus as shortcuts to directly
enter the right app and submenu and execute the corresponding action.

The comamnd is initiated by typing "/" in the command palette.

closes odoo/odoo#75396

Task-id: 2622952
Related: odoo/enterprise#20447
Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
This commit is contained in:
Francois (fge)
2021-08-30 13:27:58 +00:00
committed by Lucas Perais (lpe)
parent 7ddcebfcda
commit aa797792ca
5 changed files with 273 additions and 0 deletions
@@ -88,6 +88,11 @@
padding: 0 0.65rem;
}
}
.o_app_icon {
border-radius: 4%;
height: 1.8em;
}
}
}
@@ -53,4 +53,11 @@
</div>
</t>
<t t-name="web.AppIconCommand" owl="1">
<div class="o_command_default">
<span t-esc="props.name"/>
<img class="o_app_icon" t-attf-src="{{props.webIconData}}"/>
</div>
</t>
</templates>
@@ -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;
},
});
@@ -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();
@@ -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"
);
});