diff --git a/addons/spreadsheet/static/src/pivot/pivot_functions.js b/addons/spreadsheet/static/src/pivot/pivot_functions.js index 980dc7f254d..a17a3fc0d04 100644 --- a/addons/spreadsheet/static/src/pivot/pivot_functions.js +++ b/addons/spreadsheet/static/src/pivot/pivot_functions.js @@ -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"], +}; + +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); diff --git a/addons/spreadsheet/static/src/pivot/pivot_table.js b/addons/spreadsheet/static/src/pivot/pivot_table.js index ea73e454cab..5df1766b668 100644 --- a/addons/spreadsheet/static/src/pivot/pivot_table.js +++ b/addons/spreadsheet/static/src/pivot/pivot_table.js @@ -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 }; diff --git a/addons/spreadsheet/static/src/pivot/plugins/pivot_ui_plugin.js b/addons/spreadsheet/static/src/pivot/plugins/pivot_ui_plugin.js index 3cff6533cc5..24cd20d88e5 100644 --- a/addons/spreadsheet/static/src/pivot/plugins/pivot_ui_plugin.js +++ b/addons/spreadsheet/static/src/pivot/plugins/pivot_ui_plugin.js @@ -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", ]; diff --git a/addons/spreadsheet/static/tests/pivots/model/pivot_table_function_test.js b/addons/spreadsheet/static/tests/pivots/model/pivot_table_function_test.js new file mode 100644 index 00000000000..59065024fac --- /dev/null +++ b/addons/spreadsheet/static/tests/pivots/model/pivot_table_function_test.js @@ -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 */ ` + + + + + `, + })); + 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 */ ` + + + + + + `, + }); + 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, ""], + ["", "", "", ""], + ]); +}); diff --git a/addons/spreadsheet/static/tests/utils/getters.js b/addons/spreadsheet/static/tests/utils/getters.js index be166639281..2ba73089c71 100644 --- a/addons/spreadsheet/static/tests/utils/getters.js +++ b/addons/spreadsheet/static/tests/utils/getters.js @@ -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) */