/** @odoo-module **/ import { Component, xml } from "@odoo/owl"; import { addRow, click, clickOpenedDropdownItem, clickSave, editInput, editSelect, getFixture, getNodesTextContent, nextTick, patchWithCleanup, } from "@web/../tests/helpers/utils"; import { editSearch, validateSearch } from "@web/../tests/search/helpers"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; import { session } from "@web/session"; import { Many2XAutocomplete } from "@web/views/fields/relational_utils"; import { X2ManyField } from "@web/views/fields/x2many/x2many_field"; import { companyService } from "@web/webclient/company_service"; let target; let serverData; 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 }, 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" }, reference: { string: "Reference Field", type: "reference", selection: [ ["product", "Product"], ["partner_type", "Partner Type"], ["partner", "Partner"], ], }, }, records: [ { id: 1, display_name: "first record", bar: true, foo: "yop", int_field: 10, p: [], turtles: [2], timmy: [], trululu: 4, user_id: 17, reference: "product,37", }, { id: 2, display_name: "second record", bar: true, foo: "blip", int_field: 9, 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: { display_name: { string: "Partner Type", type: "char" }, 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_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, 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("Many2ManyField"); QUnit.test("many2many kanban: edition", async function (assert) { assert.expect(31); serverData.views = { "partner_type,false,form": '
', "partner_type,false,list": '', "partner_type,false,search": '', }; serverData.models.partner.records[0].timmy = [12, 14]; serverData.models.partner_type.records.push({ id: 15, display_name: "red", color: 6 }); serverData.models.partner_type.records.push({ id: 18, display_name: "yellow", color: 4 }); serverData.models.partner_type.records.push({ id: 21, display_name: "blue", color: 1 }); await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner_type/write") { assert.strictEqual( args.args[1].display_name, "new name", "should write 'new_name'" ); } if (route === "/web/dataset/call_kw/partner_type/create") { assert.strictEqual( args.args[0].display_name, "A new type", "should create 'A new type'" ); } if (route === "/web/dataset/call_kw/partner/write") { var commands = args.args[1].timmy; assert.strictEqual(commands.length, 1, "should have generated one command"); assert.strictEqual( commands[0][0], 6, "generated command should be REPLACE WITH" ); // get the created type's id var createdType = _.findWhere(serverData.models.partner_type.records, { display_name: "A new type", }); var ids = _.sortBy([12, 15, 18].concat(createdType.id), _.identity.bind(_)); assert.ok( _.isEqual(_.sortBy(commands[0][2], _.identity.bind(_)), ids), "new value should be " + ids ); } }, }); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 2, "should contain 2 records" ); assert.strictEqual( $(target).find(".o_kanban_record:first() span").text(), "gold", "display_name of subrecord should be the one in DB" ); assert.ok( $(target).find(".o_kanban_renderer .delete_icon").length, "delete icon should be visible in edit" ); assert.ok( $(target).find(".o_field_many2many .o-kanban-button-new").length, '"Add" button should be visible in edit' ); assert.strictEqual( $(target).find(".o_field_many2many .o-kanban-button-new").text().trim(), "Add", 'Create button should have "Add" label' ); // edit existing subrecord await click($(target).find(".oe_kanban_global_click:first()")[0]); await editInput(target, ".modal .o_form_view input", "new name"); await click($(".modal .modal-footer .btn-primary")[0]); assert.strictEqual( $(target).find(".o_kanban_record:first() span").text(), "new name", "value of subrecord should have been updated" ); // add subrecords // -> single select await click($(target).find(".o_field_many2many .o-kanban-button-new")[0]); assert.ok($(".modal .o_list_view").length, "should have opened a list view in a modal"); assert.strictEqual( $(".modal .o_list_view tbody .o_list_record_selector").length, 3, "list view should contain 3 records" ); await click($(".modal .o_list_view tbody tr:contains(red) .o_data_cell")[0]); assert.ok(!$(".modal .o_list_view").length, "should have closed the modal"); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 3, "kanban should now contain 3 records" ); assert.ok( $(target).find(".o_kanban_record:contains(red)").length, 'record "red" should be in the kanban' ); // -> multiple select await click($(target).find(".o_field_many2many .o-kanban-button-new")[0]); assert.ok( $(".modal .o_select_button").prop("disabled"), "select button should be disabled" ); assert.strictEqual( $(".modal .o_list_view tbody .o_list_record_selector").length, 2, "list view should contain 2 records" ); await click($(".modal .o_list_view thead .o_list_record_selector input")[0]); await nextTick(); await click($(".modal .o_select_button")[0]); assert.ok( !$(".modal .o_select_button").prop("disabled"), "select button should be enabled" ); assert.ok(!$(".modal .o_list_view").length, "should have closed the modal"); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 5, "kanban should now contain 5 records" ); // -> created record await click($(target).find(".o_field_many2many .o-kanban-button-new")[0]); await click($(".modal .modal-footer .btn-primary:nth(1)")[0]); assert.ok( $(".modal .o_form_view .o_form_editable").length, "should have opened a form view in edit mode, in a modal" ); await editInput(target, ".modal .o_form_view input", "A new type"); await click($(".modal:not(.o_inactive_modal) footer .btn-primary:first()")[0]); assert.ok(!$(".modal").length, "should have closed both modals"); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 6, "kanban should now contain 6 records" ); assert.ok( $(target).find(".o_kanban_record:contains(A new type)").length, "the newly created type should be in the kanban" ); // delete subrecords await click($(target).find(".o_kanban_record:contains(silver)")[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, 5, "should contain 5 records" ); assert.ok( !$(target).find(".o_kanban_record:contains(silver)").length, "the removed record should not be in kanban anymore" ); await click($(target).find(".o_kanban_record:contains(blue) .delete_icon")[0]); assert.strictEqual( $(target).find(".o_kanban_record:not(.o_kanban_ghost)").length, 4, "should contain 4 records" ); assert.ok( !$(target).find(".o_kanban_record:contains(blue)").length, "the removed record should not be in kanban anymore" ); // save the record await clickSave(target); }); QUnit.test( "many2many kanban(editable): properly handle add-label node attribute", async function (assert) { serverData.models.partner.records[0].timmy = [12]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.strictEqual( target .querySelector(".o_field_many2many[name=timmy] .o-kanban-button-new") .innerText.trim() .toUpperCase(), // for community/enterprise compatibility "ADD TIMMY", "In M2M Kanban, Add button should have 'Add timmy' label" ); } ); QUnit.test("field string is used in the SelectCreateDialog", async function (assert) { serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', "turtle,false,list": '', "turtle,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); await click(target.querySelectorAll(".o_field_x2many_list_row_add a")[0]); assert.containsOnce(target, ".modal"); assert.strictEqual(target.querySelector(".modal .modal-title").innerText, "Add: pokemon"); await click(target.querySelector(".modal .o_form_button_cancel")); assert.containsNone(target, ".modal"); await click(target.querySelectorAll(".o_field_x2many_list_row_add a")[1]); assert.containsOnce(target, ".modal"); assert.strictEqual(target.querySelector(".modal .modal-title").innerText, "Add: Abcde"); }); QUnit.test("many2many kanban: create action disabled", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "" + '' + "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.ok( $(target).find(".o-kanban-button-new").length, '"Add" button should be available in edit' ); assert.ok( $(target).find(".o_kanban_renderer .delete_icon").length, "delete icon should be visible in edit" ); await click($(target).find(".o-kanban-button-new")[0]); assert.strictEqual( $(".modal .modal-footer .btn-primary").length, 1, // only button 'Select' '"Create" button should not be available in the modal' ); }); QUnit.test("many2many kanban: conditional create/delete actions", async function (assert) { serverData.views = { "partner_type,false,form": '
', "partner_type,false,list": '', "partner_type,false,search": "", }; serverData.models.partner.records[0].timmy = [12, 14]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red assert.containsOnce(target, ".o-kanban-button-new", '"Add" button should be available'); await click($(target).find(".o_kanban_record:contains(silver)")[0]); assert.containsOnce( document.body, ".modal .modal-footer .o_btn_remove", "remove button should be visible in modal" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); await click($(target).find(".o-kanban-button-new")[0]); assert.containsN( document.body, ".modal .modal-footer button", 3, "there should be 3 buttons available in the modal" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black await editSelect(target, 'div[name="color"] select', '"black"'); assert.containsOnce( target, ".o-kanban-button-new", '"Add" button should still be available even after color field changed' ); await click($(target).find(".o-kanban-button-new")[0]); // only select and cancel button should be available, create // button should be removed based on color field condition assert.containsN( document.body, ".modal .modal-footer button", 2, '"Create" button should not be available in the modal after color field changed' ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); await click($(target).find(".o_kanban_record:contains(silver)")[0]); assert.containsNone( document.body, ".modal .modal-footer .o_btn_remove", "remove button should not be visible in modal" ); }); QUnit.test( "many2many list (non editable): create a new record and click on action button", async function (assert) { serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; const list = await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC: async (route, args) => { assert.step(args.method); if (args.method === "create") { assert.deepEqual(args.args[0], { display_name: "Hello" }); } }, }); patchWithCleanup(list.env.services.action, { doActionButton: (action, params) => { assert.step(`action: ${action.name}`); }, }); await click(target.querySelector(".o_field_x2many_list_row_add a")); let modal = target.querySelector(".modal"); await click(modal, ".o_create_button"); assert.verifySteps(["get_views", "read", "get_views", "web_search_read", "onchange"]); modal = target.querySelector(".modal"); await editInput(modal, "[name='display_name'] input", "Hello"); assert.strictEqual(modal.querySelector("[name='display_name'] input").value, "Hello"); await click(modal, ".o_statusbar_buttons [name='myaction']"); assert.strictEqual(modal.querySelector("[name='display_name'] input").value, "Hello"); assert.verifySteps(["create", "read", "action: myaction"]); } ); QUnit.test( "many2many list (non editable): create a new record and click on action button", async function (assert) { serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; const list = await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC: async (route, args) => { assert.step(args.method); if (args.method === "create") { assert.deepEqual(args.args[0], { display_name: "Hello" }); } }, }); patchWithCleanup(list.env.services.action, { doActionButton: (action, params) => { assert.step(`action: ${action.name}`); }, }); await click(target.querySelector(".o_field_x2many_list_row_add a")); let modal = target.querySelector(".modal"); await click(modal, ".o_create_button"); assert.verifySteps(["get_views", "read", "get_views", "web_search_read", "onchange"]); modal = target.querySelector(".modal"); await editInput(modal, "[name='display_name'] input", "Hello"); assert.strictEqual(modal.querySelector("[name='display_name'] input").value, "Hello"); await click(modal, ".o_statusbar_buttons [name='myaction']"); assert.strictEqual(modal.querySelector("[name='display_name'] input").value, "Hello"); assert.deepEqual( [...modal.querySelectorAll(".modal-footer button")].map( (button) => button.textContent ), ["Save & Close", "Save & New", "Discard"] ); await editInput(modal, "[name='display_name'] input", "Hello (edited)"); await click(modal.querySelector(".modal-footer button")); assert.containsNone(target, ".modal"); assert.deepEqual( [...target.querySelectorAll("[name='timmy'] .o_data_row")].map( (row) => row.textContent ), ["Hello (edited)"] ); assert.verifySteps(["create", "read", "action: myaction", "write", "read", "read"]); } ); QUnit.test("add record in a many2many non editable list with context", async function (assert) { assert.expect(1); serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "web_search_read") { // done by the SelectCreateDialog assert.deepEqual(args.kwargs.context, { abc: 2, bin_size: true, // not sure it should be there, but was in legacy 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("many2many list (editable): edition", async function (assert) { assert.expect(29); serverData.models.partner.records[0].timmy = [12, 14]; serverData.models.partner_type.records.push({ id: 15, display_name: "bronze", color: 6 }); serverData.models.partner_type.fields.float_field = { string: "Float", type: "float" }; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method !== "get_views") { assert.step(_.last(route.split("/"))); } if (args.method === "write") { assert.deepEqual(args.args[1].timmy, [ [6, false, [12, 15]], [1, 12, { display_name: "new name" }], ]); } }, resId: 1, }); assert.containsN( target, ".o_list_renderer td.o_list_number", 2, "should contain 2 records" ); assert.strictEqual( target.querySelector(".o_list_renderer tbody td").innerText, "gold", "display_name of first subrecord should be the one in DB" ); assert.containsN( target, ".o_list_record_remove", 2, "delete icon should be visible in edit" ); assert.hasClass( target.querySelector("td.o_list_record_remove button"), "fa fa-times", "should have X icons to remove (unlink) records" ); assert.containsOnce( target, ".o_field_x2many_list_row_add", '"Add an item" should not visible in edit' ); // edit existing subrecord await click(target.querySelector(".o_list_renderer tbody td")); assert.containsNone( target, ".modal", "in edit, clicking on a subrecord should not open a dialog" ); assert.hasClass( target.querySelector(".o_list_renderer tbody tr"), "o_selected_row", "first row should be in edition" ); await editInput(target, ".o_selected_row div[name=display_name] input", "new name"); assert.hasClass( target.querySelector(".o_list_renderer .o_data_row"), "o_selected_row", "first row should still be in edition" ); assert.strictEqual( document.activeElement, target.querySelector(".o_list_renderer div[name=display_name] input"), "edited field should still have the focus" ); await click(target.querySelector(".o_form_view")); assert.doesNotHaveClass( target.querySelector(".o_list_renderer tbody tr"), "o_selected_row", "first row should not be in edition anymore" ); assert.strictEqual( target.querySelector(".o_list_renderer tbody td").innerText, "new name", "value of subrecord should have been updated" ); assert.verifySteps(["read", "read"]); // add new subrecords await click(target.querySelector(".o_field_x2many_list_row_add a")); assert.containsOnce(target, ".modal", "a modal should be open"); assert.containsOnce( target, ".modal .o_list_view .o_data_row", "the list should contain one row" ); await click(target.querySelector(".modal .o_list_view .o_data_row .o_data_cell")); assert.containsNone(target, ".modal .o_list_view", "the modal should be closed"); assert.containsN( target, ".o_list_renderer td.o_list_number", 3, "should contain 3 subrecords" ); // remove subrecords await click(target.querySelectorAll(".o_list_record_remove")[1]); assert.containsN( target, ".o_list_renderer td.o_list_number", 2, "should contain 2 subrecord" ); assert.strictEqual( target.querySelector(".o_list_renderer tbody .o_data_row td").innerText, "new name", "the updated row still has the correct values" ); // save await clickSave(target); assert.containsN( target, ".o_list_renderer td.o_list_number", 2, "should contain 2 subrecords" ); assert.strictEqual( target.querySelector(".o_list_renderer .o_data_row td").innerText, "new name", "the updated row still has the correct values" ); assert.verifySteps([ "web_search_read", // list view in dialog "read", // relational field (updated) "write", // save main record "read", // main record "read", // relational field ]); }); QUnit.test("many2many: create & delete attributes (both true)", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce( target, ".o_field_x2many_list_row_add", "should have the 'Add an item' link" ); assert.containsN(target, ".o_list_record_remove", 2, "should have the 'Add an item' link"); }); QUnit.test("many2many: create & delete attributes (both false)", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce( target, ".o_field_x2many_list_row_add", "should have the 'Add an item' link" ); assert.containsN( target, ".o_list_record_remove", 2, "each record should have the 'Remove Item' link" ); }); QUnit.test("many2many list: create action disabled", async function (assert) { await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); assert.containsOnce(target, ".o_field_x2many_list_row_add"); }); QUnit.test("fieldmany2many list comodel not writable", async function (assert) { /** * Many2Many List should behave as the m2m_tags * that is, the relation can be altered even if the comodel itself is not CRUD-able * This can happen when someone has read access alone on the comodel * and full CRUD on the current model */ assert.expect(12); serverData.views = { "partner_type,false,list": ` `, "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (route === "/web/dataset/call_kw/partner/create") { assert.deepEqual(args.args[0], { timmy: [[6, false, [12]]] }); } if (route === "/web/dataset/call_kw/partner/write") { assert.deepEqual(args.args[1], { timmy: [[6, false, []]] }); } }, }); assert.containsOnce(target, ".o_field_many2many .o_field_x2many_list_row_add"); await click(target.querySelector(".o_field_many2many .o_field_x2many_list_row_add a")); assert.containsOnce(target, ".modal"); assert.containsN(target.querySelector(".modal-footer"), "button", 2); assert.containsOnce(target.querySelector(".modal-footer"), "button.o_select_button"); assert.containsOnce(target.querySelector(".modal-footer"), "button.o_form_button_cancel"); await click(target.querySelector(".modal .o_list_view .o_data_cell")); assert.containsNone(target, ".modal"); assert.containsOnce(target, ".o_field_many2many .o_data_row"); assert.deepEqual( getNodesTextContent(target.querySelectorAll(".o_field_many2many .o_data_row")), ["gold"] ); assert.containsOnce(target, ".o_field_many2many .o_field_x2many_list_row_add"); await clickSave(target); assert.containsOnce(target, ".o_field_many2many .o_data_row .o_list_record_remove"); await click(target.querySelector(".o_field_many2many .o_data_row .o_list_record_remove")); await clickSave(target); }); QUnit.test("many2many list: conditional create/delete actions", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red -> create and delete actions are available assert.containsOnce( target, ".o_field_x2many_list_row_add", "should have the 'Add an item' link" ); assert.containsN(target, ".o_list_record_remove", 2, "should have two remove icons"); await click($(target).find(".o_field_x2many_list_row_add a")[0]); assert.containsN( target, ".modal .modal-footer button", 3, "there should be 3 buttons available in the modal" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black -> create and delete actions are no longer available await editSelect(target, 'div[name="color"] select', '"black"'); // add a line and remove icon should still be there as they don't create/delete records, // but rather add/remove links assert.containsOnce( target, ".o_field_x2many_list_row_add", '"Add a line" button should still be available even after color field changed' ); assert.containsN( target, ".o_list_record_remove", 2, "should still have remove icon even after color field changed" ); await click($(target).find(".o_field_x2many_list_row_add a")[0]); assert.containsN( target, ".modal .modal-footer button", 2, '"Create" button should not be available in the modal after color field changed' ); }); QUnit.test("many2many field with link/unlink options (list)", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red -> link and unlink actions are available assert.containsOnce( target, ".o_field_x2many_list_row_add", "should have the 'Add an item' link" ); assert.containsN(target, ".o_list_record_remove", 2, "should have two remove icons"); await click($(target).find(".o_field_x2many_list_row_add a")[0]); assert.containsN( target, ".modal .modal-footer button", 3, "there should be 3 buttons available in the modal (Create action is available)" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black -> link and unlink actions are no longer available await editSelect(target, 'div[name="color"] select', '"black"'); assert.containsNone( target, ".o_field_x2many_list_row_add", '"Add a line" should no longer be available after color field changed' ); assert.containsNone( target, ".o_list_record_remove", "should no longer have remove icon after color field changed" ); }); QUnit.test( 'many2many field with link/unlink options (list, create="0")', async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red -> link and unlink actions are available assert.containsOnce( target, ".o_field_x2many_list_row_add", "should have the 'Add an item' link" ); assert.containsN(target, ".o_list_record_remove", 2, "should have two remove icons"); await click($(target).find(".o_field_x2many_list_row_add a")[0]); assert.containsN( document.body, ".modal .modal-footer button", 2, "there should be 2 buttons available in the modal (Create action is not available)" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black -> link and unlink actions are no longer available await editSelect(target, 'div[name="color"] select', '"black"'); assert.containsNone( target, ".o_field_x2many_list_row_add", '"Add a line" should no longer be available after color field changed' ); assert.containsNone( target, ".o_list_record_remove", "should no longer have remove icon after color field changed" ); } ); QUnit.test("many2many field with link option (kanban)", async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red -> link and unlink actions are available assert.containsOnce(target, ".o-kanban-button-new", "should have the 'Add' button"); await click(target.querySelector(".o-kanban-button-new")); assert.containsN( document.body, ".modal .modal-footer button", 3, "there should be 3 buttons available in the modal (Create action is available" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black -> link and unlink actions are no longer available await editSelect(target, 'div[name="color"] select', '"black"'); assert.containsNone( target, ".o-kanban-button-new", '"Add" should no longer be available after color field changed' ); }); QUnit.test('many2many field with link option (kanban, create="0")', async function (assert) { serverData.models.partner.records[0].timmy = [12, 14]; serverData.views = { "partner_type,false,list": '', "partner_type,false,search": "", }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); // color is red -> link and unlink actions are available assert.containsOnce(target, ".o-kanban-button-new", "should have the 'Add' button"); await click(target.querySelector(".o-kanban-button-new")); assert.containsN( document.body, ".modal .modal-footer button", 2, "there should be 2 buttons available in the modal (Create action is not available" ); await click($(".modal .modal-footer .o_form_button_cancel")[0]); // set color to black -> link and unlink actions are no longer available await editSelect(target, 'div[name="color"] select', '"black"'); assert.containsNone( target, ".o-kanban-button-new", '"Add" should no longer be available after color field changed' ); }); QUnit.test("many2many list: list of id as default value", async function (assert) { serverData.models.partner.fields.turtles.default = [2, 3]; serverData.models.partner.fields.turtles.type = "many2many"; await makeView({ type: "form", resModel: "partner", serverData, arch: "
" + '' + "" + '' + "" + "" + "
", }); assert.strictEqual( $(target).find("td.o_data_cell").text(), "blipkawa", "should have loaded default data" ); }); QUnit.test("many2many list with x2many: add a record", async function (assert) { serverData.models.partner_type.fields.m2m = { string: "M2M", type: "many2many", relation: "turtle", }; serverData.models.partner_type.records[0].m2m = [1, 2]; serverData.models.partner_type.records[1].m2m = [2, 3]; serverData.views = { "partner_type,false,list": ` `, "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: '
', resId: 1, mockRPC(route, args) { if (args.method !== "get_views") { assert.step(_.last(route.split("/")) + " on " + args.model); } if (args.model === "turtle") { assert.step(JSON.stringify(args.args[0])); // the read ids } }, }); await click(target.querySelector(".o_field_x2many_list_row_add a")); await click($(target).find(".modal .o_data_row:first .o_data_cell")[0]); assert.containsOnce( target, ".o_data_row", "the record should have been added to the relation" ); assert.strictEqual( $(target).find(".o_data_row:first .o_tag_badge_text").text(), "leonardodonatello", "inner m2m should have been fetched and correctly displayed" ); await click(target.querySelector(".o_field_x2many_list_row_add a")); await click(target.querySelector(".modal .o_data_row:nth-child(1) .o_data_cell")); assert.containsN( target, ".o_data_row", 2, "the second record should have been added to the relation" ); assert.strictEqual( $(target).find(".o_data_row:nth(1) .o_tag_badge_text").text(), "donatelloraphael", "inner m2m should have been fetched and correctly displayed" ); assert.verifySteps([ "read on partner", "web_search_read on partner_type", "read on turtle", "[1,2,3]", "read on partner_type", "read on turtle", "[1,2]", "web_search_read on partner_type", "read on turtle", "[2,3]", "read on partner_type", "read on turtle", "[2,3]", ]); }); QUnit.test("many2many with a domain", async function (assert) { // The domain specified on the field should not be replaced by the potential // domain the user writes in the dialog, they should rather be concatenated serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target.querySelector(".o_field_x2many_list_row_add a")); assert.strictEqual($(".modal .o_data_row").length, 1, "should contain only one row (gold)"); const modal = document.body.querySelector(".modal"); await editSearch(modal, "s"); await validateSearch(modal); assert.strictEqual($(".modal .o_data_row").length, 0, "should contain no row"); }); QUnit.test("many2many list with onchange and edition of a record", async function (assert) { serverData.models.partner.fields.turtles.type = "many2many"; serverData.models.partner.onchanges.turtles = function () {}; serverData.views = { "turtle,false,form": '
', }; 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).find("td.o_data_cell:first")[0]); assert.verifySteps(["get_views", "read"]); await click($('.modal-body input[type="checkbox"]')[0]); await click($(".modal .modal-footer .btn-primary").first()[0]); assert.verifySteps(["write", "onchange", "read"]); // there is nothing left to save -> should not do a 'write' RPC await clickSave(target); assert.verifySteps([]); }); QUnit.test( "many2many widget: creates a new record with a context containing the parentID", async function (assert) { serverData.views = { "turtle,false,list": '', "turtle,false,search": '', "turtle,false,form": '
', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { const { method, kwargs } = args; assert.step(method); if (method === "onchange") { assert.strictEqual(kwargs.context.default_turtle_trululu, 1); assert.deepEqual(args.args, [ [], {}, [], { turtle_trululu: "", }, ]); } }, }); assert.verifySteps(["get_views", "read", "read"]); await addRow(target); assert.verifySteps(["get_views", "web_search_read"]); await click(target, ".o_create_button"); assert.strictEqual( target.querySelector("[name='turtle_trululu'] input").value, "first record" ); assert.verifySteps(["get_views", "onchange"]); } ); QUnit.test("onchange with 40+ commands for a many2many", async function (assert) { // this test ensures that the basic_model correctly handles more LINK_TO // commands than the limit of the dataPoint (40 for x2many kanban) assert.expect(25); // create a lot of partner_types that will be linked by the onchange var commands = [[5]]; for (var i = 0; i < 45; i++) { var id = 100 + i; serverData.models.partner_type.records.push({ id: id, display_name: "type " + id }); commands.push([4, id]); } serverData.models.partner.onchanges = { foo: function (obj) { obj.timmy = commands; }, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { assert.step(args.method); if (args.method === "write") { assert.strictEqual(args.args[1].timmy[0][0], 6, "should send a command 6"); assert.strictEqual( args.args[1].timmy[0][2].length, 45, "should replace with 45 ids" ); } }, }); assert.verifySteps(["get_views", "read"]); await editInput(target, ".o_field_widget[name=foo] input", "trigger onchange"); assert.verifySteps(["onchange", "read"]); assert.strictEqual( $(target).find(".o_x2m_control_panel .o_pager_counter").text().trim(), "1-40 / 45", "pager should be correct" ); assert.strictEqual( $(target).find('.o_kanban_record:not(".o_kanban_ghost")').length, 40, "there should be 40 records displayed on page 1" ); await click($(target).find(".o_field_widget[name=timmy] .o_pager_next")[0]); assert.verifySteps(["read"]); assert.strictEqual( $(target).find(".o_x2m_control_panel .o_pager_counter").text().trim(), "41-45 / 45", "pager should be correct" ); assert.strictEqual( $(target).find('.o_kanban_record:not(".o_kanban_ghost")').length, 5, "there should be 5 records displayed on page 2" ); await clickSave(target); assert.strictEqual( $(target).find(".o_x2m_control_panel .o_pager_counter").text().trim(), "1-40 / 45", "pager should be correct" ); assert.strictEqual( $(target).find('.o_kanban_record:not(".o_kanban_ghost")').length, 40, "there should be 40 records displayed on page 1" ); await click($(target).find(".o_field_widget[name=timmy] .o_pager_next")[0]); assert.strictEqual( $(target).find(".o_x2m_control_panel .o_pager_counter").text().trim(), "41-45 / 45", "pager should be correct" ); assert.strictEqual( $(target).find('.o_kanban_record:not(".o_kanban_ghost")').length, 5, "there should be 5 records displayed on page 2" ); await click($(target).find(".o_field_widget[name=timmy] .o_pager_next")[0]); assert.strictEqual( $(target).find(".o_x2m_control_panel .o_pager_counter").text().trim(), "1-40 / 45", "pager should be correct" ); assert.strictEqual( $(target).find('.o_kanban_record:not(".o_kanban_ghost")').length, 40, "there should be 40 records displayed on page 1" ); assert.verifySteps(["write", "read", "read", "read"]); }); QUnit.test("default_get, onchange, onchange on m2m", async function (assert) { assert.expect(1); serverData.models.partner.onchanges.int_field = function (obj) { if (obj.int_field === 2) { assert.deepEqual(obj.timmy, [ [6, false, [12]], [1, 12, { display_name: "gold" }], ]); } obj.timmy = [[5], [1, 12, { display_name: "gold" }]]; }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, }); await editInput(target, ".o_field_widget[name=int_field] input", 2); }); QUnit.test("many2many list add *many* records, remove, re-add", async function (assert) { assert.expect(5); serverData.models.partner.fields.timmy.domain = [["color", "=", 2]]; serverData.models.partner.fields.timmy.onChange = true; serverData.models.partner_type.fields.product_ids = { string: "Product", type: "many2many", relation: "product", }; for (var i = 0; i < 50; i++) { var new_record_partner_type = { id: 100 + i, display_name: "batch" + i, color: 2 }; serverData.models.partner_type.records.push(new_record_partner_type); } serverData.views = { "partner_type,false,list": '', "partner_type,false,search": '', }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, mockRPC(route, args) { if (args.method === "get_formview_id") { assert.deepEqual( args.args[0], [1], "should call get_formview_id with correct id" ); } }, }); // First round: add 51 records in batch await click(target.querySelector(".o_field_x2many_list_row_add a")); var $modal = $(".modal-lg"); assert.equal($modal.length, 1, "There should be one modal"); await click($modal.find("thead input[type=checkbox]")[0]); await nextTick(); await click($modal.find(".btn.btn-primary.o_select_button")[0]); assert.strictEqual( $(target).find(".o_data_row").length, 51, "We should have added all the records present in the search view to the m2m field" ); // the 50 in batch + 'gold' await clickSave(target); // Secound round: remove one record var trash_buttons = $(target).find( ".o_field_many2many.o_field_widget .o_field_x2many.o_field_x2many_list .o_list_record_remove" ); await click(trash_buttons.first()[0]); var pager_limit = $(target).find( ".o_field_many2many.o_field_widget .o_field_x2many.o_field_x2many_list .o_pager_limit" ); assert.equal(pager_limit.text(), "50", "We should have 50 records in the m2m field"); // Third round: re-add 1 records await click($(target).find(".o_field_x2many_list_row_add a")[0]); $modal = $(".modal-lg"); assert.equal($modal.length, 1, "There should be one modal"); await click($modal.find("thead input[type=checkbox]")[0]); await nextTick(); await click($modal.find(".btn.btn-primary.o_select_button")[0]); assert.strictEqual( $(target).find(".o_data_row").length, 51, "We should have 51 records in the m2m field" ); }); QUnit.test("many2many kanban: action/type attribute", async function (assert) { serverData.models.partner.records[0].timmy = [12]; const form = await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); patchWithCleanup(form.env.services.action, { doActionButton(params) { assert.step(`doActionButton type ${params.type} name ${params.name}`); params.onClose(); }, }); await click(target.querySelector(".oe_kanban_global_click")); assert.verifySteps(["doActionButton type object name a1"]); }); QUnit.test("select create with _view_ref as text", async (assert) => { serverData.views = { "partner_type,my.little.string,list": ``, "partner_type,false,search": ``, }; patchWithCleanup(browser, { setTimeout: (fn) => Promise.resolve().then(fn), }); patchWithCleanup(Many2XAutocomplete.defaultProps, { searchLimit: 1, }); let checkGetViews = false; await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "get_views" && checkGetViews) { assert.step("get_views"); assert.deepEqual(args.kwargs.views, [ [false, "list"], [false, "search"], ]); assert.strictEqual(args.kwargs.context.tree_view_ref, "my.little.string"); } }, }); await click(target, ".o_field_many2many_selection input"); checkGetViews = true; await clickOpenedDropdownItem(target, "timmy", "Search More..."); assert.verifySteps([`get_views`]); assert.containsOnce(target, ".modal"); assert.strictEqual(target.querySelector(".modal-title").textContent, "Search: pokemon"); }); QUnit.test("many2many basic keys in field evalcontext -- in list", async (assert) => { assert.expect(6); serverData.models.partner_type.fields.partner_id = { string: "Partners", type: "many2one", relation: "partner", }; serverData.views = { "partner_type,false,form": `
`, }; patchWithCleanup(session, { user_companies: { allowed_companies: { 3: { id: 3, name: "Hermit", sequence: 1 }, 2: { id: 2, name: "Herman's", sequence: 2 }, 1: { id: 1, name: "Heroes TM", sequence: 3 }, }, current_company: 3, }, }); registry.category("services").add("company", companyService, { force: true }); patchWithCleanup(browser, { setTimeout: (fn) => Promise.resolve().then(fn), }); await makeView({ type: "list", resModel: "partner", serverData, arch: ` `, mockRPC(route, args) { if (args.method === "onchange") { assert.strictEqual(args.kwargs.context.default_partner_id, 1); assert.strictEqual(args.kwargs.context.model, "partner"); assert.deepEqual(args.kwargs.context.ids, [1]); assert.strictEqual(args.kwargs.context.company_id, 3); } }, }); await click(target.querySelector(".o_data_cell")); await editInput(target, ".o_field_many2many_selection input", "indianapolis"); await nextTick(); await clickOpenedDropdownItem(target, "timmy", "Create and edit..."); assert.containsOnce(target, ".modal .o_field_many2one"); assert.strictEqual( target.querySelector(".modal .o_field_many2one input").value, "first record" ); }); QUnit.test("many2many basic keys in field evalcontext -- in form", async (assert) => { assert.expect(6); serverData.models.partner_type.fields.partner_id = { string: "Partners", type: "many2one", relation: "partner", }; serverData.views = { "partner_type,false,form": `
`, }; patchWithCleanup(session, { user_companies: { allowed_companies: { 3: { id: 3, name: "Hermit", sequence: 1 }, 2: { id: 2, name: "Herman's", sequence: 2 }, 1: { id: 1, name: "Heroes TM", sequence: 3 }, }, current_company: 3, }, }); registry.category("services").add("company", companyService, { force: true }); patchWithCleanup(browser, { setTimeout: (fn) => Promise.resolve().then(fn), }); await makeView({ type: "form", resId: 1, resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange") { assert.strictEqual(args.kwargs.context.default_partner_id, 1); assert.strictEqual(args.kwargs.context.model, "partner"); assert.deepEqual(args.kwargs.context.ids, [1]); assert.strictEqual(args.kwargs.context.company_id, 3); } }, }); await editInput(target, ".o_field_many2many_selection input", "indianapolis"); await nextTick(); await clickOpenedDropdownItem(target, "timmy", "Create and edit..."); assert.containsOnce(target, ".modal .o_field_many2one"); assert.strictEqual( target.querySelector(".modal .o_field_many2one input").value, "first record" ); }); QUnit.test( "many2many basic keys in field evalcontext -- in a x2many in form", async (assert) => { assert.expect(6); serverData.models.partner_type.fields.partner_id = { string: "Partners", type: "many2one", relation: "partner", }; serverData.views = { "partner_type,false,form": `
`, }; const rec = serverData.models.partner.records.find(({ id }) => id === 2); rec.p = [1]; patchWithCleanup(session, { user_companies: { allowed_companies: { 3: { id: 3, name: "Hermit", sequence: 1 }, 2: { id: 2, name: "Herman's", sequence: 2 }, 1: { id: 1, name: "Heroes TM", sequence: 3 }, }, current_company: 3, }, }); registry.category("services").add("company", companyService, { force: true }); patchWithCleanup(browser, { setTimeout: (fn) => Promise.resolve().then(fn), }); await makeView({ type: "form", resId: 2, resModel: "partner", serverData, arch: `
`, mockRPC(route, args) { if (args.method === "onchange") { assert.strictEqual(args.kwargs.context.default_partner_id, 1); assert.strictEqual(args.kwargs.context.model, "partner"); assert.deepEqual(args.kwargs.context.ids, [1]); assert.strictEqual(args.kwargs.context.company_id, 3); } }, }); await click(target, ".o_data_cell"); await editInput(target, ".o_field_many2many_selection input", "indianapolis"); await clickOpenedDropdownItem(target, "timmy", "Create and edit..."); assert.containsOnce(target, ".modal .o_field_many2one"); assert.strictEqual( target.querySelector(".modal .o_field_many2one input").value, "first record" ); } ); QUnit.test("many2many field calling replaceWith (add + remove)", async function (assert) { serverData.models.partner.records[0].p = [1]; class MyX2Many extends Component { onClick() { this.props.value.replaceWith([2, 3]); } } MyX2Many.template = xml` `; registry.category("fields").add("my_x2many", MyX2Many); await makeView({ type: "form", resModel: "turtle", serverData, arch: `
`, resId: 2, }); assert.strictEqual(target.querySelector(".ids").innerText, "2,4"); await click(target.querySelector(".my_btn")); assert.strictEqual(target.querySelector(".ids").innerText, "2,3"); }); QUnit.test("`this` inside rendererProps should reference the component", async function (assert) { class CustomX2manyField extends X2ManyField { setup() { super.setup(); this.selectCreate = (params) => { assert.step("selectCreate"); assert.strictEqual(this.num, 2); }; this.num = 1; } async onAdd({ context, editable } = {}) { this.num = 2; assert.step("onAdd"); super.onAdd(...arguments); } } registry.category("fields").add("custom_x2many", CustomX2manyField); serverData.views = { "partner_type,false,list": ``, "partner_type,false,search": ``, }; await makeView({ type: "form", resModel: "partner", serverData, arch: `
`, resId: 1, }); await click(target.querySelector(".o_field_x2many_list_row_add a")); assert.verifySteps(["onAdd", "selectCreate"]); }); });