Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,632 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import testUtils from "web.test_utils";
import { registerCleanup } from "../../helpers/cleanup";
import {
click,
getFixture,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { Component, xml } from "@odoo/owl";
let serverData;
let target;
const actionRegistry = registry.category("actions");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Client Actions");
QUnit.test("can display client actions in Dialog", async function (assert) {
assert.expect(2);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
name: "Dialog Test",
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
assert.containsOnce(target, ".modal .test_client_action");
assert.strictEqual(target.querySelector(".modal-title").textContent, "Dialog Test");
});
QUnit.test(
"can display client actions in Dialog and close the dialog",
async function (assert) {
assert.expect(3);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
name: "Dialog Test",
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
assert.containsOnce(target, ".modal .test_client_action");
assert.strictEqual(target.querySelector(".modal-title").textContent, "Dialog Test");
target.querySelector(".modal footer .btn.btn-primary").click();
await nextTick();
assert.containsNone(target, ".modal .test_client_action");
}
);
QUnit.test("can display client actions as main, then in Dialog", async function (assert) {
assert.expect(3);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "__test__client__action__");
assert.containsOnce(target, ".o_action_manager .test_client_action");
await doAction(webClient, {
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
assert.containsOnce(target, ".o_action_manager .test_client_action");
assert.containsOnce(target, ".modal .test_client_action");
});
QUnit.test(
"can display client actions in Dialog, then as main destroys Dialog",
async function (assert) {
assert.expect(4);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
assert.containsOnce(target, ".test_client_action");
assert.containsOnce(target, ".modal .test_client_action");
await doAction(webClient, "__test__client__action__");
assert.containsOnce(target, ".test_client_action");
assert.containsNone(target, ".modal .test_client_action");
}
);
QUnit.test("soft_reload will refresh data", async (assert) => {
const mockRPC = async function (route, args) {
if (route === "/web/dataset/call_kw/partner/web_search_read") {
assert.step("web_search_read");
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
assert.verifySteps(["web_search_read"]);
await doAction(webClient, "soft_reload");
assert.verifySteps(["web_search_read"]);
});
QUnit.test("soft_reload when there is no controller", async (assert) => {
const webClient = await createWebClient({ serverData });
await doAction(webClient, "soft_reload");
assert.ok(true, "No ControllerNotFoundError when there is no controller to restore");
});
QUnit.test("can execute client actions from tag name (legacy)", async function (assert) {
// remove this test as soon as legacy Widgets are no longer supported
assert.expect(4);
const ClientAction = AbstractAction.extend({
start: function () {
this.$el.text("Hello World");
this.$el.addClass("o_client_action_test");
},
});
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
};
core.action_registry.add("HelloWorldTestLeg", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTestLeg);
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, "HelloWorldTestLeg");
assert.containsNone(
document.body,
".o_control_panel",
"shouldn't have rendered a control panel"
);
assert.strictEqual(
$(target).find(".o_client_action_test").text(),
"Hello World",
"should have correctly rendered the client action"
);
assert.verifySteps(["/web/webclient/load_menus"]);
});
QUnit.test("can execute client actions from tag name", async function (assert) {
assert.expect(4);
class ClientAction extends Component {}
ClientAction.template = xml`<div class="o_client_action_test">Hello World</div>`;
actionRegistry.add("HelloWorldTest", ClientAction);
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, "HelloWorldTest");
assert.containsNone(
document.body,
".o_control_panel",
"shouldn't have rendered a control panel"
);
assert.strictEqual(
$(target).find(".o_client_action_test").text(),
"Hello World",
"should have correctly rendered the client action"
);
assert.verifySteps(["/web/webclient/load_menus"]);
});
QUnit.test("async client action (function) returning another action", async function (assert) {
assert.expect(1);
registry.category("actions").add("my_action", async () => {
await Promise.resolve();
return 1; // execute action 1
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, "my_action");
assert.containsOnce(target, ".o_kanban_view");
});
QUnit.test(
"'CLEAR-UNCOMMITTED-CHANGES' is not triggered for function client actions",
async function (assert) {
assert.expect(2);
registry.category("actions").add("my_action", async () => {
assert.step("my_action");
});
const webClient = await createWebClient({ serverData });
webClient.env.bus.addEventListener("CLEAR-UNCOMMITTED-CHANGES", () => {
assert.step("CLEAR-UNCOMMITTED-CHANGES");
});
await doAction(webClient, "my_action");
assert.verifySteps(["my_action"]);
}
);
QUnit.test("client action with control panel (legacy)", async function (assert) {
assert.expect(4);
// LPE Fixme: at this time we don't really know the API that wowl ClientActions implement
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
start() {
this.$(".o_content").text("Hello World");
this.$el.addClass("o_client_action_test");
this.controlPanelProps.title = "Hello";
return this._super.apply(this, arguments);
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.strictEqual(
$(".o_control_panel:visible").length,
1,
"should have rendered a control panel"
);
assert.containsN(
target,
".o_control_panel .breadcrumb-item",
1,
"there should be one controller in the breadcrumbs"
);
assert.strictEqual(
$(".o_control_panel .breadcrumb-item").text(),
"Hello",
"breadcrumbs should still display the title of the controller"
);
assert.strictEqual(
$(target).find(".o_client_action_test .o_content").text(),
"Hello World",
"should have correctly rendered the client action"
);
});
QUnit.test("state is pushed for client action (legacy)", async function (assert) {
assert.expect(6);
const ClientAction = AbstractAction.extend({
getTitle: function () {
return "a title";
},
getState: function () {
return { foo: "baz" };
},
});
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
pushState(...arguments);
assert.step("push_state");
},
}),
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
let currentTitle = webClient.env.services.title.current;
assert.strictEqual(currentTitle, '{"zopenerp":"Odoo"}');
let currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {});
await doAction(webClient, "HelloWorldTest");
currentTitle = webClient.env.services.title.current;
assert.strictEqual(currentTitle, '{"zopenerp":"Odoo","action":"a title"}');
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: "HelloWorldTest",
foo: "baz",
});
assert.verifySteps(["push_state"]);
});
QUnit.test("action can use a custom control panel (legacy)", async function (assert) {
assert.expect(1);
class CustomControlPanel extends Component {}
CustomControlPanel.template = xml`
<div class="custom-control-panel">My custom control panel</div>
`;
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
config: {
ControlPanel: CustomControlPanel,
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.containsOnce(target, ".custom-control-panel", "should have a custom control panel");
});
QUnit.test("breadcrumb is updated on title change (legacy)", async function (assert) {
assert.expect(2);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
events: {
click: function () {
this.updateControlPanel({ title: "new title" });
},
},
start: async function () {
this.$(".o_content").text("Hello World");
this.$el.addClass("o_client_action_test");
this.controlPanelProps.title = "initial title";
await this._super.apply(this, arguments);
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.strictEqual(
$("ol.breadcrumb").text(),
"initial title",
"should have initial title as breadcrumb content"
);
await testUtils.dom.click($(target).find(".o_client_action_test"));
await legacyExtraNextTick();
assert.strictEqual(
$("ol.breadcrumb").text(),
"new title",
"should have updated title as breadcrumb content"
);
});
QUnit.test("client actions can have breadcrumbs (legacy)", async function (assert) {
assert.expect(4);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Goldeneye";
this._super.apply(this, arguments);
},
start() {
this.$el.addClass("o_client_action_test");
return this._super.apply(this, arguments);
},
});
const ClientAction2 = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "No time for sweetness";
this._super.apply(this, arguments);
},
start() {
this.$el.addClass("o_client_action_test_2");
return this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
core.action_registry.add("ClientAction2", ClientAction2);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").textContent,
"Goldeneye"
);
await doAction(webClient, "ClientAction2", { clearBreadcrumbs: false });
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").textContent,
"No time for sweetness"
);
delete core.action_registry.map.ClientAction;
delete core.action_registry.map.ClientAction2;
});
QUnit.test("client action restore scrollbar (legacy)", async function (assert) {
assert.expect(7);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Title1";
this._super.apply(this, arguments);
},
async start() {
for (let i = 0; i < 100; i++) {
const content = document.createElement("div");
content.innerText = "Paper company";
content.className = "lorem";
this.el.querySelector(".o_content").appendChild(content);
}
await this._super(arguments);
},
});
const ClientAction2 = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Title2";
this._super.apply(this, arguments);
},
start() {
return this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
core.action_registry.add("ClientAction2", ClientAction2);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title1");
target.querySelector(".lorem:last-child").scrollIntoView();
const scrollPosition = target.querySelector(".o_content").scrollTop;
assert.ok(scrollPosition > 0);
await doAction(webClient, "ClientAction2", { clearBreadcrumbs: false });
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title2");
await click(target.querySelector(".breadcrumb-item:first-child"));
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title1");
assert.strictEqual(
target.querySelector(".o_content").scrollTop,
scrollPosition,
"Should restore the scroll"
);
delete core.action_registry.map.ClientAction;
delete core.action_registry.map.ClientAction2;
});
QUnit.test("ClientAction receives breadcrumbs and exports title (wowl)", async (assert) => {
assert.expect(4);
class ClientAction extends Component {
setup() {
this.breadcrumbTitle = "myOwlAction";
const { breadcrumbs } = this.env.config;
assert.strictEqual(breadcrumbs.length, 2);
assert.strictEqual(breadcrumbs[0].name, "Favorite Ponies");
owl.onMounted(() => {
this.env.config.setDisplayName(this.breadcrumbTitle);
});
}
onClick() {
this.breadcrumbTitle = "newOwlTitle";
this.env.config.setDisplayName(this.breadcrumbTitle);
}
}
ClientAction.template = xml`<div class="my_owl_action" t-on-click="onClick">owl client action</div>`;
actionRegistry.add("OwlClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 8);
await doAction(webClient, "OwlClientAction");
assert.containsOnce(target, ".my_owl_action");
await click(target, ".my_owl_action");
await doAction(webClient, 3);
assert.strictEqual(
target.querySelector(".breadcrumb").textContent,
"Favorite PoniesnewOwlTitlePartners"
);
});
QUnit.test("ClientAction receives arbitrary props from doAction (wowl)", async (assert) => {
assert.expect(1);
class ClientAction extends Component {
setup() {
assert.strictEqual(this.props.division, "bell");
}
}
ClientAction.template = xml`<div class="my_owl_action"></div>`;
actionRegistry.add("OwlClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "OwlClientAction", {
props: { division: "bell" },
});
});
QUnit.test("ClientAction receives arbitrary props from doAction (legacy)", async (assert) => {
assert.expect(1);
const ClientAction = AbstractAction.extend({
init(parent, action, options) {
assert.strictEqual(options.division, "bell");
this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
registerCleanup(() => delete core.action_registry.map.ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction", {
props: { division: "bell" },
});
});
QUnit.test("test display_notification client action", async function (assert) {
assert.expect(6);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
await doAction(webClient, {
type: "ir.actions.client",
tag: "display_notification",
params: {
title: "title",
message: "message",
sticky: true,
},
});
const notificationSelector = ".o_notification_manager .o_notification";
assert.containsOnce(
document.body,
notificationSelector,
"a notification should be present"
);
const notificationElement = document.body.querySelector(notificationSelector);
assert.strictEqual(
notificationElement.querySelector(".o_notification_title").textContent,
"title",
"the notification should have the correct title"
);
assert.strictEqual(
notificationElement.querySelector(".o_notification_content").textContent,
"message",
"the notification should have the correct message"
);
assert.containsOnce(target, ".o_kanban_view");
await testUtils.dom.click(notificationElement.querySelector(".o_notification_close"));
assert.containsNone(
document.body,
notificationSelector,
"the notification should be destroy "
);
});
QUnit.test("test display_notification client action with links", async function (assert) {
assert.expect(8);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
await doAction(webClient, {
type: "ir.actions.client",
tag: "display_notification",
params: {
title: "title",
message: "message %s <R&D>",
sticky: true,
links: [
{
label: "test <R&D>",
url: "#action={action.id}&id={order.id}&model=purchase.order",
},
],
},
});
const notificationSelector = ".o_notification_manager .o_notification";
assert.containsOnce(
document.body,
notificationSelector,
"a notification should be present"
);
let notificationElement = document.body.querySelector(notificationSelector);
assert.strictEqual(
notificationElement.querySelector(".o_notification_title").textContent,
"title",
"the notification should have the correct title"
);
assert.strictEqual(
notificationElement.querySelector(".o_notification_content").textContent,
"message test <R&D> <R&D>",
"the notification should have the correct message"
);
assert.containsOnce(target, ".o_kanban_view");
await testUtils.dom.click(notificationElement.querySelector(".o_notification_close"));
assert.containsNone(
document.body,
notificationSelector,
"the notification should be destroy "
);
// display_notification without title
await doAction(webClient, {
type: "ir.actions.client",
tag: "display_notification",
params: {
message: "message %s <R&D>",
sticky: true,
links: [
{
label: "test <R&D>",
url: "#action={action.id}&id={order.id}&model=purchase.order",
},
],
},
});
assert.containsOnce(
document.body,
notificationSelector,
"a notification should be present"
);
notificationElement = document.body.querySelector(notificationSelector);
assert.containsNone(
notificationElement,
".o_notification_title",
"the notification should not have title"
);
});
QUnit.test("test next action on display_notification client action", async function (assert) {
const webClient = await createWebClient({ serverData });
const options = {
onClose: function () {
assert.step("onClose");
},
};
await doAction(
webClient,
{
type: "ir.actions.client",
tag: "display_notification",
params: {
title: "title",
message: "message",
sticky: true,
next: {
type: "ir.actions.act_window_close",
},
},
},
options
);
const notificationSelector = ".o_notification_manager .o_notification";
assert.containsOnce(
document.body,
notificationSelector,
"a notification should be present"
);
assert.verifySteps(["onClose"]);
});
});

View file

