import { Deferred } from "@web/core/utils/concurrency";
import { animationFrame } from "@odoo/hoot-mock";
import {
MockServer,
makeServerError,
patchTranslations,
serverState,
} from "@web/../tests/web_test_helpers";
import { describe, expect, test } from "@odoo/hoot";
import {
defineSpreadsheetActions,
defineSpreadsheetModels,
getBasicServerData,
} from "@spreadsheet/../tests/helpers/data";
import {
getCell,
getCellContent,
getCellFormula,
getCellValue,
getEvaluatedCell,
getFormattedValueGrid,
} from "@spreadsheet/../tests/helpers/getters";
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/helpers/pivot";
import { CommandResult } from "@spreadsheet/o_spreadsheet/cancelled_reason";
import {
addGlobalFilter,
setCellContent,
updatePivot,
updatePivotMeasureDisplay,
} from "@spreadsheet/../tests/helpers/commands";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
import { user } from "@web/core/user";
import { Model } from "@odoo/o-spreadsheet";
import { THIS_YEAR_GLOBAL_FILTER } from "@spreadsheet/../tests/helpers/global_filter";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { waitForDataLoaded } from "@spreadsheet/helpers/model";
const { toZone } = spreadsheet.helpers;
describe.current.tags("headless");
defineSpreadsheetModels();
defineSpreadsheetActions();
test("can get a pivotId from cell formula", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("can get a pivotId from cell formula with '-' before the formula", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=-PIVOT.VALUE("1","probability","bar","false","foo","2")`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("can get a pivotId from cell formula with other numerical values", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=3*PIVOT.VALUE("1","probability","bar","false","foo","2")+2`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("can get a pivotId from cell formula where pivot is in a function call", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=SUM(PIVOT.VALUE("1","probability","bar","false","foo","2"),PIVOT.VALUE("1","probability","bar","false","foo","2"))`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("can get a pivotId from cell formula where the id is a reference", async function () {
const { model } = await createSpreadsheetWithPivot();
setCellContent(model, "C3", `=PIVOT.VALUE(G10,"probability","bar","false","foo","2")+2`);
setCellContent(model, "G10", "1");
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("can get a Pivot from cell formula where the id is a reference in an inactive sheet", async function () {
const { model } = await createSpreadsheetWithPivot();
const firstSheetId = model.getters.getActiveSheetId();
model.dispatch("CREATE_SHEET", { sheetId: "2" });
model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: firstSheetId, sheetIdTo: "2" });
setCellContent(model, "A1", "1");
setCellContent(model, "A2", '=PIVOT.VALUE(A1,"probability")');
model.dispatch("ACTIVATE_SHEET", { sheetIdFrom: "2", sheetIdTo: firstSheetId });
const pivotId = model.getters.getPivotIdFromPosition({ sheetId: "2", col: 0, row: 1 });
expect(pivotId).toBe("PIVOT#1");
});
test("can get a pivotId from cell formula (Mix of test scenarios above)", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
`,
});
model.dispatch("SET_VALUE", {
xc: "C3",
text: `=3*SUM(PIVOT.VALUE("1","probability","bar","false","foo","2"),PIVOT.VALUE("1","probability","bar","false","foo","2"))+2*PIVOT.VALUE("1","probability","bar","false","foo","2")`,
});
const sheetId = model.getters.getActiveSheetId();
const pivotId = model.getters.getPivotIdFromPosition({ sheetId, col: 2, row: 2 });
expect(pivotId).toBe(model.getters.getPivotId("1"));
});
test("Can remove a pivot with undo after editing a cell", async function () {
const { model } = await createSpreadsheetWithPivot();
expect(getCellContent(model, "B1").startsWith("=PIVOT.HEADER")).toBe(true);
setCellContent(model, "G10", "should be undoable");
model.dispatch("REQUEST_UNDO");
expect(getCellContent(model, "G10")).toBe("");
// 2 REQUEST_UNDO because of the AUTORESIZE feature
model.dispatch("REQUEST_UNDO");
model.dispatch("REQUEST_UNDO");
expect(getCellContent(model, "B1")).toBe("");
expect(model.getters.getPivotIds().length).toBe(0);
});
test("rename pivot with empty name is refused", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
const result = model.dispatch("RENAME_PIVOT", {
pivotId,
name: "",
});
expect(result.reasons).toEqual([CommandResult.EmptyName]);
});
test("rename pivot with incorrect id is refused", async () => {
const { model } = await createSpreadsheetWithPivot();
const result = model.dispatch("RENAME_PIVOT", {
pivotId: "invalid",
name: "name",
});
expect(result.reasons).toEqual([CommandResult.PivotIdNotFound]);
});
test("Renaming a pivot does not retrigger RPCs", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
mockRPC: function (route, { model, method, kwargs }) {
switch (method) {
case "read_group":
expect.step("read_group");
break;
}
},
});
expect.verifySteps(["read_group", "read_group", "read_group", "read_group"]);
updatePivot(model, pivotId, { name: "name" });
await animationFrame();
expect.verifySteps([]);
});
test("Undo/Redo for RENAME_PIVOT", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot();
expect(model.getters.getPivotName(pivotId)).toBe("Partner Pivot");
model.dispatch("RENAME_PIVOT", { pivotId, name: "test" });
expect(model.getters.getPivotName(pivotId)).toBe("test");
model.dispatch("REQUEST_UNDO");
expect(model.getters.getPivotName(pivotId)).toBe("Partner Pivot");
model.dispatch("REQUEST_REDO");
expect(model.getters.getPivotName(pivotId)).toBe("test");
});
test("Can delete pivot", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot();
model.dispatch("REMOVE_PIVOT", { pivotId });
expect(model.getters.getPivotIds().length).toBe(0);
const B4 = getEvaluatedCell(model, "B4");
expect(B4.message).toBe(`There is no pivot with id "1"`);
expect(B4.value).toBe(`#ERROR`);
});
test("Can undo/redo a delete pivot", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot();
const value = getEvaluatedCell(model, "B4").value;
model.dispatch("REMOVE_PIVOT", { pivotId });
model.dispatch("REQUEST_UNDO");
await animationFrame();
expect(model.getters.getPivotIds().length).toBe(1);
let B4 = getEvaluatedCell(model, "B4");
expect(B4.value).toBe(value);
model.dispatch("REQUEST_REDO");
expect(model.getters.getPivotIds().length).toBe(0);
B4 = getEvaluatedCell(model, "B4");
expect(B4.message).toBe(`There is no pivot with id "1"`);
expect(B4.value).toBe(`#ERROR`);
});
test("Format header displays an error for non-existing field", async function () {
const { model } = await createSpreadsheetWithPivot();
setCellContent(model, "G10", `=PIVOT.HEADER("1", "measure", "non-existing")`);
setCellContent(model, "G11", `=PIVOT.HEADER("1", "non-existing", "bla")`);
await animationFrame();
expect(getCellValue(model, "G10")).toBe("#ERROR");
expect(getCellValue(model, "G11")).toBe("#ERROR");
expect(getEvaluatedCell(model, "G10").message).toBe("Field non-existing does not exist");
expect(getEvaluatedCell(model, "G11").message).toBe("Field non-existing does not exist");
});
test("invalid group dimensions", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
`,
});
const invalids = [
'=PIVOT.VALUE(1,"probability:avg", "product_id", 1, "bar", false, "foo", 1)', // inverted col dimensions
'=PIVOT.VALUE(1,"probability:avg", "product_id", 1, "bar", false, "f"&"oo", 1)', // inverted col dimensions, "foo" computed
'=PIVOT.VALUE(1,"probability:avg", "product_id", 1, "bar", false)', // missing first col dimension
'=PIVOT.VALUE(1,"probability:avg", "#product_id", 1, "#bar", 1, "#foo", 1)',
'=PIVOT.VALUE(1,"probability:avg", "bar", false, "foo", 1, "product_id", 1)', // columns before rows
'=PIVOT.HEADER(1, "product_id", 1, "bar", false, "foo", 1)', // inverted col dimensions
'=PIVOT.HEADER(1, "product_id", 1, "bar", false)', // missing first col dimension
'=PIVOT.HEADER(1, "#product_id", 1, "#bar", 1, "#foo", 1)',
'=PIVOT.HEADER(1, "bar", false, "foo", 1, "product_id", 47)', // columns before rows
];
for (const formula of invalids) {
setCellContent(model, "G10", formula);
expect(getCellValue(model, "G10")).toBe("#ERROR", { message: formula });
expect(getEvaluatedCell(model, "G10").message).toInclude(
"Dimensions don't match the pivot definition",
{ message: formula }
);
}
});
test("user context is combined with pivot context to fetch data", async function () {
serverState.companies = [
{ id: 15, name: "Hermit" },
{ id: 16, name: "Craft" },
];
serverState.timezone = "bx";
serverState.lang = "FR";
serverState.userContext.allowed_company_ids = [15];
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=PIVOT.VALUE(1, "probability")` },
},
},
],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ fieldName: "probability" }],
model: "partner",
rows: [{ fieldName: "bar" }],
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: serverState.userId,
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model !== "partner") {
return;
}
switch (method) {
case "read_group":
expect.step("read_group");
expect(kwargs.context).toEqual(expectedFetchContext, {
message: "read_group",
});
break;
}
},
});
await waitForDataLoaded(model);
expect.verifySteps(["read_group", "read_group", "read_group", "read_group"]);
});
test("Context is purged from PivotView related keys", async function (assert) {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=ODOO.PIVOT(1, "probability")` },
},
},
],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
rows: [{ fieldName: "bar" }],
domain: [],
measures: [{ fieldName: "probability" }],
model: "partner",
context: {
pivot_measures: ["__count"],
// inverse row and col group bys
pivot_row_groupby: ["test"],
pivot_column_groupby: ["check"],
dummyKey: "true",
},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "read_group") {
expect.step(`pop`);
const hasBadKeys = [
"pivot_measures",
"pivot_row_groupby",
"pivot_column_groupby",
].some((val) => val in (kwargs.context || {}));
expect(hasBadKeys).not.toBe(true);
}
},
});
await waitForDataLoaded(model);
expect.verifySteps(["pop", "pop", "pop", "pop"]);
});
test("fetch metadata only once per model", async function () {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=PIVOT.VALUE(1, "probability")` },
A2: { content: `=PIVOT.VALUE(2, "probability")` },
},
},
],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
context: {},
},
2: {
type: "ODOO",
columns: [{ fieldName: "bar" }],
domain: [],
measures: [{ field: "probability", operator: "max" }],
model: "partner",
rows: [{ fieldName: "foo" }],
context: {},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "fields_get") {
expect.step(`${model}/${method}`);
} else if (model === "ir.model" && method === "search_read") {
expect.step(`${model}/${method}`);
}
},
});
await waitForDataLoaded(model);
expect.verifySteps(["partner/fields_get"]);
});
test("An error is displayed if the pivot has invalid model", async function () {
const { model, env, pivotId } = await createSpreadsheetWithPivot({
mockRPC: async function (route, { model, method, kwargs }) {
if (model === "unknown" && method === "fields_get") {
throw makeServerError({ code: 404 });
}
},
});
const pivot = model.getters.getPivotCoreDefinition(pivotId);
env.model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...pivot,
model: "unknown",
},
});
setCellContent(model, "A1", `=PIVOT.VALUE("1", "probability:avg")`);
await animationFrame();
expect(getCellValue(model, "A1")).toBe("#ERROR");
expect(getEvaluatedCell(model, "A1").message).toBe(`The model "unknown" does not exist.`);
});
test("don't fetch pivot data if no formula use it", async function () {
const spreadsheetData = {
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (!["partner", "ir.model"].includes(model)) {
return;
}
expect.step(`${model}/${method}`);
},
});
expect.verifySteps([]);
setCellContent(model, "A1", `=PIVOT.VALUE("1", "probability:sum")`);
expect(getCellValue(model, "A1")).toBe("Loading...");
await animationFrame();
expect.verifySteps([
"partner/fields_get",
"partner/read_group",
"partner/read_group",
"partner/read_group",
"partner/read_group",
]);
expect(getCellValue(model, "A1")).toBe(131);
});
test("An error is displayed if the pivot has invalid field", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot();
const pivot = model.getters.getPivotCoreDefinition(pivotId);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...pivot,
columns: [{ fieldName: "unknown" }],
},
});
setCellContent(model, "A1", `=PIVOT.VALUE("1", "probability:avg")`);
await animationFrame();
expect(getCellValue(model, "A1")).toBe("#ERROR");
expect(getEvaluatedCell(model, "A1").message).toBe(`Field unknown does not exist`);
});
test("evaluates only once when two pivots are loading", async function () {
const spreadsheetData = {
sheets: [{ id: "sheet1" }],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
},
2: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
});
model.config.custom.odooDataProvider.addEventListener("data-source-updated", () =>
expect.step("data-source-notified")
);
setCellContent(model, "A1", '=PIVOT.VALUE("1", "probability:sum")');
setCellContent(model, "A2", '=PIVOT.VALUE("2", "probability:sum")');
expect(getCellValue(model, "A1")).toBe("Loading...");
expect(getCellValue(model, "A2")).toBe("Loading...");
await animationFrame();
expect(getCellValue(model, "A1")).toBe(131);
expect(getCellValue(model, "A2")).toBe(131);
// evaluation after both pivots are loaded
expect.verifySteps(["data-source-notified"]);
});
test("concurrently load the same pivot twice", async function () {
const spreadsheetData = {
sheets: [{ id: "sheet1" }],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
});
// the data loads first here, when we insert the first pivot function
setCellContent(model, "A1", '=PIVOT.VALUE("1", "probability:sum")');
expect(getCellValue(model, "A1")).toBe("Loading...");
// concurrently reload the same pivot
model.dispatch("REFRESH_ALL_DATA_SOURCES");
await animationFrame();
expect(getCellValue(model, "A1")).toBe(131);
});
test("display loading while data is not fully available", async function () {
const metadataPromise = new Deferred();
const dataPromise = new Deferred();
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: `=PIVOT.HEADER(1, "measure", "probability:sum")` },
A2: { content: `=PIVOT.HEADER(1, "product_id", 37)` },
A3: { content: `=PIVOT.VALUE(1, "probability:sum")` },
},
},
],
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "product_id" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [],
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: async function (route, args, performRPC) {
const { model, method, kwargs } = args;
const result = MockServer.current.callOrm(args);
if (model === "partner" && method === "fields_get") {
expect.step(`${model}/${method}`);
await metadataPromise;
}
if (
model === "partner" &&
method === "read_group" &&
kwargs.groupby[0] === "product_id"
) {
expect.step(`${model}/${method}`);
await dataPromise;
}
if (model === "product" && method === "read") {
expect(false).toBe(true, {
message: "should not be called because data is put in cache",
});
}
return result;
},
});
expect(getCellValue(model, "A1")).toBe("Loading...");
expect(getCellValue(model, "A2")).toBe("Loading...");
expect(getCellValue(model, "A3")).toBe("Loading...");
metadataPromise.resolve();
await animationFrame();
setCellContent(model, "A10", "1"); // trigger a new evaluation (might also be caused by other async formulas resolving)
expect(getCellValue(model, "A1")).toBe("Loading...");
expect(getCellValue(model, "A2")).toBe("Loading...");
expect(getCellValue(model, "A3")).toBe("Loading...");
dataPromise.resolve();
await animationFrame();
setCellContent(model, "A10", "2");
expect(getCellValue(model, "A1")).toBe("Probability");
expect(getCellValue(model, "A2")).toBe("xphone");
expect(getCellValue(model, "A3")).toBe(131);
expect.verifySteps(["partner/fields_get", "partner/read_group"]);
});
test("pivot grouped by char field which represents numbers", async function () {
const serverData = getBasicServerData();
serverData.models.partner.records = [
{ name: "111", probability: 11 },
{ name: "000111", probability: 15 },
{ name: "14.0", probability: 16 },
];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
`,
});
expect(getCell(model, "A3").content).toBe('=PIVOT.HEADER(1,"name","000111")');
expect(getCell(model, "A4").content).toBe('=PIVOT.HEADER(1,"name","111")');
expect(getCell(model, "A5").content).toBe('=PIVOT.HEADER(1,"name","14.0")');
expect(getEvaluatedCell(model, "A3").value).toBe("000111");
expect(getEvaluatedCell(model, "A4").value).toBe("111");
expect(getEvaluatedCell(model, "A5").value).toBe("14.0");
expect(getCell(model, "B3").content).toBe('=PIVOT.VALUE(1,"probability:avg","name","000111")');
expect(getCell(model, "B4").content).toBe('=PIVOT.VALUE(1,"probability:avg","name","111")');
expect(getCell(model, "B5").content).toBe('=PIVOT.VALUE(1,"probability:avg","name","14.0")');
expect(getEvaluatedCell(model, "B3").value).toBe(15);
expect(getEvaluatedCell(model, "B4").value).toBe(11);
expect(getEvaluatedCell(model, "B5").value).toBe(16);
});
test("relational PIVOT.HEADER with missing id", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
`,
});
const sheetId = model.getters.getActiveSheetId();
model.dispatch("UPDATE_CELL", {
col: 4,
row: 9,
content: `=PIVOT.HEADER("1", "product_id", "1111111")`,
sheetId,
});
await waitForDataLoaded(model);
expect(getEvaluatedCell(model, "E10").message).toBe(
"Unable to fetch the label of 1111111 of model product"
);
});
test("relational PIVOT.HEADER with undefined id", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
`,
});
setCellContent(model, "F10", `=PIVOT.HEADER("1", "product_id", A25)`);
expect(getCell(model, "A25")).toBe(undefined, { message: "the cell should be empty" });
await waitForDataLoaded(model);
const F10 = getEvaluatedCell(model, "F10");
expect(F10.value).toBe("#ERROR");
expect(F10.message).toBe("Unable to fetch the label of 0 of model product");
});
test("Verify pivot measures are correctly computed :)", async function () {
const { model } = await createSpreadsheetWithPivot();
expect(getCellValue(model, "B4")).toBe(11);
expect(getCellValue(model, "C3")).toBe(15);
expect(getCellValue(model, "D4")).toBe(10);
expect(getCellValue(model, "E4")).toBe(95);
});
test("__count measure", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /*xml*/ `
`,
});
setCellContent(model, "F10", '=PIVOT.VALUE(1, "__count")');
const F10 = getEvaluatedCell(model, "F10");
expect(F10.value).toBe(4);
expect(F10.format).toBe("0");
});
test("invalid pivot measure", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
const formula = '=PIVOT.VALUE(1, "count")';
setCellContent(model, "F10", formula);
expect(getCellValue(model, "F10")).toBe("#ERROR", { message: formula });
expect(getEvaluatedCell(model, "F10").message).toBe(
"The argument count is not a valid measure. Here are the measures: (probability:avg)",
{ message: formula }
);
});
test("aggregate to 0", async function () {
const serverData = getBasicServerData();
serverData.models.partner.records = [
{ id: 1, name: "A", probability: 10 },
{ id: 2, name: "B", probability: -10 },
];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
`,
});
setCellContent(model, "A1", '=PIVOT.VALUE(1, "probability:avg", "name", "A")');
setCellContent(model, "A2", '=PIVOT.VALUE(1, "probability:avg", "name", "B")');
setCellContent(model, "A3", '=PIVOT.VALUE(1, "probability:avg")');
expect(getEvaluatedCell(model, "A1").value).toBe(10);
expect(getEvaluatedCell(model, "A2").value).toBe(-10);
expect(getEvaluatedCell(model, "A3").value).toBe(0);
});
test("pivot formula for total should return empty string instead of 'FALSE' when pivot doesn't match any data", async function () {
const serverData = getBasicServerData();
serverData.models.partner.records = [{ id: 1, name: "A", probability: 10 }];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /*xml*/ `
`,
});
const [pivotId] = model.getters.getPivotIds();
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
domain: [["probability", "=", 100]],
},
});
await waitForDataLoaded(model);
setCellContent(model, "A1", '=PIVOT.VALUE(1, "probability:avg", "name", "A")');
setCellContent(model, "A2", '=PIVOT.VALUE(1, "probability:avg")');
expect(getEvaluatedCell(model, "A1").value).toBe("");
expect(getEvaluatedCell(model, "A2").value).toBe("");
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
domain: [],
},
});
await waitForDataLoaded(model);
expect(getEvaluatedCell(model, "A1").value).toBe(10);
expect(getEvaluatedCell(model, "A2").value).toBe(10);
});
test("can import/export sorted pivot", async () => {
const spreadsheetData = {
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "foo" }],
domain: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [{ fieldName: "bar" }],
sortedColumn: {
measure: "probability",
order: "asc",
groupId: [[], [1]],
},
name: "A pivot",
context: {},
fieldMatching: {},
formulaId: "1",
},
},
};
const model = await createModelWithDataSource({ spreadsheetData });
expect(model.getters.getPivotCoreDefinition(1).sortedColumn).toEqual({
measure: "probability",
order: "asc",
groupId: [[], [1]],
});
expect(model.exportData().pivots).toEqual(spreadsheetData.pivots);
});
test("can import (export) contextual domain", async () => {
const uid = user.userId;
const spreadsheetData = {
pivots: {
1: {
type: "ODOO",
columns: [],
domain: '[("foo", "=", uid)]',
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [],
name: "A pivot",
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, args) {
if (args.method === "read_group") {
expect(args.kwargs.domain).toEqual([["foo", "=", uid]]);
expect.step("read_group");
}
},
});
setCellContent(model, "A1", '=PIVOT.VALUE(1, "probability:sum")'); // load the data (and check the rpc domain)
await animationFrame();
expect(model.exportData().pivots[1].domain).toBe('[("foo", "=", uid)]', {
message: "the domain is exported with the dynamic parts",
});
expect.verifySteps(["read_group"]);
});
test("Adding a measure should trigger a reload", async () => {
const spreadsheetData = {
pivots: {
1: {
type: "ODOO",
columns: [],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [],
name: "A pivot",
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.fields);
expect.step("read_group");
}
},
});
setCellContent(model, "A1", '=PIVOT.VALUE(1, "probability:sum")');
await animationFrame();
expect.verifySteps([["probability_sum_id:sum(probability)"], "read_group"]);
updatePivot(model, 1, {
measures: [
{ id: "probability:sum", fieldName: "probability", aggregator: "sum" },
{ id: "probability:avg", fieldName: "probability", aggregator: "avg" },
],
});
await animationFrame();
expect.verifySteps([
["probability_sum_id:sum(probability)", "probability_avg_id:avg(probability)"],
"read_group",
]);
updatePivot(model, 1, {
measures: [
{ id: "probability:sum", fieldName: "probability", aggregator: "sum" },
{ id: "probability:avg", fieldName: "probability", aggregator: "avg" },
{ id: "__count", fieldName: "__count", aggregator: "sum" },
],
});
await animationFrame();
expect.verifySteps([
["probability_sum_id:sum(probability)", "probability_avg_id:avg(probability)", "__count"],
"read_group",
]);
});
test("Updating dimensions with undefined values does not trigger a new rpc", async () => {
const spreadsheetData = {
pivots: {
1: {
type: "ODOO",
columns: [{ fieldName: "date" }],
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
model: "partner",
rows: [],
name: "A pivot",
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, args) {
if (args.method === "read_group") {
expect.step("read_group");
}
},
});
setCellContent(model, "A1", '=PIVOT.VALUE(1, "probability:sum")');
await animationFrame();
expect.verifySteps(["read_group", "read_group"]);
updatePivot(model, 1, {
columns: [{ fieldName: "date", granularity: undefined, order: undefined }],
});
await animationFrame();
expect.verifySteps([]);
});
test("Can group by many2many field ", async () => {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getCellFormula(model, "A3")).toBe('=PIVOT.HEADER(1,"tag_ids",42)');
expect(getCellFormula(model, "A4")).toBe('=PIVOT.HEADER(1,"tag_ids",67)');
expect(getCellFormula(model, "A5")).toBe('=PIVOT.HEADER(1,"tag_ids",FALSE)');
expect(getCellFormula(model, "B3")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",42,"foo",1)'
);
expect(getCellFormula(model, "B4")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",67,"foo",1)'
);
expect(getCellFormula(model, "B5")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",FALSE,"foo",1)'
);
expect(getCellFormula(model, "C3")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",42,"foo",2)'
);
expect(getCellFormula(model, "C4")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",67,"foo",2)'
);
expect(getCellFormula(model, "C5")).toBe(
'=PIVOT.VALUE(1,"probability:avg","tag_ids",FALSE,"foo",2)'
);
expect(getCellValue(model, "A3")).toBe("isCool");
expect(getCellValue(model, "A4")).toBe("Growing");
expect(getCellValue(model, "A5")).toBe("None");
expect(getCellValue(model, "B3")).toBe(11);
expect(getCellValue(model, "B4")).toBe(11);
expect(getCellValue(model, "B5")).toBe("");
expect(getCellValue(model, "C3")).toBe(15);
expect(getCellValue(model, "C4")).toBe("");
expect(getCellValue(model, "C5")).toBe("");
});
test("PIVOT.HEADER grouped by date field without value", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
for (const granularity of ["day", "week", "month", "quarter"]) {
updatePivot(model, pivotId, {
columns: [{ fieldName: "date", granularity, order: "asc" }],
});
await animationFrame();
setCellContent(model, "A1", `=PIVOT.HEADER(1, "date:${granularity}", "false")`);
expect(getCellValue(model, "A1")).toBe("None");
}
});
test("PIVOT functions can accept spreadsheet dates", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
setCellContent(model, "A1", '=PIVOT.HEADER(1, "date:quarter",DATE(2016, 4, 1))');
expect(getCellValue(model, "A1")).toBe("Q2 2016");
setCellContent(
model,
"A1",
'=PIVOT.VALUE(1, "probability:avg", "date:quarter",DATE(2016, 4, 1))'
);
expect(getCellValue(model, "A1")).toBe(10);
// not the first day of the quarter
setCellContent(
model,
"A1",
'=PIVOT.VALUE(1, "probability:avg", "date:quarter",DATE(2016, 4, 2))'
);
expect(getCellValue(model, "A1")).toBe(10);
});
test("PIVOT formulas are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B3").format).toBe("0");
expect(getEvaluatedCell(model, "C3").format).toBe("#,##0.00");
});
test("PIVOT formulas with monetary measure are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00[$€]");
});
test("PIVOT day_of_month are correctly formatted at evaluation", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
updatePivot(model, pivotId, {
columns: [{ fieldName: "date", granularity: "day_of_month", order: "asc" }],
});
await animationFrame();
setCellContent(model, "B1", `=PIVOT.HEADER(1, "date:day_of_month", 1)`);
setCellContent(model, "B2", `=PIVOT.VALUE(1, "probability:avg", "date:day_of_month", 11)`);
expect(getEvaluatedCell(model, "B1").format).toBe("0");
expect(getEvaluatedCell(model, "B1").value).toBe(1);
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("1");
expect(getEvaluatedCell(model, "B2").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B2").value).toBe(15);
expect(getEvaluatedCell(model, "B2").formattedValue).toBe("15.00");
});
test("PIVOT day are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B1").format).toBe("dd mmm yyyy");
expect(getEvaluatedCell(model, "B1").value).toBe(42474);
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("14 Apr 2016");
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B3").value).toBe(10);
expect(getEvaluatedCell(model, "B3").formattedValue).toBe("10.00");
});
test("PIVOT iso_week_number are correctly formatted at evaluation", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
updatePivot(model, pivotId, {
columns: [{ fieldName: "date", granularity: "iso_week_number", order: "asc" }],
});
await animationFrame();
setCellContent(model, "B1", `=PIVOT.HEADER(1, "date:iso_week_number", 1)`);
setCellContent(model, "B2", `=PIVOT.VALUE(1, "probability:avg", "date:iso_week_number", 15)`);
expect(getEvaluatedCell(model, "B1").format).toBe("0");
expect(getEvaluatedCell(model, "B1").value).toBe(1);
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("1");
expect(getEvaluatedCell(model, "B2").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B2").value).toBe(10);
expect(getEvaluatedCell(model, "B2").formattedValue).toBe("10.00");
});
test("PIVOT week are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B1").format).toBe(undefined);
expect(getEvaluatedCell(model, "B1").value).toBe("W15 2016");
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("W15 2016");
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B3").value).toBe(10);
expect(getEvaluatedCell(model, "B3").formattedValue).toBe("10.00");
});
test("PIVOT month_number are correctly formatted at evaluation", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
updatePivot(model, pivotId, {
columns: [{ fieldName: "date", granularity: "month_number", order: "asc" }],
});
await animationFrame();
setCellContent(model, "B1", `=PIVOT.HEADER(1, "date:month_number", 1)`);
setCellContent(model, "B2", `=PIVOT.VALUE(1, "probability:avg", "date:month_number", 4)`);
expect(getEvaluatedCell(model, "B1").format).toBe("@");
expect(getEvaluatedCell(model, "B1").value).toBe("January");
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("January");
expect(getEvaluatedCell(model, "B2").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B2").value).toBe(10);
expect(getEvaluatedCell(model, "B2").formattedValue).toBe("10.00");
});
test("PIVOT month are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B1").format).toBe("mmmm yyyy");
expect(getEvaluatedCell(model, "B1").value).toBe(42461);
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("April 2016");
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B3").value).toBe(10);
expect(getEvaluatedCell(model, "B3").formattedValue).toBe("10.00");
});
test("PIVOT quarter_number are correctly formatted at evaluation", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
updatePivot(model, pivotId, {
columns: [{ fieldName: "date", granularity: "quarter_number", order: "asc" }],
});
await animationFrame();
setCellContent(model, "B1", `=PIVOT.HEADER(1, "date:quarter_number", 1)`);
setCellContent(model, "B2", `=PIVOT.VALUE(1, "probability:avg", "date:quarter_number", 2)`);
expect(getEvaluatedCell(model, "B1").format).toBe("@");
expect(getEvaluatedCell(model, "B1").value).toBe("Q1");
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("Q1");
expect(getEvaluatedCell(model, "B2").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B2").value).toBe(10);
expect(getEvaluatedCell(model, "B2").formattedValue).toBe("10.00");
});
test("PIVOT quarter are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B1").format).toBe(undefined);
expect(getEvaluatedCell(model, "B1").value).toBe("Q2 2016");
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("Q2 2016");
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B3").value).toBe(10);
expect(getEvaluatedCell(model, "B3").formattedValue).toBe("10.00");
});
test("PIVOT year are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "B1").format).toBe("0");
expect(getEvaluatedCell(model, "B1").value).toBe(2016);
expect(getEvaluatedCell(model, "B1").formattedValue).toBe("2016");
expect(getEvaluatedCell(model, "B3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B3").value).toBe(131);
expect(getEvaluatedCell(model, "B3").formattedValue).toBe("131.00");
});
test("PIVOT.HEADER formulas are correctly formatted at evaluation", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
expect(getEvaluatedCell(model, "A3").format).toBe("#,##0.00");
expect(getEvaluatedCell(model, "B1").format).toBe("dd mmm yyyy");
expect(getEvaluatedCell(model, "B2").format).toBe(undefined);
});
test("can edit pivot domain with UPDATE_ODOO_PIVOT_DOMAIN", async () => {
const { model } = await createSpreadsheetWithPivot();
const [pivotId] = model.getters.getPivotIds();
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([]);
expect(getCellValue(model, "B4")).toBe(11);
model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", {
pivotId,
domain: [["foo", "in", [55]]],
});
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([["foo", "in", [55]]]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe("");
model.dispatch("REQUEST_UNDO");
await waitForDataLoaded(model);
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe(11);
model.dispatch("REQUEST_REDO");
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([["foo", "in", [55]]]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe("");
});
test("can edit pivot domain with UPDATE_PIVOT", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([]);
expect(getCellValue(model, "B4")).toBe(11);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
domain: [["foo", "in", [55]]],
},
});
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([["foo", "in", [55]]]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe("");
model.dispatch("REQUEST_UNDO");
await waitForDataLoaded(model);
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe(11);
model.dispatch("REQUEST_REDO");
expect(model.getters.getPivotCoreDefinition(pivotId).domain).toEqual([["foo", "in", [55]]]);
await waitForDataLoaded(model);
expect(getCellValue(model, "B4")).toBe("");
});
test("updating a pivot without changing anything rejects the command", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
const result = model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
},
});
expect(result.isSuccessful).toBe(false);
});
test("edited domain is exported", async () => {
const { model } = await createSpreadsheetWithPivot();
const [pivotId] = model.getters.getPivotIds();
model.dispatch("UPDATE_ODOO_PIVOT_DOMAIN", {
pivotId,
domain: [["foo", "in", [55]]],
});
expect(model.exportData().pivots[pivotId].domain).toEqual([["foo", "in", [55]]]);
});
test("can edit pivot groups", async () => {
const { model } = await createSpreadsheetWithPivot();
const [pivotId] = model.getters.getPivotIds();
let definition = model.getters.getPivotCoreDefinition(pivotId);
expect(definition.columns).toEqual([{ fieldName: "foo" }]);
expect(definition.rows).toEqual([{ fieldName: "bar" }]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
columns: [],
rows: [],
},
});
definition = model.getters.getPivotCoreDefinition(pivotId);
expect(definition.columns).toEqual([]);
expect(definition.rows).toEqual([]);
model.dispatch("REQUEST_UNDO");
definition = model.getters.getPivotCoreDefinition(pivotId);
expect(definition.columns).toEqual([{ fieldName: "foo" }]);
expect(definition.rows).toEqual([{ fieldName: "bar" }]);
});
test("field matching is removed when filter is deleted", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot();
await addGlobalFilter(
model,
{
id: "42",
type: "relation",
label: "test",
defaultValue: [41],
modelName: undefined,
rangeType: undefined,
},
{
pivot: { [pivotId]: { chain: "product_id", type: "many2one" } },
}
);
const [filter] = model.getters.getGlobalFilters();
const matching = {
chain: "product_id",
type: "many2one",
};
expect(model.getters.getPivotFieldMatching(pivotId, filter.id)).toEqual(matching);
expect(model.getters.getPivot(pivotId).getDomainWithGlobalFilters()).toEqual([
["product_id", "in", [41]],
]);
model.dispatch("REMOVE_GLOBAL_FILTER", {
id: filter.id,
});
expect(model.getters.getPivotFieldMatching(pivotId, filter.id)).toBe(undefined, {
message: "it should have removed the pivot and its fieldMatching and datasource altogether",
});
expect(model.getters.getPivot(pivotId).getDomainWithGlobalFilters()).toEqual([]);
model.dispatch("REQUEST_UNDO");
expect(model.getters.getPivotFieldMatching(pivotId, filter.id)).toEqual(matching);
expect(model.getters.getPivot(pivotId).getDomainWithGlobalFilters()).toEqual([
["product_id", "in", [41]],
]);
model.dispatch("REQUEST_REDO");
expect(model.getters.getPivotFieldMatching(pivotId, filter.id)).toBe(undefined);
expect(model.getters.getPivot(pivotId).getDomainWithGlobalFilters()).toEqual([]);
});
test("Load pivot spreadsheet with models that cannot be accessed", async function () {
let hasAccessRights = true;
const { model } = await createSpreadsheetWithPivot({
mockRPC: async function (route, args) {
if (args.model === "partner" && args.method === "read_group" && !hasAccessRights) {
throw makeServerError({ description: "ya done!" });
}
},
});
let headerCell;
let cell;
await waitForDataLoaded(model);
headerCell = getEvaluatedCell(model, "A3");
cell = getEvaluatedCell(model, "C3");
expect(headerCell.value).toBe("No");
expect(cell.value).toBe(15);
hasAccessRights = false;
model.dispatch("REFRESH_ALL_DATA_SOURCES");
await waitForDataLoaded(model);
headerCell = getEvaluatedCell(model, "A3");
cell = getEvaluatedCell(model, "C3");
expect(headerCell.value).toBe("#ERROR");
expect(headerCell.message).toBe("ya done!");
expect(cell.value).toBe("#ERROR");
expect(cell.message).toBe("ya done!");
});
test("can add a calculated measure", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, { model, method, kwargs }) {
if (model === "partner" && method === "read_group") {
expect.step("read_group");
expect(kwargs.fields).toEqual(["probability_avg_id:avg(probability)"]);
}
},
});
const sheetId = model.getters.getActiveSheetId();
expect.verifySteps(["read_group"]);
updatePivot(model, pivotId, {
measures: [
{ id: "probability", fieldName: "probability", aggregator: "avg" },
{
id: "probability*2",
fieldName: "probability*2",
aggregator: "avg",
computedBy: { sheetId, formula: "=probability*2" },
},
],
});
await waitForDataLoaded(model);
setCellContent(model, "A1", '=PIVOT.VALUE(1,"probability")');
setCellContent(model, "A2", '=PIVOT.VALUE(1,"probability*2")');
expect(getEvaluatedCell(model, "A1").value).toBe(131);
expect(getEvaluatedCell(model, "A2").value).toBe(262);
expect.verifySteps(["read_group"]);
});
test("can aggregate a calculated measure grouped by relational field", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
const sheetId = model.getters.getActiveSheetId();
updatePivot(model, pivotId, {
measures: [
{
id: "calculated",
fieldName: "calculated",
aggregator: "sum",
computedBy: { sheetId, formula: "=10" },
},
],
});
await waitForDataLoaded(model);
setCellContent(model, "A1", '=PIVOT.VALUE(1,"calculated", "product_id", 41)');
setCellContent(model, "A2", '=PIVOT.VALUE(1,"calculated")');
expect(getEvaluatedCell(model, "A1").value).toBe(30);
expect(getEvaluatedCell(model, "A2").value).toBe(40);
});
test("calculated measure is recomputed when dependency changes", async function () {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
const sheetId = model.getters.getActiveSheetId();
updatePivot(model, pivotId, {
measures: [
{
id: "computed",
fieldName: "computed",
computedBy: { sheetId, formula: "=A10*2" },
aggregator: "sum",
},
],
});
await waitForDataLoaded(model);
setCellContent(model, "A1", '=PIVOT.VALUE(1,"computed")');
expect(getEvaluatedCell(model, "A1").value).toBe(0);
setCellContent(model, "A10", "5");
expect(getEvaluatedCell(model, "A1").value).toBe(10);
});
test("can import a pivot with a calculated field", async function () {
const spreadsheetData = {
sheets: [
{
id: "sheet1",
cells: {
A1: { content: '=PIVOT.VALUE(1,"probability")' },
A2: { content: '=PIVOT.VALUE(1,"probability*2")' },
},
},
],
pivots: {
1: {
type: "ODOO",
columns: [],
domain: [],
measures: [
{ id: "probability", fieldName: "probability", aggregator: "avg" },
{
id: "probability*2",
fieldName: "probability*2",
computedBy: { sheetId: "sheet1", formula: "=probability*2" },
},
],
model: "partner",
rows: [],
context: {},
},
},
};
const model = await createModelWithDataSource({
spreadsheetData,
mockRPC: function (route, { model, method, kwargs }) {
if (model === "partner" && method === "read_group") {
expect.step("read_group");
expect(kwargs.fields).toEqual(["probability_avg_id:avg(probability)"]);
}
},
});
await waitForDataLoaded(model);
expect(getEvaluatedCell(model, "A1").value).toBe(131);
expect(getEvaluatedCell(model, "A2").value).toBe(262);
expect.verifySteps(["read_group"]);
});
test("Can duplicate a pivot", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
const matching = { chain: "product_id", type: "many2one" };
const filter = { ...THIS_YEAR_GLOBAL_FILTER, id: "42" };
await addGlobalFilter(model, filter, {
pivot: { [pivotId]: matching },
});
model.dispatch("DUPLICATE_PIVOT", { pivotId, newPivotId: "2" });
const pivotIds = model.getters.getPivotIds();
expect(model.getters.getPivotIds().length).toBe(2);
expect(model.getters.getPivotCoreDefinition(pivotIds[1])).toBe(
model.getters.getPivotCoreDefinition(pivotId)
);
expect(model.getters.getPivotFieldMatching(pivotId, "42")).toEqual(matching);
expect(model.getters.getPivotFieldMatching("2", "42")).toEqual(matching);
});
test("Duplicate pivot respects the formula id increment", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
model.dispatch("DUPLICATE_PIVOT", { pivotId, newPivotId: "second" });
model.dispatch("DUPLICATE_PIVOT", { pivotId, newPivotId: "third" });
expect(model.getters.getPivotFormulaId("second")).toBe("2");
expect(model.getters.getPivotFormulaId("third")).toBe("3");
});
test("Cannot duplicate unknown pivot", async () => {
const model = new Model();
const result = model.dispatch("DUPLICATE_PIVOT", {
pivotId: "hello",
newPivotId: "new",
});
expect(result.reasons).toEqual([CommandResult.PivotIdNotFound]);
});
test("Spreadsheet pivot table ignored by global fiter plugin", () => {
patchTranslations();
const model = new Model();
model.selection.selectZone({ cell: { col: 0, row: 0 }, zone: toZone("A1:A4") });
const pivotId = "pivot1";
const sheetId = model.getters.getActiveSheetId();
model.dispatch("INSERT_NEW_PIVOT", { pivotId, sheetId });
model.dispatch("DUPLICATE_PIVOT", {
pivotId,
newPivotId: "new",
});
const pivotIds = model.getters.getPivotIds();
const pivotDef = model.getters.getPivotCoreDefinition(pivotId);
const dupPivotDef = model.getters.getPivotCoreDefinition(pivotIds[1]);
expect(dupPivotDef).toEqual({ ...pivotDef, name: pivotDef.name + " (copy)" });
});
test("isPivotUnused getter", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot();
const sheetId = model.getters.getActiveSheetId();
expect(model.getters.isPivotUnused(pivotId)).toBe(false);
model.dispatch("CREATE_SHEET", { sheetId: "2" });
model.dispatch("DELETE_SHEET", { sheetId: sheetId });
expect(model.getters.isPivotUnused(pivotId)).toBe(true);
setCellContent(model, "A1", "=PIVOT.HEADER(1)");
expect(model.getters.isPivotUnused(pivotId)).toBe(false);
setCellContent(model, "A1", "=PIVOT.HEADER(A2)");
expect(model.getters.isPivotUnused(pivotId)).toBe(true);
setCellContent(model, "A2", "1");
expect(model.getters.isPivotUnused(pivotId)).toBe(false);
model.dispatch("REQUEST_UNDO", {});
expect(model.getters.isPivotUnused(pivotId)).toBe(true);
setCellContent(model, "A1", "=PIVOT(1)");
expect(model.getters.isPivotUnused(pivotId)).toBe(false);
});
test("Data are fetched with the correct aggregator", async () => {
await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect(args.kwargs.fields).toEqual(["probability_avg_id:avg(probability)"]);
expect.step("read_group");
}
},
});
expect.verifySteps(["read_group"]);
});
test("changing measure aggregates", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.fields.join());
}
},
});
expect.verifySteps(["probability_avg_id:avg(probability)"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [{ id: "probability:sum", fieldName: "probability", aggregator: "sum" }],
},
});
await animationFrame();
expect.verifySteps(["probability_sum_id:sum(probability)"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [{ fieldName: "foo", aggregator: "sum" }],
},
});
await animationFrame();
expect.verifySteps(["foo_sum_id:sum(foo)"]);
});
test("Manipulating a computed measure does not trigger a RPC", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.fields.join());
}
},
});
const sheetId = model.getters.getActiveSheetId();
expect.verifySteps(["probability_avg_id:avg(probability)"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [
{ id: "probability:avg", fieldName: "probability", aggregator: "avg" },
{
id: "probability*2",
fieldName: "probability*2",
aggregator: "avg",
computedBy: { sheetId, formula: "=probability*2" },
},
],
},
});
await animationFrame();
expect.verifySteps([]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [{ id: "probability:avg", fieldName: "probability", aggregator: "avg" }],
},
});
await animationFrame();
expect.verifySteps([]);
});
test("many2one measures are aggregated with count_distinct by default", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.fields.join());
}
},
});
expect.verifySteps(["probability_avg_id:avg(probability)"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [{ id: "product_id", fieldName: "product_id" }], // no aggregator specified
},
});
setCellContent(model, "A1", '=PIVOT.VALUE(1, "product_id")');
await animationFrame();
expect(getEvaluatedCell(model, "A1").value).toBe(2);
expect.verifySteps(["product_id:count_distinct"]);
});
test("changing measure aggregates changes the format", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
setCellContent(model, "G1", "=PIVOT(1)");
expect(getEvaluatedCell(model, "H3").format).toBe("#,##0.00");
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
measures: [
{
id: "probability:count_distinct",
fieldName: "probability",
aggregator: "count_distinct",
},
],
},
});
await animationFrame();
expect(getEvaluatedCell(model, "H3").format).toBe("0");
});
test("changing order of group by", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.orderby || "NO_ORDER");
}
},
});
expect.verifySteps(["NO_ORDER", "NO_ORDER"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
columns: [{ fieldName: "foo", order: "asc" }],
},
});
expect(model.getters.getPivotCoreDefinition(pivotId).columns).toEqual([
{ fieldName: "foo", order: "asc" },
]);
await animationFrame();
expect.verifySteps(["NO_ORDER", "foo asc"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
columns: [{ fieldName: "foo" }],
},
});
await animationFrame();
expect.verifySteps(["NO_ORDER", "NO_ORDER"]);
});
test("change date order", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
expect.step(args.kwargs.orderby || "NO_ORDER");
}
},
});
expect.verifySteps(["NO_ORDER"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
columns: [
{ fieldName: "date", granularity: "year", order: "asc" },
{ fieldName: "date", granularity: "month", order: "desc" },
],
},
});
await animationFrame();
expect.verifySteps(["NO_ORDER", "date:year asc", "date:year asc,date:month desc"]);
});
test("duplicated dimension on col and row with different granularity", async () => {
const serverData = getBasicServerData();
serverData.models.partner.records = [{ id: 1, date: "2024-03-30", probability: 11 }];
const { model } = await createSpreadsheetWithPivot({
serverData,
arch: /* xml */ `
`,
});
setCellContent(
model,
"A1",
'=PIVOT.VALUE(1,"probability:avg","date:month","3/2024","date:year",2024)'
);
setCellContent(model, "A2", '=PIVOT.VALUE(1,"probability:avg","#date:month",1,"#date:year",1)'); // positional
expect(getEvaluatedCell(model, "A1").value).toBe(11);
expect(getEvaluatedCell(model, "A2").value).toBe(11);
});
test("changing granularity of group by", async () => {
const { model, pivotId } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
mockRPC: async function (route, args) {
if (args.method === "read_group") {
const groupBys = args.kwargs.groupby;
if (groupBys.length) {
expect.step(args.kwargs.groupby.join(","));
}
}
},
});
expect.verifySteps(["date:month"]);
model.dispatch("UPDATE_PIVOT", {
pivotId,
pivot: {
...model.getters.getPivotCoreDefinition(pivotId),
columns: [{ fieldName: "date", granularity: "day" }],
},
});
expect(model.getters.getPivotCoreDefinition(pivotId).columns).toEqual([
{ fieldName: "date", granularity: "day" },
]);
await animationFrame();
expect.verifySteps(["date:day"]);
});
test("pivot.getPossibleFieldValues does not ignore falsy values", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
const pivot = model.getters.getPivot(model.getters.getPivotIds()[0]);
const barField = pivot.definition.rows[0];
expect(pivot.getPossibleFieldValues(barField)).toEqual([
{ value: false, label: "No" },
{ value: true, label: "Yes" },
]);
});
test("Can change display type of a measure", async function () {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
`,
});
// prettier-ignore
expect(getFormattedValueGrid(model, "A1:D5")).toEqual({
A1: "", B1: "xphone", C1: "xpad", D1: "Total",
A2: "", B2: "Probability", C2: "Probability", D2: "Probability",
A3: "No", B3: "", C3: "15.00", D3: "15.00",
A4: "Yes", B4: "10.00", C4: "106.00", D4: "116.00",
A5: "Total", B5: "10.00", C5: "121.00", D5: "131.00",
});
const pivotId = model.getters.getPivotIds()[0];
updatePivotMeasureDisplay(model, pivotId, "probability:avg", { type: "%_of_grand_total" });
await waitForDataLoaded(model);
// prettier-ignore
expect(getFormattedValueGrid(model, "A1:D5")).toEqual({
A1: "", B1: "xphone", C1: "xpad", D1: "Total",
A2: "", B2: "Probability", C2: "Probability", D2: "Probability",
A3: "No", B3: "0.00%", C3: "11.45%", D3: "11.45%",
A4: "Yes", B4: "7.63%", C4: "80.92%", D4: "88.55%",
A5: "Total", B5: "7.63%", C5: "92.37%", D5: "100.00%",
});
updatePivotMeasureDisplay(model, pivotId, "probability:avg", {
type: "%_of",
fieldNameWithGranularity: "bar",
value: "(previous)",
});
await waitForDataLoaded(model);
// prettier-ignore
expect(getFormattedValueGrid(model, "A1:D5")).toEqual({
A1: "", B1: "xphone", C1: "xpad", D1: "Total",
A2: "", B2: "Probability", C2: "Probability", D2: "Probability",
A3: "No", B3: "", C3: "100.00%", D3: "100.00%",
A4: "Yes", B4: "", C4: "706.67%", D4: "773.33%",
A5: "Total", B5: "", C5: "", D5: "",
});
});