/** @odoo-module */ import { session } from "@web/session"; import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; import { makeServerError } from "@web/../tests/helpers/mock_server"; import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason"; import { createModelWithDataSource, waitForDataSourcesLoaded } from "../utils/model"; import { addGlobalFilter, selectCell, setCellContent } from "../utils/commands"; import { getCell, getCellContent, getCellFormula, getCells, getCellValue, getEvaluatedCell, getBorders, } from "../utils/getters"; import { createSpreadsheetWithList } from "../utils/list"; import { registry } from "@web/core/registry"; import { getBasicServerData } from "../utils/data"; import * as spreadsheet from "@odoo/o-spreadsheet"; const { DEFAULT_LOCALE } = spreadsheet.constants; QUnit.module("spreadsheet > list plugin", {}, () => { QUnit.test("List export", async (assert) => { const { model } = await createSpreadsheetWithList(); const total = 4 + 10 * 4; // 4 Headers + 10 lines assert.strictEqual(Object.values(getCells(model)).length, total); assert.strictEqual(getCellFormula(model, "A1"), `=ODOO.LIST.HEADER(1,"foo")`); assert.strictEqual(getCellFormula(model, "B1"), `=ODOO.LIST.HEADER(1,"bar")`); assert.strictEqual(getCellFormula(model, "C1"), `=ODOO.LIST.HEADER(1,"date")`); assert.strictEqual(getCellFormula(model, "D1"), `=ODOO.LIST.HEADER(1,"product_id")`); assert.strictEqual(getCellFormula(model, "A2"), `=ODOO.LIST(1,1,"foo")`); assert.strictEqual(getCellFormula(model, "B2"), `=ODOO.LIST(1,1,"bar")`); assert.strictEqual(getCellFormula(model, "C2"), `=ODOO.LIST(1,1,"date")`); assert.strictEqual(getCellFormula(model, "D2"), `=ODOO.LIST(1,1,"product_id")`); assert.strictEqual(getCellFormula(model, "A3"), `=ODOO.LIST(1,2,"foo")`); assert.strictEqual(getCellFormula(model, "A11"), `=ODOO.LIST(1,10,"foo")`); assert.strictEqual(getCellFormula(model, "A12"), ""); }); QUnit.test("Return display name of selection field", async (assert) => { const { model } = await createSpreadsheetWithList({ model: "documents.document", columns: ["handler"], }); assert.strictEqual(getCellValue(model, "A2"), "Spreadsheet"); }); QUnit.test("Return display_name of many2one field", async (assert) => { const { model } = await createSpreadsheetWithList({ columns: ["product_id"] }); assert.strictEqual(getCellValue(model, "A2"), "xphone"); }); QUnit.test("Boolean fields are correctly formatted", async (assert) => { const { model } = await createSpreadsheetWithList({ columns: ["bar"] }); assert.strictEqual(getCellValue(model, "A2"), "TRUE"); assert.strictEqual(getCellValue(model, "A5"), "FALSE"); }); QUnit.test("properties field displays property display names", async (assert) => { const serverData = getBasicServerData(); serverData.models.partner.records = [ { id: 45, partner_properties: [ { name: "dbfc66e0afaa6a8d", type: "date", string: "prop 1", default: false }, { name: "f80b6fb58d0d4c72", type: "integer", string: "prop 2", default: 0 }, ], }, ]; const { model } = await createSpreadsheetWithList({ serverData, columns: ["partner_properties"], }); assert.strictEqual(getCellValue(model, "A2"), "prop 1, prop 2"); }); QUnit.test("Can display a field which is not in the columns", async function (assert) { const { model } = await createSpreadsheetWithList(); setCellContent(model, "A1", `=ODOO.LIST(1,1,"active")`); assert.strictEqual(getCellValue(model, "A1"), "Loading..."); await waitForDataSourcesLoaded(model); // Await for batching collection of missing fields assert.strictEqual(getCellValue(model, "A1"), true); }); QUnit.test("Can remove a list with undo after editing a cell", async function (assert) { const { model } = await createSpreadsheetWithList(); assert.ok(getCellContent(model, "B1").startsWith("=ODOO.LIST.HEADER")); setCellContent(model, "G10", "should be undoable"); model.dispatch("REQUEST_UNDO"); assert.equal(getCellContent(model, "G10"), ""); model.dispatch("REQUEST_UNDO"); assert.equal(getCellContent(model, "B1"), ""); assert.equal(model.getters.getListIds().length, 0); }); QUnit.test("List formulas are correctly formatted at evaluation", async function (assert) { const { model } = await createSpreadsheetWithList({ columns: ["foo", "probability", "bar", "date", "create_date", "product_id", "pognon"], linesNumber: 2, }); await waitForDataSourcesLoaded(model); assert.strictEqual(getCell(model, "A2").format, undefined); assert.strictEqual(getCell(model, "B2").format, undefined); assert.strictEqual(getCell(model, "C2").format, undefined); assert.strictEqual(getCell(model, "D2").format, undefined); assert.strictEqual(getCell(model, "E2").format, undefined); assert.strictEqual(getCell(model, "F2").format, undefined); assert.strictEqual(getCell(model, "G2").format, undefined); assert.strictEqual(getCell(model, "G3").format, undefined); assert.strictEqual(getEvaluatedCell(model, "A2").format, "0"); assert.strictEqual(getEvaluatedCell(model, "B2").format, "#,##0.00"); assert.strictEqual(getEvaluatedCell(model, "C2").format, undefined); assert.strictEqual(getEvaluatedCell(model, "D2").format, "m/d/yyyy"); assert.strictEqual(getEvaluatedCell(model, "E2").format, "m/d/yyyy hh:mm:ss a"); assert.strictEqual(getEvaluatedCell(model, "F2").format, undefined); assert.strictEqual(getEvaluatedCell(model, "G2").format, "#,##0.00[$€]"); assert.strictEqual(getEvaluatedCell(model, "G3").format, "[$$]#,##0.00"); }); QUnit.test("List formulas date formats are locale dependant", async function (assert) { const { model } = await createSpreadsheetWithList({ columns: ["date", "create_date"], linesNumber: 2, }); await waitForDataSourcesLoaded(model); assert.strictEqual(getEvaluatedCell(model, "A2").format, "m/d/yyyy"); assert.strictEqual(getEvaluatedCell(model, "B2").format, "m/d/yyyy hh:mm:ss a"); const myLocale = { ...DEFAULT_LOCALE, dateFormat: "d/m/yyyy", timeFormat: "hh:mm:ss" }; model.dispatch("UPDATE_LOCALE", { locale: myLocale }); assert.strictEqual(getEvaluatedCell(model, "A2").format, "d/m/yyyy"); assert.strictEqual(getEvaluatedCell(model, "B2").format, "d/m/yyyy hh:mm:ss"); }); QUnit.test("Json fields are not supported in list formulas", async function (assert) { const { model } = await createSpreadsheetWithList({ columns: ["foo", "jsonField"], linesNumber: 2, }); setCellContent(model, "A1", `=ODOO.LIST(1,1,"foo")`); setCellContent(model, "A2", `=ODOO.LIST(1,1,"jsonField")`); await waitForDataSourcesLoaded(model); assert.strictEqual(getEvaluatedCell(model, "A1").value, 12); assert.strictEqual(getEvaluatedCell(model, "A2").value, "#ERROR"); assert.strictEqual( getEvaluatedCell(model, "A2").error.message, `Fields of type "json" are not supported` ); }); QUnit.test("can select a List from cell formula", async function (assert) { const { model } = await createSpreadsheetWithList(); const sheetId = model.getters.getActiveSheetId(); const listId = model.getters.getListIdFromPosition({ sheetId, col: 0, row: 0 }); model.dispatch("SELECT_ODOO_LIST", { listId }); const selectedListId = model.getters.getSelectedListId(); assert.strictEqual(selectedListId, "1"); }); QUnit.test( "can select a List from cell formula with '-' before the formula", async function (assert) { const { model } = await createSpreadsheetWithList(); setCellContent(model, "A1", `=-ODOO.LIST("1","1","foo")`); const sheetId = model.getters.getActiveSheetId(); const listId = model.getters.getListIdFromPosition({ sheetId, col: 0, row: 0 }); model.dispatch("SELECT_ODOO_LIST", { listId }); const selectedListId = model.getters.getSelectedListId(); assert.strictEqual(selectedListId, "1"); } ); QUnit.test( "can select a List from cell formula with other numerical values", async function (assert) { const { model } = await createSpreadsheetWithList(); setCellContent(model, "A1", `=3*ODOO.LIST("1","1","foo")`); const sheetId = model.getters.getActiveSheetId(); const listId = model.getters.getListIdFromPosition({ sheetId, col: 0, row: 0 }); model.dispatch("SELECT_ODOO_LIST", { listId }); const selectedListId = model.getters.getSelectedListId(); assert.strictEqual(selectedListId, "1"); } ); QUnit.test("List datasource is loaded with correct linesNumber", async function (assert) { const { model } = await createSpreadsheetWithList({ linesNumber: 2 }); const [listId] = model.getters.getListIds(); const dataSource = model.getters.getListDataSource(listId); assert.strictEqual(dataSource.maxPosition, 2); }); QUnit.test("can select a List from cell formula within a formula", async function (assert) { const { model } = await createSpreadsheetWithList(); setCellContent(model, "A1", `=SUM(ODOO.LIST("1","1","foo"),1)`); const sheetId = model.getters.getActiveSheetId(); const listId = model.getters.getListIdFromPosition({ sheetId, col: 0, row: 0 }); model.dispatch("SELECT_ODOO_LIST", { listId }); const selectedListId = model.getters.getSelectedListId(); assert.strictEqual(selectedListId, "1"); }); QUnit.test( "can select a List from cell formula where the id is a reference", async function (assert) { const { model } = await createSpreadsheetWithList(); setCellContent(model, "A1", `=ODOO.LIST(G10,"1","foo")`); setCellContent(model, "G10", "1"); const sheetId = model.getters.getActiveSheetId(); const listId = model.getters.getListIdFromPosition({ sheetId, col: 0, row: 0 }); model.dispatch("SELECT_ODOO_LIST", { listId }); const selectedListId = model.getters.getSelectedListId(); assert.strictEqual(selectedListId, "1"); } ); QUnit.test("Referencing non-existing fields does not crash", async function (assert) { assert.expect(4); const forbiddenFieldName = "product_id"; let spreadsheetLoaded = false; const { model } = await createSpreadsheetWithList({ columns: ["bar", "product_id"], mockRPC: async function (route, args, performRPC) { if ( spreadsheetLoaded && args.method === "search_read" && args.model === "partner" && args.kwargs.fields && args.kwargs.fields.includes(forbiddenFieldName) ) { // We should not go through this condition if the forbidden fields is properly filtered assert.ok(false, `${forbiddenFieldName} should have been ignored`); } if (this) { // @ts-ignore return this._super.apply(this, arguments); } }, }); const listId = model.getters.getListIds()[0]; // remove forbidden field from the fields of the list. delete model.getters.getListDataSource(listId).getFields()[forbiddenFieldName]; spreadsheetLoaded = true; model.dispatch("REFRESH_ALL_DATA_SOURCES"); await nextTick(); setCellContent(model, "A1", `=ODOO.LIST.HEADER("1", "${forbiddenFieldName}")`); setCellContent(model, "A2", `=ODOO.LIST("1","1","${forbiddenFieldName}")`); assert.equal( model.getters.getListDataSource(listId).getFields()[forbiddenFieldName], undefined ); assert.strictEqual(getCellValue(model, "A1"), forbiddenFieldName); const A2 = getEvaluatedCell(model, "A2"); assert.equal(A2.type, "error"); assert.equal( A2.error.message, `The field ${forbiddenFieldName} does not exist or you do not have access to that field` ); }); QUnit.test("don't fetch list data if no formula use it", async function (assert) { const spreadsheetData = { lists: { 1: { id: 1, columns: ["foo", "contact_name"], domain: [], model: "partner", orderBy: [], context: {}, }, }, }; const model = await createModelWithDataSource({ spreadsheetData, mockRPC: function (_, { model, method }) { if (!["partner", "ir.model"].includes(model)) { return; } assert.step(`${model}/${method}`); }, }); assert.verifySteps([]); setCellContent(model, "A1", `=ODOO.LIST("1", "1", "foo")`); /* * Ask a first time the value => It will trigger a loading of the data source. */ assert.equal(getCellValue(model, "A1"), "Loading..."); await nextTick(); assert.equal(getCellValue(model, "A1"), 12); assert.verifySteps(["partner/fields_get", "partner/search_read"]); }); QUnit.test("user context is combined with list context to fetch data", async function (assert) { const context = { allowed_company_ids: [15], tz: "bx", lang: "FR", uid: 4, }; const testSession = { uid: 4, user_companies: { allowed_companies: { 15: { id: 15, name: "Hermit" }, 16: { id: 16, name: "Craft" }, }, current_company: 15, }, user_context: context, }; const spreadsheetData = { sheets: [ { id: "sheet1", cells: { A1: { content: `=ODOO.LIST("1", "1", "name")` }, }, }, ], lists: { 1: { id: 1, columns: ["name", "contact_name"], domain: [], model: "partner", orderBy: [], context: { allowed_company_ids: [16], default_stage_id: 9, search_default_stage_id: 90, tz: "nz", lang: "EN", uid: 40, }, }, }, }; const expectedFetchContext = { allowed_company_ids: [15], default_stage_id: 9, search_default_stage_id: 90, tz: "bx", lang: "FR", uid: 4, }; patchWithCleanup(session, testSession); const model = await createModelWithDataSource({ spreadsheetData, mockRPC: function (route, { model, method, kwargs }) { if (model !== "partner") { return; } switch (method) { case "search_read": assert.step("search_read"); assert.deepEqual( kwargs.context, expectedFetchContext, "search_read context" ); break; } }, }); await waitForDataSourcesLoaded(model); assert.verifySteps(["search_read"]); }); QUnit.test("rename list with empty name is refused", async (assert) => { const { model } = await createSpreadsheetWithList(); const result = model.dispatch("RENAME_ODOO_LIST", { listId: "1", name: "", }); assert.deepEqual(result.reasons, [CommandResult.EmptyName]); }); QUnit.test("rename list with incorrect id is refused", async (assert) => { const { model } = await createSpreadsheetWithList(); const result = model.dispatch("RENAME_ODOO_LIST", { listId: "invalid", name: "name", }); assert.deepEqual(result.reasons, [CommandResult.ListIdNotFound]); }); QUnit.test("Undo/Redo for RENAME_ODOO_LIST", async function (assert) { assert.expect(4); const { model } = await createSpreadsheetWithList(); assert.equal(model.getters.getListName("1"), "List"); model.dispatch("RENAME_ODOO_LIST", { listId: "1", name: "test" }); assert.equal(model.getters.getListName("1"), "test"); model.dispatch("REQUEST_UNDO"); assert.equal(model.getters.getListName("1"), "List"); model.dispatch("REQUEST_REDO"); assert.equal(model.getters.getListName("1"), "test"); }); QUnit.test("Can delete list", async function (assert) { const { model } = await createSpreadsheetWithList(); model.dispatch("REMOVE_ODOO_LIST", { listId: "1" }); assert.strictEqual(model.getters.getListIds().length, 0); const B4 = getEvaluatedCell(model, "B4"); assert.equal(B4.error.message, `There is no list with id "1"`); assert.equal(B4.value, `#ERROR`); }); QUnit.test("Can undo/redo a delete list", async function (assert) { const { model } = await createSpreadsheetWithList(); const value = getEvaluatedCell(model, "B4").value; model.dispatch("REMOVE_ODOO_LIST", { listId: "1" }); model.dispatch("REQUEST_UNDO"); assert.strictEqual(model.getters.getListIds().length, 1); let B4 = getEvaluatedCell(model, "B4"); assert.equal(B4.error, undefined); assert.equal(B4.value, value); model.dispatch("REQUEST_REDO"); assert.strictEqual(model.getters.getListIds().length, 0); B4 = getEvaluatedCell(model, "B4"); assert.equal(B4.error.message, `There is no list with id "1"`); assert.equal(B4.value, `#ERROR`); }); QUnit.test("can edit list domain", async (assert) => { const { model } = await createSpreadsheetWithList(); const [listId] = model.getters.getListIds(); assert.deepEqual(model.getters.getListDefinition(listId).domain, []); assert.strictEqual(getCellValue(model, "B2"), "TRUE"); model.dispatch("UPDATE_ODOO_LIST_DOMAIN", { listId, domain: [["foo", "in", [55]]], }); assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]); await waitForDataSourcesLoaded(model); assert.strictEqual(getCellValue(model, "B2"), ""); model.dispatch("REQUEST_UNDO"); await waitForDataSourcesLoaded(model); assert.deepEqual(model.getters.getListDefinition(listId).domain, []); await waitForDataSourcesLoaded(model); assert.strictEqual(getCellValue(model, "B2"), "TRUE"); model.dispatch("REQUEST_REDO"); assert.deepEqual(model.getters.getListDefinition(listId).domain, [["foo", "in", [55]]]); await waitForDataSourcesLoaded(model); assert.strictEqual(getCellValue(model, "B2"), ""); }); QUnit.test("edited domain is exported", async (assert) => { const { model } = await createSpreadsheetWithList(); const [listId] = model.getters.getListIds(); model.dispatch("UPDATE_ODOO_LIST_DOMAIN", { listId, domain: [["foo", "in", [55]]], }); assert.deepEqual(model.exportData().lists["1"].domain, [["foo", "in", [55]]]); }); QUnit.test( "Cannot see record of a list in dashboard mode if wrong list formula", async function (assert) { const fakeActionService = { dependencies: [], start: (env) => ({ doAction: (params) => { assert.step(params.res_model); assert.step(params.res_id.toString()); }, }), }; registry.category("services").add("action", fakeActionService); const { model } = await createSpreadsheetWithList(); const sheetId = model.getters.getActiveSheetId(); model.dispatch("UPDATE_CELL", { col: 0, row: 1, sheetId, content: "=ODOO.LIST()", }); model.updateMode("dashboard"); selectCell(model, "A2"); assert.verifySteps([]); } ); QUnit.test("field matching is removed when filter is deleted", async function (assert) { const { model } = await createSpreadsheetWithList(); await addGlobalFilter( model, { id: "42", type: "relation", label: "test", defaultValue: [41], modelName: undefined, rangeType: undefined, }, { list: { 1: { chain: "product_id", type: "many2one" } }, } ); const [filter] = model.getters.getGlobalFilters(); const matching = { chain: "product_id", type: "many2one", }; assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), matching); assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), [ ["product_id", "in", [41]], ]); model.dispatch("REMOVE_GLOBAL_FILTER", { id: filter.id, }); assert.deepEqual( model.getters.getListFieldMatching("1", filter.id), undefined, "it should have removed the pivot and its fieldMatching and datasource altogether" ); assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), []); model.dispatch("REQUEST_UNDO"); assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), matching); assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), [ ["product_id", "in", [41]], ]); model.dispatch("REQUEST_REDO"); assert.deepEqual(model.getters.getListFieldMatching("1", filter.id), undefined); assert.deepEqual(model.getters.getListDataSource("1").getComputedDomain(), []); }); QUnit.test("Preload currency of monetary field", async function (assert) { assert.expect(3); await createSpreadsheetWithList({ columns: ["pognon"], mockRPC: async function (route, args, performRPC) { if (args.method === "search_read" && args.model === "partner") { assert.strictEqual(args.kwargs.fields.length, 2); assert.strictEqual(args.kwargs.fields[0], "pognon"); assert.strictEqual(args.kwargs.fields[1], "currency_id"); } }, }); }); QUnit.test( "List record limit is computed during the import and UPDATE_CELL", async function (assert) { const spreadsheetData = { sheets: [ { id: "sheet1", cells: { A1: { content: `=ODOO.LIST("1", "1", "foo")` }, }, }, ], lists: { 1: { id: 1, columns: ["foo", "contact_name"], domain: [], model: "partner", orderBy: [], context: {}, }, }, }; const model = await createModelWithDataSource({ spreadsheetData }); const ds = model.getters.getListDataSource("1"); assert.strictEqual(ds.maxPosition, 1); assert.strictEqual(ds.maxPositionFetched, 1); setCellContent(model, "A1", `=ODOO.LIST("1", "42", "foo")`); assert.strictEqual(ds.maxPosition, 42); assert.strictEqual(ds.maxPositionFetched, 1); await waitForDataSourcesLoaded(model); assert.strictEqual(ds.maxPosition, 42); assert.strictEqual(ds.maxPositionFetched, 42); } ); QUnit.test("can import (export) contextual domain", async function (assert) { const uid = session.user_context.uid; const spreadsheetData = { lists: { 1: { id: 1, columns: ["foo", "contact_name"], domain: '[("foo", "=", uid)]', model: "partner", orderBy: [], }, }, }; const model = await createModelWithDataSource({ spreadsheetData, mockRPC: function (route, args) { if (args.method === "search_read") { assert.deepEqual(args.kwargs.domain, [["foo", "=", uid]]); assert.step("search_read"); } }, }); setCellContent(model, "A1", '=ODOO.LIST("1", "1", "foo")'); await nextTick(); assert.strictEqual( model.exportData().lists[1].domain, '[("foo", "=", uid)]', "the domain is exported with the dynamic parts" ); assert.verifySteps(["search_read"]); }); QUnit.test( "Load list spreadsheet with models that cannot be accessed", async function (assert) { let hasAccessRights = true; const { model } = await createSpreadsheetWithList({ mockRPC: async function (route, args) { if ( args.model === "partner" && args.method === "search_read" && !hasAccessRights ) { throw makeServerError({ description: "ya done!" }); } }, }); let headerCell; let cell; await waitForDataSourcesLoaded(model); headerCell = getEvaluatedCell(model, "A3"); cell = getEvaluatedCell(model, "C3"); assert.equal(headerCell.value, 1); assert.equal(cell.value, 42669); hasAccessRights = false; model.dispatch("REFRESH_ODOO_LIST", { listId: "1" }); await waitForDataSourcesLoaded(model); headerCell = getEvaluatedCell(model, "A3"); cell = getEvaluatedCell(model, "C3"); assert.equal(headerCell.value, "#ERROR"); assert.equal(headerCell.error.message, "ya done!"); assert.equal(cell.value, "#ERROR"); assert.equal(cell.error.message, "ya done!"); } ); QUnit.test("Cells in the list header zone have borders", async function (assert) { const { model } = await createSpreadsheetWithList({ linesNumber: 4, }); const leftBorder = { left: { style: "thin", color: "#2D7E84" } }; const rightBorder = { right: { style: "thin", color: "#2D7E84" } }; const topBorder = { top: { style: "thin", color: "#2D7E84" } }; const bottomBorder = { bottom: { style: "thin", color: "#2D7E84" } }; assert.deepEqual(getBorders(model, "A1"), { ...topBorder, ...bottomBorder, ...leftBorder }); assert.deepEqual(getBorders(model, "B1"), { ...topBorder, ...bottomBorder }); assert.deepEqual(getBorders(model, "D1"), { ...topBorder, ...bottomBorder, ...rightBorder, }); assert.deepEqual(getBorders(model, "A5"), { ...leftBorder, ...bottomBorder }); assert.deepEqual(getBorders(model, "D5"), { ...rightBorder, ...bottomBorder }); }); });