[IMP] web: allow commands to be middle-clickable

Purpose
=======
Allow commands in the command palette to be opened in a new tab by
either CTRL+clicking on the command, CTRL+ENTER or right clicking and
copying the link/opening in new tab.

Specifications
=============
Add new slot `focusMessage` that displays the message (if device is not
a mobile device). The `href` value has then to be defined in the
registry.

Task-2710445

Part-of: odoo/odoo#94241
This commit is contained in:
Adrien Schoffeniels
2022-08-05 12:09:54 +02:00
parent a84736d610
commit 9c5b5a9dfd
10 changed files with 121 additions and 31 deletions
@@ -4,6 +4,7 @@
<t t-name="web.DefaultCommandItem" owl="1">
<div class="o_command_default d-flex align-items-center justify-content-between px-4 py-2 cursor-pointer">
<t t-slot="name"/>
<t t-slot="focusMessage"/>
</div>
</t>
@@ -8,7 +8,8 @@ import { useAutofocus, useService } from "@web/core/utils/hooks";
import { scrollTo } from "@web/core/utils/scrolling";
import { fuzzyLookup } from "@web/core/utils/search";
import { debounce } from "@web/core/utils/timing";
import { escapeRegExp } from "../utils/strings";
import { isMacOS, isMobileOS } from "@web/core/browser/feature_detection";
import { escapeRegExp } from "@web/core/utils/strings";
const {
Component,
@@ -103,6 +104,9 @@ export class CommandPalette extends Component {
this.inputRef = useAutofocus();
useHotkey("Enter", () => this.executeSelectedCommand(), { bypassEditableProtection: true });
useHotkey("Control+Enter", () => this.executeSelectedCommand(true), {
bypassEditableProtection: true,
});
useHotkey("ArrowUp", () => this.selectCommandAndScrollTo("PREV"), {
bypassEditableProtection: true,
allowRepeat: true,
@@ -245,9 +249,11 @@ export class CommandPalette extends Component {
scrollTo(command, { scrollable: this.listboxRef.el });
}
onCommandClicked(index) {
onCommandClicked(event, index) {
event.preventDefault(); // Prevent redirect for commands with href
this.selectCommand(index);
this.executeSelectedCommand();
const ctrlKey = isMacOS() ? event.metaKey : event.ctrlKey;
this.executeSelectedCommand(ctrlKey);
}
/**
@@ -265,10 +271,15 @@ export class CommandPalette extends Component {
}
}
async executeSelectedCommand() {
async executeSelectedCommand(ctrlKey) {
await this.searchValuePromise;
if (this.state.selectedCommand) {
this.executeCommand(this.state.selectedCommand);
const selectedCommand = this.state.selectedCommand;
if (selectedCommand) {
if (!ctrlKey) {
this.executeCommand(selectedCommand);
} else if (selectedCommand.href) {
window.open(selectedCommand.href, "_blank");
}
}
}
@@ -346,6 +357,13 @@ export class CommandPalette extends Component {
}
return { namespace, searchValue };
}
get isMacOS() {
return isMacOS();
}
get isMobileOS() {
return isMobileOS();
}
}
CommandPalette.lastSessionId = 0;
CommandPalette.template = "web.CommandPalette";
@@ -24,6 +24,10 @@
top: 0.4em;
}
}
a {
text-decoration: none;
color: inherit;
}
}
}
@@ -43,5 +47,9 @@
white-space: nowrap;
overflow: hidden;
}
.o_command_focus {
white-space: nowrap;
opacity: 0.9;
}
}
}
@@ -18,17 +18,26 @@
<div class="o_command_category px-0">
<t t-foreach="category.commands" t-as="command" t-key="command.keyId">
<t t-set="commandIndex" t-value="state.commands.indexOf(command)"/>
<div t-attf-id="o_command_{{commandIndex}}" class="o_command" t-att-class="{ focused: state.selectedCommand === command }" t-on-click="() => this.onCommandClicked(commandIndex)" t-on-mouseenter="() => this.onCommandMouseEnter(commandIndex)" t-on-close="() => this.props.closeMe()">
<t t-component="command.Component || DefaultCommandItem" name="command.name" searchValue="state.searchValue" t-props="command.props" executeCommand="() => this.executeCommand(command)">
<t t-set-slot="name">
<span class="o_command_name">
<t t-foreach="command.splitName" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-out="name" class="text-primary"/>
<t t-else="" t-out="name"/>
</t>
</span>
<div t-attf-id="o_command_{{commandIndex}}" class="o_command"
t-att-class="{ focused: state.selectedCommand === command }"
t-on-click="(event) => this.onCommandClicked(event, commandIndex)"
t-on-mouseenter="() => this.onCommandMouseEnter(commandIndex)"
t-on-close="() => this.props.closeMe()">
<a t-att-href="command.href">
<t t-component="command.Component || DefaultCommandItem" name="command.name" searchValue="state.searchValue" t-props="command.props" executeCommand="() => this.executeCommand(command)">
<t t-set-slot="name">
<span class="o_command_name text-ellipsis" t-att-title="command.name">
<t t-foreach="command.splitName" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-out="name" class="text-primary"/>
<t t-else="" t-out="name"/>
</t>
</span>
</t>
<t t-set-slot="focusMessage">
<small t-if="!isMobileOS and command.href and state.selectedCommand === command" class="o_command_focus text-muted"><kbd>⏎</kbd> to open, <kbd><t t-if="isMacOS">CMD</t><t t-else="">CTRL</t></kbd>+<kbd>⏎</kbd> to open in new tab</small>
</t>
</t>
</t>
</a>
</div>
</t>
</div>
@@ -14,6 +14,7 @@ const { Component, xml, EventBus } = owl;
* name: string;
* action: ()=>(void | CommandPaletteConfig);
* category?: string;
* href?: string;
* }} Command
*/
@@ -2,12 +2,15 @@
<templates xml:space="preserve">
<t t-name="web.AppIconCommand" owl="1">
<div class="o_command_default position-relative d-flex align-items-center justify-content-between px-4 py-2 cursor-pointer">
<t t-slot="name"/>
<img t-if="props.webIconData" class="o_app_icon position-relative rounded-1" t-attf-src="{{props.webIconData}}"/>
<div t-else="" class="o_app_icon d-flex align-items-center justify-content-center" t-attf-style="background-color:{{props.webIcon.backgroundColor}}" >
<div class="o_command_default position-relative d-flex align-items-center px-4 py-2 cursor-pointer">
<img t-if="props.webIconData" class="me-2 o_app_icon position-relative rounded-1" t-attf-src="{{props.webIconData}}"/>
<div t-else="" class="me-2 o_app_icon d-flex align-items-center justify-content-center" t-attf-style="background-color:{{props.webIcon.backgroundColor}}" >
<i t-att-class="props.webIcon.iconClass" t-attf-style="color:{{props.webIcon.color}}"></i>
</div>
<t t-slot="name"/>
<span class="ms-auto flex-shrink-0">
<t t-slot="focusMessage"/>
</span>
</div>
</t>
@@ -40,6 +40,7 @@ commandProviderRegistry.add("menu", {
},
category: "menu_items",
name: menu.parents + " / " + menu.label,
href: menu.href || `#menu_id=${menu.id}&amp;action_id=${menu.actionID}`,
});
});
}
@@ -63,6 +64,7 @@ commandProviderRegistry.add("menu", {
},
category: "apps",
name: menu.label,
href: menu.href || `#menu_id=${menu.id}&amp;action_id=${menu.actionID}`,
props,
});
});
@@ -832,7 +832,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(1) .o_command > div > span:first-child"
".o_command_category:nth-of-type(1) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command1"]
@@ -840,7 +840,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(2) .o_command > div > span:first-child"
".o_command_category:nth-of-type(2) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command2"]
@@ -848,7 +848,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(3) .o_command > div > span:first-child"
".o_command_category:nth-of-type(3) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command3"]
@@ -865,7 +865,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(1) .o_command > div > span:first-child"
".o_command_category:nth-of-type(1) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command6", "Command7"]
@@ -873,7 +873,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(2) .o_command > div > span:first-child"
".o_command_category:nth-of-type(2) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command5"]
@@ -881,7 +881,7 @@ QUnit.test("multi provider with categories", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(3) .o_command > div > span:first-child"
".o_command_category:nth-of-type(3) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command4"]
@@ -943,7 +943,7 @@ QUnit.test("don't display by categories if there is a search value", async (asse
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(1) .o_command > div > span:first-child"
".o_command_category:nth-of-type(1) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Command1", "Command2", "Command3"]
@@ -1547,3 +1547,51 @@ QUnit.test("generate new session id when opened", async (assert) => {
await nextTick();
assert.equal(lastSessionId, 1);
});
QUnit.test("checks that href is correctly used", async (assert) => {
await mount(TestComponent, target, { env });
const providers = [
{
namespace: "@",
provide: () => [
{
name: "Command with link",
action: () => {
assert.step("command_with_link_clicked");
},
href: "https://www.odoo.com",
},
{
name: "Command without link",
action: () => {},
},
],
},
];
const config = {providers};
env.services.dialog.add(CommandPalette, {
config,
});
patchWithCleanup(window, {
open: (href) => {
assert.step(href.toString());
}
});
await nextTick();
await editSearchBar("@");
const command = target.querySelector(".o_command_palette .o_command");
// Check that command has link inside it
assert.strictEqual(command.querySelector("a").getAttribute("href"), "https://www.odoo.com");
// Check that we get url when doing ctrl+enter on a command having a link inside it
triggerHotkey("control+enter");
await nextTick();
assert.verifySteps(["https://www.odoo.com"]);
// Check that command has no link inside it
const commandWithoutLink = target.querySelector(".o_command_palette .o_command:nth-child(2)");
assert.strictEqual(commandWithoutLink.querySelector("a").getAttribute("href"), null);
// Check that clicking on a command having a link inside it triggers the command action
// instead of redirecting to the href (last step because it closes the command palette).
await click(command);
await nextTick();
assert.verifySteps(["command_with_link_clicked"]);
});
@@ -913,7 +913,7 @@ QUnit.test("data-command-category", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(1) .o_command > div > span:first-child"
".o_command_category:nth-of-type(1) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Robert baratheon", "Joffrey baratheon"]
@@ -921,7 +921,7 @@ QUnit.test("data-command-category", async (assert) => {
assert.deepEqual(
[
...target.querySelectorAll(
".o_command_category:nth-of-type(2) .o_command > div > span:first-child"
".o_command_category:nth-of-type(2) .o_command > a > div > span:first-child"
),
].map((el) => el.textContent),
["Aria stark", "Bran stark"]
@@ -80,7 +80,7 @@ QUnit.test("displays only apps if the search value is '/'", async (assert) => {
assert.containsOnce(target, ".o_command_category");
assert.containsN(target, ".o_command", 2);
assert.deepEqual(
[...target.querySelectorAll(".o_command")].map((el) => el.textContent),
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
["Contact", "Sales"]
);
});
@@ -94,7 +94,7 @@ QUnit.test("displays apps and menu items if the search value is not only '/'", a
assert.containsOnce(target, ".o_command_palette");
assert.containsN(target, ".o_command", 3);
assert.deepEqual(
[...target.querySelectorAll(".o_command")].map((el) => el.textContent),
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
["Sales", "Sales / Info", "Sales / Report"]
);
});