From bfa2d9c205ddd231b6522efaf7151490a76aad59 Mon Sep 17 00:00:00 2001 From: "Francois (fge)" Date: Thu, 29 Jul 2021 15:29:11 +0000 Subject: [PATCH] [IMP] web: refactor Command palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purpose of this commit is to make the palette command customizable. From now, it will be possible to add spaceNames to modify the commands displayed in the command palette. For example, if I add the spaceName '@' and associate a provide function with it, then when I type "@" in the search bar of the command palette, the commands generated by provide will be displayed. const commandProviderRegistry = registry.category("command_provider"); commandProviderRegistry.add("example", { nameSpace: "@" , provide: (env, options = {}) => { return [{name: "myCommand", action: () => ()}] }, }); It will be possible to define several provide functions for the same namespace. This will make it easy to add new commands when installing a new addon. Now it is possible to define commands that modify the state of the command palette. This allows a command to change the placeholder and commands displayed in the command palette. closes odoo/odoo#73750 Related: odoo/enterprise#19970 Signed-off-by: Géry Debongnie (ged) --- .../static/src/core/hotkeys/hotkey_service.js | 11 +- addons/web/static/src/core/ui/ui_service.js | 11 - addons/web/static/src/core/utils/ui.js | 17 ++ .../webclient/commands/command_category.js | 4 +- .../src/webclient/commands/command_hook.js | 4 +- .../src/webclient/commands/command_palette.js | 162 +++++++++---- .../webclient/commands/command_palette.scss | 38 +-- .../webclient/commands/command_palette.xml | 56 +++-- .../commands/command_palette_dialog.js | 4 - .../src/webclient/commands/command_service.js | 39 +--- .../webclient/commands/default_providers.js | 81 +++++++ addons/web/static/tests/helpers/mock_env.js | 1 + .../commands/command_service_tests.js | 217 +++++++++++++++++- 13 files changed, 507 insertions(+), 138 deletions(-) create mode 100644 addons/web/static/src/core/utils/ui.js create mode 100644 addons/web/static/src/webclient/commands/default_providers.js diff --git a/addons/web/static/src/core/hotkeys/hotkey_service.js b/addons/web/static/src/core/hotkeys/hotkey_service.js index 4ca1fa1dea5..11098ce8908 100644 --- a/addons/web/static/src/core/hotkeys/hotkey_service.js +++ b/addons/web/static/src/core/hotkeys/hotkey_service.js @@ -3,6 +3,7 @@ import { isMacOS } from "../browser/feature_detection"; import { registry } from "../registry"; import { browser } from "../browser/browser"; +import { getVisibleElements } from "../utils/ui"; const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split(""); const NAV_KEYS = [ @@ -63,7 +64,8 @@ export const hotkeyService = { } // FIXME : this is a temporary hack. It replaces all [accesskey] attrs by [data-hotkey] on all elements. - const elementsWithoutDataHotkey = ui.getVisibleElements( + const elementsWithoutDataHotkey = getVisibleElements( + ui.activeElement, "[accesskey]:not([data-hotkey])" ); for (const el of elementsWithoutDataHotkey) { @@ -131,7 +133,10 @@ export const hotkeyService = { .split("+") .filter((key) => !overlayModParts.includes(key)) .join("+"); - const elems = ui.getVisibleElements(`[data-hotkey='${cleanHotkey}' i]`); + const elems = getVisibleElements( + ui.activeElement, + `[data-hotkey='${cleanHotkey}' i]` + ); for (const el of elems) { // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click el.focus(); @@ -153,7 +158,7 @@ export const hotkeyService = { if (overlaysVisible) { return; } - for (const el of env.services.ui.getVisibleElements("[data-hotkey]:not(:disabled)")) { + for (const el of getVisibleElements(ui.activeElement, "[data-hotkey]:not(:disabled)")) { const hotkey = el.dataset.hotkey; const overlay = document.createElement("div"); overlay.className = "o_web_hotkey_overlay"; diff --git a/addons/web/static/src/core/ui/ui_service.js b/addons/web/static/src/core/ui/ui_service.js index 59d9a1a9e45..44828d16e2e 100644 --- a/addons/web/static/src/core/ui/ui_service.js +++ b/addons/web/static/src/core/ui/ui_service.js @@ -80,21 +80,10 @@ export const uiService = { activeElems = activeElems.filter((x) => x !== el); bus.trigger("active-element-changed", ui.activeElement); } - function getVisibleElements(selector) { - const visibleElements = []; - for (const el of ui.activeElement.querySelectorAll(selector)) { - const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0; - if (isVisible) { - visibleElements.push(el); - } - } - return visibleElements; - } Object.assign(ui, { activateElement, deactivateElement, - getVisibleElements, }); Object.defineProperty(ui, "activeElement", { get() { diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js new file mode 100644 index 00000000000..418d04295e2 --- /dev/null +++ b/addons/web/static/src/core/utils/ui.js @@ -0,0 +1,17 @@ +/** @odoo-module **/ + +/** + * @param {Document} activeElement + * @param {DOMString} selector + * @returns all selected and visible elements present in the activeElement + */ +export function getVisibleElements(activeElement, selector) { + const visibleElements = []; + for (const el of activeElement.querySelectorAll(selector)) { + const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0; + if (isVisible) { + visibleElements.push(el); + } + } + return visibleElements; +} diff --git a/addons/web/static/src/webclient/commands/command_category.js b/addons/web/static/src/webclient/commands/command_category.js index a97fa5146f8..b9c8826ca20 100644 --- a/addons/web/static/src/webclient/commands/command_category.js +++ b/addons/web/static/src/webclient/commands/command_category.js @@ -1,7 +1,7 @@ /** @odoo-module **/ -import { _lt } from "../../core/l10n/translation"; -import { registry } from "../../core/registry"; +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; const commandCategoryRegistry = registry.category("command_categories"); commandCategoryRegistry.add("app", { label: _lt("Current App Commands") }, { sequence: 10 }); diff --git a/addons/web/static/src/webclient/commands/command_hook.js b/addons/web/static/src/webclient/commands/command_hook.js index 798ffde1608..3dbd8800aa1 100644 --- a/addons/web/static/src/webclient/commands/command_hook.js +++ b/addons/web/static/src/webclient/commands/command_hook.js @@ -1,7 +1,7 @@ /** @odoo-module **/ -import { useService } from "../../core/service_hook"; -import { useEffect } from "../../core/effect_hook"; +import { useEffect } from "@web/core/effect_hook"; +import { useService } from "@web/core/service_hook"; /** * @typedef {import("./command_service").CommandServiceAddOptions} CommandServiceAddOptions diff --git a/addons/web/static/src/webclient/commands/command_palette.js b/addons/web/static/src/webclient/commands/command_palette.js index 7e714df6eb5..2a3e09c37ff 100644 --- a/addons/web/static/src/webclient/commands/command_palette.js +++ b/addons/web/static/src/webclient/commands/command_palette.js @@ -1,15 +1,23 @@ /** @odoo-module **/ +import { useAutofocus } from "@web/core/autofocus_hook"; +import { useHotkey } from "@web/core/hotkey_hook"; import { registry } from "@web/core/registry"; -import { useAutofocus } from "../../core/autofocus_hook"; -import { isMacOS } from "../../core/browser/feature_detection"; -import { useHotkey } from "../../core/hotkey_hook"; -import { scrollTo } from "../../core/utils/scrolling"; +import { useService } from "@web/core/service_hook"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { scrollTo } from "@web/core/utils/scrolling"; +import { fuzzyLookup } from "@web/core/utils/search"; +import { debounce } from "@web/core/utils/timing"; const { Component, hooks } = owl; -const { useState } = hooks; +const { useRef, useState } = hooks; + +const DEFAULT_PLACEHOLDER = "Search for an action..."; +const FUZZY_SEARCH = ["__default__"]; const commandCategoryRegistry = registry.category("command_categories"); +const commandProviderRegistry = registry.category("command_provider"); + /** * @typedef {import("./command_service").Command} Command */ @@ -30,24 +38,18 @@ function commandsWithinCategory(categoryName) { }; } +export class DefaultCommandItem extends Component {} +DefaultCommandItem.template = "web.defaultCommandItem"; + export class CommandPalette extends Component { setup() { - /** - * @type Command[] - */ - this.initialCommands = []; - for (const [key, _] of commandCategoryRegistry.getEntries()) { - const commands = this.props.commands.filter(commandsWithinCategory(key)); - this.initialCommands = this.initialCommands.concat(commands); - } - - /** - * @type {{commands:Command[], selectedCommand: Command}} - */ - this.state = useState({ - commands: this.initialCommands, - selectedCommand: this.initialCommands[0], - }); + this.keyId = 1; + this.displayByCategory = true; + this.keepLast = new KeepLast(); + this.DefaultCommandItem = DefaultCommandItem; + this.activeElement = useService("ui").activeElement; + this.searchBar = useRef("search_bar"); + this.onDebouncedSearchInput = debounce(this.onSearchInput, 250); useAutofocus(); @@ -56,39 +58,90 @@ export class CommandPalette extends Component { useHotkey("ArrowUp", () => this.selectCommandAndScrollTo("PREV"), { allowRepeat: true }); useHotkey("ArrowDown", () => this.selectCommandAndScrollTo("NEXT"), { allowRepeat: true }); - for (const command of this.initialCommands) { - if (command.hotkey) { - useHotkey(command.hotkey, () => { - command.action(); - this.props.closeMe(); - }); + /** + * @type {{commands: Command[], selectedCommand: Command, placeHolder: String}} + */ + this.state = useState({ + commands: [], + selectedCommand: -1, + placeHolder: DEFAULT_PLACEHOLDER, + }); + + const mainProviders = { __default__: [] }; + for (const provider of commandProviderRegistry.getAll()) { + const nameSpace = provider.nameSpace || "__default__"; + if (nameSpace in mainProviders) { + mainProviders[nameSpace] = mainProviders[nameSpace].concat([provider]); + } else { + mainProviders[nameSpace] = [provider]; } } + this.providerStack = [mainProviders]; + + this.setCommands("__default__", { + activeElement: this.activeElement, + }); } get categories() { const categories = []; - for (const [key, value] of commandCategoryRegistry.getEntries()) { - const commands = this.state.commands.filter(commandsWithinCategory(key)); + if (this.displayByCategory) { + for (const [key, value] of commandCategoryRegistry.getEntries()) { + const commands = this.state.commands.filter(commandsWithinCategory(key)); + if (commands.length) { + categories.push({ + commands, + keyId: key, + ...value, + }); + } + } + } else { + const commands = this.state.commands; if (commands.length) { categories.push({ - ...value, commands, + keyId: "default", + label: "", }); } } return categories; } - getKeysToPress(command) { - const { hotkey } = command; - let result = hotkey.split("+"); - if (isMacOS()) { - result = result - .map((x) => x.replace("control", "command")) - .map((x) => x.replace("alt", "control")); + get lastProvider() { + return this.providerStack[this.providerStack.length - 1]; + } + + async setCommands(key, options = {}) { + const proms = this.lastProvider[key].map((provider) => { + const { provide } = provider; + const result = provide(this.env, options); + return result; + }); + let commands = (await this.keepLast.add(Promise.all(proms))).flatMap( + (commands) => commands + ); + + if (options.searchValue && FUZZY_SEARCH.includes(key)) { + this.displayByCategory = false; + commands = fuzzyLookup(options.searchValue, commands, (c) => c.name); + } else { + this.displayByCategory = true; + // we have to sort the commands by category to avoid navigation issues with the arrows + let commandsSorted = []; + for (const [key, _] of commandCategoryRegistry.getEntries()) { + const commandsByCategory = commands.filter(commandsWithinCategory(key)); + commandsSorted = commandsSorted.concat(commandsByCategory); + } + commands = commandsSorted; } - return result.map((key) => key.toUpperCase()); + + this.state.commands = commands.map((command) => ({ + ...command, + keyId: this.keyId++, + })); + this.selectCommand(this.state.commands.length ? 0 : -1); } selectCommand(index) { @@ -124,8 +177,18 @@ export class CommandPalette extends Component { executeSelectedCommand() { if (this.state.selectedCommand) { - this.props.closeMe(); - this.state.selectedCommand.action(); + const result = this.state.selectedCommand.action(); + if (result) { + const { placeHolder, provide } = result; + this.providerStack.push({ __default__: [{ provide }] }); + this.setCommands("__default__", { + activeElement: this.activeElement, + }); + this.state.placeHolder = placeHolder || DEFAULT_PLACEHOLDER; + this.searchBar.el.value = ""; + } else { + this.props.closeMe(); + } } } @@ -137,16 +200,17 @@ export class CommandPalette extends Component { } } - onSearchInput(ev) { - const searchValue = ev.target.value; - const newCommands = []; - for (const command of this.initialCommands) { - if (fuzzy.test(searchValue, command.name)) { - newCommands.push(command); - } + async onSearchInput(ev) { + let searchValue = ev.target.value; + let key = "__default__"; + if (searchValue.length && searchValue[0] in this.lastProvider) { + key = searchValue[0]; + searchValue = searchValue.slice(1); } - this.state.commands = newCommands; - this.selectCommand(this.state.commands.length ? 0 : -1); + this.setCommands(key, { + searchValue, + activeElement: this.activeElement, + }); } } CommandPalette.template = "web.CommandPalette"; diff --git a/addons/web/static/src/webclient/commands/command_palette.scss b/addons/web/static/src/webclient/commands/command_palette.scss index c4bf7112f90..5427d607a44 100644 --- a/addons/web/static/src/webclient/commands/command_palette.scss +++ b/addons/web/static/src/webclient/commands/command_palette.scss @@ -52,10 +52,6 @@ } .o_command { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 1.3em; position: relative; line-height: 1.8em; height: 2.8em; @@ -64,18 +60,28 @@ background: rgba(0, 160, 157, 0.15); } - kbd { - background-color: #f4f7f8; - border-radius: 3px; - border: 1px solid #e5e5e5; - $shadowvalue: 0px 1px 1px rgba(0, 0, 0, 0.2), - inset 0px -1px 1px 1px rgba(230, 230, 230, 0.8), - inset 0px 2px 0px 0px rgba(255, 255, 255, 0.8); - color: #4c4c4c; - display: inline-block; - font-family: Consolas, "Liberation Mono", Courier, monospace; - font-size: 0.85em; - padding: 0 0.65rem; + &_default, &_hotkey { + background-color: inherit; + padding: 0.5rem 1.3em; + display: flex; + align-items: center; + justify-content: space-between; + } + + &_hotkey { + kbd { + background-color: #f4f7f8; + border-radius: 3px; + border: 1px solid #e5e5e5; + $shadowvalue: 0px 1px 1px rgba(0, 0, 0, 0.2), + inset 0px -1px 1px 1px rgba(230, 230, 230, 0.8), + inset 0px 2px 0px 0px rgba(255, 255, 255, 0.8); + color: #4c4c4c; + display: inline-block; + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 0.85em; + padding: 0 0.65rem; + } } } } diff --git a/addons/web/static/src/webclient/commands/command_palette.xml b/addons/web/static/src/webclient/commands/command_palette.xml index 297a2674b88..6a0a0218530 100644 --- a/addons/web/static/src/webclient/commands/command_palette.xml +++ b/addons/web/static/src/webclient/commands/command_palette.xml @@ -4,39 +4,61 @@
No action found
+ + - +
-
- - - - - - - - - + - + + + + - - + -
+
-
+
+
+
+ + + + + + +
+ + +
+ +
+
+ + +
+ + + + + + + + +
+
+ diff --git a/addons/web/static/src/webclient/commands/command_palette_dialog.js b/addons/web/static/src/webclient/commands/command_palette_dialog.js index 50362739c66..99450f478b9 100644 --- a/addons/web/static/src/webclient/commands/command_palette_dialog.js +++ b/addons/web/static/src/webclient/commands/command_palette_dialog.js @@ -32,7 +32,3 @@ CommandPaletteDialog.renderFooter = false; CommandPaletteDialog.contentClass = "o_command_palette"; CommandPaletteDialog.bodyTemplate = "web.CommandPaletteDialogBody"; CommandPaletteDialog.components = Object.assign({}, Dialog.components, { CommandPalette }); -CommandPaletteDialog.props = { - commands: { type: Array, element: { type: Object } }, - close: Function, -}; diff --git a/addons/web/static/src/webclient/commands/command_service.js b/addons/web/static/src/webclient/commands/command_service.js index 1c515f29539..c15ceab6522 100644 --- a/addons/web/static/src/webclient/commands/command_service.js +++ b/addons/web/static/src/webclient/commands/command_service.js @@ -1,7 +1,6 @@ /** @odoo-module **/ -import { registry } from "../../core/registry"; -import { capitalize } from "../../core/utils/strings"; +import { registry } from "@web/core/registry"; import { CommandPaletteDialog } from "./command_palette_dialog"; /** @@ -38,40 +37,11 @@ export const commandService = { return; } - const overlayModifier = registry.category("services").get("hotkey").overlayModifier; - const commands = [...registeredCommands.values()].filter( - (command) => command.activeElement === ui.activeElement - ); - - // Also retrieve all hotkeyables elements - for (const el of ui.getVisibleElements("[data-hotkey]:not(:disabled)")) { - const closest = el.closest("[data-command-category]"); - const category = closest ? closest.dataset.commandCategory : "default"; - - const description = - el.title || - el.dataset.originalTitle || // LEGACY: bootstrap moves title to data-original-title - el.placeholder || - (el.innerText && - `${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) || - "no description provided"; - commands.push({ - name: capitalize(description.trim().toLowerCase()), - hotkey: `${overlayModifier}+${el.dataset.hotkey}`, - action: () => { - // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click - el.focus(); - el.click(); - }, - category, - }); - } - // Open palette dialog isPaletteOpened = true; dialog.add( CommandPaletteDialog, - { commands }, + {}, { onClose: () => { isPaletteOpened = false; @@ -144,6 +114,11 @@ export const commandService = { unregisterCommand(token); }; }, + getCommands(activeElement) { + return [...registeredCommands.values()].filter( + (command) => command.activeElement === activeElement + ); + }, }; }, }; diff --git a/addons/web/static/src/webclient/commands/default_providers.js b/addons/web/static/src/webclient/commands/default_providers.js new file mode 100644 index 00000000000..55216c8ff0a --- /dev/null +++ b/addons/web/static/src/webclient/commands/default_providers.js @@ -0,0 +1,81 @@ +/** @odoo-module **/ + +import { isMacOS } from "@web/core/browser/feature_detection"; +import { useHotkey } from "@web/core/hotkey_hook"; +import { registry } from "@web/core/registry"; +import { capitalize } from "@web/core/utils/strings"; +import { getVisibleElements } from "@web/core/utils/ui"; +import { DefaultCommandItem } from "./command_service"; + +const { Component } = owl; + +class HotkeyCommandItem extends Component { + setup() { + if (this.props.hotkey) { + useHotkey(this.props.hotkey, () => { + this.props.action(); + this.trigger("close"); + }); + } + } + + getKeysToPress(command) { + const { hotkey } = command; + let result = hotkey.split("+"); + if (isMacOS()) { + result = result + .map((x) => x.replace("control", "command")) + .map((x) => x.replace("alt", "control")); + } + return result.map((key) => key.toUpperCase()); + } +} +HotkeyCommandItem.template = "web.hotkeyCommandItem"; + +const commandProviderRegistry = registry.category("command_provider"); +commandProviderRegistry.add("command", { + provide: (env, options = {}) => { + const commands = env.services.command.getCommands(options.activeElement); + return commands.map((command) => ({ + ...command, + Component: command.hotkey ? HotkeyCommandItem : DefaultCommandItem, + })); + }, +}); + +commandProviderRegistry.add("data-hotkeys", { + provide: (env, options = {}) => { + const commands = []; + const overlayModifier = registry.category("services").get("hotkey").overlayModifier; + // Also retrieve all hotkeyables elements + for (const el of getVisibleElements( + options.activeElement, + "[data-hotkey]:not(:disabled)" + )) { + const closest = el.closest("[data-command-category]"); + const category = closest ? closest.dataset.commandCategory : "default"; + + const description = + el.title || + el.dataset.originalTitle || // LEGACY: bootstrap moves title to data-original-title + el.placeholder || + (el.innerText && + `${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) || + "no description provided"; + commands.push({ + name: capitalize(description.trim().toLowerCase()), + hotkey: `${overlayModifier}+${el.dataset.hotkey}`, + action: () => { + // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click + el.focus(); + el.click(); + }, + category, + }); + } + return commands.map((command) => ({ + ...command, + Component: HotkeyCommandItem, + })); + }, +}); diff --git a/addons/web/static/tests/helpers/mock_env.js b/addons/web/static/tests/helpers/mock_env.js index 0c54a49079c..c416171ccee 100644 --- a/addons/web/static/tests/helpers/mock_env.js +++ b/addons/web/static/tests/helpers/mock_env.js @@ -47,6 +47,7 @@ export function prepareRegistriesWithCleanup() { cloneRegistryWithCleanup(registry.category("actions")); cloneRegistryWithCleanup(registry.category("views")); cloneRegistryWithCleanup(registry.category("error_handlers")); + cloneRegistryWithCleanup(registry.category("command_provider")); cloneRegistryWithCleanup(registry.category("main_components")); diff --git a/addons/web/static/tests/webclient/commands/command_service_tests.js b/addons/web/static/tests/webclient/commands/command_service_tests.js index 86ef144c451..34ec8b9483d 100644 --- a/addons/web/static/tests/webclient/commands/command_service_tests.js +++ b/addons/web/static/tests/webclient/commands/command_service_tests.js @@ -9,6 +9,7 @@ import { commandService } from "@web/webclient/commands/command_service"; import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; import { makeTestEnv } from "../../helpers/mock_env"; import { click, getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils"; +import { clearRegistryWithCleanup } from "../../helpers/mock_env"; const { Component, mount, tags } = owl; const { xml } = tags; @@ -18,6 +19,7 @@ let target; let testComponent; const serviceRegistry = registry.category("services"); const commandCategoryRegistry = registry.category("command_categories"); +const commandProviderRegistry = registry.category("command_provider"); class TestComponent extends Component { get DialogContainer() { @@ -33,6 +35,14 @@ TestComponent.template = xml` QUnit.module("Command", { async beforeEach() { + const commandProviders = commandProviderRegistry + .getEntries() + .filter((provider) => ["command", "data-hotkeys"].includes(provider[0])); + clearRegistryWithCleanup(commandProviderRegistry); + commandProviders.forEach((provider) => { + commandProviderRegistry.add(provider[0], provider[1]); + }); + serviceRegistry.add("ui", uiService); serviceRegistry.add("dialog", dialogService); serviceRegistry.add("hotkey", hotkeyService); @@ -273,6 +283,13 @@ QUnit.test("access to hotkeys from the command palette", async (assert) => { QUnit.test("can be searched", async (assert) => { assert.expect(4); + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + testComponent = await mount(TestComponent, { env, target }); // Register some commands @@ -323,6 +340,202 @@ QUnit.test("can be searched", async (assert) => { ); }); +QUnit.test("defined multiple providers with the same nameSpace", async (assert) => { + assert.expect(2); + + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + + clearRegistryWithCleanup(commandProviderRegistry); + const action = () => {}; + + const defaultNames = ["John", "Snow"]; + commandProviderRegistry.add("default", { + nameSpace: "__default__", + provide: () => + defaultNames.map((name) => ({ + name, + action, + })), + }); + + const otherNames = ["Cersei", "Lannister"]; + commandProviderRegistry.add("other", { + provide: () => + otherNames.map((name) => ({ + name, + action, + })), + }); + + testComponent = await mount(TestComponent, { env, target }); + + // Open palette + triggerHotkey("control+k"); + await nextTick(); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").value, + "", + "search input is empty" + ); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + defaultNames.concat(otherNames), + "all default commands are present" + ); +}); + +QUnit.test("can switch between command providers", async (assert) => { + assert.expect(4); + + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + + clearRegistryWithCleanup(commandProviderRegistry); + const action = () => {}; + + const defaultNames = ["John", "Snow"]; + commandProviderRegistry.add("default", { + provide: () => + defaultNames.map((name) => ({ + name, + action, + })), + }); + + const otherNames = ["Cersei", "Lannister"]; + commandProviderRegistry.add("other", { + nameSpace: "@", + provide: () => + otherNames.map((name) => ({ + name, + action, + })), + }); + + testComponent = await mount(TestComponent, { env, target }); + + // Open palette + triggerHotkey("control+k"); + await nextTick(); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").value, + "", + "search input is empty" + ); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + defaultNames, + "all default commands are present" + ); + + // Switch to the other provider + let search = target.querySelector(".o_command_palette_search input"); + search.value = "@"; + search.dispatchEvent(new InputEvent("input")); + await nextTick(); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + otherNames, + "all other commands are present" + ); + + // Clear search input to recover the default provider + search.value = ""; + search.dispatchEvent(new InputEvent("input")); + await nextTick(); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + defaultNames, + "all default commands are again present" + ); +}); + +QUnit.test("multi level commands", async (assert) => { + assert.expect(5); + + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + + clearRegistryWithCleanup(commandProviderRegistry); + const defaultNames = ["John", "Snow"]; + const otherNames = ["Cersei", "Lannister"]; + + const action = () => ({ + placeHolder: "Who is the next King ?", + provide: () => + otherNames.map((name) => ({ + name, + action: () => {}, + })), + }); + + commandProviderRegistry.add("default", { + provide: () => + defaultNames.map((name) => ({ + name, + action, + })), + }); + + testComponent = await mount(TestComponent, { env, target }); + + // Open palette + triggerHotkey("control+k"); + await nextTick(); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").value, + "", + "search input is empty" + ); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "Search for an action...", + "the default placeholder is correctly displayed" + ); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + defaultNames, + "all default commands are present" + ); + + await click(target, ".o_command.focused"); + await nextTick(); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "Who is the next King ?", + "the new placeholder is correctly displayed" + ); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + otherNames, + "all default commands are present" + ); +}); + QUnit.test("navigate in the command palette with the arrows", async (assert) => { assert.expect(6); @@ -419,7 +632,7 @@ QUnit.test("data-command-category", async (assert) => { assert.deepEqual( [ ...target.querySelectorAll( - ".o_command_category:nth-of-type(1) .o_command > span:first-child" + ".o_command_category:nth-of-type(1) .o_command > div > span:first-child" ), ].map((el) => el.textContent), ["Robert baratheon", "Joffrey baratheon"] @@ -427,7 +640,7 @@ QUnit.test("data-command-category", async (assert) => { assert.deepEqual( [ ...target.querySelectorAll( - ".o_command_category:nth-of-type(2) .o_command > span:first-child" + ".o_command_category:nth-of-type(2) .o_command > div > span:first-child" ), ].map((el) => el.textContent), ["Aria stark", "Bran stark"]