diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 83f74c70a39..6c19b498ec4 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -35,6 +35,35 @@ export function debounce(func, wait, immediate = false) { }[funcName]; } +/** + * Returns a function which returns a Promise, that, as long as it continues + * to be invoked, will not be triggered. The function (func) will be called + * after it stops being called for N milliseconds and the Promise will be resolved. + * + * @param {Function} func + * @param {number} wait + * @returns {Function} + */ +export function debouncePromise(func, wait) { + let resolver; + const funcName = func.name ? func.name + " (promise)" : "promise"; + const obj = { + [funcName]: (...args) => { + func.apply(this, args); + resolver(); + }, + }; + const debouncedFunc = debounce(obj[funcName], wait); + return { + [funcName](...args) { + return new Promise((resolve) => { + resolver = resolve; + debouncedFunc.apply(this, args); + }); + }, + }[funcName]; +} + /** * Returns a function, that, as long as it continues to be invoked, will be * triggered every N milliseconds. diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index f00478ac324..62827e01646 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -596,6 +596,7 @@ accesskey="Q" placeholder="Search..." role="searchbox" + title="Search for records" t-ref="search-input" t-on-keydown="_onSearchKeydown" t-on-input="_onSearchInput" diff --git a/addons/web/static/src/webclient/commands/command_category.js b/addons/web/static/src/webclient/commands/command_category.js index 40e630d8c40..697411f51eb 100644 --- a/addons/web/static/src/webclient/commands/command_category.js +++ b/addons/web/static/src/webclient/commands/command_category.js @@ -1,11 +1,11 @@ /** @odoo-module **/ -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 }); -commandCategoryRegistry.add("actions", { label: _lt("More Actions") }, { sequence: 30 }); -commandCategoryRegistry.add("navbar", { label: _lt("NavBar") }, { sequence: 40 }); -commandCategoryRegistry.add("default", { label: _lt("Other commands") }, { sequence: 100 }); -commandCategoryRegistry.add("debug", { label: _lt("Debug") }, { sequence: 110 }); +commandCategoryRegistry + .add("app", {}, { sequence: 10 }) + .add("actions", {}, { sequence: 30 }) + .add("navbar", {}, { sequence: 40 }) + .add("default", {}, { sequence: 100 }) + .add("debug", {}, { sequence: 110 }); diff --git a/addons/web/static/src/webclient/commands/command_hook.js b/addons/web/static/src/webclient/commands/command_hook.js index 13fff8ad997..fd9f60b9b81 100644 --- a/addons/web/static/src/webclient/commands/command_hook.js +++ b/addons/web/static/src/webclient/commands/command_hook.js @@ -3,7 +3,7 @@ import { useEffect, useService } from "@web/core/utils/hooks"; /** - * @typedef {import("./command_service").CommandServiceAddOptions} CommandServiceAddOptions + * @typedef {import("./command_service").CommandOptions} CommandOptions */ /** @@ -11,8 +11,8 @@ import { useEffect, useService } from "@web/core/utils/hooks"; * when the caller component will mount/unmount. * * @param {string} name - * @param {() => void} action - * @param {CommandServiceAddOptions} [options] + * @param {()=>(void | CommandPaletteConfig)} action + * @param {CommandOptions} [options] */ export function useCommand(name, action, options = {}) { const commandService = useService("command"); diff --git a/addons/web/static/src/webclient/commands/command_palette.js b/addons/web/static/src/webclient/commands/command_palette.js index c58a16588e9..f9cdd9bd5be 100644 --- a/addons/web/static/src/webclient/commands/command_palette.js +++ b/addons/web/static/src/webclient/commands/command_palette.js @@ -2,36 +2,46 @@ import { useAutofocus, useService } from "@web/core/utils/hooks"; import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; -import { registry } from "@web/core/registry"; 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"; +import { debouncePromise } from "@web/core/utils/timing"; +import { _lt } from "@web/core/l10n/translation"; const { Component, hooks } = owl; -const { useRef, useState } = hooks; +const { 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"); +const DEFAULT_PLACEHOLDER = _lt("Search..."); +const DEFAULT_EMPTY_MESSAGE = _lt("No results found"); +const FUZZY_NAMESPACES = ["default"]; /** - * @typedef {{ - * Component?: Component, - * action: ()=>(void | NextProvider), - * category?: string, - * name: string, - * props?: Object, + * @typedef {import("./command_service").Command} Command + */ + +/** + * @typedef {Command & { + * Component?: owl.Component; + * props?: object; * }} CommandItem */ /** * @typedef {{ - * placeHolder?: string, - * provide: ()=>CommandItem[], - * }} NextProvider + * namespace?: string; + * provide: ()=>CommandItem[]; + * }} Provider + */ + +/** + * @typedef {{ + * categoriesByNamespace?: {[namespace]: string[]}; + * namespace?: string; + * emptyMessageByNamespace?: {[namespace]: string}; + * footerTemplate?: string; + * placeholder?: string; + * providers: Provider[]; + * }} CommandPaletteConfig */ /** @@ -39,13 +49,13 @@ const commandProviderRegistry = registry.category("command_provider"); * Note: for the default category, also get all commands having invalid category. * * @param {string} categoryName the category key + * @param {string[]} categories * @returns an array filter predicate */ -function commandsWithinCategory(categoryName) { +function commandsWithinCategory(categoryName, categories) { return (cmd) => { const inCurrentCategory = categoryName === cmd.category; - const fallbackCategory = - categoryName === "default" && !commandCategoryRegistry.contains(cmd.category); + const fallbackCategory = categoryName === "default" && !categories.includes(cmd.category); return inCurrentCategory || fallbackCategory; }; } @@ -56,12 +66,15 @@ DefaultCommandItem.template = "web.DefaultCommandItem"; export class CommandPalette extends Component { setup() { 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); + const onDebouncedSearchInput = debouncePromise.apply(this, [this.onSearchInput, 250]); + this.onDebouncedSearchInput = (...args) => { + this.inputPromise = onDebouncedSearchInput.apply(this, args).catch(() => { + this.inputPromise = null; + }); + }; useAutofocus(); @@ -70,82 +83,97 @@ export class CommandPalette extends Component { useHotkey("ArrowDown", () => this.selectCommandAndScrollTo("NEXT"), { allowRepeat: true }); /** - * @type {{commands: CommandItem[], selectedCommand: CommandItem, placeHolder: String}} + * @type {{ commands: CommandItem[], + * emptyMessage: string, + * footerTemplate: string, + * placeholder: string, + * searchValue: string, + * selectedCommand: CommandItem }} */ 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, - }); + this.setCommandPaletteConfig(this.props.config); } - get categories() { + get commandsByCategory() { const categories = []; - 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; + for (const category of this.categoryKeys) { + const commands = this.state.commands.filter( + commandsWithinCategory(category, this.categoryKeys) + ); if (commands.length) { categories.push({ commands, - keyId: "default", - label: "", + keyId: category, }); } } return categories; } - get lastProvider() { - return this.providerStack[this.providerStack.length - 1]; + /** + * Apply the new config to the command pallet + * @param {CommandPaletteConfig} config + */ + setCommandPaletteConfig(config) { + const result = { default: [] }; + for (const provider of config.providers) { + const namespace = provider.namespace || "default"; + if (namespace in result) { + result[namespace].push(provider); + } else { + result[namespace] = [provider]; + } + } + this.categoriesByNamespace = config.categoriesByNamespace; + this.emptyMessageByNamespace = config.emptyMessageByNamespace || {}; + this.providersByNamespace = result; + + this.state.footerTemplate = config.footerTemplate; + + this.state.placeholder = config.placeholder || DEFAULT_PLACEHOLDER.toString(); + + const namespace = config.namespace || "default"; + this.setCommands(namespace, { + activeElement: this.activeElement, + searchValue: "", + }); + this.state.searchValue = namespace === "default" ? "" : namespace; } - async setCommands(key, options = {}) { - const proms = this.lastProvider[key].map((provider) => { + /** + * Modifies the commands to be displayed according to the namespace and the options. + * Selects the first command in the new list. + * @param {string} namespace + * @param {object} options + */ + async setCommands(namespace, options = {}) { + this.categoryKeys = ["default"]; + const proms = this.providersByNamespace[namespace].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; + let commands = (await this.keepLast.add(Promise.all(proms))).flat(); + if (options.searchValue && FUZZY_NAMESPACES.includes(namespace)) { 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); + if (this.categoriesByNamespace && this.categoriesByNamespace[namespace]) { + let commandsSorted = []; + this.categoryKeys = this.categoriesByNamespace[namespace]; + if (!this.categoryKeys.includes("default")) { + this.categoryKeys.push("default"); + } + for (const category of this.categoryKeys) { + commandsSorted = commandsSorted.concat( + commands.filter(commandsWithinCategory(category, this.categoryKeys)) + ); + } + commands = commandsSorted; } - commands = commandsSorted; } this.state.commands = commands.map((command) => ({ @@ -154,6 +182,8 @@ export class CommandPalette extends Component { })); this.selectCommand(this.state.commands.length ? 0 : -1); this.mouseSelectionActive = false; + this.state.emptyMessage = + this.emptyMessageByNamespace[namespace] || DEFAULT_EMPTY_MESSAGE.toString(); } selectCommand(index) { @@ -169,6 +199,9 @@ export class CommandPalette extends Component { // of a command caused by a scroll. this.mouseSelectionActive = false; const index = this.state.commands.indexOf(this.state.selectedCommand); + if (index === -1) { + return; + } let nextIndex; if (type === "NEXT") { nextIndex = index < this.state.commands.length - 1 ? index + 1 : 0; @@ -187,22 +220,23 @@ export class CommandPalette extends Component { this.executeSelectedCommand(); } + /** + * Execute the action related to the order. + * If this action returns a config, then we will use it in the command palette, + * otherwise we close the command palette. + * @param {CommandItem} command + */ async executeCommand(command) { - const result = await command.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 = ""; + const config = await command.action(); + if (config) { + this.setCommandPaletteConfig(config); } else { this.props.closeMe(); } } - executeSelectedCommand() { + async executeSelectedCommand() { + await this.inputPromise; if (this.state.selectedCommand) { this.executeCommand(this.state.selectedCommand); } @@ -217,13 +251,14 @@ export class CommandPalette extends Component { } async onSearchInput(ev) { - let searchValue = ev.target.value; - let key = "__default__"; - if (searchValue.length && searchValue[0] in this.lastProvider) { - key = searchValue[0]; + this.state.searchValue = ev.target.value; + let searchValue = this.state.searchValue; + let namespace = "default"; + if (searchValue.length && searchValue[0] in this.providersByNamespace) { + namespace = searchValue[0]; searchValue = searchValue.slice(1); } - this.setCommands(key, { + this.setCommands(namespace, { searchValue, activeElement: this.activeElement, }); diff --git a/addons/web/static/src/webclient/commands/command_palette.scss b/addons/web/static/src/webclient/commands/command_palette.scss index 5427d607a44..4212c800162 100644 --- a/addons/web/static/src/webclient/commands/command_palette.scss +++ b/addons/web/static/src/webclient/commands/command_palette.scss @@ -15,10 +15,14 @@ align-items: center; justify-content: space-between; background-color: white; - border-bottom: 1px solid #ccc; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding: 0.8rem 1em; font-size: 1.5rem; + .fa-search { + color:#C7C7C7; + } + input { border: none; padding: 0; @@ -27,6 +31,7 @@ } &_listbox { + cursor: default; max-height: 50vh; overflow-y: auto; position: relative; @@ -57,7 +62,7 @@ height: 2.8em; &.focused { - background: rgba(0, 160, 157, 0.15); + background: rgba($o-brand-primary, 0.15); } &_default, &_hotkey { @@ -85,4 +90,17 @@ } } } + + &_footer { + color: #707070; + background-color: #f6f7fa; + font-size: 1rem; + padding: 0.6rem 1.3em; + border-top: 1px solid rgba(0, 0, 0, 0.1); + + .o_promote { + color: $o-brand-primary; + font-weight: 900; + } + } } diff --git a/addons/web/static/src/webclient/commands/command_palette.xml b/addons/web/static/src/webclient/commands/command_palette.xml index d7b6c6c4f75..5537f14664a 100644 --- a/addons/web/static/src/webclient/commands/command_palette.xml +++ b/addons/web/static/src/webclient/commands/command_palette.xml @@ -4,32 +4,32 @@
- -
No action found
+
- - -
+ +
-

+
+
diff --git a/addons/web/static/src/webclient/commands/command_service.js b/addons/web/static/src/webclient/commands/command_service.js index 28e602b0da2..8737df373db 100644 --- a/addons/web/static/src/webclient/commands/command_service.js +++ b/addons/web/static/src/webclient/commands/command_service.js @@ -3,49 +3,99 @@ import { registry } from "@web/core/registry"; import { CommandPaletteDialog } from "./command_palette_dialog"; +const { xml } = owl.tags; + /** - * @typedef {import("./command_palette").NextProvider} NextProvider + * @typedef {import("./command_palette").CommandPaletteConfig} CommandPaletteConfig */ /** * @typedef {{ - * name: string, - * action: ()=>(void | NextProvider), - * category?: string, - * hotkey?: string, - * hotkeyOptions?: any, + * name: string; + * action: ()=>(void | CommandPaletteConfig); + * category?: string; * }} Command */ /** * @typedef {{ * category?: string; + * global?: boolean; * hotkey?: string; - * hotkeyOptions?: any; - * }} CommandServiceAddOptions + * }} CommandOptions */ +/** + * @typedef {Command & CommandOptions & { + * activeElement?: HTMLElement; + * removeHotkey?: ()=>void; + * }} CommandRegistration + */ + +const commandCategoryRegistry = registry.category("command_categories"); +const commandEmptyMessageRegistry = registry.category("command_empty_list"); +const commandProviderRegistry = registry.category("command_provider"); + +const footerTemplate = xml` + + TIP — search for @users, #channels, and /menus + +`; + export const commandService = { dependencies: ["dialog", "hotkey", "ui"], start(env, { dialog, hotkey: hotkeyService, ui }) { + /** @type {Map} */ const registeredCommands = new Map(); let nextToken = 0; let isPaletteOpened = false; - hotkeyService.add("control+k", openPalette, { + hotkeyService.add("control+k", openMainPalette, { global: true, }); - function openPalette() { + function openMainPalette() { + const categoriesByNamespace = {}; + commandCategoryRegistry.getEntries().forEach(([category, el]) => { + const namespace = el.namespace ? el.namespace : "default"; + if (namespace in categoriesByNamespace) { + categoriesByNamespace[namespace].push(category); + } else { + categoriesByNamespace[namespace] = [category]; + } + }); + + const emptyMessageByNamespace = {}; + commandEmptyMessageRegistry.getEntries().forEach(([key, message]) => { + emptyMessageByNamespace[key] = message.toString(); + }); + + const config = { + categoriesByNamespace, + emptyMessageByNamespace, + footerTemplate, + placeholder: env._t("Search for a command..."), + providers: commandProviderRegistry.getAll(), + }; + return openPalette(config); + } + + /** + * @param {CommandPaletteConfig} config + * @returns config if the command palette is already open + */ + function openPalette(config) { if (isPaletteOpened) { - return; + return config; } - // Open palette dialog + // Open Command Palette dialog isPaletteOpened = true; dialog.add( CommandPaletteDialog, - {}, + { + config, + }, { onClose: () => { isPaletteOpened = false; @@ -56,20 +106,23 @@ export const commandService = { /** * @param {Command} command + * @param {CommandOptions} options * @returns {number} token */ - function registerCommand(command) { + function registerCommand(command, options) { if (!command.name || !command.action || typeof command.action !== "function") { throw new Error("A Command must have a name and an action function."); } - const registration = Object.assign({}, command, { activeElement: null }); + const registration = Object.assign({}, command, options, { activeElement: null }); - if (command.hotkey) { + if (registration.hotkey) { registration.removeHotkey = hotkeyService.add( - command.hotkey, - command.action, - command.hotkeyOptions + registration.hotkey, + registration.action, + { + global: registration.global, + } ); } @@ -102,18 +155,12 @@ export const commandService = { return { /** * @param {string} name - * @param {() => void} action - * @param {CommandServiceAddOptions} [options] + * @param {()=>(void | CommandPaletteConfig)} action + * @param {CommandOptions} [options] * @returns {() => void} */ add(name, action, options = {}) { - const token = registerCommand({ - name, - action, - category: options.category, - hotkey: options.hotkey, - hotkeyOptions: options.hotkeyOptions, - }); + const token = registerCommand({ name, action }, options); return () => { unregisterCommand(token); }; @@ -124,9 +171,10 @@ export const commandService = { */ getCommands(activeElement) { return [...registeredCommands.values()].filter( - (command) => command.activeElement === activeElement + (command) => command.activeElement === activeElement || command.global ); }, + openPalette, }; }, }; diff --git a/addons/web/static/src/webclient/commands/default_providers.js b/addons/web/static/src/webclient/commands/default_providers.js index 615992da6eb..6e17242c415 100644 --- a/addons/web/static/src/webclient/commands/default_providers.js +++ b/addons/web/static/src/webclient/commands/default_providers.js @@ -2,6 +2,7 @@ import { isMacOS } from "@web/core/browser/feature_detection"; import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { capitalize } from "@web/core/utils/strings"; import { getVisibleElements } from "@web/core/utils/ui"; @@ -28,11 +29,21 @@ export class HotkeyCommandItem extends Component { } } HotkeyCommandItem.template = "web.HotkeyCommandItem"; +const commandEmptyMessageRegistry = registry.category("command_empty_list"); +commandEmptyMessageRegistry.add("default", _lt("No commands found")); + +const commandCategoryRegistry = registry.category("command_categories"); const commandProviderRegistry = registry.category("command_provider"); commandProviderRegistry.add("command", { provide: (env, options = {}) => { - const commands = env.services.command.getCommands(options.activeElement); + const commands = env.services.command.getCommands(options.activeElement).map((cmd) => { + cmd.category = commandCategoryRegistry.contains(cmd.category) + ? cmd.category + : "default"; + return cmd; + }); + return commands.map((command) => ({ Component: command.hotkey ? HotkeyCommandItem : DefaultCommandItem, action: command.action, @@ -64,7 +75,8 @@ commandProviderRegistry.add("data-hotkeys", { el.placeholder || (el.innerText && `${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) || - "no description provided"; + env._t("no description provided"); + commands.push({ Component: HotkeyCommandItem, action: () => { diff --git a/addons/web/static/tests/core/utils/timing_tests.js b/addons/web/static/tests/core/utils/timing_tests.js new file mode 100644 index 00000000000..8a92406a179 --- /dev/null +++ b/addons/web/static/tests/core/utils/timing_tests.js @@ -0,0 +1,19 @@ +/** @odoo-module **/ + +import { debouncePromise } from "@web/core/utils/timing"; + +QUnit.module("utils", () => { + QUnit.module("timing"); + + QUnit.test("debouncePromise", async function (assert) { + const myFunc = () => { + assert.step("exec"); + }; + const myDebouncedFunc = debouncePromise(myFunc, 10); + let toAwait = myDebouncedFunc(); + assert.verifySteps([]); + toAwait = myDebouncedFunc(); + await toAwait; + assert.verifySteps(["exec"]); + }); +}); diff --git a/addons/web/static/tests/helpers/mock_services.js b/addons/web/static/tests/helpers/mock_services.js index 00c0183d534..1b9a45fe211 100644 --- a/addons/web/static/tests/helpers/mock_services.js +++ b/addons/web/static/tests/helpers/mock_services.js @@ -171,6 +171,20 @@ export function makeFakeRouterService(params = {}) { }; } +export const fakeCommandService = { + start() { + return { + add() { + return () => {}; + }, + getCommands() { + return []; + }, + openPalette() {}, + }; + }, +}; + export const fakeCookieService = { start() { const cookie = {}; @@ -234,6 +248,7 @@ export function makeFakeDialogService(addDialog) { export const mocks = { company: () => companyService, + command: () => fakeCommandService, cookie: () => fakeCookieService, effect: () => effectService, // BOI The real service ? Is this what we want ? localization: makeFakeLocalizationService, diff --git a/addons/web/static/tests/webclient/commands/command_palette_tests.js b/addons/web/static/tests/webclient/commands/command_palette_tests.js new file mode 100644 index 00000000000..05b137cdaf4 --- /dev/null +++ b/addons/web/static/tests/webclient/commands/command_palette_tests.js @@ -0,0 +1,864 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { dialogService } from "@web/core/dialog/dialog_service"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { registry } from "@web/core/registry"; +import { uiService } from "@web/core/ui/ui_service"; +import { CommandPaletteDialog } from "@web/webclient/commands/command_palette_dialog"; +import { commandService } from "@web/webclient/commands/command_service"; +import { makeTestEnv } from "../../helpers/mock_env"; +import { click, getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils"; +import { editSearchBar } from "./command_service_tests"; + +const { Component, mount, tags } = owl; +const { xml } = tags; + +let env; +let target; +let testComponent; +const serviceRegistry = registry.category("services"); + +const footerTemplate = xml`My footer`; + +class TestComponent extends Component { + get DialogContainer() { + return registry.category("main_components").get("DialogContainer"); + } +} +TestComponent.template = xml` +
+
+ +
+`; + +QUnit.module("Command Palette Dialog", { + async beforeEach() { + serviceRegistry.add("ui", uiService); + serviceRegistry.add("dialog", dialogService); + serviceRegistry.add("hotkey", hotkeyService); + serviceRegistry.add("command", commandService); + + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); + + env = await makeTestEnv(); + target = getFixture(); + }, + afterEach() { + if (testComponent) { + testComponent.destroy(); + } + }, +}); + +QUnit.test("empty providers", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const config = { + providers: [], + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + "No results found" + ); + assert.strictEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "Search..." + ); + assert.containsNone(target, ".o_command_palette_footer"); +}); + +QUnit.test("custom empty message", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const emptyMessageByNamespace = { + default: "Empty Default", + "@": "Empty @", + "#": "Empty #", + }; + const provide = () => []; + const providers = [ + { namespace: "@", provide }, + { namespace: "#", provide }, + ]; + const config = { + emptyMessageByNamespace, + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + emptyMessageByNamespace["default"] + ); + + await editSearchBar("@"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + emptyMessageByNamespace["@"] + ); + + await editSearchBar("#"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + emptyMessageByNamespace["#"] + ); +}); + +QUnit.test("custom placeholder", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const config = { + placeholder: "placeholder test", + providers: [], + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "placeholder test" + ); +}); + +QUnit.test("add a footer", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const config = { + providers: [], + footerTemplate, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_footer"); + assert.strictEqual(target.querySelector(".o_command_palette_footer").textContent, "My footer"); +}); + +QUnit.test("command with a Custom Component", async (assert) => { + class CustomComponent extends Component {} + CustomComponent.template = xml` +
+ +
+ `; + + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const providers = [ + { + provide: () => [ + { + Component: CustomComponent, + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + ], + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2"] + ); + assert.deepEqual( + [...target.querySelectorAll(".o_command .o_command_default")].map((el) => el.textContent), + ["Command2"] + ); + assert.deepEqual( + [...target.querySelectorAll(".o_command .o_command_custom")].map((el) => el.textContent), + ["Command1"] + ); +}); + +QUnit.test("multi namespace with provider", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + ], + }, + { + namespace: "@", + provide: () => [ + { + name: "Command3", + action, + }, + { + name: "Command4", + action, + }, + ], + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2"] + ); + + await editSearchBar("@"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command3", "Command4"] + ); +}); + +QUnit.test("apply a fuzzysearch on the namespace default not on the others", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + ], + }, + { + namespace: "@", + provide: () => [ + { + name: "Command3", + action, + }, + { + name: "Command4", + action, + }, + ], + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2"] + ); + await editSearchBar("c1"); + assert.containsN(target, ".o_command", 1); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1"] + ); + + await editSearchBar("@"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command3", "Command4"] + ); + await editSearchBar("@c3"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command3", "Command4"] + ); +}); + +QUnit.test("multi provider with the same namespace", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + ], + }, + { + provide: () => [ + { + name: "Command3", + action, + }, + { + name: "Command4", + action, + }, + ], + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 4); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2", "Command3", "Command4"] + ); +}); + +QUnit.test("open the command palette with a namespace already in the searchbar", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + ], + }, + { + namespace: "@", + provide: () => [ + { + name: "Command3", + action, + }, + { + name: "Command4", + action, + }, + ], + }, + ]; + const config = { + namespace: "@", + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.strictEqual(target.querySelector(".o_command_palette_search input").value, "@"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command3", "Command4"] + ); +}); + +QUnit.test("multi provider with categories", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const categoriesByNamespace = { + default: ["cat1", "cat2"], + "@": ["@cat1", "@cat2"], + }; + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + category: "cat1", + }, + { + name: "Command2", + action, + category: "cat2", + }, + { + name: "Command3", + action, + }, + ], + }, + { + namespace: "@", + provide: () => [ + { + name: "Command4", + action, + }, + { + name: "Command5", + action, + category: "@cat2", + }, + { + name: "Command6", + action, + category: "@cat1", + }, + { + name: "Command7", + action, + category: "@cat1", + }, + ], + }, + ]; + const config = { + categoriesByNamespace, + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 3); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2", "Command3"] + ); + assert.containsN(target, ".o_command_category", 3); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(1) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command1"] + ); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(2) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command2"] + ); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(3) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command3"] + ); + + await editSearchBar("@"); + await nextTick(); + assert.containsN(target, ".o_command", 4); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command6", "Command7", "Command5", "Command4"] + ); + assert.containsN(target, ".o_command_category", 3); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(1) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command6", "Command7"] + ); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(2) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command5"] + ); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(3) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command4"] + ); +}); + +QUnit.test("don't display by categories if there is a search value", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const categoriesByNamespace = { + default: ["cat1", "cat2"], + }; + const action = () => {}; + const providers = [ + { + provide: () => [ + { + name: "Command1", + action, + category: "cat1", + }, + { + name: "Command2", + action, + category: "cat2", + }, + { + name: "Command3", + action, + }, + ], + }, + ]; + const config = { + categoriesByNamespace, + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 3); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2", "Command3"] + ); + assert.containsN(target, ".o_command_category", 3); + + await editSearchBar("c"); + await nextTick(); + assert.containsN(target, ".o_command", 3); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2", "Command3"] + ); + + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(1) .o_command > div > span:first-child" + ), + ].map((el) => el.textContent), + ["Command1", "Command2", "Command3"] + ); +}); + +QUnit.test("click on command", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const commands = [ + { + name: "Command1", + action: () => { + assert.step("C1"); + }, + }, + { + name: "Command2", + action: () => { + assert.step("C2"); + }, + }, + ]; + const providers = [ + { + provide: () => commands, + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2"] + ); + let focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[0].name); + await click(target, ".o_command.focused"); + assert.verifySteps(["C1"]); +}); + +QUnit.test("press enter on command", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const commands = [ + { + name: "Command1", + action: () => { + assert.step("C1"); + }, + }, + { + name: "Command2", + action: () => { + assert.step("C2"); + }, + }, + ]; + const providers = [ + { + provide: () => commands, + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1", "Command2"] + ); + let focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[0].name); + triggerHotkey("arrowdown"); + await nextTick(); + triggerHotkey("enter"); + await nextTick(); + + assert.verifySteps(["C2"]); +}); + +QUnit.test("multi level command", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + const emptyMessageByNamespace = { + default: "Empty Default", + }; + const commands = [ + { + name: "Command1", + action: () => { + return { + providers: [{ provide: () => [{ name: "Command lvl2", action: () => {} }] }], + }; + }, + }, + ]; + const providers = [ + { + provide: () => commands, + }, + ]; + const config = { + emptyMessageByNamespace, + footerTemplate, + placeholder: "placeholder test", + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + await editSearchBar("empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + "Empty Default" + ); + assert.strictEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "placeholder test" + ); + assert.containsOnce(target, ".o_command_palette_footer"); + assert.strictEqual(target.querySelector(".o_command_palette_footer").textContent, "My footer"); + + await editSearchBar(""); + assert.containsN(target, ".o_command", 1); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command1"] + ); + let focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[0].name); + triggerHotkey("enter"); + await nextTick(); + assert.containsN(target, ".o_command", 1); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Command lvl2"] + ); + + // check that the configuration has been correctly cleaned + await editSearchBar("empty"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + "No results found" + ); + assert.strictEqual( + target.querySelector(".o_command_palette_search input").placeholder, + "Search..." + ); + assert.containsNone(target, ".o_command_palette_footer"); +}); + +QUnit.test("command palette dialog can be rendered and closed on outside click", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + + const config = { + providers: [], + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + + // Close on outside click + window.dispatchEvent(new MouseEvent("mousedown")); + await nextTick(); + assert.containsNone(target, ".o_command_palette"); +}); + +QUnit.test("navigate in the command palette with the arrows", async (assert) => { + assert.expect(6); + + testComponent = await mount(TestComponent, { env, target }); + const action = () => {}; + const commands = [ + { + name: "Command1", + action, + }, + { + name: "Command2", + action, + }, + { + name: "Command3", + action, + }, + ]; + const providers = [ + { + provide: () => commands, + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + + let focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[0].name); + + triggerHotkey("arrowdown"); + await nextTick(); + focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[1].name); + + triggerHotkey("arrowdown"); + await nextTick(); + focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[2].name); + + triggerHotkey("arrowdown"); + await nextTick(); + focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[0].name); + + triggerHotkey("arrowup"); + await nextTick(); + focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[2].name); + + triggerHotkey("arrowup"); + await nextTick(); + focusedCommand = target.querySelector(".o_command.focused"); + assert.strictEqual(focusedCommand.textContent, commands[1].name); +}); + +QUnit.test("navigate in the command palette with an empty list", async (assert) => { + assert.expect(6); + + testComponent = await mount(TestComponent, { env, target }); + const providers = [ + { + provide: () => [], + }, + ]; + const config = { + providers, + }; + env.services.dialog.add(CommandPaletteDialog, { + config, + }); + await nextTick(); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + + triggerHotkey("arrowdown"); + await nextTick(); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); + + triggerHotkey("arrowup"); + await nextTick(); + assert.containsNone(target, ".o_command"); + assert.containsOnce(target, ".o_command_palette_listbox_empty"); +}); 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 d2cc07474d8..938eaf5258d 100644 --- a/addons/web/static/tests/webclient/commands/command_service_tests.js +++ b/addons/web/static/tests/webclient/commands/command_service_tests.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; import { browser } from "@web/core/browser/browser"; import { dialogService } from "@web/core/dialog/dialog_service"; import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; @@ -8,6 +9,7 @@ import { uiService, useActiveElement } from "@web/core/ui/ui_service"; import { useCommand } from "@web/webclient/commands/command_hook"; import { commandService } from "@web/webclient/commands/command_service"; import { HotkeyCommandItem } from "@web/webclient/commands/default_providers"; +import testUtils from "web.test_utils"; import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env"; import { click, getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils"; @@ -21,6 +23,13 @@ const serviceRegistry = registry.category("services"); const commandCategoryRegistry = registry.category("command_categories"); const commandProviderRegistry = registry.category("command_provider"); +export async function editSearchBar(searchValue) { + const searchBar = document.querySelector(".o_command_palette_search input"); + await testUtils.fields.editInput(searchBar, searchValue); + searchBar.dispatchEvent(new Event("keydown")); + await nextTick(); +} + class TestComponent extends Component { get DialogContainer() { return registry.category("main_components").get("DialogContainer"); @@ -33,7 +42,7 @@ TestComponent.template = xml`
`; -QUnit.module("Command", { +QUnit.module("Command Service", { async beforeEach() { const commandProviders = commandProviderRegistry .getEntries() @@ -43,14 +52,22 @@ QUnit.module("Command", { commandProviderRegistry.add(provider[0], provider[1]); }); - serviceRegistry.add("ui", uiService); - serviceRegistry.add("dialog", dialogService); - serviceRegistry.add("hotkey", hotkeyService); - serviceRegistry.add("command", commandService); + serviceRegistry + .add("ui", uiService) + .add("dialog", dialogService) + .add("hotkey", hotkeyService) + .add("localization", makeFakeLocalizationService()) + .add("command", commandService); + + commandCategoryRegistry.add("custom-nolabel", {}).add("custom", {}).add("default", {}); + + patchWithCleanup(browser, { + clearTimeout: () => {}, + setTimeout: (later, wait) => { + later(); + }, + }); - commandCategoryRegistry.add("custom-nolabel", {}); - commandCategoryRegistry.add("custom", { label: "Custom" }); - commandCategoryRegistry.add("default", { label: "Other commands" }); env = await makeTestEnv(); target = getFixture(); }, @@ -61,20 +78,6 @@ QUnit.module("Command", { }, }); -QUnit.test("palette dialog can be rendered and closed on outside click", async (assert) => { - testComponent = await mount(TestComponent, { env, target }); - - // invoke command palette through hotkey control+k - triggerHotkey("control+k"); - await nextTick(); - assert.containsOnce(target, ".o_command_palette"); - - // Close on outside click - window.dispatchEvent(new MouseEvent("mousedown")); - await nextTick(); - assert.containsNone(target, ".o_command_palette"); -}); - QUnit.test("commands evilness 👹", async (assert) => { const command = env.services.command; function action() {} @@ -124,11 +127,12 @@ QUnit.test("useCommand hook", async (assert) => { }); QUnit.test("useCommand hook when the activeElement change", async (assert) => { - assert.expect(2); + assert.expect(4); class MyComponent extends TestComponent { setup() { useCommand("Take the throne", () => {}); + useCommand("Lose the throne", () => {}, { global: true }); } } @@ -141,12 +145,24 @@ QUnit.test("useCommand hook when the activeElement change", async (assert) => { OtherComponent.template = xml`
`; await mount(MyComponent, { env, target }); + triggerHotkey("control+k"); + await nextTick(); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((e) => e.textContent), + ["Take the throne", "Lose the throne"] + ); + triggerHotkey("escape"); + await mount(OtherComponent, { env, target }); triggerHotkey("control+k"); await nextTick(); - assert.containsOnce(target, ".o_command"); - assert.deepEqual(target.querySelector(".o_command").textContent, "I'm taking the throne"); + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((e) => e.textContent), + ["Lose the throne", "I'm taking the throne"] + ); }); QUnit.test("command with hotkey", async (assert) => { @@ -163,6 +179,41 @@ QUnit.test("command with hotkey", async (assert) => { assert.verifySteps([hotkey]); }); +QUnit.test("global command with hotkey", async (assert) => { + assert.expect(5); + + const globalHotkey = "a"; + env.services.command.add("testA", () => assert.step(globalHotkey), { + global: true, + hotkey: globalHotkey, + }); + const hotkey = "b"; + env.services.command.add("testB", () => assert.step(hotkey), { + hotkey, + }); + await nextTick(); + + triggerHotkey("a"); + await nextTick(); + triggerHotkey("b"); + await nextTick(); + assert.verifySteps([globalHotkey, hotkey]); + + class MyComponent extends Component { + setup() { + useActiveElement(); + } + } + MyComponent.template = xml`
`; + await mount(MyComponent, { env, target }); + + triggerHotkey("a"); + await nextTick(); + triggerHotkey("b"); + await nextTick(); + assert.verifySteps([globalHotkey]); +}); + QUnit.test("data-hotkey added to command palette", async (assert) => { assert.expect(8); @@ -283,13 +334,6 @@ 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 @@ -317,10 +361,7 @@ QUnit.test("can be searched", async (assert) => { ); // Search something - let search = target.querySelector(".o_command_palette_search input"); - search.value = "jl"; - search.dispatchEvent(new InputEvent("input")); - await nextTick(); + await editSearchBar("jl"); assert.deepEqual( [...target.querySelectorAll(".o_command")].map((el) => el.textContent), @@ -329,9 +370,7 @@ QUnit.test("can be searched", async (assert) => { ); // Clear search input - search.value = ""; - search.dispatchEvent(new InputEvent("input")); - await nextTick(); + await editSearchBar(""); assert.deepEqual( [...target.querySelectorAll(".o_command")].map((el) => el.textContent), @@ -340,22 +379,53 @@ QUnit.test("can be searched", async (assert) => { ); }); -QUnit.test("defined multiple providers with the same nameSpace", async (assert) => { +QUnit.test("configure the empty message based on the namespace", async (assert) => { assert.expect(2); - patchWithCleanup(browser, { - clearTimeout: () => {}, - setTimeout: (later, wait) => { - later(); - }, + clearRegistryWithCleanup(commandProviderRegistry); + + commandProviderRegistry.add("default", { + namespace: "default", + provide: () => [], }); + commandProviderRegistry.add("@", { + namespace: "@", + provide: () => [], + }); + + const commandEmptyMessageRegistry = registry.category("command_empty_list"); + clearRegistryWithCleanup(commandEmptyMessageRegistry); + commandEmptyMessageRegistry.add("default", "Empty Default"); + commandEmptyMessageRegistry.add("@", "Empty @"); + + testComponent = await mount(TestComponent, { env, target }); + + // Open palette + triggerHotkey("control+k"); + await nextTick(); + + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + "Empty Default" + ); + + await editSearchBar("@"); + assert.strictEqual( + target.querySelector(".o_command_palette_listbox_empty").textContent, + "Empty @" + ); +}); + +QUnit.test("defined multiple providers with the same namespace", async (assert) => { + assert.expect(2); + clearRegistryWithCleanup(commandProviderRegistry); const action = () => {}; const defaultNames = ["John", "Snow"]; commandProviderRegistry.add("default", { - nameSpace: "__default__", + namespace: "default", provide: () => defaultNames.map((name) => ({ action, @@ -394,13 +464,6 @@ QUnit.test("defined multiple providers with the same nameSpace", async (assert) QUnit.test("can switch between command providers", async (assert) => { assert.expect(4); - patchWithCleanup(browser, { - clearTimeout: () => {}, - setTimeout: (later, wait) => { - later(); - }, - }); - clearRegistryWithCleanup(commandProviderRegistry); const action = () => {}; @@ -415,7 +478,7 @@ QUnit.test("can switch between command providers", async (assert) => { const otherNames = ["Cersei", "Lannister"]; commandProviderRegistry.add("other", { - nameSpace: "@", + namespace: "@", provide: () => otherNames.map((name) => ({ action, @@ -442,10 +505,7 @@ QUnit.test("can switch between command providers", async (assert) => { ); // Switch to the other provider - let search = target.querySelector(".o_command_palette_search input"); - search.value = "@"; - search.dispatchEvent(new InputEvent("input")); - await nextTick(); + await editSearchBar("@"); assert.deepEqual( [...target.querySelectorAll(".o_command")].map((el) => el.textContent), @@ -454,9 +514,7 @@ QUnit.test("can switch between command providers", async (assert) => { ); // Clear search input to recover the default provider - search.value = ""; - search.dispatchEvent(new InputEvent("input")); - await nextTick(); + await editSearchBar(""); assert.deepEqual( [...target.querySelectorAll(".o_command")].map((el) => el.textContent), @@ -468,24 +526,21 @@ QUnit.test("can switch between command providers", async (assert) => { 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) => ({ - action: () => {}, - name, - })), + placeholder: "Who is the next King ?", + providers: [ + { + provide: () => + otherNames.map((name) => ({ + name, + action: () => {}, + })), + }, + ], }); commandProviderRegistry.add("default", { @@ -510,7 +565,7 @@ QUnit.test("multi level commands", async (assert) => { assert.deepEqual( target.querySelector(".o_command_palette_search input").placeholder, - "Search for an action...", + "Search for a command...", "the default placeholder is correctly displayed" ); @@ -539,23 +594,20 @@ QUnit.test("multi level commands", async (assert) => { QUnit.test("multi level commands with hotkey", async (assert) => { assert.expect(5); - patchWithCleanup(browser, { - clearTimeout: () => {}, - setTimeout: (later, wait) => { - later(); - }, - }); - clearRegistryWithCleanup(commandProviderRegistry); const otherNames = ["Cersei", "Lannister"]; const action = () => ({ - placeHolder: "Who is the next King ?", - provide: () => - otherNames.map((name) => ({ - action: () => {}, - name, - })), + placeholder: "Who is the next King ?", + providers: [ + { + provide: () => + otherNames.map((name) => ({ + name, + action: () => {}, + })), + }, + ], }); const hotkey = "a"; @@ -587,7 +639,7 @@ QUnit.test("multi level commands with hotkey", async (assert) => { assert.deepEqual( target.querySelector(".o_command_palette_search input").placeholder, - "Search for an action...", + "Search for a command...", "the default placeholder is correctly displayed" ); @@ -613,52 +665,6 @@ QUnit.test("multi level commands with hotkey", async (assert) => { ); }); -QUnit.test("navigate in the command palette with the arrows", async (assert) => { - assert.expect(6); - - testComponent = await mount(TestComponent, { env, target }); - - // Register some commands - function action() {} - const names = ["Cersei", "Jaime", "Tyrion"]; - for (const name of names) { - env.services.command.add(name, action); - } - await nextTick(); - - // Open palette - triggerHotkey("control+k"); - await nextTick(); - - let focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[0]); - - triggerHotkey("arrowdown"); - await nextTick(); - focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[1]); - - triggerHotkey("arrowdown"); - await nextTick(); - focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[2]); - - triggerHotkey("arrowdown"); - await nextTick(); - focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[0]); - - triggerHotkey("arrowup"); - await nextTick(); - focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[2]); - - triggerHotkey("arrowup"); - await nextTick(); - focusedCommand = target.querySelector(".o_command.focused"); - assert.strictEqual(focusedCommand.textContent, names[1]); -}); - QUnit.test("command categories", async (assert) => { testComponent = await mount(TestComponent, { env, target });