[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:
committed by
Lucas Lefèvre (lul)
co-authored by
Lucas Lefèvre
parent
824324dd9a
commit
442e8a23ae
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user