[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:
@@ -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}&action_id=${menu.actionID}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -63,6 +64,7 @@ commandProviderRegistry.add("menu", {
|
||||
},
|
||||
category: "apps",
|
||||
name: menu.label,
|
||||
href: menu.href || `#menu_id=${menu.id}&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"]
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user