[FIX] web: introduce useActionLinks view hook

The view Component supports clicking on links `a`
that have a type="action"

The feature is a lighter version of execute-action, since it doesn't support
dynamic information from the model (e.g. basicModel)

X-original-commit: f695c61a7e9422cb2aa909d08d87ef74f42d3ed8
Part-of: odoo/odoo#76370
This commit is contained in:
Lucas Perais (lpe)
2021-09-14 13:31:59 +00:00
parent 29cf3d4e84
commit ecf3099c8f
3 changed files with 257 additions and 0 deletions
@@ -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);
}
+7
View File
@@ -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() {
+156
View File
@@ -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"] = `
<toy>
<a type="action" data-method="setTheControl" data-model="animal" />
</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"] = `
<toy>
<a type="action" name="myLittleAction" data-context="{ &quot;somekey&quot;: &quot;somevalue&quot; }"/>
</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"] = `
<toy>
<a type="action" title="myTitle" data-model="animal" data-resId="66" data-views="[[55, 'toy']]" data-domain="[['field', '=', 'val']]" data-context="{ &quot;somekey&quot;: &quot;somevalue&quot; }"/>
</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
////////////////////////////////////////////////////////////////////////////