import { after, beforeEach, expect, test } from "@odoo/hoot"; import { click, dblclick, queryAll, queryAllTexts, queryFirst, queryOne, queryText, setInputFiles, } from "@odoo/hoot-dom"; import { FileInput } from "@web/core/file_input/file_input"; import { Deferred, animationFrame } from "@odoo/hoot-mock"; import { Component, onRendered, xml } from "@odoo/owl"; import { contains, defineModels, fields, getDropdownMenu, getKanbanColumn, getKanbanColumnDropdownMenu, getKanbanRecord, getKanbanRecordTexts, getService, mockService, models, mountView, mountWithCleanup, onRpc, patchWithCleanup, stepAllNetworkCalls, toggleKanbanColumnActions, toggleKanbanRecordDropdown, validateSearch, webModels, } from "@web/../tests/web_test_helpers"; import { currencies } from "@web/core/currency"; import { registry } from "@web/core/registry"; import { user } from "@web/core/user"; import { getOrigin } from "@web/core/utils/urls"; import { KanbanCompiler } from "@web/views/kanban/kanban_compiler"; import { KanbanRecord } from "@web/views/kanban/kanban_record"; import { KanbanRenderer } from "@web/views/kanban/kanban_renderer"; import { kanbanView } from "@web/views/kanban/kanban_view"; import { ViewButton } from "@web/views/view_button/view_button"; import { AnimatedNumber } from "@web/views/view_components/animated_number"; import { WebClient } from "@web/webclient/webclient"; const { IrAttachment } = webModels; const fieldRegistry = registry.category("fields"); const viewRegistry = registry.category("views"); const viewWidgetRegistry = registry.category("view_widgets"); async function createFileInput({ mockPost, mockAdd, props }) { mockService("notification", { add: mockAdd || (() => {}), }); mockService("http", { post: mockPost || (() => {}), }); await mountWithCleanup(FileInput, { props }); } class Partner extends models.Model { _name = "partner"; _rec_name = "foo"; foo = fields.Char(); bar = fields.Boolean(); sequence = fields.Integer(); int_field = fields.Integer({ aggregator: "sum", sortable: true }); float_field = fields.Float({ aggregator: "sum" }); product_id = fields.Many2one({ relation: "product" }); category_ids = fields.Many2many({ relation: "category" }); date = fields.Date(); datetime = fields.Datetime(); state = fields.Selection({ type: "selection", selection: [ ["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"], ], }); salary = fields.Monetary({ aggregator: "sum", currency_field: this.currency_id }); currency_id = fields.Many2one({ relation: "res.currency" }); _records = [ { id: 1, foo: "yop", bar: true, int_field: 10, float_field: 0.4, product_id: 3, category_ids: [], state: "abc", salary: 1750, currency_id: 1, }, { id: 2, foo: "blip", bar: true, int_field: 9, float_field: 13, product_id: 5, category_ids: [6], state: "def", salary: 1500, currency_id: 1, }, { id: 3, foo: "gnap", bar: true, int_field: 17, float_field: -3, product_id: 3, category_ids: [7], state: "ghi", salary: 2000, currency_id: 2, }, { id: 4, foo: "blip", bar: false, int_field: -4, float_field: 9, product_id: 5, category_ids: [], state: "ghi", salary: 2222, currency_id: 1, }, ]; } class Product extends models.Model { _name = "product"; name = fields.Char(); _records = [ { id: 3, name: "hello" }, { id: 5, name: "xmo" }, ]; } class Category extends models.Model { _name = "category"; name = fields.Char(); color = fields.Integer(); _records = [ { id: 6, name: "gold", color: 2 }, { id: 7, name: "silver", color: 5 }, ]; } class Currency extends models.Model { _name = "res.currency"; name = fields.Char(); symbol = fields.Char(); position = fields.Selection({ selection: [ ["after", "A"], ["before", "B"], ], }); _records = [ { id: 1, name: "USD", symbol: "$", position: "before" }, { id: 2, name: "EUR", symbol: "€", position: "after" }, ]; } defineModels([Partner, Product, Category, Currency, IrAttachment]); beforeEach(() => { patchWithCleanup(AnimatedNumber, { enableAnimations: false }); // avoid "kanban-box" deprecation warnings in this suite, which defines legacy kanban on purpose const originalConsoleWarn = console.warn; patchWithCleanup(console, { warn: (msg) => { if (msg !== "'kanban-box' is deprecated, define a 'card' template instead") { originalConsoleWarn(msg); } }, }); }); test("display full is supported on fields", async () => { await mountView({ type: "kanban", resModel: "partner", arch: ` `, }); expect(".o_kanban_record span.o_text_block").toHaveCount(4); expect("span.o_text_block:first").toHaveText("yop"); }); test.tags("desktop"); test("basic grouped rendering", async () => { expect.assertions(16); patchWithCleanup(KanbanRenderer.prototype, { setup() { super.setup(...arguments); onRendered(() => { expect.step("rendered"); }); }, }); onRpc("web_read_group", ({ kwargs }) => { // the lazy option is important, so the server can fill in the empty groups expect(kwargs.lazy).toBe(true, { message: "should use lazy read_group" }); }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["bar"], }); expect(".o_kanban_view").toHaveClass("o_kanban_test"); expect(".o_kanban_renderer").toHaveClass("o_kanban_grouped"); expect(".o_control_panel_main_buttons button.o-kanban-button-new").toHaveCount(1); expect(".o_kanban_group").toHaveCount(2); expect(".o_kanban_group:first-child .o_kanban_record").toHaveCount(1); expect(".o_kanban_group:nth-child(2) .o_kanban_record").toHaveCount(3); expect.verifySteps(["rendered"]); await toggleKanbanColumnActions(0); // check available actions in kanban header's config dropdown expect(".o-dropdown--menu .o_kanban_toggle_fold").toHaveCount(1); expect(".o_kanban_header:first-child .o_kanban_config .o_column_edit").toHaveCount(0); expect(".o_kanban_header:first-child .o_kanban_config .o_column_delete").toHaveCount(0); expect(".o_kanban_header:first-child .o_kanban_config .o_column_archive_records").toHaveCount( 0 ); expect(".o_kanban_header:first-child .o_kanban_config .o_column_unarchive_records").toHaveCount( 0 ); // focuses the search bar and closes the dropdown await click(".o_searchview input"); // the next line makes sure that reload works properly. It looks useless, // but it actually test that a grouped local record can be reloaded without // changing its result. await validateSearch(); expect(".o_kanban_group:nth-child(2) .o_kanban_record").toHaveCount(3); expect.verifySteps(["rendered"]); }); test("basic grouped rendering with no record", async () => { Partner._records = []; await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["bar"], }); expect(".o_kanban_grouped").toHaveCount(1); expect(".o_view_nocontent").toHaveCount(1); expect(".o_control_panel_main_buttons button.o-kanban-button-new").toHaveCount(1, { message: "There should be a 'New' button even though there is no column when groupby is not a many2one", }); }); test("grouped rendering with active field (archivable by default)", async () => { // add active field on partner model and make all records active Partner._fields.active = fields.Boolean({ default: true }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["bar"], }); const clickColumnAction = await toggleKanbanColumnActions(1); // check archive/restore all actions in kanban header's config dropdown expect(".o_column_archive_records").toHaveCount(1, { root: getKanbanColumnDropdownMenu(0) }); expect(".o_column_unarchive_records").toHaveCount(1, { root: getKanbanColumnDropdownMenu(0) }); expect(".o_kanban_group").toHaveCount(2); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(1) })).toHaveCount(3); await clickColumnAction("Archive All"); expect(".o_dialog").toHaveCount(1); await contains(".o_dialog footer .btn-primary").click(); expect(".o_kanban_group").toHaveCount(2); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(1) })).toHaveCount(0); }); test("context can be used in kanban template", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, context: { some_key: 1 }, domain: [["id", "=", 1]], }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1); expect(".o_kanban_record span:contains(yop)").toHaveCount(1); }); test("user context can be used in kanban template", async () => { patchWithCleanup(user, { context: { some_key: true } }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, domain: [["id", "=", 1]], }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1); expect(".o_kanban_record span:contains(yop)").toHaveCount(1); }); test("kanban with sub-template", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([ "yop", "blip", "gnap", "blip", ]); }); test("kanban with t-set outside card", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual(["10", "9", "17", "-4"]); }); test("kanban with t-if/t-else on field", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
Negative value
`, }); expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([ "10", "9", "17", "Negative value", ]); }); test("kanban with t-if/t-else on field with widget", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
Negative value
`, }); expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([ "10", "9", "17", "Negative value", ]); }); test("field with widget and dynamic attributes in kanban", async () => { const myField = { component: class MyField extends Component { static template = xml``; static props = ["*"]; }, extractProps: ({ attrs }) => { expect.step( `${attrs["dyn-bool"]}/${attrs["interp-str"]}/${attrs["interp-str2"]}/${attrs["interp-str3"]}` ); }, }; fieldRegistry.add("my_field", myField); after(() => fieldRegistry.remove("my_field")); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect.verifySteps([ "false/hello yop/hello yop !/hello yop }}", "true/hello blip/hello blip !/hello blip }}", "true/hello gnap/hello gnap !/hello gnap }}", "true/hello blip/hello blip !/hello blip }}", ]); }); test("view button and string interpolated attribute in kanban", async () => { patchWithCleanup(ViewButton.prototype, { setup() { super.setup(); expect.step(`[${this.props.clickParams["name"]}] className: '${this.props.className}'`); }, }); await mountView({ type: "kanban", resModel: "partner", arch: ` `, }); expect.verifySteps([ "[one] className: 'hola oe_kanban_action'", "[two] className: 'hola oe_kanban_action hello'", "[sri] className: 'hola oe_kanban_action yop'", "[foa] className: 'hola oe_kanban_action yop olleh'", "[fye] className: 'hola oe_kanban_action hello yop'", "[one] className: 'hola oe_kanban_action'", "[two] className: 'hola oe_kanban_action hello'", "[sri] className: 'hola oe_kanban_action blip'", "[foa] className: 'hola oe_kanban_action blip olleh'", "[fye] className: 'hola oe_kanban_action hello blip'", "[one] className: 'hola oe_kanban_action'", "[two] className: 'hola oe_kanban_action hello'", "[sri] className: 'hola oe_kanban_action gnap'", "[foa] className: 'hola oe_kanban_action gnap olleh'", "[fye] className: 'hola oe_kanban_action hello gnap'", "[one] className: 'hola oe_kanban_action'", "[two] className: 'hola oe_kanban_action hello'", "[sri] className: 'hola oe_kanban_action blip'", "[foa] className: 'hola oe_kanban_action blip olleh'", "[fye] className: 'hola oe_kanban_action hello blip'", ]); }); test("click on a button type='delete' to delete a record in a column", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
Delete
`, groupBy: ["product_id"], }); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2); expect(queryAll(".o_kanban_load_more", { root: getKanbanColumn(0) })).toHaveCount(0); await click(queryFirst(".o_kanban_record .o_delete", { root: getKanbanColumn(0) })); await animationFrame(); expect(".modal").toHaveCount(1); await contains(".modal .btn-primary").click(); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1); expect(queryAll(".o_kanban_load_more", { root: getKanbanColumn(0) })).toHaveCount(0); }); test("click on a button type='archive' to archive a record in a column", async () => { onRpc("action_archive", ({ args }) => { expect.step(`archive:${args[0]}`); return true; }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["product_id"], }); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2); await contains(".o_kanban_record .o_archive").click(); expect(".modal").toHaveCount(1); expect.verifySteps([]); await contains(".modal .btn-primary").click(); expect.verifySteps(["archive:1"]); }); test("click on a button type='unarchive' to unarchive a record in a column", async () => { onRpc("action_unarchive", ({ args }) => { expect.step(`unarchive:${args[0]}`); return true; }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["product_id"], }); expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2); await contains(".o_kanban_record .o_unarchive").click(); expect.verifySteps(["unarchive:1"]); }); test("many2many_tags in kanban views", async () => { Partner._records[0].category_ids = [6, 7]; Partner._records[1].category_ids = [7, 8]; Category._records.push({ id: 8, name: "hello", color: 0, }); stepAllNetworkCalls(); await mountView({ type: "kanban", resModel: "partner", arch: `
`, selectRecord: (resId) => { expect(resId).toBe(1, { message: "should trigger an event to open the clicked record in a form view", }); }, }); expect( queryAll(".o_field_many2many_tags .o_tag", { root: getKanbanRecord({ index: 0 }) }) ).toHaveCount(2, { message: "first record should contain 2 tags", }); expect(queryAll(".o_tag.o_tag_color_2", { root: getKanbanRecord({ index: 0 }) })).toHaveCount( 1, { message: "first tag should have color 2", } ); expect.verifySteps([ "/web/webclient/translations", "/web/webclient/load_menus", "get_views", "web_search_read", ]); // Checks that second records has only one tag as one should be hidden (color 0) expect(".o_kanban_record:nth-child(2) .o_tag").toHaveCount(1, { message: "there should be only one tag in second record", }); expect(".o_kanban_record:nth-child(2) .o_tag:first").toHaveText("silver"); // Write on the record using the priority widget to trigger a re-render in readonly await contains(".o_kanban_record:first-child .o_priority_star:first-child").click(); expect.verifySteps(["web_save"]); expect(".o_kanban_record:first-child .o_field_many2many_tags .o_tag").toHaveCount(2, { message: "first record should still contain only 2 tags", }); expect(".o_kanban_record:first-child .o_tag:eq(0)").toHaveText("gold"); expect(".o_kanban_record:first-child .o_tag:eq(1)").toHaveText("silver"); // click on a tag (should trigger switch_view) await contains(".o_kanban_record:first-child .o_tag:first-child").click(); }); test("priority field should not be editable when missing access rights", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); // Try to fill one star in the priority field of the first record await contains(".o_kanban_record:first-child .o_priority_star:first-child").click(); expect(".o_kanban_record:first-child .o_priority .fa-star-o").toHaveCount(2, { message: "first record should still contain 2 empty stars", }); }); test("Do not open record when clicking on `a` with `href`", async () => { expect.assertions(6); Partner._records = [{ id: 1, foo: "yop" }]; mockService("action", { async switchView() { // when clicking on a record in kanban view, // it switches to form view. expect.step("switchView"); }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1); expect(".o_kanban_record a").toHaveCount(1); const testLink = queryFirst(".o_kanban_record a"); expect(!!testLink.href).toBe(true, { message: "link inside kanban record should have non-empty href", }); // Prevent the browser default behaviour when clicking on anything. // This includes clicking on a `` with `href`, so that it does not // change the URL in the address bar. // Note that we should not specify a click listener on 'a', otherwise // it may influence the kanban record global click handler to not open // the record. testLink.addEventListener("click", (ev) => { expect(ev.defaultPrevented).toBe(false, { message: "should not prevented browser default behaviour beforehand", }); expect(ev.target).toBe(testLink, { message: "should have clicked on the test link in the kanban record", }); ev.preventDefault(); }); await click(testLink); expect.verifySteps([]); }); test("Open record when clicking on widget field", async function (assert) { expect.assertions(2); Product._views["form,false"] = `
`; await mountView({ type: "kanban", resModel: "partner", arch: `
`, selectRecord: (resId) => { expect(resId).toBe(1, { message: "should trigger an event to open the form view" }); }, }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4); await click(queryFirst(".o_field_monetary[name=salary]")); }); test("clicking on a link triggers correct event", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
Edit
`, selectRecord: (resId, { mode }) => { expect(resId).toBe(1); expect(mode).toBe("edit"); }, }); await contains("a", { root: getKanbanRecord({ index: 0 }) }).click(); }); test("empty grouped kanban with sample data and many2many_tags", async () => { onRpc("web_read_group", function ({ kwargs, parent }) { const result = parent(); // override read_group to return empty groups, as this is // the case for several models (e.g. project.task grouped // by stage_id) result.groups.forEach((group) => { group[`${kwargs.groupby[0]}_count`] = 0; }); return result; }); stepAllNetworkCalls(); await mountView({ type: "kanban", resModel: "partner", arch: `
`, groupBy: ["product_id"], }); expect(".o_kanban_group").toHaveCount(2, { message: "there should be 2 'real' columns" }); expect(queryFirst(".o_content")).toHaveClass("o_view_sample_data"); expect(queryAll(".o_kanban_record").length >= 1).toBe(true, { message: "there should be sample records", }); expect(queryAll(".o_field_many2many_tags .o_tag").length >= 1).toBe(true, { message: "there should be tags", }); // should not read the tags expect.verifySteps([ "/web/webclient/translations", "/web/webclient/load_menus", "get_views", "web_read_group", ]); }); test("buttons with modifiers", async () => { Partner._records[1].bar = false; // so that test is more complete await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_btn_test_1").toHaveCount(1, { message: "kanban should have one buttons of type 1" }); expect(".o_btn_test_2").toHaveCount(3, { message: "kanban should have three buttons of type 2", }); }); test("support styling of anchor tags with action type", async function (assert) { expect.assertions(3); mockService("action", { doActionButton(action) { expect(action.name).toBe("42"); }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
Click me !
`, }); await click(queryFirst("a[type='action']")); expect(queryFirst("a[type='action']")).toHaveClass("btn-primary"); expect(queryFirst("a[type='action']").style.marginLeft).toBe("10px"); }); test("button executes action and reloads", async () => { stepAllNetworkCalls(); let count = 0; mockService("action", { async doActionButton({ onClose }) { count++; await animationFrame(); onClose(); }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect.verifySteps([ "/web/webclient/translations", "/web/webclient/load_menus", "get_views", "web_search_read", ]); expect("button.a1").toHaveCount(4); expect("button.a1:first").not.toHaveAttribute("disabled"); await click("button.a1"); expect("button.a1:first").toHaveAttribute("disabled"); await animationFrame(); expect("button.a1:first").not.toHaveAttribute("disabled"); expect(count).toBe(1, { message: "should have triggered an execute action only once" }); // the records should be reloaded after executing a button action expect.verifySteps(["web_search_read"]); }); test("button executes action and check domain", async () => { Partner._fields.active = fields.Boolean({ default: true }); for (let i = 0; i < Partner.length; i++) { Partner._records[i].active = true; } mockService("action", { doActionButton({ onClose }) { Partner._records[0].active = false; onClose(); }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryText("span", { root: getKanbanRecord({ index: 0 }) })).toBe("yop", { message: "should display 'yop' record", }); await contains("button.toggle-active", { root: getKanbanRecord({ index: 0 }) }).click(); expect(queryText("span", { root: getKanbanRecord({ index: 0 }) })).not.toBe("yop", { message: "should have removed 'yop' record from the view", }); }); test("field tag with modifiers but no widget", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_kanban_record:first").toHaveText(""); expect(".o_kanban_record:eq(1)").toHaveText("blip"); }); test("field tag with widget and class attributes", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_field_widget.hi").toHaveCount(4); }); test("rendering date and datetime (value)", async () => { Partner._records[0].date = "2017-01-25"; Partner._records[1].datetime = "2016-12-12 10:55:05"; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecord({ index: 0 }).querySelector(".date")).toHaveText("01/25/2017"); expect(getKanbanRecord({ index: 1 }).querySelector(".datetime")).toHaveText( "12/12/2016 11:55:05" ); }); test("rendering date and datetime (raw value)", async () => { Partner._records[0].date = "2017-01-25"; Partner._records[1].datetime = "2016-12-12 10:55:05"; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecord({ index: 0 }).querySelector(".date")).toHaveText( "2017-01-25T00:00:00.000+01:00" ); expect(getKanbanRecord({ index: 1 }).querySelector(".datetime")).toHaveText( "2016-12-12T11:55:05.000+01:00" ); }); test("rendering many2one (value)", async () => { Partner._records[1].product_id = false; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual(["hello", "", "hello", "xmo"]); }); test("rendering many2one (raw value)", async () => { Partner._records[1].product_id = false; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual(["3", "false", "3", "5"]); }); test("evaluate conditions on relational fields", async () => { Partner._records[0].product_id = false; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4, { message: "there should be 4 records", }); expect(".o_kanban_record:not(.o_kanban_ghost) .btn_a").toHaveCount(1, { message: "only 1 of them should have the 'Action' button", }); expect(".o_kanban_record:not(.o_kanban_ghost) .btn_b").toHaveCount(2, { message: "only 2 of them should have the 'Action' button", }); }); test("properly evaluate more complex domains", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect("button.float-end.oe_kanban_action").toHaveCount(1, { message: "only one button should be visible", }); }); test("basic support for widgets (being Owl Components)", async () => { class MyComponent extends Component { static template = xml`
`; static props = ["*"]; get value() { return JSON.stringify(this.props.record.data); } } const myComponent = { component: MyComponent, }; viewWidgetRegistry.add("test", myComponent); after(() => viewWidgetRegistry.remove("test")); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecord({ index: 2 }).querySelector(".o_widget")).toHaveText('{"foo":"gnap"}'); }); test("kanban card: record value should be updated", async () => { class MyComponent extends Component { static template = xml`
`; static props = ["*"]; onClick() { this.props.record.update({ foo: "yolo" }); } } const myComponent = { component: MyComponent, }; viewWidgetRegistry.add("test", myComponent); after(() => viewWidgetRegistry.remove("test")); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryText(".foo", { root: getKanbanRecord({ index: 0 }) })).toBe("yop"); await click(queryOne("button", { root: getKanbanRecord({ index: 0 }) })); await animationFrame(); await animationFrame(); expect(queryText(".foo", { root: getKanbanRecord({ index: 0 }) })).toBe("yolo"); }); test("test displaying image (URL, image field not set)", async () => { patchWithCleanup(KanbanCompiler.prototype, { compileImage(el) { el.setAttribute("t-att-data-src", el.getAttribute("t-att-src")); el.removeAttribute("t-att-src"); return super.compileImage(el); }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); // since the field image is not set, kanban_image will generate an URL expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([ `${getOrigin()}/web/image/partner/1/image`, `${getOrigin()}/web/image/partner/2/image`, `${getOrigin()}/web/image/partner/3/image`, `${getOrigin()}/web/image/partner/4/image`, ]); expect(queryFirst(".o_kanban_record img").loading).toBe("lazy"); }); test("test displaying image (write_date field)", async () => { // the presence of write_date field ensures that the image is reloaded when necessary expect.assertions(2); patchWithCleanup(KanbanCompiler.prototype, { compileImage(el) { el.setAttribute("t-att-data-src", el.getAttribute("t-att-src")); el.removeAttribute("t-att-src"); return super.compileImage(el); }, }); const rec = Partner._records.find((r) => r.id === 1); rec.write_date = "2022-08-05 08:37:00"; onRpc("web_search_read", ({ kwargs }) => { expect(kwargs.specification).toEqual({ id: {}, write_date: {} }); }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, domain: [["id", "in", [1]]], }); expect( `.o_kanban_record img[data-src='${getOrigin()}/web/image/partner/1/image?unique=1659688620000']` ).toHaveCount(1); }); test("test displaying image (binary & placeholder)", async () => { patchWithCleanup(KanbanCompiler.prototype, { compileImage(el) { el.setAttribute("t-att-data-src", el.getAttribute("t-att-src")); el.removeAttribute("t-att-src"); return super.compileImage(el); }, }); Partner._fields.image = fields.Binary(); Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA=="; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([ "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==", `${getOrigin()}/web/image/partner/2/image`, `${getOrigin()}/web/image/partner/3/image`, `${getOrigin()}/web/image/partner/4/image`, ]); }); test("test displaying image (for another record)", async () => { patchWithCleanup(KanbanCompiler.prototype, { compileImage(el) { el.setAttribute("t-att-data-src", el.getAttribute("t-att-src")); el.removeAttribute("t-att-src"); return super.compileImage(el); }, }); Partner._fields.image = fields.Binary(); Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA=="; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); // the field image is set, but we request the image for a specific id // -> for the record matching the ID, the base64 should be returned // -> for all the other records, the image should be displayed by url expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([ "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==", `${getOrigin()}/web/image/partner/1/image`, `${getOrigin()}/web/image/partner/1/image`, `${getOrigin()}/web/image/partner/1/image`, ]); }); test("test displaying image from m2o field (m2o field not set)", async () => { patchWithCleanup(KanbanCompiler.prototype, { compileImage(el) { el.setAttribute("t-att-data-src", el.getAttribute("t-att-src")); el.removeAttribute("t-att-src"); return super.compileImage(el); }, }); class FooPartner extends models.Model { _name = "foo.partner"; name = fields.Char(); partner_id = fields.Many2one({ relation: "partner" }); _records = [ { id: 1, name: "foo_with_partner_image", partner_id: 1 }, { id: 2, name: "foo_no_partner" }, ]; } defineModels([FooPartner]); await mountView({ type: "kanban", resModel: "foo.partner", arch: `
`, }); expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([ `${getOrigin()}/web/image/partner/1/image`, `${getOrigin()}/web/image/partner/null/image`, ]); }); test.tags("desktop"); test("set cover image", async () => { expect.assertions(9); IrAttachment._records = [ { id: 1, name: "1.png", mimetype: "image/png", res_model: "partner", res_id: 1, }, { id: 2, name: "2.png", mimetype: "image/png", res_model: "partner", res_id: 2, }, ]; Partner._fields.displayed_image_id = fields.Many2one({ string: "Cover", relation: "ir.attachment", }); onRpc(({ model, method, args }) => { if (model === "partner" && method === "web_save") { expect.step(String(args[0][0])); } }); await mountView({ type: "kanban", resModel: "partner", arch: ` Set Cover Image
`, }); mockService("action", { switchView(_viewType, { mode, resModel, res_id, view_type }) { expect({ mode, resModel, res_id, view_type }).toBe({ mode: "readonly", resModel: "partner", res_id: 1, view_type: "form", }); }, }); await toggleKanbanRecordDropdown(0); await contains(".oe_kanban_action", { root: getDropdownMenu(getKanbanRecord({ index: 0 })), }).click(); expect(queryAll("img", { root: getKanbanRecord({ index: 0 }) })).toHaveCount(0, { message: "Initially there is no image.", }); await contains(".modal .o_kanban_cover_image img").click(); await contains(".modal .btn-primary:first-child").click(); expect('img[data-src*="/web/image/1"]').toHaveCount(1); await toggleKanbanRecordDropdown(1); const coverButton = getDropdownMenu(getKanbanRecord({ index: 1 })).querySelector("a"); expect(queryText(coverButton)).toBe("Set Cover Image"); await contains(coverButton).click(); expect(".modal .o_kanban_cover_image").toHaveCount(1); expect(".modal .btn:contains(Select)").toHaveCount(1); expect(".modal .btn:contains(Discard)").toHaveCount(1); expect(".modal .btn:contains(Remove Cover Image)").toHaveCount(0); await dblclick(".modal .o_kanban_cover_image img"); // doesn't work await animationFrame(); expect('img[data-src*="/web/image/2"]').toHaveCount(1); await contains(".o_kanban_record:first-child .o_attachment_image").click(); //Not sure, to discuss // should writes on both kanban records expect.verifySteps(["1", "2"]); }); test.tags("desktop"); test("open file explorer if no cover image", async () => { expect.assertions(2); Partner._fields.displayed_image_id = fields.Many2one({ string: "Cover", relation: "ir.attachment", }); const uploadedPromise = new Deferred(); await createFileInput({ mockPost: async (route) => { if (route === "/web/binary/upload_attachment") { await uploadedPromise; } return "[]"; }, props: {}, }); await mountView({ type: "kanban", resModel: "partner", arch: ` Set Cover Image
`, }); await toggleKanbanRecordDropdown(0); await contains(".oe_kanban_action", { root: getDropdownMenu(getKanbanRecord({ index: 0 })), }).click(); await setInputFiles([]); await animationFrame(); expect(`.o_file_input input`).not.toBeEnabled({ message: "the upload button should be disabled on upload", }); uploadedPromise.resolve(); await animationFrame(); expect(`.o_file_input input`).toBeEnabled({ message: "the upload button should be enabled for upload", }); }); test.tags("desktop"); test("unset cover image", async () => { IrAttachment._records = [ { id: 1, name: "1.png", mimetype: "image/png", res_model: "partner", res_id: 1, }, { id: 2, name: "2.png", mimetype: "image/png", res_model: "partner", res_id: 2, }, ]; Partner._fields.displayed_image_id = fields.Many2one({ string: "Cover", relation: "ir.attachment", }); Partner._records[0].displayed_image_id = 1; Partner._records[1].displayed_image_id = 2; onRpc(({ model, method, args }) => { if (model === "partner" && method === "web_save") { expect.step(String(args[0][0])); expect(args[1].displayed_image_id).toBe(false); } }); await mountView({ type: "kanban", resModel: "partner", arch: ` Set Cover Image
`, }); await toggleKanbanRecordDropdown(0); await contains(".oe_kanban_action", { root: getDropdownMenu(getKanbanRecord({ index: 0 })), }).click(); expect( queryAll('img[data-src*="/web/image/1"]', { root: getKanbanRecord({ index: 0 }) }) ).toHaveCount(1); expect( queryAll('img[data-src*="/web/image/2"]', { root: getKanbanRecord({ index: 1 }) }) ).toHaveCount(1); expect(".modal .o_kanban_cover_image").toHaveCount(1); expect(".modal .btn:contains(Select)").toHaveCount(1); expect(".modal .btn:contains(Discard)").toHaveCount(1); expect(".modal .btn:contains(Remove Cover Image)").toHaveCount(1); await contains(".modal .btn-secondary").click(); // click on "Remove Cover Image" button expect(queryAll("img", { root: getKanbanRecord({ index: 0 }) })).toHaveCount(0, { message: "The cover image should be removed.", }); await toggleKanbanRecordDropdown(1); const coverButton = getDropdownMenu(getKanbanRecord({ index: 1 })).querySelector("a"); expect(queryText(coverButton)).toBe("Set Cover Image"); await contains(coverButton).click(); await dblclick(".modal .o_kanban_cover_image img"); // doesn't work await animationFrame(); expect(queryAll("img", { root: getKanbanRecord({ index: 1 }) })).toHaveCount(0, { message: "The cover image should be removed.", }); // should writes on both kanban records expect.verifySteps(["1", "2"]); }); test.tags("desktop"); test("ungrouped kanban with handle field", async () => { expect.assertions(3); onRpc("/web/dataset/resequence", async (request) => { const { params } = await request.json(); expect(params.ids).toEqual([2, 1, 3, 4], { message: "should write the sequence in correct order", }); return true; }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual(["blip", "blip", "yop", "gnap"]); await contains(".o_kanban_record").dragAndDrop(queryFirst(".o_kanban_record:nth-child(4)")); expect(getKanbanRecordTexts()).toEqual(["blip", "yop", "gnap", "blip"]); }); test("ungrouped kanban without handle field", async () => { onRpc("/web/dataset/resequence", () => { expect.step("resequence"); return true; }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual(["yop", "blip", "gnap", "blip"]); await contains(".o_kanban_record").dragAndDrop(queryFirst(".o_kanban_record:nth-child(4)")); expect(getKanbanRecordTexts()).toEqual(["yop", "blip", "gnap", "blip"]); expect.verifySteps([]); }); test("click on image field in kanban (with default global_click)", async () => { expect.assertions(2); Partner._fields.image = fields.Binary(); Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA=="; await mountView({ type: "kanban", resModel: "partner", arch: `
`, selectRecord(recordId) { expect(recordId).toBe(1, { message: "should call its selectRecord prop with the clicked record", }); }, }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4); await contains(".o_field_image").click(); }); test("kanban view with boolean field", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_kanban_record input:disabled").toHaveCount(4); expect(".o_kanban_record input:checked").toHaveCount(3); expect(".o_kanban_record input:not(:checked)").toHaveCount(1); }); test("kanban view with boolean widget", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect( queryAll("div.o_field_boolean .o-checkbox", { root: getKanbanRecord({ index: 0 }) }) ).toHaveCount(1); }); test("kanban view with boolean toggle widget", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecord({ index: 0 }).querySelector("[name='bar'] input")).toBeChecked(); expect(getKanbanRecord({ index: 1 }).querySelector("[name='bar'] input")).toBeChecked(); await click(queryOne("[name='bar'] input", { root: getKanbanRecord({ index: 1 }) })); await animationFrame(); expect(getKanbanRecord({ index: 0 }).querySelector("[name='bar'] input")).toBeChecked(); expect(getKanbanRecord({ index: 1 }).querySelector("[name='bar'] input")).not.toBeChecked(); }); test("kanban view with monetary and currency fields without widget", async () => { const mockedCurrencies = {}; for (const record of Currency._records) { mockedCurrencies[record.id] = record; } patchWithCleanup(currencies, mockedCurrencies); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual([ `$ 1,750.00`, `$ 1,500.00`, `2,000.00 €`, `$ 2,222.00`, ]); }); test("kanban with isHtmlEmpty method", async () => { Product._fields.description = fields.Html(); Product._records.push({ id: 11, name: "product 11", description: "hello", }); Product._records.push({ id: 12, name: "product 12", description: "


", }); await mountView({ type: "kanban", resModel: "product", arch: `
`, domain: [["id", "in", [11, 12]]], }); expect(".o_kanban_record:first-child div.test").toHaveCount(1, { message: "the container is displayed if description have actual content", }); expect(queryText("span.text-info", { root: getKanbanRecord({ index: 0 }) })).toBe("hello", { message: "the inner html content is rendered properly", }); expect(".o_kanban_record:last-child div.test").toHaveCount(0, { message: "the container is not displayed if description just have formatting tags and no actual content", }); }); test("kanban widget can extract props from attrs", async () => { class TestWidget extends Component { static template = xml`
`; static props = ["*"]; } const testWidget = { component: TestWidget, extractProps: ({ attrs }) => { return { title: attrs.title, }; }, }; viewWidgetRegistry.add("widget_test_option", testWidget); after(() => viewWidgetRegistry.remove("widget_test_option")); await mountView({ arch: `
`, resModel: "partner", type: "kanban", }); expect(".o-test-widget-option").toHaveCount(4); expect(".o-test-widget-option:first").toHaveText("Widget with Option"); }); test("action/type attributes on kanban arch, type='object'", async () => { mockService("action", { doActionButton(params) { expect.step(`doActionButton type ${params.type} name ${params.name}`); params.onClose(); }, }); stepAllNetworkCalls(); await mountView({ type: "kanban", resModel: "partner", arch: `

some value

`, }); expect.verifySteps([ "/web/webclient/translations", "/web/webclient/load_menus", "get_views", "web_search_read", ]); await contains(".o_kanban_record p").click(); expect.verifySteps(["doActionButton type object name a1", "web_search_read"]); }); test("action/type attributes on kanban arch, type='action'", async () => { mockService("action", { doActionButton(params) { expect.step(`doActionButton type ${params.type} name ${params.name}`); params.onClose(); }, }); stepAllNetworkCalls(); await mountView({ type: "kanban", resModel: "partner", arch: `

some value

`, }); expect.verifySteps([ "/web/webclient/translations", "/web/webclient/load_menus", "get_views", "web_search_read", ]); await contains(".o_kanban_record p").click(); expect.verifySteps(["doActionButton type action name a1", "web_search_read"]); }); test("Missing t-key is automatically filled with a warning", async () => { patchWithCleanup(console, { warn: (msg) => { if (msg !== "'kanban-box' is deprecated, define a 'card' template instead") { expect.step("warning"); } }, }); await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect.verifySteps(["warning"]); expect(getKanbanRecord({ index: 0 })).toHaveText("123"); }); test("Allow use of 'editable'/'deletable' in ungrouped kanban", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(getKanbanRecordTexts()).toEqual([ "EDITDELETE", "EDITDELETE", "EDITDELETE", "EDITDELETE", ]); }); test("can use JSON in kanban template", async () => { Partner._records = [{ id: 1, foo: '["g", "e", "d"]' }]; await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1); expect(".o_kanban_record span").toHaveCount(3); expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveText("ged"); }); test("Can't use KanbanRecord implementation details in arch", async () => { await mountView({ type: "kanban", resModel: "partner", arch: `
`, }); expect(queryFirst(".o_kanban_record").innerHTML).toBe("
"); }); test("kanbans with basic and custom compiler, same arch", async () => { // In this test, the exact same arch will be rendered by 2 different kanban renderers: // once with the basic one, and once with a custom renderer having a custom compiler. The // purpose of the test is to ensure that the template is compiled twice, once by each // compiler, even though the arch is the same. class MyKanbanCompiler extends KanbanCompiler { setup() { super.setup(); this.compilers.push({ selector: "div", fn: this.compileDiv }); } compileDiv(node, params) { const compiledNode = this.compileGenericNode(node, params); compiledNode.setAttribute("class", "my_kanban_compiler"); return compiledNode; } } class MyKanbanRecord extends KanbanRecord {} MyKanbanRecord.Compiler = MyKanbanCompiler; class MyKanbanRenderer extends KanbanRenderer {} MyKanbanRenderer.components = { ...KanbanRenderer.components, KanbanRecord: MyKanbanRecord, }; viewRegistry.add("my_kanban", { ...kanbanView, Renderer: MyKanbanRenderer, }); after(() => viewRegistry.remove("my_kanban")); Partner._fields.one2many = fields.One2many({ relation: "partner" }); Partner._records[0].one2many = [1]; Partner._views["form,false"] = `
`; Partner._views["search,false"] = ``; Partner._views["kanban,false"] = `
`; await mountWithCleanup(WebClient); await getService("action").doAction({ res_model: "partner", type: "ir.actions.act_window", views: [ [false, "kanban"], [false, "form"], ], }); // main kanban, custom view expect(".o_kanban_view").toHaveCount(1); expect(".o_my_kanban_view").toHaveCount(1); expect(".my_kanban_compiler").toHaveCount(4); // switch to form await contains(".o_kanban_record").click(); await animationFrame(); expect(".o_form_view").toHaveCount(1); expect(".o_form_view .o_field_widget[name=one2many]").toHaveCount(1); // x2many kanban, basic renderer expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1); expect(".my_kanban_compiler").toHaveCount(0); });