Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions packages/o-spreadsheet-engine/src/functions/module_lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,14 +906,19 @@ export const PIVOT = {
"include_measure_titles (boolean, default=TRUE)",
_t("Whether to include the measure titles row or not.")
),
arg(
"tabular_form (boolean, default=FALSE)",
_t("Whether to display the pivot in a tabular form.")
),
],
compute: function (
pivotFormulaId: Maybe<FunctionResultObject>,
rowCount: Maybe<FunctionResultObject>,
includeTotal: Maybe<FunctionResultObject>,
includeColumnHeaders: Maybe<FunctionResultObject>,
columnCount: Maybe<FunctionResultObject>,
includeMeasureTitles: Maybe<FunctionResultObject>
includeMeasureTitles: Maybe<FunctionResultObject>,
tabularForm: Maybe<FunctionResultObject>
) {
const _pivotFormulaId = toString(pivotFormulaId);
const pivotId = getPivotId(_pivotFormulaId, this.getters);
Expand All @@ -927,6 +932,7 @@ export const PIVOT = {
includeColumnHeaders,
columnCount,
includeMeasureTitles,
tabularForm,
this.locale
);

Expand Down Expand Up @@ -961,7 +967,10 @@ export const PIVOT = {
if (tableHeight === 0) {
return [[{ value: pivotTitle }]];
}
const tableWidth = Math.min(1 + pivotStyle.numberOfColumns, cells.length);
const tableWidth = Math.min(
table.getRowHeadersWidth(pivotStyle) + pivotStyle.numberOfColumns,
cells.length
);
const result: Matrix<FunctionResultObject> = [];
for (const col of range(0, tableWidth)) {
result[col] = [];
Expand All @@ -981,10 +990,16 @@ export const PIVOT = {
case "VALUE":
result[col].push(pivot.getPivotCellValueAndFormat(pivotCell.measure, pivotCell.domain));
break;
case "ROW_GROUP_NAME":
result[col].push(pivot.getPivotRowGroupName(pivotCell.groupByIndex));
break;
}
}
}
if (pivotStyle.displayColumnHeaders || pivotStyle.displayMeasuresRow) {
if (
(pivotStyle.displayColumnHeaders || pivotStyle.displayMeasuresRow) &&
cells[0][0].type === "EMPTY"
) {
result[0][0] = { value: pivotTitle };
}
return result;
Expand Down
16 changes: 15 additions & 1 deletion packages/o-spreadsheet-engine/src/helpers/pivot/pivot_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const DEFAULT_PIVOT_STYLE: Required<PivotStyle> = {
displayMeasuresRow: true,
numberOfRows: Number.MAX_VALUE,
numberOfColumns: Number.MAX_VALUE,
tabularForm: false,
};

const AGGREGATOR_NAMES = {
Expand Down Expand Up @@ -463,6 +464,7 @@ export function getPivotStyleFromFnArgs(
includeColumnHeadersArg: Maybe<FunctionResultObject | CellValue>,
columnCountArg: Maybe<FunctionResultObject | CellValue>,
includeMeasuresRowArg: Maybe<FunctionResultObject | CellValue>,
tabularFormArg: Maybe<FunctionResultObject | CellValue>,
locale: Locale
): Required<PivotStyle> {
const style = definition.style;
Expand All @@ -489,5 +491,17 @@ export function getPivotStyleFromFnArgs(
? toBoolean(includeMeasuresRowArg)
: style?.displayMeasuresRow ?? DEFAULT_PIVOT_STYLE.displayMeasuresRow;

return { numberOfRows, numberOfColumns, displayTotals, displayColumnHeaders, displayMeasuresRow };
const tabularForm =
tabularFormArg !== undefined
? toBoolean(tabularFormArg)
: style?.tabularForm ?? DEFAULT_PIVOT_STYLE.tabularForm;

return {
numberOfRows,
numberOfColumns,
displayTotals,
displayColumnHeaders,
displayMeasuresRow,
tabularForm,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ export class SpreadsheetPivot implements Pivot<SpreadsheetPivotRuntimeDefinition
return finalCell;
}

getPivotRowGroupName(groupByIndex: number): FunctionResultObject {
const rowDimension = this.definition.rows[groupByIndex];
return { value: rowDimension?.displayName || "" };
}

getPivotCellValueAndFormat(measureId: string, domain: PivotDomain): FunctionResultObject {
const dataEntries = this.filterDataEntriesFromDomain(this.dataEntries, domain);
if (dataEntries.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface CollapsiblePivotTableColumn extends PivotTableColumn {
export class SpreadsheetPivotTable {
readonly columns: CollapsiblePivotTableColumn[][];
rows: PivotTableRow[];
readonly numberOfRowGroupings: number;
readonly measures: string[];
readonly fieldsType: Record<string, string | undefined>;
readonly maxIndent: number;
Expand All @@ -87,9 +88,7 @@ export class SpreadsheetPivotTable {
columns = this.removeCollapsedColumns(columns, measures, collapsedDomains.COL);
}
this.columns = columns.map((cols) => {
// offset in the pivot table
// starts at 1 because the first column is the row title
let offset = 1;
let offset = 0;
return cols.map((col) => {
col = { ...col, offset };
offset += col.width;
Expand All @@ -98,6 +97,7 @@ export class SpreadsheetPivotTable {
});

this.rows = rows.filter((row) => !this.isParentCollapsed(collapsedDomains.ROW, row));
this.numberOfRowGroupings = Math.max(...rows.map((row) => row.fields.length));
this.maxIndent = Math.max(...this.rows.map((row) => row.indent));
this.rowTree = lazy(() => this.buildRowsTree());
this.colTree = lazy(() => this.buildColumnsTree());
Expand Down Expand Up @@ -159,13 +159,23 @@ export class SpreadsheetPivotTable {

private getSkippedRows(pivotStyle: Required<PivotStyle>) {
const skippedRows: Set<number> = new Set();
const colHeadersHeight = this.getColHeadersHeight();
if (!pivotStyle.displayColumnHeaders) {
for (let i = 0; i < this.columns.length - 1; i++) {
for (let i = 0; i < colHeadersHeight - 1; i++) {
skippedRows.add(i);
}
}
if (!pivotStyle.displayMeasuresRow) {
skippedRows.add(this.columns.length - 1);
skippedRows.add(colHeadersHeight - 1);
}
// Skip sub-total rows in tabular form
if (pivotStyle.tabularForm) {
for (let i = 0; i < this.rows.length; i++) {
const indent = this.rows[i].indent;
if (indent !== 0 && indent !== this.maxIndent) {
skippedRows.add(i + colHeadersHeight);
}
}
}
return skippedRows;
}
Expand All @@ -176,8 +186,8 @@ export class SpreadsheetPivotTable {
const { displayTotals } = pivotStyle;
const numberOfDataRows = this.rows.length;
const numberOfDataColumns = this.getNumberOfDataColumns();
let pivotHeight = this.columns.length + numberOfDataRows;
let pivotWidth = 1 /*(row headers)*/ + numberOfDataColumns;
let pivotHeight = numberOfDataRows + this.getColHeadersHeight();
let pivotWidth = numberOfDataColumns + this.getRowHeadersWidth(pivotStyle);
if (!displayTotals && numberOfDataRows !== 1) {
pivotHeight -= 1;
}
Expand All @@ -192,7 +202,10 @@ export class SpreadsheetPivotTable {
if (skippedRows.has(row)) {
continue;
}
domainArray[col].push(this.getPivotCell(col, row, displayTotals));
const cell = pivotStyle.tabularForm
? this.getTabularFormPivotCell(col, row, pivotStyle)
: this.getPivotCell(col, row, pivotStyle);
domainArray[col].push(cell);
}
}
this.pivotCells[key] = domainArray;
Expand All @@ -212,38 +225,72 @@ export class SpreadsheetPivotTable {
return this.rows[index].indent !== this.maxIndent;
}

private getPivotCell(col: number, row: number, includeTotal = true): PivotTableCell {
const colHeadersHeight = this.columns.length;
if (col > 0 && row === colHeadersHeight - 1) {
const domain = this.getColHeaderDomain(col, row);
private getPivotCell(col: number, row: number, pivotStyle: Required<PivotStyle>): PivotTableCell {
const colHeadersHeight = this.getColHeadersHeight();
const rowHeadersWidth = this.getRowHeadersWidth(pivotStyle);

const isColHeader = row < colHeadersHeight - 1 && col >= rowHeadersWidth;
const isMeasureHeader = row === colHeadersHeight - 1 && col >= rowHeadersWidth;
const isRowHeader = row > colHeadersHeight - 1 && col < rowHeadersWidth;
const isPivotValue = row > colHeadersHeight - 1 && col >= rowHeadersWidth;

if (isMeasureHeader) {
const colIndex = col - rowHeadersWidth;
const domain = this.getColHeaderDomain(colIndex, row);
if (!domain) {
return EMPTY_PIVOT_CELL;
}
const measure = domain.at(-1)?.value?.toString() || "";
return { type: "MEASURE_HEADER", domain: domain.slice(0, -1), measure };
} else if (row <= colHeadersHeight - 1) {
const domain = this.getColHeaderDomain(col, row);
} else if (isColHeader) {
const colIndex = col - rowHeadersWidth;
const domain = this.getColHeaderDomain(colIndex, row);
return domain ? { type: "HEADER", domain, dimension: "COL" } : EMPTY_PIVOT_CELL;
} else if (col === 0) {
} else if (isRowHeader) {
const rowIndex = row - colHeadersHeight;
const domain = this.getDomain(this.rows[rowIndex]);
return { type: "HEADER", domain, dimension: "ROW" };
} else {
} else if (isPivotValue) {
const rowIndex = row - colHeadersHeight;
if (!includeTotal && this.isTotalRow(rowIndex)) {
const colIndex = col - rowHeadersWidth;
if (!pivotStyle.displayTotals && this.isTotalRow(rowIndex)) {
return EMPTY_PIVOT_CELL;
}
const domain = [...this.getDomain(this.rows[rowIndex]), ...this.getColDomain(col)];
const measure = this.getColMeasure(col);
const domain = [...this.getDomain(this.rows[rowIndex]), ...this.getColDomain(colIndex)];
const measure = this.getColMeasure(colIndex);
return { type: "VALUE", domain, measure };
}

return EMPTY_PIVOT_CELL;
}

private getColHeaderDomain(col: number, row: number) {
if (col === 0) {
return undefined;
private getTabularFormPivotCell(
col: number,
row: number,
pivotStyle: Required<PivotStyle>
): PivotTableCell {
const colHeadersHeight = this.getColHeadersHeight();
const rowHeadersWidth = this.getRowHeadersWidth(pivotStyle);

const isRowHeader = row > colHeadersHeight - 1 && col < rowHeadersWidth;
const isRowGroupName = row === colHeadersHeight - 1 && col < rowHeadersWidth;

if (isRowHeader) {
const rowIndex = row - colHeadersHeight;
const domain = this.getDomain(this.rows[rowIndex]).slice(0, col + 1);
if (domain.length === 0 && col !== 0) {
return EMPTY_PIVOT_CELL;
}
return { type: "HEADER", domain, dimension: "ROW" };
} else if (isRowGroupName) {
return { type: "ROW_GROUP_NAME", groupByIndex: col };
}
const pivotCol = this.columns[row].find((pivotCol) => pivotCol.offset === col);

return this.getPivotCell(col, row, pivotStyle);
}

private getColHeaderDomain(colIndex: number, row: number) {
const pivotCol = this.columns[row].find((pivotCol) => pivotCol.offset === colIndex);
if (!pivotCol || pivotCol.collapsedHeader) {
return undefined;
}
Expand Down Expand Up @@ -273,20 +320,28 @@ export class SpreadsheetPivotTable {
});
}

private getColDomain(col: number) {
const domain = this.getColHeaderDomain(col, this.columns.length - 1);
private getColDomain(colIndex: number) {
const domain = this.getColHeaderDomain(colIndex, this.getColHeadersHeight() - 1);
return domain ? domain.slice(0, -1) : []; // slice: remove measure and value
}

private getColMeasure(col: number) {
const domain = this.getColHeaderDomain(col, this.columns.length - 1);
private getColMeasure(colIndex: number) {
const domain = this.getColHeaderDomain(colIndex, this.getColHeadersHeight() - 1);
const measure = domain?.at(-1)?.value;
if (measure === undefined || measure === null) {
throw new Error("Measure is missing");
}
return measure.toString();
}

getRowHeadersWidth(pivotStyle: Required<PivotStyle>) {
return pivotStyle.tabularForm ? this.numberOfRowGroupings : 1;
}

private getColHeadersHeight() {
return this.columns.length;
}

buildRowsTree(): DimensionTree {
const tree: DimensionTree = [];
let depth = 0;
Expand Down Expand Up @@ -406,7 +461,7 @@ export class SpreadsheetPivotTable {
}

getColumnDomainsAtDepth(depth: number) {
if (depth < 0 || depth >= this.columns.length - 1) {
if (depth < 0 || depth >= this.getColHeadersHeight() - 1) {
return [];
}
return this.columns[depth].map((col) => this.getDomain(col)).filter((d) => d.length);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export class PivotUIPlugin extends CoreViewPlugin {
toScalar(args[3]),
toScalar(args[4]),
toScalar(args[5]),
toScalar(args[6]),
this.getters.getLocale()
);
const pivotCells = pivot.getCollapsedTableStructure().getPivotCells(pivotStyle);
Expand Down Expand Up @@ -292,7 +293,12 @@ export class PivotUIPlugin extends CoreViewPlugin {
getPivotCellSortDirection(position: CellPosition): SortDirection | "none" | undefined {
const pivotId = this.getters.getPivotIdFromPosition(position);
const pivotCell = this.getters.getPivotCellFromPosition(position);
if (pivotCell.type === "EMPTY" || pivotCell.type === "HEADER" || !pivotId) {
if (
pivotCell.type === "EMPTY" ||
pivotCell.type === "HEADER" ||
pivotCell.type === "ROW_GROUP_NAME" ||
!pivotId
) {
return undefined;
}
const pivot = this.getters.getPivot(pivotId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
PIVOT_INDENT,
} from "../constants";
import { deepEquals } from "../helpers/misc";
import { togglePivotCollapse } from "../helpers/pivot/pivot_helpers";
import { DEFAULT_PIVOT_STYLE, togglePivotCollapse } from "../helpers/pivot/pivot_helpers";
import { computeTextFontSizeInPixels } from "../helpers/text_helper";
import { Getters } from "../types/getters";
import { ImageSVG } from "../types/image";
Expand Down Expand Up @@ -139,14 +139,15 @@ iconsOnCellRegistry.add("conditional_formatting", (getters, position) => {
});

iconsOnCellRegistry.add("pivot_collapse", (getters, position) => {
if (!getters.isSpillPivotFormula(position)) {
const pivotId = getters.getPivotIdFromPosition(position);
if (!getters.isSpillPivotFormula(position) || !pivotId) {
return undefined;
}
const pivotCell = getters.getPivotCellFromPosition(position);
const pivotId = getters.getPivotIdFromPosition(position);
const definition = getters.getPivotCoreDefinition(pivotId);
const tabularForm = definition.style?.tabularForm ?? DEFAULT_PIVOT_STYLE.tabularForm;

if (pivotCell.type === "HEADER" && pivotId && pivotCell.domain.length) {
const definition = getters.getPivotCoreDefinition(pivotId);
if (!tabularForm && pivotCell.type === "HEADER" && pivotId && pivotCell.domain.length) {
Comment on lines +148 to +150
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon is still displayed when it's not in tabular form in the definition, but it is in the PIVOT formula (and nothing works)

Copy link
Contributor Author

@hokolomopo hokolomopo Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah .... I think I'll wait for the pivot style PR before fixing this, in there we should have a strategy to get the actual pivot style for a pivot cell (+ the pivot style will need to be adapted for tabular form)

const isDashboard = getters.isDashboard();

const fields = pivotCell.dimension === "COL" ? definition.columns : definition.rows;
Expand Down
7 changes: 7 additions & 0 deletions packages/o-spreadsheet-engine/src/types/pivot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ export interface PivotHeaderCell {
dimension: Dimension;
}

export interface PivotGroupNameCell {
type: "ROW_GROUP_NAME";
groupByIndex: number;
}

export interface PivotMeasureHeaderCell {
type: "MEASURE_HEADER";
domain: PivotDomain;
Expand All @@ -174,6 +179,7 @@ export interface PivotEmptyCell {

export type PivotTableCell =
| PivotHeaderCell
| PivotGroupNameCell
| PivotMeasureHeaderCell
| PivotValueCell
| PivotEmptyCell;
Expand Down Expand Up @@ -246,4 +252,5 @@ export interface PivotStyle {
displayTotals?: boolean;
displayColumnHeaders?: boolean;
displayMeasuresRow?: boolean;
tabularForm?: boolean;
}
1 change: 1 addition & 0 deletions packages/o-spreadsheet-engine/src/types/pivot_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface Pivot<T = PivotRuntimeDefinition> {
getPivotHeaderValueAndFormat(domain: PivotDomain): FunctionResultObject;
getPivotCellValueAndFormat(measure: string, domain: PivotDomain): FunctionResultObject;
getPivotMeasureValue(measure: string, domain: PivotDomain): FunctionResultObject;
getPivotRowGroupName(groupByIndex: number): FunctionResultObject;

getMeasure: (id: string) => PivotMeasure;

Expand Down
Loading