diff --git a/addons/web/static/src/core/commands/command_items.xml b/addons/web/static/src/core/commands/command_items.xml index 4d24daffe1e..30fa831ca21 100644 --- a/addons/web/static/src/core/commands/command_items.xml +++ b/addons/web/static/src/core/commands/command_items.xml @@ -4,6 +4,7 @@
+
diff --git a/addons/web/static/src/core/commands/command_palette.js b/addons/web/static/src/core/commands/command_palette.js index 0f98333d8a9..b72d3739a82 100644 --- a/addons/web/static/src/core/commands/command_palette.js +++ b/addons/web/static/src/core/commands/command_palette.js @@ -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"; diff --git a/addons/web/static/src/core/commands/command_palette.scss b/addons/web/static/src/core/commands/command_palette.scss index dcd3837e0b4..f2210a9873b 100644 --- a/addons/web/static/src/core/commands/command_palette.scss +++ b/addons/web/static/src/core/commands/command_palette.scss @@ -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; + } } } diff --git a/addons/web/static/src/core/commands/command_palette.xml b/addons/web/static/src/core/commands/command_palette.xml index c7c898a39fd..4b9f69e0833 100644 --- a/addons/web/static/src/core/commands/command_palette.xml +++ b/addons/web/static/src/core/commands/command_palette.xml @@ -18,17 +18,26 @@
- diff --git a/addons/web/static/src/core/commands/command_service.js b/addons/web/static/src/core/commands/command_service.js index 5fa19bf8e52..e429fd8064a 100644 --- a/addons/web/static/src/core/commands/command_service.js +++ b/addons/web/static/src/core/commands/command_service.js @@ -14,6 +14,7 @@ const { Component, xml, EventBus } = owl; * name: string; * action: ()=>(void | CommandPaletteConfig); * category?: string; + * href?: string; * }} Command */ diff --git a/addons/web/static/src/webclient/menus/menu_command_item.xml b/addons/web/static/src/webclient/menus/menu_command_item.xml index b4c8803f842..90b1e4d4a14 100644 --- a/addons/web/static/src/webclient/menus/menu_command_item.xml +++ b/addons/web/static/src/webclient/menus/menu_command_item.xml @@ -2,12 +2,15 @@ -
- - -
+
+ +
+ + + +
diff --git a/addons/web/static/src/webclient/menus/menu_providers.js b/addons/web/static/src/webclient/menus/menu_providers.js index 161a4709c8f..73c9a5b9526 100644 --- a/addons/web/static/src/webclient/menus/menu_providers.js +++ b/addons/web/static/src/webclient/menus/menu_providers.js @@ -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, }); }); diff --git a/addons/web/static/tests/core/commands/command_palette_tests.js b/addons/web/static/tests/core/commands/command_palette_tests.js index ed6057e09ba..8f44a7cdf84 100644 --- a/addons/web/static/tests/core/commands/command_palette_tests.js +++ b/addons/web/static/tests/core/commands/command_palette_tests.js @@ -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"]); +}); diff --git a/addons/web/static/tests/core/commands/command_service_tests.js b/addons/web/static/tests/core/commands/command_service_tests.js index c937db4a69d..2c357f50fc4 100644 --- a/addons/web/static/tests/core/commands/command_service_tests.js +++ b/addons/web/static/tests/core/commands/command_service_tests.js @@ -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"] diff --git a/addons/web/static/tests/core/commands/menu_provider_tests.js b/addons/web/static/tests/core/commands/menu_provider_tests.js index 6d0a99cf7a1..b57381964ec 100644 --- a/addons/web/static/tests/core/commands/menu_provider_tests.js +++ b/addons/web/static/tests/core/commands/menu_provider_tests.js @@ -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"] ); });