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", mobile_view_mode: "kanban", type: "ir.actions.act_window", views: [ [false, "list"], [1, "kanban"], [false, "form"], ], }, { id: 4, xml_id: "action_4", name: "Partners Action 4", res_model: "partner", type: "ir.actions.act_window", views: [ [1, "kanban"], [2, "list"], [false, "form"], ], }, { id: 8, xml_id: "action_8", name: "Favorite Ponies", res_model: "pony", type: "ir.actions.act_window", 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: "root", name: "root", appID: "root", children: [ // id:0 is a hack to not load anything at webClient mount { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" }, { id: 1, children: [], name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" }, { id: 2, children: [], name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" }, ], }, ]); 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: ` `, list: ``, form: `
`, 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: ``, form: `
`, search: ``, }; } defineModels([Partner, Pony]); class TestClientAction extends Component { static template = xml`
ClientAction_
`; 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`
ClientAction_
`; 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`
ClientAction_
`; 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`
ClientAction_
`; 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: 1001, 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(1001); 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":[]}`, }, }); });