/** @odoo-module **/ import { addRow, click, clickCreate, clickDiscard, clickSave, clickM2OHighlightedItem, clickOpenedDropdownItem, clickOpenM2ODropdown, dragAndDrop, editInput, getFixture, getNodesTextContent, makeDeferred, nextTick, patchWithCleanup, removeRow, selectDropdownItem, triggerEvent, triggerEvents, triggerHotkey, } from "@web/../tests/helpers/utils"; import BasicModel from "web.BasicModel"; import { browser } from "@web/core/browser/browser"; import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; import { getNextTabableElement } from "@web/core/utils/ui"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { registerCleanup } from "@web/../tests/helpers/cleanup"; import { registry } from "@web/core/registry"; import { session } from "@web/session"; import { X2ManyField } from "@web/views/fields/x2many/x2many_field"; import { useOpenX2ManyRecord, useX2ManyCrud } from "@web/views/fields/relational_utils"; 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 = [[5], [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 name_get // of the lines of "p" await dragAndDrop("tbody tr:nth-child(2) .o_handle_cell", "tbody tr", "top"); assert.verifySteps(["onchange partner"]); } ); 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(4); // 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", []]]); } }, }); 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, ".modal:not(.o_inactive_modal) .modal-footer .o_form_button_save"); await click(target, ".modal: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( "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, kwargs }) { if (method === "read" && model === "turtle") { assert.step("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(["read turtle", "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: [ [6, false, [2, 4]], [1, 2, { int_field: 0 }], [1, 4, { int_field: 1 }], ], }, ]); } }, }); assert.verifySteps(["get_views", "read", "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(["read", "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(["write", "read", "read"]); }); 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/write") { assert.deepEqual( args.args[1].p[1][2], { foo: "ff", qux: 99 }, "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 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", "read", "read"]); // assert.containsNone(target, "td.o_list_record_selector"); // assert.containsNone(target, ".o_field_x2many_list_row_add"); // assert.containsNone(target, "td.o_list_record_remove"); // await clickEdit(target); 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 = new 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( "use the limit attribute in arch (in field o2m non inline tree view)", async function (assert) { assert.expect(2); 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) { if (args.model === "turtle" && args.method === "read") { assert.deepEqual(args.args[0], [1, 2]); } }, }); assert.containsN(target, ".o_data_row", 2); } ); 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) { 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 = [ [5, false, false], [ 1, 1, { turtle_foo: "hop", partner_ids: [ [5, false, false], [4, 1, false], ], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { const expectedResultTurtles = [[1, 1, { turtle_foo: "hop" }]]; 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 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 = [ [5, false, false], [ 1, 1, { turtle_foo: "hop", partner_ids: [ [5, false, false], [4, 1, false], ], }, ], [ 1, 2, { turtle_foo: "blip", partner_ids: [ [5, false, false], [4, 2, false], [4, 4, false], ], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { const expectedResultTurtles = [ [1, 1, { turtle_foo: "hop" }], [ 1, 2, { partner_ids: [ [4, 2, false], [4, 4, false], ], 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 = [ [5], [ 1, 2, { turtle_foo: "hop", partner_ids: [[5], [4, 2], [4, 4]], }, ], ]; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/write") { var expectedResultTurtles = [ [ 1, 2, { partner_ids: [ [4, 2, false], [4, 4, false], ], 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 (with command 5) 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) { obj.turtles = [[5]].concat(obj.turtles); }, }; 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) { // TODO: make this test more 'difficult' // For now, the server only returns UPDATE commands (no LINK TO) // even though it should do it (for performance reasons) // var turtles = obj.turtles.splice(0, 20); const turtles = [[5]]; // create UPDATE commands for each records (this is the server // usual answer for onchange) for (const k in obj.turtles) { const change = obj.turtles[k]; const record = serverData.models.turtle.records.find( (r) => r.id === change[1] ); if (change[0] === 1) { Object.assign(record, change[2]); } turtles.push([1, record.id, record]); } obj.turtles = turtles; }, }; 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) { obj.turtles = [[5]].concat(obj.turtles); }, }; 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) { obj.turtles = [[5]].concat(obj.turtles); }, }; // 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 command 6 for an x2many", async function (assert) { serverData.models.partner.onchanges = { foo(obj) { obj.turtles = [[6, false, [1, 2, 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(6); serverData.models.turtle.records[0].partner_ids = [1]; serverData.models.partner.onchanges = { foo: function (obj) { obj.turtles = [[5], [4, 1], [4, 2], [4, 3]]; }, }; let checkRPC = false; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (checkRPC && args.method === "read" && args.model === "partner") { assert.deepEqual( args.args[1], ["display_name"], "should only read the display_name for the m2m tags" ); assert.deepEqual( args.args[0], [1], "should only read the display_name of the unknown record" ); } }, 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 checkRPC = true; // enable flag to check read RPC for the m2m field 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(5); serverData.models.turtle.records[1].turtle_ref = "product,41"; serverData.models.partner.onchanges = { foo: function (obj) { obj.turtles = [[5], [4, 1], [4, 2], [4, 3]]; }, }; var checkRPC = false; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (checkRPC && args.method === "name_get") { assert.deepEqual( args.args[0], [37], "should only fetch the name_get of the unknown record" ); } }, 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 checkRPC = true; // enable flag to check read RPC for reference field 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) { 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: `
`, }); 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( "onchange on one2many with x2many in list (many2many_tags) and form view (list)", async function (assert) { 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: `
`, }); 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]; let model; patchWithCleanup(BasicModel.prototype, { init() { this._super(...arguments); model = this; }, notifyChanges() { const changes = arguments[1]; assert.step(String(this.get(changes.turtles.id).res_id)); return this._super(...arguments); }, }); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); const formHandle = Object.keys(model.localData).find((k) => /partner/.test(k)); assert.deepEqual( Object.values(model.get(formHandle).data.turtles.data).map((r) => { return r.data; }), [ { id: 6, turtle_foo: "a3", turtle_int: 2 }, { id: 1, turtle_foo: "yop", turtle_int: 6 }, { id: 2, turtle_foo: "blip", turtle_int: 9 }, { id: 5, turtle_foo: "a2", turtle_int: 9 }, { id: 7, turtle_foo: "a4", turtle_int: 11 }, { id: 4, turtle_foo: "a1", turtle_int: 20 }, { id: 3, turtle_foo: "kawa", turtle_int: 21 }, ] ); 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( Object.values(model.get(formHandle).data.turtles.data).map((r) => { return r.data; }), [ { id: 3, turtle_foo: "kawa", turtle_int: 2 }, { id: 7, turtle_foo: "a4", turtle_int: 3 }, { id: 1, turtle_foo: "yop", turtle_int: 4 }, { id: 2, turtle_foo: "blip", turtle_int: 5 }, { id: 5, turtle_foo: "a2", turtle_int: 6 }, { id: 6, turtle_foo: "a3", turtle_int: 7 }, { id: 4, turtle_foo: "a1", turtle_int: 8 }, ] ); } ); 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 === "write") { assert.step(args.method); assert.deepEqual(args.args[1].p, [ [1, 2, { int_field: 0 }], [1, 4, { int_field: 1 }], [4, 1, false], ]); } }, }); 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(["write"]); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell.o_list_char")), [ "blip", "My little Foo Value", "yop", ]); }); 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); let count = 0; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method !== "get_views") { count++; } }, resId: 1, resIds: [1, 2], }); // we are on record 1, which has 90 related record (first 40 should be // displayed), 2 RPCs (read) should have been done, one on the main record // and one for the O2M assert.strictEqual(count, 2); 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). Two additional RPCs should have // been done await click(target.querySelector(".o_form_view .o_control_panel .o_pager_next")); assert.strictEqual(count, 4); 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.strictEqual(count, 6); 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.strictEqual(count, 7, "one RPC should have been done"); 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.strictEqual(count, 9); 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.strictEqual(count, 11); await click(target.querySelector(".o_x2m_control_panel .o_pager_next")); assert.strictEqual(count, 12, "one RPC should have been done"); 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) { assert.expect(30); 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 === "read" && checkRead) { readIDs = args.args[0]; checkRead = false; } if (args.method === "write") { saveCount++; const nbCommands = args.args[1].p.length; const nbLinkCommands = _.filter(args.args[1].p, function (command) { return command[0] === 4; }).length; switch (saveCount) { case 1: assert.strictEqual( nbCommands, 46, "should send 46 commands (one for each record)" ); assert.strictEqual( nbLinkCommands, 45, "should send a LINK_TO command for each existing record" ); assert.deepEqual( args.args[1].p[45], [ 0, args.args[1].p[45][1], { display_name: "new record", }, ], "should sent a CREATE command for the new record" ); break; case 2: assert.strictEqual(nbCommands, 46, "should send 46 commands"); assert.strictEqual( nbLinkCommands, 45, "should send a LINK_TO command for each existing record" ); assert.deepEqual( args.args[1].p[45], [2, 10, false], "should sent a DELETE command for the deleted record" ); break; case 3: assert.strictEqual(nbCommands, 47, "should send 47 commands"); assert.strictEqual( nbLinkCommands, 43, "should send a LINK_TO command for each existing record" ); assert.deepEqual( args.args[1].p[43], [0, args.args[1].p[43][1], { display_name: "new record page 1" }], "should sent correct CREATE command" ); assert.deepEqual( args.args[1].p[44], [0, args.args[1].p[44][1], { display_name: "new record page 2" }], "should sent correct CREATE command" ); assert.deepEqual( args.args[1].p[45], [2, 11, false], "should sent correct DELETE command" ); assert.deepEqual( args.args[1].p[46], [2, 52, false], "should sent correct DELETE command" ); 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); }); 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 a one2many kanban (mode 'edit') without access rights", 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"); // There should be no input since it is readonly assert.containsNone(target, ".modal div[name=display_name] input"); assert.strictEqual( target.querySelector(".modal div[name=display_name] span").textContent, "donatello" ); } ); QUnit.test("add record in a one2many non editable list with context", async function (assert) { assert.expect(1); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange" && args.model === "turtle") { // done by the X2ManyFieldDialog assert.deepEqual(args.kwargs.context, { abc: 2, lang: "en", tz: "taht", uid: 7, }); } }, }); await editInput(target, ".o_field_widget[name=int_field] input", "2"); await click(target.querySelector(".o_field_x2many_list_row_add a")); }); QUnit.test( "edition of one2many field, with onchange and not inline sub view", async function (assert) { serverData.models.turtle.onchanges.turtle_int = function (obj) { obj.turtle_foo = String(obj.turtle_int); }; serverData.models.partner.onchanges.turtles = function () {}; serverData.views = { "turtle,false,list": ` `, "turtle,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target); await editInput(target, 'div[name="turtle_int"] input', "5"); await click(target.querySelector(".modal-footer button.btn-primary")); let firstCellOfSecondRow = target.querySelectorAll(".o_data_cell.o_list_char")[1]; assert.strictEqual(firstCellOfSecondRow.innerText, "5"); await click(firstCellOfSecondRow); await editInput(target, 'div[name="turtle_int"] input', "3"); await click(target.querySelector(".modal-footer button.btn-primary")); firstCellOfSecondRow = target.querySelectorAll(".o_data_cell.o_list_char")[1]; assert.strictEqual(firstCellOfSecondRow.innerText, "3"); } ); QUnit.test("sorting one2many fields", async function (assert) { serverData.models.partner.fields.foo.sortable = true; serverData.models.partner.records.push({ id: 23, foo: "abc" }); serverData.models.partner.records.push({ id: 24, foo: "xyz" }); serverData.models.partner.records.push({ id: 25, foo: "def" }); serverData.models.partner.records[0].p = [23, 24, 25]; let rpcCount = 0; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC() { rpcCount++; }, }); rpcCount = 0; assert.strictEqual( [...target.querySelectorAll(".o_data_cell")].map((c) => c.textContent).join(" "), "abc xyz def" ); await click(target.querySelector("table thead .o_column_sortable")); assert.strictEqual(rpcCount, 0, "in memory sort, no RPC should have been done"); assert.strictEqual( [...target.querySelectorAll(".o_data_cell")].map((c) => c.textContent).join(" "), "abc def xyz" ); await click(target.querySelector("table thead .o_column_sortable")); assert.strictEqual( [...target.querySelectorAll(".o_data_cell")].map((c) => c.textContent).join(" "), "xyz def abc" ); }); QUnit.test("one2many list field edition", async function (assert) { serverData.models.partner.records.push({ id: 3, display_name: "relational record 1", }); serverData.models.partner.records[1].p = [3]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, }); assert.strictEqual( target.querySelector(".o_field_one2many tbody td").textContent, "relational record 1" ); await click(target.querySelector(".o_field_one2many tbody td")); assert.hasClass( target.querySelector(".o_field_one2many tbody .o_data_row"), "o_selected_row" ); await editInput(target, ".o_field_one2many tbody td input", "new value"); assert.hasClass( target.querySelector(".o_field_one2many tbody .o_data_row"), "o_selected_row" ); assert.strictEqual( target.querySelector(".o_field_one2many tbody td input").value, "new value" ); // leave o2m edition await click(target.querySelector(".o_form_view")); assert.doesNotHaveClass( target.querySelector(".o_field_one2many tbody .o_data_row"), "o_selected_row" ); // discard changes await clickDiscard(target); assert.containsNone(target, ".modal"); assert.strictEqual( target.querySelector(".o_field_one2many tbody td").textContent, "relational record 1" ); // edit again and save await click(target.querySelector(".o_field_one2many tbody td")); await editInput(target, ".o_field_one2many tbody td input", "new value"); await click(target.querySelector(".o_form_view")); await clickSave(target); assert.strictEqual( target.querySelector(".o_field_one2many tbody td").textContent, "new value", "display name of first record in o2m list should be 'new value'" ); }); QUnit.test("one2many list: create action disabled", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsNone(target, ".o_field_x2many_list_row_add"); }); QUnit.test("one2many list: conditional create/delete actions", async function (assert) { serverData.models.partner.records[0].p = [2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // bar is true -> create and delete action are available assert.containsOnce(target, ".o_field_x2many_list_row_add"); assert.containsN(target, "td.o_list_record_remove button", 2); // set bar to false -> create and delete action are no longer available await click(target, '.o_field_widget[name="bar"] input'); assert.containsNone(target, ".o_field_x2many_list_row_add"); assert.containsNone(target, "td.o_list_record_remove button"); }); QUnit.test("many2many list: unlink two records", async function (assert) { assert.expect(7); serverData.models.partner.records[0].p = [1, 2, 4]; serverData.views = { "partner,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/write") { const commands = args.args[1].p; assert.strictEqual(commands.length, 3, "should have generated three commands"); assert.ok( commands[0][0] === 4 && commands[0][1] === 2, "should have generated the command 4 (LINK_TO) with id 4" ); assert.ok( commands[1][0] === 4 && commands[1][1] === 4, "should have generated the command 4 (LINK_TO) with id 4" ); assert.ok( commands[2][0] === 3 && commands[2][1] === 1, "should have generated the command 3 (UNLINK) with id 1" ); } }, }); assert.containsN(target, "td.o_list_record_remove button", 3); await click(target.querySelector("td.o_list_record_remove button")); assert.containsN(target, "td.o_list_record_remove button", 2); await click(target.querySelector("tr.o_data_row td")); assert.containsNone(target, ".modal .modal-footer .o_btn_remove"); await click(target.querySelector(".modal .btn-secondary")); await clickSave(target); }); QUnit.test("one2many list: deleting one records", async function (assert) { assert.expect(3); serverData.models.partner.records[0].p = [1, 2, 4]; serverData.views = { "partner,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/write") { const commands = args.args[1].p; assert.deepEqual(commands, [ [4, 2, false], [4, 4, false], [2, 1, false], ]); } }, }); assert.containsN(target, "td.o_list_record_remove button", 3); await click(target.querySelector("td.o_list_record_remove button")); assert.containsN(target, "td.o_list_record_remove button", 2); // save and check that the correct command has been generated await clickSave(target); // FIXME: it would be nice to test that the view is re-rendered correctly, // but as the relational data isn't re-fetched, the rendering is ok even // if the changes haven't been saved }); QUnit.test("one2many list: double click on delete record", async function (assert) { // This test simulates a precise scenario: a one2many contains a record, and the user // clicks on the trash icon to remove it. It clicks again, precisely when the model has // been updated (so the record no longer exists there), but before the x2many field is // re-rendered (so the icon is still present). serverData.models.partner.records[0].p = [1]; let clickOnDeleteBeforeRender = false; patchWithCleanup(X2ManyField.prototype, { setup() { this._super.apply(this, arguments); owl.onWillRender(() => { if (clickOnDeleteBeforeRender) { assert.step("click a second time"); click(target.querySelector("td.o_list_record_remove")); } }); }, }); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_data_row"); click(target.querySelector("td.o_list_record_remove")); clickOnDeleteBeforeRender = true; await nextTick(); await nextTick(); assert.verifySteps(["click a second time"]); assert.containsNone(target, ".o_data_row"); }); QUnit.test("one2many kanban: edition", async function (assert) { assert.expect(20); serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, // color will be in the kanban but not in the form // foo will be in the form but not in the kanban arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/write") { const commands = args.args[1].p; assert.strictEqual(commands.length, 2); assert.strictEqual(commands[0][0], 0); assert.deepEqual(commands[0][2], { color: "red", display_name: "new subrecord 3", foo: "My little Foo Value", }); assert.deepEqual(commands[1], [2, 2, false]); } }, }); assert.containsOnce(target, ".o_kanban_record:not(.o_kanban_ghost)"); assert.strictEqual( target.querySelector(".o_kanban_record span").textContent, "second record" ); assert.strictEqual(target.querySelectorAll(".o_kanban_record span")[1].textContent, "Red"); assert.containsOnce(target, ".delete_icon"); assert.containsOnce(target, ".o_field_one2many .o-kanban-button-new"); assert.hasClass( target.querySelector(".o_field_one2many .o-kanban-button-new"), "btn-secondary" ); assert.strictEqual( target.querySelector(".o_field_one2many .o-kanban-button-new").textContent, "Add" ); // edit existing subrecord await click($(target).find(".oe_kanban_global_click")[0]); await editInput( target, ".modal .o_form_view .o_field_widget:nth-child(1) input", "new name" ); await click($(".modal .modal-footer .btn-primary")[0]); assert.strictEqual( $(target).find(".o_kanban_record span:first").text(), "new name", "value of subrecord should have been updated" ); // create a new subrecord await click($(target).find(".o-kanban-button-new")[0]); await editInput( target, ".modal .o_form_view .o_field_widget:nth-child(1) input", "new subrecord 1" ); await click($(target).find(".modal .modal-footer .btn-primary")[0]); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 2, "should contain 2 records" ); assert.strictEqual( $(target).find(".o_kanban_record:nth(1) span").text(), "new subrecord 1Red", 'value of newly created subrecord should be "new subrecord 1"' ); // create two new subrecords await click($(target).find(".o-kanban-button-new")[0]); await editInput( target, ".modal .o_form_view .o_field_widget:nth-child(1) input", "new subrecord 2" ); await click($(".modal .modal-footer .btn-primary:nth(1)")[0]); await editInput( target, ".modal .o_form_view .o_field_widget:nth-child(1) input", "new subrecord 3" ); await click($(target).find(".modal .modal-footer .btn-primary")[0]); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 4, "should contain 4 records" ); // delete subrecords await click($(target).find(".oe_kanban_global_click").first()[0]); assert.strictEqual( $(".modal .modal-footer .o_btn_remove").length, 1, "There should be a modal having Remove Button" ); await click($(".modal .modal-footer .o_btn_remove")[0]); assert.containsNone($(".o_modal"), "modal should have been closed"); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 3, "should contain 3 records" ); await click($(target).find(".o_kanban_renderer .delete_icon:first()")[0]); await click($(target).find(".o_kanban_renderer .delete_icon:first()")[0]); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 1, "should contain 1 records" ); assert.strictEqual( $(target).find(".o_kanban_record span:first").text(), "new subrecord 3", 'the remaining subrecord should be "new subrecord 3"' ); // save and check that the correct command has been generated await clickSave(target); }); QUnit.test( "one2many kanban (editable): properly handle add-label node attribute", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.deepEqual( [ ...target.querySelectorAll( '.o_field_one2many[name="turtles"] .o-kanban-button-new' ), ].map((el) => el.textContent), ["Add turtle"], "In O2M Kanban, Add button should have 'Add turtle' label" ); } ); QUnit.test("one2many kanban: create action disabled", async function (assert) { serverData.models.partner.records[0].p = [4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsNone(target, ".o-kanban-button-new"); assert.containsOnce(target, ".o_field_x2many_kanban .delete_icon"); }); QUnit.test("one2many kanban: conditional create/delete actions", async function (assert) { serverData.models.partner.records[0].p = [2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // bar is initially true -> create and delete actions are available assert.containsOnce(target, ".o-kanban-button-new", '"Add" button should be available'); await click(target.querySelector(".oe_kanban_global_click")); assert.containsOnce( target, ".modal .modal-footer .o_btn_remove", "There should be a Remove Button inside modal" ); await clickDiscard(target.querySelector(".modal")); // set bar false -> create and delete actions are no longer available await click(target.querySelector('.o_field_widget[name="bar"] input')); assert.containsNone( target, ".o-kanban-button-new", '"Add" button should not be available as bar is False' ); await click(target.querySelector(".oe_kanban_global_click")); assert.containsNone( target, ".modal .modal-footer .o_btn_remove", "There should not be a Remove Button as bar field is False" ); }); QUnit.test("editable one2many list, pager is updated", async function (assert) { serverData.models.turtle.records.push({ id: 4, turtle_foo: "stephen hawking" }); serverData.models.partner.records[0].turtles = [1, 2, 3, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add a record, add value to turtle_foo then click in form view to confirm it await addRow(target); await editInput(target, 'div[name="turtle_foo"] input', "nora"); await click(target); assert.strictEqual( target.querySelector(".o_field_widget[name=turtles] .o_pager").textContent.trim(), "1-4 / 5" ); }); QUnit.test("one2many list (non editable): edition", async function (assert) { assert.expect(10); let nbWrite = 0; serverData.models.partner.records[0].p = [2, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC: function (route, args) { if (args.method === "write") { nbWrite++; assert.deepEqual(args.args[1], { p: [ [1, 2, { display_name: "new name" }], [2, 4, false], ], }); } }, }); assert.containsN(target, "td.o_list_number", 2); assert.strictEqual( target.querySelector(".o_list_renderer tbody td").textContent, "second record" ); assert.containsN(target, ".o_list_record_remove", 2); assert.containsOnce(target, ".o_field_x2many_list_row_add"); // edit existing subrecord await click(target.querySelectorAll(".o_list_renderer tbody tr td")[1]); // ? await editInput(target, ".modal .o_form_editable input", "new name"); await click(target, ".modal .modal-footer .btn-primary"); assert.strictEqual( target.querySelector(".o_list_renderer tbody td").textContent, "new name" ); assert.strictEqual(nbWrite, 0, "should not have write anything in DB"); // remove subrecords await click(target.querySelectorAll(".o_list_record_remove")[1]); assert.containsOnce(target, "td.o_list_number"); assert.strictEqual( target.querySelector(".o_list_renderer tbody td").textContent, "new name" ); await clickSave(target); // save the record assert.strictEqual(nbWrite, 1, "should have write the changes in DB"); }); QUnit.test("one2many list (editable): edition, part 2", async function (assert) { assert.expect(11); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { // WOWL: could be nice to assert this way, but with the basic model, we don't // control the virtual ids index // assert.deepEqual(args.args[1].p, [ // [0, "virtual_2", { foo: "gemuse" }], // [0, "virtual_1", { foo: "kartoffel" }], // ]); assert.strictEqual(args.args[1].p[0][0], 0); assert.strictEqual(args.args[1].p[1][0], 0); assert.deepEqual(args.args[1].p[0][2], { foo: "gemuse" }); assert.deepEqual(args.args[1].p[1][2], { foo: "kartoffel" }); } }, }); // edit mode, then click on Add an item and enter a value await addRow(target); await editInput(target, ".o_selected_row > td input", "kartoffel"); assert.strictEqual(target.querySelector("td .o_field_char input").value, "kartoffel"); // click again on Add an item await addRow(target); assert.hasClass(target.querySelector(".o_data_row"), "o_selected_row"); assert.strictEqual(target.querySelectorAll(".o_data_cell")[1].textContent, "kartoffel"); assert.containsOnce(target, ".o_selected_row > td input"); assert.containsN(target, "tr.o_data_row", 2); // enter another value and save await editInput(target, ".o_selected_row > td input", "gemuse"); await clickSave(target); assert.containsN(target, "tr.o_data_row", 2); assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell")), [ "gemuse", "kartoffel", ]); }); QUnit.test("one2many list (editable): edition, part 3", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // edit mode, then click on Add an item, enter value in turtle_foo and Add an item again assert.containsOnce(target, "tr.o_data_row"); await addRow(target); await editInput(target, 'div[name="turtle_foo"] input', "nora"); await addRow(target); assert.containsN(target, "tr.o_data_row", 3); // cancel the edition await clickDiscard(target); assert.containsNone(target, ".modal"); assert.containsOnce(target, "tr.o_data_row"); }); QUnit.test("one2many list (editable): edition, part 4", async function (assert) { patchWithCleanup(browser, { setTimeout: (fn) => fn() }); let i = 0; serverData.models.turtle.onchanges = { turtle_trululu: function (obj) { if (i) { obj.turtle_description = "Some Description"; } i++; }, }; serverData["partner,false,"]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, }); // edit mode, then click on Add an item assert.containsNone(target, "tr.o_data_row"); await addRow(target); assert.strictEqual(target.querySelector(".o_data_row textarea").value, ""); // add a value in the turtle_trululu field to trigger an onchange await clickOpenM2ODropdown(target, "turtle_trululu"); await clickM2OHighlightedItem(target, "turtle_trululu"); assert.strictEqual(target.querySelector(".o_data_row textarea").value, "Some Description"); }); QUnit.test("one2many list (editable): edition, part 5", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // edit mode, then click on Add an item, enter value in turtle_foo and Add an item again assert.containsOnce(target, "tr.o_data_row"); assert.strictEqual(target.querySelector(".o_data_cell").innerText, "blip"); await addRow(target); await editInput(target, ".o_field_widget[name=turtle_foo] input", "aaa"); assert.containsN(target, "tr.o_data_row", 2); await removeRow(target, 1); assert.containsOnce(target, "tr.o_data_row"); // cancel the edition await clickDiscard(target); assert.containsOnce(target, "tr.o_data_row"); assert.strictEqual(target.querySelector(".o_data_cell").innerText, "blip"); }); QUnit.test("one2many list (editable): discarding required empty data", async function (assert) { serverData.models.turtle.fields.turtle_foo.required = true; delete serverData.models.turtle.fields.turtle_foo.default; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { assert.step(args.method); }, }); // edit mode, then click on Add an item, then click elsewhere assert.containsNone(target, "tr.o_data_row"); await addRow(target); await click(target); assert.containsNone(target, "tr.o_data_row"); // click on Add an item again, then click on save await addRow(target); await clickSave(target); assert.containsNone(target, "tr.o_data_row"); assert.verifySteps(["get_views", "read", "onchange", "onchange"]); }); QUnit.test("editable one2many list, adding line when only one page", async function (assert) { serverData.models.partner.records[0].turtles = [1, 2, 3]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add a record, to reach the page size limit await addRow(target); // the record currently being added should not count in the pager assert.containsNone(target, ".o_field_widget[name=turtles] .o_pager"); // enter value in turtle_foo field and click outside to unselect the row await editInput(target, '.o_field_widget[name="turtle_foo"] input', "nora"); await click(target); assert.containsNone(target, ".o_selected_row"); assert.containsNone(target, ".o_field_widget[name=turtles] .o_pager"); await clickSave(target); assert.containsOnce(target, ".o_field_widget[name=turtles] .o_pager"); assert.strictEqual( target.querySelector(".o_field_widget[name=turtles] .o_pager").textContent, "1-3 / 4" ); }); QUnit.test("editable one2many list, adding line, then discarding", async function (assert) { serverData.models.turtle.records.push({ id: 4, turtle_foo: "stephen hawking" }); serverData.models.partner.records[0].turtles = [1, 2, 3, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add a record, then discard await addRow(target); await clickDiscard(target); assert.containsNone(target, ".modal"); assert.isVisible(target.querySelector(".o_field_widget[name=turtles] .o_pager")); assert.strictEqual( target.querySelector(".o_field_widget[name=turtles] .o_pager").textContent.trim(), "1-3 / 4" ); }); QUnit.test("editable one2many list, required field and pager", async function (assert) { serverData.models.turtle.records.push({ id: 4, turtle_foo: "stephen hawking" }); serverData.models.turtle.fields.turtle_foo.required = true; serverData.models.partner.records[0].turtles = [1, 2, 3, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add a (empty) record await addRow(target); // go on next page. The new record is not valid and should be discarded await click(target.querySelector(".o_field_widget[name=turtles] .o_pager_next")); assert.containsOnce(target, "tr.o_data_row"); }); QUnit.test( "editable one2many list, required field, pager and confirm discard", async function (assert) { serverData.models.turtle.records.push({ id: 4, turtle_foo: "stephen hawking" }); serverData.models.turtle.fields.turtle_foo.required = true; serverData.models.partner.records[0].turtles = [1, 2, 3, 4]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add a record with a dirty state, but not valid await addRow(target); await editInput(target, '.o_field_widget[name="turtle_int"] input', 4321); // try to go to next page. The new record is not valid, but dirty so we should // stay on the current page, and the record should be marked as invalid await click(target.querySelector(".o_field_widget[name=turtles] .o_pager_next")); assert.strictEqual( target.querySelector(".o_field_widget[name=turtles] .o_pager").textContent, "1-4 / 5" ); assert.strictEqual( target.querySelector(".o_field_widget[name=turtles] .o_pager").textContent, "1-4 / 5" ); assert.containsOnce(target, ".o_field_widget[name=turtle_foo].o_field_invalid"); } ); QUnit.test("save a record with not new, dirty and invalid subrecord", async function (assert) { serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].display_name = ""; // invalid record await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { throw new Error("Should not call write as record is invalid"); } }, mode: "edit", }); assert.containsOnce(target, ".o_form_editable"); await click(target.querySelector(".o_data_cell")); // edit the first row assert.hasClass(target.querySelector(".o_data_row"), "o_selected_row"); await editInput(target, ".o_field_widget[name=int_field] input", 44); await click(target.querySelector(".o_form_button_save")); assert.containsOnce(target, ".o_form_editable"); assert.containsOnce(target, ".o_invalid_cell"); }); QUnit.test("editable one2many list, adding, discarding, and pager", async function (assert) { serverData.models.partner.records[0].turtles = [1]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // add 4 records (to have more records than the limit) await addRow(target); await editInput(target, '.o_field_widget[name="turtle_foo"] input', "nora"); await addRow(target); await editInput(target, '.o_field_widget[name="turtle_foo"] input', "nora"); await addRow(target); await editInput(target, '.o_field_widget[name="turtle_foo"] input', "nora"); await addRow(target); assert.containsN(target, "tr.o_data_row", 5); assert.containsNone(target, ".o_field_widget[name=turtles] .o_pager"); // discard await clickDiscard(target); assert.containsNone(target, ".modal"); assert.containsOnce(target, "tr.o_data_row"); assert.containsNone(target, ".o_field_widget[name=turtles] .o_pager"); }); QUnit.test("unselecting a line with missing required data", async function (assert) { serverData.models.turtle.fields.turtle_foo.required = true; delete serverData.models.turtle.fields.turtle_foo.default; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, }); // edit mode, then click on Add an item, then click elsewhere assert.containsNone(target, "tr.o_data_row"); await addRow(target); assert.containsOnce(target, "tr.o_data_row"); // adding a value in the non required field, so it is dirty, but with // a missing required field await editInput(target, '.o_field_widget[name="turtle_int"] input', "12345"); // click elsewhere await click(target); assert.containsNone(target, ".modal"); // the line should still be selected assert.containsOnce(target, "tr.o_data_row.o_selected_row"); // click discard await clickDiscard(target); assert.containsNone(target, ".modal"); assert.containsNone(target, "tr.o_data_row"); }); QUnit.test("pressing enter in a o2m with a required empty field", async function (assert) { serverData.models.turtle.fields.turtle_foo.required = true; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { assert.step(args.method); }, }); // edit mode, then click on Add an item, then press enter await addRow(target); triggerHotkey("Enter"); await nextTick(); assert.hasClass(target.querySelector('div[name="turtle_foo"]'), "o_field_invalid"); assert.verifySteps(["get_views", "read", "onchange"]); }); QUnit.test("pressing enter several times in a one2many", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, }); await addRow(target); assert.containsOnce(target, ".o_data_row"); assert.hasClass(target.querySelectorAll(".o_data_row")[0], "o_selected_row"); await editInput(target, "[name='turtle_foo'] input", "a"); triggerHotkey("Enter"); await nextTick(); assert.containsN(target, ".o_data_row", 2); assert.hasClass(target.querySelectorAll(".o_data_row")[1], "o_selected_row"); await editInput(target, "[name='turtle_foo'] input", "a"); triggerHotkey("Enter"); await nextTick(); assert.containsN(target, ".o_data_row", 3); assert.hasClass(target.querySelectorAll(".o_data_row")[2], "o_selected_row"); // this is a weird case, but there's no required fields, so the record is already valid, we can press Enter directly. triggerHotkey("Enter"); await nextTick(); assert.containsN(target, ".o_data_row", 4); assert.hasClass(target.querySelectorAll(".o_data_row")[3], "o_selected_row"); }); QUnit.test( "creating a new line in an o2m with an handle field does not focus the handler", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, }); await addRow(target); assert.strictEqual( document.activeElement, target.querySelector("[name='turtle_foo'] input") ); triggerHotkey("Enter"); await nextTick(); assert.strictEqual( document.activeElement, target.querySelector("[name='turtle_foo'] input") ); } ); QUnit.test("editing a o2m, with required field and onchange", async function (assert) { serverData.models.turtle.fields.turtle_foo.required = true; delete serverData.models.turtle.fields.turtle_foo.default; serverData.models.turtle.onchanges = { turtle_foo: function (obj) { obj.turtle_int = obj.turtle_foo.length; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { if (args.method) { assert.step(args.method); } }, }); // edit mode, then click on Add an item assert.containsNone(target, "tr.o_data_row"); await addRow(target); // input some text in required turtle_foo field await editInput(target, '.o_field_widget[name="turtle_foo"] input', "aubergine"); assert.strictEqual( target.querySelector('.o_field_widget[name="turtle_int"] input').value, "9" ); // save and check everything is fine await clickSave(target); assert.strictEqual( target.querySelector(".o_data_row .o_data_cell.o_list_char").textContent, "aubergine" ); assert.strictEqual( target.querySelector(".o_data_row .o_data_cell.o_list_number").textContent, "9" ); assert.verifySteps(["get_views", "read", "onchange", "onchange", "write", "read", "read"]); }); QUnit.test("editable o2m, pressing ESC discard current changes", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { assert.step(args.method); }, }); await addRow(target); assert.containsOnce(target, "tr.o_data_row"); await triggerEvent(target, '[name="turtle_foo"] input', "keydown", { key: "Escape" }); assert.containsNone(target, "tr.o_data_row"); assert.verifySteps(["get_views", "read", "onchange"]); }); QUnit.test( "editable o2m with required field, pressing ESC discard current changes", async function (assert) { serverData.models.turtle.fields.turtle_foo.required = true; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { assert.step(args.method); }, }); await addRow(target); assert.containsOnce(target, "tr.o_data_row"); await triggerEvent(target, '[name="turtle_foo"] input', "keydown", { key: "Escape" }); assert.containsNone(target, "tr.o_data_row"); assert.verifySteps(["get_views", "read", "onchange"]); } ); QUnit.test("pressing escape in editable o2m list in dialog", async function (assert) { serverData.views = { "partner,false,form": `
`, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target); await addRow(target, ".modal"); assert.containsOnce(target, ".modal .o_data_row.o_selected_row"); await triggerEvent(target, '[name="display_name"] input', "keydown", { key: "Escape" }); assert.containsOnce(target, ".modal"); assert.containsNone(target, ".modal .o_data_row"); }); QUnit.test( "editable o2m with onchange and required field: delete an invalid line", async function (assert) { serverData.models.partner.onchanges = { turtles: function () {}, }; serverData.models.partner.records[0].turtles = [1]; serverData.models.turtle.records[0].product_id = 37; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); }, }); assert.verifySteps(["get_views", "read", "read"]); await click(target.querySelector(".o_data_cell")); await editInput(target, ".o_field_widget[name=product_id] input", ""); assert.verifySteps([], "no onchange should be done as line is invalid"); await click(target.querySelector(".o_list_record_remove")); assert.verifySteps(["onchange"], "onchange should have been done"); } ); QUnit.test("onchange in a one2many", async function (assert) { serverData.models.partner.records.push({ id: 3, foo: "relational record 1", }); serverData.models.partner.records[1].p = [3]; serverData.models.partner.onchanges = { p: true }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { if (args.method === "onchange") { return Promise.resolve({ value: { p: [ [5], // delete all [0, 0, { foo: "from onchange" }], // create new ], }, }); } }, }); await click(target.querySelector(".o_field_one2many tbody td")); await editInput( target.querySelector(".o_field_one2many tbody td input"), null, "new value" ); await clickSave(target); assert.strictEqual( target.querySelector(".o_field_one2many tbody td").textContent, "from onchange" ); }); QUnit.test("one2many, default_get and onchange (basic)", async function (assert) { serverData.models.partner.fields.p.default = [ [6, 0, []], // replace with zero ids ]; serverData.models.partner.onchanges = { p: true }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange") { return { value: { p: [ [5], // delete all [0, 0, { foo: "from onchange" }], // create new ], }, }; } }, }); assert.strictEqual(target.querySelector("td").textContent, "from onchange"); }); QUnit.test("one2many and default_get (with date)", async function (assert) { serverData.models.partner.fields.p.default = [[0, false, { date: "2017-10-08", p: [] }]]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); assert.strictEqual( target.querySelector(".o_data_cell").textContent, "10/08/2017", "should correctly display the date" ); }); QUnit.test("one2many and onchange (with integer)", async function (assert) { serverData.models.turtle.onchanges = { turtle_int: function () {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); }, }); const td = target.querySelector("td"); assert.strictEqual(td.textContent, "9"); await click(td); await editInput(target, 'td [name="turtle_int"] input', "3"); assert.verifySteps(["get_views", "read", "read", "onchange"]); }); QUnit.test("one2many and onchange (with date)", async function (assert) { serverData.models.partner.onchanges = { date: function () {}, }; serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); }, }); const td = target.querySelector("td"); assert.strictEqual(td.textContent, "01/25/2017"); await click(td); await click(target.querySelector(".o_datepicker_input")); await nextTick(); await click(document.body.querySelector(".bootstrap-datetimepicker-widget .picker-switch")); await click( document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1] ); await click( [...document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")].filter( (el) => el.textContent === "2017" )[0] ); await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]); await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .day")[22]); await clickSave(target); assert.verifySteps(["get_views", "read", "read", "onchange", "write", "read", "read"]); }); QUnit.test("one2many and onchange (with command DELETE_ALL)", async function (assert) { assert.expect(5); serverData.models.partner.onchanges = { foo: function (obj) { obj.p = [[5]]; }, p: function () {}, // dummy onchange on the o2m to execute _isX2ManyValid() }; serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC: function (method, args) { if (args.method === "write") { assert.deepEqual(args.args[1].p, [ [0, args.args[1].p[0][1], { display_name: "z" }], [2, 2, false], ]); } }, resId: 1, }); assert.containsOnce(target, ".o_data_row"); // empty o2m by triggering the onchange await editInput(target, ".o_field_widget[name=foo] input", "trigger onchange"); assert.containsNone(target, ".o_data_row", "rows of the o2m should have been deleted"); // add two new subrecords await addRow(target); await editInput(target, ".o_field_widget[name=display_name] input", "x"); await addRow(target); await editInput(target, ".o_field_widget[name=display_name] input", "y"); assert.containsN(target, ".o_data_row", 2); // empty o2m by triggering the onchange await editInput(target, ".o_field_widget[name=foo] input", "trigger onchange again"); assert.containsNone(target, ".o_data_row", "rows of the o2m should have been deleted"); await addRow(target); await editInput(target, ".o_field_widget[name=display_name] input", "z"); await clickSave(target); }); QUnit.test("one2many and onchange only write modified field", async function (assert) { assert.expect(2); serverData.models.partner.onchanges = { turtles: function (obj) { obj.turtles = [ [5], // delete all [ 1, 3, { // the server returns all fields display_name: "coucou", product_id: [37, "xphone"], turtle_bar: false, turtle_foo: "has changed", turtle_int: 42, turtle_qux: 9.8, partner_ids: [], turtle_ref: "product,37", }, ], ]; }, }; serverData.models.partner.records[0].turtles = [3]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC: function (method, args) { if (args.method === "write") { assert.deepEqual( args.args[1].turtles, [ [ 1, 3, { display_name: "coucou", turtle_foo: "has changed", turtle_int: 42, }, ], ], "correct commands should be sent (only send changed values)" ); } }, resId: 1, }); assert.containsOnce(target, ".o_data_row"); await click(target.querySelector(".o_field_one2many td")); await editInput(target, ".o_field_widget[name=display_name] input", "blurp"); await clickSave(target); }); QUnit.test("one2many with CREATE onchanges correctly refreshed", async function (assert) { let delta = 0; const fieldRegistry = registry.category("fields"); for (const [name, Field] of fieldRegistry.getEntries()) { class DeltaField extends Field { setup() { super.setup(); owl.onWillStart(() => { delta++; }); owl.onWillDestroy(() => { delta--; }); } } fieldRegistry.add(name, DeltaField, { force: true }); } let deactiveOnchange = true; serverData.models.partner.records[0].turtles = []; serverData.models.partner.onchanges = { turtles: function (obj) { if (deactiveOnchange) { return; } // the onchange will either: // - create a second line if there is only one line // - edit the second line if there are two lines if (obj.turtles.length === 1) { obj.turtles = [ [5], // delete all [ 0, obj.turtles[0][1], { display_name: "first", turtle_int: obj.turtles[0][2].turtle_int, }, ], [ 0, 0, { display_name: "second", turtle_int: -obj.turtles[0][2].turtle_int, }, ], ]; } else if (obj.turtles.length === 2) { obj.turtles = [ [5], // delete all [ 0, obj.turtles[0][1], { display_name: "first", turtle_int: obj.turtles[0][2].turtle_int, }, ], [ 0, obj.turtles[1][1], { display_name: "second", turtle_int: -obj.turtles[0][2].turtle_int, }, ], ]; } }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsNone(target, ".o_data_row"); await addRow(target); // trigger the first onchange deactiveOnchange = false; await editInput(target, '[name="turtle_int"] input', "10"); // put the list back in non edit mode await click(target, '[name="foo"] input'); assert.deepEqual( [...target.querySelectorAll(".o_data_row")].map((el) => el.textContent), ["first10", "second-10"] ); // trigger the second onchange await click(target.querySelector(".o_field_x2many_list tbody tr td")); await editInput(target, '[name="turtle_int"] input', "20"); await click(target, '[name="foo"] input'); assert.deepEqual( [...target.querySelectorAll(".o_data_row")].map((el) => el.textContent), ["first20", "second-20"] ); assert.containsN( target, ".o_field_widget", delta, "all (non visible) field widgets should have been destroyed" ); await clickSave(target); assert.deepEqual( [...target.querySelectorAll(".o_data_row")].map((el) => el.textContent), ["first20", "second-20"] ); }); QUnit.test( "editable one2many with sub widgets are rendered in readonly", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_form_view .o_field_x2many_list_row_add "); assert.containsNone(target, ".o_form_view input"); await addRow(target); assert.containsOnce(target, ".o_form_view .o_field_x2many_list_row_add "); assert.containsN(target, ".o_form_view input", 2); } ); QUnit.test("one2many editable list with onchange keeps the order", async function (assert) { serverData.models.partner.records[0].p = [1, 2, 4]; serverData.models.partner.onchanges = { p: function () {}, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.deepEqual( [...target.querySelectorAll(".o_data_cell")].map((el) => el.textContent), ["first record", "second record", "aaa"] ); await click(target.querySelector(".o_data_row .o_data_cell")); await editInput(target, ".o_selected_row .o_field_widget[name=display_name] input", "new"); await click(target, ".o_form_view"); assert.deepEqual( [...target.querySelectorAll(".o_data_cell")].map((el) => el.textContent), ["new", "second record", "aaa"] ); }); QUnit.test("one2many list (editable): readonly domain is evaluated", async function (assert) { serverData.models.partner.records[0].p = [2, 4]; serverData.models.partner.records[1].product_id = false; serverData.models.partner.records[2].product_id = 37; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // switch the first row in edition await click(target.querySelector(".o_data_cell")); assert.hasClass( target.querySelector(".o_selected_row .o_field_widget"), "o_readonly_modifier", "first record should have display_name in readonly mode" ); // switch the second row in edition await click(target.querySelector(".o_data_row:not(.o_selected_row) .o_data_cell")); assert.doesNotHaveClass( target.querySelector(".o_selected_row .o_field_widget"), "o_readonly_modifier", "second record should not have display_name in readonly mode" ); }); QUnit.test("pager of one2many field in new record", async function (assert) { serverData.models.partner.records[0].p = []; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); assert.containsNone(target, ".o_x2m_control_panel .o_pager", "o2m pager should be hidden"); // click to create a subrecord await addRow(target); assert.containsOnce(target, "tr.o_data_row"); assert.containsNone(target, ".o_x2m_control_panel .o_pager", "o2m pager should be hidden"); }); QUnit.test("one2many list with a many2one", async function (assert) { assert.expect(5); let checkOnchange = false; serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].product_id = 37; serverData.models.partner.onchanges.p = function (obj) { obj.p = [ [5], // delete all [1, 2, { product_id: [37, "xphone"] }], // update existing record [0, 0, { product_id: [41, "xpad"] }], ]; }; serverData.views = {}; serverData.views["partner,false,form"] = '
'; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "onchange" && checkOnchange) { assert.deepEqual( args.args[1].p, [ [4, 2, false], [0, args.args[1].p[1][1], { product_id: 41 }], ], "should trigger onchange with correct parameters" ); } }, }); assert.containsOnce(target, ".o_data_cell[data-tooltip='xphone']"); assert.containsNone(target, ".o_data_cell[data-tooltip='xpad']"); await addRow(target); checkOnchange = true; await clickOpenM2ODropdown(target, "product_id"); await click(target.querySelectorAll('div[name="product_id"] .o_input_dropdown li')[1]); await click(target.querySelector(".modal .modal-footer button")); assert.containsOnce(target, ".o_data_cell[data-tooltip='xphone']"); assert.containsOnce(target, ".o_data_cell[data-tooltip='xpad']"); }); QUnit.test("one2many list with inline form view", async function (assert) { assert.expect(5); serverData.models.partner.records[0].p = []; await makeView({ type: "form", resModel: "partner", serverData, // don't remove foo field in sub tree view, it is useful to make sure // the foo fieldwidget does not crash because the foo field is not in the form view arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "write") { assert.deepEqual(args.args[1].p, [ [ 0, args.args[1].p[0][1], { foo: "My little Foo Value", int_field: 123, product_id: 41, }, ], ]); } }, }); await addRow(target); // write in the many2one field, value = 37 (xphone) await clickOpenM2ODropdown(target, "product_id"); await clickM2OHighlightedItem(target, "product_id"); // write in the integer field await editInput(target, '.modal .modal-body div[name="int_field"] input', "123"); // save and close await clickSave(target.querySelector(".modal")); assert.containsOnce(target, ".o_data_cell[data-tooltip='xphone']"); // reopen the record in form view await click(target, ".o_data_cell[data-tooltip='xphone']"); assert.strictEqual(target.querySelector(".modal .modal-body input").value, "xphone"); await editInput(target, '.modal .modal-body div[name="int_field"] input', "456"); // discard await clickDiscard(target.querySelector(".modal")); // reopen the record in form view await click(target, ".o_data_cell[data-tooltip='xphone']"); assert.strictEqual( target.querySelector('.modal .modal-body div[name="int_field"] input').value, "123", "should display 123 (previous change has been discarded)" ); // write in the many2one field, value = 41 (xpad) await clickOpenM2ODropdown(target, "product_id"); await click(target.querySelectorAll('div[name="product_id"] .o_input_dropdown li')[1]); // save and close await clickSave(target.querySelector(".modal")); assert.containsOnce(target, ".o_data_cell[data-tooltip='xpad']"); // save the record await clickSave(target); }); QUnit.test("one2many, edit record in dialog, save, re-edit, discard", async function (assert) { assert.expect(6); serverData.models.partner.records[0].p = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.strictEqual(target.querySelector(".o_data_cell[name=int_field]").innerText, "9"); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal .o_field_widget[name=int_field] input").value, "9" ); await editInput(target, ".modal .o_field_widget[name=int_field] input", "123"); await clickSave(target.querySelector(".modal")); assert.strictEqual(target.querySelector(".o_data_cell[name=int_field]").innerText, "123"); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal .o_field_widget[name=int_field] input").value, "123" ); await clickDiscard(target.querySelector(".modal")); assert.strictEqual(target.querySelector(".o_data_cell[name=int_field]").innerText, "123"); await click(target.querySelector(".o_data_row .o_data_cell")); assert.strictEqual( target.querySelector(".modal .o_field_widget[name=int_field] input").value, "123" ); }); QUnit.test( "one2many list with inline form view with context with parent key", async function (assert) { assert.expect(2); serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[0].product_id = 41; serverData.models.partner.records[1].product_id = 37; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "name_search") { assert.strictEqual( args.kwargs.context.partner_foo, "yop", "should have correctly evaluated parent foo field" ); assert.strictEqual( args.kwargs.context.lalala, 41, "should have correctly evaluated parent product_id field" ); } }, }); // open a modal await click(target.querySelector("tr.o_data_row td[data-tooltip='xphone']")); // write in the many2one field await click(target, ".modal .o_field_many2one input"); } ); QUnit.test( "value of invisible x2many fields is correctly evaluated in context", async function (assert) { assert.expect(2); serverData.models.partner.records[0].timmy = [12]; serverData.models.partner.records[0].p = [2, 3]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "name_search") { const { p, timmy } = args.kwargs.context; assert.deepEqual(p, [ [4, 2, false], [4, 3, false], ]); assert.deepEqual(timmy, [[6, false, [12]]]); } }, }); await click(target, ".o_field_widget[name=product_id] input"); } ); QUnit.test( "one2many list, editable, with many2one and with context with parent key", async function (assert) { assert.expect(1); serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].product_id = 37; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "name_search") { assert.strictEqual( args.kwargs.context.partner_foo, "yop", "should have correctly evaluated parent foo field" ); } }, }); await click(target.querySelector("tr.o_data_row td[data-tooltip='xphone']")); // trigger a name search await click(target, "table td input"); } ); QUnit.test("one2many list, editable, with a date in the context", async function (assert) { assert.expect(1); serverData.models.partner.records[0].p = [2]; serverData.models.partner.records[1].product_id = 37; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 2, mockRPC(route, args) { if (args.method === "onchange") { assert.strictEqual( args.kwargs.context.date, "2017-01-25", "should have properly evaluated date key in context" ); } }, }); await addRow(target); }); QUnit.test("one2many field with context", async function (assert) { assert.expect(2); let counter = 0; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "onchange") { const expected = counter === 0 ? [[4, 2, false]] : [ [4, 2, false], [0, args.kwargs.context.turtles[1][1], { turtle_foo: "hammer" }], ]; assert.deepEqual( args.kwargs.context.turtles, expected, "should have properly evaluated turtles key in context" ); counter++; } }, }); await addRow(target); await editInput(target, '[name="turtle_foo"] input', "hammer"); await addRow(target); }); QUnit.test("one2many list edition, some basic functionality", async function (assert) { serverData.models.partner.fields.foo.default = false; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target); assert.containsOnce( target, "td .o_field_widget input", "should have created a row in edit mode" ); await editInput(target, "td .o_field_widget input", "a"); assert.containsOnce( target, "td .o_field_widget input", "should not have unselected the row after edition" ); await editInput(target, "td .o_field_widget input", "abc"); await clickSave(target); assert.strictEqual( [...target.querySelectorAll("td")].filter((el) => el.textContent === "abc").length, 1, "should have a row with the correct value" ); }); QUnit.test( "one2many list, the context is properly evaluated and sent", async function (assert) { assert.expect(2); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "onchange") { var context = args.kwargs.context; assert.strictEqual(context.hello, "world"); assert.strictEqual(context.abc, 10); } }, }); await addRow(target); } ); QUnit.test( "one2many list not editable, the context is properly evaluated and sent", async function (assert) { assert.expect(3); serverData.views = { "turtle,false,form": '
', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "get_views" && args.model === "turtle") { const context = args.kwargs.context; assert.strictEqual(context.hello, "world"); assert.strictEqual(context.abc, 10); } }, }); await addRow(target); assert.containsOnce(target, ".modal"); } ); QUnit.test("one2many with many2many widget: create", async function (assert) { assert.expect(10); serverData.views = { "turtle,false,list": ` `, "turtle,false,search": ` `, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/turtle/create") { assert.ok(args.args, "should write on the turtle record"); } if (route === "/web/dataset/call_kw/partner/write") { assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1"); assert.strictEqual( args.args[1].turtles[0][0], 6, "should send only a 'replace with' command" ); } }, }); await addRow(target); assert.strictEqual( $(".modal .o_data_row").length, 2, "should have 2 records in the select view (the last one is not displayed because it is already selected)" ); await click($(".modal .o_data_row:first .o_list_record_selector input")[0]); await nextTick(); // additional render due to the change of selection (done in owl, not pure js) await click($(".modal .o_select_button")[0]); await clickSave(target); await addRow(target); assert.strictEqual( $(".modal .o_data_row").length, 1, "should have 1 record in the select view" ); await click($(".modal-footer button:eq(1)")[0]); await editInput(target, '.modal .o_field_widget[name="turtle_foo"] input', "tototo"); await editInput(target, '.modal .o_field_widget[name="turtle_int"] input', 50); await clickOpenM2ODropdown(target, "product_id"); await clickM2OHighlightedItem(target, "product_id"); await click($(".modal-footer button:contains(&):first")[0]); assert.strictEqual($(".modal").length, 0, "should close the modals"); assert.containsN(target, ".o_data_row", 3, "should have 3 records in one2many list"); assert.strictEqual( $(target.querySelectorAll(".o_data_row")).text(), "blip1.59yop1.50tototo1.550xphone", "should display the record values in one2many list" ); await clickSave(target); }); QUnit.test("one2many with many2many widget: edition", async function (assert) { assert.expect(7); serverData.views = { "turtle,false,list": ` `, "turtle,false,search": ` `, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/turtle/write") { assert.strictEqual(args.args[0].length, 1, "should write on the turtle record"); assert.deepEqual( args.args[1], { product_id: 37 }, "should write only the product_id on the turtle record" ); } if (route === "/web/dataset/call_kw/partner/write") { assert.strictEqual(args.args[0][0], 1, "should write on the partner record 1"); assert.strictEqual( args.args[1].turtles[0][0], 6, "should send only a 'replace with' command" ); } }, }); //await new Promise(() => {}) await click($(target).find(".o_data_cell:first")[0]); assert.strictEqual( $(".modal .modal-title").first().text().trim(), "Open: one2many turtle field", "modal should use the python field string as title" ); await clickDiscard(target.querySelector(".modal")); // edit the first one2many record await click($(target).find(".o_data_cell:first")[0]); await clickOpenM2ODropdown(target, "product_id"); await clickM2OHighlightedItem(target, "product_id"); await click($(".modal-footer button:first")[0]); await clickSave(target); // add a one2many record await addRow(target); await click($(".modal .o_data_row:first .o_list_record_selector input")[0]); await nextTick(); // wait for re-rendering because of the change of selection await click($(".modal .o_select_button")[0]); // edit the second one2many record await click($(target).find(".o_data_row:eq(1) .o_data_cell")[0]); await clickOpenM2ODropdown(target, "product_id"); await clickM2OHighlightedItem(target, "product_id"); await click($(".modal .modal-footer button:first")[0]); await clickSave(target); }); QUnit.test("new record, the context is properly evaluated and sent", async function (assert) { assert.expect(2); serverData.models.partner.fields.int_field.default = 17; let n = 0; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange") { n++; if (n === 2) { var context = args.kwargs.context; assert.strictEqual(context.hello, "world"); assert.strictEqual(context.abc, 17); } } }, }); await addRow(target); }); QUnit.test("parent data is properly sent on an onchange rpc", async function (assert) { assert.expect(1); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "onchange") { const fieldValues = args.args[1]; assert.strictEqual( fieldValues.trululu.foo, "yop", "should have properly sent the parent foo value" ); } }, }); await addRow(target); }); QUnit.test( "parent data is properly sent on an onchange rpc (existing x2many record)", async function (assert) { assert.expect(4); serverData.models.partner.onchanges = { display_name: function () {}, }; serverData.models.partner.records[0].p = [1]; serverData.models.partner.records[0].turtles = [2]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "onchange") { const fieldValues = args.args[1]; assert.strictEqual(fieldValues.trululu.foo, "yop"); // we only send fields that changed inside the reverse many2one assert.deepEqual(fieldValues.trululu.p, [ [1, 1, { display_name: "new val" }], ]); } }, }); assert.containsOnce(target, ".o_data_row"); await click(target.querySelector(".o_data_row .o_data_cell")); assert.containsOnce(target, ".o_data_row.o_selected_row"); await editInput( target, ".o_selected_row .o_field_widget[name=display_name] input", "new val" ); } ); QUnit.test( "parent data is properly sent on an onchange rpc, new record", async function (assert) { assert.expect(5); serverData.models.turtle.onchanges = { turtle_bar: function () {} }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { assert.step(args.method); if (args.method === "onchange" && args.model === "turtle") { var fieldValues = args.args[1]; assert.strictEqual( fieldValues.turtle_trululu.foo, "My little Foo Value", "should have properly sent the parent foo value" ); } }, }); await addRow(target); assert.verifySteps(["get_views", "onchange", "onchange"]); } ); QUnit.test("id in one2many obtained in onchange is properly set", async function (assert) { serverData.models.partner.onchanges.turtles = function (obj) { obj.turtles = [[5], [1, 3, { turtle_foo: "kawa" }]]; }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); assert.deepEqual( [...target.querySelectorAll("tr.o_data_row .o_data_cell")].map((el) => el.textContent), ["3", "kawa"], "should have properly displayed id and foo field" ); }); QUnit.test("id field in one2many in a new record", async function (assert) { assert.expect(1); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "create") { var virtualID = args.args[0].turtles[0][1]; assert.deepEqual( args.args[0].turtles, [[0, virtualID, { turtle_foo: "cat" }]], "should send proper commands" ); } }, }); await addRow(target); await editInput(target, 'td [name="turtle_foo"] input', "cat"); await clickSave(target); }); QUnit.test("sub form view with a required field", async function (assert) { serverData.models.partner.fields.foo.required = true; serverData.models.partner.fields.foo.default = null; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await addRow(target); await click(target.querySelector(".modal-footer button.btn-primary")); assert.containsOnce(target, ".modal"); assert.containsOnce(target, ".modal label.o_field_invalid"); }); QUnit.test("one2many list with action button", async function (assert) { assert.expect(4); serverData.models.partner.records[0].p = [2]; const form = await makeView({ type: "form", resModel: "partner", serverData, arch: `