diff --git a/addons/web/static/src/views/helpers/view_hook.js b/addons/web/static/src/views/helpers/view_hook.js index 55020fd0ffa..38ca552539e 100644 --- a/addons/web/static/src/views/helpers/view_hook.js +++ b/addons/web/static/src/views/helpers/view_hook.js @@ -3,6 +3,8 @@ import { useDebugCategory } from "@web/core/debug/debug_context"; import { useSetupAction } from "@web/webclient/actions/action_hook"; import { registry } from "@web/core/registry"; +import { useListener, useService } from "@web/core/utils/hooks"; +import { evaluateExpr } from "@web/core/py_js/py"; const { useComponent } = owl.hooks; @@ -36,3 +38,95 @@ export function useViewArch(arch, params = {}) { return processedArch; } + +/** + * Allows for a component (usually a View component) to handle links with + * attribute type="action". This is used to support onboarding banners and content helpers. + * + * A @web/core/concurrency:KeepLast must be present in the owl environment to allow coordinating + * between clicks. (env.keepLast) + * + * Note that this is similar but quite different from action buttons, since action links + * are not dynamic according to the record. + * @param {Object} params + * @param {String} params.resModel The default resModel to which actions will apply + * @param {Function} [params.reload] The function to execute to reload, if a button has data-reload-on-close + */ +export function useActionLinks({ resModel, reload }) { + const selector = `a[type="action"]`; + const component = owl.hooks.useComponent(); + const keepLast = component.env.keepLast; + + const orm = useService("orm"); + const { doAction } = useService("action"); + + function checkAndCollapseBootstrap(target) { + // the handler should have stopped the Event + // But we still need to alert bootstrap if we need to + // This function should be removed when we get rid of bootstrap as a JS framework + if (target.dataset.toggle === "collapse") { + $(target).trigger("click.bs.collapse.data-api"); + } + } + + async function handler(ev) { + ev.preventDefault(); + ev.stopPropagation(); + let target = ev.target; + if (target.tagName !== "A") { + target = target.closest("a"); + } + const data = target.dataset; + + if (data.method !== undefined && data.model !== undefined) { + const options = {}; + if (data.reloadOnClose) { + options.onClose = reload || (() => component.render()); + } + const action = await keepLast.add(orm.call(data.model, data.method)); + if (action !== undefined) { + keepLast.add(Promise.resolve(doAction(action, options))); + } + } else if (target.getAttribute("name")) { + const options = {}; + if (data.context) { + options.additionalContext = evaluateExpr(data.context); + } + keepLast.add(doAction(target.getAttribute("name"), options)); + } else { + let views; + const resId = data.resid ? parseInt(data.resid, 10) : null; + if (data.views) { + views = evaluateExpr(data.views); + } else { + views = resId + ? [[false, "form"]] + : [ + [false, "list"], + [false, "form"], + ]; + } + + const action = { + name: target.getAttribute("title") || target.textContent.trim(), + type: "ir.actions.act_window", + res_model: data.model || resModel, + target: "current", // TODO: make customisable? + views, + domain: data.domain ? evaluateExpr(data.domain) : [], + }; + if (resId) { + action.res_id = resId; + } + + const options = {}; + if (data.context) { + options.additionalContext = evaluateExpr(data.context); + } + keepLast.add(doAction(action, options)); + } + checkAndCollapseBootstrap(target); + } + + useListener("click", selector, handler); +} diff --git a/addons/web/static/src/views/view.js b/addons/web/static/src/views/view.js index c108168ec60..8b2137e5773 100644 --- a/addons/web/static/src/views/view.js +++ b/addons/web/static/src/views/view.js @@ -3,7 +3,9 @@ import { evaluateExpr } from "@web/core/py_js/py"; import { registry } from "@web/core/registry"; import { useService } from "@web/core/utils/hooks"; +import { KeepLast } from "@web/core/utils/concurrency"; import { WithSearch } from "@web/search/with_search/with_search"; +import { useActionLinks } from "@web/views/helpers/view_hook"; const viewRegistry = registry.category("views"); @@ -116,6 +118,11 @@ export class View extends Component { this.viewService = useService("view"); this.withSearchProps = null; + + owl.hooks.useSubEnv({ + keepLast: new KeepLast(), + }); + useActionLinks({ resModel }); } async willStart() { diff --git a/addons/web/static/tests/views/view_tests.js b/addons/web/static/tests/views/view_tests.js index 9dcc503e236..aa1ba8e0ab2 100644 --- a/addons/web/static/tests/views/view_tests.js +++ b/addons/web/static/tests/views/view_tests.js @@ -7,6 +7,7 @@ import { registerCleanup } from "@web/../tests/helpers/cleanup"; import { registry } from "@web/core/registry"; import { View } from "@web/views/view"; import { viewService } from "@web/views/view_service"; +import { click } from "../helpers/utils"; const { Component, mount, hooks, tags } = owl; const { useState } = hooks; @@ -107,6 +108,16 @@ QUnit.module("Views", (hooks) => { serviceRegistry.add("orm", ormService); serviceRegistry.add("view", viewService); + + const fakeActionService = { + name: "action", + start() { + return { + doAction() {}, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); }); QUnit.module("View component"); @@ -655,6 +666,151 @@ QUnit.module("Views", (hooks) => { } ); + QUnit.test("can click on action-bound links -- 1", async (assert) => { + assert.expect(5); + + const expectedAction = { + action: { + type: "ir.actions.client", + tag: "someAction", + }, + options: {}, + }; + + const fakeActionService = { + name: "action", + start() { + return { + doAction(action, options) { + assert.deepEqual(action, expectedAction.action); + assert.deepEqual(options, expectedAction.options); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + serverData.views["animal,1,toy"] = ` + + + `; + + const mockRPC = (route) => { + if (route.includes("setTheControl")) { + assert.step(route); + return { + type: "ir.actions.client", + tag: "someAction", + }; + } + }; + + const toy = await makeView({ + serverData, + mockRPC, + resModel: "animal", + type: "toy", + views: [[1, "toy"]], + }); + + assert.containsOnce(toy, "a"); + await click(toy.el.querySelector("a")); + assert.verifySteps(["/web/dataset/call_kw/animal/setTheControl"]); + }); + + QUnit.test("can click on action-bound links -- 2", async (assert) => { + assert.expect(3); + + const expectedAction = { + action: "myLittleAction", + options: { + additionalContext: { + somekey: "somevalue", + }, + }, + }; + + const fakeActionService = { + name: "action", + start() { + return { + doAction(action, options) { + assert.deepEqual(action, expectedAction.action); + assert.deepEqual(options, expectedAction.options); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + serverData.views["animal,1,toy"] = ` + + + `; + + const toy = await makeView({ + serverData, + resModel: "animal", + type: "toy", + views: [[1, "toy"]], + }); + + assert.containsOnce(toy, "a"); + await click(toy.el.querySelector("a")); + }); + + QUnit.test("can click on action-bound links -- 3", async (assert) => { + assert.expect(3); + + const expectedAction = { + action: { + domain: [["field", "=", "val"]], + name: "myTitle", + res_id: 66, + res_model: "animal", + target: "current", + type: "ir.actions.act_window", + views: [[55, "toy"]], + }, + options: { + additionalContext: { + somekey: "somevalue", + }, + }, + }; + + const fakeActionService = { + name: "action", + start() { + return { + doAction(action, options) { + assert.deepEqual(action, expectedAction.action); + assert.deepEqual(options, expectedAction.options); + return Promise.resolve(true); + }, + }; + }, + }; + serviceRegistry.add("action", fakeActionService, { force: true }); + + serverData.views["animal,1,toy"] = ` + + + `; + + const toy = await makeView({ + serverData, + resModel: "animal", + type: "toy", + views: [[1, "toy"]], + }); + + assert.containsOnce(toy, "a"); + await click(toy.el.querySelector("a")); + }); + //////////////////////////////////////////////////////////////////////////// // js_class ////////////////////////////////////////////////////////////////////////////