vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View file

@ -0,0 +1,465 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, onMounted, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineModels,
getService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
stepAllNetworkCalls,
webModels,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { redirect } from "@web/core/utils/urls";
import { WebClient } from "@web/webclient/webclient";
const { ResCompany, ResPartner, ResUsers } = webModels;
const actionRegistry = registry.category("actions");
class TestClientAction extends Component {
static template = xml`
<div class="test_client_action">
ClientAction_<t t-esc="props.action.params?.description"/>
</div>`;
static props = ["*"];
setup() {
onMounted(() => this.env.config.setDisplayName(`Client action ${this.props.action.id}`));
}
}
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
form: /* xml */ `
<form>
<group>
<field name="display_name"/>
</group>
</form>
`,
"kanban,1": /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>
`,
list: /* xml */ `
<list>
<field name="display_name" />
</list>
`,
};
}
defineModels([Partner, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
]);
beforeEach(() => {
actionRegistry.add("__test__client__action__", TestClientAction);
});
test("can display client actions in Dialog", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Dialog Test",
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
expect(".modal .test_client_action").toHaveCount(1);
expect(".modal-title").toHaveText("Dialog Test");
});
test("can display client actions in Dialog and close the dialog", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Dialog Test",
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
expect(".modal .test_client_action").toHaveCount(1);
expect(".modal-title").toHaveText("Dialog Test");
await contains(".modal footer .btn.btn-primary").click();
expect(".modal .test_client_action").toHaveCount(0);
});
test("can display client actions as main, then in Dialog", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction("__test__client__action__");
expect(".o_action_manager .test_client_action").toHaveCount(1);
await getService("action").doAction({
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
expect(".o_action_manager .test_client_action").toHaveCount(1);
expect(".modal .test_client_action").toHaveCount(1);
});
test("can display client actions in Dialog, then as main destroys Dialog", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
target: "new",
tag: "__test__client__action__",
type: "ir.actions.client",
});
expect(".test_client_action").toHaveCount(1);
expect(".modal .test_client_action").toHaveCount(1);
await getService("action").doAction("__test__client__action__");
expect(".test_client_action").toHaveCount(1);
expect(".modal .test_client_action").toHaveCount(0);
});
test("soft_reload will refresh data", async () => {
onRpc("web_search_read", () => {
expect.step("web_search_read");
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect.verifySteps(["web_search_read"]);
await getService("action").doAction("soft_reload");
expect.verifySteps(["web_search_read"]);
});
test("soft_reload a form view", async () => {
onRpc("web_read", ({ args }) => {
expect.step(`read ${args[0][0]}`);
});
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[false, "form"],
],
type: "ir.actions.act_window",
});
await contains(".o_data_row .o_data_cell").click();
await contains(".o_form_view .o_pager_next").click();
expect.verifySteps(["read 1", "read 2"]);
await getService("action").doAction("soft_reload");
expect.verifySteps(["read 2"]);
});
test("soft_reload when there is no controller", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction("soft_reload");
expect(true).toBe(true, {
message: "No ControllerNotFoundError when there is no controller to restore",
});
});
test("can execute client actions from tag name", async () => {
class ClientAction extends Component {
static template = xml`<div class="o_client_action_test">Hello World</div>`;
static props = ["*"];
}
actionRegistry.add("HelloWorldTest", ClientAction);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction("HelloWorldTest");
expect(".o_control_panel").toHaveCount(0);
expect(".o_client_action_test").toHaveText("Hello World");
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
});
test("async client action (function) returning another action", async () => {
actionRegistry.add("my_action", async () => {
await Promise.resolve();
return 1; // execute action 1
});
await mountWithCleanup(WebClient);
await getService("action").doAction("my_action");
expect(".o_kanban_view").toHaveCount(1);
});
test("'CLEAR-UNCOMMITTED-CHANGES' is not triggered for function client actions", async () => {
actionRegistry.add("my_action", () => {
expect.step("my_action");
});
const webClient = await mountWithCleanup(WebClient);
webClient.env.bus.addEventListener("CLEAR-UNCOMMITTED-CHANGES", () => {
expect.step("CLEAR-UNCOMMITTED-CHANGES");
});
await getService("action").doAction("my_action");
expect.verifySteps(["my_action"]);
});
test.tags("desktop");
test("ClientAction receives breadcrumbs and exports title", async () => {
expect.assertions(4);
class ClientAction extends Component {
static template = xml`<div class="my_action" t-on-click="onClick">client action</div>`;
static props = ["*"];
setup() {
this.breadcrumbTitle = "myAction";
const { breadcrumbs } = this.env.config;
expect(breadcrumbs).toHaveLength(2);
expect(breadcrumbs[0].name).toBe("Partners Action 1");
onMounted(() => {
this.env.config.setDisplayName(this.breadcrumbTitle);
});
}
onClick() {
this.breadcrumbTitle = "newTitle";
this.env.config.setDisplayName(this.breadcrumbTitle);
}
}
actionRegistry.add("SomeClientAction", ClientAction);
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await getService("action").doAction("SomeClientAction");
expect(".my_action").toHaveCount(1);
await contains(".my_action").click();
await getService("action").doAction(3);
expect(".o_breadcrumb").toHaveText("Partners Action 1\nnewTitle\nPartners");
});
test("ClientAction receives arbitrary props from doAction", async () => {
expect.assertions(1);
class ClientAction extends Component {
static template = xml`<div></div>`;
static props = ["*"];
setup() {
expect(this.props.division).toBe("bell");
}
}
actionRegistry.add("SomeClientAction", ClientAction);
await mountWithCleanup(WebClient);
await getService("action").doAction("SomeClientAction", {
props: { division: "bell" },
});
});
test("test display_notification client action", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
await getService("action").doAction({
type: "ir.actions.client",
tag: "display_notification",
params: {
title: "title",
message: "message",
sticky: true,
},
});
await animationFrame(); // wait for the notification to be displayed
expect(".o_notification_manager .o_notification").toHaveCount(1);
expect(".o_notification_manager .o_notification .o_notification_title").toHaveText("title");
expect(".o_notification_manager .o_notification .o_notification_content").toHaveText("message");
expect(".o_kanban_view").toHaveCount(1);
await contains(".o_notification_close").click();
expect(".o_notification_manager .o_notification").toHaveCount(0);
});
test("test display_notification client action with links", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
await getService("action").doAction({
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",
},
],
},
});
await animationFrame(); // wait for the notification to be displayed
expect(".o_notification_manager .o_notification").toHaveCount(1);
expect(".o_notification_manager .o_notification .o_notification_title").toHaveText("title");
expect(".o_notification_manager .o_notification .o_notification_content").toHaveText(
"message test <R&D> <R&D>"
);
expect(".o_kanban_view").toHaveCount(1);
await contains(".o_notification_close").click();
expect(".o_notification_manager .o_notification").toHaveCount(0);
// display_notification without title
await getService("action").doAction({
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",
},
],
},
});
await animationFrame(); // wait for the notification to be displayed
expect(".o_notification_manager .o_notification").toHaveCount(1);
expect(".o_notification_manager .o_notification .o_notification_title").toHaveCount(0);
});
test("test next action on display_notification client action", async () => {
await mountWithCleanup(WebClient);
const options = {
onClose: function () {
expect.step("onClose");
},
};
await getService("action").doAction(
{
type: "ir.actions.client",
tag: "display_notification",
params: {
title: "title",
message: "message",
sticky: true,
next: {
type: "ir.actions.act_window_close",
},
},
},
options
);
await animationFrame(); // wait for the notification to be displayed
expect(".o_notification_manager .o_notification").toHaveCount(1);
expect.verifySteps(["onClose"]);
});
test("test reload client action", async () => {
redirect("/odoo?test=42");
browser.location.search = "?test=42";
patchWithCleanup(browser.history, {
pushState: (_state, _unused, url) => {
expect.step(`pushState ${url.replace(browser.location.origin, "")}`);
},
replaceState: (_state, _unused, url) => {
expect.step(`replaceState ${url.replace(browser.location.origin, "")}`);
},
});
patchWithCleanup(browser.location, {
reload: function () {
expect.step("window_reload");
},
});
await mountWithCleanup(WebClient);
await runAllTimers();
await getService("action").doAction({
type: "ir.actions.client",
tag: "reload",
});
await runAllTimers();
await getService("action").doAction({
type: "ir.actions.client",
tag: "reload",
params: {
action_id: 2,
},
});
await runAllTimers();
await getService("action").doAction({
type: "ir.actions.client",
tag: "reload",
params: {
menu_id: 1,
},
});
await runAllTimers();
await getService("action").doAction({
type: "ir.actions.client",
tag: "reload",
params: {
action_id: 1,
menu_id: 2,
},
});
await runAllTimers();
expect.verifySteps([
"replaceState /odoo?test=42",
"window_reload",
"pushState /odoo/action-2",
"window_reload",
"pushState /odoo?menu_id=1",
"window_reload",
"pushState /odoo/action-1?menu_id=2",
"window_reload",
]);
});
test("test home client action", async () => {
redirect("/odoo");
browser.location.search = "";
patchWithCleanup(browser.location, {
assign: (url) => expect.step(`assign ${url}`),
});
onRpc("/web/webclient/version_info", () => {
expect.step("/web/webclient/version_info");
return true;
});
await mountWithCleanup(WebClient);
await getService("action").doAction({
type: "ir.actions.client",
tag: "home",
});
await runAllTimers();
await animationFrame();
expect.verifySteps(["/web/webclient/version_info", "assign /"]);
});

View file

@ -0,0 +1,245 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import {
contains,
defineActions,
defineModels,
findComponent,
getService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
webModels,
} from "@web/../tests/web_test_helpers";
import { formView } from "@web/views/form/form_view";
import { listView } from "@web/views/list/list_view";
import { WebClient } from "@web/webclient/webclient";
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
form: `
<form>
<group>
<field name="display_name"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="display_name"/></list>`,
};
}
defineModels([Partner, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
]);
test("close the currently opened dialog", async () => {
await mountWithCleanup(WebClient);
// execute an action in target="new"
await getService("action").doAction(5);
expect(".o_technical_modal .o_form_view").toHaveCount(1);
// execute an 'ir.actions.act_window_close' action
await getService("action").doAction({
type: "ir.actions.act_window_close",
});
expect(".o_technical_modal .o_form_view").toHaveCount(0);
});
test("close dialog by clicking on the header button", async () => {
await mountWithCleanup(WebClient);
// execute an action in target="new"
function onClose() {
expect.step("on_close");
}
await getService("action").doAction(5, { onClose });
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog .modal-header button").click();
expect(".o_dialog").toHaveCount(0);
expect.verifySteps(["on_close"]);
// execute an 'ir.actions.act_window_close' action
// should not call 'on_close' as it was already called.
await getService("action").doAction({ type: "ir.actions.act_window_close" });
expect.verifySteps([]);
});
test('execute "on_close" only if there is no dialog to close', async () => {
await mountWithCleanup(WebClient);
// execute an action in target="new"
await getService("action").doAction(5);
function onClose() {
expect.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 getService("action").doAction({ type: "ir.actions.act_window_close" }, options);
expect.verifySteps([]);
// execute again an 'ir.actions.act_window_close' action
// should call 'on_close' as there is no dialog to close
await getService("action").doAction({ type: "ir.actions.act_window_close" }, options);
expect.verifySteps(["on_close"]);
});
test("close action with provided infos", async () => {
expect.assertions(1);
await mountWithCleanup(WebClient);
const options = {
onClose: function (infos) {
expect(infos).toBe("just for testing", {
message: "should have the correct close infos",
});
},
};
await getService("action").doAction(
{
type: "ir.actions.act_window_close",
infos: "just for testing",
},
options
);
});
test("history back calls on_close handler of dialog action", async () => {
const webClient = await mountWithCleanup(WebClient);
function onClose() {
expect.step("on_close");
}
// open a new dialog form
await getService("action").doAction(5, { onClose });
expect(".modal").toHaveCount(1);
const form = findComponent(webClient, (c) => c instanceof formView.Controller);
form.env.config.historyBack();
expect.verifySteps(["on_close"]);
await animationFrame();
expect(".modal").toHaveCount(0);
});
test.tags("desktop");
test("history back called within on_close", async () => {
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
super.setup(...arguments);
list = this;
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
function onClose() {
list.env.config.historyBack();
expect.step("on_close");
}
// open a new dialog form
await getService("action").doAction(5, { onClose });
await contains(".modal-header button.btn-close").click();
// await nextTick();
expect(".modal").toHaveCount(0);
expect(".o_list_view").toHaveCount(0);
expect(".o_kanban_view").toHaveCount(1);
expect.verifySteps(["on_close"]);
});
test.tags("desktop");
test("history back calls onclose handler of dialog action with 2 breadcrumbs", async () => {
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
super.setup(...arguments);
list = this;
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1); // kanban
await getService("action").doAction(3); // list
expect(".o_list_view").toHaveCount(1);
function onClose() {
expect.step("on_close");
}
// open a new dialog form
await getService("action").doAction(5, { onClose });
expect(".modal").toHaveCount(1);
expect(".o_list_view").toHaveCount(1);
list.env.config.historyBack();
expect.verifySteps(["on_close"]);
await animationFrame();
expect(".o_list_view").toHaveCount(1);
expect(".modal").toHaveCount(0);
});
test.tags("desktop");
test("web client is not deadlocked when a view crashes", async () => {
expect.assertions(4);
expect.errors(1);
const readOnFirstRecordDef = new Deferred();
onRpc("web_read", ({ args }) => {
if (args[0][0] === 1) {
return readOnFirstRecordDef;
}
});
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
// open first record in form view. this will crash and will not
// display a form view
await contains(".o_list_view .o_data_cell").click();
readOnFirstRecordDef.reject(new Error("not working as intended"));
await animationFrame();
expect.verifyErrors(["not working as intended"]);
expect(".o_list_view").toHaveCount(1, { message: "there should still be a list view in dom" });
// open another record, the read will not crash
await contains(".o_list_view .o_data_row:eq(1) .o_data_cell").click();
expect(".o_list_view").toHaveCount(0, { message: "there should not be a list view in dom" });
expect(".o_form_view").toHaveCount(1, { message: "there should be a form view in dom" });
});

View file

@ -0,0 +1,794 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, runAllTimers } from "@odoo/hoot-dom";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { Component, onWillStart, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineModels,
fields,
getService,
isItemSelected,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
stepAllNetworkCalls,
switchView,
toggleMenuItem,
toggleSearchBarMenu,
webModels,
} from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
import { redirect } from "@web/core/utils/urls";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { useSetupAction } from "@web/search/action_hook";
import { WebClient } from "@web/webclient/webclient";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
const { ResCompany, ResPartner, ResUsers } = webModels;
const actionRegistry = registry.category("actions");
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
form: /* xml */ `
<form>
<header>
<button name="object" string="Call method" type="object"/>
</header>
<group>
<field name="display_name"/>
</group>
</form>
`,
"kanban,1": /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>
`,
"list,2": /* xml */ `
<list>
<field name="display_name" />
</list>
`,
};
}
class Pony extends models.Model {
name = fields.Char();
_records = [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
];
_views = {
list: `<list><field name="name"/></list>`,
form: `<form><field name="name"/></form>`,
};
}
defineModels([Partner, Pony, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
{
id: 8,
xml_id: "action_8",
name: "Favorite Ponies",
res_model: "pony",
views: [
[false, "list"],
[false, "form"],
],
},
]);
test("drop previous actions if possible", async () => {
const def = new Deferred();
stepAllNetworkCalls();
onRpc("/web/action/load", () => def);
await mountWithCleanup(WebClient);
getService("action").doAction(4);
getService("action").doAction(8);
def.resolve();
await animationFrame();
// action 4 loads a kanban view first, 6 loads a list view. We want a list
expect(".o_list_view").toHaveCount(1);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
]);
});
test.tags("desktop");
test("handle switching view and switching back on slow network", async () => {
const def = new Deferred();
const defs = [null, def, null];
stepAllNetworkCalls();
onRpc("web_search_read", () => defs.shift());
await mountWithCleanup(WebClient);
await getService("action").doAction(4);
// kanban view is loaded, switch to list view
await switchView("list");
// here, list view is not ready yet, because def is not resolved
// switch back to kanban view
await switchView("kanban");
// here, we want the kanban view to reload itself, regardless of list view
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"web_search_read",
"has_group",
"web_search_read",
]);
// we resolve def => list view is now ready (but we want to ignore it)
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, { message: "there should be a kanban view in dom" });
expect(".o_list_view").toHaveCount(0, { message: "there should not be a list view in dom" });
});
test.tags("desktop");
test("clicking quickly on breadcrumbs...", async () => {
let def;
onRpc("web_read", () => def);
await mountWithCleanup(WebClient);
// create a situation with 3 breadcrumbs: kanban/form/list
await getService("action").doAction(4);
await contains(".o_kanban_record").click();
await getService("action").doAction(8);
// now, the next read operations will be promise (this is the read
// operation for the form view reload)
def = new Deferred();
// click on the breadcrumbs for the form view, then on the kanban view
// before the form view is fully reloaded
await contains(queryAll(".o_control_panel .breadcrumb-item")[1]).click();
await contains(".o_control_panel .breadcrumb-item").click();
// resolve the form view read
def.resolve();
await animationFrame();
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners Action 4"]);
});
test.tags("desktop");
test("execute a new action while loading a lazy-loaded controller", async () => {
redirect("/odoo/action-4/2?cids=1");
let def;
onRpc("partner", "web_search_read", () => def);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await animationFrame(); // blank component
expect(".o_form_view").toHaveCount(1, { message: "should display the form view of action 4" });
// click to go back to Kanban (this request is blocked)
def = new Deferred();
await contains(".o_control_panel .breadcrumb a").click();
expect(".o_form_view").toHaveCount(1, {
message: "should still display the form view of action 4",
});
// execute another action meanwhile (don't block this request)
await getService("action").doAction(8, { clearBreadcrumbs: true });
expect(".o_list_view").toHaveCount(1, { message: "should display action 8" });
expect(".o_form_view").toHaveCount(0, { message: "should no longer display the form view" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_read",
"web_search_read",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
]);
// unblock the switch to Kanban in action 4
def.resolve();
await animationFrame();
expect(".o_list_view").toHaveCount(1, { message: "should still display action 8" });
expect(".o_kanban_view").toHaveCount(0, {
message: "should not display the kanban view of action 4",
});
expect.verifySteps([]);
});
test.tags("desktop");
test("execute a new action while handling a call_button", async () => {
const def = new Deferred();
onRpc("/web/dataset/call_button/*", async () => {
await def;
return {
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
};
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
// execute action 3 and open a record in form view
await getService("action").doAction(3);
await contains(".o_list_view .o_data_cell").click();
expect(".o_form_view").toHaveCount(1, { message: "should display the form view of action 3" });
// click on 'Call method' button (this request is blocked)
await contains('.o_form_view button[name="object"]').click();
expect(".o_form_view").toHaveCount(1, {
message: "should still display the form view of action 3",
});
// execute another action
await getService("action").doAction(8, { clearBreadcrumbs: true });
expect(".o_list_view").toHaveCount(1, { message: "should display the list view of action 8" });
expect(".o_form_view").toHaveCount(0, { message: "should no longer display the form view" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"web_read",
"object",
"/web/action/load",
"get_views",
"web_search_read",
]);
// unblock the call_button request
def.resolve();
await animationFrame();
expect(".o_list_view").toHaveCount(1, {
message: "should still display the list view of action 8",
});
expect(".o_kanban_view").toHaveCount(0, { message: "should not display action 1" });
expect.verifySteps([]);
});
test.tags("desktop");
test("execute a new action while switching to another controller", async () => {
// 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;
stepAllNetworkCalls();
onRpc("web_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1, { message: "should display the list view of action 3" });
// switch to the form view (this request is blocked)
def = new Deferred();
await contains(".o_list_view .o_data_cell").click();
expect(".o_list_view").toHaveCount(1, {
message: "should still display the list view of action 3",
});
// execute another action meanwhile (don't block this request)
await getService("action").doAction(4, { clearBreadcrumbs: true });
expect(".o_kanban_view").toHaveCount(1, {
message: "should display the kanban view of action 8",
});
expect(".o_list_view").toHaveCount(0, { message: "should no longer display the list view" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"web_read",
"/web/action/load",
"get_views",
"web_search_read",
]);
// unblock the switch to the form view in action 3
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, {
message: "should still display the kanban view of action 8",
});
expect(".o_form_view").toHaveCount(0, {
message: "should not display the form view of action 3",
});
expect.verifySteps([]);
});
test("execute a new action while loading views", async () => {
const def = new Deferred();
stepAllNetworkCalls();
onRpc("get_views", () => def);
await mountWithCleanup(WebClient);
// execute a first action (its 'get_views' RPC is blocked)
getService("action").doAction(3);
await animationFrame();
expect(".o_list_view").toHaveCount(0, {
message: "should not display the list view of action 3",
});
// execute another action meanwhile (and unlock the RPC)
getService("action").doAction(4);
await animationFrame();
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, {
message: "should display the kanban view of action 4",
});
expect(".o_list_view").toHaveCount(0, {
message: "should not display the list view of action 3",
});
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners Action 4"]);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
test.tags("desktop");
test("execute a new action while loading data of default view", async () => {
const def = new Deferred();
stepAllNetworkCalls();
onRpc("web_search_read", () => def);
await mountWithCleanup(WebClient);
// execute a first action (its 'search_read' RPC is blocked)
getService("action").doAction(3);
await animationFrame();
expect(".o_list_view").toHaveCount(0, {
message: "should not display the list view of action 3",
});
// execute another action meanwhile (and unlock the RPC)
getService("action").doAction(4);
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, {
message: "should display the kanban view of action 4",
});
expect(".o_list_view").toHaveCount(0, {
message: "should not display the list view of action 3",
});
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners Action 4"]);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
test.tags("desktop");
test("open a record while reloading the list view", async () => {
let def;
onRpc("web_search_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
expect(".o_list_view .o_data_row").toHaveCount(2);
expect(".o_control_panel .o_list_buttons").toHaveCount(1);
// reload (the search_read RPC will be blocked)
def = new Deferred();
await switchView("list");
expect(".o_list_view .o_data_row").toHaveCount(2);
expect(".o_control_panel .o_list_buttons").toHaveCount(1);
// open a record in form view
await contains(".o_list_view .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
expect(".o_control_panel .o_list_buttons").toHaveCount(0);
// unblock the search_read RPC
def.resolve();
await animationFrame();
expect(".o_form_view").toHaveCount(1);
expect(".o_list_view").toHaveCount(0);
expect(".o_control_panel .o_list_buttons").toHaveCount(0);
});
test("properly drop client actions after new action is initiated", async () => {
const slowWillStartDef = new Deferred();
class ClientAction extends Component {
static template = xml`<div class="client_action">ClientAction</div>`;
static props = ["*"];
setup() {
onWillStart(() => slowWillStartDef);
}
}
actionRegistry.add("slowAction", ClientAction);
await mountWithCleanup(WebClient);
getService("action").doAction("slowAction");
await animationFrame();
expect(".client_action").toHaveCount(0, { message: "client action isn't ready yet" });
getService("action").doAction(4);
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, { message: "should have loaded a kanban view" });
slowWillStartDef.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1, { message: "should still display the kanban view" });
});
test.tags("desktop");
test("restoring a controller when doing an action -- load_action slow", async () => {
let def;
onRpc("/web/action/load", () => def);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
await contains(".o_list_view .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
def = new Deferred();
getService("action").doAction(4, { clearBreadcrumbs: true });
await animationFrame();
expect(".o_form_view").toHaveCount(1, { message: "should still contain the form view" });
await contains(".o_control_panel .breadcrumb-item a").click();
def.resolve();
await animationFrame();
expect(".o_list_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners"]);
expect(".o_form_view").toHaveCount(0);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"web_read",
"/web/action/load",
"web_search_read",
]);
});
test.tags("desktop");
test("switching when doing an action -- load_action slow", async () => {
let def;
onRpc("/web/action/load", () => def);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
def = new Deferred();
getService("action").doAction(4, { clearBreadcrumbs: true });
await animationFrame();
expect(".o_list_view").toHaveCount(1, { message: "should still contain the list view" });
await switchView("kanban");
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners"]);
expect(".o_list_view").toHaveCount(0);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"/web/action/load",
"web_search_read",
]);
});
test.tags("desktop");
test("switching when doing an action -- get_views slow", async () => {
let def;
onRpc("get_views", () => def);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
def = new Deferred();
getService("action").doAction(4);
await animationFrame();
expect(".o_list_view").toHaveCount(1, { message: "should still contain the list view" });
await switchView("kanban");
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners"]);
expect(".o_list_view").toHaveCount(0);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
test.tags("desktop");
test("switching when doing an action -- search_read slow", async () => {
const def = new Deferred();
const defs = [null, def, null];
onRpc("web_search_read", () => defs.shift());
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
getService("action").doAction(4);
await animationFrame();
await switchView("kanban");
def.resolve();
await animationFrame();
expect(".o_kanban_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners"]);
expect(".o_list_view").toHaveCount(0);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"has_group",
"/web/action/load",
"get_views",
"web_search_read",
"web_search_read",
]);
});
test.tags("desktop");
test("click multiple times to open a record", async () => {
const def = new Deferred();
const defs = [null, def];
onRpc("web_read", () => defs.shift());
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
await contains(".o_list_view .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_back_button").click();
expect(".o_list_view").toHaveCount(1);
const row1 = queryAll(".o_list_view .o_data_row")[0];
const row2 = queryAll(".o_list_view .o_data_row")[1];
await contains(row1.querySelector(".o_data_cell")).click();
await contains(row2.querySelector(".o_data_cell")).click();
expect(".o_form_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual([
"Partners",
"Second record",
]);
def.resolve();
await animationFrame();
expect(".o_form_view").toHaveCount(1);
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual([
"Partners",
"Second record",
]);
});
test("dialog will only open once for two rapid actions with the target new", async () => {
const def = new Deferred();
onRpc("onchange", () => def);
await mountWithCleanup(WebClient);
getService("action").doAction(5);
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(0);
getService("action").doAction(5);
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
});
test.tags("desktop");
test("local state, global state, and race conditions", async () => {
patchWithCleanup(serverState.view_info, {
toy: { multi_record: true, display_name: "Toy", icon: "fab fa-android" },
});
Partner._views = {
toy: `<toy/>`,
list: `<list><field name="display_name"/></list>`,
search: `<search><filter name="display_name" string="Foo" domain="[]"/></search>`,
};
let def = Promise.resolve();
let id = 1;
class ToyController extends Component {
static template = xml`
<div class="o_toy_view">
<ControlPanel />
<SearchBar />
</div>`;
static components = { ControlPanel, SearchBar };
static props = ["*"];
setup() {
this.id = id++;
expect.step(JSON.stringify(this.props.state || "no state"));
useSetupAction({
getLocalState: () => {
return { fromId: this.id };
},
});
onWillStart(() => def);
}
}
registry.category("views").add("toy", {
type: "toy",
Controller: ToyController,
});
await mountWithCleanup(WebClient);
await getService("action").doAction({
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 toggleSearchBarMenu();
await toggleMenuItem("Foo");
expect(isItemSelected("Foo")).toBe(true);
// reload twice by clicking on toy view switcher
def = new Deferred();
await contains(".o_control_panel .o_switch_view.o_toy").click();
await contains(".o_control_panel .o_switch_view.o_toy").click();
def.resolve();
await animationFrame();
await toggleSearchBarMenu();
expect(isItemSelected("Foo")).toBe(true);
// 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.
expect.verifySteps([
`"no state"`, // setup first view instantiated
`{"fromId":1}`, // setup second view instantiated
`{"fromId":1}`, // setup third view instantiated
]);
});
test.tags("desktop");
test("doing browser back temporarily disables the UI", async () => {
let def;
onRpc("partner", "web_search_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction(4);
await getService("action").doAction(8);
await runAllTimers(); // wait for the update of the router
expect(router.current).toEqual({
action: 8,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
],
});
def = new Deferred();
browser.history.back();
expect(document.body.style.pointerEvents).toBe("none");
// await contains(".o_control_panel .breadcrumb-item").click(); todo JUM: click on breadcrumb
def.resolve();
await animationFrame();
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Partners Action 4"]);
expect(document.body.style.pointerEvents).toBe("auto");
});

View file

@ -0,0 +1,170 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import {
contains,
defineActions,
defineModels,
getService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
webModels,
} from "@web/../tests/web_test_helpers";
import { user } from "@web/core/user";
import { WebClient } from "@web/webclient/webclient";
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
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"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="display_name"/></list>`,
};
}
defineModels([Partner, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 6,
xml_id: "action_6",
name: "Partner",
res_id: 2,
res_model: "partner",
target: "inline",
views: [[false, "form"]],
},
]);
test.tags("desktop");
test("rainbowman integrated to webClient", async () => {
patchWithCleanup(user, { showEffect: true });
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
expect(".o_reward").toHaveCount(0);
getService("effect").add({ type: "rainbow_man", message: "", fadeout: "no" });
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_kanban_view").toHaveCount(1);
await contains(".o_reward").click();
expect(".o_reward").toHaveCount(0);
expect(".o_kanban_view").toHaveCount(1);
getService("effect").add({ type: "rainbow_man", message: "", fadeout: "no" });
await animationFrame();
expect(".o_reward").toHaveCount(1);
expect(".o_kanban_view").toHaveCount(1);
// Do not force rainbow man to destroy on doAction
// we let it die either after its animation or on user click
await getService("action").doAction(3);
expect(".o_reward").toHaveCount(1);
expect(".o_list_view").toHaveCount(1);
});
test.tags("desktop");
test("on close with effect from server", async () => {
patchWithCleanup(user, { showEffect: true });
onRpc("/web/dataset/call_button/*", () => {
return {
type: "ir.actions.act_window_close",
effect: {
type: "rainbow_man",
message: "button called",
},
};
});
await mountWithCleanup(WebClient);
await getService("action").doAction(6);
await contains("button[name=object]").click();
expect(".o_reward").toHaveCount(1);
});
test.tags("desktop");
test("on close with effect in xml on desktop", async () => {
patchWithCleanup(user, { showEffect: true });
Partner._views["form"] = `
<form>
<header>
<button string="Call method" name="object" type="object"
effect="{'type': 'rainbow_man', 'message': 'rainBowInXML'}"
/>
</header>
<field name="display_name"/>
</form>`;
onRpc("/web/dataset/call_button/*", () => false);
await mountWithCleanup(WebClient);
await getService("action").doAction(6);
await contains("button[name=object]").click();
expect(".o_reward").toHaveCount(1);
expect(".o_reward .o_reward_msg_content").toHaveText("rainBowInXML");
});
test.tags("mobile");
test("on close with effect in xml on mobile", async () => {
patchWithCleanup(user, { showEffect: true });
Partner._views["form"] = `
<form>
<header>
<button string="Call method" name="object" type="object"
effect="{'type': 'rainbow_man', 'message': 'rainBowInXML'}"
/>
</header>
<field name="display_name"/>
</form>`;
onRpc("/web/dataset/call_button/*", () => false);
await mountWithCleanup(WebClient);
await getService("action").doAction(6);
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
await contains("button[name=object]").click();
expect(".o_reward").toHaveCount(1);
expect(".o_reward .o_reward_msg_content").toHaveText("rainBowInXML");
});