@ -0,0 +1,230 @@
/** @odoo-module **/
import testUtils from "web.test_utils";
import { registerCleanup } from "../../helpers/cleanup";
import {
click,
getFixture,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { listView } from "../../../src/views/list/list_view";
let serverData;
let target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module('"ir.actions.act_window_close" actions');
QUnit.test("close the currently opened dialog", async function (assert) {
assert.expect(2);
const webClient = await createWebClient({ serverData });
// execute an action in target="new"
await doAction(webClient, 5);
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view",
"should have rendered a form view in a modal"
);
// execute an 'ir.actions.act_window_close' action
await doAction(webClient, {
type: "ir.actions.act_window_close",
});
assert.containsNone(document.body, ".o_technical_modal", "should have closed the modal");
});
QUnit.test("close dialog by clicking on the header button", async function (assert) {
assert.expect(5);
const webClient = await createWebClient({ serverData });
// execute an action in target="new"
function onClose() {
assert.step("on_close");
}
await doAction(webClient, 5, { onClose });
assert.containsOnce(target, ".o_dialog_container .o_dialog");
await click(target.querySelector(".o_dialog_container .o_dialog .modal-header button"));
assert.containsNone(target, ".o_dialog_container .o_dialog");
assert.verifySteps(["on_close"]);
// execute an 'ir.actions.act_window_close' action
// should not call 'on_close' as it was already called.
await doAction(webClient, { type: "ir.actions.act_window_close" });
assert.verifySteps([]);
});
QUnit.test('execute "on_close" only if there is no dialog to close', async function (assert) {
assert.expect(3);
const webClient = await createWebClient({ serverData });
// execute an action in target="new"
await doAction(webClient, 5);
function onClose() {
assert.step("on_close");
}
const options = { onClose };
// execute an 'ir.actions.act_window_close' action
// should not call 'on_close' as there is a dialog to close
await doAction(webClient, { type: "ir.actions.act_window_close" }, options);
assert.verifySteps([]);
// execute again an 'ir.actions.act_window_close' action
// should call 'on_close' as there is no dialog to close
await doAction(webClient, { type: "ir.actions.act_window_close" }, options);
assert.verifySteps(["on_close"]);
});
QUnit.test("close action with provided infos", async function (assert) {
assert.expect(1);
const webClient = await createWebClient({ serverData });
const options = {
onClose: function (infos) {
assert.strictEqual(
infos,
"just for testing",
"should have the correct close infos"
);
},
};
await doAction(
webClient,
{
type: "ir.actions.act_window_close",
infos: "just for testing",
},
options
);
});
QUnit.test("history back calls on_close handler of dialog action", async function (assert) {
assert.expect(4);
let form;
patchWithCleanup(formView.Controller.prototype, {
setup() {
this._super(...arguments);
form = this;
},
});
const webClient = await createWebClient({ serverData });
function onClose() {
assert.step("on_close");
}
// open a new dialog form
await doAction(webClient, 5, { onClose });
assert.containsOnce(target, ".modal");
form.env.config.historyBack();
assert.verifySteps(["on_close"], "should have called the on_close handler");
await nextTick();
assert.containsNone(target, ".modal");
});
QUnit.test("history back called within on_close", async function (assert) {
assert.expect(7);
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
function onClose() {
list.env.config.historyBack();
assert.step("on_close");
}
// open a new dialog form
await doAction(webClient, 5, { onClose });
await click(target, ".modal-header button.btn-close");
await nextTick();
await legacyExtraNextTick();
assert.containsNone(target, ".modal");
assert.containsNone(target, ".o_list_view");
assert.containsOnce(target, ".o_kanban_view");
assert.verifySteps(["on_close"]);
});
QUnit.test(
"history back calls on_close handler of dialog action with 2 breadcrumbs",
async function (assert) {
assert.expect(7);
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1); // kanban
await doAction(webClient, 3); // list
assert.containsOnce(target, ".o_list_view");
function onClose() {
assert.step("on_close");
}
// open a new dialog form
await doAction(webClient, 5, { onClose });
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".o_list_view");
list.env.config.historyBack();
assert.verifySteps(["on_close"], "should have called the on_close handler");
await nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_list_view");
assert.containsNone(target, ".modal");
}
);
QUnit.test("web client is not deadlocked when a view crashes", async function (assert) {
assert.expect(6);
const handler = (ev) => {
assert.step("error");
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
// fake error service so that the odoo qunit handlers don't think that they need to handle the error
registry.category("services").add("error", { start: () => {} });
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
const readOnFirstRecordDef = testUtils.makeTestPromise();
const mockRPC = (route, args) => {
if (args.method === "read" && args.args[0][0] === 1) {
return readOnFirstRecordDef;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
// open first record in form view. this will crash and will not
// display a form view
await testUtils.dom.click($(target).find(".o_list_view .o_data_cell:first"));
assert.verifySteps([]);
await legacyExtraNextTick();
readOnFirstRecordDef.reject(new Error("not working as intended"));
await nextTick();
assert.verifySteps(["error"]);
assert.containsOnce(target, ".o_list_view", "there should still be a list view in dom");
// open another record, the read will not crash
await testUtils.dom.click(
$(target).find(".o_list_view .o_data_row:eq(2) .o_data_cell:first")
);
await legacyExtraNextTick();
assert.containsNone(target, ".o_list_view", "there should not be a list view in dom");
assert.containsOnce(target, ".o_form_view", "there should be a form view in dom");
});
});

View file

@ -0,0 +1,736 @@
/** @odoo-module **/
import {
click,
getFixture,
legacyExtraNextTick,
makeDeferred,
nextTick,
} from "@web/../tests/helpers/utils";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import {
isItemSelected,
toggleFilterMenu,
toggleMenuItem,
switchView,
} from "@web/../tests/search/helpers";
import { registry } from "@web/core/registry";
import { useSetupView } from "@web/views/view_hook";
import {
createWebClient,
doAction,
getActionManagerServerData,
loadState,
} from "@web/../tests/webclient/helpers";
import { Component, xml } from "@odoo/owl";
const actionRegistry = registry.category("actions");
let serverData;
let target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Concurrency management");
QUnit.test("drop previous actions if possible", async function (assert) {
assert.expect(7);
const def = makeDeferred();
const mockRPC = async function (route) {
assert.step(route);
if (route === "/web/action/load") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
doAction(webClient, 4);
doAction(webClient, 8);
def.resolve();
await nextTick();
// action 4 loads a kanban view first, 6 loads a list view. We want a list
assert.containsOnce(target, ".o_list_view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/web/action/load",
"/web/dataset/call_kw/pony/get_views",
"/web/dataset/call_kw/pony/web_search_read",
]);
});
QUnit.test("handle switching view and switching back on slow network", async function (assert) {
assert.expect(9);
const def = makeDeferred();
const defs = [Promise.resolve(), def, Promise.resolve()];
const mockRPC = async function (route, { method }) {
assert.step(route);
if (method === "web_search_read") {
await defs.shift();
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 4);
// kanban view is loaded, switch to list view
await switchView(target, "list");
// here, list view is not ready yet, because def is not resolved
// switch back to kanban view
await switchView(target, "kanban");
// here, we want the kanban view to reload itself, regardless of list view
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/web_search_read",
"/web/dataset/call_kw/partner/web_search_read",
"/web/dataset/call_kw/partner/web_search_read",
]);
// we resolve def => list view is now ready (but we want to ignore it)
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view", "there should be a kanban view in dom");
assert.containsNone(target, ".o_list_view", "there should not be a list view in dom");
});
QUnit.test("when an server action takes too much time...", async function (assert) {
assert.expect(1);
const def = makeDeferred();
const mockRPC = async function (route, args) {
if (route === "/web/action/run") {
await def;
return 1;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
doAction(webClient, 2);
doAction(webClient, 4);
def.resolve();
await nextTick();
await legacyExtraNextTick();
assert.strictEqual(
$(target).find(".o_control_panel .breadcrumb-item.active").text(),
"Partners Action 4",
"action 4 should be loaded"
);
});
QUnit.test("clicking quickly on breadcrumbs...", async function (assert) {
assert.expect(1);
let def;
const mockRPC = async function (route, args) {
if (args && args.method === "read") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// create a situation with 3 breadcrumbs: kanban/form/list
await doAction(webClient, 4);
await click(target.querySelector(".o_kanban_record"));
await doAction(webClient, 8);
// now, the next read operations will be promise (this is the read
// operation for the form view reload)
def = makeDeferred();
// click on the breadcrumbs for the form view, then on the kanban view
// before the form view is fully reloaded
await click(target.querySelectorAll(".o_control_panel .breadcrumb-item")[1]);
await click(target.querySelector(".o_control_panel .breadcrumb-item"));
// resolve the form view read
def.resolve();
await nextTick();
assert.strictEqual(
$(target).find(".o_control_panel .breadcrumb-item.active").text(),
"Partners Action 4",
"action 4 should be loaded and visible"
);
});
QUnit.test(
"execute a new action while loading a lazy-loaded controller",
async function (assert) {
assert.expect(16);
let def;
const mockRPC = async function (route, { method, model }) {
assert.step(method || route);
if (method === "web_search_read" && model === "partner") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await loadState(webClient, {
action: 4,
id: 2,
view_type: "form",
});
assert.containsOnce(target, ".o_form_view", "should display the form view of action 4");
// click to go back to Kanban (this request is blocked)
def = makeDeferred();
await click(target.querySelector(".o_control_panel .breadcrumb a"));
assert.containsOnce(
target,
".o_form_view",
"should still display the form view of action 4"
);
// execute another action meanwhile (don't block this request)
await doAction(webClient, 8, { clearBreadcrumbs: true });
assert.containsOnce(target, ".o_list_view", "should display action 8");
assert.containsNone(target, ".o_form_view", "should no longer display the form view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"read",
"web_search_read",
"/web/action/load",
"get_views",
"web_search_read",
]);
// unblock the switch to Kanban in action 4
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_list_view", "should still display action 8");
assert.containsNone(
target,
".o_kanban_view",
"should not display the kanban view of action 4"
);
assert.verifySteps([]);
}
);
QUnit.test("execute a new action while handling a call_button", async function (assert) {
assert.expect(17);
const def = makeDeferred();
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
if (route === "/web/dataset/call_button") {
await def;
return serverData.actions[1];
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute action 3 and open a record in form view
await doAction(webClient, 3);
await click(target.querySelector(".o_list_view .o_data_cell"));
assert.containsOnce(target, ".o_form_view", "should display the form view of action 3");
// click on 'Call method' button (this request is blocked)
await click(target.querySelector('.o_form_view button[name="object"]'));
assert.containsOnce(
target,
".o_form_view",
"should still display the form view of action 3"
);
// execute another action
await doAction(webClient, 8, { clearBreadcrumbs: true });
assert.containsOnce(target, ".o_list_view", "should display the list view of action 8");
assert.containsNone(target, ".o_form_view", "should no longer display the form view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"read",
"object",
"/web/action/load",
"get_views",
"web_search_read",
]);
// unblock the call_button request
def.resolve();
await nextTick();
assert.containsOnce(
target,
".o_list_view",
"should still display the list view of action 8"
);
assert.containsNone(target, ".o_kanban_view", "should not display action 1");
assert.verifySteps([]);
});
QUnit.test(
"execute a new action while switching to another controller",
async function (assert) {
assert.expect(16);
// This test's bottom line is that a doAction always has priority
// over a switch controller (clicking on a record row to go to form view).
// In general, the last actionManager's operation has priority because we want
// to allow the user to make mistakes, or to rapidly reconsider her next action.
// Here we assert that the actionManager's RPC are in order, but a 'read' operation
// is expected, with the current implementation, to take place when switching to the form view.
// Ultimately the form view's 'read' is superfluous, but can happen at any point of the flow,
// except at the very end, which should always be the final action's list's 'search_read'.
let def;
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
if (args && args.method === "read") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view", "should display the list view of action 3");
// switch to the form view (this request is blocked)
def = makeDeferred();
await click(target.querySelector(".o_list_view .o_data_cell"));
assert.containsOnce(
target,
".o_list_view",
"should still display the list view of action 3"
);
// execute another action meanwhile (don't block this request)
await doAction(webClient, 4, { clearBreadcrumbs: true });
assert.containsOnce(
target,
".o_kanban_view",
"should display the kanban view of action 8"
);
assert.containsNone(target, ".o_list_view", "should no longer display the list view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"read",
"/web/action/load",
"get_views",
"web_search_read",
]);
// unblock the switch to the form view in action 3
def.resolve();
await nextTick();
assert.containsOnce(
target,
".o_kanban_view",
"should still display the kanban view of action 8"
);
assert.containsNone(
target,
".o_form_view",
"should not display the form view of action 3"
);
assert.verifySteps([]);
}
);
QUnit.test("execute a new action while loading views", async function (assert) {
assert.expect(11);
const def = makeDeferred();
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
if (args && args.method === "get_views") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute a first action (its 'get_views' RPC is blocked)
doAction(webClient, 3);
await nextTick();
assert.containsNone(target, ".o_list_view", "should not display the list view of action 3");
// execute another action meanwhile (and unlock the RPC)
doAction(webClient, 4);
await nextTick();
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view", "should display the kanban view of action 4");
assert.containsNone(target, ".o_list_view", "should not display the list view of action 3");
assert.containsOnce(
target,
".o_control_panel .breadcrumb-item",
"there should be one controller in the breadcrumbs"
);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
QUnit.test("execute a new action while loading data of default view", async function (assert) {
assert.expect(12);
const def = makeDeferred();
const mockRPC = async function (route, { method }) {
assert.step(method || route);
if (method === "web_search_read") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute a first action (its 'search_read' RPC is blocked)
doAction(webClient, 3);
await nextTick();
assert.containsNone(target, ".o_list_view", "should not display the list view of action 3");
// execute another action meanwhile (and unlock the RPC)
doAction(webClient, 4);
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view", "should display the kanban view of action 4");
assert.containsNone(target, ".o_list_view", "should not display the list view of action 3");
assert.containsOnce(
target,
".o_control_panel .breadcrumb-item",
"there should be one controller in the breadcrumbs"
);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
QUnit.test("open a record while reloading the list view", async function (assert) {
assert.expect(10);
let def;
const mockRPC = async function (route) {
if (route === "/web/dataset/search_read") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
assert.containsN(target, ".o_list_view .o_data_row", 5);
assert.containsOnce(target, ".o_control_panel .o_list_buttons");
// reload (the search_read RPC will be blocked)
def = makeDeferred();
await switchView(target, "list");
assert.containsN(target, ".o_list_view .o_data_row", 5);
assert.containsOnce(target, ".o_control_panel .o_list_buttons");
// open a record in form view
await click(target.querySelector(".o_list_view .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
assert.containsNone(target, ".o_control_panel .o_list_buttons");
// unblock the search_read RPC
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_form_view");
assert.containsNone(target, ".o_list_view");
assert.containsNone(target, ".o_control_panel .o_list_buttons");
});
QUnit.test(
"properly drop client actions after new action is initiated",
async function (assert) {
assert.expect(3);
const slowWillStartDef = makeDeferred();
class ClientAction extends Component {
setup() {
owl.onWillStart(() => slowWillStartDef);
}
}
ClientAction.template = xml`<div class="client_action">ClientAction</div>`;
actionRegistry.add("slowAction", ClientAction);
const webClient = await createWebClient({ serverData });
doAction(webClient, "slowAction");
await nextTick();
await legacyExtraNextTick();
assert.containsNone(target, ".client_action", "client action isn't ready yet");
doAction(webClient, 4);
await nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view", "should have loaded a kanban view");
slowWillStartDef.resolve();
await nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view", "should still display the kanban view");
}
);
QUnit.test(
"restoring a controller when doing an action -- load_action slow",
async function (assert) {
assert.expect(14);
let def;
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/web/action/load") {
return Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_list_view .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
def = makeDeferred();
doAction(webClient, 4, { clearBreadcrumbs: true });
await nextTick();
assert.containsOnce(target, ".o_form_view", "should still contain the form view");
await click(target.querySelector(".o_control_panel .breadcrumb-item a"));
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_list_view");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb-item").textContent,
"Partners"
);
assert.containsNone(target, ".o_form_view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"read",
"/web/action/load",
"web_search_read",
]);
}
);
QUnit.test("switching when doing an action -- load_action slow", async function (assert) {
assert.expect(12);
let def;
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/web/action/load") {
return Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
def = makeDeferred();
doAction(webClient, 4, { clearBreadcrumbs: true });
await nextTick();
assert.containsOnce(target, ".o_list_view", "should still contain the list view");
await switchView(target, "kanban");
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb-item").textContent,
"Partners"
);
assert.containsNone(target, ".o_list_view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"/web/action/load",
"web_search_read",
]);
});
QUnit.test("switching when doing an action -- get_views slow", async function (assert) {
assert.expect(13);
let def;
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (args && args.method === "get_views") {
return Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
def = makeDeferred();
doAction(webClient, 4, { clearBreadcrumbs: true });
await nextTick();
assert.containsOnce(target, ".o_list_view", "should still contain the list view");
await switchView(target, "kanban");
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb-item").textContent,
"Partners"
);
assert.containsNone(target, ".o_list_view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
QUnit.test("switching when doing an action -- search_read slow", async function (assert) {
assert.expect(13);
const def = makeDeferred();
const defs = [null, def, null];
const mockRPC = async (route, { method }) => {
assert.step(method || route);
if (method === "web_search_read") {
await Promise.resolve(defs.shift());
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
doAction(webClient, 4, { clearBreadcrumbs: true });
await nextTick();
await switchView(target, "kanban");
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_kanban_view");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb-item").textContent,
"Partners"
);
assert.containsNone(target, ".o_list_view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"/web/action/load",
"get_views",
"web_search_read",
"web_search_read",
]);
});
QUnit.test("click multiple times to open a record", async function (assert) {
const def = makeDeferred();
const defs = [null, def];
const mockRPC = async (route, args) => {
if (args.method === "read") {
await Promise.resolve(defs.shift());
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_list_view .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
await click(target.querySelector(".o_back_button"));
assert.containsOnce(target, ".o_list_view");
const row1 = target.querySelectorAll(".o_list_view .o_data_row")[0];
const row2 = target.querySelectorAll(".o_list_view .o_data_row")[1];
await click(row1.querySelector(".o_data_cell"));
await click(row2.querySelector(".o_data_cell"));
assert.containsOnce(target, ".o_form_view");
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").innerText,
"Second record"
);
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_form_view");
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").innerText,
"Second record"
);
});
QUnit.test(
"dialog will only open once for two rapid actions with the target new",
async function (assert) {
assert.expect(3)
const def = makeDeferred();
const mockRPC = async (route, args) => {
if (args.method === "onchange") {
return def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
doAction(webClient, 5);
await nextTick();
assert.containsNone(target, ".o_dialog .o_form_view");
doAction(webClient, 5);
await nextTick();
assert.containsNone(target, ".o_dialog .o_form_view");
def.resolve();
await nextTick();
assert.containsOnce(target, ".o_dialog .o_form_view", "dialog should open only once");
}
);
QUnit.test("local state, global state, and race conditions", async function (assert) {
serverData.views = {
"partner,false,toy": `<toy/>`,
"partner,false,list": `<list><field name="foo"/></list>`,
"partner,false,search": `
<search>
<filter name="foo" string="Foo" domain="[]"/>
</search>
`,
};
let def = Promise.resolve();
let id = 1;
class ToyController extends Component {
setup() {
this.id = id++;
assert.step(JSON.stringify(this.props.state || "no state"));
useSetupView({
getLocalState: () => {
return { fromId: this.id };
},
});
owl.onWillStart(() => def);
}
}
ToyController.template = xml`
<div class="o_toy_view">
<ControlPanel />
</div>`;
ToyController.components = { ControlPanel };
registry.category("views").add("toy", {
type: "toy",
display_name: "Toy",
icon: "fab fa-android",
multiRecord: true,
searchMenuTypes: ["filter"],
Controller: ToyController,
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
res_model: "partner",
type: "ir.actions.act_window",
// list (or something else) must be added to have the view switcher displayed
views: [
[false, "toy"],
[false, "list"],
],
});
await toggleFilterMenu(target);
await toggleMenuItem(target, "Foo");
assert.ok(isItemSelected(target, "Foo"));
// reload twice by clicking on toy view switcher
def = makeDeferred();
await click(target.querySelector(".o_control_panel .o_switch_view.o_toy"));
await click(target.querySelector(".o_control_panel .o_switch_view.o_toy"));
def.resolve();
await nextTick();
await toggleFilterMenu(target);
assert.ok(isItemSelected(target, "Foo"));
// this test is not able to detect that getGlobalState is put on the right place:
// currentController.action.globalState contains in any case the search state
// of the first instantiated toy view.
assert.verifySteps([
`"no state"`, // setup first view instantiated
`{"fromId":1}`, // setup second view instantiated
`{"fromId":1}`, // setup third view instantiated
]);
});
});

View file

@ -0,0 +1,110 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import testUtils from "web.test_utils";
import { clearRegistryWithCleanup } from "../../helpers/mock_env";
import {
click,
getFixture,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { session } from "@web/session";
let serverData;
let target;
const mainComponentRegistry = registry.category("main_components");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Effects");
QUnit.test("rainbowman integrated to webClient", async function (assert) {
assert.expect(10);
patchWithCleanup(session, { show_effect: true });
clearRegistryWithCleanup(mainComponentRegistry);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
assert.containsNone(target, ".o_reward");
webClient.env.services.effect.add({ type: "rainbow_man", message: "", fadeout: "no" });
await nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_kanban_view");
await testUtils.dom.click(target.querySelector(".o_kanban_record"));
await legacyExtraNextTick();
assert.containsNone(target, ".o_reward");
assert.containsOnce(target, ".o_kanban_view");
webClient.env.services.effect.add({ type: "rainbow_man", message: "", fadeout: "no" });
await nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_kanban_view");
// Do not force rainbow man to destroy on doAction
// we let it die either after its animation or on user click
await doAction(webClient, 3);
assert.containsOnce(target, ".o_reward");
assert.containsOnce(target, ".o_list_view");
});
QUnit.test("on close with effect from server", async function (assert) {
assert.expect(1);
patchWithCleanup(session, { show_effect: true });
const mockRPC = async (route) => {
if (route === "/web/dataset/call_button") {
return Promise.resolve({
type: "ir.actions.act_window_close",
effect: {
type: "rainbow_man",
message: "button called",
},
});
}
};
clearRegistryWithCleanup(mainComponentRegistry);
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 6);
await click(target.querySelector('button[name="object"]'));
assert.containsOnce(target, ".o_reward");
});
QUnit.test("on close with effect in xml", async function (assert) {
assert.expect(2);
serverData.views["partner,false,form"] = `
<form>
<header>
<button string="Call method" name="object" type="object"
effect="{'type': 'rainbow_man', 'message': 'rainBowInXML'}"
/>
</header>
<field name="display_name"/>
</form>`;
patchWithCleanup(session, { show_effect: true });
const mockRPC = async (route) => {
if (route === "/web/dataset/call_button") {
return Promise.resolve(false);
}
};
clearRegistryWithCleanup(mainComponentRegistry);
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 6);
await click(target.querySelector('button[name="object"]'));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_reward");
assert.strictEqual(
target.querySelector(".o_reward .o_reward_msg_content").textContent,
"rainBowInXML"
);
});
});

View file

@ -0,0 +1,81 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { registerCleanup } from "../../helpers/cleanup";
import { click, getFixture, nextTick, patchWithCleanup } from "../../helpers/utils";
import { errorService } from "@web/core/errors/error_service";
import { Component, xml } from "@odoo/owl";
let serverData;
let target;
const actionRegistry = registry.category("actions");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Error handling");
QUnit.test("error in a client action (at rendering)", async function (assert) {
assert.expect(4);
class Boom extends Component {}
Boom.template = xml`<div><t t-esc="a.b.c"/></div>`;
actionRegistry.add("Boom", Boom);
const webClient = await createWebClient({ serverData });
assert.strictEqual(target.querySelector(".o_action_manager").innerHTML, "");
await doAction(webClient, "1");
const contents = target.querySelector(".o_action_manager").innerHTML;
assert.ok(contents !== "");
try {
await doAction(webClient, "Boom");
} catch (e) {
assert.ok(e.cause instanceof TypeError);
}
assert.strictEqual(target.querySelector(".o_action_manager").innerHTML, contents);
});
QUnit.test("error in a client action (after the first rendering)", async function (assert) {
const handler = (ev) => {
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
registry.category("services").add("error", errorService);
class Boom extends Component {
setup() {
this.boom = false;
}
onClick() {
this.boom = true;
this.render();
}
}
Boom.template = xml`
<div>
<t t-if="boom" t-esc="a.b.c"/>
<button t-else="" class="my_button" t-on-click="onClick">Click Me</button>
</div>`;
actionRegistry.add("Boom", Boom);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "Boom");
assert.containsOnce(target, ".my_button");
await click(document.querySelector(".my_button"));
await nextTick();
assert.containsOnce(target, ".my_button");
assert.containsOnce(target, ".o_dialog_error");
});
});

View file

@ -0,0 +1,776 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import testUtils from "web.test_utils";
import ListController from "web.ListController";
import FormView from "web.FormView";
import ListView from "web.ListView";
import {
click,
destroy,
getFixture,
makeDeferred,
legacyExtraNextTick,
patchWithCleanup,
triggerEvents,
} from "../../helpers/utils";
import KanbanView from "web.KanbanView";
import { registerCleanup } from "../../helpers/cleanup";
import { makeTestEnv } from "../../helpers/mock_env";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import makeTestEnvironment from "web.test_env";
import { ClientActionAdapter, ViewAdapter } from "@web/legacy/action_adapters";
import { makeLegacyCrashManagerService } from "@web/legacy/utils";
import { useDebugCategory } from "@web/core/debug/debug_context";
import { ErrorDialog } from "@web/core/errors/error_dialogs";
import * as cpHelpers from "@web/../tests/search/helpers";
import AbstractView from "web.AbstractView";
import ControlPanel from "web.ControlPanel";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import Widget from "web.Widget";
import SystrayMenu from "web.SystrayMenu";
import legacyViewRegistry from "web.view_registry";
let serverData;
let target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
registry.category("views").remove("form"); // remove new form from registry
registry.category("views").remove("kanban"); // remove new kanban from registry
registry.category("views").remove("list"); // remove new list from registry
legacyViewRegistry.add("form", FormView); // add legacy form -> will be wrapped and added to new registry
legacyViewRegistry.add("kanban", KanbanView); // add legacy kanban -> will be wrapped and added to new registry
legacyViewRegistry.add("list", ListView); // add legacy list -> will be wrapped and added to new registry
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Legacy tests (to eventually drop)");
QUnit.test("display warning as notification", async function (assert) {
// this test can be removed as soon as the legacy layer is dropped
assert.expect(5);
let list;
patchWithCleanup(ListController.prototype, {
init() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_legacy_list_view");
list.trigger_up("warning", {
title: "Warning!!!",
message: "This is a warning...",
});
await testUtils.nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_list_view");
assert.containsOnce(document.body, ".o_notification.border-warning");
assert.strictEqual($(".o_notification_title").text(), "Warning!!!");
assert.strictEqual($(".o_notification_content").text(), "This is a warning...");
});
QUnit.test("display warning as modal", async function (assert) {
// this test can be removed as soon as the legacy layer is dropped
assert.expect(5);
let list;
patchWithCleanup(ListController.prototype, {
init() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_legacy_list_view");
list.trigger_up("warning", {
title: "Warning!!!",
message: "This is a warning...",
type: "dialog",
});
await testUtils.nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_list_view");
assert.containsOnce(document.body, ".modal");
assert.strictEqual($(".modal-title").text(), "Warning!!!");
assert.strictEqual($(".modal-body").text(), "This is a warning...");
});
QUnit.test("display multiline warning as modal", async function (assert) {
assert.expect(5);
let list;
patchWithCleanup(ListController.prototype, {
init() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_legacy_list_view");
list.trigger_up("warning", {
title: "Warning!!!",
message: "This is a warning...\nabc",
type: "dialog",
});
await testUtils.nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_list_view");
assert.containsOnce(document.body, ".modal");
assert.strictEqual($(".modal-title").text(), "Warning!!!");
assert.strictEqual($(".modal-body")[0].innerText, "This is a warning...\nabc");
});
QUnit.test(
"legacy crash manager is still properly remapped to error service",
async function (assert) {
// this test can be removed as soon as the legacy layer is dropped
assert.expect(2);
const legacyEnv = makeTestEnvironment();
registry
.category("services")
.add("legacy_crash_manager", makeLegacyCrashManagerService(legacyEnv))
.add("dialog", {
start() {
return {
add(dialogClass, props) {
assert.strictEqual(dialogClass, ErrorDialog);
assert.strictEqual(props.traceback, "BOOM");
},
};
},
});
await makeTestEnv();
legacyEnv.services.crash_manager.show_message("BOOM");
}
);
QUnit.test("redraw a controller and open debugManager does not crash", async (assert) => {
assert.expect(11);
const LegacyAction = AbstractAction.extend({
start() {
const ret = this._super(...arguments);
const el = document.createElement("div");
el.classList.add("custom-action");
this.el.append(el);
return ret;
},
});
core.action_registry.add("customLegacy", LegacyAction);
patchWithCleanup(ClientActionAdapter.prototype, {
setup() {
useDebugCategory("custom", { widget: this });
this._super();
},
});
registry
.category("debug")
.category("custom")
.add("item1", ({ widget }) => {
assert.step("debugItems executed");
assert.ok(widget);
return {};
});
patchWithCleanup(odoo, { debug: true });
const mockRPC = (route) => {
if (route.includes("check_access_rights")) {
return true;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, "customLegacy");
assert.containsOnce(target, ".custom-action");
assert.verifySteps([]);
await click(target, ".o_debug_manager button");
assert.verifySteps(["debugItems executed"]);
await doAction(webClient, 5); // action in Dialog
await click(target, ".modal .o_form_button_cancel");
assert.containsNone(target, ".modal");
assert.containsOnce(target, ".custom-action");
assert.verifySteps([]);
// close debug menu
await click(target, ".o_debug_manager button");
// open debug menu
await click(target, ".o_debug_manager button");
assert.verifySteps(["debugItems executed"]);
delete core.action_registry.map.customLegacy;
});
QUnit.test("willUnmount is called down the legacy layers", async (assert) => {
assert.expect(7);
let mountCount = 0;
patchWithCleanup(ControlPanel.prototype, {
setup() {
this._super();
owl.onMounted(() => {
mountCount = mountCount + 1;
this.__uniqueId = mountCount;
assert.step(`mounted ${this.__uniqueId}`);
});
owl.onWillUnmount(() => {
assert.step(`willUnmount ${this.__uniqueId}`);
});
},
});
const LegacyAction = AbstractAction.extend({
hasControlPanel: true,
start() {
const ret = this._super(...arguments);
const el = document.createElement("div");
el.classList.add("custom-action");
this.el.append(el);
return ret;
},
});
core.action_registry.add("customLegacy", LegacyAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
await doAction(webClient, "customLegacy");
await click(target.querySelectorAll(".breadcrumb-item")[0]);
await legacyExtraNextTick();
destroy(webClient);
assert.verifySteps([
"mounted 1",
"willUnmount 1",
"mounted 2",
"willUnmount 2",
"mounted 3",
"willUnmount 3",
]);
delete core.action_registry.map.customLegacy;
});
QUnit.test("Checks the availability of all views in the action", async (assert) => {
assert.expect(2);
patchWithCleanup(ListView.prototype, {
init(viewInfo, params) {
const action = params.action;
const views = action.views.map((view) => [view.viewID, view.type]);
assert.deepEqual(views, [
[1, "list"],
[2, "kanban"],
[3, "form"],
]);
assert.deepEqual(action._views, [
[1, "list"],
[2, "kanban"],
[3, "form"],
[false, "search"],
]);
this._super(...arguments);
},
});
const models = {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
},
bar: { string: "Bar", type: "boolean" },
int_field: { string: "Integer field", type: "integer", group_operator: "sum" },
},
records: [
{
id: 1,
display_name: "first record",
foo: "yop",
int_field: 3,
},
{
id: 2,
display_name: "second record",
foo: "lalala",
int_field: 5,
},
{
id: 4,
display_name: "aaa",
foo: "abc",
int_field: 2,
},
],
},
};
const views = {
"partner,1,list": '<list><field name="foo"/></list>',
"partner,2,kanban": "<kanban></kanban>",
"partner,3,form": `<form></form>`,
"partner,false,search": "<search></search>",
};
const serverData = { models, views };
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
id: 1,
res_model: "partner",
type: "ir.actions.act_window",
views: [
[1, "list"],
[2, "kanban"],
[3, "form"],
],
});
});
QUnit.test("client actions may take and push their params", async function (assert) {
assert.expect(2);
const ClientAction = AbstractAction.extend({
init(parent, action) {
this._super(...arguments);
assert.deepEqual(action.params, {
active_id: 99,
take: "five",
active_ids: "1,2",
list: [9, 10],
});
},
});
core.action_registry.add("clientAction", ClientAction);
registerCleanup(() => delete core.action_registry.map.clientAction);
const webClient = await createWebClient({});
await doAction(webClient, {
type: "ir.actions.client",
tag: "clientAction",
params: {
active_id: 99,
take: "five",
active_ids: "1,2",
list: [9, 10],
},
});
assert.deepEqual(webClient.env.services.router.current.hash, {
action: "clientAction",
active_id: 99,
take: "five",
active_ids: "1,2",
});
});
QUnit.test("client actions honour do_push_state", async function (assert) {
assert.expect(2);
const ClientAction = AbstractAction.extend({
init(parent) {
this._super(...arguments);
this.parent = parent;
this.parent.do_push_state({ pinball: "wizard" });
},
async start() {
await this._super(...arguments);
const btn = document.createElement("button");
btn.classList.add("tommy");
btn.addEventListener("click", () => {
this.parent.do_push_state({ gipsy: "the acid queen" });
});
this.el.append(btn);
},
getState() {
return {
doctor: "quackson",
};
},
});
core.action_registry.add("clientAction", ClientAction);
registerCleanup(() => delete core.action_registry.map.clientAction);
const webClient = await createWebClient({});
await doAction(webClient, {
type: "ir.actions.client",
tag: "clientAction",
});
assert.deepEqual(webClient.env.services.router.current.hash, {
action: "clientAction",
pinball: "wizard",
doctor: "quackson",
});
await click(target, ".tommy");
assert.deepEqual(webClient.env.services.router.current.hash, {
action: "clientAction",
pinball: "wizard",
gipsy: "the acid queen",
doctor: "quackson",
});
});
QUnit.test("Systray item triggers do action on legacy service provider", async (assert) => {
assert.expect(3);
function createMockActionService(assert) {
return {
dependencies: [],
start() {
return {
doAction(params) {
assert.step("do action");
assert.strictEqual(params, 128, "The doAction parameters are invalid.");
},
loadState() {},
};
},
};
}
registry.category("services").add("action", createMockActionService(assert));
const FakeSystrayItemWidget = Widget.extend({
on_attach_callback() {
this.do_action(128);
},
});
SystrayMenu.Items.push(FakeSystrayItemWidget);
await createWebClient({ serverData });
assert.verifySteps(["do action"]);
delete SystrayMenu.Items.FakeSystrayItemWidget;
});
QUnit.test("usercontext always added to legacy actions", async (assert) => {
assert.expect(8);
core.action_registry.add("testClientAction", AbstractAction);
registerCleanup(() => delete core.action_registry.map.testClientAction);
patchWithCleanup(ClientActionAdapter.prototype, {
setup() {
assert.step("ClientActionAdapter");
const action = { ...this.props.widgetArgs[0] };
const originalAction = JSON.parse(action._originalAction);
assert.deepEqual(originalAction.context, undefined);
assert.deepEqual(action.context, this.env.services.user.context);
this._super();
},
});
patchWithCleanup(ViewAdapter.prototype, {
setup() {
assert.step("ViewAdapter");
const action = { ...this.props.viewParams.action };
const originalAction = JSON.parse(action._originalAction);
assert.deepEqual(originalAction.context, undefined);
assert.deepEqual(action.context, this.env.services.user.context);
this._super();
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, "testClientAction");
assert.verifySteps(["ClientActionAdapter"]);
await doAction(webClient, 1);
assert.verifySteps(["ViewAdapter"]);
});
QUnit.test("correctly transports legacy Props for doAction", async (assert) => {
assert.expect(4);
let ID = 0;
const MyAction = AbstractAction.extend({
init() {
this._super(...arguments);
this.ID = ID++;
assert.step(`id: ${this.ID} props: ${JSON.stringify(arguments[2])}`);
},
async start() {
const res = await this._super(...arguments);
const link = document.createElement("a");
link.innerText = "some link";
link.setAttribute("id", `client_${this.ID}`);
link.addEventListener("click", () => {
this.do_action("testClientAction", {
clear_breadcrumbs: true,
props: { chain: "never break" },
});
});
this.el.appendChild(link);
return res;
},
});
core.action_registry.add("testClientAction", MyAction);
registerCleanup(() => delete core.action_registry.map.testClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "testClientAction");
assert.verifySteps(['id: 0 props: {"className":"o_action","breadcrumbs":[]}']);
await click(document.getElementById("client_0"));
assert.verifySteps([
'id: 1 props: {"chain":"never break","className":"o_action","breadcrumbs":[]}',
]);
});
QUnit.test("bootstrap tooltip in dialog action auto destroy", async (assert) => {
assert.expect(2);
const mockRPC = (route) => {
if (route === "/web/dataset/call_button") {
return false;
}
};
serverData.views["partner,3,form"] = /*xml*/ `
<form>
<field name="display_name" />
<footer>
<button name="echoes" type="object" string="Echoes" help="echoes"/>
</footer>
</form>
`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 25);
const tooltipProm = makeDeferred();
$(target).one("shown.bs.tooltip", () => {
tooltipProm.resolve();
});
triggerEvents(target, ".modal footer button", ["mouseover", "focusin"]);
await tooltipProm;
// check on webClient dom
assert.containsOnce(document.body, ".tooltip");
await doAction(webClient, {
type: "ir.actions.act_window_close",
});
// check on the whole DOM
assert.containsNone(document.body, ".tooltip");
});
QUnit.test("bootstrap tooltip destroyed on click", async (assert) => {
assert.expect(2);
const mockRPC = (route) => {
if (route === "/web/dataset/call_button") {
return false;
}
};
serverData.views["partner,666,form"] = /*xml*/ `
<form>
<header>
<button name="echoes" type="object" string="Echoes" help="echoes"/>
</header>
<field name="display_name" />
</form>
`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 24);
const tooltipProm = makeDeferred();
$(target).one("shown.bs.tooltip", () => {
tooltipProm.resolve();
});
triggerEvents(target, ".o_form_statusbar button", ["mouseover", "focusin"]);
await tooltipProm;
// check on webClient DOM
assert.containsOnce(document.body, ".tooltip");
await click(target, ".o_content");
// check on the whole DOM
assert.containsNone(document.body, ".tooltip");
});
QUnit.test("breadcrumbs are correct in stacked legacy client actions", async function (assert) {
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
async start() {
this.$el.addClass("client_action");
return this._super(...arguments);
},
getTitle() {
return "Blabla";
},
});
core.action_registry.add("clientAction", ClientAction);
registerCleanup(() => delete core.action_registry.map.clientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_legacy_list_view");
assert.strictEqual($(target).find(".breadcrumb-item").text(), "Partners");
await doAction(webClient, {
type: "ir.actions.client",
tag: "clientAction",
});
assert.containsOnce(target, ".client_action");
assert.strictEqual($(target).find(".breadcrumb-item").text(), "PartnersBlabla");
});
QUnit.test("view with js_class attribute (legacy)", async function (assert) {
assert.expect(2);
const TestView = AbstractView.extend({
viewType: "test_view",
});
const TestJsClassView = TestView.extend({
init() {
this._super.call(this, ...arguments);
assert.step("init js class");
},
});
serverData.views["partner,false,test_view"] = `<div js_class="test_jsClass"></div>`;
serverData.actions[9999] = {
id: 1,
name: "Partners Action 1",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "test_view"]],
};
legacyViewRegistry.add("test_view", TestView);
legacyViewRegistry.add("test_jsClass", TestJsClassView);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 9999);
assert.verifySteps(["init js class"]);
delete legacyViewRegistry.map.test_view;
delete legacyViewRegistry.map.test_jsClass;
});
QUnit.test(
"execute action without modal closes bootstrap tooltips anyway",
async function (assert) {
assert.expect(12);
Object.assign(serverData.views, {
"partner,666,form": `<form>
<header>
<button name="object" string="Call method" type="object" help="need somebody"/>
</header>
<field name="display_name"/>
</form>`,
});
const mockRPC = async (route) => {
assert.step(route);
if (route === "/web/dataset/call_button") {
// Some business stuff server side, then return an implicit close action
return Promise.resolve(false);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 24);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/read",
]);
assert.containsN(target, ".o_form_buttons_view button:not([disabled])", 2);
const actionButton = target.querySelector("button[name=object]");
const tooltipProm = new Promise((resolve) => {
document.body.addEventListener(
"shown.bs.tooltip",
() => {
actionButton.dispatchEvent(new Event("mouseout"));
resolve();
},
{
once: true,
}
);
});
actionButton.dispatchEvent(new Event("mouseover"));
await tooltipProm;
assert.containsOnce(document.body, ".tooltip");
await click(actionButton);
await legacyExtraNextTick();
assert.verifySteps(["/web/dataset/call_button", "/web/dataset/call_kw/partner/read"]);
assert.containsNone(document.body, ".tooltip"); // body different from webClient in tests !
assert.containsN(target, ".o_form_buttons_view button:not([disabled])", 2);
}
);
QUnit.test("click multiple times to open a record", async function (assert) {
assert.expect(5);
const def = testUtils.makeTestPromise();
const defs = [null, def];
const mockRPC = async (route, args) => {
if (args.method === "read") {
await Promise.resolve(defs.shift());
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_legacy_list_view");
await testUtils.dom.click(target.querySelector(".o_legacy_list_view .o_data_row"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_form_view");
await testUtils.dom.click(target.querySelector(".o_back_button"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_list_view");
await testUtils.dom.click(target.querySelector(".o_legacy_list_view .o_data_row"));
await testUtils.dom.click(target.querySelector(".o_legacy_list_view .o_data_row"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_list_view");
def.resolve();
await testUtils.nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_legacy_form_view");
});
QUnit.test("correct pager when coming from list (legacy)", async (assert) => {
assert.expect(4);
registry.category("views").remove("list");
legacyViewRegistry.add("list", ListView);
serverData.views = {
"partner,false,search": `<search />`,
"partner,99,list": `<list limit="4"><field name="display_name" /></list>`,
"partner,100,form": `<form><field name="display_name" /></form>`,
};
const wc = await createWebClient({ serverData });
await doAction(wc, {
res_model: "partner",
type: "ir.actions.act_window",
views: [
[99, "list"],
[100, "form"],
],
});
assert.deepEqual(cpHelpers.getPagerValue(target), [1, 4]);
assert.deepEqual(cpHelpers.getPagerLimit(target), 5);
await click(target, ".o_data_row:nth-child(2) .o_data_cell");
await legacyExtraNextTick();
assert.deepEqual(cpHelpers.getPagerValue(target), [2]);
assert.deepEqual(cpHelpers.getPagerLimit(target), 4);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,660 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import AbstractAction from "web.AbstractAction";
import core from "web.core";
import testUtils from "web.test_utils";
import Widget from "web.Widget";
import { makeTestEnv } from "../../helpers/mock_env";
import {
click,
getFixture,
hushConsole,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import {
createWebClient,
doAction,
getActionManagerServerData,
setupWebClientRegistries,
} from "./../helpers";
import * as cpHelpers from "@web/../tests/search/helpers";
import { listView } from "@web/views/list/list_view";
import { companyService } from "@web/webclient/company_service";
import { GraphModel } from "@web/views/graph/graph_model";
import { fakeCookieService } from "../../helpers/mock_services";
let serverData;
let target;
// legacy stuff
const actionRegistry = registry.category("actions");
const actionHandlersRegistry = registry.category("action_handlers");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Misc");
QUnit.test("can execute actions from id, xmlid and tag", async (assert) => {
assert.expect(6);
serverData.actions[1] = {
tag: "client_action_by_db_id",
target: "main",
type: "ir.actions.client",
};
serverData.actions["wowl.some_action"] = {
tag: "client_action_by_xml_id",
target: "main",
type: "ir.actions.client",
};
actionRegistry
.add("client_action_by_db_id", () => assert.step("client_action_db_id"))
.add("client_action_by_xml_id", () => assert.step("client_action_xml_id"))
.add("client_action_by_object", () => assert.step("client_action_object"));
setupWebClientRegistries();
const env = await makeTestEnv({ serverData });
await doAction(env, 1);
assert.verifySteps(["client_action_db_id"]);
await doAction(env, "wowl.some_action");
assert.verifySteps(["client_action_xml_id"]);
await doAction(env, {
tag: "client_action_by_object",
target: "current",
type: "ir.actions.client",
});
assert.verifySteps(["client_action_object"]);
});
QUnit.test("action doesn't exists", async (assert) => {
assert.expect(1);
setupWebClientRegistries();
const env = await makeTestEnv({ serverData });
try {
await doAction(env, {
tag: "this_is_a_tag",
target: "current",
type: "ir.not_action.error",
});
} catch (e) {
assert.strictEqual(
e.message,
"The ActionManager service can't handle actions of type ir.not_action.error"
);
}
});
QUnit.test("action in handler registry", async (assert) => {
assert.expect(2);
setupWebClientRegistries();
const env = await makeTestEnv({ serverData });
actionHandlersRegistry.add("ir.action_in_handler_registry", ({ action }) =>
assert.step(action.type)
);
await doAction(env, {
tag: "this_is_a_tag",
target: "current",
type: "ir.action_in_handler_registry",
});
assert.verifySteps(["ir.action_in_handler_registry"]);
});
QUnit.test("properly handle case when action id does not exist", async (assert) => {
assert.expect(2);
const webClient = await createWebClient({ serverData });
patchWithCleanup(window, { console: hushConsole }, { pure: true });
patchWithCleanup(webClient.env.services.notification, {
add(message) {
assert.strictEqual(message, "No action with id '4448' could be found");
},
});
await doAction(webClient, 4448);
assert.containsOnce(target, "div.o_invalid_action");
});
QUnit.test("actions can be cached", async function (assert) {
assert.expect(8);
const mockRPC = async (route, args) => {
if (route === "/web/action/load") {
assert.step(JSON.stringify(args));
}
};
setupWebClientRegistries();
const env = await makeTestEnv({ serverData, mockRPC });
const loadAction = env.services.action.loadAction;
// With no additional params
await loadAction(3);
await loadAction(3);
// With specific additionalContext
await loadAction(3, { additionalContext: { configuratorMode: "add" } });
await loadAction(3, { additionalContext: { configuratorMode: "edit" } });
// With same active_id
await loadAction(3, { active_id: 1 });
await loadAction(3, { active_id: 1 });
// With active_id change
await loadAction(3, { active_id: 2 });
// With same active_ids
await loadAction(3, { active_ids: [1, 2] });
await loadAction(3, { active_ids: [1, 2] });
// With active_ids change
await loadAction(3, { active_ids: [1, 2, 3] });
// With same active_model
await loadAction(3, { active_model: "a" });
await loadAction(3, { active_model: "a" });
// With active_model change
await loadAction(3, { active_model: "b" });
assert.verifySteps(
[
'{"action_id":3,"additional_context":{}}',
'{"action_id":3,"additional_context":{"active_id":1}}',
'{"action_id":3,"additional_context":{"active_id":2}}',
'{"action_id":3,"additional_context":{"active_ids":[1,2]}}',
'{"action_id":3,"additional_context":{"active_ids":[1,2,3]}}',
'{"action_id":3,"additional_context":{"active_model":"a"}}',
'{"action_id":3,"additional_context":{"active_model":"b"}}',
],
"should load from server once per active_id/active_ids/active_model change, nothing else"
);
});
QUnit.test("action cache: additionalContext is respected", async function (assert) {
assert.expect(5);
const mockRPC = async (route) => {
if (route === "/web/action/load") {
assert.step("server loaded");
}
};
setupWebClientRegistries();
const env = await makeTestEnv({ serverData, mockRPC });
const { loadAction } = env.services.action;
const actionParams = {
additionalContext: {
some: { deep: { nested: "Robert" } },
},
};
let action = await loadAction(3, actionParams);
assert.verifySteps(["server loaded"]);
assert.deepEqual(action.context, actionParams);
// Modify the action in place
action.context.additionalContext.some.deep.nested = "Nesta";
// Change additionalContext and reload from cache
actionParams.additionalContext.some.deep.nested = "Marley";
action = await loadAction(3, actionParams);
assert.verifySteps([], "loaded from cache");
assert.deepEqual(action.context, actionParams);
});
QUnit.test("no widget memory leaks when doing some action stuff", async function (assert) {
assert.expect(1);
let delta = 0;
testUtils.mock.patch(Widget, {
init: function () {
delta++;
this._super.apply(this, arguments);
},
destroy: function () {
delta--;
this._super.apply(this, arguments);
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 8);
const n = delta;
await doAction(webClient, 4);
// kanban view is loaded, switch to list view
await cpHelpers.switchView(target, "list");
await legacyExtraNextTick();
// open a record in form view
await testUtils.dom.click(target.querySelector(".o_list_view .o_data_row"));
await legacyExtraNextTick();
// go back to action 7 in breadcrumbs
await testUtils.dom.click(target.querySelector(".o_control_panel .breadcrumb a"));
await legacyExtraNextTick();
assert.strictEqual(delta, n, "should have properly destroyed all other widgets");
testUtils.mock.unpatch(Widget);
});
QUnit.test("no widget memory leaks when executing actions in dialog", async function (assert) {
assert.expect(1);
let delta = 0;
testUtils.mock.patch(Widget, {
init: function () {
delta++;
this._super.apply(this, arguments);
},
destroy: function () {
if (!this.isDestroyed()) {
delta--;
}
this._super.apply(this, arguments);
},
});
const webClient = await createWebClient({ serverData });
const n = delta;
await doAction(webClient, 5);
await doAction(webClient, { type: "ir.actions.act_window_close" });
assert.strictEqual(delta, n, "should have properly destroyed all widgets");
testUtils.mock.unpatch(Widget);
});
QUnit.test(
"no memory leaks when executing an action while switching view",
async function (assert) {
assert.expect(1);
let def;
let delta = 0;
testUtils.mock.patch(Widget, {
init: function () {
delta += 1;
this._super.apply(this, arguments);
},
destroy: function () {
delta -= 1;
this._super.apply(this, arguments);
},
});
const mockRPC = async function (route, args) {
if (args && args.method === "read") {
await Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 4);
const n = delta;
await doAction(webClient, 3, { clearBreadcrumbs: true });
// switch to the form view (this request is blocked)
def = testUtils.makeTestPromise();
await testUtils.dom.click(target.querySelector(".o_list_view .o_data_row"));
// execute another action meanwhile (don't block this request)
await doAction(webClient, 4, { clearBreadcrumbs: true });
// unblock the switch to the form view in action 3
def.resolve();
await testUtils.nextTick();
assert.strictEqual(n, delta, "all widgets of action 3 should have been destroyed");
testUtils.mock.unpatch(Widget);
}
);
QUnit.test(
"no memory leaks when executing an action while loading views",
async function (assert) {
assert.expect(1);
let def;
let delta = 0;
testUtils.mock.patch(Widget, {
init: function () {
delta += 1;
this._super.apply(this, arguments);
},
destroy: function () {
delta -= 1;
this._super.apply(this, arguments);
},
});
const mockRPC = async function (route, args) {
if (args && args.method === "get_views") {
await Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute action 4 to know the number of widgets it instantiates
await doAction(webClient, 4);
const n = delta;
// execute a first action (its 'get_views' RPC is blocked)
def = testUtils.makeTestPromise();
doAction(webClient, 3, { clearBreadcrumbs: true });
await testUtils.nextTick();
await legacyExtraNextTick();
// execute another action meanwhile (and unlock the RPC)
doAction(webClient, 4, { clearBreadcrumbs: true });
def.resolve();
await testUtils.nextTick();
await legacyExtraNextTick();
assert.strictEqual(n, delta, "all widgets of action 3 should have been destroyed");
testUtils.mock.unpatch(Widget);
}
);
QUnit.test(
"no memory leaks when executing an action while loading data of default view",
async function (assert) {
assert.expect(1);
let def;
let delta = 0;
testUtils.mock.patch(Widget, {
init: function () {
delta += 1;
this._super.apply(this, arguments);
},
destroy: function () {
delta -= 1;
this._super.apply(this, arguments);
},
});
const mockRPC = async function (route) {
if (route === "/web/dataset/search_read") {
await Promise.resolve(def);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute action 4 to know the number of widgets it instantiates
await doAction(webClient, 4);
const n = delta;
// execute a first action (its 'search_read' RPC is blocked)
def = testUtils.makeTestPromise();
doAction(webClient, 3, { clearBreadcrumbs: true });
await testUtils.nextTick();
await legacyExtraNextTick();
// execute another action meanwhile (and unlock the RPC)
doAction(webClient, 4, { clearBreadcrumbs: true });
def.resolve();
await testUtils.nextTick();
await legacyExtraNextTick();
assert.strictEqual(n, delta, "all widgets of action 3 should have been destroyed");
testUtils.mock.unpatch(Widget);
}
);
QUnit.test('action with "no_breadcrumbs" set to true', async function (assert) {
serverData.actions[4].context = { no_breadcrumbs: true };
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_control_panel .breadcrumb-item");
// push another action flagged with 'no_breadcrumbs=true'
await doAction(webClient, 4);
assert.containsNone(target, ".o_control_panel .breadcrumb-item");
});
QUnit.test("document's title is updated when an action is executed", async function (assert) {
const defaultTitle = { zopenerp: "Odoo" };
const webClient = await createWebClient({ serverData });
let currentTitle = webClient.env.services.title.getParts();
assert.deepEqual(currentTitle, defaultTitle);
let currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {});
await doAction(webClient, 4);
currentTitle = webClient.env.services.title.getParts();
assert.deepEqual(currentTitle, {
...defaultTitle,
action: "Partners Action 4",
});
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, { action: 4, model: "partner", view_type: "kanban" });
await doAction(webClient, 8);
currentTitle = webClient.env.services.title.getParts();
assert.deepEqual(currentTitle, {
...defaultTitle,
action: "Favorite Ponies",
});
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, { action: 8, model: "pony", view_type: "list" });
await click(target.querySelector(".o_data_row .o_data_cell"));
await nextTick();
currentTitle = webClient.env.services.title.getParts();
assert.deepEqual(currentTitle, {
...defaultTitle,
action: "Twilight Sparkle",
});
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, { action: 8, id: 4, model: "pony", view_type: "form" });
});
QUnit.test(
"on_reverse_breadcrumb handler is correctly called (legacy)",
async function (assert) {
// This test can be removed as soon as we no longer support legacy actions as the new
// ActionManager doesn't support this option. Indeed, it is used to reload the previous
// action when coming back, but we won't need such an artefact to that with Wowl, as the
// controller will be re-instantiated with an (exported) state given in props.
assert.expect(5);
const ClientAction = AbstractAction.extend({
events: {
"click button": "_onClick",
},
start() {
this.$el.html('<button class="my_button">Execute another action</button>');
},
_onClick() {
this.do_action(4, {
on_reverse_breadcrumb: () => assert.step("on_reverse_breadcrumb"),
});
},
});
core.action_registry.add("ClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".my_button");
await testUtils.dom.click(target.querySelector(".my_button"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view");
await testUtils.dom.click($(target).find(".o_control_panel .breadcrumb a:first"));
await legacyExtraNextTick();
assert.containsOnce(target, ".my_button");
assert.verifySteps(["on_reverse_breadcrumb"]);
delete core.action_registry.map.ClientAction;
}
);
QUnit.test('handles "history_back" event', async function (assert) {
assert.expect(3);
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
this._super(...arguments);
list = this;
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 4);
await doAction(webClient, 3);
assert.containsN(target, ".o_control_panel .breadcrumb-item", 2);
list.env.config.historyBack();
await testUtils.nextTick();
await legacyExtraNextTick();
assert.containsOnce(target, ".o_control_panel .breadcrumb-item");
assert.strictEqual(
$(target).find(".o_control_panel .breadcrumb-item").text(),
"Partners Action 4",
"breadcrumbs should display the display_name of the action"
);
});
QUnit.test("stores and restores scroll position (in kanban)", async function (assert) {
serverData.actions[3].views = [[false, "kanban"]];
assert.expect(3);
for (let i = 0; i < 60; i++) {
serverData.models.partner.records.push({ id: 100 + i, foo: `Record ${i}` });
}
const container = document.createElement("div");
container.classList.add("o_web_client");
container.style.height = "250px";
target.appendChild(container);
const webClient = await createWebClient({ target: container, serverData });
// execute a first action
await doAction(webClient, 3);
assert.strictEqual(target.querySelector(".o_content").scrollTop, 0);
// simulate a scroll
target.querySelector(".o_content").scrollTop = 100;
// execute a second action (in which we don't scroll)
await doAction(webClient, 4);
assert.strictEqual(target.querySelector(".o_content").scrollTop, 0);
// go back using the breadcrumbs
await click(target.querySelector(".o_control_panel .breadcrumb a"));
assert.strictEqual(target.querySelector(".o_content").scrollTop, 100);
});
QUnit.test("stores and restores scroll position (in list)", async function (assert) {
for (let i = 0; i < 60; i++) {
serverData.models.partner.records.push({ id: 100 + i, foo: `Record ${i}` });
}
const container = document.createElement("div");
container.classList.add("o_web_client");
container.style.height = "250px";
target.appendChild(container);
const webClient = await createWebClient({ target: container, serverData });
// execute a first action
await doAction(webClient, 3);
assert.strictEqual(target.querySelector(".o_content").scrollTop, 0);
assert.strictEqual(target.querySelector(".o_list_renderer").scrollTop, 0);
// simulate a scroll
target.querySelector(".o_list_renderer").scrollTop = 100;
await nextTick();
// execute a second action (in which we don't scroll)
await doAction(webClient, 4);
assert.strictEqual(target.querySelector(".o_content").scrollTop, 0);
// go back using the breadcrumbs
await click(target.querySelector(".o_control_panel .breadcrumb a"));
assert.strictEqual(target.querySelector(".o_content").scrollTop, 0);
assert.strictEqual(target.querySelector(".o_list_renderer").scrollTop, 100);
});
QUnit.test(
'executing an action with target != "new" closes all dialogs',
async function (assert) {
serverData.views["partner,false,form"] = `
<form>
<field name="o2m">
<tree><field name="foo"/></tree>
<form><field name="foo"/></form>
</field>
</form>
`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_list_view .o_data_row .o_list_char"));
assert.containsOnce(target, ".o_form_view");
await click(target.querySelector(".o_form_view .o_data_row .o_data_cell"));
assert.containsOnce(document.body, ".modal .o_form_view");
await doAction(webClient, 1); // target != 'new'
assert.containsNone(document.body, ".modal");
}
);
QUnit.test(
'executing an action with target "new" does not close dialogs',
async function (assert) {
assert.expect(4);
serverData.views["partner,false,form"] = `
<form>
<field name="o2m">
<tree><field name="foo"/></tree>
<form><field name="foo"/></form>
</field>
</form>
`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
await click(target.querySelector(".o_list_view .o_data_row .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
await click(target.querySelector(".o_form_view .o_data_row .o_data_cell"));
assert.containsOnce(document.body, ".modal .o_form_view");
await doAction(webClient, 5); // target 'new'
assert.containsN(document.body, ".modal .o_form_view", 2);
}
);
QUnit.test(
"search defaults are removed from context when switching view",
async function (assert) {
assert.expect(1);
serverData.views["partner,false,graph"] = `<graph/>`;
serverData.views["partner,false,list"] = `<list/>`;
const context = {
search_default_x: true,
searchpanel_default_y: true,
};
registry.category("services").add("cookie", fakeCookieService);
patchWithCleanup(GraphModel.prototype, {
load(searchParams) {
assert.deepEqual(searchParams.context, { lang: "en", tz: "taht", uid: 7 });
return this._super.apply(this, arguments);
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "graph"],
],
context,
});
// list view is loaded, switch to graph view
await cpHelpers.switchView(target, "graph");
}
);
QUnit.test(
"retrieving a stored action should remove 'allowed_company_ids' from its context",
async function (assert) {
// Prepare a multi company scenario
session.user_companies = {
allowed_companies: {
3: { id: 3, name: "Hermit", sequence: 1 },
2: { id: 2, name: "Herman's", sequence: 2 },
1: { id: 1, name: "Heroes TM", sequence: 3 },
},
current_company: 3,
};
registry.category("services").add("company", companyService);
// Prepare a stored action
browser.sessionStorage.setItem(
"current_action",
JSON.stringify({
...serverData.actions[1],
context: {
someKey: 44,
allowed_company_ids: [1, 2],
lang: "not_en",
tz: "not_taht",
uid: 42,
},
})
);
// Prepare the URL hash to make sure the stored action will get executed.
browser.location.hash = "#model=partner&view_type=kanban";
// Create the web client. It should execute the stored action.
const webClient = await createWebClient({ serverData });
// Check the current action context
assert.deepEqual(webClient.env.services.action.currentController.action.context, {
// action context
someKey: 44,
lang: "not_en",
tz: "not_taht",
uid: 42,
// note there is no 'allowed_company_ids' in the action context
});
}
);
});

View file

@ -0,0 +1,251 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import testUtils from "web.test_utils";
import {
click,
getFixture,
legacyExtraNextTick,
makeDeferred,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { Component, xml } from "@odoo/owl";
let serverData;
let target;
const actionRegistry = registry.category("actions");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Push State");
QUnit.test("basic action as App", async (assert) => {
assert.expect(5);
const webClient = await createWebClient({ serverData });
let urlState = webClient.env.services.router.current;
assert.deepEqual(urlState.hash, {});
await click(target, ".o_navbar_apps_menu button");
await click(target, ".o_navbar_apps_menu .dropdown-item:nth-child(3)");
await nextTick();
await nextTick();
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, 1002);
assert.strictEqual(urlState.hash.menu_id, 2);
assert.strictEqual(
target.querySelector(".test_client_action").textContent.trim(),
"ClientAction_Id 2"
);
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "App2");
});
QUnit.test("do action keeps menu in url", async (assert) => {
assert.expect(9);
const webClient = await createWebClient({ serverData });
let urlState = webClient.env.services.router.current;
assert.deepEqual(urlState.hash, {});
await click(target, ".o_navbar_apps_menu button");
await click(target, ".o_navbar_apps_menu .dropdown-item:nth-child(3)");
await nextTick();
await nextTick();
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, 1002);
assert.strictEqual(urlState.hash.menu_id, 2);
assert.strictEqual(
target.querySelector(".test_client_action").textContent.trim(),
"ClientAction_Id 2"
);
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "App2");
await doAction(webClient, 1001, { clearBreadcrumbs: true });
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, 1001);
assert.strictEqual(urlState.hash.menu_id, 2);
assert.strictEqual(
target.querySelector(".test_client_action").textContent.trim(),
"ClientAction_Id 1"
);
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "App2");
});
QUnit.test("actions can push state", async (assert) => {
assert.expect(5);
class ClientActionPushes extends Component {
setup() {
this.router = useService("router");
}
_actionPushState() {
this.router.pushState({ arbitrary: "actionPushed" });
}
}
ClientActionPushes.template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description" />
</div>`;
actionRegistry.add("client_action_pushes", ClientActionPushes);
const webClient = await createWebClient({ serverData });
let urlState = webClient.env.services.router.current;
assert.deepEqual(urlState.hash, {});
await doAction(webClient, "client_action_pushes");
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, "client_action_pushes");
assert.strictEqual(urlState.hash.menu_id, undefined);
await click(target, ".test_client_action");
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, "client_action_pushes");
assert.strictEqual(urlState.hash.arbitrary, "actionPushed");
});
QUnit.test("actions override previous state", async (assert) => {
assert.expect(5);
class ClientActionPushes extends Component {
setup() {
this.router = useService("router");
}
_actionPushState() {
this.router.pushState({ arbitrary: "actionPushed" });
}
}
ClientActionPushes.template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description" />
</div>`;
actionRegistry.add("client_action_pushes", ClientActionPushes);
const webClient = await createWebClient({ serverData });
let urlState = webClient.env.services.router.current;
assert.deepEqual(urlState.hash, {});
await doAction(webClient, "client_action_pushes");
await click(target, ".test_client_action");
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, "client_action_pushes");
assert.strictEqual(urlState.hash.arbitrary, "actionPushed");
await doAction(webClient, 1001);
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, 1001);
assert.strictEqual(urlState.hash.arbitrary, undefined);
});
QUnit.test("actions override previous state from menu click", async (assert) => {
assert.expect(3);
class ClientActionPushes extends Component {
setup() {
this.router = useService("router");
}
_actionPushState() {
this.router.pushState({ arbitrary: "actionPushed" });
}
}
ClientActionPushes.template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description" />
</div>`;
actionRegistry.add("client_action_pushes", ClientActionPushes);
const webClient = await createWebClient({ serverData });
let urlState = webClient.env.services.router.current;
assert.deepEqual(urlState.hash, {});
await doAction(webClient, "client_action_pushes");
await click(target, ".test_client_action");
await click(target, ".o_navbar_apps_menu button");
await click(target, ".o_navbar_apps_menu .dropdown-item:nth-child(3)");
await nextTick();
await nextTick();
urlState = webClient.env.services.router.current;
assert.strictEqual(urlState.hash.action, 1002);
assert.strictEqual(urlState.hash.menu_id, 2);
});
QUnit.test("action in target new do not push state", async (assert) => {
assert.expect(1);
serverData.actions[1001].target = "new";
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
throw new Error("should not push state");
},
}),
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1001);
assert.containsOnce(target, ".modal .test_client_action");
});
QUnit.test("properly push state", async function (assert) {
assert.expect(3);
const webClient = await createWebClient({ serverData });
await doAction(webClient, 4);
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 4,
model: "partner",
view_type: "kanban",
});
await doAction(webClient, 8);
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 8,
model: "pony",
view_type: "list",
});
await testUtils.dom.click($(target).find("tr .o_data_cell:first"));
await nextTick();
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 8,
model: "pony",
view_type: "form",
id: 4,
});
});
QUnit.test("push state after action is loaded, not before", async function (assert) {
assert.expect(2);
const def = makeDeferred();
const mockRPC = async function (route, args) {
if (args.method === "web_search_read") {
await def;
}
};
const webClient = await createWebClient({ serverData, mockRPC });
doAction(webClient, 4);
await nextTick();
await nextTick();
assert.deepEqual(webClient.env.services.router.current.hash, {});
def.resolve();
await nextTick();
await nextTick();
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 4,
model: "partner",
view_type: "kanban",
});
});
QUnit.test("do not push state when action fails", async function (assert) {
assert.expect(3);
const mockRPC = async function (route, args) {
if (args && args.method === "read") {
return Promise.reject();
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 8);
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 8,
model: "pony",
view_type: "list",
});
await testUtils.dom.click($(target).find("tr.o_data_row:first"));
await legacyExtraNextTick();
// we make sure here that the list view is still in the dom
assert.containsOnce(target, ".o_list_view", "there should still be a list view in dom");
assert.deepEqual(webClient.env.services.router.current.hash, {
action: 8,
model: "pony",
view_type: "list",
});
});
});

View file

@ -0,0 +1,380 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { session } from "@web/session";
import { ReportAction } from "@web/webclient/actions/reports/report_action";
import { clearRegistryWithCleanup } from "@web/../tests/helpers/mock_env";
import { makeFakeNotificationService } from "@web/../tests/helpers/mock_services";
import { mockDownload, patchWithCleanup, getFixture, click } from "@web/../tests/helpers/utils";
import {
createWebClient,
doAction,
getActionManagerServerData,
} from "@web/../tests/webclient/helpers";
let serverData;
let target;
const serviceRegistry = registry.category("services");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
clearRegistryWithCleanup(registry.category("main_components"));
});
QUnit.module("Report actions");
QUnit.test("can execute report actions from db ID", async function (assert) {
assert.expect(6);
mockDownload((options) => {
assert.step(options.url);
return Promise.resolve();
});
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/report/check_wkhtmltopdf") {
return Promise.resolve("ok");
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 7, { onClose: () => assert.step("on_close") });
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"/report/download",
"on_close",
]);
});
QUnit.test("report actions can close modals and reload views", async function (assert) {
assert.expect(8);
mockDownload((options) => {
assert.step(options.url);
return Promise.resolve();
});
const mockRPC = async (route) => {
if (route === "/report/check_wkhtmltopdf") {
return Promise.resolve("ok");
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 5, { onClose: () => assert.step("on_close") });
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view",
"should have rendered a form view in a modal"
);
await doAction(webClient, 7, { onClose: () => assert.step("on_printed") });
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view",
"The modal should still exist"
);
await doAction(webClient, 11);
assert.containsNone(
document.body,
".o_technical_modal .o_form_view",
"the modal should have been closed after the action report"
);
assert.verifySteps(["/report/download", "on_printed", "/report/download", "on_close"]);
});
QUnit.test(
"should trigger a notification if wkhtmltopdf is to upgrade",
async function (assert) {
serviceRegistry.add(
"notification",
makeFakeNotificationService(
() => {
assert.step("notify");
},
() => {}
)
);
mockDownload((options) => {
assert.step(options.url);
return Promise.resolve();
});
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/report/check_wkhtmltopdf") {
return Promise.resolve("upgrade");
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 7);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"notify",
"/report/download",
]);
}
);
QUnit.test(
"should open the report client action if wkhtmltopdf is broken",
async function (assert) {
mockDownload(() => {
assert.step("download"); // should not be called
return Promise.resolve();
});
serviceRegistry.add(
"notification",
makeFakeNotificationService(
() => {
assert.step("notify");
},
() => {}
)
);
const mockRPC = async (route, args) => {
assert.step(args.method || route);
if (route === "/report/check_wkhtmltopdf") {
return Promise.resolve("broken");
}
if (route.includes("/report/html/some_report")) {
return Promise.resolve(true);
}
};
// patch the report client action to override its iframe's url so that
// it doesn't trigger an RPC when it is appended to the DOM (for this
// usecase, using removeSRCAttribute doesn't work as the RPC is
// triggered as soon as the iframe is in the DOM, even if its src
// attribute is removed right after)
patchWithCleanup(ReportAction.prototype, {
setup() {
this._super(...arguments);
this.env.services.rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 7);
assert.containsOnce(
target,
".o_content iframe",
"should have opened the report client action"
);
assert.containsOnce(target, "button[title='Print']", "should have a print button");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"notify",
// context={"lang":'en',"uid":7,"tz":'taht'}
"/report/html/some_report?context=%7B%22lang%22%3A%22en%22%2C%22uid%22%3A7%2C%22tz%22%3A%22taht%22%7D",
]);
}
);
QUnit.test("send context in case of html report", async function (assert) {
assert.expect(5);
mockDownload(() => {
assert.step("download"); // should not be called
return Promise.resolve();
});
serviceRegistry.add(
"notification",
makeFakeNotificationService(
(message, options) => {
assert.step(options.type || "notification");
},
() => {}
)
);
patchWithCleanup(session.user_context, { some_key: 2 });
const mockRPC = async (route, args) => {
assert.step(args.method || route);
if (route.includes("/report/html/some_report")) {
return Promise.resolve(true);
}
};
// patch the report client action to override its iframe's url so that
// it doesn't trigger an RPC when it is appended to the DOM (for this
// usecase, using removeSRCAttribute doesn't work as the RPC is
// triggered as soon as the iframe is in the DOM, even if its src
// attribute is removed right after)
patchWithCleanup(ReportAction.prototype, {
setup() {
this._super(...arguments);
this.env.services.rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 12);
assert.containsOnce(target, ".o_content iframe", "should have opened the client action");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
// context={"lang":'en',"uid":7,"tz":'taht',"some_key":2}
"/report/html/some_report?context=%7B%22lang%22%3A%22en%22%2C%22uid%22%3A7%2C%22tz%22%3A%22taht%22%2C%22some_key%22%3A2%7D",
]);
});
QUnit.test(
"UI unblocks after downloading the report even if it threw an error",
async function (assert) {
assert.expect(8);
let timesDownloasServiceHasBeenCalled = 0;
mockDownload(() => {
if (timesDownloasServiceHasBeenCalled === 0) {
assert.step("successful download");
timesDownloasServiceHasBeenCalled++;
return Promise.resolve();
}
if (timesDownloasServiceHasBeenCalled === 1) {
assert.step("failed download");
return Promise.reject();
}
});
serviceRegistry.add("ui", uiService);
const mockRPC = async (route) => {
if (route === "/report/check_wkhtmltopdf") {
return Promise.resolve("ok");
}
};
const webClient = await createWebClient({ serverData, mockRPC });
const ui = webClient.env.services.ui;
const onBlock = () => {
assert.step("block");
};
const onUnblock = () => {
assert.step("unblock");
};
ui.bus.addEventListener("BLOCK", onBlock);
ui.bus.addEventListener("UNBLOCK", onUnblock);
await doAction(webClient, 7);
try {
await doAction(webClient, 7);
} catch (_e) {
assert.step("error caught");
}
assert.verifySteps([
"block",
"successful download",
"unblock",
"block",
"failed download",
"unblock",
"error caught",
]);
ui.bus.removeEventListener("BLOCK", onBlock);
ui.bus.removeEventListener("UNBLOCK", onUnblock);
}
);
QUnit.test("can use custom handlers for report actions", async function (assert) {
assert.expect(8);
mockDownload((options) => {
assert.step(options.url);
return Promise.resolve();
});
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/report/check_wkhtmltopdf") {
return "ok";
}
};
const webClient = await createWebClient({ serverData, mockRPC });
let customHandlerCalled = false;
registry.category("ir.actions.report handlers").add("custom_handler", async (action) => {
if (action.id === 7 && !customHandlerCalled) {
customHandlerCalled = true;
assert.step("calling custom handler");
return true;
}
assert.step("falling through to default handler");
});
await doAction(webClient, 7);
assert.step("first doAction finished");
await doAction(webClient, 7);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"calling custom handler",
"first doAction finished",
"falling through to default handler",
"/report/check_wkhtmltopdf",
"/report/download",
]);
});
QUnit.test("context is correctly passed to the client action report", async (assert) => {
assert.expect(8);
mockDownload((options) => {
assert.step(options.url);
assert.deepEqual(JSON.parse(options.data.data), [
"/report/pdf/ennio.morricone/99",
"qweb-pdf",
]);
return Promise.resolve();
});
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/report/check_wkhtmltopdf") {
return "ok";
}
if (route.includes("/report/html")) {
return true;
}
};
patchWithCleanup(ReportAction.prototype, {
setup() {
this._super(...arguments);
this.env.services.rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
const webClient = await createWebClient({ serverData, mockRPC });
const action = {
context: {
rabbia: "E Tarantella",
active_ids: [99],
},
data: null,
name: "Ennio Morricone",
report_name: "ennio.morricone",
report_type: "qweb-html",
type: "ir.actions.report",
};
assert.verifySteps(["/web/webclient/load_menus"]);
await doAction(webClient, action);
assert.verifySteps([
"/report/html/ennio.morricone/99?context=%7B%22lang%22%3A%22en%22%2C%22uid%22%3A7%2C%22tz%22%3A%22taht%22%7D",
]);
await click(target.querySelector("button[title='Print']"));
assert.verifySteps(["/report/check_wkhtmltopdf", "/report/download"]);
});
QUnit.test("url is valid", async (assert) => {
assert.expect(2);
patchWithCleanup(ReportAction.prototype, {
init() {
this._super(...arguments);
this.reportUrl = "about:blank";
},
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, 12); // 12 is a html report action in serverData
const hash = webClient.router.current.hash;
// used to put report.client_action in the url
assert.strictEqual(hash.action === "report.client_action", false);
assert.strictEqual(hash.action === 12, true);
});
});

View file

@ -0,0 +1,115 @@
/** @odoo-module **/
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { getFixture } from "../../helpers/utils";
let serverData;
let target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module("Server actions");
QUnit.test("can execute server actions from db ID", async function (assert) {
assert.expect(10);
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/web/action/run") {
assert.strictEqual(args.action_id, 2, "should call the correct server action");
return Promise.resolve(1); // execute action 1
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 2);
assert.containsOnce(target, ".o_control_panel", "should have rendered a control panel");
assert.containsOnce(target, ".o_kanban_view", "should have rendered a kanban view");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"/web/action/run",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
QUnit.test("handle server actions returning false", async function (assert) {
assert.expect(10);
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
if (route === "/web/action/run") {
return Promise.resolve(false);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
// execute an action in target="new"
function onClose() {
assert.step("close handler");
}
await doAction(webClient, 5, { onClose });
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view",
"should have rendered a form view in a modal"
);
// execute a server action that returns false
await doAction(webClient, 2);
assert.containsNone(document.body, ".o_technical_modal", "should have closed the modal");
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"onchange",
"/web/action/load",
"/web/action/run",
"close handler",
]);
});
QUnit.test("send correct context when executing a server action", async function (assert) {
assert.expect(1);
serverData.actions[2].context = { someKey: 44 };
const mockRPC = async (route, args) => {
if (route === "/web/action/run") {
assert.deepEqual(args.context, {
// user context
lang: "en",
tz: "taht",
uid: 7,
// action context
someKey: 44,
});
return Promise.resolve(1); // execute action 1
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 2);
});
QUnit.test("action with html help returned by a server action", async function (assert) {
serverData.actions[2].context = { someKey: 44 };
const mockRPC = async (route, args) => {
if (route === "/web/action/run") {
return Promise.resolve({
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
help: "<p>I am not a helper</p>",
domain: [[0, "=", 1]],
});
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 2);
assert.strictEqual(
target.querySelector(".o_list_view .o_nocontent_help p").innerText,
"I am not a helper"
);
});
});

View file

@ -0,0 +1,673 @@
/** @odoo-module **/
import testUtils from "web.test_utils";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import { registry } from "@web/core/registry";
import { click, getFixture, patchWithCleanup, makeDeferred, nextTick } from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { registerCleanup } from "../../helpers/cleanup";
import { errorService } from "@web/core/errors/error_service";
import { useService } from "@web/core/utils/hooks";
import { ClientErrorDialog } from "@web/core/errors/error_dialogs";
import { Component, onMounted, xml } from "@odoo/owl";
let serverData;
let target;
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
target = getFixture();
});
QUnit.module('Actions in target="new"');
QUnit.test('can execute act_window actions in target="new"', async function (assert) {
assert.expect(8);
const mockRPC = async (route, args) => {
assert.step((args && args.method) || route);
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 5);
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view",
"should have rendered a form view in a modal"
);
assert.hasClass(
$(".o_technical_modal .modal-body")[0],
"o_act_window",
"dialog main element should have classname 'o_act_window'"
);
assert.containsOnce(
document.body,
".o_technical_modal .o_form_view .o_form_editable",
"form view should be in edit mode"
);
assert.verifySteps([
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"onchange",
]);
});
QUnit.test("chained action on_close", async function (assert) {
assert.expect(4);
function onClose(closeInfo) {
assert.strictEqual(closeInfo, "smallCandle");
assert.step("Close Action");
}
const webClient = await createWebClient({ serverData });
await doAction(webClient, 5, { onClose });
// a target=new action shouldn't activate the on_close
await doAction(webClient, 5);
assert.verifySteps([]);
// An act_window_close should trigger the on_close
await doAction(webClient, { type: "ir.actions.act_window_close", infos: "smallCandle" });
assert.verifySteps(["Close Action"]);
});
QUnit.test("footer buttons are moved to the dialog footer", async function (assert) {
assert.expect(3);
serverData.views["partner,false,form"] = `
<form>
<field name="display_name"/>
<footer>
<button string="Create" type="object" class="infooter"/>
</footer>
</form>
`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 5);
assert.containsNone(
$(".o_technical_modal .modal-body")[0],
"button.infooter",
"the button should not be in the body"
);
assert.containsOnce(
$(".o_technical_modal .modal-footer")[0],
"button.infooter",
"the button should be in the footer"
);
assert.containsOnce(
target,
".modal-footer button:not(.d-none)",
"the modal footer should only contain one visible button"
);
});
QUnit.test("Button with `close` attribute closes dialog", async function (assert) {
serverData.views = {
"partner,false,form": `
<form>
<header>
<button string="Open dialog" name="5" type="action"/>
</header>
</form>`,
"partner,view_ref,form": `
<form>
<footer>
<button string="I close the dialog" name="some_method" type="object" close="1"/>
</footer>
</form>`,
"partner,false,search": "<search></search>",
};
serverData.actions[4] = {
id: 4,
name: "Partners Action 4",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "form"]],
};
serverData.actions[5] = {
id: 5,
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [["view_ref", "form"]],
};
const mockRPC = async (route, args) => {
assert.step(route);
if (route === "/web/dataset/call_button" && args.method === "some_method") {
return {
tag: "display_notification",
type: "ir.actions.client",
};
}
};
const webClient = await createWebClient({ serverData, mockRPC });
assert.verifySteps(["/web/webclient/load_menus"]);
await doAction(webClient, 4);
assert.verifySteps([
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/onchange",
]);
await testUtils.dom.click(`button[name="5"]`);
assert.verifySteps([
"/web/dataset/call_kw/partner/create",
"/web/dataset/call_kw/partner/read",
"/web/action/load",
"/web/dataset/call_kw/partner/get_views",
"/web/dataset/call_kw/partner/onchange",
]);
assert.containsOnce(document.body, ".modal");
await testUtils.dom.click(`button[name="some_method"]`);
assert.verifySteps([
"/web/dataset/call_kw/partner/create",
"/web/dataset/call_kw/partner/read",
"/web/dataset/call_button",
"/web/dataset/call_kw/partner/read",
]);
assert.containsNone(document.body, ".modal");
});
QUnit.test('on_attach_callback is called for actions in target="new"', async function (assert) {
assert.expect(3);
const ClientAction = AbstractAction.extend({
on_attach_callback: function () {
assert.step("on_attach_callback");
assert.containsOnce(
document.body,
".modal .o_test",
"should have rendered the client action in a dialog"
);
},
start: function () {
this.$el.addClass("o_test");
},
});
core.action_registry.add("test", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
tag: "test",
target: "new",
type: "ir.actions.client",
});
assert.verifySteps(["on_attach_callback"]);
delete core.action_registry.map.test;
});
QUnit.test(
'footer buttons are updated when having another action in target "new"',
async function (assert) {
serverData.views["partner,false,form"] = `
<form>
<field name="display_name"/>
<footer>
<button string="Create" type="object" class="infooter"/>
</footer>
</form>
`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 5);
assert.containsNone(target, '.o_technical_modal .modal-body button[special="save"]');
assert.containsNone(target, ".o_technical_modal .modal-body button.infooter");
assert.containsOnce(target, ".o_technical_modal .modal-footer button.infooter");
assert.containsOnce(target, ".o_technical_modal .modal-footer button:not(.d-none)");
await doAction(webClient, 25);
assert.containsNone(target, ".o_technical_modal .modal-body button.infooter");
assert.containsNone(target, ".o_technical_modal .modal-footer button.infooter");
assert.containsNone(target, '.o_technical_modal .modal-body button[special="save"]');
assert.containsOnce(target, '.o_technical_modal .modal-footer button[special="save"]');
assert.containsOnce(target, ".o_technical_modal .modal-footer button:not(.d-none)");
}
);
QUnit.test(
'buttons of client action in target="new" and transition to MVC action',
async function (assert) {
const ClientAction = AbstractAction.extend({
renderButtons($target) {
const button = document.createElement("button");
button.setAttribute("class", "o_stagger_lee");
$target[0].appendChild(button);
},
});
core.action_registry.add("test", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
tag: "test",
target: "new",
type: "ir.actions.client",
});
assert.containsOnce(target, ".modal footer button.o_stagger_lee");
assert.containsNone(target, '.modal footer button[special="save"]');
await doAction(webClient, 25);
assert.containsNone(target, ".modal footer button.o_stagger_lee");
assert.containsOnce(target, '.modal footer button[special="save"]');
delete core.action_registry.map.test;
}
);
QUnit.test(
'button with confirm attribute in act_window action in target="new"',
async function (assert) {
serverData.actions[999] = {
id: 999,
name: "A window action",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[999, "form"]],
};
serverData.views["partner,999,form"] = `
<form>
<button name="method" string="Call method" type="object" confirm="Are you sure?"/>
</form>`;
serverData.views["partner,1000,form"] = `<form>Another action</form>`;
const mockRPC = (route, args) => {
if (args.method === "method") {
return Promise.resolve({
id: 1000,
name: "Another window action",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[1000, "form"]],
});
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 999);
assert.containsOnce(document.body, ".modal button[name=method]");
await testUtils.dom.click($(".modal button[name=method]"));
assert.containsN(document.body, ".modal", 2);
assert.strictEqual($(".modal:last .modal-body").text(), "Are you sure?");
await testUtils.dom.click($(".modal:last .modal-footer .btn-primary"));
// needs two renderings to close the ConfirmationDialog:
// - 1 to open the next dialog (the action in target="new")
// - 1 to close the ConfirmationDialog, once the next action is executed
await nextTick();
assert.containsOnce(document.body, ".modal");
assert.strictEqual(
target.querySelector(".modal main .o_content").innerText.trim(),
"Another action"
);
}
);
QUnit.test('actions in target="new" do not update page title', async function (assert) {
const mockedTitleService = {
start() {
return {
setParts({ action }) {
if (action) {
assert.step(action);
}
},
};
},
};
registry.category("services").add("title", mockedTitleService);
const webClient = await createWebClient({ serverData });
// sanity check: execute an action in target="current"
await doAction(webClient, 1);
assert.verifySteps(["Partners Action 1"]);
// execute an action in target="new"
await doAction(webClient, 5);
assert.verifySteps([]);
});
QUnit.test("do not commit a dialog in error", async (assert) => {
assert.expect(6);
const handler = (ev) => {
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
class ErrorClientAction extends Component {
setup() {
throw new Error("my error");
}
}
ErrorClientAction.template = xml`<div/>`;
registry.category("actions").add("failing", ErrorClientAction);
class ClientActionTargetNew extends Component {}
ClientActionTargetNew.template = xml`<div class="my_action_new" />`;
registry.category("actions").add("clientActionNew", ClientActionTargetNew);
class ClientAction extends Component {
setup() {
this.action = useService("action");
}
async onClick() {
try {
await this.action.doAction(
{ type: "ir.actions.client", tag: "failing", target: "new" },
{ onClose: () => assert.step("failing dialog closed") }
);
} catch (e) {
assert.strictEqual(e.cause.message, "my error");
}
}
}
ClientAction.template = xml`
<div class="my_action" t-on-click="onClick">
My Action
</div>`;
registry.category("actions").add("clientAction", ClientAction);
const errorDialogOpened = makeDeferred();
patchWithCleanup(ClientErrorDialog.prototype, {
setup() {
this._super(...arguments);
onMounted(() => errorDialogOpened.resolve());
},
});
registry.category("services").add("error", errorService);
const webClient = await createWebClient({});
await doAction(webClient, { type: "ir.actions.client", tag: "clientAction" });
await click(target, ".my_action");
await errorDialogOpened;
assert.containsOnce(target, ".modal");
await click(target, ".modal-body button.btn-link");
assert.ok(
target.querySelector(".modal-body .o_error_detail").textContent.includes("my error")
);
await click(target, ".modal-footer button");
assert.containsNone(target, ".modal");
await doAction(webClient, {
type: "ir.actions.client",
tag: "clientActionNew",
target: "new",
});
assert.containsOnce(target, ".modal .my_action_new");
assert.verifySteps([]);
});
QUnit.test('breadcrumbs of actions in target="new"', async function (assert) {
const webClient = await createWebClient({ serverData });
// execute an action in target="current"
await doAction(webClient, 1);
assert.deepEqual(
[...target.querySelectorAll(".breadcrumb-item")].map((i) => i.innerText),
["Partners Action 1"]
);
// execute an action in target="new" and a list view (s.t. there is a control panel)
await doAction(webClient, {
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
assert.containsNone(target, ".modal .breadcrumb");
});
QUnit.test('call switchView in an action in target="new"', async function (assert) {
const webClient = await createWebClient({ serverData });
// execute an action in target="current"
await doAction(webClient, 4);
assert.containsOnce(target, ".o_kanban_view");
// execute an action in target="new" and a list view (s.t. we can call switchView)
await doAction(webClient, {
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
assert.containsOnce(target, ".modal .o_list_view");
assert.containsOnce(target, ".o_kanban_view");
// click on a record in the dialog -> should do nothing as we can't switch view
// in the dialog, and we don't want to switch view behind the dialog
await click(target.querySelector(".modal .o_data_row .o_data_cell"));
assert.containsOnce(target, ".modal .o_list_view");
assert.containsOnce(target, ".o_kanban_view");
});
QUnit.test("action with 'dialog_size' key in context", async function (assert) {
const action = {
name: "Some Action",
res_model: "partner",
type: "ir.actions.act_window",
target: "new",
views: [[false, "form"]],
};
const webClient = await createWebClient({ serverData });
await doAction(webClient, action);
assert.hasClass(target.querySelector(".o_dialog .modal-dialog"), "modal-lg");
await doAction(webClient, { ...action, context: { dialog_size: "small" } });
assert.hasClass(target.querySelector(".o_dialog .modal-dialog"), "modal-sm");
await doAction(webClient, { ...action, context: { dialog_size: "medium" } });
assert.hasClass(target.querySelector(".o_dialog .modal-dialog"), "modal-md");
await doAction(webClient, { ...action, context: { dialog_size: "large" } });
assert.hasClass(target.querySelector(".o_dialog .modal-dialog"), "modal-lg");
await doAction(webClient, { ...action, context: { dialog_size: "extra-large" } });
assert.hasClass(target.querySelector(".o_dialog .modal-dialog"), "modal-xl");
});
QUnit.test('click on record in list view action in target="new"', async function (assert) {
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1001);
await doAction(webClient, {
name: "Favorite Ponies",
res_model: "pony",
type: "ir.actions.act_window",
target: "new",
views: [[false, "list"], [false, "form"]],
});
// The list view has been opened in a dialog
assert.containsOnce(target, ".o_dialog .modal-dialog .o_list_view");
// click on a record in the dialog -> should do nothing as we can't switch view in the dialog
await click(target.querySelector(".modal .o_data_row .o_data_cell"));
assert.containsOnce(target, ".o_dialog .modal-dialog .o_list_view");
assert.containsNone(target, ".o_form_view");
});
QUnit.module('Actions in target="fullscreen"');
QUnit.test(
'correctly execute act_window actions in target="fullscreen"',
async function (assert) {
serverData.actions[1].target = "fullscreen";
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsOnce(target, ".o_control_panel", "should have rendered a control panel");
assert.containsOnce(target, ".o_kanban_view", "should have rendered a kanban view");
assert.containsNone(target, ".o_main_navbar");
}
);
QUnit.test('fullscreen on action change: back to a "current" action', async function (assert) {
serverData.actions[1].target = "fullscreen";
serverData.views[
"partner,false,form"
] = `<form><button name="1" type="action" class="oe_stat_button" /></form>`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 6);
assert.containsOnce(target, ".o_main_navbar");
await click(target.querySelector("button[name='1']"));
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsNone(target, ".o_main_navbar");
await click(target.querySelector(".breadcrumb li a"));
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsOnce(target, ".o_main_navbar");
});
QUnit.test('fullscreen on action change: all "fullscreen" actions', async function (assert) {
serverData.actions[6].target = "fullscreen";
serverData.views[
"partner,false,form"
] = `<form><button name="1" type="action" class="oe_stat_button" /></form>`;
const webClient = await createWebClient({ serverData });
await doAction(webClient, 6);
assert.isNotVisible(target.querySelector(".o_main_navbar"));
await click(target.querySelector("button[name='1']"));
assert.isNotVisible(target.querySelector(".o_main_navbar"));
await click(target.querySelector(".breadcrumb li a"));
assert.isNotVisible(target.querySelector(".o_main_navbar"));
});
QUnit.test(
'fullscreen on action change: back to another "current" action',
async function (assert) {
serverData.menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [], name: "MAIN APP", appID: 1, actionID: 6 },
};
serverData.actions[1].target = "fullscreen";
serverData.views["partner,false,form"] =
'<form><button name="24" type="action" class="oe_stat_button"/></form>';
await createWebClient({ serverData });
await nextTick(); // wait for the load state (default app)
assert.containsOnce(target, "nav .o_menu_brand");
assert.strictEqual(target.querySelector("nav .o_menu_brand").innerText, "MAIN APP");
await click(target.querySelector("button[name='24']"));
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsOnce(target, "nav .o_menu_brand");
await click(target.querySelector("button[name='1']"));
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsNone(target, "nav.o_main_navbar");
await click(target.querySelectorAll(".breadcrumb li a")[1]);
await nextTick(); // wait for the webclient template to be re-rendered
assert.containsOnce(target, "nav .o_menu_brand");
assert.strictEqual(target.querySelector("nav .o_menu_brand").innerText, "MAIN APP");
}
);
QUnit.module('Actions in target="main"');
QUnit.test('can execute act_window actions in target="main"', async function (assert) {
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partners Action 1"
);
await doAction(webClient, {
name: "Another Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
target: "main",
});
assert.containsOnce(target, ".o_list_view");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Another Partner Action"
);
});
QUnit.test('can switch view in an action in target="main"', async function (assert) {
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
name: "Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
target: "main",
});
assert.containsOnce(target, ".o_list_view");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partner Action"
);
// open first record
await click(target.querySelector(".o_data_row .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partner ActionFirst record"
);
});
QUnit.test('can restore an action in target="main"', async function (assert) {
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
name: "Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
target: "main",
});
assert.containsOnce(target, ".o_list_view");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partner Action"
);
// open first record
await click(target.querySelector(".o_data_row .o_data_cell"));
assert.containsOnce(target, ".o_form_view");
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partner ActionFirst record"
);
await doAction(webClient, 1);
assert.containsOnce(target, ".o_kanban_view");
assert.containsN(target, ".breadcrumb-item", 3);
// go back to form view
await click(target.querySelectorAll(".breadcrumb-item")[1]);
assert.containsOnce(target, ".o_form_view");
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(
target.querySelector(".o_control_panel .breadcrumb").textContent,
"Partner ActionFirst record"
);
});
});

View file

@ -0,0 +1,70 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { makeTestEnv } from "../../helpers/mock_env";
import { makeFakeRouterService } from "../../helpers/mock_services";
import { setupWebClientRegistries, doAction, getActionManagerServerData } from "./../helpers";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
let serverData;
const serviceRegistry = registry.category("services");
QUnit.module("ActionManager", (hooks) => {
hooks.beforeEach(() => {
serverData = getActionManagerServerData();
});
QUnit.module("URL actions");
QUnit.test("execute an 'ir.actions.act_url' action with target 'self'", async (assert) => {
serviceRegistry.add(
"router",
makeFakeRouterService({
onRedirect(url) {
assert.step(url);
},
})
);
setupWebClientRegistries();
const env = await makeTestEnv({ serverData });
await doAction(env, {
type: "ir.actions.act_url",
target: "self",
url: "/my/test/url",
});
assert.verifySteps(["/my/test/url"]);
});
QUnit.test("execute an 'ir.actions.act_url' action with onClose option", async (assert) => {
setupWebClientRegistries();
patchWithCleanup(browser, {
open: () => assert.step("browser open"),
});
const env = await makeTestEnv({ serverData });
const options = {
onClose: () => assert.step("onClose"),
};
await doAction(env, { type: "ir.actions.act_url" }, options);
assert.verifySteps(["browser open", "onClose"]);
});
QUnit.test("execute an 'ir.actions.act_url' action with url javascript:", async (assert) => {
assert.expect(1);
serviceRegistry.add(
"router",
makeFakeRouterService({
onRedirect(url) {
assert.strictEqual(url, "/javascript:alert()");
},
})
);
setupWebClientRegistries();
const env = await makeTestEnv({ serverData });
await doAction(env, {
type: "ir.actions.act_url",
target: "self",
url: "javascript:alert()",
});
});
});

View file

@ -0,0 +1,133 @@
/** @odoo-module **/
/* global ZXing */
import { browser } from "@web/core/browser/browser";
import {
makeDeferred,
nextTick,
patchWithCleanup,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { scanBarcode, BarcodeDialog } from "@web/webclient/barcode/barcode_scanner";
QUnit.module("Barcode scanner", {});
QUnit.test("Barcode scanner crop overlay", async (assert) => {
const firstBarcodeValue = "Odoo";
const secondBarcodeValue = "O-CMD-TEST";
let barcodeToGenerate = firstBarcodeValue;
let videoReady = makeDeferred();
function mockUserMedia() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const stream = canvas.captureStream();
const multiFormatWriter = new ZXing.MultiFormatWriter();
const bitMatrix = multiFormatWriter.encode(
barcodeToGenerate,
ZXing.BarcodeFormat.QR_CODE,
250,
250,
null
);
canvas.width = bitMatrix.width;
canvas.height = bitMatrix.height;
for (let x = 0; x < bitMatrix.width; x++) {
for (let y = 0; y < bitMatrix.height; y++) {
if (bitMatrix.get(x, y)) {
ctx.beginPath();
ctx.rect(x, y, 1, 1);
ctx.stroke();
}
}
}
return stream;
}
// simulate an environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
navigator: {
mediaDevices: {
getUserMedia: mockUserMedia,
},
},
})
);
patchWithCleanup(BarcodeDialog.prototype, {
async isVideoReady() {
return this._super(...arguments).then(() => {
videoReady.resolve();
});
},
onResize(overlayInfo) {
assert.step(JSON.stringify(overlayInfo));
return this._super(...arguments);
},
});
const firstBarcodeFound = scanBarcode();
await videoReady;
// Needed due to the change on the props in the Crop component
await nextTick();
const cropIconSelector = ".o_crop_icon";
const cropIcon = document.querySelector(cropIconSelector);
const cropOverlay = document.querySelector(".o_crop_overlay");
const cropContainer = document.querySelector(".o_crop_container");
const cropIconPosition = cropIcon.getBoundingClientRect();
const cropOverlayPosition = cropOverlay.getBoundingClientRect();
await triggerEvent(cropContainer, cropIconSelector, "touchstart", {
touches: [
{
identifier: 0,
clientX: cropIconPosition.x + cropIconPosition.width / 2,
clientY: cropIconPosition.y + cropIconPosition.height / 2,
target: cropIcon,
},
],
});
await triggerEvent(cropContainer, cropIconSelector, "touchmove", {
touches: [
{
identifier: 0,
clientX: cropOverlayPosition.right,
clientY: cropOverlayPosition.bottom,
target: cropIcon,
},
],
});
await triggerEvent(cropContainer, cropIconSelector, "touchend", {});
const firstValueScanned = await firstBarcodeFound;
assert.strictEqual(
firstValueScanned,
firstBarcodeValue,
`The detected barcode should be the same as generated (${firstBarcodeValue})`
);
// Do another scan barcode to the test position of the overlay saved in the locale storage
// Reset all values for the second test
barcodeToGenerate = secondBarcodeValue;
videoReady = makeDeferred();
const secondBarcodeFound = scanBarcode();
await videoReady;
const secondValueScanned = await secondBarcodeFound;
assert.strictEqual(
secondValueScanned,
secondBarcodeValue,
`The detected barcode should be the same as generated (${secondBarcodeValue})`
);
assert.verifySteps(
[
JSON.stringify({ x: 25, y: 100, width: 200, height: 50 }),
JSON.stringify({ x: 0, y: 0, width: 250, height: 250 }),
JSON.stringify({ x: 0, y: 0, width: 250, height: 250 }),
],
"We should haves three resize event; one for the default position, another one for the all frame and the last one must be the same as the saved second position"
);
});

View file

@ -0,0 +1,600 @@
/** @odoo-module **/
import { dialogService } from "@web/core/dialog/dialog_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { legacyServiceProvider } from "@web/legacy/legacy_service_provider";
import {
makeLegacyNotificationService,
mapLegacyEnvToWowlEnv,
makeLegacySessionService,
} from "@web/legacy/utils";
import { makeLegacyActionManagerService } from "@web/legacy/backend_utils";
import { generateLegacyLoadViewsResult } from "@web/legacy/legacy_load_views";
import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { effectService } from "@web/core/effects/effect_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { WebClient } from "@web/webclient/webclient";
// This import is needed because of it's sideeffects, for exemple :
// web.test_utils easyload xml templates at line : 124:130.
// Also it set the autocomplete delay time for the field Many2One at 0 for the tests at line : 132:137
import "web.test_legacy";
import AbstractService from "web.AbstractService";
import ActionMenus from "web.ActionMenus";
import basicFields from "web.basic_fields";
import Registry from "web.Registry";
import core from "web.core";
import makeTestEnvironment from "web.test_env";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import {
fakeTitleService,
fakeCompanyService,
makeFakeLocalizationService,
makeFakeRouterService,
makeFakeHTTPService,
makeFakeUserService,
} from "../helpers/mock_services";
import {
getFixture,
legacyExtraNextTick,
mount,
nextTick,
patchWithCleanup,
} from "../helpers/utils";
import session from "web.session";
import LegacyMockServer from "web.MockServer";
import Widget from "web.Widget";
import { uiService } from "@web/core/ui/ui_service";
import { ClientActionAdapter, ViewAdapter } from "@web/legacy/action_adapters";
import { commandService } from "@web/core/commands/command_service";
import { ConnectionAbortedError } from "@web/core/network/rpc_service";
import { CustomFavoriteItem } from "@web/search/favorite_menu/custom_favorite_item";
import { standaloneAdapter } from "web.OwlCompatibility";
import { Component, onMounted, xml } from "@odoo/owl";
const actionRegistry = registry.category("actions");
const serviceRegistry = registry.category("services");
const favoriteMenuRegistry = registry.category("favoriteMenu");
/**
* Builds the required registries for tests using a WebClient.
* We use a default version of each required registry item.
* If the registry already contains one of those items,
* the existing one is kept (it means it has been added in the test
* directly, e.g. to have a custom version of the item).
*/
export function setupWebClientRegistries() {
const favoriveMenuItems = {
"custom-favorite-item": {
value: { Component: CustomFavoriteItem, groupNumber: 3 },
options: { sequence: 0 },
},
};
for (const [key, { value, options }] of Object.entries(favoriveMenuItems)) {
if (!favoriteMenuRegistry.contains(key)) {
favoriteMenuRegistry.add(key, value, options);
}
}
const services = {
action: () => actionService,
command: () => commandService,
dialog: () => dialogService,
effect: () => effectService,
hotkey: () => hotkeyService,
http: () => makeFakeHTTPService(),
legacy_service_provider: () => legacyServiceProvider,
localization: () => makeFakeLocalizationService(),
menu: () => menuService,
notification: () => notificationService,
orm: () => ormService,
popover: () => popoverService,
router: () => makeFakeRouterService(),
title: () => fakeTitleService,
ui: () => uiService,
user: () => makeFakeUserService(),
view: () => viewService,
company: () => fakeCompanyService,
};
for (const serviceName in services) {
if (!serviceRegistry.contains(serviceName)) {
serviceRegistry.add(serviceName, services[serviceName]());
}
}
}
/**
* Remove this as soon as we drop the legacy support
*/
export async function addLegacyMockEnvironment(env, legacyParams = {}) {
// setup a legacy env
const dataManager = Object.assign(
{
load_action: (actionID, context) => {
return env.services.rpc("/web/action/load", {
action_id: actionID,
additional_context: context,
});
},
load_views: async (params, options) => {
let result = await env.services.rpc(`/web/dataset/call_kw/${params.model}`, {
args: [],
kwargs: {
context: params.context,
options: options,
views: params.views_descr,
},
method: "get_views",
model: params.model,
});
const { models, views: _views } = result;
result = generateLegacyLoadViewsResult(params.model, _views, models);
const views = result.fields_views;
for (const [, viewType] of params.views_descr) {
const fvg = views[viewType];
fvg.viewFields = fvg.fields;
fvg.fields = result.fields;
}
if (params.favoriteFilters && "search" in views) {
views.search.favoriteFilters = params.favoriteFilters;
}
return views;
},
load_filters: (params) => {
if (QUnit.config.debug) {
console.log("[mock] load_filters", params);
}
return Promise.resolve([]);
},
},
legacyParams.dataManager
);
// clear the ActionMenus registry to prevent external code from doing unknown rpcs
const actionMenusRegistry = ActionMenus.registry;
ActionMenus.registry = new Registry();
registerCleanup(() => (ActionMenus.registry = actionMenusRegistry));
let localSession;
if (legacyParams && legacyParams.getTZOffset) {
patchWithCleanup(session, {
getTZOffset: legacyParams.getTZOffset,
});
localSession = { getTZOffset: legacyParams.getTZOffset };
}
const baseEnv = { dataManager, bus: core.bus, session: localSession };
const legacyEnv = makeTestEnvironment(Object.assign(baseEnv, legacyParams.env));
if (legacyParams.serviceRegistry) {
const legacyServiceMap = core.serviceRegistry.map;
core.serviceRegistry.map = legacyParams.serviceRegistry.map;
// notification isn't a deployed service, but it is added by `makeTestEnvironment`.
// Here, we want full control on the deployed services, so we simply remove it.
delete legacyEnv.services.notification;
AbstractService.prototype.deployServices(legacyEnv);
registerCleanup(() => {
core.serviceRegistry.map = legacyServiceMap;
});
}
Component.env = legacyEnv;
mapLegacyEnvToWowlEnv(legacyEnv, env);
function patchLegacySession() {
const userContext = Object.getOwnPropertyDescriptor(session, "user_context");
registerCleanup(() => {
Object.defineProperty(session, "user_context", userContext);
});
}
patchLegacySession();
serviceRegistry.add("legacy_session", makeLegacySessionService(legacyEnv, session));
// deploy the legacyActionManagerService (in Wowl env)
const legacyActionManagerService = makeLegacyActionManagerService(legacyEnv);
serviceRegistry.add("legacy_action_manager", legacyActionManagerService);
serviceRegistry.add("legacy_notification", makeLegacyNotificationService(legacyEnv));
// deploy wowl services into the legacy env.
const wowlToLegacyServiceMappers = registry.category("wowlToLegacyServiceMappers").getEntries();
for (const [legacyServiceName, wowlToLegacyServiceMapper] of wowlToLegacyServiceMappers) {
serviceRegistry.add(legacyServiceName, wowlToLegacyServiceMapper(legacyEnv));
}
// patch DebouncedField delay
const debouncedField = basicFields.DebouncedField;
const initialDebouncedVal = debouncedField.prototype.DEBOUNCE;
debouncedField.prototype.DEBOUNCE = 0;
registerCleanup(() => (debouncedField.prototype.DEBOUNCE = initialDebouncedVal));
if (legacyParams.withLegacyMockServer) {
const adapter = standaloneAdapter({ Component });
registerCleanup(() => adapter.__owl__.app.destroy());
adapter.env = legacyEnv;
const W = Widget.extend({ do_push_state() {} });
const widget = new W(adapter);
const legacyMockServer = new LegacyMockServer(legacyParams.models, { widget });
const originalRPC = env.services.rpc;
const rpc = async (...args) => {
try {
return await originalRPC(...args);
} catch (e) {
if (e.message.includes("Unimplemented")) {
return legacyMockServer._performRpc(...args);
} else {
throw e;
}
}
};
env.services.rpc = function () {
let rejectFn;
const rpcProm = new Promise((resolve, reject) => {
rejectFn = reject;
rpc(...arguments)
.then(resolve)
.catch(reject);
});
rpcProm.abort = () => rejectFn(new ConnectionAbortedError("XmlHttpRequestError abort"));
return rpcProm;
};
}
}
/**
* This method create a web client instance properly configured.
*
* Note that the returned web client will be automatically cleaned up after the
* end of the test.
*
* @param {*} params
*/
export async function createWebClient(params) {
setupWebClientRegistries();
// With the compatibility layer, the action manager keeps legacy alive if they
// are still acessible from the breacrumbs. They are manually destroyed as soon
// as they are no longer referenced in the stack. This works fine in production,
// because the webclient is never destroyed. However, at the end of each test,
// we destroy the webclient and expect every legacy that has been instantiated
// to be destroyed. We thus need to manually destroy them here.
const controllers = [];
patchWithCleanup(ClientActionAdapter.prototype, {
setup() {
this._super();
onMounted(() => {
controllers.push(this.widget);
});
},
});
patchWithCleanup(ViewAdapter.prototype, {
setup() {
this._super();
onMounted(() => {
controllers.push(this.widget);
});
},
});
const legacyParams = params.legacyParams;
params.serverData = params.serverData || {};
const models = params.serverData.models;
if (legacyParams && legacyParams.withLegacyMockServer && models) {
legacyParams.models = Object.assign({}, models);
// In lagacy, data may not be sole models, but can contain some other variables
// So we filter them out for our WOWL mockServer
Object.entries(legacyParams.models).forEach(([k, v]) => {
if (!(v instanceof Object) || !("fields" in v)) {
delete models[k];
}
});
}
const mockRPC = params.mockRPC || undefined;
const env = await makeTestEnv({
serverData: params.serverData,
mockRPC,
});
await addLegacyMockEnvironment(env, legacyParams);
const WebClientClass = params.WebClientClass || WebClient;
const target = params && params.target ? params.target : getFixture();
const wc = await mount(WebClientClass, target, { env });
target.classList.add("o_web_client"); // necessary for the stylesheet
registerCleanup(() => {
target.classList.remove("o_web_client");
for (const controller of controllers) {
if (!controller.isDestroyed()) {
controller.destroy();
}
}
});
// Wait for visual changes caused by a potential loadState
await nextTick();
return wc;
}
export async function doAction(env, ...args) {
if (env instanceof Component) {
env = env.env;
}
try {
await env.services.action.doAction(...args);
} finally {
await legacyExtraNextTick();
}
}
export async function loadState(env, state) {
if (env instanceof Component) {
env = env.env;
}
env.bus.trigger("test:hashchange", state);
// wait the asynchronous hashchange
// (the event hashchange must be triggered in a nonBlocking stack)
await nextTick();
// wait for the regular rendering
await nextTick();
// wait for the legacy rendering below owl layer
await legacyExtraNextTick();
}
export function getActionManagerServerData() {
// additional basic client action
class TestClientAction extends Component {}
TestClientAction.template = xml`
<div class="test_client_action">
ClientAction_<t t-esc="props.action.params?.description"/>
</div>`;
actionRegistry.add("__test__client__action__", TestClientAction);
const menus = {
root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" },
// id:0 is a hack to not load anything at webClient mount
0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" },
1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" },
2: { id: 2, children: [], name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" },
};
const actionsArray = [
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
type: "ir.actions.act_window",
views: [[1, "kanban"]],
},
{
id: 2,
xml_id: "action_2",
type: "ir.actions.server",
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "form"]],
},
{
id: 6,
xml_id: "action_6",
name: "Partner",
res_id: 2,
res_model: "partner",
target: "inline",
type: "ir.actions.act_window",
views: [[false, "form"]],
},
{
id: 7,
xml_id: "action_7",
name: "Some Report",
report_name: "some_report",
report_type: "qweb-pdf",
type: "ir.actions.report",
},
{
id: 8,
xml_id: "action_8",
name: "Favorite Ponies",
res_model: "pony",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
},
{
id: 9,
xml_id: "action_9",
name: "A Client Action",
tag: "ClientAction",
type: "ir.actions.client",
},
{
id: 10,
type: "ir.actions.act_window_close",
},
{
id: 11,
xml_id: "action_11",
name: "Another Report",
report_name: "another_report",
report_type: "qweb-pdf",
type: "ir.actions.report",
close_on_report_download: true,
},
{
id: 12,
xml_id: "action_12",
name: "Some HTML Report",
report_name: "some_report",
report_type: "qweb-html",
type: "ir.actions.report",
},
{
id: 24,
name: "Partner",
res_id: 2,
res_model: "partner",
type: "ir.actions.act_window",
views: [[666, "form"]],
},
{
id: 25,
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[3, "form"]],
},
{
id: 1001,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Id 1" },
},
{
id: 1002,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "Id 2" },
},
{
xmlId: "wowl.client_action",
id: 1099,
tag: "__test__client__action__",
target: "main",
type: "ir.actions.client",
params: { description: "xmlId" },
},
];
const actions = {};
actionsArray.forEach((act) => {
actions[act.xmlId || act.id] = act;
});
const archs = {
// kanban views
"partner,1,kanban":
'<kanban><templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click"><field name="foo"/></div>' +
"</t></templates></kanban>",
// list views
"partner,false,list": '<tree><field name="foo"/></tree>',
"partner,2,list": '<tree limit="3"><field name="foo"/></tree>',
"pony,false,list": '<tree><field name="name"/></tree>',
// form views
"partner,false,form":
"<form>" +
"<header>" +
'<button name="object" string="Call method" type="object"/>' +
'<button name="4" string="Execute action" type="action"/>' +
"</header>" +
"<group>" +
'<field name="display_name"/>' +
'<field name="foo"/>' +
"</group>" +
"</form>",
"partner,3,form": `
<form>
<footer>
<button class="btn-primary" string="Save" special="save"/>
</footer>
</form>`,
"partner,666,form": `<form>
<header></header>
<sheet>
<div class="oe_button_box" name="button_box" modifiers="{}">
<button class="oe_stat_button" type="action" name="1" icon="fa-star" context="{'default_partner': active_id}">
<field string="Partners" name="o2m" widget="statinfo"/>
</button>
</div>
<field name="display_name"/>
</sheet>
</form>`,
"pony,false,form": "<form>" + '<field name="name"/>' + "</form>",
// search views
"partner,false,search": '<search><field name="foo" string="Foo"/></search>',
"partner,4,search":
"<search>" +
'<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', 1)]"/>' +
"</search>",
"pony,false,search": "<search></search>",
};
const models = {
partner: {
fields: {
id: { string: "Id", type: "integer" },
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "many2one", relation: "partner" },
o2m: {
string: "One2Many",
type: "one2many",
relation: "partner",
relation_field: "bar",
},
m2o: { string: "Many2one", type: "many2one", relation: "partner" },
},
records: [
{ id: 1, display_name: "First record", foo: "yop", bar: 2, o2m: [2, 3], m2o: 3 },
{
id: 2,
display_name: "Second record",
foo: "blip",
bar: 1,
o2m: [1, 4, 5],
m2o: 3,
},
{ id: 3, display_name: "Third record", foo: "gnap", bar: 1, o2m: [], m2o: 1 },
{ id: 4, display_name: "Fourth record", foo: "plop", bar: 2, o2m: [], m2o: 1 },
{ id: 5, display_name: "Fifth record", foo: "zoup", bar: 2, o2m: [], m2o: 1 },
],
},
pony: {
fields: {
id: { string: "Id", type: "integer" },
name: { string: "Name", type: "char" },
},
records: [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
],
},
};
return {
models,
views: archs,
actions,
menus,
};
}

View file

@ -0,0 +1,198 @@
/** @odoo-module **/
import { browser as originalBrowser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { patch, unpatch } from "@web/core/utils/patch";
import { LoadingIndicator } from "@web/webclient/loading_indicator/loading_indicator";
import { makeTestEnv } from "../helpers/mock_env";
import {
getFixture,
mockTimeout,
mount,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
const serviceRegistry = registry.category("services");
let target;
QUnit.module("LoadingIndicator", {
async beforeEach() {
target = getFixture();
serviceRegistry.add("ui", uiService);
patchWithCleanup(originalBrowser, {
setTimeout: async (f) => {
await Promise.resolve();
f();
},
});
},
});
QUnit.test("displays the loading indicator in non debug mode", async (assert) => {
const env = await makeTestEnv();
await mount(LoadingIndicator, target, { env });
let loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
env.bus.trigger("RPC:REQUEST", 1);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.notStrictEqual(loadingIndicator, null, "the loading indicator should be displayed");
assert.strictEqual(
loadingIndicator.textContent,
"Loading",
"the loading indicator should display 'Loading'"
);
env.bus.trigger("RPC:RESPONSE", 1);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
});
QUnit.test("displays the loading indicator for one rpc in debug mode", async (assert) => {
patchWithCleanup(odoo, { debug: "1" });
const env = await makeTestEnv();
await mount(LoadingIndicator, target, { env });
let loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
env.bus.trigger("RPC:REQUEST", 1);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.notStrictEqual(loadingIndicator, null, "the loading indicator should be displayed");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (1)",
"the loading indicator should indicate 1 request in progress"
);
env.bus.trigger("RPC:RESPONSE", 1);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
});
QUnit.test("displays the loading indicator for multi rpc in debug mode", async (assert) => {
patchWithCleanup(odoo, { debug: "1" });
const env = await makeTestEnv();
await mount(LoadingIndicator, target, { env });
let loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
env.bus.trigger("RPC:REQUEST", 1);
env.bus.trigger("RPC:REQUEST", 2);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.notStrictEqual(loadingIndicator, null, "the loading indicator should be displayed");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (2)",
"the loading indicator should indicate 2 requests in progress."
);
env.bus.trigger("RPC:REQUEST", 3);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (3)",
"the loading indicator should indicate 3 requests in progress."
);
env.bus.trigger("RPC:RESPONSE", 1);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (2)",
"the loading indicator should indicate 2 requests in progress."
);
env.bus.trigger("RPC:REQUEST", 4);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (3)",
"the loading indicator should indicate 3 requests in progress."
);
env.bus.trigger("RPC:RESPONSE", 2);
env.bus.trigger("RPC:RESPONSE", 3);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(
loadingIndicator.textContent,
"Loading (1)",
"the loading indicator should indicate 1 request in progress."
);
env.bus.trigger("RPC:RESPONSE", 4);
await nextTick();
loadingIndicator = target.querySelector(".o_loading_indicator");
assert.strictEqual(loadingIndicator, null, "the loading indicator should not be displayed");
});
QUnit.test("loading indicator blocks UI", async (assert) => {
const env = await makeTestEnv();
patch(originalBrowser, "mock.settimeout", {
setTimeout: async (callback, delay) => {
assert.step(`set timeout ${delay}`);
await Promise.resolve();
callback();
},
});
const ui = env.services.ui;
ui.bus.addEventListener("BLOCK", () => {
assert.step("block");
});
ui.bus.addEventListener("UNBLOCK", () => {
assert.step("unblock");
});
await mount(LoadingIndicator, target, { env });
env.bus.trigger("RPC:REQUEST", 1);
await nextTick();
env.bus.trigger("RPC:RESPONSE", 1);
await nextTick();
assert.verifySteps(["set timeout 250", "set timeout 3000", "block", "unblock"]);
unpatch(originalBrowser, "mock.settimeout");
});
QUnit.test("loading indicator doesn't unblock ui if it didn't block it", async (assert) => {
const env = await makeTestEnv();
const { execRegisteredTimeouts } = mockTimeout();
const ui = env.services.ui;
ui.bus.on("BLOCK", null, () => {
assert.step("block");
});
ui.bus.on("UNBLOCK", null, () => {
assert.step("unblock");
});
await mount(LoadingIndicator, target, { env });
env.bus.trigger("RPC:REQUEST", 1);
execRegisteredTimeouts();
env.bus.trigger("RPC:RESPONSE", 1);
assert.verifySteps(["block", "unblock"]);
env.bus.trigger("RPC:REQUEST", 2);
env.bus.trigger("RPC:RESPONSE", 2);
execRegisteredTimeouts();
assert.verifySteps([]);
});
QUnit.test("loading indicator is not displayed immediately", async (assert) => {
const env = await makeTestEnv();
const { advanceTime } = mockTimeout();
const ui = env.services.ui;
ui.bus.addEventListener("BLOCK", () => {
assert.step("block");
});
ui.bus.addEventListener("UNBLOCK", () => {
assert.step("unblock");
});
await mount(LoadingIndicator, target, { env });
env.bus.trigger("RPC:REQUEST", 1);
await nextTick();
assert.containsNone(target, ".o_loading_indicator");
await advanceTime(400);
await nextTick();
assert.containsOnce(target, ".o_loading_indicator");
env.bus.trigger("RPC:RESPONSE", 1);
await nextTick();
assert.containsNone(target, ".o_loading_indicator");
});

View file

@ -0,0 +1,319 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import {
click,
getFixture,
makeDeferred,
mount,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { MobileSwitchCompanyMenu } from "@web/webclient/burger_menu/mobile_switch_company_menu/mobile_switch_company_menu";
import { companyService } from "@web/webclient/company_service";
import { uiService } from "@web/core/ui/ui_service";
import { session } from "@web/session";
const serviceRegistry = registry.category("services");
let target;
const ORIGINAL_TOGGLE_DELAY = MobileSwitchCompanyMenu.toggleDelay;
async function createSwitchCompanyMenu(routerParams = {}, toggleDelay = 0) {
patchWithCleanup(MobileSwitchCompanyMenu, { toggleDelay });
if (routerParams.onPushState) {
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState(state, title, url) {
pushState(...arguments);
if (routerParams.onPushState) {
routerParams.onPushState(url);
}
},
}),
});
}
const env = await makeTestEnv();
const scMenu = await mount(MobileSwitchCompanyMenu, target, { env });
return scMenu;
}
QUnit.module("MobileSwitchCompanyMenu", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
patchWithCleanup(session.user_companies, {
allowed_companies: {
1: { id: 1, name: "Hermit" },
2: { id: 2, name: "Herman's" },
3: { id: 3, name: "Heroes TM" },
},
current_company: 1,
});
serviceRegistry.add("ui", uiService);
serviceRegistry.add("company", companyService);
serviceRegistry.add("hotkey", hotkeyService);
});
QUnit.test("basic rendering", async (assert) => {
assert.expect(13);
await createSwitchCompanyMenu();
const scMenuEl = target.querySelector(".o_burger_menu_companies");
assert.strictEqual(scMenuEl.tagName.toUpperCase(), "DIV");
assert.hasClass(scMenuEl, "o_burger_menu_companies");
assert.containsN(scMenuEl, ".toggle_company", 3);
assert.containsN(scMenuEl, ".log_into", 3);
assert.containsOnce(scMenuEl, ".fa-check-square");
assert.containsN(scMenuEl, ".fa-square-o", 2);
assert.strictEqual(
scMenuEl.querySelectorAll(".menu_companies_item")[0].textContent,
"Hermit(current)"
);
assert.strictEqual(
scMenuEl.querySelectorAll(".menu_companies_item")[1].textContent,
"Herman's"
);
assert.strictEqual(
scMenuEl.querySelectorAll(".menu_companies_item")[2].textContent,
"Heroes TM"
);
assert.hasClass(scMenuEl.querySelectorAll(".menu_companies_item i")[0], "fa-check-square");
assert.hasClass(scMenuEl.querySelectorAll(".menu_companies_item i")[1], "fa-square-o");
assert.hasClass(scMenuEl.querySelectorAll(".menu_companies_item i")[2], "fa-square-o");
assert.strictEqual(scMenuEl.textContent, "CompaniesHermit(current)Herman'sHeroes TM");
});
QUnit.test("companies can be toggled: toggle a second company", async (assert) => {
assert.expect(9);
const prom = makeDeferred();
function onPushState(url) {
assert.step(url.split("#")[1]);
prom.resolve();
}
const scMenu = await createSwitchCompanyMenu({ onPushState });
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 2);
/**
* [x] **Company 1**
* [x] Company 2 -> toggle
* [ ] Company 3
*/
await click(scMenuEl.querySelectorAll(".toggle_company")[1]);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 2);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 1);
await prom;
assert.verifySteps(["cids=1%2C2"]);
});
QUnit.test("can toggle multiple companies at once", async (assert) => {
assert.expect(10);
const prom = makeDeferred();
function onPushState(url) {
assert.step(url.split("#")[1]);
prom.resolve();
}
const scMenu = await createSwitchCompanyMenu({ onPushState }, ORIGINAL_TOGGLE_DELAY);
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Company 1** -> toggle all
* [x] Company 2 -> toggle all
* [x] Company 3 -> toggle all
*/
await click(scMenuEl.querySelectorAll(".toggle_company")[0]);
await click(scMenuEl.querySelectorAll(".toggle_company")[1]);
await click(scMenuEl.querySelectorAll(".toggle_company")[2]);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 2);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 1);
assert.verifySteps([]);
await prom; // await toggle promise
assert.verifySteps(["cids=2%2C3"]);
});
QUnit.test("single company selected: toggling it off will keep it", async (assert) => {
assert.expect(11);
patchWithCleanup(browser, {
setTimeout(fn) {
return fn(); // s.t. we can directly assert changes in the hash
},
});
const scMenu = await createSwitchCompanyMenu();
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
assert.deepEqual(scMenu.env.services.router.current.hash, { cids: 1 });
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Company 1** -> toggle off
* [ ] Company 2
* [ ] Company 3
*/
await click(scMenuEl.querySelectorAll(".toggle_company")[0]);
assert.deepEqual(scMenu.env.services.router.current.hash, { cids: 1 });
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-squarqe", 0);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 3);
});
QUnit.test("single company mode: companies can be logged in", async (assert) => {
assert.expect(7);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
const scMenu = await createSwitchCompanyMenu({ onPushState });
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 2);
/**
* [x] **Company 1**
* [ ] Company 2 -> log into
* [ ] Company 3
*/
await click(scMenuEl.querySelectorAll(".log_into")[1]);
assert.verifySteps(["cids=2"]);
});
QUnit.test("multi company mode: log into a non selected company", async (assert) => {
assert.expect(7);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
Object.assign(browser.location, { hash: "cids=3%2C1" });
const scMenu = await createSwitchCompanyMenu({ onPushState });
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] Company 1
* [ ] Company 2
* [x] **Company 3**
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3, 1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 2);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 1);
/**
* [x] Company 1
* [ ] Company 2 -> log into
* [x] **Company 3**
*/
await click(scMenuEl.querySelectorAll(".log_into")[1]);
assert.verifySteps(["cids=2%2C3%2C1"]);
});
QUnit.test("multi company mode: log into an already selected company", async (assert) => {
assert.expect(7);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
Object.assign(browser.location, { hash: "cids=2%2C3" });
const scMenu = await createSwitchCompanyMenu({ onPushState });
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [ ] Company 1
* [x] **Company 2**
* [x] Company 3
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [2, 3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 2);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 2);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 1);
/**
* [ ] Company 1
* [x] **Company 2**
* [x] Company 3 -> log into
*/
await click(scMenuEl.querySelectorAll(".log_into")[2]);
assert.verifySteps(["cids=3%2C2"]);
});
QUnit.test("companies can be logged in even if some toggled within delay", async (assert) => {
assert.expect(7);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
const scMenu = await createSwitchCompanyMenu({ onPushState }, ORIGINAL_TOGGLE_DELAY);
const scMenuEl = target.querySelector(".o_burger_menu_companies");
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 1);
assert.containsN(scMenuEl, "[data-company-id]", 3);
assert.containsN(scMenuEl, "[data-company-id] .fa-check-square", 1);
assert.containsN(scMenuEl, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Company 1** -> toggled
* [ ] Company 2 -> logged in
* [ ] Company 3 -> toggled
*/
await click(scMenuEl.querySelectorAll(".toggle_company")[2]);
await click(scMenuEl.querySelectorAll(".toggle_company")[0]);
await click(scMenuEl.querySelectorAll(".log_into")[1]);
assert.verifySteps(["cids=2"]);
});
});

View file

@ -0,0 +1,518 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { notificationService } from "@web/core/notifications/notification_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { registry } from "@web/core/registry";
import { ormService } from "@web/core/orm_service";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { NavBar } from "@web/webclient/navbar/navbar";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import {
click,
destroy,
getFixture,
mount,
nextTick,
patchWithCleanup,
mockTimeout,
} from "../helpers/utils";
import { Component, xml, onRendered } from "@odoo/owl";
const systrayRegistry = registry.category("systray");
const serviceRegistry = registry.category("services");
class MySystrayItem extends Component {}
MySystrayItem.template = xml`<li class="my-item">my item</li>`;
let baseConfig;
let target;
QUnit.module("Navbar", {
async beforeEach() {
target = getFixture();
serviceRegistry.add("menu", menuService);
serviceRegistry.add("action", actionService);
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
serviceRegistry.add("view", viewService); // #action-serv-leg-compat-js-class
serviceRegistry.add("orm", ormService); // #action-serv-leg-compat-js-class
systrayRegistry.add("addon.myitem", { Component: MySystrayItem });
patchWithCleanup(browser, {
setTimeout: (handler, delay, ...args) => handler(...args),
clearTimeout: () => {},
});
const menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [], name: "App0", appID: 1 },
};
const serverData = { menus };
baseConfig = { serverData };
},
});
QUnit.test("can be rendered", async (assert) => {
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
assert.containsOnce(
target,
".o_navbar_apps_menu button.dropdown-toggle",
"1 apps menu toggler present"
);
});
QUnit.test("dropdown menu can be toggled", async (assert) => {
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
const dropdown = target.querySelector(".o_navbar_apps_menu");
await click(dropdown, "button.dropdown-toggle");
assert.containsOnce(dropdown, ".dropdown-menu");
await click(dropdown, "button.dropdown-toggle");
assert.containsNone(dropdown, ".dropdown-menu");
});
QUnit.test("href attribute on apps menu items", async (assert) => {
baseConfig.serverData.menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [2], name: "My app", appID: 1, actionID: 339 },
};
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
const appsMenu = target.querySelector(".o_navbar_apps_menu");
await click(appsMenu, "button.dropdown-toggle");
const dropdownItem = target.querySelector(".o_navbar_apps_menu .dropdown-item");
assert.strictEqual(dropdownItem.getAttribute("href"), "#menu_id=1&action=339");
});
QUnit.test("many sublevels in app menu items", async (assert) => {
baseConfig.serverData.menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [2], name: "My app", appID: 1 },
2: { id: 2, children: [3], name: "My menu", appID: 1 },
3: { id: 3, children: [4], name: "My submenu 1", appID: 1 },
4: { id: 4, children: [5], name: "My submenu 2", appID: 1 },
5: { id: 5, children: [6], name: "My submenu 3", appID: 1 },
6: { id: 6, children: [7], name: "My submenu 4", appID: 1 },
7: { id: 7, children: [8], name: "My submenu 5", appID: 1 },
8: { id: 8, children: [9], name: "My submenu 6", appID: 1 },
9: { id: 9, children: [], name: "My submenu 7", appID: 1 },
};
const env = await makeTestEnv(baseConfig);
env.services.menu.setCurrentMenu(1);
await mount(NavBar, target, { env });
const firstSectionMenu = target.querySelector(".o_menu_sections .dropdown");
await click(firstSectionMenu, "button.dropdown-toggle");
const menuChildren = [...firstSectionMenu.querySelectorAll(".dropdown-menu > *")];
assert.deepEqual(
menuChildren.map((el) => ({
text: el.textContent,
paddingLeft: el.style.paddingLeft,
tagName: el.tagName,
})),
[
{ text: "My submenu 1", paddingLeft: "20px", tagName: "DIV" },
{ text: "My submenu 2", paddingLeft: "32px", tagName: "DIV" },
{ text: "My submenu 3", paddingLeft: "44px", tagName: "DIV" },
{ text: "My submenu 4", paddingLeft: "56px", tagName: "DIV" },
{ text: "My submenu 5", paddingLeft: "68px", tagName: "DIV" },
{ text: "My submenu 6", paddingLeft: "80px", tagName: "DIV" },
{ text: "My submenu 7", paddingLeft: "92px", tagName: "A" },
]
);
});
QUnit.test("data-menu-xmlid attribute on AppsMenu items", async (assert) => {
baseConfig.serverData.menus = {
root: { id: "root", children: [1, 2], name: "root", appID: "root" },
1: { id: 1, children: [3, 4], name: "App0 with xmlid", appID: 1, xmlid: "wowl" },
2: { id: 2, children: [], name: "App1 without xmlid", appID: 2 },
3: { id: 3, children: [], name: "Menu without children", appID: 1, xmlid: "menu_3" },
4: { id: 4, children: [5], name: "Menu with children", appID: 1, xmlid: "menu_4" },
5: { id: 5, children: [], name: "Sub menu", appID: 1, xmlid: "menu_5" },
};
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
// check apps
const appsMenu = target.querySelector(".o_navbar_apps_menu");
await click(appsMenu, "button.dropdown-toggle");
const menuItems = appsMenu.querySelectorAll("a");
assert.strictEqual(
menuItems[0].dataset.menuXmlid,
"wowl",
"first menu item should have the correct data-menu-xmlid attribute set"
);
assert.strictEqual(
menuItems[1].dataset.menuXmlid,
undefined,
"second menu item should not have any data-menu-xmlid attribute set"
);
// check menus
env.services.menu.setCurrentMenu(1);
await nextTick();
assert.containsOnce(target, ".o_menu_sections .dropdown-item[data-menu-xmlid=menu_3]");
// check sub menus toggler
assert.containsOnce(target, ".o_menu_sections button.dropdown-toggle[data-menu-xmlid=menu_4]");
// check sub menus
await click(target.querySelector(".o_menu_sections .dropdown-toggle"));
assert.containsOnce(target, ".o_menu_sections .dropdown-item[data-menu-xmlid=menu_5]");
});
QUnit.test("navbar can display current active app", async (assert) => {
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
const dropdown = target.querySelector(".o_navbar_apps_menu");
// Open apps menu
await click(dropdown, "button.dropdown-toggle");
assert.containsOnce(
dropdown,
".dropdown-menu .dropdown-item:not(.focus)",
"should not show the current active app as the menus service has not loaded an app yet"
);
// Activate an app
env.services.menu.setCurrentMenu(1);
await nextTick();
assert.containsOnce(
dropdown,
".dropdown-menu .dropdown-item.focus",
"should show the current active app"
);
});
QUnit.test("navbar can display systray items", async (assert) => {
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
assert.containsOnce(target, "li.my-item");
});
QUnit.test("navbar can display systray items ordered based on their sequence", async (assert) => {
class MyItem1 extends Component {}
MyItem1.template = xml`<li class="my-item-1">my item 1</li>`;
class MyItem2 extends Component {}
MyItem2.template = xml`<li class="my-item-2">my item 2</li>`;
class MyItem3 extends Component {}
MyItem3.template = xml`<li class="my-item-3">my item 3</li>`;
class MyItem4 extends Component {}
MyItem4.template = xml`<li class="my-item-4">my item 4</li>`;
clearRegistryWithCleanup(systrayRegistry);
systrayRegistry.add("addon.myitem2", { Component: MyItem2 });
systrayRegistry.add("addon.myitem1", { Component: MyItem1 }, { sequence: 0 });
systrayRegistry.add("addon.myitem3", { Component: MyItem3 }, { sequence: 100 });
systrayRegistry.add("addon.myitem4", { Component: MyItem4 });
const env = await makeTestEnv(baseConfig);
await mount(NavBar, target, { env });
const menuSystray = target.getElementsByClassName("o_menu_systray")[0];
assert.containsN(menuSystray, "li", 4, "four systray items should be displayed");
assert.strictEqual(menuSystray.innerText, "my item 3\nmy item 4\nmy item 2\nmy item 1");
});
QUnit.test("navbar updates after adding a systray item", async (assert) => {
class MyItem1 extends Component {}
MyItem1.template = xml`<li class="my-item-1">my item 1</li>`;
clearRegistryWithCleanup(systrayRegistry);
systrayRegistry.add("addon.myitem1", { Component: MyItem1 });
const env = await makeTestEnv(baseConfig);
patchWithCleanup(NavBar.prototype, {
setup() {
onRendered(() => {
if (!systrayRegistry.contains("addon.myitem2")) {
class MyItem2 extends Component {}
MyItem2.template = xml`<li class="my-item-2">my item 2</li>`;
systrayRegistry.add("addon.myitem2", { Component: MyItem2 });
}
});
this._super();
},
});
await mount(NavBar, target, { env });
const menuSystray = target.getElementsByClassName("o_menu_systray")[0];
assert.containsN(menuSystray, "li", 2, "2 systray items should be displayed");
});
QUnit.test("can adapt with 'more' menu sections behavior", async (assert) => {
class MyNavbar extends NavBar {
async adapt() {
await super.adapt();
const sectionsCount = this.currentAppSections.length;
const hiddenSectionsCount = this.currentAppSectionsExtra.length;
assert.step(`adapt -> hide ${hiddenSectionsCount}/${sectionsCount} sections`);
}
}
const newMenus = {
root: { id: "root", children: [1, 2], name: "root", appID: "root" },
1: { id: 1, children: [10, 11, 12], name: "App0", appID: 1 },
10: { id: 10, children: [], name: "Section 10", appID: 1 },
11: { id: 11, children: [], name: "Section 11", appID: 1 },
12: { id: 12, children: [120, 121, 122], name: "Section 12", appID: 1 },
120: { id: 120, children: [], name: "Section 120", appID: 1 },
121: { id: 121, children: [], name: "Section 121", appID: 1 },
122: { id: 122, children: [], name: "Section 122", appID: 1 },
};
baseConfig.serverData.menus = newMenus;
const env = await makeTestEnv(baseConfig);
// Force the parent width, to make this test independent of screen size
target.style.width = "1080px";
// Set menu and mount
env.services.menu.setCurrentMenu(1);
await mount(MyNavbar, target, { env });
assert.containsN(
target,
".o_menu_sections > *:not(.o_menu_sections_more):not(.d-none)",
3,
"should have 3 menu sections displayed (that are not the 'more' menu)"
);
assert.containsNone(target, ".o_menu_sections_more", "the 'more' menu should not exist");
// Force minimal width and dispatch window resize event
target.style.width = "0%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.containsOnce(
target,
".o_menu_sections > *:not(.d-none)",
"only one menu section should be displayed"
);
assert.containsOnce(
target,
".o_menu_sections_more:not(.d-none)",
"the displayed menu section should be the 'more' menu"
);
// Open the more menu
await click(target, ".o_menu_sections_more .dropdown-toggle");
assert.deepEqual(
[...target.querySelectorAll(".dropdown-menu > *")].map((el) => el.textContent),
["Section 10", "Section 11", "Section 12", "Section 120", "Section 121", "Section 122"],
"'more' menu should contain all hidden sections in correct order"
);
// Reset to full width and dispatch window resize event
target.style.width = "100%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.containsN(
target,
".o_menu_sections > *:not(.o_menu_sections_more):not(.d-none)",
3,
"should have 3 menu sections displayed (that are not the 'more' menu)"
);
assert.containsNone(target, ".o_menu_sections_more", "the 'more' menu should not exist");
// Check the navbar adaptation calls
assert.verifySteps([
"adapt -> hide 0/3 sections",
"adapt -> hide 3/3 sections",
"adapt -> hide 0/3 sections",
]);
});
QUnit.test(
"'more' menu sections adaptations do not trigger render in some cases",
async (assert) => {
let adaptRunning = false;
let adaptCount = 0;
let adaptRenderCount = 0;
class MyNavbar extends NavBar {
async adapt() {
adaptRunning = true;
adaptCount++;
await super.adapt();
adaptRunning = false;
}
async render() {
if (adaptRunning) {
adaptRenderCount++;
}
await super.render(...arguments);
}
}
const newMenus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [11, 12, 13], name: "App1", appID: 1 },
11: { id: 11, children: [], name: "Section 1", appID: 1 },
12: { id: 12, children: [], name: "Section 2", appID: 1 },
13: { id: 13, children: [], name: "Section 3", appID: 1 },
};
baseConfig.serverData.menus = newMenus;
// Force the parent width, to make this test independent of screen size
target.style.width = "600px";
const env = await makeTestEnv(baseConfig);
const navbar = await mount(MyNavbar, target, { env });
assert.strictEqual(navbar.currentAppSections.length, 0, "0 app sub menus");
assert.strictEqual(target.querySelector(".o_navbar").offsetWidth, 600);
assert.strictEqual(adaptCount, 1);
assert.strictEqual(
adaptRenderCount,
0,
"during adapt, render not triggered as the navbar has no app sub menus"
);
// Force minimal width and dispatch window resize event
target.querySelector(".o_navbar").style.width = "0%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.strictEqual(target.querySelector(".o_navbar").offsetWidth, 0);
assert.strictEqual(adaptCount, 2);
assert.strictEqual(
adaptRenderCount,
0,
"during adapt, render not triggered as the navbar has no app sub menus"
);
// Set menu
env.services.menu.setCurrentMenu(1);
await nextTick();
assert.strictEqual(navbar.currentAppSections.length, 3, "3 app sub menus");
assert.strictEqual(
navbar.currentAppSectionsExtra.length,
3,
"all app sub menus are inside the more menu"
);
assert.strictEqual(adaptCount, 3);
assert.strictEqual(
adaptRenderCount,
1,
"during adapt, render triggered as the navbar does not have enough space for app sub menus"
);
// Force 40% width and dispatch window resize event
target.querySelector(".o_navbar").style.width = "40%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.strictEqual(
navbar.currentAppSectionsExtra.length,
3,
"all app sub menus are STILL inside the more menu"
);
assert.strictEqual(adaptCount, 4);
assert.strictEqual(
adaptRenderCount,
1,
"during adapt, render not triggered as the more menu dropdown is STILL the same"
);
// Reset to full width and dispatch window resize event
target.querySelector(".o_navbar").style.width = "100%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.strictEqual(navbar.currentAppSections.length, 3, "still 3 app sub menus");
assert.strictEqual(
navbar.currentAppSectionsExtra.length,
0,
"all app sub menus are NO MORE inside the more menu"
);
assert.strictEqual(adaptCount, 5);
assert.strictEqual(
adaptRenderCount,
2,
"during adapt, render triggered as the more menu dropdown is NO MORE the same"
);
}
);
QUnit.test("'more' menu sections properly updated on app change", async (assert) => {
const newMenus = {
root: { id: "root", children: [1, 2], name: "root", appID: "root" },
// First App
1: { id: 1, children: [10, 11, 12], name: "App1", appID: 1 },
10: { id: 10, children: [], name: "Section 10", appID: 1 },
11: { id: 11, children: [], name: "Section 11", appID: 1 },
12: { id: 12, children: [120, 121, 122], name: "Section 12", appID: 1 },
120: { id: 120, children: [], name: "Section 120", appID: 1 },
121: { id: 121, children: [], name: "Section 121", appID: 1 },
122: { id: 122, children: [], name: "Section 122", appID: 1 },
// Second App
2: { id: 2, children: [20, 21, 22], name: "App2", appID: 2 },
20: { id: 20, children: [], name: "Section 20", appID: 2 },
21: { id: 21, children: [], name: "Section 21", appID: 2 },
22: { id: 22, children: [220, 221, 222], name: "Section 22", appID: 2 },
220: { id: 220, children: [], name: "Section 220", appID: 2 },
221: { id: 221, children: [], name: "Section 221", appID: 2 },
222: { id: 222, children: [], name: "Section 222", appID: 2 },
};
baseConfig.serverData.menus = newMenus;
const env = await makeTestEnv(baseConfig);
// Force the parent width, to make this test independent of screen size
target.style.width = "1080px";
// Set App1 menu and mount
env.services.menu.setCurrentMenu(1);
await mount(NavBar, target, { env });
// Force minimal width and dispatch window resize event
target.style.width = "0%";
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.containsOnce(
target,
".o_menu_sections > *:not(.d-none)",
"only one menu section should be displayed"
);
assert.containsOnce(
target,
".o_menu_sections_more:not(.d-none)",
"the displayed menu section should be the 'more' menu"
);
// Open the more menu
await click(target, ".o_menu_sections_more .dropdown-toggle");
assert.deepEqual(
[...target.querySelectorAll(".dropdown-menu > *")].map((el) => el.textContent),
["Section 10", "Section 11", "Section 12", "Section 120", "Section 121", "Section 122"],
"'more' menu should contain App1 sections"
);
// Close the more menu
await click(target, ".o_menu_sections_more .dropdown-toggle");
// Set App2 menu
env.services.menu.setCurrentMenu(2);
await nextTick();
// Open the more menu
await click(target, ".o_menu_sections_more .dropdown-toggle");
assert.deepEqual(
[...target.querySelectorAll(".dropdown-menu > *")].map((el) => el.textContent),
["Section 20", "Section 21", "Section 22", "Section 220", "Section 221", "Section 222"],
"'more' menu should contain App2 sections"
);
});
QUnit.test("Do not execute adapt when navbar is destroyed", async (assert) => {
assert.expect(5);
const { execRegisteredTimeouts } = mockTimeout();
class MyNavbar extends NavBar {
async adapt() {
assert.step("adapt NavBar");
return super.adapt();
}
}
const env = await makeTestEnv(baseConfig);
// Set menu and mount
env.services.menu.setCurrentMenu(1);
const navbar = await mount(MyNavbar, target, { env });
assert.verifySteps(["adapt NavBar"]);
window.dispatchEvent(new Event("resize"));
execRegisteredTimeouts();
assert.verifySteps(["adapt NavBar"]);
window.dispatchEvent(new Event("resize"));
destroy(navbar);
execRegisteredTimeouts();
assert.verifySteps([]);
});

View file

@ -0,0 +1,131 @@
/** @odoo-module **/
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("SettingsUpgradeBoolean", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
"res.config.settings": {
fields: {
bar: { string: "Bar", type: "boolean" },
},
},
},
};
target = getFixture();
setupViewRegistries();
});
QUnit.test("widget upgrade_boolean in a form view - dialog", async function (assert) {
await makeView({
type: "form",
arch: `
<form js_class="base_settings">
<field name="bar" widget="upgrade_boolean"/>
</form>`,
serverData,
resModel: "res.config.settings",
});
await click(target.querySelector(".o-checkbox .form-check-input"));
assert.containsOnce(
target,
".o_dialog .modal",
"the 'Upgrade to Enterprise' dialog should be opened"
);
});
QUnit.test("widget upgrade_boolean in a form view - label", async function (assert) {
await makeView({
type: "form",
arch: `
<form js_class="base_settings">
<div class="o_field">
<field name="bar" widget="upgrade_boolean"/>
</div>
<div class="o_label"><label for="bar"/><div>Coucou</div></div>
</form>`,
serverData,
resModel: "res.config.settings",
});
assert.containsNone(
target,
".o_field .badge",
"the upgrade badge shouldn't be inside the field section"
);
assert.containsOnce(
target,
".o_label .badge",
"the upgrade badge should be inside the label section"
);
assert.strictEqual(
target.querySelector(".o_label").textContent,
"BarEnterpriseCoucou",
"the upgrade label should be inside the label section"
);
});
QUnit.test(
"widget upgrade_boolean in a form view - dialog (enterprise version)",
async function (assert) {
patchWithCleanup(odoo, { info: { isEnterprise: 1 } });
await makeView({
type: "form",
arch: `
<form js_class="base_settings">
<field name="bar" widget="upgrade_boolean"/>
</form>`,
serverData,
resModel: "res.config.settings",
});
await click(target.querySelector(".o-checkbox .form-check-input"));
assert.containsNone(
target,
".o_dialog .modal",
"the 'Upgrade to Enterprise' dialog shouldn't be opened"
);
}
);
QUnit.test(
"widget upgrade_boolean in a form view - label (enterprise version)",
async function (assert) {
patchWithCleanup(odoo, { info: { isEnterprise: 1 } });
await makeView({
type: "form",
arch: `
<form js_class="base_settings">
<div class="o_field">
<field name="bar" widget="upgrade_boolean"/>
</div>
<div class="o_label"><label for="bar"/><div>Coucou</div></div>
</form>`,
serverData,
resModel: "res.config.settings",
});
assert.containsNone(
target,
".o_field .badge",
"the upgrade badge shouldn't be inside the field section"
);
assert.containsNone(
target,
".o_label .badge",
"the upgrade badge shouldn't be inside the label section"
);
assert.strictEqual(
target.querySelector(".o_label").textContent,
"BarCoucou",
"the label shouldn't contains the upgrade label"
);
}
);
});

View file

@ -0,0 +1,333 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { SwitchCompanyMenu } from "@web/webclient/switch_company_menu/switch_company_menu";
import { makeTestEnv } from "../helpers/mock_env";
import { companyService } from "@web/webclient/company_service";
import { click, getFixture, makeDeferred, mount, patchWithCleanup } from "../helpers/utils";
import { uiService } from "@web/core/ui/ui_service";
import { session } from "@web/session";
const serviceRegistry = registry.category("services");
let target;
const ORIGINAL_TOGGLE_DELAY = SwitchCompanyMenu.toggleDelay;
async function createSwitchCompanyMenu(routerParams = {}, toggleDelay = 0) {
patchWithCleanup(SwitchCompanyMenu, { toggleDelay });
if (routerParams.onPushState) {
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState(state, title, url) {
pushState(...arguments);
if (routerParams.onPushState) {
routerParams.onPushState(url);
}
},
}),
});
}
const env = await makeTestEnv();
const scMenu = await mount(SwitchCompanyMenu, target, { env });
return scMenu;
}
QUnit.module("SwitchCompanyMenu", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(session.user_companies, {
allowed_companies: {
3: { id: 3, name: "Hermit", sequence: 1 },
2: { id: 2, name: "Herman's", sequence: 2 },
1: { id: 1, name: "Heroes TM", sequence: 3 },
},
current_company: 3,
});
serviceRegistry.add("ui", uiService);
serviceRegistry.add("company", companyService);
serviceRegistry.add("hotkey", hotkeyService);
target = getFixture();
});
QUnit.test("basic rendering", async (assert) => {
assert.expect(9);
await createSwitchCompanyMenu();
assert.containsOnce(target, "div.o_switch_company_menu");
assert.strictEqual(target.querySelector("div.o_switch_company_menu").textContent, "Hermit");
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, ".toggle_company", 3);
assert.containsN(target, ".log_into", 3);
assert.containsOnce(target, ".fa-check-square");
assert.containsN(target, ".fa-square-o", 2);
assert.strictEqual(
target.querySelector(".fa-check-square").closest(".dropdown-item").textContent,
"Hermit"
);
assert.strictEqual(
target.querySelector(".fa-square-o").closest(".dropdown-item").textContent,
"Herman's"
);
assert.strictEqual(
target.querySelector(".dropdown-menu").textContent,
"HermitHerman'sHeroes TM"
);
});
QUnit.test("companies can be toggled: toggle a second company", async (assert) => {
const prom = makeDeferred();
function onPushState(url) {
assert.step(url.split("#")[1]);
prom.resolve();
}
const scMenu = await createSwitchCompanyMenu({ onPushState });
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 1);
assert.containsN(target, "[data-company-id] .fa-square-o", 2);
assert.deepEqual(
[...target.querySelectorAll("[data-company-id] .toggle_company")].map(
(el) => el.ariaChecked
),
["true", "false", "false"]
);
assert.deepEqual(
[...target.querySelectorAll("[data-company-id] .log_into")].map((el) => el.ariaPressed),
["true", "false", "false"]
);
/**
* [x] **Hermit**
* [x] Herman's -> toggle
* [ ] Heroes TM
*/
await click(target.querySelectorAll(".toggle_company")[1]);
assert.containsOnce(target, ".dropdown-menu", "dropdown is still opened");
assert.containsN(target, "[data-company-id] .fa-check-square", 2);
assert.containsN(target, "[data-company-id] .fa-square-o", 1);
assert.deepEqual(
[...target.querySelectorAll("[data-company-id] .toggle_company")].map(
(el) => el.ariaChecked
),
["true", "true", "false"]
);
assert.deepEqual(
[...target.querySelectorAll("[data-company-id] .log_into")].map((el) => el.ariaPressed),
["true", "false", "false"]
);
await prom;
assert.verifySteps(["cids=3%2C2"]);
});
QUnit.test("can toggle multiple companies at once", async (assert) => {
assert.expect(11);
const prom = makeDeferred();
function onPushState(url) {
assert.step(url.split("#")[1]);
prom.resolve();
}
const scMenu = await createSwitchCompanyMenu({ onPushState }, ORIGINAL_TOGGLE_DELAY);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 1);
assert.containsN(target, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Hermit** -> toggle all
* [x] Herman's -> toggle all
* [x] Heroes TM -> toggle all
*/
await click(target.querySelectorAll(".toggle_company")[0]);
await click(target.querySelectorAll(".toggle_company")[1]);
await click(target.querySelectorAll(".toggle_company")[2]);
assert.containsOnce(target, ".dropdown-menu", "dropdown is still opened");
assert.containsN(target, "[data-company-id] .fa-check-square", 2);
assert.containsN(target, "[data-company-id] .fa-square-o", 1);
assert.verifySteps([]);
await prom; // await toggle promise
assert.verifySteps(["cids=2%2C1"]);
});
QUnit.test("single company selected: toggling it off will keep it", async (assert) => {
assert.expect(12);
patchWithCleanup(browser, {
setTimeout(fn) {
return fn(); // s.t. we can directly assert changes in the hash
},
});
const scMenu = await createSwitchCompanyMenu();
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
*/
assert.deepEqual(scMenu.env.services.router.current.hash, { cids: 3 });
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 1);
assert.containsN(target, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Hermit** -> toggle off
* [ ] Herman's
* [ ] Heroes TM
*/
await click(target.querySelectorAll(".toggle_company")[0]);
assert.deepEqual(scMenu.env.services.router.current.hash, { cids: 3 });
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
assert.containsOnce(target, ".dropdown-menu", "dropdown is still opened");
assert.containsN(target, "[data-company-id] .fa-check-square", 0);
assert.containsN(target, "[data-company-id] .fa-square-o", 3);
});
QUnit.test("single company mode: companies can be logged in", async (assert) => {
assert.expect(8);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
const scMenu = await createSwitchCompanyMenu({ onPushState });
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 1);
assert.containsN(target, "[data-company-id] .fa-square-o", 2);
/**
* [x] **Hermit**
* [ ] Herman's -> log into
* [ ] Heroes TM
*/
await click(target.querySelectorAll(".log_into")[1]);
assert.containsNone(target, ".dropdown-menu", "dropdown is directly closed");
assert.verifySteps(["cids=2"]);
});
QUnit.test("multi company mode: log into a non selected company", async (assert) => {
assert.expect(8);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
Object.assign(browser.location, { hash: "cids=3%2C1" });
const scMenu = await createSwitchCompanyMenu({ onPushState });
/**
* [x] Hermit
* [ ] Herman's
* [x] **Heroes TM**
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3, 1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 2);
assert.containsN(target, "[data-company-id] .fa-square-o", 1);
/**
* [x] Hermit
* [ ] Herman's -> log into
* [x] **Heroes TM**
*/
await click(target.querySelectorAll(".log_into")[1]);
assert.containsNone(target, ".dropdown-menu", "dropdown is directly closed");
assert.verifySteps(["cids=2%2C3%2C1"]);
});
QUnit.test("multi company mode: log into an already selected company", async (assert) => {
assert.expect(8);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
Object.assign(browser.location, { hash: "cids=2%2C1" });
const scMenu = await createSwitchCompanyMenu({ onPushState });
/**
* [ ] Hermit
* [x] **Herman's**
* [x] Heroes TM
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [2, 1]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 2);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 2);
assert.containsN(target, "[data-company-id] .fa-square-o", 1);
/**
* [ ] Hermit
* [x] **Herman's**
* [x] Heroes TM -> log into
*/
await click(target.querySelectorAll(".log_into")[2]);
assert.containsNone(target, ".dropdown-menu", "dropdown is directly closed");
assert.verifySteps(["cids=1%2C2"]);
});
QUnit.test("companies can be logged in even if some toggled within delay", async (assert) => {
assert.expect(8);
function onPushState(url) {
assert.step(url.split("#")[1]);
}
const scMenu = await createSwitchCompanyMenu({ onPushState }, ORIGINAL_TOGGLE_DELAY);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
*/
assert.deepEqual(scMenu.env.services.company.allowedCompanyIds, [3]);
assert.strictEqual(scMenu.env.services.company.currentCompany.id, 3);
await click(target.querySelector(".dropdown-toggle"));
assert.containsN(target, "[data-company-id]", 3);
assert.containsN(target, "[data-company-id] .fa-check-square", 1);
assert.containsN(target, "[data-company-id] .fa-square-o", 2);
/**
* [ ] **Hermit** -> toggled
* [ ] Herman's -> logged in
* [ ] Heroes TM -> toggled
*/
await click(target.querySelectorAll(".toggle_company")[2]);
await click(target.querySelectorAll(".toggle_company")[0]);
await click(target.querySelectorAll(".log_into")[1]);
assert.containsNone(target, ".dropdown-menu", "dropdown is directly closed");
assert.verifySteps(["cids=2"]);
});
});

View file

@ -0,0 +1,174 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { ormService } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { UserMenu } from "@web/webclient/user_menu/user_menu";
import { preferencesItem } from "@web/webclient/user_menu/user_menu_items";
import { userService } from "@web/core/user_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { click, getFixture, mount, patchWithCleanup } from "@web/../tests/helpers/utils";
import { session } from "@web/session";
const serviceRegistry = registry.category("services");
const userMenuRegistry = registry.category("user_menuitems");
let target;
let env;
QUnit.module("UserMenu", {
async beforeEach() {
patchWithCleanup(session, { name: "Sauron" });
patchWithCleanup(browser, {
location: {
origin: "http://lordofthering",
},
});
serviceRegistry.add("user", userService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
target = getFixture();
},
});
QUnit.test("can be rendered", async (assert) => {
env = await makeTestEnv();
userMenuRegistry.add("bad_item", function () {
return {
type: "item",
id: "bad",
description: "Bad",
callback: () => {
assert.step("callback bad_item");
},
sequence: 10,
};
});
userMenuRegistry.add("ring_item", function () {
return {
type: "item",
id: "ring",
description: "Ring",
callback: () => {
assert.step("callback ring_item");
},
sequence: 5,
};
});
userMenuRegistry.add("frodo_item", function () {
return {
type: "switch",
id: "frodo",
description: "Frodo",
callback: () => {
assert.step("callback frodo_item");
},
sequence: 11,
};
});
userMenuRegistry.add("separator", function () {
return {
type: "separator",
sequence: 15,
};
});
userMenuRegistry.add("invisible_item", function () {
return {
type: "item",
id: "hidden",
description: "Hidden Power",
callback: () => {},
sequence: 5,
hide: true,
};
});
userMenuRegistry.add("eye_item", function () {
return {
type: "item",
id: "eye",
description: "Eye",
callback: () => {
assert.step("callback eye_item");
},
};
});
await mount(UserMenu, target, { env });
assert.containsOnce(target, "img.o_user_avatar");
assert.strictEqual(
target.querySelector("img.o_user_avatar").dataset.src,
"http://lordofthering/web/image?model=res.users&field=avatar_128&id=7"
);
assert.containsOnce(target, "span.oe_topbar_name");
assert.strictEqual(target.querySelector(".oe_topbar_name").textContent, "Sauron");
assert.containsNone(target, ".dropdown-menu .dropdown-item");
await click(target.querySelector("button.dropdown-toggle"));
assert.containsN(target, ".dropdown-menu .dropdown-item", 4);
assert.containsOnce(target, ".dropdown-menu .dropdown-item input.form-check-input");
assert.containsOnce(target, "div.dropdown-divider");
const children = [...(target.querySelector(".dropdown-menu").children || [])];
assert.deepEqual(
children.map((el) => el.tagName),
["SPAN", "SPAN", "SPAN", "DIV", "SPAN"]
);
const items = [...target.querySelectorAll(".dropdown-menu .dropdown-item")] || [];
assert.deepEqual(
items.map((el) => el.dataset.menu),
["ring", "bad", "frodo", "eye"]
);
assert.deepEqual(
items.map((el) => el.textContent),
["Ring", "Bad", "Frodo", "Eye"]
);
for (const item of items) {
click(item);
}
assert.verifySteps(["callback ring_item", "callback bad_item", "callback frodo_item", "callback eye_item"]);
});
QUnit.test("display the correct name in debug mode", async (assert) => {
patchWithCleanup(odoo, { debug: "1" });
env = await makeTestEnv();
await mount(UserMenu, target, { env });
assert.containsOnce(target, "img.o_user_avatar");
assert.containsOnce(target, "span.oe_topbar_name");
assert.strictEqual(target.querySelector(".oe_topbar_name").textContent, "Sauron (test)");
});
QUnit.test("can execute the callback of settings", async (assert) => {
const mockRPC = (route) => {
if (route === "/web/dataset/call_kw/res.users/action_get") {
return Promise.resolve({
name: "Change My Preferences",
res_id: 0,
});
}
};
const testConfig = { mockRPC };
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("orm", ormService);
const fakeActionService = {
name: "action",
start() {
return {
doAction(actionId) {
assert.step("" + actionId.res_id);
assert.step(actionId.name);
return Promise.resolve(true);
},
};
},
};
serviceRegistry.add("action", fakeActionService, { force: true });
env = await makeTestEnv(testConfig);
userMenuRegistry.add("profile", preferencesItem);
await mount(UserMenu, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
assert.containsOnce(target, ".dropdown-menu .dropdown-item");
const item = target.querySelector(".dropdown-menu .dropdown-item");
assert.strictEqual(item.textContent, "Preferences");
await click(item);
assert.verifySteps(["7", "Change My Preferences"]);
});

View file

@ -0,0 +1,109 @@
/** @odoo-module **/
import { dialogService } from "@web/core/dialog/dialog_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { ormService } from "@web/core/orm_service";
import { uiService } from "@web/core/ui/ui_service";
import { viewService } from "@web/views/view_service";
import { legacyServiceProvider } from "@web/legacy/legacy_service_provider";
import { actionService } from "@web/webclient/actions/action_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { WebClient } from "@web/webclient/webclient";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { fakeTitleService } from "../helpers/mock_services";
import { destroy, getFixture, mount, patchWithCleanup, triggerEvent } from "../helpers/utils";
import { Component, xml } from "@odoo/owl";
const mainComponentRegistry = registry.category("main_components");
const serviceRegistry = registry.category("services");
let baseConfig;
let target;
QUnit.module("WebClient", {
async beforeEach() {
serviceRegistry
.add("action", actionService)
.add("dialog", dialogService)
.add("hotkey", hotkeyService)
.add("legacy_service_provider", legacyServiceProvider)
.add("menu", menuService)
.add("notification", notificationService)
.add("popover", popoverService)
.add("title", fakeTitleService)
.add("ui", uiService)
.add("view", viewService) // #action-serv-leg-compat-js-class
.add("orm", ormService); // #action-serv-leg-compat-js-class
baseConfig = { activateMockServer: true };
target = getFixture();
},
});
QUnit.test("can be rendered", async (assert) => {
assert.expect(1);
const env = await makeTestEnv(baseConfig);
await mount(WebClient, target, { env });
assert.containsOnce(target, "header > nav.o_main_navbar");
});
QUnit.test("can render a main component", async (assert) => {
assert.expect(1);
class MyComponent extends Component {}
MyComponent.template = xml`<span class="chocolate">MyComponent</span>`;
clearRegistryWithCleanup(mainComponentRegistry);
mainComponentRegistry.add("mycomponent", { Component: MyComponent });
const env = await makeTestEnv(baseConfig);
await mount(WebClient, target, { env });
assert.containsOnce(target, ".chocolate");
});
QUnit.test("control-click propagation stopped on <a href/>", async (assert) => {
assert.expect(8);
patchWithCleanup(WebClient.prototype, {
/** @param {MouseEvent} ev */
onGlobalClick(ev) {
this._super(ev);
if (ev.ctrlKey) {
assert.ok(
ev.defaultPrevented === false,
"the global click should not prevent the default behavior on ctrl-click an <a href/>"
);
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
},
});
class MyComponent extends Component {
/** @param {MouseEvent} ev */
onclick(ev) {
assert.step(ev.ctrlKey ? "ctrl-click" : "click");
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
}
MyComponent.template = xml`<a href="#" class="MyComponent" t-on-click="onclick">Some link</a>`;
let env = await makeTestEnv(baseConfig);
// Mount the component as standalone and control-click the <a href/>
const standaloneComponent = await mount(MyComponent, target, { env });
assert.verifySteps([]);
await triggerEvent(target.querySelector(".MyComponent"), "", "click", { ctrlKey: false });
await triggerEvent(target.querySelector(".MyComponent"), "", "click", { ctrlKey: true });
assert.verifySteps(["click", "ctrl-click"]);
destroy(standaloneComponent);
// Register the component as a main one, mount the webclient and control-click the <a href/>
clearRegistryWithCleanup(mainComponentRegistry);
mainComponentRegistry.add("mycomponent", { Component: MyComponent });
env = await makeTestEnv(baseConfig);
await mount(WebClient, target, { env });
assert.verifySteps([]);
await triggerEvent(target, ".MyComponent", "click", { ctrlKey: false });
await triggerEvent(target, ".MyComponent", "click", { ctrlKey: true });
assert.verifySteps(["click"]);
});