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"]