View file

@ -0,0 +1,528 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import {
contains,
defineActions,
defineModels,
fields,
getService,
models,
mountWithCleanup,
onRpc,
toggleMenuItem,
toggleSearchBarMenu,
webModels,
} from "@web/../tests/web_test_helpers";
import { mockTouch, runAllTimers } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
import { user } from "@web/core/user";
import { WebClient } from "@web/webclient/webclient";
describe.current.tags("desktop");
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
display_name = fields.Char();
foo = fields.Char();
m2o = fields.Many2one({ relation: "partner" });
o2m = fields.One2many({ relation: "partner" });
_records = [
{ id: 1, display_name: "First record", foo: "yop", m2o: 3, o2m: [2, 3] },
{ id: 2, display_name: "Second record", foo: "blip", m2o: 3, o2m: [1, 4, 5] },
{ id: 3, display_name: "Third record", foo: "gnap", m2o: 1, o2m: [] },
{ id: 4, display_name: "Fourth record", foo: "plop", m2o: 1, o2m: [] },
{ id: 5, display_name: "Fifth record", foo: "zoup", m2o: 1, o2m: [] },
];
_views = {
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>`,
"form,74": `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="action" name="1" icon="fa-star" context="{'default_partner': id}">
<field string="Partners" name="o2m" widget="statinfo"/>
</button>
</div>
<field name="display_name"/>
</sheet>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="foo"/></list>`,
search: `<search><field name="foo" string="Foo"/></search>`,
};
}
class Pony extends models.Model {
name = fields.Char();
_records = [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
];
_views = {
list: `<list>
<field name="name"/>
<button name="action_test" type="object" string="Action Test" column_invisible="not context.get('display_button')"/>
</list>`,
kanban: `<kanban>
<templates>
<t t-name="card">
<field name="name"/>
</t>
</templates>
</kanban>`,
form: `<form><field name="name"/></form>`,
search: `<search>
<filter name="my_filter" string="My filter" domain="[['name', '=', 'Applejack']]"/>
</search>`,
};
}
defineModels([Partner, Pony, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 2,
xml_id: "action_2",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 3,
xml_id: "action_3",
name: "Favorite Ponies",
res_model: "pony",
views: [
[false, "list"],
[false, "kanban"],
[false, "form"],
],
},
{
id: 4,
xml_id: "action_4",
name: "Ponies",
res_model: "pony",
views: [
[false, "list"],
[false, "kanban"],
[false, "form"],
],
},
{
id: 102,
xml_id: "embedded_action_2",
name: "Embedded Action 2",
parent_res_model: "partner",
type: "ir.embedded.actions",
parent_action_id: 1,
action_id: 3,
context: {
display_button: true,
},
},
{
id: 103,
name: "Embedded Action 3",
parent_res_model: "partner",
type: "ir.embedded.actions",
parent_action_id: 1,
python_method: "do_python_method",
},
{
id: 104,
name: "Custom Embedded Action 4",
type: "ir.embedded.actions",
user_id: user.userId,
parent_action_id: 4,
action_id: 4,
},
]);
test("can display embedded actions linked to the current action", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_control_panel").toHaveCount(1, { message: "should have rendered a control panel" });
expect(".o_kanban_view").toHaveCount(1, { message: "should have rendered a kanban view" });
expect(".o_control_panel_navigation > button > i.fa-sliders").toHaveCount(1, {
message: "should display the toggle embedded button",
});
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
expect(".o_embedded_actions").toHaveCount(1, { message: "should display the embedded" });
expect(".o_embedded_actions > button > span").toHaveText("Partners Action 1", {
message:
"The first embedded action should be the parent one and should be shown by default",
});
});
test("can toggle visibility of embedded actions", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
expect(".o_popover.dropdown-menu .dropdown-item").toHaveCount(4, {
message: "Three embedded actions should be displayed in the dropdown + button 'Save View'",
});
expect(".dropdown-menu .dropdown-item.selected").toHaveCount(1, {
message: "only one embedded action should be selected",
});
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
expect(".o_embedded_actions > button").toHaveCount(3, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
});
test("can click on a embedded action and execute the corresponding action (with xml_id)", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
await contains(".o_embedded_actions > button > span:contains('Embedded Action 2')").click();
await runAllTimers();
expect(router.current.action).toBe(3, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(".o_list_view").toHaveCount(1, { message: "the view should be a list view" });
expect(".o_embedded_actions").toHaveCount(1, { message: "the embedded should stay open" });
expect(".o_embedded_actions > button.active").toHaveText("Embedded Action 2", {
message: "The second embedded action should be active",
});
});
test("can click on a embedded action and execute the corresponding action (with python_method)", async () => {
await mountWithCleanup(WebClient);
onRpc("do_python_method", () => {
return {
id: 4,
name: "Favorite Ponies from python action",
res_model: "pony",
type: "ir.actions.act_window",
views: [[false, "kanban"]],
};
});
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 3')"
).click();
await contains(".o_embedded_actions > button > span:contains('Embedded Action 3')").click();
await runAllTimers();
expect(router.current.action).toBe(4, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(".o_kanban_view").toHaveCount(1, { message: "the view should be a kanban view" });
expect(".o_embedded_actions").toHaveCount(1, { message: "the embedded should stay open" });
expect(".o_embedded_actions > button.active").toHaveText("Embedded Action 3", {
message: "The third embedded action should be active",
});
});
test("breadcrumbs are updated when clicking on embeddeds", async () => {
await mountWithCleanup(WebClient);
onRpc("do_python_method", () => {
return {
id: 4,
name: "Favorite Ponies from python action",
res_model: "pony",
type: "ir.actions.act_window",
views: [[false, "kanban"]],
};
});
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 3')"
).click();
expect(".o_control_panel .breadcrumb-item").toHaveCount(0);
expect(".o_control_panel .o_breadcrumb .active").toHaveText("Partners Action 1");
expect(browser.location.href).toBe("https://www.hoot.test/odoo/action-1");
await contains(".o_embedded_actions > button > span:contains('Embedded Action 2')").click();
await runAllTimers();
expect(browser.location.href).toBe("https://www.hoot.test/odoo/action-3");
expect(router.current.action).toBe(3, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual(["Favorite Ponies"]);
await contains(".o_embedded_actions > button > span:contains('Embedded Action 3')").click();
await runAllTimers();
expect(browser.location.href).toBe("https://www.hoot.test/odoo/action-4");
expect(router.current.action).toBe(4, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(queryAllTexts(".breadcrumb-item, .o_breadcrumb .active")).toEqual([
"Favorite Ponies from python action",
]);
});
test("a view coming from a embedded can be saved in the embedded actions", async () => {
onRpc("create", ({ args }) => {
const values = args[0][0];
expect(values.name).toBe("Custom Embedded Action 2");
expect(values.action_id).toBe(3);
expect(values).not.toInclude("python_method");
return [4, values.name]; // Fake new embedded action id
});
onRpc("create_or_replace", ({ args }) => {
expect(args[0].domain).toBe(`[["name", "=", "Applejack"]]`);
expect(args[0].embedded_action_id).toBe(4);
expect(args[0].user_id).toBe(false);
return 5; // Fake new filter id
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
await contains(".o_embedded_actions > button > span:contains('Embedded Action 2')").click();
await runAllTimers();
expect(router.current.action).toBe(3, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(".o_list_view").toHaveCount(1, { message: "the view should be a list view" });
await contains("button.o_switch_view.o_kanban").click();
expect(".o_kanban_view").toHaveCount(1, { message: "the view should be a kanban view" });
await toggleSearchBarMenu();
await toggleMenuItem("My filter");
await toggleSearchBarMenu();
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1, {
message: "There should be one record",
});
await contains(".o_embedded_actions .dropdown").click();
await contains(".o_save_current_view ").click();
await contains("input.form-check-input").click();
await contains(".o_save_favorite ").click();
expect(".o_embedded_actions > button").toHaveCount(4, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
});
test("a view coming from a embedded with python_method can be saved in the embedded actions", async () => {
onRpc(({ args, method }) => {
let values;
if (method === "create") {
values = args[0][0];
expect(values.name).toBe("Custom Embedded Action 3");
expect(values.python_method).toBe("do_python_method");
expect(values).not.toInclude("action_id");
return [4, values.name]; // Fake new embedded action id
} else if (method === "create_or_replace") {
values = args[0][0];
expect(args[0].domain).toBe(`[["name", "=", "Applejack"]]`);
expect(args[0].embedded_action_id).toBe(4);
expect(args[0].user_id).toBe(false);
return 5; // Fake new filter id
} else if (method === "do_python_method") {
return {
id: 4,
name: "Favorite Ponies from python action",
res_model: "pony",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "kanban"],
],
};
}
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 3')"
).click();
await contains(".o_embedded_actions > button > span:contains('Embedded Action 3')").click();
await runAllTimers();
expect(router.current.action).toBe(4, {
message: "the current action should be the one of the embedded action previously clicked",
});
expect(".o_list_view").toHaveCount(1, { message: "the view should be a list view" });
await contains("button.o_switch_view.o_kanban").click();
expect(".o_kanban_view").toHaveCount(1, { message: "the view should be a kanban view" });
await toggleSearchBarMenu();
await toggleMenuItem("My filter");
await toggleSearchBarMenu();
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1, {
message: "There should be one record",
});
await contains(".o_embedded_actions .dropdown").click();
await contains(".o_save_current_view ").click();
await contains("input.form-check-input").click();
await contains(".o_save_favorite ").click();
expect(".o_embedded_actions > button").toHaveCount(4, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
});
test("the embedded actions should not be displayed when switching view", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
await contains(".o_embedded_actions > button > span:contains('Embedded Action 2')").click();
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains("button.o_switch_view.o_kanban").click();
expect(".o_embedded_actions").toHaveCount(0, {
message: "The embedded actions menu should not be displayed",
});
});
test("User can move the main (first) embedded action", async () => {
mockTouch(true);
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
await contains(
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Embedded Action 2')"
).click();
await contains(".o_embedded_actions > button:first-child").dragAndDrop(
".o_embedded_actions > button:nth-child(2)"
);
expect(".o_embedded_actions > button:nth-child(2) > span").toHaveText("Partners Action 1", {
message: "Main embedded action should've been moved to 2nd position",
});
});
test("User can unselect the main (first) embedded action", async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await contains(".o_embedded_actions .dropdown").click();
const dropdownItem =
".o_popover.dropdown-menu .dropdown-item > div > span:contains('Partners Action 1')";
expect(dropdownItem).not.toHaveClass("text-muted", {
message: "Main embedded action should not be displayed in muted",
});
await contains(dropdownItem).click();
expect(dropdownItem).not.toHaveClass("selected", {
message: "Main embedded action should be unselected",
});
});
test("User should be redirected to the first embedded action set in localStorage", async () => {
await mountWithCleanup(WebClient);
browser.localStorage.setItem(
`orderEmbedded1++${user.userId}`,
JSON.stringify([102, false, 103])
); // set embedded action 2 in first
await getService("action").doActionButton({
name: 1,
type: "action",
});
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
expect(".o_embedded_actions > button:first-child").toHaveClass("active", {
message: "First embedded action in order should have the 'active' class",
});
expect(".o_embedded_actions > button:first-child > span").toHaveText("Embedded Action 2", {
message: "First embedded action in order should be 'Embedded Action 2'",
});
expect(".o_last_breadcrumb_item > span").toHaveText("Favorite Ponies", {
message: "'Favorite Ponies' view should be loaded",
});
expect(".o_list_renderer .btn-link").toHaveCount(3, {
message:
"The button should be displayed since `display_button` is true in the context of the embedded action 2",
});
});
test("execute a regular action from an embedded action", async () => {
Pony._views["form"] = `
<form>
<button type="action" name="2" string="Execute another action"/>
<field name="name"/>
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
await contains(".o_control_panel_navigation button .fa-sliders").click();
expect(".o_control_panel .o_embedded_actions button:not(.dropdown-toggle)").toHaveCount(1);
await contains(".o_embedded_actions .dropdown").click();
await contains(".dropdown-menu .dropdown-item span:contains('Embedded Action 2')").click();
expect(".o_control_panel .o_embedded_actions button:not(.dropdown-toggle)").toHaveCount(2);
await contains(".o_control_panel .o_embedded_actions button:eq(1)").click();
expect(".o_list_view").toHaveCount(1);
await contains(".o_data_row .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_form_view button[type=action]").click();
expect(".o_control_panel .o_embedded_actions").toHaveCount(0);
});
test("custom embedded action loaded first", async () => {
await mountWithCleanup(WebClient);
browser.localStorage.setItem(`orderEmbedded4++${user.userId}`, JSON.stringify([104, false])); // set embedded action 4 in first
await getService("action").doActionButton({
name: 4,
type: "action",
});
expect(".o_list_view").toHaveCount(1);
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
expect(".o_embedded_actions > button:first-child").toHaveClass("active", {
message: "First embedded action in order should have the 'active' class",
});
expect(".o_embedded_actions > button:first-child > span").toHaveText(
"Custom Embedded Action 4",
{
message: "First embedded action in order should be 'Embedded Action 4'",
}
);
expect(".o_last_breadcrumb_item > span").toHaveText("Ponies", {
message: "'Favorite Ponies' view should be loaded",
});
});

View file

