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
////////////////////////////////////////////////////////////////////////////