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