@ -0,0 +1,270 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame, mockFetch, runAllTimers } from "@odoo/hoot-mock";
import { Component, onMounted, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineModels,
fields,
getService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
stepAllNetworkCalls,
webModels,
} from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
import { BooleanField } from "@web/views/fields/boolean/boolean_field";
import { FormController } from "@web/views/form/form_controller";
import { WebClient } from "@web/webclient/webclient";
const actionRegistry = registry.category("actions");
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
form: `<form><field name="display_name"/></form>`,
};
}
defineModels([Partner, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [
[1, "kanban"],
[false, "form"],
],
},
]);
test("error in a client action (at rendering)", async () => {
expect.assertions(9);
class Boom extends Component {
static template = xml`<div><t t-esc="a.b.c"/></div>`;
static props = ["*"];
}
actionRegistry.add("Boom", Boom);
onRpc("web_search_read", () => {
expect.step("web_search_read");
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
expect(".o_breadcrumb").toHaveText("Partners Action 1");
expect(queryAllTexts(".o_kanban_record span")).toEqual(["First record", "Second record"]);
expect.verifySteps(["web_search_read"]);
try {
await getService("action").doAction("Boom");
} catch (e) {
expect(e.cause).toBeInstanceOf(TypeError);
}
await animationFrame();
expect(".o_kanban_view").toHaveCount(1);
expect(".o_breadcrumb").toHaveText("Partners Action 1");
expect(queryAllTexts(".o_kanban_record span")).toEqual(["First record", "Second record"]);
expect.verifySteps(["web_search_read"]);
});
test("error in a client action (after the first rendering)", async () => {
expect.errors(1);
class Boom extends Component {
static 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>`;
static props = ["*"];
setup() {
this.boom = false;
}
get a() {
// a bit artificial, but makes the test firefox compliant
throw new Error("Cannot read properties of undefined (reading 'b')");
}
onClick() {
this.boom = true;
this.render();
}
}
actionRegistry.add("Boom", Boom);
await mountWithCleanup(WebClient);
await getService("action").doAction("Boom");
expect(".my_button").toHaveCount(1);
await contains(".my_button").click();
await animationFrame();
expect(".my_button").toHaveCount(1);
expect(".o_error_dialog").toHaveCount(1);
expect.verifyErrors(["Cannot read properties of undefined (reading 'b')"]);
});
test("connection lost when opening form view from kanban", async () => {
expect.errors(2);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
mockFetch((input) => {
expect.step(input);
if (input === "/web/webclient/version_info") {
// simulate a connection restore at the end of the test, to have no
// impact on other tests (see connectionLostNotifRemove)
return true;
}
throw new Error(); // simulate a ConnectionLost error
});
await contains(".o_kanban_record").click();
expect(".o_kanban_view").toHaveCount(1);
expect(".o_notification").toHaveCount(1);
expect(".o_notification").toHaveText("Connection lost. Trying to reconnect...");
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"/web/dataset/call_kw/partner/web_read", // from mockFetch
"/web/dataset/call_kw/partner/web_search_read", // from mockFetch
]);
await animationFrame();
expect.verifySteps([]); // doesn't indefinitely try to reload the list
// cleanup
await runAllTimers();
await animationFrame();
expect.verifySteps(["/web/webclient/version_info"]);
expect.verifyErrors([Error, Error]);
});
test.tags("desktop");
test("connection lost when coming back to kanban from form", async () => {
expect.errors(1);
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
await contains(".o_kanban_record").click();
expect(".o_form_view").toHaveCount(1);
mockFetch((input) => {
expect.step(input);
if (input === "/web/webclient/version_info") {
// simulate a connection restore at the end of the test, to have no
// impact on other tests (see connectionLostNotifRemove)
return true;
}
throw new Error(); // simulate a ConnectionLost error
});
await contains(".o_breadcrumb .o_back_button a").click();
await animationFrame();
expect(".o_form_view").toHaveCount(1);
expect(".o_notification").toHaveCount(1);
expect(".o_notification").toHaveText("Connection lost. Trying to reconnect...");
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
"web_read",
"/web/dataset/call_kw/partner/web_search_read", // from mockFetch
]);
await animationFrame();
expect.verifySteps([]); // doesn't indefinitely try to reload the list
// cleanup
await runAllTimers();
await animationFrame();
expect.verifySteps(["/web/webclient/version_info"]);
expect.verifyErrors([Error]);
});
test("error on onMounted", async () => {
expect.errors(1);
Partner._fields.bar = fields.Boolean();
Partner._views = {
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
form: `<form><field name="display_name"/><field name="bar"/></form>`,
};
stepAllNetworkCalls();
patchWithCleanup(BooleanField.prototype, {
setup() {
super.setup();
onMounted(() => {
throw new Error("faulty on mounted");
});
},
});
patchWithCleanup(FormController.prototype, {
setup() {
super.setup();
onMounted(() => {
// If a onMounted hook is faulty, the rest of the onMounted will not be executed
// leading to inconsistent views.
throw new Error("Never Executed code");
});
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await animationFrame();
expect(".o_kanban_view").toHaveCount(1);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"web_search_read",
]);
await contains(".o_kanban_record").click();
await animationFrame();
expect(".o_form_view").toHaveCount(0);
// check that the action manager is empty
expect(".o_action_manager").toHaveText("");
expect(".o_error_dialog").toHaveCount(1);
expect.verifySteps(["web_read"]);
expect.verifyErrors(["Error: faulty on mounted"]);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,669 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryOne, scroll, waitFor } from "@odoo/hoot-dom";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { Component, onWillStart, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineMenus,
defineModels,
fields,
getDropdownMenu,
getService,
makeMockEnv,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
switchView,
webModels,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { router } from "@web/core/browser/router";
import { listView } from "@web/views/list/list_view";
import { PivotModel } from "@web/views/pivot/pivot_model";
import { WebClient } from "@web/webclient/webclient";
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
o2m = fields.One2many({ relation: "partner", relation_field: "bar" });
_records = [
{ id: 1, display_name: "First record", o2m: [2, 3] },
{
id: 2,
display_name: "Second record",
o2m: [1, 4, 5],
},
{ id: 3, display_name: "Third record", o2m: [] },
{ id: 4, display_name: "Fourth record", o2m: [] },
{ id: 5, display_name: "Fifth record", o2m: [] },
];
_views = {
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"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="display_name"/></list>`,
"list,2": `<list limit="3"><field name="display_name"/></list>`,
};
}
class Pony extends models.Model {
name = fields.Char();
_records = [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
];
_views = {
list: '<list><field name="name"/></list>',
form: `<form><field name="name"/></form>`,
};
}
defineModels([Partner, Pony, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 8,
xml_id: "action_8",
name: "Favorite Ponies",
res_model: "pony",
views: [
[false, "list"],
[false, "form"],
],
},
]);
const actionRegistry = registry.category("actions");
const actionHandlersRegistry = registry.category("action_handlers");
test("can execute actions from id, xmlid and tag", async () => {
defineActions([
{
id: 10,
tag: "client_action_by_db_id",
target: "main",
type: "ir.actions.client",
},
{
id: 20,
xml_id: "some_action",
tag: "client_action_by_xml_id",
target: "main",
type: "ir.actions.client",
},
{
id: 30,
path: "my_action",
tag: "client_action_by_path",
target: "main",
type: "ir.actions.client",
},
]);
actionRegistry
.add("client_action_by_db_id", () => expect.step("client_action_db_id"))
.add("client_action_by_xml_id", () => expect.step("client_action_xml_id"))
.add("client_action_by_path", () => expect.step("client_action_path"))
.add("client_action_by_tag", () => expect.step("client_action_tag"))
.add("client_action_by_object", () => expect.step("client_action_object"));
await makeMockEnv();
await getService("action").doAction(10);
expect.verifySteps(["client_action_db_id"]);
await getService("action").doAction("some_action");
expect.verifySteps(["client_action_xml_id"]);
await getService("action").doAction("my_action");
expect.verifySteps(["client_action_path"]);
await getService("action").doAction("client_action_by_tag");
expect.verifySteps(["client_action_tag"]);
await getService("action").doAction({
tag: "client_action_by_object",
target: "current",
type: "ir.actions.client",
});
expect.verifySteps(["client_action_object"]);
});
test("action doesn't exists", async () => {
expect.assertions(1);
await makeMockEnv();
try {
await getService("action").doAction({
tag: "this_is_a_tag",
target: "current",
type: "ir.not_action.error",
});
} catch (e) {
expect(e.message).toBe(
"The ActionManager service can't handle actions of type ir.not_action.error"
);
}
});
test("action in handler registry", async () => {
await makeMockEnv();
actionHandlersRegistry.add("ir.action_in_handler_registry", ({ action }) =>
expect.step(action.type)
);
await getService("action").doAction({
tag: "this_is_a_tag",
target: "current",
type: "ir.action_in_handler_registry",
});
expect.verifySteps(["ir.action_in_handler_registry"]);
});
test("properly handle case when action id does not exist", async () => {
expect.errors(1);
await mountWithCleanup(WebClient);
getService("action").doAction(4448);
await animationFrame();
expect.verifyErrors(["RPC_ERROR"]);
expect(`.modal .o_error_dialog`).toHaveCount(1);
expect(".o_error_dialog .modal-body").toHaveText("The action 4448 does not exist");
});
test("properly handle case when action path does not exist", async () => {
expect.errors(1);
await mountWithCleanup(WebClient);
getService("action").doAction("plop");
await animationFrame();
expect.verifyErrors(["RPC_ERROR"]);
expect(`.modal .o_error_dialog`).toHaveCount(1);
expect(".o_error_dialog .modal-body").toHaveText('The action "plop" does not exist');
});
test("properly handle case when action xmlId does not exist", async () => {
expect.errors(1);
await mountWithCleanup(WebClient);
getService("action").doAction("not.found.action");
await animationFrame();
expect.verifyErrors(["RPC_ERROR"]);
expect(`.modal .o_error_dialog`).toHaveCount(1);
expect(".o_error_dialog .modal-body").toHaveText(
'The action "not.found.action" does not exist'
);
});
test("actions can be cached", async () => {
onRpc("/web/action/load", async (request) => {
const { params } = await request.json();
expect.step(JSON.stringify(params));
});
await makeMockEnv();
// With no additional params
await getService("action").loadAction(3);
await getService("action").loadAction(3);
// With specific context
await getService("action").loadAction(3, { configuratorMode: "add" });
await getService("action").loadAction(3, { configuratorMode: "edit" });
// With same active_id
await getService("action").loadAction(3, { active_id: 1 });
await getService("action").loadAction(3, { active_id: 1 });
// With active_id change
await getService("action").loadAction(3, { active_id: 2 });
// With same active_ids
await getService("action").loadAction(3, { active_ids: [1, 2] });
await getService("action").loadAction(3, { active_ids: [1, 2] });
// With active_ids change
await getService("action").loadAction(3, { active_ids: [1, 2, 3] });
// With same active_model
await getService("action").loadAction(3, { active_model: "a" });
await getService("action").loadAction(3, { active_model: "a" });
// With active_model change
await getService("action").loadAction(3, { active_model: "b" });
// should load from server once per active_id/active_ids/active_model change, nothing else
expect.verifySteps([
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1]}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"configuratorMode":"add"}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"configuratorMode":"edit"}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_id":1}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_id":2}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_ids":[1,2]}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_ids":[1,2,3]}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_model":"a"}}',
'{"action_id":3,"context":{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"active_model":"b"}}',
]);
});
test("action cache: additionalContext is used on the key", async () => {
onRpc("/web/action/load", () => {
expect.step("server loaded");
});
await makeMockEnv();
const actionParams = {
additionalContext: {
some: { deep: { nested: "Robert" } },
},
};
let action = await getService("action").loadAction(3, actionParams);
expect.verifySteps(["server loaded"]);
expect(action.context).toEqual(actionParams);
// Modify the action in place
action.context.additionalContext.some.deep.nested = "Nesta";
// Change additionalContext and reload
actionParams.additionalContext.some.deep.nested = "Marley";
action = await getService("action").loadAction(3, actionParams);
expect.verifySteps(["server loaded"]);
expect(action.context).toEqual(actionParams);
});
test.tags("desktop");
test('action with "no_breadcrumbs" set to true', async () => {
defineActions([
{
id: 42,
res_model: "partner",
type: "ir.actions.act_window",
views: [
[1, "kanban"],
[false, "list"],
],
context: { no_breadcrumbs: true },
},
]);
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_breadcrumb").toHaveCount(1);
// push another action flagged with 'no_breadcrumbs=true'
await getService("action").doAction(42);
await waitFor(".o_kanban_view");
expect(".o_breadcrumb").toHaveCount(0);
await contains(".o_switch_view.o_list").click();
await waitFor(".o_list_view");
expect(".o_breadcrumb").toHaveCount(0);
});
test("document's title is updated when an action is executed", async () => {
await mountWithCleanup(WebClient);
await animationFrame();
let currentTitle = getService("title").getParts();
expect(currentTitle).toEqual({});
let currentState = router.current;
await getService("action").doAction(4);
await animationFrame();
currentTitle = getService("title").getParts();
expect(currentTitle).toEqual({ action: "Partners Action 4" });
currentState = router.current;
expect(currentState).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
});
await getService("action").doAction(8);
await animationFrame();
currentTitle = getService("title").getParts();
expect(currentTitle).toEqual({ action: "Favorite Ponies" });
currentState = router.current;
expect(currentState).toEqual({
action: 8,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
],
});
await contains(".o_data_row .o_data_cell").click();
await animationFrame();
currentTitle = getService("title").getParts();
expect(currentTitle).toEqual({ action: "Twilight Sparkle" });
currentState = router.current;
expect(currentState).toEqual({
action: 8,
resId: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
{
action: 8,
resId: 4,
displayName: "Twilight Sparkle",
view_type: "form",
},
],
});
});
test.tags("desktop");
test('handles "history_back" event', async () => {
let list;
patchWithCleanup(listView.Controller.prototype, {
setup() {
super.setup(...arguments);
list = this;
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction(4);
await getService("action").doAction(3);
expect("ol.breadcrumb").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
list.env.config.historyBack();
await animationFrame();
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_breadcrumb").toHaveText("Partners Action 4", {
message: "breadcrumbs should display the display_name of the action",
});
});
test.tags("desktop");
test("stores and restores scroll position (in kanban)", async () => {
defineActions([
{
id: 10,
name: "Partners",
res_model: "partner",
views: [[false, "kanban"]],
},
]);
for (let i = 0; i < 60; i++) {
Partner._records.push({ id: 100 + i, display_name: `Record ${i}` });
}
const container = document.createElement("div");
container.classList.add("o_web_client");
container.style.height = "250px";
getFixture().appendChild(container);
await mountWithCleanup(WebClient, { target: container });
// execute a first action
await getService("action").doAction(10);
expect(".o_content").toHaveProperty("scrollTop", 0);
// simulate a scroll
await scroll(".o_content", { top: 100 });
// execute a second action (in which we don't scroll)
await getService("action").doAction(4);
expect(".o_content").toHaveProperty("scrollTop", 0);
// go back using the breadcrumbs
await contains(".o_control_panel .breadcrumb a").click();
expect(".o_content").toHaveProperty("scrollTop", 100);
});
test.tags("desktop");
test("stores and restores scroll position (in list)", async () => {
for (let i = 0; i < 60; i++) {
Partner._records.push({ id: 100 + i, display_name: `Record ${i}` });
}
const container = document.createElement("div");
container.classList.add("o_web_client");
container.style.height = "250px";
getFixture().appendChild(container);
await mountWithCleanup(WebClient, { target: container });
// execute a first action
await getService("action").doAction(3);
expect(".o_content").toHaveProperty("scrollTop", 0);
expect(queryOne(".o_list_renderer").scrollTop).toBe(0);
// simulate a scroll
queryOne(".o_list_renderer").scrollTop = 100;
// execute a second action (in which we don't scroll)
await getService("action").doAction(4);
expect(".o_content").toHaveProperty("scrollTop", 0);
// go back using the breadcrumbs
await contains(".o_control_panel .breadcrumb a").click();
expect(".o_content").toHaveProperty("scrollTop", 0);
expect(queryOne(".o_list_renderer").scrollTop).toBe(100);
});
test.tags("desktop");
test('executing an action with target != "new" closes all dialogs', async () => {
Partner._views["form"] = `
<form>
<field name="o2m">
<list><field name="display_name"/></list>
<form><field name="display_name"/></form>
</field>
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
await contains(".o_list_view .o_data_row .o_list_char").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_form_view .o_data_row .o_data_cell").click();
expect(".modal .o_form_view").toHaveCount(1);
await getService("action").doAction(1); // target != 'new'
await animationFrame(); // wait for the dialog to be closed
expect(".modal").toHaveCount(0);
});
test.tags("desktop");
test('executing an action with target "new" does not close dialogs', async () => {
Partner._views["form"] = `
<form>
<field name="o2m">
<list><field name="display_name"/></list>
<form><field name="display_name"/></form>
</field>
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(3);
expect(".o_list_view").toHaveCount(1);
await contains(".o_list_view .o_data_row .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_form_view .o_data_row .o_data_cell").click();
expect(".modal .o_form_view").toHaveCount(1);
await getService("action").doAction(5); // target 'new'
expect(".modal .o_form_view").toHaveCount(2);
});
test.tags("desktop");
test("search defaults are removed from context when switching view", async () => {
expect.assertions(1);
const context = {
search_default_x: true,
searchpanel_default_y: true,
};
patchWithCleanup(PivotModel.prototype, {
load(searchParams) {
expect(searchParams.context).toEqual({
allowed_company_ids: [1],
lang: "en",
tz: "taht",
uid: 7,
});
return super.load(...arguments);
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "pivot"],
],
context,
});
// list view is loaded, switch to pivot view
await switchView("pivot");
});
test("retrieving a stored action should remove 'allowed_company_ids' from its context", async () => {
// Prepare a multi company scenario
serverState.companies = [
{ id: 3, name: "Hermit", sequence: 1 },
{ id: 2, name: "Herman's", sequence: 2 },
{ id: 1, name: "Heroes TM", sequence: 3 },
];
// Prepare a stored action
browser.sessionStorage.setItem(
"current_action",
JSON.stringify({
id: 1,
name: "Partners Action 1",
res_model: "partner",
type: "ir.actions.act_window",
views: [[1, "kanban"]],
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.
Object.assign(browser.location, { search: "?model=partner&view_type=kanban" });
// Create the web client. It should execute the stored action.
await mountWithCleanup(WebClient);
await animationFrame(); // blank action
// Check the current action context
expect(getService("action").currentController.action.context).toEqual({
// action context
someKey: 44,
lang: "not_en",
tz: "not_taht",
uid: 42,
// note there is no 'allowed_company_ids' in the action context
});
});
test.tags("desktop");
test("action is removed while waiting for another action with selectMenu", async () => {
let def;
class SlowClientAction extends Component {
static template = xml`<div>My client action</div>`;
static props = ["*"];
setup() {
onWillStart(() => def);
}
}
actionRegistry.add("slow_client_action", SlowClientAction);
defineActions([
{
id: 1001,
tag: "slow_client_action",
target: "main",
type: "ir.actions.client",
params: { description: "Id 1" },
},
]);
defineMenus([
{
id: 1,
name: "App1",
actionID: 1001,
xmlid: "menu_1",
},
]);
await mountWithCleanup(WebClient);
// starting point: a kanban view
await getService("action").doAction(4);
expect(".o_kanban_view").toHaveCount(1);
// select app in navbar menu
def = new Deferred();
await contains(".o_navbar_apps_menu .dropdown-toggle").click();
const appsMenu = getDropdownMenu(".o_navbar_apps_menu");
await contains(".o_app:contains(App1)", { root: appsMenu }).click();
// check that the action manager is empty, even though client action is loading
expect(".o_action_manager").toHaveText("");
// resolve onwillstart so client action is ready
def.resolve();
await animationFrame();
expect(".o_action_manager").toHaveText("My client action");
});

View file

@ -0,0 +1,123 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { cookie } from "@web/core/browser/cookie";
import { redirect } from "@web/core/utils/urls";
import {
defineModels,
fields,
getService,
makeServerError,
models,
mountWebClient,
onRpc,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { animationFrame } from "@odoo/hoot-dom";
import { browser } from "@web/core/browser/browser";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
class Partner extends models.Model {
_name = "res.partner";
name = fields.Char();
_records = [{ id: 1, name: "First record" }];
_views = {
form: `
<form>
<group>
<field name="display_name"/>
</group>
</form>
`,
};
}
defineModels([Partner]);
beforeEach(() => {
serverState.companies = [
{ id: 1, name: "Company 1", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Company 2", sequence: 2, parent_id: false, child_ids: [] },
{ id: 3, name: "Company 3", sequence: 3, parent_id: false, child_ids: [] },
];
patchWithCleanup(browser.location, {
reload() {
expect.step("reload");
},
});
patchWithCleanup(browser.location, {
origin: "http://example.com",
});
});
test("open record withtout the correct company (load state)", async () => {
cookie.set("cids", "1");
onRpc("web_read", () => {
throw makeServerError({
type: "AccessError",
message: "Wrong Company",
context: { suggested_company: { id: 2, display_name: "Company 2" } },
});
});
redirect("/odoo/res.partner/1");
await mountWebClient();
expect(cookie.get("cids")).toBe("1-2");
expect.verifySteps(["reload"]);
expect(browser.location.href).toBe("http://example.com/odoo/res.partner/1", {
message: "url did not change",
});
});
test("open record withtout the correct company (doAction)", async () => {
cookie.set("cids", "1");
onRpc("web_read", () => {
throw makeServerError({
type: "AccessError",
message: "Wrong Company",
context: { suggested_company: { id: 2, display_name: "Company 2" } },
});
});
await mountWebClient();
getService("action").doAction({
type: "ir.actions.act_window",
res_id: 1,
res_model: "res.partner",
views: [[false, "form"]],
});
await animationFrame();
expect(cookie.get("cids")).toBe("1-2");
expect.verifySteps(["reload"]);
expect(browser.location.href).toBe("http://example.com/odoo/res.partner/1", {
message: "url should contain the information of the doAction",
});
});
test.tags("desktop");
test("form view in dialog shows wrong company error", async () => {
expect.errors(1);
cookie.set("cids", "1");
onRpc("web_read", () => {
throw makeServerError({
type: "AccessError",
message: "Wrong Company",
context: { suggested_company: { id: 2, display_name: "Company 2" } },
});
});
onRpc("has_group", () => true);
Partner._views["list,false"] = `<list><field name="display_name"/></list>`;
await mountWebClient();
getService("dialog").add(FormViewDialog, {
resModel: "res.partner",
resId: 1,
});
await animationFrame();
expect.verifyErrors(['Error: The following error occurred in onWillStart: "Wrong Company"']);
expect(cookie.get("cids")).toBe("1"); // cookies were not modified
expect.verifySteps([]); // don't reload
});

View file

@ -0,0 +1,666 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { Component, onMounted, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineMenus,
defineModels,
editSearch,
fields,
getService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
validateSearch,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
import { registry } from "@web/core/registry";
import { redirect } from "@web/core/utils/urls";
import { WebClient } from "@web/webclient/webclient";
describe.current.tags("desktop");
const actionRegistry = registry.category("actions");
defineActions([
{
id: 3,
xml_id: "action_3",
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[1, "kanban"],
[false, "form"],
],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 8,
xml_id: "action_8",
name: "Favorite Ponies",
res_model: "pony",
views: [
[false, "list"],
[false, "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" },
},
]);
defineMenus([
{ id: 0 }, // prevents auto-loading the first action
{ id: 1, actionID: 1001 },
{ id: 2, actionID: 1002 },
]);
class Partner extends models.Model {
name = fields.Char();
foo = fields.Char();
parent_id = fields.Many2one({ relation: "partner" });
child_ids = fields.One2many({ relation: "partner", relation_field: "parent_id" });
_records = [
{ id: 1, name: "First record", foo: "yop" },
{ id: 2, name: "Second record", foo: "blip" },
{ id: 3, name: "Third record", foo: "gnap" },
{ id: 4, name: "Fourth record", foo: "plop" },
{ id: 5, name: "Fifth record", foo: "zoup" },
];
_views = {
"kanban,1": /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>
`,
"list,2": /* xml */ `
<list>
<field name="foo" />
</list>
`,
form: /* xml */ `
<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>
`,
search: /* xml */ `
<search>
<field name="foo" string="Foo" />
</search>
`,
};
}
class Pony extends models.Model {
name = fields.Char();
_records = [
{ id: 4, name: "Twilight Sparkle" },
{ id: 6, name: "Applejack" },
{ id: 9, name: "Fluttershy" },
];
_views = {
list: `<list><field name="name"/></list>`,
form: `<form><field name="name"/></form>`,
};
}
defineModels([Partner, Pony]);
class TestClientAction extends Component {
static template = xml`
<div class="test_client_action">
ClientAction_<t t-esc="props.action.params?.description"/>
</div>
`;
static props = ["*"];
setup() {
onMounted(() => {
this.env.config.setDisplayName(`Client action ${this.props.action.id}`);
});
}
}
onRpc("has_group", () => true);
beforeEach(() => {
actionRegistry.add("__test__client__action__", TestClientAction);
patchWithCleanup(browser.location, {
origin: "http://example.com",
});
redirect("/odoo");
});
test(`basic action as App`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(router.current).toEqual({});
await contains(`.o_navbar_apps_menu button`).click();
await contains(`.o-dropdown-item:eq(2)`).click();
await animationFrame();
await animationFrame();
expect(router.current.action).toBe(1002);
expect(browser.location.href).toBe("http://example.com/odoo/action-1002");
expect(`.test_client_action`).toHaveText("ClientAction_Id 2");
expect(`.o_menu_brand`).toHaveText("App2");
});
test(`do action keeps menu in url`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(router.current).toEqual({});
await contains(`.o_navbar_apps_menu button`).click();
await contains(`.o-dropdown-item:eq(2)`).click();
await animationFrame();
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-1002");
expect(router.current.action).toBe(1002);
expect(`.test_client_action`).toHaveText("ClientAction_Id 2");
expect(`.o_menu_brand`).toHaveText("App2");
await getService("action").doAction(1001, { clearBreadcrumbs: true });
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-1001");
expect(router.current.action).toBe(1001);
expect(`.test_client_action`).toHaveText("ClientAction_Id 1");
expect(`.o_menu_brand`).toHaveText("App2");
});
test(`actions can push state`, async () => {
class ClientActionPushes extends Component {
static template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description"/>
</div>
`;
static props = ["*"];
_actionPushState() {
router.pushState({ arbitrary: "actionPushed" });
}
}
actionRegistry.add("client_action_pushes", ClientActionPushes);
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
expect(router.current).toEqual({});
await getService("action").doAction("client_action_pushes");
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/client_action_pushes");
expect(browser.history.length).toBe(2);
expect(router.current.action).toBe("client_action_pushes");
expect(router.current.menu_id).toBe(undefined);
await contains(`.test_client_action`).click();
await animationFrame();
expect(browser.location.href).toBe(
"http://example.com/odoo/client_action_pushes?arbitrary=actionPushed"
);
expect(browser.history.length).toBe(3);
expect(router.current.action).toBe("client_action_pushes");
expect(router.current.arbitrary).toBe("actionPushed");
});
test(`actions override previous state`, async () => {
class ClientActionPushes extends Component {
static template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description"/>
</div>
`;
static props = ["*"];
_actionPushState() {
router.pushState({ arbitrary: "actionPushed" });
}
}
actionRegistry.add("client_action_pushes", ClientActionPushes);
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
expect(router.current).toEqual({});
await getService("action").doAction("client_action_pushes");
await animationFrame(); // wait for pushState because it's unrealistic to click before it
await contains(`.test_client_action`).click();
await animationFrame();
expect(browser.location.href).toBe(
"http://example.com/odoo/client_action_pushes?arbitrary=actionPushed"
);
expect(browser.history.length).toBe(3); // Two history entries
expect(router.current.action).toBe("client_action_pushes");
expect(router.current.arbitrary).toBe("actionPushed");
await getService("action").doAction(1001);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-1001", {
message: "client_action_pushes removed from url because action 1001 is in target main",
});
expect(browser.history.length).toBe(4);
expect(router.current.action).toBe(1001);
expect(router.current.arbitrary).toBe(undefined);
});
test(`actions override previous state from menu click`, async () => {
class ClientActionPushes extends Component {
static template = xml`
<div class="test_client_action" t-on-click="_actionPushState">
ClientAction_<t t-esc="props.params and props.params.description"/>
</div>
`;
static props = ["*"];
_actionPushState() {
router.pushState({ arbitrary: "actionPushed" });
}
}
actionRegistry.add("client_action_pushes", ClientActionPushes);
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(router.current).toEqual({});
await getService("action").doAction("client_action_pushes");
await contains(`.test_client_action`).click();
await contains(`.o_navbar_apps_menu button`).click();
await contains(`.o-dropdown-item:eq(2)`).click();
await animationFrame();
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-1002");
expect(router.current.action).toBe(1002);
});
test(`action in target new do not push state`, async () => {
defineActions([
{
id: 2001,
tag: "__test__client__action__",
target: "new",
type: "ir.actions.client",
params: { description: "Id 1" },
},
]);
patchWithCleanup(browser.history, {
pushState() {
throw new Error("should not push state");
},
});
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(2001);
expect(`.modal .test_client_action`).toHaveCount(1);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo", {
message: "url did not change",
});
expect(browser.history.length).toBe(1, { message: "did not create a history entry" });
expect(router.current).toEqual({});
});
test(`properly push state`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(4);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-4");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
});
await getService("action").doAction(8);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-4/action-8");
expect(browser.history.length).toBe(3);
expect(router.current).toEqual({
action: 8,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
],
});
await contains(`tr .o_data_cell:first`).click();
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-4/action-8/4");
expect(browser.history.length).toBe(4);
expect(router.current).toEqual({
action: 8,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
{
action: 8,
displayName: "Twilight Sparkle",
resId: 4,
view_type: "form",
},
],
resId: 4,
});
});
test(`push state after action is loaded, not before`, async () => {
const def = new Deferred();
onRpc("web_search_read", () => def);
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
getService("action").doAction(4);
await animationFrame();
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
expect(router.current).toEqual({});
def.resolve();
await animationFrame();
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-4");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
});
});
test(`do not push state when action fails`, async () => {
onRpc("read", () => Promise.reject());
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(8);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-8");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 8,
actionStack: [
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
],
});
await contains(`tr.o_data_row:first`).click();
// we make sure here that the list view is still in the dom
expect(`.o_list_view`).toHaveCount(1, {
message: "there should still be a list view in dom",
});
await animationFrame(); // wait for possible debounced pushState
expect(browser.location.href).toBe("http://example.com/odoo/action-8");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 8,
actionStack: [
{
action: 8,
displayName: "Favorite Ponies",
view_type: "list",
},
],
});
});
test(`view_type is in url when not the default one`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(3);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-3");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 3,
actionStack: [
{
action: 3,
displayName: "Partners",
view_type: "list",
},
],
});
expect(`.breadcrumb`).toHaveCount(0);
await getService("action").doAction(3, { viewType: "kanban" });
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-3?view_type=kanban");
expect(browser.history.length).toBe(3, { message: "created a history entry" });
expect(`.breadcrumb`).toHaveCount(1, {
message: "created a breadcrumb entry",
});
expect(router.current).toEqual({
action: 3,
view_type: "kanban", // view_type is on the state when it's not the default one
actionStack: [
{
action: 3,
displayName: "Partners",
view_type: "list",
},
{
action: 3,
displayName: "Partners",
view_type: "kanban",
},
],
});
});
test(`switchView pushes the stat but doesn't add to the breadcrumbs`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(3);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-3");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 3,
actionStack: [
{
action: 3,
displayName: "Partners",
view_type: "list",
},
],
});
expect(`.breadcrumb`).toHaveCount(0);
await getService("action").switchView("kanban");
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-3?view_type=kanban");
expect(browser.history.length).toBe(3, { message: "created a history entry" });
expect(`.breadcrumb`).toHaveCount(0, { message: "didn't create a breadcrumb entry" });
expect(router.current).toEqual({
action: 3,
view_type: "kanban", // view_type is on the state when it's not the default one
actionStack: [
{
action: 3,
displayName: "Partners",
view_type: "kanban",
},
],
});
});
test(`properly push globalState`, async () => {
await mountWithCleanup(WebClient);
expect(browser.location.href).toBe("http://example.com/odoo");
expect(browser.history.length).toBe(1);
await getService("action").doAction(4);
await animationFrame();
expect(browser.location.href).toBe("http://example.com/odoo/action-4");
expect(browser.history.length).toBe(2);
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
});
// add element on the search Model
await editSearch("blip");
await validateSearch();
expect(queryAllTexts(".o_facet_value")).toEqual(["blip"]);
//open record
await contains(".o_kanban_record").click();
// Add the globalState on the state before leaving the kanban
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
globalState: {
searchModel: `{"nextGroupId":2,"nextGroupNumber":1,"nextId":2,"query":[{"searchItemId":1,"autocompleteValue":{"label":"blip","operator":"ilike","value":"blip"}}],"searchItems":{"1":{"type":"field","fieldName":"foo","fieldType":"char","description":"Foo","groupId":1,"id":1}},"searchPanelInfo":{"className":"","fold":false,"viewTypes":["kanban","list"],"loaded":false,"shouldReload":true},"sections":[]}`,
},
});
// pushState is defered
await animationFrame();
expect(".o_form_view").toHaveCount(1);
expect(browser.location.href).toBe("http://example.com/odoo/action-4/2");
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
{
action: 4,
displayName: "Second record",
resId: 2,
view_type: "form",
},
],
resId: 2,
});
// came back using the browser
browser.history.back(); // Click on back button
await animationFrame();
// The search Model should be restored
expect(queryAllTexts(".o_facet_value")).toEqual(["blip"]);
expect(browser.location.href).toBe("http://example.com/odoo/action-4");
// The global state is restored on the state
expect(router.current).toEqual({
action: 4,
actionStack: [
{
action: 4,
displayName: "Partners Action 4",
view_type: "kanban",
},
],
globalState: {
searchModel: `{"nextGroupId":2,"nextGroupNumber":1,"nextId":2,"query":[{"searchItemId":1,"autocompleteValue":{"label":"blip","operator":"ilike","value":"blip"}}],"searchItems":{"1":{"type":"field","fieldName":"foo","fieldType":"char","description":"Foo","groupId":1,"id":1}},"searchPanelInfo":{"className":"","fold":false,"viewTypes":["kanban","list"],"loaded":false,"shouldReload":true},"sections":[]}`,
},
});
});

