/** @odoo-module **/ import { registerCleanup } from "@web/../tests/helpers/cleanup"; import { addRow, click, clickCreate, clickDiscard, clickM2OHighlightedItem, clickOpenM2ODropdown, clickOpenedDropdownItem, clickSave, dragAndDrop, editInput, getFixture, getNodesTextContent, makeDeferred, nextTick, patchWithCleanup, removeRow, selectDropdownItem, triggerEvent, triggerEvents, triggerHotkey, } from "@web/../tests/helpers/utils"; import { makeView, makeViewInDialog, setupViewRegistries } from "@web/../tests/views/helpers"; import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { pick } from "@web/core/utils/objects"; import { getNextTabableElement } from "@web/core/utils/ui"; import { session } from "@web/session"; import { Record } from "@web/model/relational_model/record"; import { getPickerCell } from "../../core/datetime/datetime_test_helpers"; import { makeServerError } from "@web/../tests/helpers/mock_server"; import { errorService } from "../../../src/core/errors/error_service"; import { onWillDestroy, onWillStart, reactive, useState } from "@odoo/owl"; import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field"; const serviceRegistry = registry.category("services"); let serverData; let target; QUnit.module("Fields", (hooks) => { hooks.beforeEach(() => { target = getFixture(); serverData = { models: { partner: { fields: { display_name: { string: "Displayed name", type: "char" }, foo: { string: "Foo", type: "char", default: "My little Foo Value" }, bar: { string: "Bar", type: "boolean", default: true }, int_field: { string: "int_field", type: "integer", sortable: true }, qux: { string: "Qux", type: "float", digits: [16, 1] }, p: { string: "one2many field", type: "one2many", relation: "partner", relation_field: "trululu", }, turtles: { string: "one2many turtle field", type: "one2many", relation: "turtle", relation_field: "turtle_trululu", }, trululu: { string: "Trululu", type: "many2one", relation: "partner" }, timmy: { string: "pokemon", type: "many2many", relation: "partner_type" }, product_id: { string: "Product", type: "many2one", relation: "product" }, color: { type: "selection", selection: [ ["red", "Red"], ["black", "Black"], ], default: "red", string: "Color", }, date: { string: "Some Date", type: "date" }, datetime: { string: "Datetime Field", type: "datetime" }, user_id: { string: "User", type: "many2one", relation: "user" }, }, records: [ { id: 1, display_name: "first record", bar: true, foo: "yop", int_field: 10, qux: 0.44, p: [], turtles: [2], timmy: [], trululu: 4, user_id: 17, }, { id: 2, display_name: "second record", bar: true, foo: "blip", int_field: 9, qux: 13, p: [], timmy: [], trululu: 1, product_id: 37, date: "2017-01-25", datetime: "2016-12-12 10:55:05", user_id: 17, }, { id: 4, display_name: "aaa", bar: false, }, ], onchanges: {}, }, product: { fields: { name: { string: "Product Name", type: "char" }, }, records: [ { id: 37, display_name: "xphone", }, { id: 41, display_name: "xpad", }, ], }, partner_type: { fields: { name: { string: "Partner Type", type: "char" }, color: { string: "Color index", type: "integer" }, }, records: [ { id: 12, display_name: "gold", color: 2 }, { id: 14, display_name: "silver", color: 5 }, ], }, turtle: { fields: { display_name: { string: "Displayed name", type: "char" }, turtle_foo: { string: "Foo", type: "char" }, turtle_bar: { string: "Bar", type: "boolean", default: true }, turtle_int: { string: "int", type: "integer", sortable: true }, turtle_qux: { string: "Qux", type: "float", digits: [16, 1], required: true, default: 1.5, }, turtle_description: { string: "Description", type: "text" }, turtle_trululu: { string: "Trululu", type: "many2one", relation: "partner", }, turtle_ref: { string: "Reference", type: "reference", selection: [ ["product", "Product"], ["partner", "Partner"], ], }, product_id: { string: "Product", type: "many2one", relation: "product", required: true, }, partner_ids: { string: "Partner", type: "many2many", relation: "partner" }, }, records: [ { id: 1, display_name: "leonardo", turtle_bar: true, turtle_foo: "yop", partner_ids: [], }, { id: 2, display_name: "donatello", turtle_bar: true, turtle_foo: "blip", turtle_int: 9, partner_ids: [2, 4], }, { id: 3, display_name: "raphael", product_id: 37, turtle_bar: false, turtle_foo: "kawa", turtle_int: 21, turtle_qux: 9.8, partner_ids: [], turtle_ref: "product,37", }, ], onchanges: {}, }, user: { fields: { name: { string: "Name", type: "char" }, partner_ids: { string: "one2many partners field", type: "one2many", relation: "partner", relation_field: "user_id", }, }, records: [ { id: 17, name: "Aline", partner_ids: [1, 2], }, { id: 19, name: "Christine", }, ], }, }, }; setupViewRegistries(); }); QUnit.module("One2ManyField"); QUnit.test( "New record with a o2m also with 2 new records, ordered, and resequenced", async function (assert) { // Needed to have two new records in a single stroke serverData.models.partner.onchanges = { foo: function (obj) { obj.p = [ [0, 0, { trululu: false }], [0, 0, { trululu: false }], ]; }, }; let startAssert = false; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (startAssert) { assert.step(args.method + " " + args.model); } }, resId: 1, }); startAssert = true; await clickCreate(target); // change the int_field through drag and drop // that way, we'll trigger the sorting and the display_name read // of the lines of "p" await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.verifySteps(["onchange partner"]); } ); QUnit.test("resequence with NULL value", async function (assert) { const mockedActionService = { start() { return { doActionButton(params) { if (params.name === "reload") { params.onClose(); } else { throw makeServerError(); } }, }; }, }; serviceRegistry.add("action", mockedActionService, { force: true }); serverData.models.partner.records.push( { id: 10, int_field: 1 }, { id: 11, int_field: 2 }, { id: 12, int_field: 3 }, { id: 13 } ); serverData.models.partner.records[0].p = [10, 11, 12, 13]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, async mockRPC(route, args, performRPC) { if (args.method === "web_read") { const res = await performRPC(route, args); const serverRecords = Object.fromEntries( Object.values(serverData.models.partner.records).map((e) => [e.id, e]) ); // when sorted, NULL values are last const getServerValue = (record) => serverRecords[record.id].int_field === false ? Number.MAX_SAFE_INTEGER : serverRecords[record.id].int_field; res[0].p.sort((a, b) => getServerValue(a) - getServerValue(b)); return res; } }, resId: 1, }); assert.deepEqual( Array.from(target.querySelectorAll(".o_field_cell[name=id]")).map((e) => e.textContent), ["10", "11", "12", "13"] ); await dragAndDrop("tbody tr:nth-child(4) .o_handle_cell", "tbody tr:nth-child(3)"); assert.deepEqual( Array.from(target.querySelectorAll(".o_field_cell[name=id]")).map((e) => e.textContent), ["10", "11", "13", "12"] ); await click(target.querySelector("button.reload")); assert.deepEqual( Array.from(target.querySelectorAll(".o_field_cell[name=id]")).map((e) => e.textContent), ["10", "11", "13", "12"] ); }); QUnit.test("one2many in a list x2many editable use the right context", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "name_create") { assert.step(`name_create ${args.kwargs.context.my_context}`); } }, resId: 1, }); await addRow(target, ".o_field_x2many_list"); await editInput(target, "[name='trululu'] input", "new partner"); await selectDropdownItem(target, "trululu", 'Create "new partner"'); assert.verifySteps(["name_create list"]); }); QUnit.test( "one2many in a list x2many non-editable use the right context", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "name_create") { assert.step(`name_create ${args.kwargs.context.my_context}`); } }, resId: 1, }); await addRow(target, ".o_field_x2many_list"); await editInput(target, "[name='trululu'] input", "new partner"); await selectDropdownItem(target, "trululu", 'Create "new partner"'); assert.verifySteps(["name_create form"]); } ); QUnit.test("O2M field without relation_field", async function (assert) { delete serverData.models.partner.fields.p.relation_field; serverData.models.partner.records[0].p = [2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target, ".o_field_x2many_list"); assert.containsOnce(target, ".o_dialog"); }); QUnit.test( "O2M List with pager, decoration and default_order: add and cancel adding", async function (assert) { // The decoration on the list implies that its condition will be evaluated // against the data of the field (actual records *displayed*) // If one data is wrongly formed, it will crash // This test adds then cancels a record in a paged, ordered, and decorated list // That implies prefetching of records for sorting // and evaluation of the decoration against *visible records* serverData.models.partner.records[0].p = [2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target, ".o_field_x2many_list"); assert.containsN( target, ".o_field_x2many_list .o_data_row", 2, "There should be 2 rows" ); const expectedSelectedRow = target.querySelectorAll( ".o_field_x2many_list .o_data_row" )[1]; const actualSelectedRow = target.querySelector(".o_selected_row"); assert.equal( actualSelectedRow[0], expectedSelectedRow[0], "The selected row should be the new one" ); // Cancel Creation triggerEvent(actualSelectedRow, "input", "keydown", { key: "Escape" }); await nextTick(); assert.containsOnce( target, ".o_field_x2many_list .o_data_row", "There should be 1 row" ); } ); QUnit.test("O2M with parented m2o and domain on parent.m2o", async function (assert) { assert.expect(7); // Records in an o2m can have a m2o pointing to themselves. // In that case, a domain evaluation on that field followed by name_search // shouldn't send virtual_ids to the server. patchWithCleanup(browser, { setTimeout: (fn) => fn(), }); serverData.models.turtle.fields.parent_id = { string: "Parent", type: "many2one", relation: "turtle", }; serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, { kwargs }) { if (route === "/web/dataset/call_kw/turtle/name_search") { assert.deepEqual(kwargs.args, [["id", "in", []]]); assert.deepEqual(JSON.stringify(kwargs.args), '[["id","in",[]]]'); } }, }); await addRow(target); await clickOpenM2ODropdown(target, "parent_id"); await editInput(target, ".o_field_widget[name=parent_id] input", "ABC"); await clickOpenedDropdownItem(target, "parent_id", "Create and edit..."); await click(target, ".o_dialog:not(.o_inactive_modal) .modal-footer .o_form_button_save"); await click(target, ".o_dialog:not(.o_inactive_modal) .o_form_button_save_new"); assert.containsOnce( target, ".o_data_row", "The main record should have the new record in its o2m" ); await click(target, ".o_field_many2one input"); }); QUnit.test( 'O2M with buttons with attr "special" in dialog close the dialog', async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); await addRow(target); assert.containsOnce(target, ".o_dialog"); assert.strictEqual(document.querySelector(".modal .btn").innerText, "Cancel"); await click(target, ".modal .btn"); assert.containsNone(target, ".o_dialog"); } ); QUnit.test("O2M modal buttons are disabled on click", async function (assert) { // Records in an o2m can have a m2o pointing to themselves. // In that case, a domain evaluation on that field followed by name_search // shouldn't send virtual_ids to the server. patchWithCleanup(browser, { setTimeout: (fn) => fn(), }); serverData.models.turtle.fields.parent_id = { string: "Parent", type: "many2one", relation: "turtle", }; serverData.views = { "turtle,false,form": `
`, }; const def = makeDeferred(); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, async mockRPC(route, args) { if (args.method === "web_save") { await def; } }, }); await addRow(target); await clickOpenM2ODropdown(target, "parent_id"); await editInput(target, ".o_field_widget[name=parent_id] input", "ABC"); await clickOpenedDropdownItem(target, "parent_id", "Create and edit..."); await click( target.querySelector( ".o_dialog:not(.o_inactive_modal) .modal-footer .o_form_button_save" ) ); assert.strictEqual( target .querySelector(".o_dialog:not(.o_inactive_modal) .modal-footer .o_form_button_save") .getAttribute("disabled"), "1" ); def.resolve(); await nextTick(); // close all dialogs await click( target.querySelector( ".o_dialog:not(.o_inactive_modal) .modal-footer .o_form_button_save" ) ); await nextTick(); assert.containsNone(target, ".o_dialog .o_form_view"); }); QUnit.test( "clicking twice on a record in a one2many will open it once", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; const def = makeDeferred(); let firstRead = true; await makeView({ type: "form", resModel: "partner", serverData, resId: 1, arch: `
`, async mockRPC(route, { method, model }) { if (method === "web_read" && model === "turtle") { assert.step("web_read turtle"); if (!firstRead) { await def; } firstRead = false; } }, }); await click(target, ".o_data_cell"); await click(target, ".o_data_cell"); def.resolve(); await nextTick(); assert.containsOnce(target, ".modal"); await click(target, ".modal .btn-close"); assert.containsNone(target, ".modal"); await click(target, ".o_data_cell"); assert.containsOnce(target, ".modal"); assert.verifySteps(["web_read turtle"]); } ); QUnit.test("resequence a x2m in a form view dialog from another x2m", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, resId: 1, arch: `
`, mockRPC(route, args) { assert.step(args.method); if (args.method === "write") { assert.deepEqual(Object.keys(args.args[1]), ["turtles"]); assert.strictEqual(args.args[1].turtles.length, 1); assert.deepEqual(args.args[1].turtles[0], [ 1, 2, { partner_ids: [ [1, 2, { int_field: 0 }], [1, 4, { int_field: 1 }], ], }, ]); } }, }); assert.verifySteps(["get_views", "web_read"]); await click(target, ".o_data_cell"); assert.containsOnce(target, ".modal"); assert.deepEqual( [...target.querySelectorAll(".modal [name='display_name']")].map( (el) => el.textContent ), ["aaa", "second record"] ); assert.verifySteps(["web_read"]); await dragAndDrop(".modal tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.deepEqual( [...target.querySelectorAll(".modal [name='display_name']")].map( (el) => el.textContent ), ["second record", "aaa"] ); assert.verifySteps([]); await clickSave(target.querySelector(".modal")); await clickSave(target); assert.verifySteps(["web_save"]); }); QUnit.test("one2many list editable with cell readonly modifier", async function (assert) { assert.expect(3); serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].turtles = [1, 2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/web_save") { assert.deepEqual( args.args[1].p[0][2], { foo: "ff", qux: 99, turtles: [] }, "The right values should be written" ); } }, }); await addRow(target); const targetInput = target.querySelector(".o_selected_row [name=foo] input"); assert.equal( targetInput, document.activeElement, "The first input of the line should have the focus" ); // Simulating hitting the 'f' key twice targetInput.value = "f"; await triggerEvent(targetInput, null, "input"); targetInput.value = "ff"; await triggerEvent(targetInput, null, "input"); assert.equal( targetInput, document.activeElement, "The first input of the line should still have the focus" ); // Simulating a TAB key triggerHotkey("Tab"); await triggerEvent(targetInput, null, "change"); await nextTick(); const secondTarget = target.querySelector(".o_selected_row [name=qux] input"); secondTarget.value = 9; await triggerEvent(secondTarget, null, "input"); secondTarget.value = 99; await triggerEvent(secondTarget, null, "input"); await triggerEvent(secondTarget, null, "change"); await clickSave(target); }); QUnit.test( "one2many wait for the onchange of the resequenced finish before save", async function (assert) { assert.expect(4); serverData.models.partner.records[0].p = [1, 2]; serverData.models.partner.onchanges = { p: function (obj) { obj.p = [[1, 2, { qux: 99 }]]; }, }; const def = makeDeferred(); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, async mockRPC(route, args) { if (args.method === "onchange") { await def; assert.step("onchange"); } if (args.method === "web_save") { assert.step("web_save"); assert.deepEqual(args.args[1].p, [ [1, 1, { int_field: 9 }], [1, 2, { int_field: 10, qux: 99 }], ]); } }, }); // Drag and drop the second line in first position await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); await clickSave(target); // resolve the onchange promise def.resolve(); await nextTick(); assert.verifySteps(["onchange", "web_save"]); } ); QUnit.test("one2many basic properties", async function (assert) { serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); }, }); assert.verifySteps(["get_views", "web_read"]); assert.containsOnce(target, ".o_field_x2many_list_row_add"); assert.hasAttrValue(target.querySelector(".o_field_x2many_list_row_add"), "colspan", "2"); assert.containsOnce(target, "td.o_list_record_remove"); }); QUnit.test("transferring class attributes in one2many sub fields", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, "td.hey"); await click(target.querySelector("td.o_data_cell")); assert.containsOnce(target, 'td.hey div[name="turtle_foo"] input'); // WOWL to check! hey on input? }); QUnit.test("one2many with date and datetime", async function (assert) { const originalZone = luxon.Settings.defaultZone; luxon.Settings.defaultZone = luxon.FixedOffsetZone.instance(120); registerCleanup(() => { luxon.Settings.defaultZone = originalZone; }); serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.strictEqual(target.querySelector("td").textContent, "01/25/2017"); assert.strictEqual(target.querySelectorAll("td")[1].textContent, "12/12/2016 12:55:05"); }); QUnit.test("rendering with embedded one2many", async function (assert) { serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); const firstHeader = target.querySelector("thead th"); assert.strictEqual(firstHeader.textContent, "Foo"); const firstValue = target.querySelector("tbody td"); assert.strictEqual(firstValue.textContent, "blip"); }); QUnit.test( "use the limit attribute in arch (in field o2m inline tree view)", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.model === "turtle") { assert.deepEqual(args.args[0], [1, 2]); } }, }); assert.containsN(target, ".o_data_row", 2); } ); QUnit.test("nested x2manys with inline form, but not list", async function (assert) { serverData.views = { "turtle,false,list": ``, "partner,false,list": ``, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_form_view"); assert.containsOnce(target, ".o_data_row"); await click(target, ".o_data_row .o_data_cell"); assert.containsOnce(target, ".o_dialog"); assert.containsN(target.querySelector(".o_dialog"), ".o_data_row", 2); }); QUnit.test( "use the limit attribute in arch (in field o2m non inline tree view)", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; serverData.views = { "turtle,false,list": ``, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); if (args.method === "web_read") { assert.deepEqual(args.kwargs.specification, { display_name: {}, turtles: { fields: { turtle_foo: {}, }, limit: 2, order: "", }, }); } }, }); assert.containsN(target, ".o_data_row", 2); assert.verifySteps(["get_views", "get_views", "web_read"]); } ); QUnit.test("one2many with default_order on view not inline", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; serverData.views = { "turtle,false,list": ` `, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.deepEqual( [...target.querySelectorAll(".o_field_one2many .o_data_cell")].map( (el) => el.textContent ), ["9", "blip", "21", "kawa", "0", "yop"] ); }); QUnit.test("embedded one2many with widget", async function (assert) { serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, "span.o_row_handle"); }); QUnit.test("embedded one2many with handle widget", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; serverData.models.partner.onchanges = { turtles: function () {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, async mockRPC(route, args) { if (args.method === "onchange") { assert.step("onchange"); } }, }); assert.deepEqual( [...target.querySelectorAll(".o_data_cell.o_list_char")].map((el) => el.innerText), ["yop", "blip", "kawa"] ); // Drag and drop the second line in first position await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.verifySteps(["onchange"]); assert.deepEqual( [...target.querySelectorAll(".o_data_cell.o_list_char")].map((el) => el.innerText), ["blip", "yop", "kawa"] ); await clickSave(target); assert.deepEqual( serverData.models.turtle.records.map((r) => { return { id: r.id, turtle_foo: r.turtle_foo, turtle_int: r.turtle_int, }; }), [ { id: 1, turtle_foo: "yop", turtle_int: 1 }, { id: 2, turtle_foo: "blip", turtle_int: 0 }, { id: 3, turtle_foo: "kawa", turtle_int: 21 }, ] ); assert.deepEqual( [...target.querySelectorAll(".o_data_cell.o_list_char")].map((el) => el.innerText), ["blip", "yop", "kawa"] ); }); QUnit.test("onchange for embedded one2many in a one2many", async function (assert) { assert.expect(3); serverData.models.turtle.fields.partner_ids.type = "one2many"; serverData.models.turtle.records[0].partner_ids = [1]; serverData.models.partner.records[0].turtles = [1]; serverData.models.partner.onchanges = { turtles: function (obj) { obj.turtles = [ [ 1, 1, { partner_ids: [[4, 2]], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "web_save") { const expectedResultTurtles = [ [1, 1, { turtle_foo: "hop", partner_ids: [[4, 2]] }], ]; assert.deepEqual(args.args[1].turtles, expectedResultTurtles); } }, }); assert.deepEqual(target.querySelector(".o_field_many2many_tags").innerText.split("\n"), [ "first record", ]); await click(target.querySelectorAll(".o_data_cell")[1]); await editInput(target, ".o_selected_row .o_field_widget[name=turtle_foo] input", "hop"); assert.deepEqual(target.querySelector(".o_field_many2many_tags").innerText.split("\n"), [ "first record", "second record", ]); await clickSave(target); }); QUnit.test( "onchange for embedded one2many in a one2many with a second page", async function (assert) { serverData.models.turtle.fields.partner_ids.type = "one2many"; serverData.models.turtle.records[0].partner_ids = [1]; // we need a second page, so we set two records and only display one per page serverData.models.partner.records[0].turtles = [1, 2]; serverData.models.partner.onchanges = { turtles: function (obj) { obj.turtles = [ [ 1, 1, { partner_ids: [[4, 2]], }, ], [ 1, 2, { turtle_foo: "blip", partner_ids: [[4, 1]], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "web_save") { const expectedResultTurtles = [ [1, 1, { turtle_foo: "hop", partner_ids: [[4, 2]] }], [ 1, 2, { partner_ids: [[4, 1]], turtle_foo: "blip", }, ], ]; assert.deepEqual(args.args[1].turtles, expectedResultTurtles); } }, }); await click(target.querySelectorAll(".o_data_cell")[1]); await editInput( target, ".o_selected_row .o_field_widget[name=turtle_foo] input", "hop" ); await clickSave(target); } ); QUnit.test( "onchange for embedded one2many in a one2many updated by server", async function (assert) { // here we test that after an onchange, the embedded one2many field has // been updated by a new list of ids by the server response, to this new // list should be correctly sent back at save time assert.expect(3); serverData.models.turtle.fields.partner_ids.type = "one2many"; serverData.models.partner.records[0].turtles = [2]; serverData.models.turtle.records[1].partner_ids = [2]; serverData.models.partner.onchanges = { turtles: function (obj) { obj.turtles = [ [ 1, 2, { partner_ids: [[4, 4]], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/web_save") { var expectedResultTurtles = [ [ 1, 2, { partner_ids: [[4, 4]], turtle_foo: "hop", }, ], ]; assert.deepEqual( args.args[1].turtles, expectedResultTurtles, "The right values should be written" ); } }, }); assert.deepEqual( [ ...target.querySelectorAll( ".o_data_cell.o_many2many_tags_cell .o_tag_badge_text" ), ].map((el) => el.textContent), ["second record"] ); await click(target.querySelectorAll(".o_data_cell")[1]); await editInput(target, ".o_selected_row [name=turtle_foo] input", "hop"); await clickSave(target); assert.deepEqual( [ ...target.querySelectorAll( ".o_data_cell.o_many2many_tags_cell .o_tag_badge_text" ), ].map((el) => el.textContent), ["second record", "aaa"] ); } ); QUnit.test("onchange for embedded one2many with handle widget", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; let partnerOnchange = 0; serverData.models.partner.onchanges = { turtles: function () { partnerOnchange++; }, }; let turtleOnchange = 0; serverData.models.turtle.onchanges = { turtle_int: function () { turtleOnchange++; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "yop", "blip", "kawa", ]); // Drag and drop the second line in first position await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blip", "yop", "kawa", ]); assert.strictEqual(turtleOnchange, 2, "should trigger one onchange per line updated"); assert.strictEqual(partnerOnchange, 1, "should trigger only one onchange on the parent"); }); QUnit.test( "onchange for embedded one2many with handle widget using same sequence", async function (assert) { serverData.models.turtle.records[0].turtle_int = 1; serverData.models.turtle.records[1].turtle_int = 1; serverData.models.turtle.records[2].turtle_int = 1; serverData.models.partner.records[0].turtles = [1, 2, 3]; var turtleOnchange = 0; serverData.models.turtle.onchanges = { turtle_int: function () { turtleOnchange++; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { assert.deepEqual( args.args[1].turtles, [ [1, 2, { turtle_int: 1 }], [1, 1, { turtle_int: 2 }], [1, 3, { turtle_int: 3 }], ], "should change all lines that have changed (the first one doesn't change because it has the same sequence)" ); } }, }); assert.deepEqual( getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), ["yop", "blip", "kawa"] ); // Drag and drop the second line in first position await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.deepEqual( getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), ["blip", "yop", "kawa"] ); assert.strictEqual(turtleOnchange, 3, "should update all lines"); await clickSave(target); } ); QUnit.test("onchange for embedded one2many with handle widget", async function (assert) { const ids = []; for (let i = 10; i < 50; i++) { const id = 10 + i; ids.push(id); serverData.models.turtle.records.push({ id: id, turtle_int: 0, turtle_foo: "#" + id, }); } ids.push(1, 2, 3); serverData.models.partner.records[0].turtles = ids; serverData.models.partner.onchanges = { turtles: function (obj) {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target, "div[name=turtles] .o_pager_next"); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "yop", "blip", "kawa", ]); await click(target.querySelector(".o_data_cell.o_list_char")); await editInput(target, '.o_list_renderer div[name="turtle_foo"] input', "blurp"); // Drag and drop the third line in second position await dragAndDrop("tbody tr:nth-child(3) .o_handle_cell", "tbody tr:nth-child(2)"); // need to unselect row... assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blurp", "kawa", "blip", ]); await clickSave(target); await click(target, 'div[name="turtles"] .o_pager_next'); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blurp", "kawa", "blip", ]); }); QUnit.test( "onchange with modifiers for embedded one2many on the second page", async function (assert) { const ids = []; for (let i = 10; i < 60; i++) { const id = 10 + i; ids.push(id); serverData.models.turtle.records.push({ id: id, turtle_int: 0, turtle_foo: "#" + id, }); } ids.push(1, 2, 3); serverData.models.partner.records[0].turtles = ids; serverData.models.partner.onchanges = { turtles: function (obj) {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); const getTurtleFooValues = () => { return getNodesTextContent( target.querySelectorAll(".o_data_cell.o_list_char") ).join(""); }; assert.strictEqual(getTurtleFooValues(), "#20#21#22#23#24#25#26#27#28#29"); await click(target.querySelector(".o_data_cell.o_list_char")); await editInput(target, "div[name=turtle_foo] input", "blurp"); // click outside of the one2many to unselect the row await click(target, ".o_form_view"); assert.strictEqual(getTurtleFooValues(), "blurp#21#22#23#24#25#26#27#28#29"); // the domain fail if the widget does not use the already loaded data. await clickDiscard(target); assert.containsNone(target, ".modal"); assert.strictEqual(getTurtleFooValues(), "#20#21#22#23#24#25#26#27#28#29"); // Drag and drop the third line in second position await dragAndDrop("tbody tr:nth-child(3) .o_handle_cell", "tbody tr:nth-child(2)"); assert.strictEqual(getTurtleFooValues(), "#20#30#31#32#33#34#35#36#37#38"); // Drag and drop the third line in second position await dragAndDrop("tbody tr:nth-child(3) .o_handle_cell", "tbody tr:nth-child(2)"); assert.strictEqual(getTurtleFooValues(), "#20#39#40#41#42#43#44#45#46#47"); await click(target, ".o_form_view"); assert.strictEqual(getTurtleFooValues(), "#20#39#40#41#42#43#44#45#46#47"); await clickDiscard(target); assert.containsNone(target, ".modal"); assert.strictEqual(getTurtleFooValues(), "#20#21#22#23#24#25#26#27#28#29"); } ); QUnit.test("onchange followed by edition on the second page", async function (assert) { const ids = []; for (let i = 1; i < 85; i++) { const id = 10 + i; ids.push(id); serverData.models.turtle.records.push({ id: id, turtle_int: (id / 3) | 0, turtle_foo: "#" + i, }); } ids.splice(41, 0, 1, 2, 3); serverData.models.partner.records[0].turtles = ids; serverData.models.partner.onchanges = { turtles: function (obj) {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target.querySelector(".o_field_widget[name=turtles] .o_pager_next")); await click( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody tr td.o_handle_cell" )[1] ); await editInput( target, '.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"] input', "value 1" ); await click( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody tr td.o_handle_cell" )[2] ); await editInput( target, '.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"] input', "value 2" ); assert.containsN(target, ".o_data_row", 40, "should display 40 records"); assert.strictEqual( target.querySelector(".o_field_one2many .o_list_renderer .o_data_cell.o_list_char") .innerText, "#39", "should display '#39' at the first line" ); await addRow(target); assert.containsN( target, ".o_data_row", 40, "should display 39 records and the create line" ); assert.hasClass( target.querySelector(".o_data_row"), "o_selected_row", "should display the create line in first position" ); assert.strictEqual( target.querySelector('.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"]') .innerText, "", "should be an empty input" ); assert.strictEqual( target.querySelectorAll( ".o_field_one2many .o_list_renderer .o_data_cell.o_list_char" )[1].innerText, "#39", "should display '#39' at the second line" ); await editInput(target, ".o_data_row input", "value 3"); assert.hasClass( target.querySelector(".o_data_row"), "o_selected_row", "should display the create line in first position" ); assert.strictEqual( target.querySelectorAll( ".o_field_one2many .o_list_renderer .o_data_cell.o_list_char" )[1].innerText, "#39", "should display '#39' at the second line after onchange" ); await addRow(target); assert.containsN( target, ".o_data_row", 40, "should display 39 records and the create line" ); assert.deepEqual( [ ...target.querySelectorAll( ".o_field_one2many .o_list_renderer .o_data_cell.o_list_char" ), ] .slice(0, 3) .map((el) => el.innerText), ["", "value 3", "#39"] ); }); QUnit.test("onchange followed by edition on the second page (part 2)", async function (assert) { const ids = []; for (let i = 1; i < 85; i++) { const id = 10 + i; ids.push(id); serverData.models.turtle.records.push({ id: id, turtle_int: (id / 3) | 0, turtle_foo: "#" + i, }); } ids.splice(41, 0, 1, 2, 3); serverData.models.partner.records[0].turtles = ids; serverData.models.partner.onchanges = { turtles: function (obj) {}, }; // bottom order await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target.querySelector(".o_field_widget[name=turtles] .o_pager_next")); await click( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody tr td.o_handle_cell" )[1] ); await editInput( target, '.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"] input', "value 1" ); await click( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody tr td.o_handle_cell" )[2] ); await editInput( target, '.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"] input', "value 2" ); assert.containsN(target, ".o_data_row", 40, "should display 40 records"); assert.strictEqual( target.querySelector( ".o_field_one2many .o_list_renderer tbody .o_data_cell.o_list_char" ).innerText, "#39", "should display '#39' at the first line" ); assert.strictEqual( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody .o_data_cell.o_list_char" )[39].innerText, "#77", "should display '#77' at the last line" ); await addRow(target); assert.containsN( target, ".o_data_row", 41, "should display 41 records and the create line" ); assert.strictEqual( target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody .o_data_cell.o_list_char" )[39].innerText, "#77", "should display '#77' at the penultimate line" ); assert.hasClass( target.querySelectorAll(".o_data_row")[40], "o_selected_row", "should display the create line in first position" ); await editInput( target, '.o_field_one2many .o_list_renderer tbody div[name="turtle_foo"] input', "value 3" ); await addRow(target); assert.containsN( target, ".o_data_row", 42, "should display 42 records and the create line" ); assert.deepEqual( [ ...target.querySelectorAll( ".o_field_one2many .o_list_renderer tbody .o_data_cell.o_list_char" ), ] .slice(39) .map((el) => el.innerText), ["#77", "value 3", ""] ); assert.hasClass( target.querySelectorAll(".o_data_row")[41], "o_selected_row", "should display the create line in first position" ); }); QUnit.test("onchange returning a commands 4 for an x2many", async function (assert) { serverData.models.partner.onchanges = { foo(obj) { obj.turtles = [ [4, 1], [4, 3], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_data_row"); // change the value of foo to trigger the onchange await editInput(target, ".o_field_widget[name=foo] input", "some value"); assert.containsN(target, ".o_data_row", 3); }); QUnit.test( "x2many fields inside x2manys are fetched after an onchange", async function (assert) { assert.expect(5); serverData.models.turtle.records[0].partner_ids = [1]; serverData.models.partner.onchanges = { foo: function (obj) { obj.turtles = [ [3, 2], [4, 1], [4, 2], [4, 3], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange") { assert.deepEqual(args.args[3], { // spec display_name: {}, foo: {}, turtles: { fields: { partner_ids: { fields: { display_name: {}, }, }, turtle_foo: {}, }, limit: 40, order: "", }, }); } }, resId: 1, }); assert.containsOnce( target, ".o_data_row", "there should be one record in the relation" ); assert.strictEqual( target .querySelector(".o_data_row .o_field_widget[name=partner_ids]") .textContent.replace(/\s/g, ""), "secondrecordaaa", "many2many_tags should be correctly displayed" ); // change the value of foo to trigger the onchange await editInput(target, ".o_field_widget[name=foo] input", "some value"); assert.containsN( target, ".o_data_row", 3, "there should be three records in the relation" ); assert.strictEqual( target .querySelector(".o_data_row .o_field_widget[name=partner_ids]") .textContent.trim(), "first record", "many2many_tags should be correctly displayed" ); } ); QUnit.test( "reference fields inside x2manys are fetched after an onchange", async function (assert) { assert.expect(4); serverData.models.turtle.records[1].turtle_ref = "product,41"; serverData.models.partner.onchanges = { foo: function (obj) { obj.turtles = [ [4, 1], [4, 3], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_data_row"); assert.deepEqual( [...target.querySelectorAll(".ref_field")].map((el) => el.textContent), ["xpad"] ); // change the value of foo to trigger the onchange await editInput(target, ".o_field_widget[name=foo] input", "some value"); assert.containsN(target, ".o_data_row", 3); assert.deepEqual( [...target.querySelectorAll(".ref_field")].map((el) => el.textContent), ["xpad", "", "xphone"] ); } ); QUnit.test("onchange on one2many containing x2many in form view", async function (assert) { serverData.models.partner.onchanges = { foo: function (obj) { obj.turtles = [[0, false, { turtle_foo: "new record" }]]; }, }; serverData.views = { "partner,false,list": '', "partner,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); assert.containsOnce( target, ".o_data_row", "the onchange should have created one record in the relation" ); // open the created o2m record in a form view, and add a m2m subrecord // in its relation await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".modal"); assert.containsNone(target, ".modal .o_data_row"); // add a many2many subrecord await addRow(target.querySelector(".modal")); assert.containsN(target, ".modal", 2, "should have opened a second dialog"); // select a many2many subrecord let secondDialog = target.querySelectorAll(".modal")[1]; await click(secondDialog.querySelector(".o_list_view .o_data_cell")); assert.containsOnce(target, ".modal"); assert.containsOnce(target, ".modal .o_data_row"); assert.containsNone( target, ".modal .o_x2m_control_panel .o_pager", "m2m pager should be hidden" ); // click on 'Save & Close' await click(target.querySelector(".modal-footer .btn-primary")); assert.containsNone(target, ".modal", "dialog should be closed"); // reopen o2m record, and another m2m subrecord in its relation, but // discard the changes await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".modal", "should have opened a dialog"); assert.containsOnce( target, ".modal .o_data_row", "there should be one record in the one2many in the dialog" ); // add another m2m subrecord await addRow(target, ".modal"); assert.containsN(target, ".modal", 2, "should have opened a second dialog"); secondDialog = target.querySelectorAll(".modal")[1]; await click(secondDialog.querySelector(".o_list_view .o_data_cell")); assert.containsOnce(target, ".modal", "second dialog should be closed"); assert.containsN( target, ".modal .o_data_row", 2, "there should be two records in the one2many in the dialog" ); // click on 'Discard' await click(target.querySelector(".modal-footer .btn-secondary")); assert.containsNone(target, ".modal", "dialog should be closed"); // reopen o2m record to check that second changes have properly been discarded await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".modal", "should have opened a dialog"); assert.containsOnce( target, ".modal .o_data_row", "there should be one record in the one2many in the dialog" ); }); QUnit.test( "onchange on one2many with x2many in list (no widget) and form view (list)", async function (assert) { assert.expect(7); serverData.models.turtle.fields.turtle_foo.default = "a default value"; serverData.models.partner.onchanges = { foo: function (obj) { obj.p = [[0, false, { turtles: [[0, false, { turtle_foo: "hello" }]] }]]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange" && args.model === "partner") { assert.deepEqual(args.args[3], { display_name: {}, foo: {}, p: { fields: { turtles: { fields: { turtle_foo: {}, }, }, }, limit: 40, order: "", }, }); } }, }); assert.containsOnce( target, ".o_data_row", "the onchange should have created one record in the relation" ); // open the created o2m record in a form view await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(document.body, ".modal", "should have opened a dialog"); assert.containsOnce(document.body, ".modal .o_data_row"); assert.strictEqual( document.querySelector(".modal .o_data_row").textContent.trim(), "hello" ); // add a one2many subrecord and check if the default value is correctly applied await addRow(target, ".modal"); assert.containsN(document.body, ".modal .o_data_row", 2); assert.strictEqual( document.querySelector(".modal .o_data_row .o_field_widget[name=turtle_foo] input") .value, "a default value" ); } ); QUnit.test("save an o2m dialog form view and discard main form view", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, resId: 1, arch: `
`, }); assert.containsOnce(target, ".o_data_row"); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "donatello" ); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal [name='display_name'] input").value, "donatello" ); await editInput(target, ".modal [name='display_name'] input", "leonardo"); await click(target.querySelector(".modal .o_form_button_save")); assert.containsNone(target, ".modal"); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "leonardo" ); await click(target.querySelector(".o_data_row .o_data_cell")); await click(target.querySelector(".modal .o_form_button_cancel")); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "leonardo" ); await clickDiscard(target); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "donatello" ); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal [name='display_name'] input").value, "donatello" ); }); QUnit.test("discard with nested o2m form view dialog", async function (assert) { serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].p = [4]; await makeView({ type: "form", resModel: "partner", serverData, resId: 1, arch: `
`, }); assert.containsOnce(target, ".o_data_row"); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "second record" ); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector("#dialog_0 [name='display_name'] input").value, "second record" ); await click(target.querySelector("#dialog_0 .o_data_row .o_data_cell")); assert.strictEqual( target.querySelector("#dialog_1 [name='display_name'] input").value, "aaa" ); await editInput(target, "#dialog_1 [name='display_name'] input", "leonardo"); await click(target.querySelector("#dialog_1 .o_form_button_save")); assert.containsNone(target, "#dialog_1"); assert.strictEqual( target.querySelector("#dialog_0 .o_data_row [name='display_name']").textContent, "leonardo" ); await click(target.querySelector("#dialog_0 .o_data_row .o_data_cell")); assert.strictEqual( target.querySelector("#dialog_2 [name='display_name'] input").value, "leonardo" ); await click(target.querySelector("#dialog_2 .o_form_button_cancel")); await click(target.querySelector("#dialog_0 .o_form_button_cancel")); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal .o_data_row [name='display_name']").textContent, "aaa" ); }); QUnit.test( "discard a form dialog view and then reopen it with a domain based on a text field", async function (assert) { serverData.models.turtle.records[1].turtle_foo = "yop"; serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, resId: 1, arch: `
`, }); assert.containsOnce(target, ".o_data_row"); assert.strictEqual( target.querySelector(".o_data_row [name='display_name']").textContent, "donatello" ); await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsNone(target, ".modal [name='display_name']"); assert.strictEqual( target.querySelector(".modal [name='turtle_foo'] input").value, "yop" ); await editInput(target, ".modal [name='turtle_foo'] input", "display"); assert.strictEqual( target.querySelector(".modal [name='display_name'] input").value, "donatello" ); assert.strictEqual( target.querySelector(".modal [name='turtle_foo'] input").value, "display" ); await click(target.querySelector(".modal .o_form_button_save")); await clickDiscard(target); await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsNone(target, ".modal [name='display_name']"); assert.strictEqual( target.querySelector(".modal [name='turtle_foo'] input").value, "yop" ); } ); QUnit.test( "onchange on one2many with x2many in list (many2many_tags) and form view (list)", async function (assert) { assert.expect(7); serverData.models.turtle.fields.turtle_foo.default = "a default value"; serverData.models.partner.onchanges = { foo: function (obj) { obj.p = [[0, false, { turtles: [[0, false, { turtle_foo: "hello" }]] }]]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange" && args.model === "partner") { assert.deepEqual(args.args[3], { display_name: {}, foo: {}, p: { fields: { turtles: { fields: { display_name: {}, turtle_foo: {}, }, }, }, limit: 40, order: "", }, }); } }, }); assert.containsOnce( target, ".o_data_row", "the onchange should have created one record in the relation" ); // open the created o2m record in a form view await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(document.body, ".modal", "should have opened a dialog"); assert.containsOnce(document.body, ".modal .o_data_row"); assert.strictEqual( document.querySelector(".modal .o_data_row").textContent.trim(), "hello" ); // add a one2many subrecord and check if the default value is correctly applied await addRow(target, ".modal"); assert.containsN(document.body, ".modal .o_data_row", 2); assert.strictEqual( document.querySelector(".modal .o_data_row .o_field_widget[name=turtle_foo] input") .value, "a default value" ); } ); QUnit.test( "embedded one2many with handle widget with minimum setValue calls", async function (assert) { serverData.models.turtle.records[0].turtle_int = 6; serverData.models.turtle.records.push( { id: 4, turtle_int: 20, turtle_foo: "a1", }, { id: 5, turtle_int: 9, turtle_foo: "a2", }, { id: 6, turtle_int: 2, turtle_foo: "a3", }, { id: 7, turtle_int: 11, turtle_foo: "a4", } ); serverData.models.partner.records[0].turtles = [1, 2, 3, 4, 5, 6, 7]; patchWithCleanup(Record.prototype, { _update() { if (this.resModel === "turtle") { assert.step(`${this.resId}`); } return super._update(...arguments); }, }); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.deepEqual( [...target.querySelectorAll(".o_data_row [name='turtle_foo']")].map( (el) => el.textContent ), ["a3", "yop", "blip", "a2", "a4", "a1", "kawa"] ); const positions = [ [7, 1, ["3", "6", "1", "2", "5", "7", "4"]], // move the last to the first line [6, 2, ["7", "6", "1", "2", "5"]], // move the penultimate to the second line [3, 6, ["1", "2", "5", "6"]], // move the third to the penultimate line ]; for (const [sourceIndex, targetIndex, steps] of positions) { await dragAndDrop( `tbody tr:nth-child(${sourceIndex}) .o_handle_cell`, `tbody tr:nth-child(${targetIndex})` ); assert.verifySteps(steps); } assert.deepEqual( [...target.querySelectorAll(".o_data_row [name='turtle_foo']")].map( (el) => el.textContent ), ["kawa", "a4", "yop", "blip", "a2", "a3", "a1"] ); } ); QUnit.test("embedded one2many (editable list) with handle widget", async function (assert) { serverData.models.partner.records[0].p = [1, 2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "web_save") { assert.step(args.method); assert.deepEqual(args.args[1].p, [ [1, 2, { int_field: 0 }], [1, 4, { int_field: 1 }], ]); } }, }); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "My little Foo Value", "blip", "yop", ]); assert.verifySteps([]); // Drag and drop the second line in first position await dragAndDrop( "tbody tr:nth-child(2) .o_handle_cell", ".o_field_one2many tbody tr:nth-child(1)" ); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blip", "My little Foo Value", "yop", ]); await click(target.querySelector(".o_data_cell.o_list_char")); assert.strictEqual(target.querySelector(".o_field_widget[name=foo] input").value, "blip"); assert.verifySteps([]); await clickSave(target); assert.verifySteps(["web_save"]); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blip", "My little Foo Value", "yop", ]); }); QUnit.test("one2many list order with handle widget", async (assert) => { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "web_read") { assert.step(`web_read`); assert.strictEqual(args.kwargs.specification.p.order, "int_field ASC, id ASC"); } }, }); assert.verifySteps(["web_read"]); }); QUnit.test("one2many kanban order with handle widget", async (assert) => { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "web_read") { assert.step(`web_read`); assert.strictEqual(args.kwargs.specification.p.order, "int_field ASC, id ASC"); } }, }); assert.verifySteps(["web_read"]); }); QUnit.test("one2many field when using the pager", async function (assert) { const ids = []; for (let i = 0; i < 45; i++) { const id = 10 + i; ids.push(id); serverData.models.partner.records.push({ id, display_name: `relational record ${id}`, }); } serverData.models.partner.records[0].p = ids.slice(0, 42); serverData.models.partner.records[1].p = ids.slice(42); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "web_read") { assert.step(`unity read ${args.args[0]}`); } }, resId: 1, resIds: [1, 2], }); assert.verifySteps(["unity read 1"]); assert.containsN(target, '.o_kanban_record:not(".o_kanban_ghost")', 40); // move to record 2, which has 3 related records (and shouldn't contain the // related records of record 1 anymore) await click(target.querySelector(".o_form_view .o_control_panel .o_pager_next")); assert.verifySteps(["unity read 2"]); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 3, "one2many kanban should contain 3 cards for record 2" ); // move back to record 1, which should contain again its first 40 related // records await click(target.querySelector(".o_form_view .o_control_panel .o_pager_previous")); assert.verifySteps(["unity read 1"]); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 40, "one2many kanban should contain 40 cards for record 1" ); // move to the second page of the o2m: 1 RPC should have been done to fetch // the 2 subrecords of page 2, and those records should now be displayed await click(target.querySelector(".o_x2m_control_panel .o_pager_next")); assert.verifySteps(["unity read 50,51"]); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 2, "one2many kanban should contain 2 cards for record 1 at page 2" ); // move to record 2 again and check that everything is correctly updated await click(target.querySelector(".o_form_view .o_control_panel .o_pager_next")); assert.verifySteps(["unity read 2"]); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 3, "one2many kanban should contain 3 cards for record 2" ); // move back to record 1 and move to page 2 again: all data should have // been correctly reloaded await click(target.querySelector(".o_form_view .o_control_panel .o_pager_previous")); assert.verifySteps(["unity read 1"]); await click(target.querySelector(".o_x2m_control_panel .o_pager_next")); assert.verifySteps(["unity read 50,51"]); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 2, "one2many kanban should contain 2 cards for record 1 at page 2" ); }); QUnit.test("edition of one2many field with pager", async function (assert) { const ids = []; for (let i = 0; i < 45; i++) { const id = 10 + i; ids.push(id); serverData.models.partner.records.push({ id: id, display_name: "relational record " + id, }); } serverData.models.partner.records[0].p = ids; serverData.views = { "partner,false,form": '
', }; let saveCount = 0; let checkRead = false; let readIDs; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "web_read" && checkRead) { readIDs = args.args[0]; checkRead = false; } if (args.method === "web_save") { assert.step("web_save"); saveCount++; const commands = args.args[1].p; switch (saveCount) { case 1: assert.deepEqual(commands, [ [0, commands[0][1], { display_name: "new record" }], ]); break; case 2: assert.deepEqual(commands, [[2, 10]]); break; case 3: assert.deepEqual(commands, [ [0, commands[0][1], { display_name: "new record page 1" }], [2, 11], [2, 52], [0, commands[3][1], { display_name: "new record page 2" }], ]); break; } } }, resId: 1, }); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 40, "there should be 40 records on page 1" ); assert.strictEqual( target.querySelector(".o_x2m_control_panel .o_pager_counter").innerText, "1-40 / 45", "pager range should be correct" ); // add a record on page one checkRead = true; await click(target.querySelector(".o-kanban-button-new")); await editInput(target, ".modal input", "new record"); await click(target.querySelector(".modal .modal-footer .btn-primary")); // checks assert.strictEqual(readIDs, undefined, "should not have read any record"); assert.notOk( [...target.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")].some( (el) => el.innerText === "new record" ) ); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 40, "there should be 40 records on page 1" ); assert.strictEqual( target.querySelector(".o_x2m_control_panel .o_pager_counter").innerText, "1-40 / 46", "pager range should be correct" ); // save await clickSave(target); // delete a record on page one checkRead = true; assert.strictEqual( target.querySelector(".o_kanban_record:not(.o_kanban_ghost)").innerText, "relational record 10" ); await click(target.querySelector(".delete_icon")); // should remove record!!! // checks assert.deepEqual( readIDs, [50], "should have read a record (to display 40 records on page 1)" ); assert.containsN( target, '.o_kanban_record:not(".o_kanban_ghost")', 40, "there should be 40 records on page 1" ); assert.strictEqual( target.querySelector(".o_x2m_control_panel .o_pager_counter").innerText, "1-40 / 45", "pager range should be correct" ); // save await clickSave(target); // add and delete records in both pages checkRead = true; readIDs = undefined; // add and delete a record in page 1 await click(target.querySelector(".o-kanban-button-new")); await editInput(target, ".modal input", "new record page 1"); await click(target.querySelector(".modal .modal-footer .btn-primary")); assert.strictEqual( target.querySelector(".o_kanban_record:not(.o_kanban_ghost)").innerText, "relational record 11", "first record should be the one with id 11 (next checks rely on that)" ); await click(target.querySelector(".delete_icon")); // should remove record!!! assert.deepEqual( readIDs, [51], "should have read a record (to display 40 records on page 1)" ); // add and delete a record in page 2 await click(target.querySelector(".o_x2m_control_panel .o_pager_next")); assert.strictEqual( target.querySelector(".o_kanban_record:not(.o_kanban_ghost)").innerText, "relational record 52", "first record should be the one with id 52 (next checks rely on that)" ); checkRead = true; readIDs = undefined; await click(target.querySelector(".delete_icon")); // should remove record!!! await click(target.querySelector(".o-kanban-button-new")); await editInput(target, ".modal input", "new record page 2"); await click(target.querySelector(".modal .modal-footer .btn-primary")); assert.strictEqual(readIDs, undefined, "should not have read any record"); // checks assert.containsN( target, ".o_kanban_record:not(.o_kanban_ghost)", 5, "there should be 5 records on page 2" ); assert.strictEqual( target.querySelector(".o_x2m_control_panel .o_pager_counter").innerText, "41-45 / 45", "pager range should be correct" ); assert.ok( [...target.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")].some( (el) => el.innerText === "new record page 1" ), "new records should be on page 2" ); assert.ok( [...target.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")].some( (el) => el.innerText === "new record page 2" ), "new records should be on page 2" ); // save await clickSave(target); assert.verifySteps(["web_save", "web_save", "web_save"]); }); QUnit.test( "When viewing one2many records in an embedded kanban, the delete button should say 'Delete' and not 'Remove'", async function (assert) { assert.expect(1); serverData.views = { "turtle,false,form": `

