[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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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="{ "somekey": "somevalue" }"/>
|
||||
</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="{ "somekey": "somevalue" }"/>
|
||||
</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
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
Reference in New Issue
Block a user