View file

@ -0,0 +1,419 @@
import { afterEach, expect, test } from "@odoo/hoot";
import { runAllTimers } from "@odoo/hoot-mock";
import {
contains,
defineActions,
defineModels,
getService,
mockService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
stepAllNetworkCalls,
} from "@web/../tests/web_test_helpers";
import { router } from "@web/core/browser/router";
import { download } from "@web/core/network/download";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { ReportAction } from "@web/webclient/actions/reports/report_action";
import { downloadReport } from "@web/webclient/actions/reports/utils";
import { WebClient } from "@web/webclient/webclient";
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
form: `
<form>
<header>
<button name="object" string="Call method" type="object"/>
</header>
<group>
<field name="display_name"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="display_name"/></list>`,
};
}
defineModels([Partner]);
defineActions([
{
id: 7,
xml_id: "action_7",
name: "Some Report",
report_name: "some_report",
report_type: "qweb-pdf",
type: "ir.actions.report",
},
{
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",
},
]);
afterEach(() => {
// In the prod environment, we keep a promise with the wkhtmlstatus (e.g. broken, upgrade...).
// This ensures the request to be done only once. In the test environment, we mock this request
// to simulate the different status, so we want to erase the promise at the end of each test,
// otherwise all tests but the first one would use the status in cache.
delete downloadReport.wkhtmltopdfStatusProm;
});
test("can execute report actions from db ID", async () => {
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
return Promise.resolve();
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(7, { onClose: () => expect.step("on_close") });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"/report/download",
"on_close",
]);
});
test("report actions can close modals and reload views", async () => {
defineActions([
{
id: 5,
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
]);
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
return Promise.resolve();
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
await mountWithCleanup(WebClient);
await getService("action").doAction(5, { onClose: () => expect.step("on_close") });
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "should have rendered a form view in a modal",
});
await getService("action").doAction(7, { onClose: () => expect.step("on_printed") });
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "The modal should still exist",
});
await getService("action").doAction(11);
expect(".o_technical_modal .o_form_view").toHaveCount(0, {
message: "the modal should have been closed after the action report",
});
expect.verifySteps(["/report/download", "on_printed", "/report/download", "on_close"]);
});
test("should trigger a notification if wkhtmltopdf is to upgrade", async () => {
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
return Promise.resolve();
},
});
mockService("notification", {
add: () => expect.step("notify"),
});
onRpc("/report/check_wkhtmltopdf", () => "upgrade");
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(7);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"/report/download",
"notify",
]);
});
test("should open the report client action if wkhtmltopdf is broken", async () => {
// 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
patchWithCleanup(ReportAction.prototype, {
setup() {
super.setup(...arguments);
rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
patchWithCleanup(download, {
_download: () => {
expect.step("download"); // should not be called
return Promise.resolve();
},
});
mockService("notification", {
add: () => expect.step("notify"),
});
onRpc("/report/check_wkhtmltopdf", () => "broken");
onRpc("/report/html/some_report", async (request) => {
const search = decodeURIComponent(new URL(request.url).search);
expect(search).toBe(`?context={"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1]}`);
return true;
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(7);
expect(".o_content iframe").toHaveCount(1, {
message: "should have opened the report client action",
});
// the control panel has the content twice and a d-none class is toggled depending the screen size
expect(":not(.d-none) > button[title='Print']").toHaveCount(1, {
message: "should have a print button",
});
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/report/check_wkhtmltopdf",
"notify",
"/report/html/some_report",
]);
});
test("send context in case of html report", async () => {
serverState.userContext = { some_key: 2 };
// 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
patchWithCleanup(ReportAction.prototype, {
setup() {
super.setup(...arguments);
rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
patchWithCleanup(download, {
_download: () => {
expect.step("download"); // should not be called
return Promise.resolve();
},
});
mockService("notification", {
add(message, options) {
expect.step(options.type || "notification");
},
});
onRpc("/report/html/some_report", async (request) => {
const search = decodeURIComponent(new URL(request.url).search);
expect(search).toBe(
`?context={"some_key":2,"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1]}`
);
return true;
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(12);
expect(".o_content iframe").toHaveCount(1, { message: "should have opened the client action" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/report/html/some_report",
]);
});
test("UI unblocks after downloading the report even if it threw an error", async () => {
let timesDownloasServiceHasBeenCalled = 0;
patchWithCleanup(download, {
_download: () => {
if (timesDownloasServiceHasBeenCalled === 0) {
expect.step("successful download");
timesDownloasServiceHasBeenCalled++;
return Promise.resolve();
}
if (timesDownloasServiceHasBeenCalled === 1) {
expect.step("failed download");
return Promise.reject();
}
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
await mountWithCleanup(WebClient);
const onBlock = () => {
expect.step("block");
};
const onUnblock = () => {
expect.step("unblock");
};
getService("ui").bus.addEventListener("BLOCK", onBlock);
getService("ui").bus.addEventListener("UNBLOCK", onUnblock);
await getService("action").doAction(7);
try {
await getService("action").doAction(7);
} catch {
expect.step("error caught");
}
expect.verifySteps([
"block",
"successful download",
"unblock",
"block",
"failed download",
"unblock",
"error caught",
]);
getService("ui").bus.removeEventListener("BLOCK", onBlock);
getService("ui").bus.removeEventListener("UNBLOCK", onUnblock);
});
test("can use custom handlers for report actions", async () => {
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
return Promise.resolve();
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
let customHandlerCalled = false;
registry.category("ir.actions.report handlers").add("custom_handler", async (action) => {
if (action.id === 7 && !customHandlerCalled) {
customHandlerCalled = true;
expect.step("calling custom handler");
return true;
}
expect.step("falling through to default handler");
});
await getService("action").doAction(7);
expect.step("first doAction finished");
await getService("action").doAction(7);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"calling custom handler",
"first doAction finished",
"falling through to default handler",
"/report/check_wkhtmltopdf",
"/report/download",
]);
});
test.tags("desktop");
test("context is correctly passed to the client action report", async (assert) => {
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
expect(options.data.context).toBe(
`{"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1],"rabbia":"E Tarantella","active_ids":[99]}`
);
expect(JSON.parse(options.data.data)).toEqual([
"/report/pdf/ennio.morricone/99",
"qweb-pdf",
]);
return Promise.resolve();
},
});
patchWithCleanup(ReportAction.prototype, {
setup() {
super.setup(...arguments);
rpc(this.reportUrl);
this.reportUrl = "about:blank";
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
onRpc("/report/html", async (request) => {
const search = decodeURIComponent(new URL(request.url).search);
expect(search).toBe(`?context={"lang":"en","tz":"taht","uid":7,"allowed_company_ids":[1]}`);
return true;
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
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",
};
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
await getService("action").doAction(action);
expect.verifySteps(["/report/html/ennio.morricone/99"]);
await contains(".o_control_panel_main_buttons button[title='Print']").click();
expect.verifySteps(["/report/check_wkhtmltopdf", "/report/download"]);
});
test("url is valid", async (assert) => {
patchWithCleanup(ReportAction.prototype, {
init() {
super.init(...arguments);
this.reportUrl = "about:blank";
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction(12); // 12 is a html report action
await runAllTimers();
const urlState = router.current;
// used to put report.client_action in the url
expect(urlState.action === "report.client_action").toBe(false);
expect(urlState.action).toBe(12);
});

View file

@ -0,0 +1,144 @@
import { expect, test } from "@odoo/hoot";
import {
defineActions,
defineModels,
getService,
models,
mountWithCleanup,
onRpc,
stepAllNetworkCalls,
} from "@web/../tests/web_test_helpers";
import { WebClient } from "@web/webclient/webclient";
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
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"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
};
}
defineModels([Partner]);
test("can execute server actions from db ID", async () => {
defineActions([
{
id: 2,
type: "ir.actions.server",
},
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
]);
onRpc("/web/action/run", async () => {
return 1; // execute action 1
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(2, { additionalContext: { someKey: 44 } });
expect(".o_control_panel").toHaveCount(1, { message: "should have rendered a control panel" });
expect(".o_kanban_view").toHaveCount(1, { message: "should have rendered a kanban view" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"/web/action/run",
"/web/action/load",
"get_views",
"web_search_read",
]);
});
test("handle server actions returning false", async function (assert) {
defineActions([
{
id: 2,
type: "ir.actions.server",
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
]);
onRpc("/web/action/run", async () => {
return false;
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
// execute an action in target="new"
function onClose() {
expect.step("close handler");
}
await getService("action").doAction(5, { onClose });
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "should have rendered a form view in a modal",
});
// execute a server action that returns false
await getService("action").doAction(2);
expect(".o_technical_modal").toHaveCount(0, { message: "should have closed the modal" });
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"onchange",
"/web/action/load",
"/web/action/run",
"close handler",
]);
});
test("action with html help returned by a server action", async () => {
defineActions([
{
id: 2,
type: "ir.actions.server",
},
]);
onRpc("/web/action/run", async () => {
return {
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "kanban"]],
help: "<p>I am not a helper</p>",
domain: [[0, "=", 1]],
};
});
await mountWithCleanup(WebClient);
await getService("action").doAction(2);
expect(".o_kanban_view .o_nocontent_help p").toHaveText("I am not a helper");
});

View file

@ -0,0 +1,772 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, queryText } from "@odoo/hoot-dom";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { Component, onMounted, xml } from "@odoo/owl";
import {
contains,
defineActions,
defineMenus,
defineModels,
getService,
mockService,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
stepAllNetworkCalls,
webModels,
} from "@web/../tests/web_test_helpers";
import { ClientErrorDialog } from "@web/core/errors/error_dialogs";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { WebClient } from "@web/webclient/webclient";
const { ResCompany, ResPartner, ResUsers } = webModels;
class Partner extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 1, display_name: "First record" },
{ id: 2, display_name: "Second record" },
];
_views = {
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"/>
</group>
</form>`,
"kanban,1": `
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="display_name"/></list>`,
"list,2": `<list limit="3"><field name="display_name"/></list>`,
};
}
defineModels([Partner, ResCompany, ResPartner, ResUsers]);
defineActions([
{
id: 1,
xml_id: "action_1",
name: "Partners Action 1",
res_model: "partner",
views: [[1, "kanban"]],
},
{
id: 4,
xml_id: "action_4",
name: "Partners Action 4",
res_model: "partner",
views: [
[1, "kanban"],
[2, "list"],
[false, "form"],
],
},
{
id: 5,
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
{
id: 15,
name: "Partners Action Fullscreen",
res_model: "partner",
target: "fullscreen",
views: [[1, "kanban"]],
},
]);
describe("new", () => {
test('can execute act_window actions in target="new"', async () => {
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
await getService("action").doAction(5);
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "should have rendered a form view in a modal",
});
expect(".o_technical_modal .modal-body").toHaveClass("o_act_window", {
message: "dialog main element should have classname 'o_act_window'",
});
expect(".o_technical_modal .o_form_view .o_form_editable").toHaveCount(1, {
message: "form view should be in edit mode",
});
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"/web/action/load",
"get_views",
"onchange",
]);
});
test("chained action on_close", async () => {
function onClose(closeInfo) {
expect(closeInfo).toBe("smallCandle");
expect.step("Close Action");
}
await mountWithCleanup(WebClient);
await getService("action").doAction(5, { onClose });
// a target=new action shouldn't activate the on_close
await getService("action").doAction(5);
expect.verifySteps([]);
// An act_window_close should trigger the on_close
await getService("action").doAction({
type: "ir.actions.act_window_close",
infos: "smallCandle",
});
expect.verifySteps(["Close Action"]);
});
test("footer buttons are moved to the dialog footer", async () => {
Partner._views["form"] = `
<form>
<field name="display_name"/>
<footer>
<button string="Create" type="object" class="infooter"/>
</footer>
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(5);
expect(".o_technical_modal .modal-body button.infooter").toHaveCount(0, {
message: "the button should not be in the body",
});
expect(".o_technical_modal .modal-footer button.infooter").toHaveCount(1, {
message: "the button should be in the footer",
});
expect(".modal-footer button:not(.d-none)").toHaveCount(1, {
message: "the modal footer should only contain one visible button",
});
});
test.tags("desktop");
test("Button with `close` attribute closes dialog on desktop", async () => {
Partner._views = {
form: `
<form>
<header>
<button string="Open dialog" name="5" type="action"/>
</header>
</form>`,
"form,17": `
<form>
<footer>
<button string="I close the dialog" name="some_method" type="object" close="1"/>
</footer>
</form>`,
};
defineActions(
[
{
id: 4,
name: "Partners Action 4",
res_model: "partner",
views: [[false, "form"]],
},
{
id: 5,
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[17, "form"]],
},
],
{ mode: "replace" }
);
onRpc("/web/dataset/call_button/*", async (request) => {
const { params } = await request.json();
if (params.method === "some_method") {
return {
tag: "display_notification",
type: "ir.actions.client",
};
}
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
await getService("action").doAction(4);
expect.verifySteps(["/web/action/load", "get_views", "onchange"]);
await contains(`button[name="5"]`).click();
expect.verifySteps(["web_save", "/web/action/load", "get_views", "onchange"]);
expect(".modal").toHaveCount(1);
await contains(`button[name=some_method]`).click();
expect.verifySteps(["web_save", "some_method", "web_read"]);
expect(".modal").toHaveCount(0);
});
test.tags("mobile");
test("Button with `close` attribute closes dialog on mobile", async () => {
Partner._views = {
form: `
<form>
<header>
<button string="Open dialog" name="5" type="action"/>
</header>
</form>`,
"form,17": `
<form>
<footer>
<button string="I close the dialog" name="some_method" type="object" close="1"/>
</footer>
</form>`,
};
defineActions(
[
{
id: 4,
name: "Partners Action 4",
res_model: "partner",
views: [[false, "form"]],
},
{
id: 5,
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[17, "form"]],
},
],
{ mode: "replace" }
);
onRpc("/web/dataset/call_button/*", async (request) => {
const { params } = await request.json();
if (params.method === "some_method") {
return {
tag: "display_notification",
type: "ir.actions.client",
};
}
});
stepAllNetworkCalls();
await mountWithCleanup(WebClient);
expect.verifySteps(["/web/webclient/translations", "/web/webclient/load_menus"]);
await getService("action").doAction(4);
expect.verifySteps(["/web/action/load", "get_views", "onchange"]);
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
await contains(`button[name="5"]`).click();
expect.verifySteps(["web_save", "/web/action/load", "get_views", "onchange"]);
expect(".modal").toHaveCount(1);
await contains(`button[name=some_method]`).click();
expect.verifySteps(["web_save", "some_method", "web_read"]);
expect(".modal").toHaveCount(0);
});
test('footer buttons are updated when having another action in target "new"', async () => {
defineActions([
{
id: 25,
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[3, "form"]],
},
]);
Partner._views = {
form: `
<form>
<field name="display_name"/>
<footer>
<button string="Create" type="object" class="infooter"/>
</footer>
</form>`,
"form,3": `
<form>
<footer>
<button class="btn-primary" string="Save" special="save"/>
</footer>
</form>`,
};
await mountWithCleanup(WebClient);
await getService("action").doAction(5);
expect('.o_technical_modal .modal-body button[special="save"]').toHaveCount(0);
expect(".o_technical_modal .modal-body button.infooter").toHaveCount(0);
expect(".o_technical_modal .modal-footer button.infooter").toHaveCount(1);
expect(".o_technical_modal .modal-footer button:not(.d-none)").toHaveCount(1);
await getService("action").doAction(25);
expect(".o_technical_modal .modal-body button.infooter").toHaveCount(0);
expect(".o_technical_modal .modal-footer button.infooter").toHaveCount(0);
expect('.o_technical_modal .modal-body button[special="save"]').toHaveCount(0);
expect('.o_technical_modal .modal-footer button[special="save"]').toHaveCount(1);
expect(".o_technical_modal .modal-footer button:not(.d-none)").toHaveCount(1);
});
test('button with confirm attribute in act_window action in target="new"', async () => {
defineActions([
{
id: 999,
name: "A window action",
res_model: "partner",
target: "new",
views: [[999, "form"]],
},
]);
Partner._views["form,999"] = `
<form>
<button name="method" string="Call method" type="object" confirm="Are you sure?"/>
</form>`;
Partner._views["form,1000"] = `<form>Another action</form>`;
onRpc("method", () => {
return {
id: 1000,
name: "Another window action",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[1000, "form"]],
};
});
await mountWithCleanup(WebClient);
await getService("action").doAction(999);
expect(".modal button[name=method]").toHaveCount(1);
await contains(".modal button[name=method]").click();
expect(".modal").toHaveCount(2);
expect(".modal:last .modal-body").toHaveText("Are you sure?");
await contains(".modal:last .modal-footer .btn-primary").click();
// 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 animationFrame();
expect(".modal").toHaveCount(1);
expect(".modal main .o_content").toHaveText("Another action");
});
test('actions in target="new" do not update page title', async () => {
mockService("title", {
setParts({ action }) {
if (action) {
expect.step(action);
}
},
});
await mountWithCleanup(WebClient);
// sanity check: execute an action in target="current"
await getService("action").doAction(1);
expect.verifySteps(["Partners Action 1"]);
// execute an action in target="new"
await getService("action").doAction(5);
expect.verifySteps([]);
});
test("do not commit a dialog in error", async () => {
expect.assertions(7);
expect.errors(1);
class ErrorClientAction extends Component {
static template = xml`<div/>`;
static props = ["*"];
setup() {
throw new Error("my error");
}
}
registry.category("actions").add("failing", ErrorClientAction);
class ClientActionTargetNew extends Component {
static template = xml`<div class="my_action_new" />`;
static props = ["*"];
}
registry.category("actions").add("clientActionNew", ClientActionTargetNew);
class ClientAction extends Component {
static template = xml`
<div class="my_action" t-on-click="onClick">
My Action
</div>`;
static props = ["*"];
setup() {
this.action = useService("action");
}
async onClick() {
try {
await this.action.doAction(
{ type: "ir.actions.client", tag: "failing", target: "new" },
{ onClose: () => expect.step("failing dialog closed") }
);
} catch (e) {
expect(e.cause.message).toBe("my error");
throw e;
}
}
}
registry.category("actions").add("clientAction", ClientAction);
const errorDialogOpened = new Deferred();
patchWithCleanup(ClientErrorDialog.prototype, {
setup() {
super.setup(...arguments);
onMounted(() => errorDialogOpened.resolve());
},
});
await mountWithCleanup(WebClient);
await getService("action").doAction({ type: "ir.actions.client", tag: "clientAction" });
await contains(".my_action").click();
await errorDialogOpened;
expect(".modal").toHaveCount(1);
await contains(".modal-body button.btn-link").click();
expect(queryText(".modal-body .o_error_detail")).toInclude("my error");
expect.verifyErrors(["my error"]);
await contains(".modal-footer .btn-primary").click();
expect(".modal").toHaveCount(0);
await getService("action").doAction({
type: "ir.actions.client",
tag: "clientActionNew",
target: "new",
});
expect(".modal .my_action_new").toHaveCount(1);
expect.verifySteps([]);
});
test('breadcrumbs of actions in target="new"', async () => {
await mountWithCleanup(WebClient);
// execute an action in target="current"
await getService("action").doAction(1);
expect(queryAllTexts(".o_breadcrumb span")).toEqual(["Partners Action 1"]);
// execute an action in target="new" and a list view (s.t. there is a control panel)
await getService("action").doAction({
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
expect(".modal .o_breadcrumb").toHaveCount(0);
});
test('call switchView in an action in target="new"', async () => {
await mountWithCleanup(WebClient);
// execute an action in target="current"
await getService("action").doAction(4);
expect(".o_kanban_view").toHaveCount(1);
// execute an action in target="new" and a list view (s.t. we can call switchView)
await getService("action").doAction({
xml_id: "action_5",
name: "Create a Partner",
res_model: "partner",
target: "new",
type: "ir.actions.act_window",
views: [[false, "list"]],
});
expect(".modal .o_list_view").toHaveCount(1);
expect(".o_kanban_view").toHaveCount(1);
// 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 contains(".modal .o_data_row .o_data_cell").click();
expect(".modal .o_list_view").toHaveCount(1);
expect(".o_kanban_view").toHaveCount(1);
});
test("action with 'dialog_size' key in context", async () => {
const action = {
name: "Some Action",
res_model: "partner",
type: "ir.actions.act_window",
target: "new",
views: [[false, "form"]],
};
await mountWithCleanup(WebClient);
await getService("action").doAction(action);
expect(".o_dialog .modal-dialog").toHaveClass("modal-lg");
await getService("action").doAction({ ...action, context: { dialog_size: "small" } });
expect(".o_dialog .modal-dialog").toHaveClass("modal-sm");
await getService("action").doAction({ ...action, context: { dialog_size: "medium" } });
expect(".o_dialog .modal-dialog").toHaveClass("modal-md");
await getService("action").doAction({ ...action, context: { dialog_size: "large" } });
expect(".o_dialog .modal-dialog").toHaveClass("modal-lg");
await getService("action").doAction({ ...action, context: { dialog_size: "extra-large" } });
expect(".o_dialog .modal-dialog").toHaveClass("modal-xl");
});
test('click on record in list view action in target="new"', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "My Partners",
res_model: "partner",
type: "ir.actions.act_window",
target: "new",
views: [
[false, "list"],
[false, "form"],
],
});
// The list view has been opened in a dialog
expect(".o_dialog .modal-dialog .o_list_view").toHaveCount(1);
// click on a record in the dialog -> should do nothing as we can't switch view in the dialog
await contains(".modal .o_data_row .o_data_cell").click();
expect(".o_dialog .modal-dialog .o_list_view").toHaveCount(1);
expect(".o_form_view").toHaveCount(0);
});
});
describe("fullscreen", () => {
test('correctly execute act_window actions in target="fullscreen"', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(15);
await animationFrame(); // wait for the webclient template to be re-rendered
expect(".o_control_panel").toHaveCount(1, {
message: "should have rendered a control panel",
});
expect(".o_kanban_view").toHaveCount(1, { message: "should have rendered a kanban view" });
expect(".o_main_navbar").toHaveCount(0);
});
test('action after another in target="fullscreen" is not displayed in fullscreen mode', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(15);
await animationFrame(); // wait for the webclient template to be re-rendered
expect(".o_main_navbar").toHaveCount(0);
await getService("action").doAction(1);
await animationFrame(); // wait for the webclient template to be re-rendered
// The navbar should be displayed again
expect(".o_main_navbar").toHaveCount(1);
});
test.tags("desktop");
test('fullscreen on action change: back to a "current" action', async () => {
defineActions([
{
id: 6,
xml_id: "action_6",
name: "Partner",
res_id: 2,
res_model: "partner",
target: "current",
views: [[false, "form"]],
},
]);
Partner._views["form"] = `
<form>
<button name="15" type="action" class="oe_stat_button" />
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(6);
expect(".o_main_navbar").toHaveCount(1);
await contains("button[name='15']").click();
await animationFrame(); // wait for the webclient template to be re-rendered
expect(".o_main_navbar").toHaveCount(0);
await contains(".breadcrumb li a").click();
await animationFrame(); // wait for the webclient template to be re-rendered
expect(".o_main_navbar").toHaveCount(1);
});
test.tags("desktop");
test('fullscreen on action change: all "fullscreen" actions', async () => {
defineActions([
{
id: 6,
xml_id: "action_6",
name: "Partner",
res_id: 2,
res_model: "partner",
target: "fullscreen",
views: [[false, "form"]],
},
]);
Partner._views["form"] = `
<form>
<button name="15" type="action" class="oe_stat_button" />
</form>`;
await mountWithCleanup(WebClient);
await getService("action").doAction(6);
await animationFrame(); // for the webclient to react and remove the navbar
expect(".o_main_navbar").not.toHaveCount();
await contains("button[name='15']").click();
await animationFrame();
expect(".o_main_navbar").not.toHaveCount();
await contains(".breadcrumb li a").click();
await animationFrame();
expect(".o_main_navbar").not.toHaveCount();
});
test.tags("desktop");
test('fullscreen on action change: back to another "current" action', async () => {
defineActions([
{
id: 6,
name: "Partner",
res_id: 2,
res_model: "partner",
target: "current",
views: [[false, "form"]],
},
{
id: 24,
name: "Partner",
res_id: 2,
res_model: "partner",
views: [[666, "form"]],
},
]);
defineMenus([
{
id: 1,
name: "MAIN APP",
actionID: 6,
},
]);
Partner._views["form"] = `
<form>
<button name="24" type="action" string="Execute action 24" class="oe_stat_button"/>
</form>`;
Partner._views["form,666"] = `
<form>
<button type="action" name="15" icon="fa-star" context="{'default_partner': id}" class="oe_stat_button"/>
</form>`;
await mountWithCleanup(WebClient);
await animationFrame(); // wait for the load state (default app)
await animationFrame(); // wait for the action to be mounted
expect("nav .o_menu_brand").toHaveCount(1);
expect("nav .o_menu_brand").toHaveText("MAIN APP");
await contains("button[name='24']").click();
await animationFrame(); // wait for the webclient template to be re-rendered
expect("nav .o_menu_brand").toHaveCount(1);
await contains("button[name='15']").click();
await animationFrame(); // wait for the webclient template to be re-rendered
expect("nav.o_main_navbar").toHaveCount(0);
await contains(queryAll(".breadcrumb li a")[1]).click();
await animationFrame(); // wait for the webclient template to be re-rendered
expect("nav .o_menu_brand").toHaveCount(1);
expect("nav .o_menu_brand").toHaveText("MAIN APP");
});
});
describe("main", () => {
test.tags("desktop");
test('can execute act_window actions in target="main"', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partners Action 1");
await getService("action").doAction({
name: "Another Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
target: "main",
});
expect(".o_list_view").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Another Partner Action");
});
test.tags("desktop");
test('can switch view in an action in target="main"', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
target: "main",
});
expect(".o_list_view").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partner Action");
// open first record
await contains(".o_data_row .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
expect("ol.breadcrumb").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partner Action\nFirst record");
});
test.tags("desktop");
test('can restore an action in target="main"', async () => {
await mountWithCleanup(WebClient);
await getService("action").doAction({
name: "Partner Action",
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "list"],
[false, "form"],
],
target: "main",
});
expect(".o_list_view").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partner Action");
// open first record
await contains(".o_data_row .o_data_cell").click();
expect(".o_form_view").toHaveCount(1);
expect("ol.breadcrumb").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partner Action\nFirst record");
await getService("action").doAction(1);
expect(".o_kanban_view").toHaveCount(1);
expect("ol.breadcrumb").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
// go back to form view
await contains("ol.breadcrumb .o_back_button").click();
expect(".o_form_view").toHaveCount(1);
expect("ol.breadcrumb").toHaveCount(1);
expect(".o_breadcrumb span").toHaveCount(1);
expect(".o_control_panel .o_breadcrumb").toHaveText("Partner Action\nFirst record");
});
});

View file

@ -0,0 +1,61 @@
import { expect, test } from "@odoo/hoot";
import { getService, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
test("execute an 'ir.actions.act_url' action with target 'self'", async () => {
patchWithCleanup(browser.location, {
assign: (url) => {
expect.step(url);
},
});
await makeMockEnv();
await getService("action").doAction({
type: "ir.actions.act_url",
target: "self",
url: "/my/test/url",
});
expect.verifySteps(["/my/test/url"]);
});
test("execute an 'ir.actions.act_url' action with onClose option", async () => {
patchWithCleanup(browser, {
open: () => expect.step("browser open"),
});
await makeMockEnv();
const options = {
onClose: () => expect.step("onClose"),
};
await getService("action").doAction({ type: "ir.actions.act_url" }, options);
expect.verifySteps(["browser open", "onClose"]);
});
test("execute an 'ir.actions.act_url' action with url javascript:", async () => {
patchWithCleanup(browser.location, {
assign: (url) => {
expect.step(url);
},
});
await makeMockEnv();
await getService("action").doAction({
type: "ir.actions.act_url",
target: "self",
url: "javascript:alert()",
});
expect.verifySteps(["/javascript:alert()"]);
});
test("execute an 'ir.actions.act_url' action with target 'download'", async () => {
patchWithCleanup(browser, {
open: (url) => {
expect.step(url);
},
});
await makeMockEnv();
await getService("action").doAction({
type: "ir.actions.act_url",
target: "download",
url: "/my/test/url",
});
expect(".o_blockUI").toHaveCount(0);
expect.verifySteps(["/my/test/url"]);
});

View file

@ -0,0 +1,197 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame, press } from "@odoo/hoot-dom";
import { Deferred } from "@odoo/hoot-mock";
import {
contains,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { scanBarcode } from "@web/core/barcode/barcode_dialog";
import { BarcodeVideoScanner } from "@web/core/barcode/barcode_video_scanner";
import { WebClient } from "@web/webclient/webclient";
/* global ZXing */
test("Barcode scanner crop overlay", async () => {
const env = await makeMockEnv();
await mountWithCleanup(WebClient, { env });
const firstBarcodeValue = "Odoo";
const secondBarcodeValue = "OCDTEST";
let barcodeToGenerate = firstBarcodeValue;
let videoReady = new Deferred();
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;
ctx.strokeStyle = "black";
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.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.navigator, {
mediaDevices: {
getUserMedia: mockUserMedia,
},
});
patchWithCleanup(BarcodeVideoScanner.prototype, {
async isVideoReady() {
const result = await super.isVideoReady(...arguments);
videoReady.resolve();
return result;
},
onResize(overlayInfo) {
expect.step(overlayInfo);
return super.onResize(...arguments);
},
});
const firstBarcodeFound = scanBarcode(env);
await videoReady;
await contains(".o_crop_icon").dragAndDrop(".o_crop_container", {
relative: true,
position: {
x: 0,
y: 0,
},
});
const firstValueScanned = await firstBarcodeFound;
expect(firstValueScanned).toBe(firstBarcodeValue, {
message: `The detected barcode (${firstValueScanned}) 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 = new Deferred();
const secondBarcodeFound = scanBarcode(env);
await videoReady;
const secondValueScanned = await secondBarcodeFound;
expect(secondValueScanned).toBe(secondBarcodeValue, {
message: `The detected barcode (${secondValueScanned}) should be the same as generated (${secondBarcodeValue})`,
});
expect.verifySteps([
{ x: 25, y: 100, width: 200, height: 50 },
{ x: 0, y: 0, width: 250, height: 250 },
{ x: 0, y: 0, width: 250, height: 250 },
]);
});
test("BarcodeVideoScanner onReady props", async () => {
function mockUserMedia() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const stream = canvas.captureStream();
canvas.width = 250;
canvas.height = 250;
ctx.strokeStyle = "black";
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
return stream;
}
// Simulate an environment with a camera/webcam.
patchWithCleanup(browser.navigator, {
mediaDevices: {
getUserMedia: mockUserMedia,
},
});
const resolvedOnReadyPromise = new Promise((resolve) => {
mountWithCleanup(BarcodeVideoScanner, {
props: {
facingMode: "environment",
onReady: () => resolve(true),
onResult: () => {},
onError: () => {},
},
});
});
expect(await resolvedOnReadyPromise).toBe(true);
});
test("Closing barcode scanner before camera loads should not throw an error", async () => {
const env = await makeMockEnv();
await mountWithCleanup(WebClient, { env });
const cameraReady = new Deferred();
patchWithCleanup(browser.navigator, {
mediaDevices: {
async getUserMedia() {
await cameraReady;
const canvas = document.createElement("canvas");
return canvas.captureStream();
},
},
});
scanBarcode(env);
await animationFrame();
expect(".o-barcode-modal").toHaveCount(1)
await press("escape");
await animationFrame();
expect(".o-barcode-modal").toHaveCount(0)
cameraReady.resolve();
await animationFrame()
expect(".o_error_dialog").toHaveCount(0)
});
test("Closing barcode scanner while video is loading should not cause errors", async () => {
const env = await makeMockEnv();
await mountWithCleanup(WebClient, { env });
patchWithCleanup(browser.navigator, {
mediaDevices: {
async getUserMedia() {
const canvas = document.createElement("canvas");
return canvas.captureStream();
},
},
});
scanBarcode(env);
await animationFrame();
expect(".o-barcode-modal").toHaveCount(1)
await press("escape");
await animationFrame();
expect(".o-barcode-modal").toHaveCount(0)
await animationFrame()
expect(".o_error_dialog").toHaveCount(0)
});