Data

`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `

Record 1

`, resId: 1, }); // Opening the record to see the footer buttons await click(target.querySelector(".o_kanban_record")); assert.strictEqual(target.querySelector(".o_btn_remove").textContent, "Delete"); } ); QUnit.test("open a record in a one2many kanban (mode 'readonly')", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.strictEqual(target.querySelector(".o_kanban_record").innerText, "donatello"); await click(target.querySelector(".o_kanban_record")); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] span").innerText, "donatello" ); }); QUnit.test("open a record in a one2many kanban (mode 'edit')", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.strictEqual(target.querySelector(".o_kanban_record ").innerText, "donatello"); await click(target.querySelector(".o_kanban_record")); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] input").value, "donatello" ); }); QUnit.test("open a record in an one2many readonly", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] span").textContent, "donatello" ); await click(target, ".modal .o_form_button_cancel"); await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] span").textContent, "donatello" ); }); QUnit.test( "open a record in a one2many kanban with an x2m in the form", async function (assert) { serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].p = [4]; serverData.views = { "partner,false,form": `
`, }; const def = makeDeferred(); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, async mockRPC(route, args) { if (args.method === "web_read" && args.args[0][0] === 2) { assert.step("web_read: 2"); await def; } }, }); await click(target.querySelector(".o_kanban_record")); def.resolve(); await nextTick(); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal [name=display_name] input").value, "second record" ); assert.deepEqual( [...target.querySelectorAll(".modal .o_data_row")].map((el) => el.textContent), ["aaa"] ); assert.verifySteps(["web_read: 2"]); } ); QUnit.test( "one2many in kanban: add a line custom control create editable", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); const createButtons = target.querySelectorAll( ".o_x2m_control_panel .o_cp_buttons button" ); assert.deepEqual( [...createButtons].map((el) => el.textContent), ["Add food", "Add pizza", "Add pasta"] ); await click(createButtons[0]); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] input").value, "" ); await click(target, ".modal .o_form_button_cancel"); await click(createButtons[1]); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] input").value, "pizza" ); await click(target, ".modal .o_form_button_cancel"); await click(createButtons[2]); assert.containsOnce(target, ".modal"); assert.strictEqual( target.querySelector(".modal div[name=display_name] input").value, "pasta" ); } ); QUnit.test( "one2many in kanban: add a line custom control create editable", async function (assert) { serverData.views = { "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `