[IMP] web: add hotkey and command services

Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Francois (fge) <fge@odoo.com>
This commit is contained in:
Bruno Boi
2021-06-18 21:31:27 +02:00
committed by Aaron Bohy
co-authored by Francois
parent 0573acae23
commit 3a4f1c31de
17 changed files with 1681 additions and 7 deletions
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { useHotkey } from "../hotkey_hook";
import { useActiveElement } from "../ui_service";
const { Component, hooks, misc, QWeb } = owl;
@@ -10,6 +11,15 @@ export class Dialog extends Component {
setup() {
this.modalRef = useRef("modal");
useActiveElement("modal");
useHotkey(
"escape",
() => {
if (!this.modalRef.el.classList.contains("o_inactive_modal")) {
this.close();
}
},
{ altIsOptional: true }
);
useSubEnv({ inDialog: true });
}
@@ -6,10 +6,12 @@ import { scrollTo } from "../utils/scrolling";
import { ParentClosingMode } from "./dropdown_item";
const { Component, core, hooks, useState, QWeb } = owl;
const { useExternalListener, onMounted, onWillStart } = hooks;
const { useExternalListener, onMounted, onPatched, onWillStart } = hooks;
export class Dropdown extends Component {
setup() {
this.hotkeyService = useService("hotkey");
this.hotkeyTokens = [];
this.state = useState({ open: this.props.startOpen, groupIsOpen: this.props.startOpen });
this.ui = useService("ui");
@@ -31,6 +33,16 @@ export class Dropdown extends Component {
});
});
function autoSubscribeKeynav() {
if (this.state.open) {
this.subscribeKeynav();
} else {
this.unsubscribeKeynav();
}
}
onMounted(autoSubscribeKeynav.bind(this));
onPatched(autoSubscribeKeynav.bind(this));
onWillStart(() => {
if ((this.state.open || this.state.groupIsOpen) && this.props.beforeOpen) {
return this.props.beforeOpen();
@@ -82,6 +94,43 @@ export class Dropdown extends Component {
}
}
subscribeKeynav() {
if (this.hotkeyTokens.length) {
return;
}
const subs = {
arrowup: () => this.setActiveItem("PREV"),
arrowdown: () => this.setActiveItem("NEXT"),
"shift+arrowup": () => this.setActiveItem("FIRST"),
"shift+arrowdown": () => this.setActiveItem("LAST"),
enter: () => {
const activeItem = this.el.querySelector(
":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active"
);
if (activeItem) {
activeItem.click();
}
},
escape: this.close.bind(this),
};
this.hotkeyTokens = [];
for (const [hotkey, callback] of Object.entries(subs)) {
this.hotkeyTokens.push(
this.hotkeyService.registerHotkey(hotkey, callback, {
altIsOptional: true,
allowRepeat: true,
})
);
}
}
unsubscribeKeynav() {
this.hotkeyTokens.forEach((tokenId) => this.hotkeyService.unregisterHotkey(tokenId));
this.hotkeyTokens = [];
}
close() {
return this.changeStateAndNotify({ open: false, groupIsOpen: false });
}
@@ -8,7 +8,8 @@
t-att-class="{ 'o_dropdown_active': state.open }"
t-on-click="onTogglerClick"
t-on-mouseenter="onTogglerMouseEnter"
t-att-title="props.title">
t-att-title="props.title"
t-att-data-hotkey="props.hotkey">
<span>
<t t-slot="default" />
</span>
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.DropdownItem" owl="1">
<li class="o_dropdown_item" t-on-click="onClick" t-att-title="props.title">
<li class="o_dropdown_item" t-on-click="onClick" t-att-title="props.title" t-att-data-hotkey="props.hotkey">
<span>
<t t-slot="default" />
</span>
@@ -0,0 +1,11 @@
/** @odoo-module **/
import { _lt } from "../../core/l10n/translation";
import { registry } from "../../core/registry";
const commandCategoryRegistry = registry.category("command_categories");
commandCategoryRegistry.add("app", { label: _lt("Current App Commands") }, { sequence: 10 });
commandCategoryRegistry.add("mail", { label: _lt("Discuss") }, { sequence: 20 });
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 });
@@ -0,0 +1,27 @@
/** @odoo-module **/
import { useService } from "../../core/service_hook";
const { hooks } = owl;
const { onMounted, onWillUnmount } = hooks;
/**
* @typedef {import("./command_service").Command} Command
*/
/**
* This hook will subscribe/unsubscribe the given subscription
* when the caller component will mount/unmount.
*
* @param {Command} command
*/
export function useCommand(command) {
const commandService = useService("command");
let token;
onMounted(() => {
token = commandService.registerCommand(command);
});
onWillUnmount(() => {
commandService.unregisterCommand(token);
});
}
@@ -0,0 +1,145 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useAutofocus } from "../../core/autofocus_hook";
import { useHotkey } from "../../core/hotkey_hook";
import { scrollTo } from "../../core/utils/scrolling";
const { Component, hooks } = owl;
const { onPatched, useState } = hooks;
const commandCategoryRegistry = registry.category("command_categories");
/**
* @typedef {import("./command_service").Command} Command
*/
/**
* Util used to filter commands that are within category.
* Note: for the default category, also get all commands having invalid category.
*
* @param {string} categoryName the category key
* @returns an array filter predicate
*/
function commandsWithinCategory(categoryName) {
return (cmd) => {
const inCurrentCategory = categoryName === cmd.category;
const fallbackCategory =
categoryName === "default" && !commandCategoryRegistry.contains(cmd.category);
return inCurrentCategory || fallbackCategory;
};
}
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],
});
useAutofocus();
this.mouseSelectionActive = false;
onPatched(() => {
const index = this.state.commands.indexOf(this.state.selectedCommand);
const listbox = this.el.querySelector(".o_command_palette_listbox");
const command = listbox.querySelector(`#o_command_${index}`);
scrollTo(command, listbox);
});
useHotkey(
"Enter",
() => {
this.executeSelectedCommand();
},
{ altIsOptional: true }
);
useHotkey(
"ArrowUp",
() => {
this.mouseSelectionActive = false;
const index = this.state.commands.indexOf(this.state.selectedCommand);
if (index > 0) {
this.selectCommand(index - 1);
}
},
{ altIsOptional: true, allowRepeat: true }
);
useHotkey(
"ArrowDown",
() => {
this.mouseSelectionActive = false;
const index = this.state.commands.indexOf(this.state.selectedCommand);
if (index < this.state.commands.length - 1) {
this.selectCommand(index + 1);
}
},
{ altIsOptional: true, allowRepeat: true }
);
}
get categories() {
const categories = [];
for (const [key, value] of commandCategoryRegistry.getEntries()) {
const commands = this.state.commands.filter(commandsWithinCategory(key));
if (commands.length) {
categories.push({
...value,
commands,
});
}
}
return categories;
}
selectCommand(index) {
if (index === -1 || index >= this.state.commands.length) {
this.state.selectedCommand = null;
return;
}
this.state.selectedCommand = this.state.commands[index];
}
onCommandClicked(index) {
this.selectCommand(index);
this.executeSelectedCommand();
}
executeSelectedCommand() {
if (this.state.selectedCommand) {
this.trigger("dialog-closed");
this.state.selectedCommand.action();
}
}
onCommandMouseEnter(index) {
if (this.mouseSelectionActive) {
this.selectCommand(index);
} else {
this.mouseSelectionActive = true;
}
}
onSearchInput(ev) {
const searchValue = ev.target.value;
const newCommands = [];
for (const command of this.initialCommands) {
if (fuzzy.test(searchValue, command.name)) {
newCommands.push(command);
}
}
this.state.commands = newCommands;
this.selectCommand(this.state.commands.length ? 0 : -1);
}
}
CommandPalette.template = "web.CommandPalette";
@@ -0,0 +1,79 @@
.o_command_palette {
&.modal-content {
border: none;
border-radius: 3px !important;
overflow: hidden;
}
> .modal-body {
padding: 0;
overflow: hidden;
}
&_search {
display: flex;
align-items: center;
justify-content: space-between;
background-color: white;
border-bottom: 1px solid #ccc;
padding: 0.5rem 1em;
input {
border: none;
padding: 0;
line-height: 1.8em;
}
}
&_listbox {
max-height: 50vh;
overflow-y: auto;
position: relative;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
&_empty {
padding: 0.7rem 1em;
line-height: 1.8em;
}
.o_command_category {
padding: 0.5rem 0;
}
hr {
margin: 0;
}
.o_command {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.2rem 1em;
position: relative;
line-height: 1.8em;
&.focused {
background: $o-brand-secondary;
}
kbd {
background-color: #f4f7f8;
border-radius: 3px;
border: 1px solid #b4b4b4;
$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;
}
}
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<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..." />
<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-foreach="categories" t-as="category">
<div class="o_command_category">
<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="command.hotkey.toUpperCase().split('+')" t-as="key">
<kbd t-esc="key" />
<span t-if="!key_last"> + </span>
</t>
</span>
</span>
</t>
</div>
<hr t-if="!category_last" />
</t>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,30 @@
/** @odoo-module **/
import { CommandPalette } from "./command_palette";
const { Component, hooks } = owl;
const { useExternalListener, useRef } = hooks;
/**
* @typedef {import("./command_service").Command} Command
*/
export class CommandPaletteDialog extends Component {
setup() {
this.dialogRef = useRef("dialogRef");
useExternalListener(window, "mousedown", this.onWindowMouseDown);
}
/**
* Used to close ourself on outside click.
*/
onWindowMouseDown(ev) {
const element = ev.target.parentElement;
const gotClickedInside = this.dialogRef.comp.modalRef.el.contains(element);
if (!gotClickedInside) {
this.trigger("dialog-closed");
}
}
}
CommandPaletteDialog.template = "web.CommandPaletteDialog";
CommandPaletteDialog.components = { CommandPalette };
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CommandPaletteDialog" owl="1">
<Dialog contentClass="'o_command_palette'" renderHeader="false" renderFooter="false" t-ref="dialogRef">
<CommandPalette t-props="props"/>
</Dialog>
</t>
</templates>
@@ -0,0 +1,127 @@
/** @odoo-module **/
import { registry } from "../../core/registry";
import { getHotkeyToPress } from "../hotkeys/hotkey_service";
import { CommandPaletteDialog } from "./command_palette_dialog";
/**
* @typedef {{
* name: string,
* action: ()=>void,
* category?: string,
* hotkey?: string,
* hotkeyOptions?: any,
* }} Command
*/
export const commandService = {
dependencies: ["dialog", "hotkey", "ui"],
start(env, { dialog, hotkey: hotkeyService, ui }) {
const registeredCommands = new Map();
let nextToken = 0;
let isPaletteOpened = false;
hotkeyService.registerHotkey("control+k", openPalette, {
altIsOptional: true,
global: true,
});
function openPalette() {
if (isPaletteOpened) {
return;
}
const commands = [...registeredCommands.values()];
// 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.placeholder ||
(el.innerText &&
`${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) ||
"no description provided";
commands.push({
name: description,
hotkey: getHotkeyToPress(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.open(
CommandPaletteDialog,
{ commands },
{
onCloseCallback: () => {
isPaletteOpened = false;
},
}
);
}
/**
* @param {Command} command
* @returns {number} token
*/
function registerCommand(command) {
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 });
if (command.hotkey) {
registration.hotkeyToken = hotkeyService.registerHotkey(
command.hotkey,
command.action,
command.hotkeyOptions
);
const altIsOptional = command.hotkeyOptions && command.hotkeyOptions.altIsOptional;
registration.hotkey = getHotkeyToPress(command.hotkey, altIsOptional);
}
const token = nextToken++;
registeredCommands.set(token, registration);
// Due to the way elements are mounted in the DOM by Owl (bottom-to-top),
// we need to wait the next micro task tick to set the context activate
// element of the subscription.
Promise.resolve().then(() => {
registration.activeElement = ui.activeElement;
});
return token;
}
/**
* Unsubscribes the token corresponding subscription.
*
* @param {number} token
*/
function unregisterCommand(token) {
const cmd = registeredCommands.get(token);
if (cmd && cmd.hotkeyToken >= 0) {
env.services.hotkey.unregisterHotkey(cmd.hotkeyToken);
}
registeredCommands.delete(token);
}
return {
registerCommand,
unregisterCommand,
};
},
};
registry.category("services").add("command", commandService);
@@ -0,0 +1,12 @@
.o_web_hotkey_overlay {
@include o-position-absolute(0, 0);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
color: #ffffff;
font-size: 1rem;
font-family: $font-family-sans-serif;
}
@@ -0,0 +1,317 @@
/** @odoo-module **/
import { isMacOS } from "../../core/browser/feature_detection";
import { registry } from "../../core/registry";
import { browser } from "../../core/browser/browser";
export function getHotkeyToPress(hotkey, altIsOptional = false) {
let result = hotkey.split("+");
if (isMacOS()) {
result = result.map((x) => x.replace("control", "command"));
}
if (!altIsOptional) {
result = isMacOS() ? ["control", ...result] : ["alt", ...result];
}
return result.join("+");
}
const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split("");
const NAV_KEYS = [
"arrowleft",
"arrowright",
"arrowup",
"arrowdown",
"pageup",
"pagedown",
"home",
"end",
"backspace",
"enter",
"escape",
];
const MODIFIERS = new Set(["control", "shift"]);
const AUTHORIZED_KEYS = new Set([...ALPHANUM_KEYS, ...NAV_KEYS]);
export const hotkeyService = {
dependencies: ["ui"],
start(env, { ui }) {
const registrations = new Map();
let nextToken = 0;
let overlaysVisible = false;
browser.addEventListener("keydown", onKeydown);
browser.addEventListener("keyup", removeHotkeyOverlays);
browser.addEventListener("blur", removeHotkeyOverlays);
browser.addEventListener("click", removeHotkeyOverlays);
/**
* Handler for keydown events.
* Verifies if the keyboard event can be dispatched or not.
* Rules sequence to forbid dispatching :
* - UI is blocked
* - the pressed key is not whitelisted
*
* @param {KeyboardEvent} event
*/
function onKeydown(event) {
if (!event.key) {
// Chrome may trigger incomplete keydown events under certain circumstances.
// E.g. when using browser built-in autocomplete on an input.
// See https://stackoverflow.com/questions/59534586/google-chrome-fires-keydown-event-when-form-autocomplete
return;
}
const hotkey = getActiveHotkey(event);
// Do not dispatch if UI is blocked
if (ui.isBlocked) {
return;
}
// FIXME : this is a temporary hack. It replaces all [accesskey] attrs by [data-hotkey] on all elements.
const elementsWithoutDataHotkey = ui.getVisibleElements(
"[accesskey]:not([data-hotkey])"
);
for (const el of elementsWithoutDataHotkey) {
el.dataset.hotkey = el.accessKey;
el.removeAttribute("accesskey");
}
// Special case: open hotkey overlays
if (isMacOS() ? hotkey === "control" : hotkey === "alt") {
addHotkeyOverlays();
event.preventDefault();
return;
}
// Is the pressed key NOT whitelisted ?
const singleKey = hotkey.split("+").pop();
if (!AUTHORIZED_KEYS.has(singleKey)) {
return;
}
// Finally, prepare and dispatch.
const infos = {
hotkey,
_originalEvent: event,
};
dispatch(infos);
removeHotkeyOverlays();
}
/**
* Dispatches an hotkey to all matching registrations and
* clicks on all elements having a data-hotkey attribute matching the hotkey.
*
* @param {{
* hotkey: string,
* _originalEvent: KeyboardEvent
* }} infos
*/
function dispatch(infos) {
let dispatched = false;
const { hotkey, _originalEvent: event } = infos;
const isAlted = isMacOS() ? event.ctrlKey : event.altKey;
const activeElement = ui.activeElement;
// Dispatch actual hotkey to all matching registrations
for (const [_, reg] of registrations) {
if (!reg.global && reg.activeElement !== activeElement) {
continue;
}
if (reg.hotkey !== hotkey) {
continue;
}
if (!reg.altIsOptional && !isAlted) {
continue;
}
if (!reg.allowRepeat && event.repeat) {
continue;
}
reg.callback();
dispatched = true;
}
if (!event.repeat && isAlted) {
// Click on all elements having a data-hotkey attribute matching the actual hotkey.
const elems = activeElement.querySelectorAll(`[data-hotkey='${hotkey}' 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();
el.click();
dispatched = true;
}
}
// Prevent default on event if it has been handheld.
if (dispatched) {
event.preventDefault();
}
}
/**
* Add the hotkey overlays respecting the ui active element.
*/
function addHotkeyOverlays() {
if (overlaysVisible) {
return;
}
for (const el of env.services.ui.getVisibleElements("[data-hotkey]:not(:disabled)")) {
const hotkey = el.dataset.hotkey;
const overlay = document.createElement("div");
overlay.className = "o_web_hotkey_overlay";
overlay.appendChild(document.createTextNode(hotkey.toUpperCase()));
let overlayParent;
if (el.tagName.toUpperCase() === "INPUT") {
// special case for the search input that has an access key
// defined. We cannot set the overlay on the input itself,
// only on its parent.
overlayParent = el.parentElement;
} else {
overlayParent = el;
}
if (overlayParent.style.position !== "absolute") {
overlayParent.style.position = "relative";
}
overlayParent.appendChild(overlay);
}
overlaysVisible = true;
}
/**
* Remove all the hotkey overlays.
*/
function removeHotkeyOverlays() {
if (!overlaysVisible) {
return;
}
for (const overlay of document.querySelectorAll(".o_web_hotkey_overlay")) {
overlay.remove();
}
overlaysVisible = false;
}
/**
* Get the actual hotkey being pressed.
*
* @param {KeyboardEvent} ev
* @returns {string} the active hotkey, in lowercase
*/
function getActiveHotkey(ev) {
const hotkey = [];
// ------- Modifiers -------
// Modifiers are pushed in ascending order to the hotkey.
if (isMacOS() ? ev.metaKey : ev.ctrlKey) {
hotkey.push("control");
}
if (ev.shiftKey) {
hotkey.push("shift");
}
// ------- Key -------
let key = ev.key.toLowerCase();
// Identify if the user has tapped on the number keys above the text keys.
if (ev.code && ev.code.indexOf("Digit") === 0) {
key = ev.code.slice(-1);
}
// Make sure we do not duplicate a modifier key
if (!hotkey.includes(key)) {
hotkey.push(key);
}
return hotkey.join("+");
}
/**
* Registers a new hotkey.
*
* @param {string} hotkey
* @param {()=>void} callback
* @param {Object} options additional options
* @param {boolean} [options.altIsOptional=false]
* allow registration to perform even without pressing the ALT key
* @param {boolean} [options.allowRepeat=false]
* allow registration to perform multiple times when hotkey is held down
* @param {boolean} [options.global=false]
* allow registration to perform no matter the UI active element
* @returns {number} registration token
*/
function registerHotkey(hotkey, callback, options = {}) {
// Validate some informations
if (!hotkey || hotkey.length === 0) {
throw new Error("You must specify an hotkey when registering a registration.");
}
if (!callback || typeof callback !== "function") {
throw new Error(
"You must specify a callback function when registering a registration."
);
}
/**
* An hotkey must comply to these rules:
* - all parts are whitelisted
* - single key part comes last
* - each part is separated by the dash character: "+"
*/
const keys = hotkey
.toLowerCase()
.split("+")
.filter((k) => !MODIFIERS.has(k));
if (keys.some((k) => !AUTHORIZED_KEYS.has(k))) {
throw new Error(
`You are trying to subscribe for an hotkey ('${hotkey}')
that contains parts not whitelisted: ${keys.join(", ")}`
);
} else if (keys.length > 1) {
throw new Error(
`You are trying to subscribe for an hotkey ('${hotkey}')
that contains more than one single key part: ${keys.join("+")}`
);
}
// Add registration
const token = nextToken++;
const registration = {
hotkey: hotkey.toLowerCase(),
callback,
activeElement: null,
altIsOptional: options && options.altIsOptional,
allowRepeat: options && options.allowRepeat,
global: options && options.global,
};
registrations.set(token, registration);
// Due to the way elements are mounted in the DOM by Owl (bottom-to-top),
// we need to wait the next micro task tick to set the context owner of the registration.
Promise.resolve().then(() => {
registration.activeElement = ui.activeElement;
});
return token;
}
/**
* Unsubscribes the token corresponding registration.
*
* @param {number} token
*/
function unregisterHotkey(token) {
registrations.delete(token);
}
return {
registerHotkey,
unregisterHotkey,
};
},
};
registry.category("services").add("hotkey", hotkeyService);
@@ -33,7 +33,7 @@
</t>
<t t-name="web.NavBar.AppsMenu" owl="1">
<Dropdown title="'Home Menu'" class="o_navbar_apps_menu">
<Dropdown hotkey="'h'" title="'Home Menu'" class="o_navbar_apps_menu">
<i class="fa fa-th-large" />
<t t-set-slot="menu">
<MenuItem
@@ -52,13 +52,20 @@
<t t-foreach="sections" t-as="section" t-key="section.id">
<t t-set="sectionsVisibleCount" t-value="(sections.length - currentAppSectionsExtra.length)" />
<t t-if="section_index lt Math.min(10, sectionsVisibleCount)">
<t t-set="hotkey" t-value="((section_index + 1) % 10).toString()"/>
</t>
<t t-else="">
<t t-set="hotkey" t-value="undefined"/>
</t>
<t t-if="!section.childrenTree.length">
<MenuItem title="section.name" payload="section">
<MenuItem title="section.name" payload="section" hotkey="hotkey">
<span t-esc="section.name" t-att-data-section="section.id" />
</MenuItem>
</t>
<t t-else="">
<MenuDropdown title="section.name" xmlid="section.xmlid">
<MenuDropdown hotkey="hotkey" title="section.name" xmlid="section.xmlid">
<span t-esc="section.name" t-att-data-section="section.id" />
<t t-set-slot="menu">
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
@@ -71,6 +78,9 @@
<t t-call="web.NavBar.SectionsMenu.MoreDropdown">
<t t-set="sections" t-value="currentAppSectionsExtra" />
<t t-if="sectionsVisibleCount lt 10">
<t t-set="hotkey" t-value="(sectionsVisibleCount + 1 % 10).toString()"/>
</t>
</t>
</ul>
@@ -94,7 +104,7 @@
</t>
<t t-name="web.NavBar.SectionsMenu.MoreDropdown" owl="1">
<Dropdown class="o_menu_sections_more d-none" title="'More Menu'">
<Dropdown class="o_menu_sections_more d-none" title="'More Menu'" hotkey="hotkey">
<i class="fa fa-plus"/>
<t t-set-slot="menu">
<t t-foreach="sections" t-as="section" t-key="section.id">
@@ -0,0 +1,307 @@
/** @odoo-module **/
import { dialogService } from "@web/core/dialog/dialog_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui_service";
import { useCommand } from "@web/webclient/commands/command_hook";
import { commandService } from "@web/webclient/commands/command_service";
import { hotkeyService } from "@web/webclient/hotkeys/hotkey_service";
import { makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, nextTick, triggerHotkey } from "../../helpers/utils";
const { Component, mount, tags } = owl;
const { xml } = tags;
let env;
let target;
let testComponent;
const serviceRegistry = registry.category("services");
const commandCategoryRegistry = registry.category("command_categories");
class TestComponent extends Component {}
TestComponent.components = {
DialogContainer: registry.category("main_components").get("DialogContainer"),
};
TestComponent.template = xml`
<div>
<div class="o_dialog_container"/>
<DialogContainer/>
</div>
`;
QUnit.module("Command", {
async beforeEach() {
serviceRegistry.add("ui", uiService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("command", commandService);
commandCategoryRegistry.add("custom-nolabel", {});
commandCategoryRegistry.add("custom", { label: "Custom" });
commandCategoryRegistry.add("default", { label: "Other commands" });
env = await makeTestEnv();
target = getFixture();
},
afterEach() {
if (testComponent) {
testComponent.destroy();
}
},
});
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", true);
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() {}
assert.throws(function () {
command.registerCommand();
}, /undefined/);
assert.throws(function () {
command.registerCommand(null);
}, /null/);
assert.throws(function () {
command.registerCommand({});
}, /A Command must have a name and an action function/);
assert.throws(function () {
command.registerCommand({ name: "" });
}, /A Command must have a name and an action function/);
assert.throws(function () {
command.registerCommand({ action });
}, /A Command must have a name and an action function/);
assert.throws(function () {
command.registerCommand({ name: "", action });
}, /A Command must have a name and an action function/);
});
QUnit.test("useCommand hook", async (assert) => {
assert.expect(5);
class MyComponent extends TestComponent {
setup() {
super.setup();
useCommand({
name: "Take the throne",
action: () => {
assert.step("Hodor");
},
});
}
}
testComponent = await mount(MyComponent, { env, target });
triggerHotkey("control+k", true);
await nextTick();
assert.containsOnce(target, ".o_command");
assert.deepEqual(target.querySelector(".o_command").textContent, "Take the throne");
await click(target, ".o_command");
assert.verifySteps(["Hodor"]);
testComponent.unmount();
triggerHotkey("control+k", true);
await nextTick();
assert.containsNone(target, ".o_command");
});
QUnit.test("command with hotkey", async (assert) => {
assert.expect(2);
const hotkey = "a";
env.services.command.registerCommand({
name: "test",
action: () => assert.step(hotkey),
hotkey,
hotkeyOptions: { altIsOptional: true },
});
await nextTick();
triggerHotkey("a", true);
await nextTick();
assert.verifySteps([hotkey]);
});
QUnit.test("data-hotkey added to command palette", async (assert) => {
assert.expect(8);
class MyComponent extends Component {
onClick() {
assert.step("Hodor");
}
}
MyComponent.components = { TestComponent };
MyComponent.template = xml`
<div>
<button title="Aria Stark" data-hotkey="a" t-on-click="onClick" />
<input title="Bran Stark" type="text" data-hotkey="b" />
<TestComponent />
</div>
`;
testComponent = await mount(MyComponent, { env, target });
// Open palette
triggerHotkey("control+k", true);
await nextTick();
assert.containsN(target, ".o_command", 2);
assert.deepEqual(
[...target.querySelectorAll(".o_command span:first-child")].map((el) => el.textContent),
["Aria Stark", "Bran Stark"]
);
// Click on first command
await click(target, "#o_command_0");
assert.containsNone(target, ".o_command_palette", "palette is closed due to command action");
// Reopen palette
triggerHotkey("control+k", true);
await nextTick();
// Click on second command
assert.notStrictEqual(
document.activeElement,
target.querySelector("input[title='Bran Stark']"),
"input should not have the focus"
);
await click(target, "#o_command_1");
assert.containsNone(target, ".o_command_palette", "palette is closed due to command action");
assert.strictEqual(
document.activeElement,
target.querySelector("input[title='Bran Stark']"),
"input should now have the focus after matching command action has been executed"
);
// only step should come from the first command execution
assert.verifySteps(["Hodor"]);
});
QUnit.test("can be searched", async (assert) => {
assert.expect(4);
testComponent = await mount(TestComponent, { env, target });
// Register some commands
function action() {}
const names = ["Cersei Lannister", "Jaime Lannister", "Tyrion Lannister", "Tywin Lannister"];
for (const name of names) {
env.services.command.registerCommand({ name, action });
}
await nextTick();
// Open palette
triggerHotkey("control+k", true);
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),
names,
"all commands are present"
);
// Search something
let search = target.querySelector(".o_command_palette_search input");
search.value = "jl";
search.dispatchEvent(new InputEvent("input"));
await nextTick();
assert.deepEqual(
[...target.querySelectorAll(".o_command")].map((el) => el.textContent),
["Jaime Lannister"],
"only search matches are present"
);
// Clear search input
search.value = "";
search.dispatchEvent(new InputEvent("input"));
await nextTick();
assert.deepEqual(
[...target.querySelectorAll(".o_command")].map((el) => el.textContent),
names,
"all commands are again present"
);
});
QUnit.test("command categories", async (assert) => {
testComponent = await mount(TestComponent, { env, target });
// Register some commands
function action() {}
env.services.command.registerCommand({ name: "a", action, category: "custom-nolabel" });
env.services.command.registerCommand({ name: "b", action, category: "custom" });
env.services.command.registerCommand({ name: "c", action });
env.services.command.registerCommand({ name: "d", action, category: "invalid-category" });
await nextTick();
// Open palette
triggerHotkey("control+k", true);
await nextTick();
assert.containsN(target, ".o_command_category", 3);
assert.deepEqual(
[...target.querySelectorAll(".o_command_category")].map((el) => el.textContent),
["a", "b", "cd"]
);
});
QUnit.test("data-command-category", async (assert) => {
assert.expect(3);
class MyComponent extends Component {}
MyComponent.components = { TestComponent };
MyComponent.template = xml`
<div>
<div>
<button title="Aria Stark" data-hotkey="a" />
<button title="Bran Stark" data-hotkey="b" />
</div>
<div data-command-category="custom">
<button title="Robert Baratheon" data-hotkey="r" />
<button title="Joffrey Baratheon" data-hotkey="j" />
</div>
<TestComponent />
</div>
`;
testComponent = await mount(MyComponent, { env, target });
// Open palette
triggerHotkey("control+k", true);
await nextTick();
assert.containsN(target, ".o_command", 4);
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(1) .o_command > span:first-child"
),
].map((el) => el.textContent),
["Robert Baratheon", "Joffrey Baratheon"]
);
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(2) .o_command > span:first-child"
),
].map((el) => el.textContent),
["Aria Stark", "Bran Stark"]
);
});
@@ -0,0 +1,497 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { useHotkey } from "@web/core/hotkey_hook";
import { registry } from "@web/core/registry";
import { uiService, useActiveElement } from "@web/core/ui_service";
import { hotkeyService } from "@web/webclient/hotkeys/hotkey_service";
import { makeTestEnv } from "../../helpers/mock_env";
import { getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils";
const { Component, mount, tags } = owl;
const { xml } = tags;
const serviceRegistry = registry.category("services");
let env;
let target;
QUnit.module("Hotkey Service", {
async beforeEach() {
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
env = await makeTestEnv();
target = getFixture();
},
});
QUnit.test("register / unregister", async (assert) => {
assert.expect(2);
const hotkey = env.services.hotkey;
const key = "q";
triggerHotkey(key);
await nextTick();
let token = hotkey.registerHotkey(key, () => assert.step(key));
await nextTick();
triggerHotkey(key);
await nextTick();
hotkey.unregisterHotkey(token);
triggerHotkey(key);
await nextTick();
assert.verifySteps([key]);
});
QUnit.test("data-hotkey", async (assert) => {
assert.expect(2);
class MyComponent extends Component {
onClick() {
assert.step("click");
}
}
MyComponent.template = xml`
<div>
<button t-on-click="onClick" data-hotkey="b" />
</div>
`;
const key = "b";
triggerHotkey(key);
await nextTick();
const comp = await mount(MyComponent, { env, target });
triggerHotkey(key);
await nextTick();
comp.unmount();
triggerHotkey(key);
await nextTick();
assert.verifySteps(["click"]);
comp.destroy();
});
QUnit.test("hook", async (assert) => {
const key = "q";
class TestComponent extends Component {
setup() {
useHotkey(key, () => assert.step(key));
}
}
TestComponent.template = xml`<div/>`;
triggerHotkey(key);
await nextTick();
const comp = await mount(TestComponent, { env, target });
triggerHotkey(key);
await nextTick();
comp.unmount();
triggerHotkey(key);
await nextTick();
assert.verifySteps([key]);
comp.destroy();
});
QUnit.test("non-MacOS usability", async (assert) => {
assert.expect(6);
patchWithCleanup(browser, {
navigator: {
platform: "OdooOS",
},
});
const hotkey = env.services.hotkey;
const key = "q";
// On non-MacOS, ALT is NOT replaced by CONTROL key
let token = hotkey.registerHotkey(key, () => assert.step(key), { altIsOptional: false });
await nextTick();
let keydown = new KeyboardEvent("keydown", { key, altKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([key]);
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
hotkey.unregisterHotkey(token);
// On non-MacOS, CONTROL is NOT replaced by COMMAND key (= metaKey)
token = hotkey.registerHotkey(`control+${key}`, () => assert.step(`control+${key}`), {
altIsOptional: true,
});
await nextTick();
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`control+${key}`]);
keydown = new KeyboardEvent("keydown", { key, metaKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
hotkey.unregisterHotkey(token);
});
QUnit.test("MacOS usability", async (assert) => {
assert.expect(6);
patchWithCleanup(browser, {
navigator: {
platform: "Mac",
},
});
const hotkey = env.services.hotkey;
const key = "q";
// On MacOS, ALT is replaced by CONTROL key
let token = hotkey.registerHotkey(key, () => assert.step(key), { altIsOptional: false });
await nextTick();
let keydown = new KeyboardEvent("keydown", { key, altKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([key]);
hotkey.unregisterHotkey(token);
// On MacOS, CONTROL is replaced by COMMAND key (= metaKey)
token = hotkey.registerHotkey(`control+${key}`, () => assert.step(`control+${key}`), {
altIsOptional: true,
});
await nextTick();
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
keydown = new KeyboardEvent("keydown", { key, metaKey: true });
window.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`control+${key}`]);
hotkey.unregisterHotkey(token);
});
QUnit.test("alt is optional parameter", async (assert) => {
const altIsOptionalKey = "a";
const altIsRequiredKey = "b";
const defaultBehaviourKey = "c";
class TestComponent extends Component {
setup() {
useHotkey(altIsOptionalKey, () => assert.step(altIsOptionalKey), {
altIsOptional: true,
});
useHotkey(altIsRequiredKey, () => assert.step(altIsRequiredKey), {
altIsOptional: false,
});
useHotkey(defaultBehaviourKey, () => assert.step(defaultBehaviourKey));
}
}
TestComponent.template = xml`<div/>`;
const comp = await mount(TestComponent, { env, target });
// Dispatch keys without ALT
triggerHotkey(altIsOptionalKey, true);
triggerHotkey(altIsRequiredKey, true);
triggerHotkey(defaultBehaviourKey, true);
await nextTick();
assert.verifySteps([altIsOptionalKey]);
// Dispatch keys with ALT
triggerHotkey(altIsOptionalKey, false);
triggerHotkey(altIsRequiredKey, false);
triggerHotkey(defaultBehaviourKey, false);
await nextTick();
assert.verifySteps([altIsOptionalKey, altIsRequiredKey, defaultBehaviourKey]);
comp.destroy();
});
QUnit.test("[data-hotkey] alt is required", async (assert) => {
const key = "a";
class TestComponent extends Component {
onClick() {
assert.step(key);
}
}
TestComponent.template = xml`<div><button t-on-click="onClick" data-hotkey="${key}"/></div>`;
const comp = await mount(TestComponent, { env, target });
triggerHotkey(key);
await nextTick();
assert.verifySteps([key]);
triggerHotkey(key, true);
await nextTick();
assert.verifySteps([]);
comp.destroy();
});
QUnit.test("registration allows repeat if specified", async (assert) => {
assert.expect(6);
const allowRepeatKey = "a";
const disallowRepeatKey = "b";
const defaultBehaviourKey = "c";
env.services.hotkey.registerHotkey(allowRepeatKey, () => assert.step(allowRepeatKey), {
allowRepeat: true,
});
env.services.hotkey.registerHotkey(disallowRepeatKey, () => assert.step(disallowRepeatKey), {
allowRepeat: false,
});
env.services.hotkey.registerHotkey(defaultBehaviourKey, () => assert.step(defaultBehaviourKey));
await nextTick();
// Dispatch the three keys without repeat:
triggerHotkey(allowRepeatKey);
triggerHotkey(disallowRepeatKey);
triggerHotkey(defaultBehaviourKey);
await nextTick();
assert.verifySteps([allowRepeatKey, disallowRepeatKey, defaultBehaviourKey]);
// Dispatch the three keys with repeat:
triggerHotkey(allowRepeatKey, false, { repeat: true });
triggerHotkey(disallowRepeatKey, false, { repeat: true });
triggerHotkey(defaultBehaviourKey, false, { repeat: true });
await nextTick();
assert.verifySteps([allowRepeatKey]);
});
QUnit.test("[data-hotkey] never allow repeat", async (assert) => {
assert.expect(3);
const key = "a";
class TestComponent extends Component {
onClick() {
assert.step(key);
}
}
TestComponent.template = xml`<div><button t-on-click="onClick" data-hotkey="${key}"/></div>`;
const comp = await mount(TestComponent, { env, target });
triggerHotkey(key);
await nextTick();
assert.verifySteps([key]);
triggerHotkey(key, false, { repeat: true });
await nextTick();
assert.verifySteps([]);
comp.destroy();
});
QUnit.test("hotkeys evil 👹", async (assert) => {
const hotkey = env.services.hotkey;
assert.throws(function () {
hotkey.registerHotkey();
}, /must specify an hotkey/);
assert.throws(function () {
hotkey.registerHotkey(null);
}, /must specify an hotkey/);
function callback() {}
assert.throws(function () {
hotkey.registerHotkey(null, callback);
}, /must specify an hotkey/);
assert.throws(function () {
hotkey.registerHotkey("");
}, /must specify an hotkey/);
assert.throws(function () {
hotkey.registerHotkey("crap", callback);
}, /not whitelisted/);
assert.throws(function () {
hotkey.registerHotkey("ctrl+o", callback);
}, /not whitelisted/);
assert.throws(function () {
hotkey.registerHotkey("Control+o");
}, /specify a callback/);
assert.throws(function () {
hotkey.registerHotkey("Control+o+d", callback);
}, /more than one single key part/);
});
QUnit.test("component can register many hotkeys", async (assert) => {
assert.expect(8);
class MyComponent extends Component {
setup() {
for (const hotkey of ["a", "b", "c"]) {
useHotkey(hotkey, () => assert.step(`callback:${hotkey}`));
}
for (const hotkey of ["d", "e", "f"]) {
useHotkey(hotkey, () => assert.step(`callback2:${hotkey}`));
}
}
onClick() {
assert.step("click");
}
}
MyComponent.template = xml`
<div>
<button t-on-click="onClick" data-hotkey="b" />
</div>
`;
const comp = await mount(MyComponent, { env, target });
triggerHotkey("a");
triggerHotkey("b");
triggerHotkey("c");
triggerHotkey("d");
triggerHotkey("e");
triggerHotkey("f");
await nextTick();
assert.verifySteps([
"callback:a",
"callback:b",
"click",
"callback:c",
"callback2:d",
"callback2:e",
"callback2:f",
]);
comp.destroy();
});
QUnit.test("many components can register same hotkeys", async (assert) => {
assert.expect(1);
const result = [];
const hotkeys = ["a", "b", "c"];
class MyComponent1 extends Component {
setup() {
for (const hotkey of hotkeys) {
useHotkey(hotkey, () => result.push(`comp1:${hotkey}`));
}
}
onClick() {
result.push("comp1:click");
}
}
MyComponent1.template = xml`
<div>
<button t-on-click="onClick" data-hotkey="b" />
</div>
`;
class MyComponent2 extends Component {
setup() {
for (const hotkey of hotkeys) {
useHotkey(hotkey, () => result.push(`comp2:${hotkey}`));
}
}
onClick() {
result.push("comp2:click");
}
}
MyComponent2.template = xml`
<div>
<button t-on-click="onClick" data-hotkey="b" />
</div>
`;
const comp1 = await mount(MyComponent1, { env, target });
const comp2 = await mount(MyComponent2, { env, target });
triggerHotkey("a");
triggerHotkey("b");
triggerHotkey("c");
await nextTick();
assert.deepEqual(result.sort(), [
"comp1:a",
"comp1:b",
"comp1:c",
"comp1:click",
"comp2:a",
"comp2:b",
"comp2:c",
"comp2:click",
]);
comp1.destroy();
comp2.destroy();
});
QUnit.test("registrations and elements belong to the correct UI owner", async (assert) => {
assert.expect(7);
class MyComponent1 extends Component {
setup() {
useHotkey("a", () => assert.step("MyComponent1 subscription"));
}
onClick() {
assert.step("MyComponent1 [data-hotkey]");
}
}
MyComponent1.template = xml`<div><button data-hotkey="b" t-on-click="onClick()"/></div>`;
class MyComponent2 extends Component {
setup() {
useHotkey("a", () => assert.step("MyComponent2 subscription"));
useActiveElement();
}
onClick() {
assert.step("MyComponent2 [data-hotkey]");
}
}
MyComponent2.template = xml`<div><button data-hotkey="b" t-on-click="onClick()"/></div>`;
const comp1 = await mount(MyComponent1, { env, target });
triggerHotkey("a");
triggerHotkey("b");
await nextTick();
const comp2 = await mount(MyComponent2, { env, target });
triggerHotkey("a");
triggerHotkey("b");
await nextTick();
comp2.unmount();
triggerHotkey("a");
triggerHotkey("b");
await nextTick();
assert.verifySteps([
"MyComponent1 subscription",
"MyComponent1 [data-hotkey]",
"MyComponent2 subscription",
"MyComponent2 [data-hotkey]",
"MyComponent1 subscription",
"MyComponent1 [data-hotkey]",
]);
comp1.destroy();
comp2.destroy();
});