View file

@ -0,0 +1,626 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred, mockDate, runAllTimers, tick } from "@odoo/hoot-mock";
import {
defineActions,
defineMenus,
defineModels,
fields,
makeServerError,
models,
mountWithCleanup,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { onWillStart, onWillUpdateProps } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { ListRenderer } from "@web/views/list/list_renderer";
import { SUCCESS_SIGNAL } from "@web/webclient/clickbot/clickbot";
import { WebClient } from "@web/webclient/webclient";
class Foo extends models.Model {
foo = fields.Char();
bar = fields.Boolean();
date = fields.Date();
_records = [
{ id: 1, bar: true, foo: "yop", date: "2017-01-25" },
{ id: 2, bar: true, foo: "blip" },
{ id: 3, bar: true, foo: "gnap" },
{ id: 4, bar: false, foo: "blip" },
];
_views = {
search: /* xml */ `
<search>
<filter string="Not Bar" name="not bar" domain="[['bar','=',False]]"/>
<filter string="Date" name="date" date="date"/>
</search>
`,
list: /* xml */ `
<list>
<field name="foo" />
</list>
`,
kanban: /* xml */ `
<kanban class="o_kanban_test">
<templates><t t-name="card">
<field name="foo"/>
</t></templates>
</kanban>
`,
};
}
describe.current.tags("desktop");
defineModels([Foo]);
beforeEach(() => {
onRpc("has_group", () => true);
defineActions([
{
id: 1001,
name: "App1",
res_model: "foo",
views: [
[false, "list"],
[false, "kanban"],
],
xml_id: "app1",
},
{
id: 1002,
name: "App2 Menu 1",
res_model: "foo",
views: [[false, "kanban"]],
xml_id: "app2_menu1",
},
{
id: 1022,
name: "App2 Menu 2",
res_model: "foo",
views: [[false, "list"]],
xml_id: "app2_menu2",
},
]);
});
test("clickbot clickeverywhere test", async () => {
onRpc("has_group", () => true);
mockDate("2017-10-08T15:35:11.000");
const clickEverywhereDef = new Deferred();
patchWithCleanup(browser, {
console: {
log: (msg) => {
expect.step(msg);
if (msg === SUCCESS_SIGNAL) {
clickEverywhereDef.resolve();
}
},
error: (msg) => {
expect.step(msg);
clickEverywhereDef.resolve();
},
},
});
defineMenus([
{ id: 1, name: "App1", appID: 1, actionID: 1001, xmlid: "app1" },
{
id: 2,
children: [
{
id: 3,
name: "menu 1",
appID: 2,
actionID: 1002,
xmlid: "app2_menu1",
},
{
id: 4,
name: "menu 2",
appID: 2,
actionID: 1022,
xmlid: "app2_menu2",
},
],
name: "App2",
appID: 2,
actionID: 1002,
xmlid: "app2",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
window.clickEverywhere();
await clickEverywhereDef;
expect.verifySteps([
"Clicking on: apps menu toggle button",
"Testing app menu: app1",
"Testing menu App1 app1",
'Clicking on: menu item "App1"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Testing view switch: kanban",
"Clicking on: kanban view switcher",
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Clicking on: apps menu toggle button",
"Testing app menu: app2",
"Testing menu App2 app2",
'Clicking on: menu item "App2"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Testing menu menu 1 app2_menu1",
'Clicking on: menu item "menu 1"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Testing menu menu 2 app2_menu2",
'Clicking on: menu item "menu 2"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Successfully tested 2 apps",
"Successfully tested 2 menus",
"Successfully tested 0 modals",
"Successfully tested 10 filters",
SUCCESS_SIGNAL,
]);
});
test("clickbot clickeverywhere test (with dropdown menu)", async () => {
onRpc("has_group", () => true);
mockDate("2017-10-08T15:35:11.000");
const clickEverywhereDef = new Deferred();
patchWithCleanup(browser, {
console: {
log: (msg) => {
expect.step(msg);
if (msg === SUCCESS_SIGNAL) {
clickEverywhereDef.resolve();
}
},
error: (msg) => {
expect.step(msg);
clickEverywhereDef.resolve();
},
},
});
defineMenus(
[
{
id: 2,
children: [
{
id: 5,
children: [
{
id: 3,
name: "menu 1",
appID: 2,
actionID: 1002,
xmlid: "app2_menu1",
},
{
id: 4,
name: "menu 2",
appID: 2,
actionID: 1022,
xmlid: "app2_menu2",
},
],
name: "a dropdown",
appID: 2,
xmlid: "app2_dropdown_menu",
},
],
name: "App2",
appID: 2,
actionID: 1002,
xmlid: "app2",
},
],
{ mode: "replace" }
);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
await runAllTimers();
await animationFrame();
expect(".o_menu_sections .dropdown-toggle").toHaveText("a dropdown");
window.clickEverywhere();
await clickEverywhereDef;
expect.verifySteps([
"Clicking on: apps menu toggle button",
"Testing app menu: app2",
"Testing menu App2 app2",
'Clicking on: menu item "App2"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Clicking on: menu toggler",
"Testing menu menu 1 app2_menu1",
'Clicking on: menu item "menu 1"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Clicking on: menu toggler",
"Testing menu menu 2 app2_menu2",
'Clicking on: menu item "menu 2"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Successfully tested 1 apps",
"Successfully tested 2 menus",
"Successfully tested 0 modals",
"Successfully tested 6 filters",
SUCCESS_SIGNAL,
]);
});
test("clickbot test waiting rpc after clicking filter", async () => {
const clickEverywhereDef = new Deferred();
let clickBotStarted = false;
patchWithCleanup(browser, {
console: {
log: (msg) => {
if (msg === SUCCESS_SIGNAL) {
expect.step(msg);
clickEverywhereDef.resolve();
}
},
error: () => {
clickEverywhereDef.resolve();
},
},
});
onRpc("web_search_read", async () => {
if (clickBotStarted) {
expect.step("web_search_read called");
await tick();
expect.step("response");
}
});
defineActions(
[
{
id: 1,
res_model: "foo",
views: [[false, "list"]],
},
],
{ mode: "replace" }
);
defineMenus([
{
id: 1,
actionID: 1,
xmlid: "app1",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
await runAllTimers();
await animationFrame();
clickBotStarted = true;
window.clickEverywhere();
await clickEverywhereDef;
expect.verifySteps([
"web_search_read called", // click on the App
"response",
"web_search_read called", // click on the Filter
"response",
"web_search_read called", // click on the Second Filter
"response",
SUCCESS_SIGNAL,
]);
});
test("clickbot show rpc error when an error dialog is detected", async () => {
expect.errors(1);
onRpc("has_group", () => true);
mockDate("2024-04-10T00:00:00.000");
const clickEverywhereDef = new Deferred();
let clickBotStarted = false;
let id = 1;
patchWithCleanup(browser, {
console: {
log: (msg) => {
if (msg === "test successful") {
expect.step(msg);
clickEverywhereDef.resolve();
}
},
error: (msg) => {
// Replace msg with null id as JSON-RPC ids are not reset between two tests
expect.step(msg.toString().replaceAll(/"id":\d+,/g, `"id":null,`));
clickEverywhereDef.resolve();
},
},
});
onRpc("web_search_read", () => {
if (clickBotStarted) {
if (id === 3) {
// click on the Second Filter
throw makeServerError({
message: "This is a server Error, it should be displayed in an error dialog",
type: "Programming error",
});
}
id++;
}
});
defineActions([
{
id: 1,
name: "App1",
res_model: "foo",
views: [[false, "list"]],
},
]);
defineMenus([
{
id: 1,
name: "App1",
appID: 1,
actionID: 1001,
xmlid: "app1",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
await runAllTimers();
await animationFrame();
clickBotStarted = true;
window.clickEverywhere();
await clickEverywhereDef;
await tick();
const expectedRpcData = JSON.stringify({
data: {
id: null,
jsonrpc: "2.0",
method: "call",
params: {
model: "foo",
method: "web_search_read",
args: [],
kwargs: {
specification: { foo: {} },
offset: 0,
order: "",
limit: 80,
context: {
lang: "en",
tz: "taht",
uid: 7,
allowed_company_ids: [1],
bin_size: true,
current_company_id: 1,
},
count_limit: 10001,
domain: [
"|",
["bar", "=", false],
"&",
["date", ">=", "2024-04-01"],
["date", "<=", "2024-04-30"],
],
},
},
},
settings: { silent: false },
error: {
name: "RPC_ERROR",
type: "server",
code: 200,
data: {
name: "odoo.exceptions.Programming error",
debug: "traceback",
arguments: [],
context: {},
},
exceptionName: "odoo.exceptions.Programming error",
subType: "server",
message: "This is a server Error, it should be displayed in an error dialog",
id: null,
model: "foo",
errorEvent: { isTrusted: true },
},
});
const expectedModalHtml = /* xml */ `
<header class="modal-header">
<h4 class="modal-title text-break flex-grow-1">Oops!</h4>
<button type="button" class="btn-close" aria-label="Close" tabindex="-1"></button>
</header>
<main class="modal-body">
<div role="alert">
<p class="text-prewrap"> Something went wrong... If you really are stuck, share the report with your friendly support service </p>
<button class="btn btn-link p-0">See technical details</button>
</div>
</main>
<footer class="modal-footer justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
<button class="btn btn-primary o-default-button">Close</button>
</footer>`
.trim()
.replaceAll(/>[\n\s]+</gm, "><");
expect.verifyErrors(["This is a server Error"]);
expect.verifySteps([
`A RPC in error was detected, maybe it's related to the error dialog : ${expectedRpcData}`,
"Error while testing App1 app1",
`Error: Error dialog detected${expectedModalHtml}`,
]);
});
test("clickbot test waiting render after clicking filter", async () => {
onRpc("has_group", () => true);
const clickEverywhereDef = new Deferred();
let clickBotStarted = false;
patchWithCleanup(browser, {
console: {
log: (msg) => {
if (msg === SUCCESS_SIGNAL) {
expect.step(msg);
clickEverywhereDef.resolve();
}
},
error: () => {
clickEverywhereDef.resolve();
},
},
});
patchWithCleanup(ListRenderer.prototype, {
setup() {
super.setup(...arguments);
onWillStart(async () => {
if (clickBotStarted) {
expect.step("onWillStart called");
await runAllTimers();
expect.step("response");
}
});
onWillUpdateProps(async () => {
if (clickBotStarted) {
expect.step("onWillUpdateProps called");
await runAllTimers();
expect.step("response");
}
});
},
});
defineActions([
{
id: 1,
res_model: "foo",
views: [[false, "list"]],
},
]);
defineMenus([
{
id: 1,
actionID: 1001,
xmlid: "app1",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
await runAllTimers();
await animationFrame();
clickBotStarted = true;
window.clickEverywhere();
await clickEverywhereDef;
expect.verifySteps([
"onWillStart called", // click on APP
"response",
"onWillUpdateProps called", // click on filter
"response",
"onWillUpdateProps called", // click on second filter
"response",
SUCCESS_SIGNAL,
]);
});
test("clickbot clickeverywhere menu modal", async () => {
onRpc("has_group", () => true);
mockDate("2017-10-08T15:35:11.000");
Foo._views.form = /* xml */ `
<form>
<field name="foo"/>
</form>
`;
const clickEverywhereDef = new Deferred();
patchWithCleanup(browser, {
console: {
log: (msg) => {
expect.step(msg);
if (msg === SUCCESS_SIGNAL) {
clickEverywhereDef.resolve();
}
},
error: (msg) => {
expect.step(msg);
clickEverywhereDef.resolve();
},
},
});
defineActions([
{
id: 1099,
name: "Modal",
res_model: "foo",
views: [[false, "form"]],
view_mode: "form",
target: "new",
},
]);
defineMenus([
{
id: 1,
name: "App1",
actionID: 1001,
xmlid: "app1",
},
{
id: 2,
name: "App Modal",
actionID: 1099,
xmlid: "test.modal",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
window.clickEverywhere();
await clickEverywhereDef;
expect.verifySteps([
"Clicking on: apps menu toggle button",
"Testing app menu: app1",
"Testing menu App1 app1",
'Clicking on: menu item "App1"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Testing view switch: kanban",
"Clicking on: kanban view switcher",
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Clicking on: apps menu toggle button",
"Testing app menu: test.modal",
"Testing menu App Modal test.modal",
'Clicking on: menu item "App Modal"',
"Modal detected: App Modal test.modal",
"Clicking on: modal close button",
"Successfully tested 2 apps",
"Successfully tested 0 menus",
"Successfully tested 1 modals",
"Successfully tested 4 filters",
SUCCESS_SIGNAL,
]);
});

View file

@ -0,0 +1,72 @@
import { expect, test } from "@odoo/hoot";
import {
defineModels,
getService,
makeMockEnv,
mockService,
models,
serverState,
} from "@web/../tests/web_test_helpers";
import { cookie } from "@web/core/browser/cookie";
import { rpcBus } from "@web/core/network/rpc";
class Company extends models.Model {
_name = "res.company";
}
class Notacompany extends models.Model {}
defineModels([Company, Notacompany]);
test("reload webclient when updating a res.company", async () => {
mockService("action", {
async doAction(action) {
expect.step(action);
},
});
await makeMockEnv();
expect.verifySteps([]);
await getService("orm").read("res.company", [32]);
expect.verifySteps([]);
await getService("orm").unlink("res.company", [32]);
expect.verifySteps(["reload_context"]);
await getService("orm").unlink("notacompany", [32]);
expect.verifySteps([]);
});
test("do not reload webclient when updating a res.company, but there is an error", async () => {
mockService("action", {
async doAction(action) {
expect.step(action);
},
});
await makeMockEnv();
expect.verifySteps([]);
rpcBus.trigger("RPC:RESPONSE", {
data: { params: { model: "res.company", method: "write" } },
settings: {},
result: {},
});
expect.verifySteps(["reload_context"]);
rpcBus.trigger("RPC:RESPONSE", {
data: { params: { model: "res.company", method: "write" } },
settings: {},
error: {},
});
expect.verifySteps([]);
});
test("extract allowed company ids from cookies", async () => {
serverState.companies = [
{ id: 1, name: "Company 1", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Company 2", sequence: 2, parent_id: false, child_ids: [] },
{ id: 3, name: "Company 3", sequence: 3, parent_id: false, child_ids: [] },
];
cookie.set("cids", "3-1");
await makeMockEnv();
expect(Object.values(getService("company").allowedCompanies).map((c) => c.id)).toEqual([
1, 2, 3,
]);
expect(getService("company").activeCompanyIds).toEqual([3, 1]);
expect(getService("company").currentCompany.id).toBe(3);
});

View file

@ -0,0 +1,67 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-dom";
import {
defineModels,
getService,
makeMockEnv,
models,
onRpc,
} from "@web/../tests/web_test_helpers";
import { currencies } from "@web/core/currency";
import { rpcBus } from "@web/core/network/rpc";
class Currency extends models.Model {
_name = "res.currency";
}
class Notcurrency extends models.Model {}
defineModels([Currency, Notcurrency]);
test("reload currencies when updating a res.currency", async () => {
onRpc(({ route }) => {
expect.step(route);
});
onRpc("/web/session/get_session_info", ({ url }) => {
expect.step(new URL(url).pathname);
return {
uid: 1,
currencies: {
7: { symbol: "$", position: "before", digits: 2 },
},
};
});
await makeMockEnv();
expect.verifySteps([]);
await getService("orm").read("res.currency", [32]);
expect.verifySteps(["/web/dataset/call_kw/res.currency/read"]);
await getService("orm").unlink("res.currency", [32]);
expect.verifySteps([
"/web/dataset/call_kw/res.currency/unlink",
"/web/session/get_session_info",
]);
await getService("orm").unlink("notcurrency", [32]);
expect.verifySteps(["/web/dataset/call_kw/notcurrency/unlink"]);
expect(Object.keys(currencies)).toEqual(["7"]);
});
test("do not reload webclient when updating a res.currency, but there is an error", async () => {
onRpc("/web/session/get_session_info", ({ url }) => {
expect.step(new URL(url).pathname);
});
await makeMockEnv();
expect.verifySteps([]);
rpcBus.trigger("RPC:RESPONSE", {
data: { params: { model: "res.currency", method: "write" } },
settings: {},
result: {},
});
await animationFrame();
expect.verifySteps(["/web/session/get_session_info"]);
rpcBus.trigger("RPC:RESPONSE", {
data: { params: { model: "res.currency", method: "write" } },
settings: {},
error: {},
});
expect.verifySteps([]);
});

View file

@ -0,0 +1,131 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
getService,
mountWithCleanup,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { rpcBus } from "@web/core/network/rpc";
import { config as transitionConfig } from "@web/core/transition";
import { LoadingIndicator } from "@web/webclient/loading_indicator/loading_indicator";
const payload = (id) => ({ data: { id, params: { model: "", method: "" } }, settings: {} });
beforeEach(() => {
patchWithCleanup(transitionConfig, { disabled: true });
});
test("displays the loading indicator in non debug mode", async () => {
await mountWithCleanup(LoadingIndicator, { noMainContainer: true });
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
rpcBus.trigger("RPC:REQUEST", payload(1));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(1, {
message: "the loading indicator should be displayed",
});
expect(".o_loading_indicator").toHaveText("Loading", {
message: "the loading indicator should display 'Loading'",
});
rpcBus.trigger("RPC:RESPONSE", payload(1));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
});
test("displays the loading indicator for one rpc in debug mode", async () => {
serverState.debug = "1";
await mountWithCleanup(LoadingIndicator, { noMainContainer: true });
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
rpcBus.trigger("RPC:REQUEST", payload(1));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(1, {
message: "the loading indicator should be displayed",
});
expect(".o_loading_indicator").toHaveText("Loading (1)", {
message: "the loading indicator should indicate 1 request in progress",
});
rpcBus.trigger("RPC:RESPONSE", payload(1));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
});
test("displays the loading indicator for multi rpc in debug mode", async () => {
serverState.debug = "1";
await mountWithCleanup(LoadingIndicator, { noMainContainer: true });
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
rpcBus.trigger("RPC:REQUEST", payload(1));
rpcBus.trigger("RPC:REQUEST", payload(2));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(1, {
message: "the loading indicator should be displayed",
});
expect(".o_loading_indicator").toHaveText("Loading (2)", {
message: "the loading indicator should indicate 2 requests in progress.",
});
rpcBus.trigger("RPC:REQUEST", payload(3));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveText("Loading (3)", {
message: "the loading indicator should indicate 3 requests in progress.",
});
rpcBus.trigger("RPC:RESPONSE", payload(1));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveText("Loading (2)", {
message: "the loading indicator should indicate 2 requests in progress.",
});
rpcBus.trigger("RPC:REQUEST", payload(4));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveText("Loading (3)", {
message: "the loading indicator should indicate 3 requests in progress.",
});
rpcBus.trigger("RPC:RESPONSE", payload(2));
rpcBus.trigger("RPC:RESPONSE", payload(3));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveText("Loading (1)", {
message: "the loading indicator should indicate 1 request in progress.",
});
rpcBus.trigger("RPC:RESPONSE", payload(4));
await runAllTimers();
await animationFrame();
expect(".o_loading_indicator").toHaveCount(0, {
message: "the loading indicator should not be displayed",
});
});
test("loading indicator is not displayed immediately", async () => {
await mountWithCleanup(LoadingIndicator, { noMainContainer: true });
const ui = getService("ui");
ui.bus.addEventListener("BLOCK", () => {
expect.step("block");
});
ui.bus.addEventListener("UNBLOCK", () => {
expect.step("unblock");
});
rpcBus.trigger("RPC:REQUEST", payload(1));
await animationFrame();
expect(".o_loading_indicator").toHaveCount(0);
await advanceTime(400);
expect(".o_loading_indicator").toHaveCount(1);
rpcBus.trigger("RPC:RESPONSE", payload(1));
await animationFrame();
expect(".o_loading_indicator").toHaveCount(0);
});

View file

@ -0,0 +1,193 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { click, queryAll } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
contains,
defineActions,
defineMenus,
getService,
mountWithCleanup,
patchWithCleanup,
useTestClientAction,
} from "@web/../tests/web_test_helpers";
import { config as transitionConfig } from "@web/core/transition";
import { WebClient } from "@web/webclient/webclient";
import { registry } from "@web/core/registry";
describe.current.tags("mobile");
beforeEach(() => {
const testAction = useTestClientAction();
defineActions([
{ ...testAction, id: 1001, params: { description: "Id 1" } },
{ ...testAction, id: 1002, params: { description: "Info" } },
{ ...testAction, id: 1003, params: { description: "Report" } },
]);
defineMenus([
{ id: 0 }, // prevents auto-loading the first action
{ id: 1, name: "App1", actionID: 1001, xmlid: "menu_1" },
]);
patchWithCleanup(transitionConfig, { disabled: true });
});
test("Burger menu can be opened and closed", async () => {
await mountWithCleanup(WebClient);
await contains(".o_mobile_menu_toggle", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(1);
await contains(".o_sidebar_close", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(0);
});
test("Burger Menu on an App", async () => {
defineMenus([
{
id: 1,
children: [
{
id: 99,
name: "SubMenu",
appID: 1,
actionID: 1002,
xmlid: "",
webIconData: undefined,
webIcon: false,
},
],
},
]);
await mountWithCleanup(WebClient);
await contains("a.o_menu_toggle", { root: document.body }).click();
await contains(".o_sidebar_topbar a.btn-primary", { root: document.body }).click();
await contains(".o_burger_menu_content li:nth-of-type(2)", { root: document.body }).click();
expect(queryAll(".o_burger_menu_content", { root: document.body })).toHaveCount(0);
await contains("a.o_menu_toggle", { root: document.body }).click();
expect(
queryAll(".o_app_menu_sidebar nav.o_burger_menu_content", { root: document.body })
).toHaveText("App1\nSubMenu");
await click(".modal-backdrop", { root: document.body });
await contains(".o_mobile_menu_toggle", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(1);
expect(
queryAll(".o_burger_menu nav.o_burger_menu_content", { root: document.body })
).toHaveCount(1);
expect(queryAll(".o_burger_menu_content", { root: document.body })).toHaveClass(
"o_burger_menu_app"
);
await click(".o_sidebar_topbar", { root: document.body });
expect(queryAll(".o_burger_menu_content", { root: document.body })).not.toHaveClass(
"o_burger_menu_dark"
);
await click(".o_sidebar_topbar", { root: document.body });
expect(queryAll(".o_burger_menu_content", { root: document.body })).toHaveClass(
"o_burger_menu_app"
);
});
test("Burger Menu on an App without SubMenu", async () => {
await mountWithCleanup(WebClient);
await contains("a.o_menu_toggle", { root: document.body }).click();
await contains(".o_sidebar_topbar a.btn-primary", { root: document.body }).click();
await contains(".o_burger_menu_content li:nth-of-type(2)", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(0);
await contains(".o_mobile_menu_toggle", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(1);
expect(queryAll(".o_user_menu_mobile", { root: document.body })).toHaveCount(1);
await click(".o_sidebar_close", { root: document.body });
expect(queryAll(".o_burger_menu")).toHaveCount(0);
});
test("Burger menu closes when an action is requested", async () => {
await mountWithCleanup(WebClient);
await contains(".o_mobile_menu_toggle", { root: document.body }).click();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(1);
expect(queryAll(".test_client_action", { root: document.body })).toHaveCount(0);
await getService("action").doAction(1001);
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(0);
expect(queryAll(".o_kanban_view", { root: document.body })).toHaveCount(0);
expect(queryAll(".test_client_action", { root: document.body })).toHaveCount(1);
});
test("Burger menu closes when click on menu item", async () => {
defineMenus([
{
id: 1,
children: [
{
id: 99,
name: "SubMenu",
actionID: 1002,
xmlid: "",
webIconData: undefined,
webIcon: false,
},
],
},
{ id: 2, name: "App2", actionID: 1003, xmlid: "menu_2" },
]);
await mountWithCleanup(WebClient);
getService("menu").setCurrentMenu(2);
await contains(".o_menu_toggle", { root: document.body }).click();
expect(
queryAll(".o_app_menu_sidebar nav.o_burger_menu_content", { root: document.body })
).toHaveText("App2");
await contains(".oi-apps", { root: document.body }).click();
expect(
queryAll(".o_app_menu_sidebar nav.o_burger_menu_content", { root: document.body })
).toHaveText("App0\nApp1\nApp2");
await contains(".o_burger_menu_app > ul > li:nth-of-type(2)", { root: document.body }).click();
expect(queryAll(".o_burger_menu_app")).toHaveCount(0);
await contains(".o_menu_toggle", { root: document.body }).click();
expect(queryAll(".o_burger_menu_app", { root: document.body })).toHaveCount(1);
expect(
queryAll(".o_app_menu_sidebar nav.o_burger_menu_content", { root: document.body })
).toHaveText("App1\nSubMenu");
await click(".o_burger_menu_content li:nth-of-type(1)", { root: document.body });
// click
await animationFrame();
// action
await animationFrame();
// close burger
await animationFrame();
expect(queryAll(".o_burger_menu_content", { root: document.body })).toHaveCount(0);
expect(queryAll(".test_client_action", { root: document.body })).toHaveCount(1);
});
test("Burger menu closes when click on user menu item", async () => {
registry.category("user_menuitems").add("ring_item", () => ({
type: "item",
id: "ring",
description: "Ring",
callback: () => {
expect.step("callback ring_item");
},
sequence: 5,
}));
await mountWithCleanup(WebClient);
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(0);
await click(queryAll(".o_mobile_menu_toggle", { root: document.body }));
await animationFrame();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(1);
await click(queryAll(".o_burger_menu .o_user_menu_mobile a", { root: document.body }));
await animationFrame();
expect(queryAll(".o_burger_menu", { root: document.body })).toHaveCount(0);
expect.verifySteps(["callback ring_item"]);
});

View file

@ -0,0 +1,305 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
contains,
getService,
mountWithCleanup,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { cookie } from "@web/core/browser/cookie";
import { MobileSwitchCompanyMenu } from "@web/webclient/burger_menu/mobile_switch_company_menu/mobile_switch_company_menu";
describe.current.tags("mobile");
const clickConfirm = () => contains(".o_switch_company_menu_buttons button:first").click();
const stepOnCookieChange = () =>
patchWithCleanup(cookie, {
set(key, value) {
if (key === "cids") {
expect.step(value);
}
return super.set(key, value);
},
});
/**
* @param {number} index
*/
const toggleCompany = async (index) =>
contains(`[data-company-id] [role=menuitemcheckbox]:eq(${index})`).click();
beforeEach(() => {
serverState.companies = [
{ id: 1, name: "Hermit", parent_id: false, child_ids: [] },
{ id: 2, name: "Herman's", parent_id: false, child_ids: [] },
{ id: 3, name: "Heroes TM", parent_id: false, child_ids: [] },
];
});
test("basic rendering", async () => {
await mountWithCleanup(MobileSwitchCompanyMenu);
expect(".o_burger_menu_companies").toHaveProperty("tagName", "DIV");
expect(".o_burger_menu_companies").toHaveClass("o_burger_menu_companies");
expect("[data-company-id]").toHaveCount(3);
expect(".log_into").toHaveCount(3);
expect(".fa-check-square").toHaveCount(1);
expect(".fa-square-o").toHaveCount(2);
expect(".o_switch_company_item:eq(0)").toHaveText("Hermit");
expect(".o_switch_company_item:eq(0)").toHaveClass("alert-secondary");
expect(".o_switch_company_item:eq(1)").toHaveText("Herman's");
expect(".o_switch_company_item:eq(2)").toHaveText("Heroes TM");
expect(".o_switch_company_item i:eq(0)").toHaveClass("fa-check-square");
expect(".o_switch_company_item i:eq(1)").toHaveClass("fa-square-o");
expect(".o_switch_company_item i:eq(2)").toHaveClass("fa-square-o");
expect(".o_burger_menu_companies").toHaveText("Companies\nHermit\nHerman's\nHeroes TM");
});
test("companies can be toggled: toggle a second company", async () => {
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [x] **Company 1**
* [x] Company 2 -> toggle
* [ ] Company 3
*/
await toggleCompany(1);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
await clickConfirm();
expect.verifySteps(["1-2"]);
});
test("can toggle multiple companies at once", async () => {
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [ ] **Company 1** -> toggle all
* [x] Company 2 -> toggle all
* [x] Company 3 -> toggle all
*/
await toggleCompany(0);
await toggleCompany(1);
await toggleCompany(2);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
expect.verifySteps([]);
await clickConfirm();
expect.verifySteps(["2-3"]);
});
test("single company selected: toggling it off will keep it", async () => {
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
await runAllTimers();
expect(cookie.get("cids")).toBe("1");
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [ ] **Company 1** -> toggle off
* [ ] Company 2
* [ ] Company 3
*/
await toggleCompany(0);
await clickConfirm();
expect.verifySteps(["1"]);
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id] .fa-check-squarqe").toHaveCount(0);
expect("[data-company-id] .fa-square-o").toHaveCount(3);
});
test("single company mode: companies can be logged in", async () => {
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [x] **Company 1**
* [ ] Company 2 -> log into
* [ ] Company 3
*/
await contains(".log_into:eq(1)").click();
expect.verifySteps(["2"]);
});
test("multi company mode: log into a non selected company", async () => {
cookie.set("cids", "3-1");
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["3-1"]);
/**
* [x] Company 1
* [ ] Company 2
* [x] **Company 3**
*/
expect(getService("company").activeCompanyIds).toEqual([3, 1]);
expect(getService("company").currentCompany.id).toBe(3);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
/**
* [x] Company 1
* [ ] Company 2 -> log into
* [x] **Company 3**
*/
await contains(".log_into:eq(1)").click();
expect.verifySteps(["2-3-1"]);
});
test("multi company mode: log into an already selected company", async () => {
cookie.set("cids", "2-3");
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["2-3"]);
/**
* [ ] Company 1
* [x] **Company 2**
* [x] Company 3
*/
expect(getService("company").activeCompanyIds).toEqual([2, 3]);
expect(getService("company").currentCompany.id).toBe(2);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
/**
* [ ] Company 1
* [x] **Company 2**
* [x] Company 3 -> log into
*/
await contains(".log_into:eq(2)").click();
expect.verifySteps(["3-2"]);
});
test("companies can be logged in even if some toggled within delay", async () => {
stepOnCookieChange();
await mountWithCleanup(MobileSwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [x] **Company 1**
* [ ] Company 2
* [ ] Company 3
*/
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [ ] **Company 1** -> toggled
* [ ] Company 2 -> logged in
* [ ] Company 3 -> toggled
*/
await contains("[data-company-id] [role=menuitemcheckbox]:eq(2)").click();
await contains("[data-company-id] [role=menuitemcheckbox]:eq(0)").click();
await contains(".log_into:eq(1)").click();
expect.verifySteps(["2"]);
});
test("show confirm and reset buttons only when selection has changed", async () => {
await mountWithCleanup(MobileSwitchCompanyMenu);
expect(".o_switch_company_menu_buttons").toHaveCount(0);
await toggleCompany(1);
expect(".o_switch_company_menu_buttons button").toHaveCount(2);
await toggleCompany(1);
expect(".o_switch_company_menu_buttons").toHaveCount(0);
});
test("No collapse and no search input when less that 10 companies", async () => {
await mountWithCleanup(MobileSwitchCompanyMenu);
expect(".o_burger_menu_companies .fa-caret-right").toHaveCount(0);
expect(".o_burger_menu_companies .visually-hidden input").toHaveCount(1);
});
test("Show search input when more that 10 companies & search filters items but ignore case and spaces", async () => {
serverState.companies = [
{ id: 3, name: "Hermit", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Herman's", sequence: 2, parent_id: false, child_ids: [] },
{ id: 1, name: "Heroes TM", sequence: 3, parent_id: false, child_ids: [4, 5] },
{ id: 4, name: "Hercules", sequence: 4, parent_id: 1, child_ids: [] },
{ id: 5, name: "Hulk", sequence: 5, parent_id: 1, child_ids: [] },
{ id: 6, name: "Random Company a", sequence: 6, parent_id: false, child_ids: [7, 8] },
{ id: 7, name: "Random Company aa", sequence: 7, parent_id: 6, child_ids: [] },
{ id: 8, name: "Random Company ab", sequence: 8, parent_id: 6, child_ids: [] },
{ id: 9, name: "Random d", sequence: 9, parent_id: false, child_ids: [] },
{ id: 10, name: "Random e", sequence: 10, parent_id: false, child_ids: [] },
];
await mountWithCleanup(MobileSwitchCompanyMenu);
await contains(".o_burger_menu_companies > div").click();
expect(".o_burger_menu_companies input").toHaveCount(1);
expect(".o_burger_menu_companies input").not.toBeFocused();
expect(".o_switch_company_item").toHaveCount(10);
contains(".o_burger_menu_companies input").edit("omcom");
await animationFrame();
expect(".o_switch_company_item").toHaveCount(3);
expect(queryAllTexts(".o_switch_company_item.o-navigable")).toEqual([
"Random Company a",
"Random Company aa",
"Random Company ab",
]);
});

View file

@ -0,0 +1,500 @@
import { beforeEach, destroy, expect, test } from "@odoo/hoot";
import { queryAll, queryAllAttributes, queryAllTexts, resize } from "@odoo/hoot-dom";
import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
clearRegistry,
contains,
defineMenus,
getService,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Component, onRendered, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { NavBar } from "@web/webclient/navbar/navbar";
const systrayRegistry = registry.category("systray");
// Debounce time for Adaptation (`debouncedAdapt`) on resize event in navbar
const waitNavbarAdaptation = () => advanceTime(500);
class MySystrayItem extends Component {
static props = ["*"];
static template = xml`<li class="my-item">my item</li>`;
}
beforeEach(async () => {
systrayRegistry.add("addon.myitem", { Component: MySystrayItem });
defineMenus([{ id: 1 }]);
return () => {
clearRegistry(systrayRegistry);
};
});
test.tags("desktop");
test("can be rendered", async () => {
await mountWithCleanup(NavBar);
expect(".o_navbar_apps_menu button.dropdown-toggle").toHaveCount(1, {
message: "1 apps menu toggler present",
});
});
test.tags("desktop");
test("dropdown menu can be toggled", async () => {
await mountWithCleanup(NavBar);
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(".dropdown-menu").toHaveCount(1);
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(".dropdown-menu").toHaveCount(0);
});
test.tags("desktop");
test("href attribute on apps menu items", async () => {
defineMenus([{ id: 1, actionID: 339 }]);
await mountWithCleanup(NavBar);
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(".o-dropdown--menu .dropdown-item").toHaveAttribute("href", "/odoo/action-339");
});
test.tags("desktop");
test("href attribute with path on apps menu items", async () => {
defineMenus([{ id: 1, actionID: 339, actionPath: "my-path" }]);
await mountWithCleanup(NavBar);
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(".o-dropdown--menu .dropdown-item").toHaveAttribute("href", "/odoo/my-path");
});
test.tags("desktop");
test("many sublevels in app menu items", async () => {
defineMenus([
{ id: 1, children: [2], name: "My app" },
{ id: 2, children: [3], name: "My menu" },
{ id: 3, children: [4], name: "My submenu 1" },
{ id: 4, children: [5], name: "My submenu 2" },
{ id: 5, children: [6], name: "My submenu 3" },
{ id: 6, children: [7], name: "My submenu 4" },
{ id: 7, children: [8], name: "My submenu 5" },
{ id: 8, children: [9], name: "My submenu 6" },
{ id: 9, name: "My submenu 7" },
]);
await makeMockEnv();
getService("menu").setCurrentMenu(1);
await mountWithCleanup(NavBar);
await contains(".o_menu_sections .o-dropdown").click();
expect(
queryAll(".o-dropdown--menu > *").map((el) => ({
text: el.innerText,
paddingLeft: el.style.paddingLeft,
tagName: el.tagName,
}))
).toEqual([
{ 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" },
]);
});
test.tags("desktop");
test("data-menu-xmlid attribute on AppsMenu items", async () => {
// Replace all default menus and setting new one
defineMenus([
{
id: 1,
children: [
{ id: 3, xmlid: "menu_3" },
{ id: 4, xmlid: "menu_4", children: [{ id: 5, xmlid: "menu_5" }] },
],
xmlid: "wowl",
},
{ id: 2 },
]);
await mountWithCleanup(NavBar);
// check apps
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(queryAllAttributes(".o-dropdown--menu a", "data-menu-xmlid")).toEqual(["wowl", null], {
message:
"menu items should have the correct data-menu-xmlid attribute (only the first is set)",
});
// check menus
getService("menu").setCurrentMenu(1);
await animationFrame();
expect(".o_menu_sections .dropdown-item[data-menu-xmlid=menu_3]").toHaveCount(1);
// check sub menus toggler
expect(".o_menu_sections button.dropdown-toggle[data-menu-xmlid=menu_4]").toHaveCount(1);
// check sub menus
await contains(".o_menu_sections .dropdown-toggle").click();
expect(".o-dropdown--menu .dropdown-item[data-menu-xmlid=menu_5]").toHaveCount(1);
});
test.tags("desktop");
test("navbar can display current active app", async () => {
await mountWithCleanup(NavBar);
// Open apps menu
await contains(".o_navbar_apps_menu button.dropdown-toggle").click();
expect(".o-dropdown--menu .dropdown-item:not(.focus)").toHaveCount(1, {
message:
"should not show the current active app as the menus service has not loaded an app yet",
});
// Activate an app
getService("menu").setCurrentMenu(1);
await animationFrame();
expect(".o-dropdown--menu .dropdown-item.focus").toHaveCount(1, {
message: "should show the current active app",
});
});
test("navbar can display systray items", async () => {
await mountWithCleanup(NavBar);
expect("li.my-item").toHaveCount(1);
});
test("navbar can display systray items ordered based on their sequence", async () => {
class MyItem1 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-1">my item 1</li>`;
}
class MyItem2 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-2">my item 2</li>`;
}
class MyItem3 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-3">my item 3</li>`;
}
class MyItem4 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-4">my item 4</li>`;
}
// Remove systray added by beforeEach
systrayRegistry.remove("addon.myitem");
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 });
await mountWithCleanup(NavBar);
expect(".o_menu_systray:eq(0) li").toHaveCount(4, {
message: "four systray items should be displayed",
});
expect(queryAllTexts(".o_menu_systray:eq(0) li")).toEqual([
"my item 3",
"my item 4",
"my item 2",
"my item 1",
]);
});
test("navbar updates after adding a systray item", async () => {
class MyItem1 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-1">my item 1</li>`;
}
// Remove systray added by beforeEach
systrayRegistry.remove("addon.myitem");
systrayRegistry.add("addon.myitem1", { Component: MyItem1 });
patchWithCleanup(NavBar.prototype, {
setup() {
onRendered(() => {
if (!systrayRegistry.contains("addon.myitem2")) {
class MyItem2 extends Component {
static props = ["*"];
static template = xml`<li class="my-item-2">my item 2</li>`;
}
systrayRegistry.add("addon.myitem2", { Component: MyItem2 });
}
});
super.setup();
},
});
await mountWithCleanup(NavBar);
expect(".o_menu_systray:eq(0) li").toHaveCount(2, {
message: "2 systray items should be displayed",
});
});
test.tags("desktop");
test("can adapt with 'more' menu sections behavior", async () => {
class MyNavbar extends NavBar {
async adapt() {
await super.adapt();
const sectionsCount = this.currentAppSections.length;
const hiddenSectionsCount = this.currentAppSectionsExtra.length;
expect.step(`adapt -> hide ${hiddenSectionsCount}/${sectionsCount} sections`);
}
}
defineMenus([
{
id: 1,
children: [
{ id: 10 },
{ id: 11 },
{
id: 12,
children: [{ id: 120 }, { id: 121 }, { id: 122 }],
},
],
},
]);
// Force the parent width, to make this test independent of screen size
await resize({ width: 1080 });
// TODO: this test case doesn't make sense since it relies on small widths
// with `env.isSmall` still returning `false`.
const env = await makeMockEnv();
Object.defineProperty(env, "isSmall", { get: () => false });
// Set menu and mount
getService("menu").setCurrentMenu(1);
await mountWithCleanup(MyNavbar);
expect(".o_menu_sections > *:not(.o_menu_sections_more):visible").toHaveCount(3, {
message: "should have 3 menu sections displayed (that are not the 'more' menu)",
});
expect(".o_menu_sections_more").toHaveCount(0);
// Force minimal width
await resize({ width: 0 });
await waitNavbarAdaptation();
expect(".o_menu_sections").not.toBeVisible({
message: "no menu section should be displayed",
});
// Reset to full width
await resize({ width: 1366 });
await waitNavbarAdaptation();
expect(".o_menu_sections > *:not(.o_menu_sections_more):not(.d-none)").toHaveCount(3, {
message: "should have 3 menu sections displayed (that are not the 'more' menu)",
});
expect(".o_menu_sections_more").toHaveCount(0, { message: "the 'more' menu should not exist" });
expect.verifySteps([
"adapt -> hide 0/3 sections",
"adapt -> hide 3/3 sections",
"adapt -> hide 0/3 sections",
]);
});
test.tags("desktop");
test("'more' menu sections adaptations do not trigger render in some cases", async () => {
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);
}
}
defineMenus([
{
id: 1,
children: [
{ id: 11, name: "Section with a very long name 1" },
{ id: 12, name: "Section with a very long name 2" },
{ id: 13, name: "Section with a very long name 3" },
],
},
]);
// Force the parent width, to make this test independent of screen size
await resize({ width: 600 });
// TODO: this test case doesn't make sense since it relies on small widths
// with `env.isSmall` still returning `false`.
const env = await makeMockEnv();
Object.defineProperty(env, "isSmall", { get: () => false });
const navbar = await mountWithCleanup(MyNavbar);
expect(navbar.currentAppSections).toHaveLength(0, { message: "0 app sub menus" });
expect(".o_navbar").toHaveRect({ width: 600 });
expect(adaptCount).toBe(1);
expect(adaptRenderCount).toBe(0, {
message: "during adapt, render not triggered as the navbar has no app sub menus",
});
await resize({ width: 0 });
await waitNavbarAdaptation();
expect(".o_navbar").toHaveRect({ width: 0 });
expect(adaptCount).toBe(2);
expect(adaptRenderCount).toBe(0, {
message: "during adapt, render not triggered as the navbar has no app sub menus",
});
// Set menu
getService("menu").setCurrentMenu(1);
await animationFrame();
expect(navbar.currentAppSections).toHaveLength(3, { message: "3 app sub menus" });
expect(navbar.currentAppSectionsExtra).toHaveLength(3, {
message: "all app sub menus are inside the more menu",
});
expect(adaptCount).toBe(3);
expect(adaptRenderCount).toBe(1, {
message:
"during adapt, render triggered as the navbar does not have enough space for app sub menus",
});
// Force small width
await resize({ width: 240 });
await waitNavbarAdaptation();
expect(navbar.currentAppSectionsExtra).toHaveLength(3, {
message: "all app sub menus are inside the more menu",
});
expect(adaptCount).toBe(4);
expect(adaptRenderCount).toBe(1, {
message: "during adapt, render not triggered as the more menu dropdown is STILL the same",
});
// Reset to full width
await resize({ width: 1366 });
await waitNavbarAdaptation();
expect(navbar.currentAppSections).toHaveLength(3, { message: "still 3 app sub menus" });
expect(navbar.currentAppSectionsExtra).toHaveLength(0, {
message: "all app sub menus are NO MORE inside the more menu",
});
expect(adaptCount).toBe(5);
expect(adaptRenderCount).toBe(2, {
message: "during adapt, render triggered as the more menu dropdown is NO MORE the same",
});
});
test.tags("desktop");
test("'more' menu sections properly updated on app change", async () => {
defineMenus([
// First App
{
id: 1,
children: [
{ id: 10, name: "Section 10" },
{ id: 11, name: "Section 11" },
{
id: 12,
name: "Section 12",
children: [
{ id: 120, name: "Section 120" },
{ id: 121, name: "Section 121" },
{ id: 122, name: "Section 122" },
],
},
],
},
// Second App
{
id: 2,
children: [
{ id: 20, name: "Section 20" },
{ id: 21, name: "Section 21" },
{
id: 22,
name: "Section 22",
children: [
{ id: 220, name: "Section 220" },
{ id: 221, name: "Section 221" },
{ id: 222, name: "Section 222" },
],
},
],
},
]);
// Force the parent width, to make this test independent of screen size
await resize({ width: 1080 });
// TODO: this test case doesn't make sense since it relies on small widths
// with `env.isSmall` still returning `false`.
const env = await makeMockEnv();
Object.defineProperty(env, "isSmall", { get: () => false });
// Set menu and mount
getService("menu").setCurrentMenu(1);
await mountWithCleanup(NavBar);
// Force minimal width
await resize({ width: 0 });
await waitNavbarAdaptation();
expect(".o_menu_sections > *:not(.d-none)").toHaveCount(1, {
message: "only one menu section should be displayed",
});
expect(".o_menu_sections_more:not(.d-none)").toHaveCount(1, {
message: "the displayed menu section should be the 'more' menu",
});
// Open the more menu
await contains(".o_menu_sections_more .dropdown-toggle").click();
expect(queryAllTexts(".dropdown-menu > *")).toEqual(
["Section 10", "Section 11", "Section 12", "Section 120", "Section 121", "Section 122"],
{ message: "'more' menu should contain first app sections" }
);
// Close the more menu
await contains(".o_menu_sections_more .dropdown-toggle").click();
// Set App2 menu
getService("menu").setCurrentMenu(2);
await animationFrame();
// Open the more menu
await contains(".o_menu_sections_more .dropdown-toggle").click();
expect(queryAllTexts(".dropdown-menu > *")).toEqual(
["Section 20", "Section 21", "Section 22", "Section 220", "Section 221", "Section 222"],
{ message: "'more' menu should contain second app sections" }
);
});
test("Do not execute adapt when navbar is destroyed", async () => {
expect.assertions(3);
class MyNavbar extends NavBar {
async adapt() {
expect.step("adapt NavBar");
return super.adapt();
}
}
await makeMockEnv();
// Set menu and mount
getService("menu").setCurrentMenu(1);
const navbar = await mountWithCleanup(MyNavbar);
expect.verifySteps(["adapt NavBar"]);
await resize();
await runAllTimers();
expect.verifySteps(["adapt NavBar"]);
await resize();
destroy(navbar);
await runAllTimers();
expect.verifySteps([]);
});

View file

@ -0,0 +1,161 @@
import { expect, test } from "@odoo/hoot";
import { click, queryAllTexts } from "@odoo/hoot-dom";
import { tick } from "@odoo/hoot-mock";
import {
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
import { redirect } from "@web/core/utils/urls";
class ResConfigSettings extends models.Model {
_name = "res.config.settings";
bar = fields.Boolean();
}
defineModels([ResConfigSettings]);
test("Simple render", async () => {
onRpc("/base_setup/demo_active", () => {
return true;
});
redirect("/odoo");
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="MyApp" name="my_app">
<widget name='res_config_dev_tool'/>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(router.current).toEqual({});
expect(".o_widget_res_config_dev_tool").toHaveCount(1);
expect(queryAllTexts`#developer_tool h2`).toEqual(["Developer Tools"]);
expect(queryAllTexts`#developer_tool .o_setting_right_pane .d-block`).toEqual([
"Activate the developer mode",
"Activate the developer mode (with assets)",
"Activate the developer mode (with tests assets)",
]);
});
test("Activate the developer mode", async () => {
onRpc("/base_setup/demo_active", () => {
return true;
});
patchWithCleanup(browser.location, {
reload() {
expect.step("location reload");
},
});
redirect("/odoo");
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="MyApp" name="my_app">
<widget name='res_config_dev_tool'/>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(router.current).toEqual({});
await click("a:contains('Activate the developer mode')");
await tick();
expect(router.current).toEqual({ debug: 1 });
expect.verifySteps(["location reload"]);
});
test("Activate the developer mode (with assets)", async () => {
onRpc("/base_setup/demo_active", () => {
return true;
});
patchWithCleanup(browser.location, {
reload() {
expect.step("location reload");
},
});
redirect("/odoo");
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="MyApp" name="my_app">
<widget name='res_config_dev_tool'/>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(router.current).toEqual({});
await click("a:contains('Activate the developer mode (with assets)')");
await tick();
expect(router.current).toEqual({ debug: "assets" });
expect.verifySteps(["location reload"]);
});
test("Activate the developer mode (with tests assets)", async () => {
onRpc("/base_setup/demo_active", () => {
return true;
});
patchWithCleanup(browser.location, {
reload() {
expect.step("location reload");
},
});
redirect("/odoo");
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="MyApp" name="my_app">
<widget name='res_config_dev_tool'/>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(router.current).toEqual({});
await click("a:contains('Activate the developer mode (with tests assets)')");
await tick();
expect(router.current).toEqual({ debug: "assets,tests" });
expect.verifySteps(["location reload"]);
});
test("Activate the developer modeddd (with tests assets)", async () => {
serverState.debug = "assets,tests";
onRpc("/base_setup/demo_active", () => {
return true;
});
patchWithCleanup(browser.location, {
reload() {
expect.step("location reload");
},
});
redirect("/odoo?debug=assets%2Ctests");
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="MyApp" name="my_app">
<widget name='res_config_dev_tool'/>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(router.current).toEqual({ debug: "assets,tests" });
expect(queryAllTexts`#developer_tool .o_setting_right_pane .d-block`).toEqual([
"Deactivate the developer mode",
]);
await click("a:contains('Deactivate the developer mode')");
await tick();
expect(router.current).toEqual({ debug: 0 });
expect.verifySteps(["location reload"]);
});

View file

@ -0,0 +1,107 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { click } from "@odoo/hoot-dom";
import {
defineModels,
fields,
models,
mountView,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
class ResConfigSettings extends models.Model {
_name = "res.config.settings";
bar = fields.Boolean({ string: "Bar" });
}
defineModels([ResConfigSettings]);
test("widget upgrade_boolean in a form view - dialog", async () => {
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="CRM" name="crm">
<field name="bar" widget="upgrade_boolean"/>
</app>
</form>`,
resModel: "res.config.settings",
});
await click(".o-checkbox .form-check-input");
await animationFrame();
expect(".o_dialog .modal").toHaveCount(1, {
message: "the 'Upgrade to Enterprise' dialog should be opened",
});
});
test("widget upgrade_boolean in a form view - label", async () => {
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="CRM" name="crm">
<setting string="Coucou">
<field name="bar" widget="upgrade_boolean"/>
</setting>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(".o_field .badge").toHaveCount(0, {
message: "the upgrade badge shouldn't be inside the field section",
});
expect(".o_form_label .badge").toHaveCount(1, {
message: "the upgrade badge should be inside the label section",
});
expect(".o_form_label").toHaveText("Coucou\nEnterprise", {
message: "the upgrade label should be inside the label section",
});
});
test("widget upgrade_boolean in a form view - dialog (enterprise version)", async () => {
patchWithCleanup(odoo, { info: { isEnterprise: 1 } });
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="CRM" name="crm">
<field name="bar" widget="upgrade_boolean"/>
</app>
</form>`,
resModel: "res.config.settings",
});
await click(".o-checkbox .form-check-input");
await animationFrame();
expect(".o_dialog .modal").toHaveCount(0, {
message: "the 'Upgrade to Enterprise' dialog shouldn't be opened",
});
});
test("widget upgrade_boolean in a form view - label (enterprise version)", async () => {
patchWithCleanup(odoo, { info: { isEnterprise: 1 } });
await mountView({
type: "form",
arch: /* xml */ `
<form js_class="base_settings">
<app string="CRM" name="crm">
<setting string="Coucou">
<field name="bar" widget="upgrade_boolean"/>
</setting>
</app>
</form>`,
resModel: "res.config.settings",
});
expect(".o_field .badge").toHaveCount(0, {
message: "the upgrade badge shouldn't be inside the field section",
});
expect(".o_form_label .badge").toHaveCount(0, {
message: "the upgrade badge shouldn't be inside the label section",
});
expect(".o_form_label").toHaveText("Coucou", {
message: "the label shouldn't contains the upgrade label",
});
});

View file

@ -0,0 +1,688 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { edit, keyDown, press, queryAllAttributes, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
contains,
getService,
mountWithCleanup,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { session } from "@web/session";
import { cookie } from "@web/core/browser/cookie";
import { SwitchCompanyMenu } from "@web/webclient/switch_company_menu/switch_company_menu";
describe.current.tags("desktop");
const clickConfirm = () => contains(".o_switch_company_menu_buttons button:first").click();
const openCompanyMenu = () => contains(".dropdown-toggle").click();
const stepOnCookieChange = () =>
patchWithCleanup(cookie, {
set(key, value) {
if (key === "cids") {
expect.step(value);
}
return super.set(key, value);
},
});
/**
* @param {number} index
*/
const toggleCompany = (index) =>
contains(`[data-company-id] [role=menuitemcheckbox]:eq(${index})`).click();
beforeEach(() => {
serverState.companies = [
{ id: 3, name: "Hermit", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Herman's", sequence: 2, parent_id: false, child_ids: [] },
{ id: 1, name: "Heroes TM", sequence: 3, parent_id: false, child_ids: [4, 5] },
{ id: 4, name: "Hercules", sequence: 4, parent_id: 1, child_ids: [] },
{ id: 5, name: "Hulk", sequence: 5, parent_id: 1, child_ids: [] },
];
});
test("basic rendering", async () => {
await mountWithCleanup(SwitchCompanyMenu);
expect("div.o_switch_company_menu").toHaveCount(1);
expect("div.o_switch_company_menu").toHaveText("Hermit");
await openCompanyMenu();
expect("[data-company-id] [role=menuitemcheckbox]").toHaveCount(5);
expect(".log_into").toHaveCount(5);
expect(".fa-check-square").toHaveCount(1);
expect(".fa-square-o").toHaveCount(4);
expect(".dropdown-item:has(.fa-check-square)").toHaveText("Hermit");
expect(".dropdown-item:has(.fa-square-o):eq(0)").toHaveText("Herman's");
expect(".dropdown-menu").toHaveText("Hermit\nHerman's\nHeroes TM\nHercules\nHulk");
});
test("companies can be toggled: toggle a second company", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
expect(queryAllAttributes("[data-company-id] [role=menuitemcheckbox]", "aria-checked")).toEqual(
["true", "false", "false", "false", "false"]
);
expect(queryAllAttributes("[data-company-id] .log_into", "aria-pressed")).toEqual([
"true",
"false",
"false",
"false",
"false",
]);
/**
* [x] **Hermit**
* [x] Herman's -> toggle
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await toggleCompany(1);
expect(".dropdown-menu").toHaveCount(1, { message: "dropdown is still opened" });
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(3);
expect(queryAllAttributes("[data-company-id] [role=menuitemcheckbox]", "aria-checked")).toEqual(
["true", "true", "false", "false", "false"]
);
expect(queryAllAttributes("[data-company-id] .log_into", "aria-pressed")).toEqual([
"true",
"false",
"false",
"false",
"false",
]);
await clickConfirm();
expect.verifySteps(["3-2"]);
});
test("can toggle multiple companies at once", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [ ] Hermit -> toggle all
* [x] **Herman's** -> toggle all
* [x] Heroes TM -> toggle all
* [ ] Hercules
* [ ] Hulk
*/
await toggleCompany(0);
await toggleCompany(1);
await toggleCompany(2);
expect(".dropdown-menu").toHaveCount(1, { message: "dropdown is still opened" });
expect("[data-company-id] .fa-check-square").toHaveCount(4);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
expect.verifySteps([]);
await clickConfirm();
expect.verifySteps(["2-1-4-5"]);
});
test("single company selected: toggling it off will keep it", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await runAllTimers();
expect(cookie.get("cids")).toBe("3");
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [x] **Hermit** -> toggle off
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await toggleCompany(0);
await clickConfirm();
await animationFrame();
expect.verifySteps(["3"]);
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id] .fa-check-square").toHaveCount(0);
expect("[data-company-id] .fa-square-o").toHaveCount(5);
});
test("single company mode: companies can be logged in", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [ ] Hermit
* [x] **Herman's** -> log into
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await contains(".log_into:eq(1)").click();
expect(".dropdown-menu").toHaveCount(0, { message: "dropdown is directly closed" });
expect.verifySteps(["2"]);
});
test("multi company mode: log into a non selected company", async () => {
cookie.set("cids", "3-1");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3-1"]);
/**
* [x] Hermit
* [ ] Herman's
* [x] **Heroes TM**
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3, 1]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(3);
/**
* [x] Hermit
* [x] **Herman's** -> log into
* [x] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await contains(".log_into:eq(1)").click();
expect(".dropdown-menu").toHaveCount(0, { message: "dropdown is directly closed" });
expect.verifySteps(["2-3-1"]);
});
test("multi company mode: log into an already selected company", async () => {
cookie.set("cids", "2-1");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["2-1"]);
/**
* [ ] Hermit
* [x] **Herman's**
* [x] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([2, 1]);
expect(getService("company").currentCompany.id).toBe(2);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(2);
expect("[data-company-id] .fa-square-o").toHaveCount(3);
/**
* [ ] Hermit
* [x] Herman's
* [x] **Heroes TM** -> log into
* [x] Hercules
* [x] Hulk
*/
await contains(".log_into:eq(2)").click();
expect(".dropdown-menu").toHaveCount(0, { message: "dropdown is directly closed" });
expect.verifySteps(["1-2-4-5"]);
});
test("companies can be logged in even if some toggled within delay", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [ ] Hermit -> toggled
* [x] **Herman's** -> logged in
* [ ] Heroes TM -> toggled
* [ ] Hercules
* [ ] Hulk
*/
await contains("[data-company-id] [role=menuitemcheckbox]:eq(2)").click();
await contains("[data-company-id] [role=menuitemcheckbox]:eq(0)").click();
await contains(".log_into:eq(1)").click();
expect(".dropdown-menu").toHaveCount(0, { message: "dropdown is directly closed" });
expect.verifySteps(["2"]);
});
test("always show the name of the company on the top right of the app", async () => {
// initialize a single company
serverState.companies = [
{ id: 1, name: "Single company", sequence: 1, parent_id: false, child_ids: [] },
];
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["1"]);
// in case of a single company, drop down button should be displayed but disabled
expect(".dropdown-toggle").toBeDisplayed();
expect(".dropdown-toggle").not.toBeEnabled();
expect(".dropdown-toggle").toHaveText("Single company");
});
test("single company mode: from company loginto branch", async () => {
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [x] **Hermit**
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await contains(".dropdown-toggle").click();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [ ] Hermit
* [ ] Herman's
* [x] **Heroes TM** -> log into
* [x] Hercules
* [x] Hulk
*/
await contains(".log_into:eq(2)").click();
expect.verifySteps(["1-4-5"]);
});
test("single company mode: from branch loginto company", async () => {
cookie.set("cids", "1-4-5");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["1-4-5"]);
/**
* [ ] Hermit
* [ ] Herman's
* [x] **Heroes TM**
* [x] Hercules
* [x] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([1, 4, 5]);
expect(getService("company").currentCompany.id).toBe(1);
await contains(".dropdown-toggle").click();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(3);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [x] Hermit -> log into
* [ ] Herman's
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await contains(".log_into:eq(0)").click();
expect.verifySteps(["3"]);
});
test("single company mode: from leaf (only one company in branch selected) loginto company", async () => {
cookie.set("cids", "1");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["1"]);
/**
* [ ] Hermit
* [ ] Herman's
* [x] **Heroes TM**
* [ ] Hercules
* [ ] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([1]);
expect(getService("company").currentCompany.id).toBe(1);
await contains(".dropdown-toggle").click();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(4);
/**
* [ ] Hermit
* [x] **Herman's** -> log into
* [ ] Heroes TM
* [ ] Hercules
* [ ] Hulk
*/
await contains(".log_into:eq(1)").click();
expect.verifySteps(["2"]);
});
test("multi company mode: switching company doesn't deselect already selected ones", async () => {
cookie.set("cids", "1-2-4-5");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["1-2-4-5"]);
/**
* [ ] Hermit
* [x] Herman's
* [x] **Heroes TM**
* [x] Hercules
* [x] Hulk
*/
expect(getService("company").activeCompanyIds).toEqual([1, 2, 4, 5]);
expect(getService("company").currentCompany.id).toBe(1);
await contains(".dropdown-toggle").click();
expect("[data-company-id]").toHaveCount(5);
expect("[data-company-id] .fa-check-square").toHaveCount(4);
expect("[data-company-id] .fa-square-o").toHaveCount(1);
/**
* [ ] Hermit
* [x] **Herman's** -> log into
* [x] Heroes TM
* [x] Hercules
* [x] Hulk
*/
await contains(".log_into:eq(1)").click();
expect.verifySteps(["2-1-4-5"]);
});
test("show confirm and reset buttons only when selection has changed", async () => {
await mountWithCleanup(SwitchCompanyMenu);
await openCompanyMenu();
expect(".o_switch_company_menu_buttons").toHaveCount(0);
await toggleCompany(1);
expect(".o_switch_company_menu_buttons button").toHaveCount(2);
await toggleCompany(1);
expect(".o_switch_company_menu_buttons").toHaveCount(0);
});
test("no search input when less that 10 companies", async () => {
await mountWithCleanup(SwitchCompanyMenu);
await openCompanyMenu();
expect(".o-dropdown--menu .visually-hidden input").toHaveCount(1);
});
test("show search input when more that 10 companies & search filters items but ignore case and spaces", async () => {
serverState.companies = [
{ id: 3, name: "Hermit", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Herman's", sequence: 2, parent_id: false, child_ids: [] },
{ id: 1, name: "Heroes TM", sequence: 3, parent_id: false, child_ids: [4, 5] },
{ id: 4, name: "Hercules", sequence: 4, parent_id: 1, child_ids: [] },
{ id: 5, name: "Hulk", sequence: 5, parent_id: 1, child_ids: [] },
{ id: 6, name: "Random Company a", sequence: 6, parent_id: false, child_ids: [7, 8] },
{ id: 7, name: "Random Company aa", sequence: 7, parent_id: 6, child_ids: [] },
{ id: 8, name: "Random Company ab", sequence: 8, parent_id: 6, child_ids: [] },
{ id: 9, name: "Random d", sequence: 9, parent_id: false, child_ids: [] },
{ id: 10, name: "Random e", sequence: 10, parent_id: false, child_ids: [] },
];
await mountWithCleanup(SwitchCompanyMenu);
await openCompanyMenu();
expect(".o-dropdown--menu input").toHaveCount(1);
expect(".o-dropdown--menu input").toBeFocused();
expect(".o-dropdown--menu .o_switch_company_item").toHaveCount(10);
await edit("omcom");
await animationFrame();
expect(".o-dropdown--menu .o_switch_company_item").toHaveCount(3);
expect(queryAllTexts(".o-dropdown--menu .o_switch_company_item")).toEqual([
"Random Company a",
"Random Company aa",
"Random Company ab",
]);
});
test("when less than 10 companies, typing key makes the search input visible", async () => {
await mountWithCleanup(SwitchCompanyMenu);
await openCompanyMenu();
expect(".o-dropdown--menu input").toHaveCount(1);
expect(".o-dropdown--menu input").toBeFocused();
expect(".o-dropdown--menu .visually-hidden input").toHaveCount(1);
await edit("a");
await animationFrame();
expect(".o-dropdown--menu input").toHaveValue("a");
expect(".o-dropdown--menu :not(.visually-hidden) input").toHaveCount(1);
});
test.tags("focus required");
test("navigation with search input", async () => {
serverState.companies = [
{ id: 3, name: "Hermit", sequence: 1, parent_id: false, child_ids: [] },
{ id: 2, name: "Herman's", sequence: 2, parent_id: false, child_ids: [] },
{ id: 1, name: "Heroes TM", sequence: 3, parent_id: false, child_ids: [4, 5] },
{ id: 4, name: "Hercules", sequence: 4, parent_id: 1, child_ids: [] },
{ id: 5, name: "Hulk", sequence: 5, parent_id: 1, child_ids: [] },
{ id: 6, name: "Random Company a", sequence: 6, parent_id: false, child_ids: [7, 8] },
{ id: 7, name: "Random Company aa", sequence: 7, parent_id: 6, child_ids: [] },
{ id: 8, name: "Random Company ab", sequence: 8, parent_id: 6, child_ids: [] },
{ id: 9, name: "Random d", sequence: 9, parent_id: false, child_ids: [] },
{ id: 10, name: "Random e", sequence: 10, parent_id: false, child_ids: [] },
];
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
await openCompanyMenu();
expect(".o-dropdown--menu input").toBeFocused();
expect(".o_switch_company_item.focus").toHaveCount(0);
const navigationSteps = [
{ hotkey: "arrowdown", focused: 1, selectedCompanies: [3] }, // Go to first item
{ hotkey: "arrowup", focused: 0 }, // Go to search input
{ hotkey: "arrowup", focused: 10 }, // Go to last item
{ hotkey: "Space", focused: 10, selectedCompanies: [3, 10] }, // Select last item
{ hotkey: ["shift", "tab"], focused: 9, selectedCompanies: [3, 10] }, // Go to previous item
{ hotkey: "tab", focused: 10, selectedCompanies: [3, 10] }, // Go to next item
{ hotkey: "arrowdown", focused: 11 }, // Go to Confirm
{ hotkey: "arrowdown", focused: 12 }, // Go to Reset
{ hotkey: "enter", focused: 10, selectedCompanies: [3] }, // Reset, focus is on last item
{ hotkey: "arrowdown", focused: 0 }, // Go to seach input
{ input: "a", focused: 0 }, // Type "a"
{ hotkey: "arrowdown", focused: 1 }, // Go to first item
{ hotkey: "Space", focused: 1, selectedCompanies: [2] }, // Select first item
];
for (const navigationStep of navigationSteps) {
expect.step(navigationStep);
const { hotkey, focused, selectedCompanies, input } = navigationStep;
if (hotkey) {
await press(hotkey);
}
if (input) {
await edit(input);
}
// Ensure debounced mutation listener update and owl re-render
await animationFrame();
await runAllTimers();
expect(`.o_popover .o-navigable:eq(${focused})`).toHaveClass("focus");
expect(`.o_popover .o-navigable:eq(${focused})`).toBeFocused();
if (selectedCompanies) {
expect(
queryAllAttributes(
".o_switch_company_item:has([role=menuitemcheckbox][aria-checked=true])",
"data-company-id"
).map(Number)
).toEqual(selectedCompanies);
}
}
await keyDown(["control", "enter"]);
await animationFrame();
expect.verifySteps([...navigationSteps, "3-2"]);
expect(".o_switch_company_item").toHaveCount(0);
});
test("select and de-select all", async () => {
await mountWithCleanup(SwitchCompanyMenu);
await openCompanyMenu();
// Show search
await edit(" ");
await animationFrame();
// One company is selected, there should be a check box with minus inside
expect("[role=menuitemcheckbox][title='Deselect all'] i").toHaveClass("fa-minus-square-o");
await contains("[role=menuitemcheckbox][title='Deselect all']").click();
// No company is selected, there should be a empty check box
expect("[role=menuitemcheckbox][title='Select all'] i").toHaveClass("fa-square-o");
expect(".o_switch_company_item:has([role=menuitemcheckbox][aria-checked=true])").toHaveCount(0);
await contains("[role=menuitemcheckbox][title='Select all']").click();
// All companies are selected, there should be a checked check box
expect("[role=menuitemcheckbox][title='Deselect all'] i").toHaveClass("fa-check-square");
expect(".o_switch_company_item:has([role=menuitemcheckbox][aria-checked=true])").toHaveCount(5);
await contains("[role=menuitemcheckbox][title='Deselect all']").click();
// No company is selected, there should be a empty check box
expect("[role=menuitemcheckbox][title='Select all'] i").toHaveClass("fa-square-o");
expect(".o_switch_company_item:has([role=menuitemcheckbox][aria-checked=true])").toHaveCount(0);
});
test("disallowed companies in between allowed companies are not enabled", async () => {
const companies = {
1: { id: 1, name: "Parent", sequence: 1, parent_id: false, child_ids: [2] },
2: { id: 2, name: "Child A", sequence: 2, parent_id: 1, child_ids: [3] },
3: { id: 3, name: "Child B", sequence: 3, parent_id: 2, child_ids: [] },
};
patchWithCleanup(session.user_companies, {
allowed_companies: companies,
current_company: 3,
disallowed_ancestor_companies: {
2: companies[2]
},
});
cookie.set("cids", "3");
stepOnCookieChange();
await mountWithCleanup(SwitchCompanyMenu);
expect.verifySteps(["3"]);
/**
* [ ] Parent
* [ ] Child A
* [x] Child B
*/
expect(getService("company").activeCompanyIds).toEqual([3]);
expect(getService("company").currentCompany.id).toBe(3);
await openCompanyMenu();
expect("[data-company-id]").toHaveCount(3);
expect("[data-company-id] .fa-check-square").toHaveCount(1);
expect("[data-company-id] .fa-square-o").toHaveCount(2);
/**
* [x] Parent -> toggle
* [ ] Child A
* [x] Child B
*/
await contains(".log_into:eq(0)").click();
expect.verifySteps(["1-3"]);
});

View file

@ -0,0 +1,165 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { click, queryAllAttributes, queryAllProperties, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clearRegistry,
contains,
mockService,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
stepAllNetworkCalls,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { getOrigin } from "@web/core/utils/urls";
import { UserMenu } from "@web/webclient/user_menu/user_menu";
import { odooAccountItem, preferencesItem } from "@web/webclient/user_menu/user_menu_items";
const userMenuRegistry = registry.category("user_menuitems");
describe.current.tags("desktop");
beforeEach(async () => {
serverState.partnerName = "Sauron";
clearRegistry(userMenuRegistry);
});
test("can be rendered", async () => {
patchWithCleanup(user, { writeDate: "2024-01-01 12:00:00" });
userMenuRegistry.add("bad_item", () => ({
type: "item",
id: "bad",
description: "Bad",
callback: () => {
expect.step("callback bad_item");
},
sequence: 10,
}));
userMenuRegistry.add("ring_item", () => ({
type: "item",
id: "ring",
description: "Ring",
callback: () => {
expect.step("callback ring_item");
},
sequence: 5,
}));
userMenuRegistry.add("frodo_item", () => ({
type: "switch",
id: "frodo",
description: "Frodo",
callback: () => {
expect.step("callback frodo_item");
},
sequence: 11,
}));
userMenuRegistry.add("separator", () => ({
type: "separator",
sequence: 15,
}));
userMenuRegistry.add("invisible_item", () => ({
type: "item",
id: "hidden",
description: "Hidden Power",
callback: () => {},
sequence: 5,
hide: true,
}));
userMenuRegistry.add("eye_item", () => ({
type: "item",
id: "eye",
description: "Eye",
callback: () => {
expect.step("callback eye_item");
},
}));
await mountWithCleanup(UserMenu);
expect("img.o_user_avatar").toHaveCount(1);
expect("img.o_user_avatar").toHaveAttribute(
"data-src",
`${getOrigin()}/web/image/res.partner/17/avatar_128?unique=1704106800000`
);
expect(".dropdown-menu .dropdown-item").toHaveCount(0);
await contains("button.dropdown-toggle").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(4);
expect(".dropdown-menu .dropdown-item input.form-check-input").toHaveCount(1);
expect("div.dropdown-divider").toHaveCount(1);
expect(queryAllProperties(".dropdown-menu > *", "tagName")).toEqual([
"SPAN",
"SPAN",
"SPAN",
"DIV",
"SPAN",
]);
expect(queryAllAttributes(".dropdown-menu .dropdown-item", "data-menu")).toEqual([
"ring",
"bad",
"frodo",
"eye",
]);
expect(queryAllTexts(".dropdown-menu .dropdown-item")).toEqual(["Ring", "Bad", "Frodo", "Eye"]);
for (let i = 0; i < 4; i++) {
await click(`.dropdown-menu .dropdown-item:eq(${i})`);
await click("button.dropdown-toggle"); // re-open the dropdown
await animationFrame();
}
expect.verifySteps([
"callback ring_item",
"callback bad_item",
"callback frodo_item",
"callback eye_item",
]);
});
test("display the correct name in debug mode", async () => {
serverState.debug = "1";
await mountWithCleanup(UserMenu);
expect("img.o_user_avatar").toHaveCount(1);
expect("small.oe_topbar_name").toHaveCount(1);
expect(".oe_topbar_name").toHaveText("Sauron" + "\n" + "test");
});
test("can execute the callback of settings", async () => {
onRpc("action_get", () => ({
name: "Change My Preferences",
res_id: 0,
}));
mockService("action", {
async doAction(actionId) {
expect.step(String(actionId.res_id));
expect.step(actionId.name);
return true;
},
});
userMenuRegistry.add("profile", preferencesItem);
await mountWithCleanup(UserMenu);
await contains("button.dropdown-toggle").click();
expect(".dropdown-menu .dropdown-item").toHaveCount(1);
expect(".dropdown-menu .dropdown-item").toHaveText("Preferences");
await contains(".dropdown-menu .dropdown-item").click();
expect.verifySteps(["7", "Change My Preferences"]);
});
test("click on odoo account item", async () => {
patchWithCleanup(browser, {
open: (url) => expect.step(`open ${url}`),
});
userMenuRegistry.add("odoo_account", odooAccountItem);
await mountWithCleanup(UserMenu);
onRpc("/web/session/account", () => "https://account-url.com");
stepAllNetworkCalls();
await contains("button.dropdown-toggle").click();
expect(".o-dropdown--menu .dropdown-item").toHaveCount(1);
expect(".o-dropdown--menu .dropdown-item").toHaveText("My Odoo.com account");
await contains(".o-dropdown--menu .dropdown-item").click();
expect.verifySteps(["/web/session/account", "open https://account-url.com"]);
});

View file

@ -0,0 +1,100 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
import { WebClient } from "@web/webclient/webclient";
test("can be rendered", async () => {
await mountWithCleanup(WebClient);
expect(`header > nav.o_main_navbar`).toHaveCount(1);
});
test("can render a main component", async () => {
class MyComponent extends Component {
static props = {};
static template = xml`<span class="chocolate">MyComponent</span>`;
}
const env = await makeMockEnv();
registry.category("main_components").add("mycomponent", { Component: MyComponent });
await mountWithCleanup(WebClient, { env });
expect(`.chocolate`).toHaveCount(1);
});
test.tags("desktop");
test("control-click <a href/> in a standalone component", async () => {
class MyComponent extends Component {
static props = {};
static template = xml`<a href="#" class="MyComponent" t-on-click="onclick">Some link</a>`;
/** @param {MouseEvent} ev */
onclick(ev) {
expect.step(ev.ctrlKey ? "ctrl-click" : "click");
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
}
await mountWithCleanup(MyComponent);
expect.verifySteps([]);
await contains(".MyComponent").click();
await contains(".MyComponent").click({ ctrlKey: true });
expect.verifySteps(["click", "ctrl-click"]);
});
test.tags("desktop");
test("control-click propagation stopped on <a href/>", async () => {
expect.assertions(3);
patchWithCleanup(WebClient.prototype, {
/** @param {MouseEvent} ev */
onGlobalClick(ev) {
super.onGlobalClick(ev);
if (ev.ctrlKey) {
expect(ev.defaultPrevented).toBe(false, {
message:
"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 {
static props = {};
static template = xml`<a href="#" class="MyComponent" t-on-click="onclick">Some link</a>`;
/** @param {MouseEvent} ev */
onclick(ev) {
expect.step(ev.ctrlKey ? "ctrl-click" : "click");
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
}
await mountWithCleanup(WebClient);
registry.category("main_components").add("mycomponent", { Component: MyComponent });
await animationFrame();
expect.verifySteps([]);
await contains(".MyComponent").click();
await contains(".MyComponent").click({ ctrlKey: true });
expect.verifySteps(["click"]);
});