[IMP] spreadsheet: add ODOO.PIVOT.TABLE function

This commit introduces a new pivot function:

ODOO.PIVOT.TABLE(<pivot_id>, [row_count], [include_total], [include_column_headers])

Purpose
-------
It answers several current limitations:
- The pivot in spreadsheet not being "dynamic" (growing as new groups/records
  are created) is a *very* frequent feedback

- sorted pivots are very cumbersome (# syntax) and takes a lot of cells

- cannot easily compute arbitrary aggregates (max, avg). You only get the
  total. ... as soon as new groups appears that are not reflected in the
  spreadsheet, you can't guess data is missing and that it's not taken into
  account.

Specification
-------------
Add a single and simple function =ODOO.PIVOT.TABLE(<pivot_id>) that would
display the entire table (dynamic, as more groups and records are added).

We can achieve almost everything we want:
- by combining with other functions (INDEX, CHOOSECOLS, CHOOSEROWS, XLOOKUP,
  ...) if we want to target a specific row or column.

- by using infinite ranges (e.g. =MAX(B2:B)) to account for a growing table

- we could remove all the positional PIVOT version of the function
  (=ODOO.PIVOT.HEADER(7,"#country_id",1)). The implementation is a mess
  (lots of ifs everywhere to account for the #). Just replace it by a single
  function SORT(ODOO.PIVOT.TABLE(1), 1) (or with NSORT, or sorted from the
  pivot view directly)

For some situations though, there would be the "Totals" in the way:
if you want =MAX(ODOO.PIVOT.TABLE(1)), you don't want to have totals in the
way (with infinite ranges). We add an optional parameter 'include_total'
(default to true)

For dashboards, we want to have custom header names, we don't want to use
the headers from the pivot. To only have data and not the headers:
new parameter 'include_column_headers' (default to true)

All in all: here is what it looks like:

ODOO.PIVOT.TABLE(<pivot_id>, [row_count], [include_total], [include_column_headers])

Task: 3318865
Part-of: odoo/odoo#138594
Co-authored-by: Lucas Lefèvre <lul@odoo.com>
This commit is contained in:
Adrien Minne
2023-10-20 14:59:19 +00:00
committed by Lucas Lefèvre (lul)
co-authored by Lucas Lefèvre
parent 824324dd9a
commit 442e8a23ae
5 changed files with 335 additions and 10 deletions
@@ -4,9 +4,14 @@ import { _t } from "@web/core/l10n/translation";
import { sprintf } from "@web/core/utils/strings";
import * as spreadsheet from "@odoo/o-spreadsheet";
const { arg, toString } = spreadsheet.helpers;
import { range } from "@web/core/utils/numbers";
const { arg, toBoolean, toString, toNumber } = spreadsheet.helpers;
const { functionRegistry } = spreadsheet.registries;
/**
* @typedef {import("@spreadsheet/pivot/pivot_table.js").SpreadsheetPivotTable} SpreadsheetPivotTable
*/
//--------------------------------------------------------------------------
// Spreadsheet functions
//--------------------------------------------------------------------------
@@ -155,8 +160,80 @@ const ODOO_PIVOT_POSITION = {
hidden: true,
};
const ODOO_PIVOT_TABLE = {
description: _t("Get a pivot table."),
args: [
arg("pivot_id (string)", _t("ID of the pivot.")),
arg("row_count (number, optional, default=10000)", _t("number of rows")),
arg(
"include_total (boolean, default=TRUE)",
_t("Whether to include total/sub-totals or not.")
),
arg(
"include_column_titles (boolean, default=TRUE)",
_t("Whether to include the column titles or not.")
),
],
computeValueAndFormat: function (
pivotId,
rowCount = { value: 10000 },
includeTotal = { value: true },
includeColumnHeaders = { value: true }
) {
const _pivotId = toString(pivotId.value);
assertPivotsExists(_pivotId, this.getters);
/** @type {SpreadsheetPivotTable} */
const table = this.getters.getPivotTableStructure(_pivotId);
const _includeColumnHeaders = toBoolean(includeColumnHeaders.value);
const cells = table.getPivotCells(toBoolean(includeTotal.value), _includeColumnHeaders);
const headerRows = _includeColumnHeaders ? table.getNumberOfHeaderRows() : 0;
const pivotTitle = _t("Pivot #%s", _pivotId);
const _rowCount = toNumber(rowCount.value);
if (_rowCount < 0) {
throw new Error(_t("The number of rows must be positive."));
}
const end = Math.min(headerRows + _rowCount, cells[0].length);
if (end === 0) {
return [[{ value: pivotTitle }]];
}
const tableWidth = cells.length;
const tableRows = range(0, end);
const result = [];
for (const col of range(0, tableWidth)) {
result[col] = [];
for (const row of tableRows) {
const pivotCell = cells[col][row];
result[col].push(getPivotCellValueAndFormat.call(this, _pivotId, pivotCell));
}
}
if (_includeColumnHeaders) {
result[0][0] = { value: pivotTitle };
}
return result;
},
category: "Odoo",
returns: ["RANGE<ANY>"],
};
function getPivotCellValueAndFormat(pivotId, pivotCell) {
if (!pivotCell.domain) {
return { value: "", format: undefined };
} else {
const domain = pivotCell.domain;
const measure = pivotCell.measure;
const fn = pivotCell.isHeader ? ODOO_PIVOT_HEADER : ODOO_PIVOT;
const args = pivotCell.isHeader ? [pivotId, ...domain] : [pivotId, measure, ...domain];
return {
value: fn.compute.call(this, ...args),
format: fn.computeFormat.call(this, ...args.map((a) => ({ value: a }))),
};
}
}
functionRegistry
.add("ODOO.FILTER.VALUE", ODOO_FILTER_VALUE)
.add("ODOO.PIVOT", ODOO_PIVOT)
.add("ODOO.PIVOT.HEADER", ODOO_PIVOT_HEADER)
.add("ODOO.PIVOT.POSITION", ODOO_PIVOT_POSITION);
.add("ODOO.PIVOT.POSITION", ODOO_PIVOT_POSITION)
.add("ODOO.PIVOT.TABLE", ODOO_PIVOT_TABLE);
@@ -91,7 +91,8 @@ export class SpreadsheetPivotTable {
this._rows = rows;
this._measures = measures;
this._rowTitle = rowTitle;
this.pivotCells = this._getPivotCells();
this._maxIndent = Math.max(...this._rows.map((row) => row.indent));
this.pivotCells = this.getPivotCells();
}
/**
@@ -188,24 +189,34 @@ export class SpreadsheetPivotTable {
* @private
* @returns {PivotCell[][]}
*/
_getPivotCells() {
getPivotCells(includeTotal = true, includeColumnHeaders = true) {
const pivotHeight = this.getNumberOfHeaderRows() + this.getNumberOfDataRows();
const pivotWidth = this.getNumberOfDataColumns() + 1;
let pivotWidth = 1 /*(row headers)*/ + this.getNumberOfDataColumns();
if (!includeTotal) {
pivotWidth -= this._measures.length;
}
const domainArray = [];
const startRow = includeColumnHeaders ? 0 : this.getNumberOfHeaderRows();
for (let col = 0; col < pivotWidth; col++) {
domainArray.push([]);
for (let row = 0; row < pivotHeight; row++) {
domainArray[col].push(this._getPivotCell(col, row));
for (let row = startRow; row < pivotHeight; row++) {
if (!includeTotal && row === pivotHeight - 1) {
continue;
}
domainArray[col].push(this._getPivotCell(col, row, includeTotal));
}
}
return domainArray;
}
_isTotalRow(row) {
return this._rows[row].indent !== this._maxIndent;
}
/**
* @returns {PivotCell}
*/
_getPivotCell(col, row) {
_getPivotCell(col, row, includeTotal = true) {
const colHeadersHeight = this.getNumberOfHeaderRows();
if (col === 0 && row === colHeadersHeight - 1) {
return { content: this._rowTitle, isHeader: true, style: HEADER_STYLE };
@@ -221,6 +232,9 @@ export class SpreadsheetPivotTable {
return { domain, isHeader: true, style };
} else {
const rowIndex = row - colHeadersHeight;
if (!includeTotal && this._isTotalRow(rowIndex)) {
return { isHeader: false };
}
const domain = [...this._getRowDomain(rowIndex), ...this._getColDomain(col)];
const measure = this._getColMeasure(col);
return { domain, isHeader: false, measure };
@@ -263,6 +263,11 @@ export class PivotUIPlugin extends spreadsheet.UIPlugin {
return dataSource.getPivotCellValue(measure, domain);
}
getPivotTableStructure(pivotId) {
const dataSource = this.getters.getPivotDataSource(pivotId);
return dataSource.getTableStructure();
}
/**
* Get the filter impacted by a pivot formula's argument
* @param {string} formula Formula of the pivot cell
@@ -442,4 +447,5 @@ PivotUIPlugin.getters = [
"getPivotGroupByValues",
"getFiltersMatchingPivot",
"getPivotDataSourceId",
"getPivotTableStructure",
];
@@ -0,0 +1,201 @@
/** @odoo-module */
import { setCellContent } from "../../utils/commands";
import { getEvaluatedCell, getEvaluatedFormatGrid, getEvaluatedGrid } from "../../utils/getters";
import { createSpreadsheetWithPivot } from "../../utils/pivot";
let model;
QUnit.module("ODOO.PIVOT.TABLE", {
async beforeEach(assert) {
({ model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
}));
model.dispatch("CREATE_SHEET", { sheetId: "42" });
},
});
QUnit.test("full ODOO.PIVOT.TABLE() values", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1")`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D7", "42"), [
["Pivot #1", "xphone", "xpad", "Total"],
["", "Probability", "Probability", "Probability"],
[1, "", 11, 11],
[2, "", 15, 15],
[12, 10, "", 10],
[17, "", 95, 95],
["Total", 10, 121, 131],
]);
});
QUnit.test("full ODOO.PIVOT.TABLE() formats", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1")`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedFormatGrid(model, "A1:D7", "42"), [
[undefined, undefined, undefined, undefined],
[undefined, undefined, undefined, undefined],
["0", "#,##0.00", "#,##0.00", "#,##0.00"],
["0", "#,##0.00", "#,##0.00", "#,##0.00"],
["0", "#,##0.00", "#,##0.00", "#,##0.00"],
["0", "#,##0.00", "#,##0.00", "#,##0.00"],
[undefined, "#,##0.00", "#,##0.00", "#,##0.00"],
]);
});
QUnit.test("ODOO.PIVOT.TABLE(row_count=1)", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1", 1)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D4", "42"), [
["Pivot #1", "xphone", "xpad", "Total"],
["", "Probability", "Probability", "Probability"],
[1, "", 11, 11],
["", "", "", ""],
]);
});
QUnit.test("ODOO.PIVOT.TABLE(row_count=0)", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1", 0)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D3", "42"), [
["Pivot #1", "xphone", "xpad", "Total"],
["", "Probability", "Probability", "Probability"],
["", "", "", ""],
]);
});
QUnit.test("ODOO.PIVOT.TABLE(negative row_count)", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1", -1)`, "42");
assert.strictEqual(getEvaluatedCell(model, "A1", "42").value, "#ERROR");
assert.strictEqual(
getEvaluatedCell(model, "A1", "42").error.message,
"The number of rows must be positive."
);
});
QUnit.test("ODOO.PIVOT.TABLE(include_column_titles=FALSE)", async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",,,FALSE)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D5", "42"), [
[1, "", 11, 11],
[2, "", 15, 15],
[12, 10, "", 10],
[17, "", 95, 95],
["Total", 10, 121, 131],
]);
});
QUnit.test(
"ODOO.PIVOT.TABLE(include_total=FALSE, include_column_titles=FALSE)",
async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",,FALSE,FALSE)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D5", "42"), [
[1, "", 11, ""],
[2, "", 15, ""],
[12, 10, "", ""],
[17, "", 95, ""],
["", "", "", ""],
]);
}
);
QUnit.test(
"ODOO.PIVOT.TABLE(row_count=1, include_total=FALSE, include_column_titles=FALSE)",
async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",1,FALSE,FALSE)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D2", "42"), [
[1, "", 11, ""],
["", "", "", ""],
]);
}
);
QUnit.test(
"ODOO.PIVOT.TABLE(row_count=0, include_total=FALSE, include_column_titles=FALSE)",
async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",0,FALSE,FALSE)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D1", "42"), [
["Pivot #1", "", "", ""],
]);
}
);
QUnit.test(
"ODOO.PIVOT.TABLE(row_count=0, include_total=TRUE, include_column_titles=FALSE)",
async function (assert) {
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",0,TRUE,FALSE)`, "42");
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D1", "42"), [
["Pivot #1", "", "", ""],
]);
}
);
QUnit.test("ODOO.PIVOT.TABLE with multiple row groups", async function (assert) {
const { model } = await createSpreadsheetWithPivot({
arch: /* xml */ `
<pivot>
<field name="product_id" type="col"/>
<field name="foo" type="row"/>
<field name="id" type="row"/>
<field name="probability" type="measure"/>
</pivot>`,
});
const firstSheetId = model.getters.getActiveSheetId();
// values in the first sheet from the individual pivot functions
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D11", firstSheetId), [
["", "xphone", "xpad", "Total"],
["Foo", "Probability", "Probability", "Probability"],
[1, "", 11, 11],
[2, "", 11, 11],
[2, "", 15, 15],
[4, "", 15, 15],
[12, 10, "", 10],
[1, 10, "", 10],
[17, "", 95, 95],
[3, "", 95, 95],
["Total", 10, 121, 131],
]);
model.dispatch("CREATE_SHEET", { sheetId: "42" });
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1")`, "42");
// values from the ODOO.PIVOT.TABLE function
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D11", "42"), [
["Pivot #1", "xphone", "xpad", "Total"],
["", "Probability", "Probability", "Probability"],
[1, "", 11, 11],
[2, "", 11, 11],
[2, "", 15, 15],
[4, "", 15, 15],
[12, 10, "", 10],
[1, 10, "", 10],
[17, "", 95, 95],
[3, "", 95, 95],
["Total", 10, 121, 131],
]);
setCellContent(model, "A1", `=ODOO.PIVOT.TABLE("1",,FALSE)`, "42");
// values from the ODOO.PIVOT.TABLE function without any group totals
// prettier-ignore
assert.deepEqual(getEvaluatedGrid(model, "A1:D11", "42"), [
["Pivot #1", "xphone", "xpad", ""],
["", "Probability", "Probability", ""],
[1, "", "", ""], // group header but without total values
[2, "", 11, ""],
[2, "", "", ""], // group header but without total values
[4, "", 15, ""],
[12, "", "", ""], // group header but without total values
[1, 10, "", ""],
[17, "", "", ""], // group header but without total values
[3, "", 95, ""],
["", "", "", ""],
]);
});
@@ -1,8 +1,9 @@
/** @odoo-module */
import * as spreadsheet from "@odoo/o-spreadsheet";
import { range } from "@web/core/utils/numbers";
const { toCartesian } = spreadsheet.helpers;
const { toCartesian, toZone } = spreadsheet.helpers;
/**
* Get the value of the given cell
@@ -26,6 +27,32 @@ export function getEvaluatedCell(model, xc, sheetId = model.getters.getActiveShe
return model.getters.getEvaluatedCell({ sheetId, col, row });
}
export function getEvaluatedGrid(model, zoneXc, sheetId = model.getters.getActiveSheetId()) {
const { top, bottom, left, right } = toZone(zoneXc);
const grid = [];
for (const row of range(top, bottom + 1)) {
grid.push([]);
for (const col of range(left, right + 1)) {
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
grid[row][col] = cell.value;
}
}
return grid;
}
export function getEvaluatedFormatGrid(model, zoneXc, sheetId = model.getters.getActiveSheetId()) {
const { top, bottom, left, right } = toZone(zoneXc);
const grid = [];
for (const row of range(top, bottom + 1)) {
grid.push([]);
for (const col of range(left, right + 1)) {
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
grid[row][col] = cell.format;
}
}
return grid;
}
/**
* Get the cells of the given sheet (or active sheet if not provided)
*/