[IMP] web: refactor command palette

This commit is a refactoring of the command palette. It aims to improve
its design and make it very easily configurable. For this, we added the
config props allowing the command palette to know what to display.

The config of a palette command is composed of:
- categoriesByNamespace?: list containing all categories according
  to their namespace;
- emptyMessageByNamespace?: dictionary having for key a namespace and
  for value the message to display if there is no choice available;
- footerTemplate?: template to display in the footer;
- namespace?: namespace to add in the search bar;
- placeholder?: placeholder of the searcher;
- providers: list containing all the providers generating the commands
  to display;
The providers are the only information needed in the config. The rest of
the config is optional.

This commit also adds the possibility to open a custom command palette
using the command service. For this, you just have to call openPalette
and give it the required config.

If the command action returns a config, the command palette  will
not be close and will use this new config.

Task-id: 2622240
Part-of: odoo/odoo#74933
This commit is contained in:
Francois (fge)
2021-08-26 13:56:14 +00:00
parent 61d9cb8098
commit 8b856c6699
13 changed files with 1319 additions and 272 deletions
@@ -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.
@@ -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"
@@ -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 });
@@ -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");
@@ -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,
});
@@ -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;
}
}
}
@@ -4,32 +4,32 @@
<t t-name="web.CommandPalette" owl="1">
<div>
<div class="o_command_palette_search">
<input type="text" class="o_command_palette_search" t-ref="search_bar" t-on-input="onDebouncedSearchInput" autofocus="" t-att-placeholder="state.placeHolder" />
<i title="Search for an action..." role="img" aria-label="Search for an action..." class="fa fa-search"></i>
<input type="text" t-model="state.searchValue" t-on-input="onDebouncedSearchInput" autofocus="" t-att-placeholder="state.placeholder"/>
<i t-att-title="state.placeholder" role="img" t-att-aria-label="state.placeholder" class="fa fa-search"></i>
</div>
<div class="o_command_palette_listbox">
<div t-if="!categories.length" class="o_command_palette_listbox_empty">No action found</div>
<div t-if="!state.commands.length" class="o_command_palette_listbox_empty" t-esc="state.emptyMessage"/>
<t t-if="!isFuzzySearch">
<t t-foreach="categories" t-as="category">
<div class="o_command_category" t-att-aria-label="category.label" t-key="category.keyId">
<t t-foreach="commandsByCategory" t-as="category">
<div class="o_command_category" t-key="category.keyId">
<t t-foreach="category.commands" t-as="command">
<t t-set="commandIndex" t-value="state.commands.indexOf(command)" />
<span t-attf-id="o_command_{{commandIndex}}" class="o_command" t-att-class="{ focused: state.selectedCommand === command }" t-on-click="onCommandClicked(commandIndex)" t-on-mouseenter="onCommandMouseEnter(commandIndex)" t-key="command.keyId">
<t t-component="command.Component || DefaultCommandItem" name="command.name" t-props="command.props" t-on-close="props.closeMe()" t-on-execute-command="executeCommand(command)"/>
</span>
</t>
</div>
<hr t-if="!category_last" />
</t>
</t>
</div>
<div t-if="state.footerTemplate" class="o_command_palette_footer">
<t t-call="{{ state.footerTemplate }}"/>
</div>
</div>
@@ -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`
<span>
<span class='o_promote'>TIP</span> — search for <span class='o_promote'>@</span>users, <span class='o_promote'>#</span>channels, and <span class='o_promote'>/</span>menus
</span>
`;
export const commandService = {
dependencies: ["dialog", "hotkey", "ui"],
start(env, { dialog, hotkey: hotkeyService, ui }) {
/** @type {Map<CommandRegistration>} */
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,
};
},
};
@@ -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: () => {
@@ -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"]);
});
});
@@ -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,
@@ -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`<span>My footer</span>`;
class TestComponent extends Component {
get DialogContainer() {
return registry.category("main_components").get("DialogContainer");
}
}
TestComponent.template = xml`
<div>
<div class="o_dialog_container"/>
<t t-component="DialogContainer.Component" t-props="DialogContainer.props" />
</div>
`;
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`
<div class="o_command_custom">
<span t-esc="props.name"/>
</div>
`;
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");
});
@@ -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`
</div>
`;
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`<div></div>`;
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`<div></div>`;
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 });