diff --git a/packages/o-spreadsheet-engine/src/functions/create_compute_function.ts b/packages/o-spreadsheet-engine/src/functions/create_compute_function.ts index 65a309b85a..6870f09f52 100644 --- a/packages/o-spreadsheet-engine/src/functions/create_compute_function.ts +++ b/packages/o-spreadsheet-engine/src/functions/create_compute_function.ts @@ -34,7 +34,7 @@ export function createComputeFunction( acceptToVectorize.push(!argDefinition.acceptMatrix); } - return applyVectorization(errorHandlingCompute.bind(this), args, acceptToVectorize); + return applyVectorization(this, errorHandlingCompute, args, acceptToVectorize); } function errorHandlingCompute( diff --git a/packages/o-spreadsheet-engine/src/functions/helpers.ts b/packages/o-spreadsheet-engine/src/functions/helpers.ts index 46185d15cc..8e388f0b33 100644 --- a/packages/o-spreadsheet-engine/src/functions/helpers.ts +++ b/packages/o-spreadsheet-engine/src/functions/helpers.ts @@ -11,7 +11,7 @@ import { NotAvailableError, errorTypes, } from "../types/errors"; -import { LookupCaches } from "../types/functions"; +import { EvalContext, LookupCaches } from "../types/functions"; import { Locale } from "../types/locale"; import { Arg, @@ -499,6 +499,7 @@ type VectorArgType = "horizontal" | "vertical" | "matrix"; * as ranges and invoke this helper directly within your `compute` implementation. */ export function applyVectorization( + evalCtx: EvalContext, formula: (...args: Arg[]) => Matrix | FunctionResultObject, args: Arg[], acceptToVectorize: boolean[] | undefined = undefined @@ -541,7 +542,7 @@ export function applyVectorization( if (countVectorizedCol === 1 && countVectorizedRow === 1) { // either this function is not vectorized or it ends up with a 1x1 dimension - return formula(...args); + return formula.call(evalCtx, ...args); } const getArgOffset: (i: number, j: number) => Arg[] = (i, j) => @@ -564,7 +565,16 @@ export function applyVectorization( _t("Array arguments to [[FUNCTION_NAME]] are of different size.") ); } - const singleCellComputeResult = formula(...getArgOffset(col, row)); + const basePosition = evalCtx.__originCellPosition; + const ctx = { ...evalCtx }; + if (basePosition) { + ctx.__originCellPosition = { + col: basePosition.col + col, + row: basePosition.row + row, + sheetId: basePosition.sheetId, + }; + } + const singleCellComputeResult = formula.call(ctx, ...getArgOffset(col, row)); // In the case where the user tries to vectorize arguments of an array formula, we will get an // array for every combination of the vectorized arguments, which will lead to a 3D matrix and // we won't be able to return the values. diff --git a/packages/o-spreadsheet-engine/src/functions/module_logical.ts b/packages/o-spreadsheet-engine/src/functions/module_logical.ts index 7ba76c5e64..9a0fb9c68b 100644 --- a/packages/o-spreadsheet-engine/src/functions/module_logical.ts +++ b/packages/o-spreadsheet-engine/src/functions/module_logical.ts @@ -72,7 +72,7 @@ export const IF = { ], compute: function (logicalExpression: Arg, valueIfTrue: Arg, valueIfFalse: Arg) { if (isMultipleElementMatrix(logicalExpression)) { - return applyVectorization(IF.compute, [logicalExpression, valueIfTrue, valueIfFalse]); + return applyVectorization(this, IF.compute, [logicalExpression, valueIfTrue, valueIfFalse]); } const result = toBoolean(toScalar(logicalExpression)) ? valueIfTrue : valueIfFalse; return result ?? { value: 0 }; @@ -94,7 +94,7 @@ export const IFERROR = { ], compute: function (value: Arg, valueIfError: Arg) { if (isMultipleElementMatrix(value)) { - return applyVectorization(IFERROR.compute, [value, valueIfError]); + return applyVectorization(this, IFERROR.compute, [value, valueIfError]); } const result = isEvaluationError(toScalar(value)?.value) ? valueIfError : value; return result ?? { value: 0 }; @@ -116,7 +116,7 @@ export const IFNA = { ], compute: function (value: Arg, valueIfError: Arg) { if (isMultipleElementMatrix(value)) { - return applyVectorization(IFNA.compute, [value, valueIfError]); + return applyVectorization(this, IFNA.compute, [value, valueIfError]); } const result = toScalar(value)?.value === CellErrorType.NotAvailable ? valueIfError : value; return result ?? { value: 0 }; @@ -149,7 +149,7 @@ export const IFS = { } while (values.length > 0) { if (isMultipleElementMatrix(values[0])) { - return applyVectorization(IFS.compute, values); + return applyVectorization(this, IFS.compute, values); } const condition = toBoolean(toScalar(values.shift())); const valueIfTrue = values.shift(); diff --git a/packages/o-spreadsheet-engine/src/functions/module_lookup.ts b/packages/o-spreadsheet-engine/src/functions/module_lookup.ts index 0072f5563f..e4159a0df3 100644 --- a/packages/o-spreadsheet-engine/src/functions/module_lookup.ts +++ b/packages/o-spreadsheet-engine/src/functions/module_lookup.ts @@ -10,8 +10,8 @@ import { import { toZone } from "../helpers/zones"; import { _t } from "../translation"; import { CellErrorType, EvaluationError, InvalidReferenceError } from "../types/errors"; -import { AddFunctionDescription } from "../types/functions"; -import { Arg, FunctionResultObject, Matrix, Maybe, Zone } from "../types/misc"; +import { AddFunctionDescription, EvalContext } from "../types/functions"; +import { Arg, FunctionResultObject, Matrix, Maybe, PivotCacheItem, Zone } from "../types/misc"; import { arg } from "./arguments"; import { expectNumberGreaterThanOrEqualToOne } from "./helper_assert"; import { @@ -786,6 +786,13 @@ export const XLOOKUP = { // Pivot functions //-------------------------------------------------------------------------- +function addPivotMetaDataToContext(context: EvalContext, item: PivotCacheItem) { + const { __originCellPosition: position } = context; + if (position) { + context.sendEvaluationMessage({ type: "addPivotToPosition", position, item }); + } +} + // PIVOT.VALUE export const PIVOT_VALUE = { @@ -804,8 +811,13 @@ export const PIVOT_VALUE = { const _pivotFormulaId = toString(formulaId); const _measure = toString(measureName); const pivotId = getPivotId(_pivotFormulaId, this.getters); - assertMeasureExist(pivotId, _measure, this.getters); - assertDomainLength(domainArgs); + try { + assertMeasureExist(pivotId, _measure, this.getters); + assertDomainLength(domainArgs); + } catch (e) { + addPivotMetaDataToContext(this, { type: "error", pivotId }); + return e; + } const pivot = this.getters.getPivot(pivotId); const coreDefinition = this.getters.getPivotCoreDefinition(pivotId); @@ -817,6 +829,7 @@ export const PIVOT_VALUE = { pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { + addPivotMetaDataToContext(this, { type: "error", pivotId }); return error; } @@ -825,6 +838,7 @@ export const PIVOT_VALUE = { "Consider using a dynamic pivot formula: %s. Or re-insert the static pivot from the Data menu.", `=PIVOT(${_pivotFormulaId})` ); + addPivotMetaDataToContext(this, { type: "error", pivotId }); return { value: CellErrorType.GenericError, message: _t("Dimensions don't match the pivot definition") + ". " + suggestion, @@ -834,6 +848,12 @@ export const PIVOT_VALUE = { if (this.getters.getActiveSheetId() === this.__originSheetId) { this.getters.getPivotPresenceTracker(pivotId)?.trackValue(_measure, domain); } + addPivotMetaDataToContext(this, { + type: "static", + pivotId, + pivotCell: { type: "VALUE", measure: _measure, domain }, + }); + return pivot.getPivotCellValueAndFormat(_measure, domain); }, } satisfies AddFunctionDescription; @@ -853,13 +873,19 @@ export const PIVOT_HEADER = { ) { const _pivotFormulaId = toString(pivotId); const _pivotId = getPivotId(_pivotFormulaId, this.getters); - assertDomainLength(domainArgs); + try { + assertDomainLength(domainArgs); + } catch (e) { + addPivotMetaDataToContext(this, { type: "error", pivotId: _pivotId }); + return e; + } const pivot = this.getters.getPivot(_pivotId); const coreDefinition = this.getters.getPivotCoreDefinition(_pivotId); addPivotDependencies(this, coreDefinition, []); pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { + addPivotMetaDataToContext(this, { type: "error", pivotId: _pivotId }); return error; } if (!pivot.areDomainArgsFieldsValid(domainArgs)) { @@ -867,6 +893,7 @@ export const PIVOT_HEADER = { "Consider using a dynamic pivot formula: %s. Or re-insert the static pivot from the Data menu.", `=PIVOT(${_pivotFormulaId})` ); + addPivotMetaDataToContext(this, { type: "error", pivotId: _pivotId }); return { value: CellErrorType.GenericError, message: _t("Dimensions don't match the pivot definition") + ". " + suggestion, @@ -876,11 +903,32 @@ export const PIVOT_HEADER = { if (this.getters.getActiveSheetId() === this.__originSheetId) { this.getters.getPivotPresenceTracker(_pivotId)?.trackHeader(domain); } + const lastNode = domain.at(-1); if (lastNode?.field === "measure") { - return pivot.getPivotMeasureValue(toString(lastNode.value), domain); + const measure = toString(lastNode.value); + addPivotMetaDataToContext(this, { + type: "static", + pivotId: _pivotId, + pivotCell: { type: "MEASURE_HEADER", measure, domain: domain.slice(0, -1) }, + }); + + return pivot.getPivotMeasureValue(measure, domain); } const { value, format } = pivot.getPivotHeaderValueAndFormat(domain); + + const columns = pivot.definition.columns; + const isColumnHeader = columns.some((col) => col.nameWithGranularity === domain[0]?.field); + addPivotMetaDataToContext(this, { + type: "static", + pivotId: _pivotId, + pivotCell: { + type: "HEADER", + domain, + dimension: isColumnHeader ? "COL" : "ROW", + }, + }); + return { value, format: @@ -941,13 +989,16 @@ export const PIVOT = { pivot.init({ reload: pivot.needsReevaluation }); const error = pivot.assertIsValid({ throwOnError: false }); if (error) { + addPivotMetaDataToContext(this, { type: "error", pivotId }); return error; } const table = pivot.getCollapsedTableStructure(); if (table.numberOfCells > PIVOT_MAX_NUMBER_OF_CELLS) { + addPivotMetaDataToContext(this, { type: "error", pivotId }); return new EvaluationError(getPivotTooBigErrorMessage(table.numberOfCells, this.locale)); } const cells = table.getPivotCells(pivotStyle); + addPivotMetaDataToContext(this, { type: "dynamic", pivotStyle, pivotId }); let headerRows = 0; if (pivotStyle.displayColumnHeaders) { diff --git a/packages/o-spreadsheet-engine/src/functions/module_math.ts b/packages/o-spreadsheet-engine/src/functions/module_math.ts index 8251086009..7e011191a9 100644 --- a/packages/o-spreadsheet-engine/src/functions/module_math.ts +++ b/packages/o-spreadsheet-engine/src/functions/module_math.ts @@ -1,6 +1,5 @@ import { splitReference } from "../helpers"; import { toZone } from "../helpers/zones"; -import { isSubtotalCell } from "../plugins/ui_feature/subtotal_evaluation"; import { _t } from "../translation"; import { EvaluatedCell } from "../types/cells"; import { DivisionByZeroError, EvaluationError } from "../types/errors"; @@ -1386,6 +1385,12 @@ export const SUBTOTAL = { code -= 100; acceptHiddenCells = false; } + + const { __originCellPosition: position } = this; + if (position) { + this.sendEvaluationMessage({ type: "addSubTotalToPosition", position }); + } + if (code < 1 || code > 11) { return new EvaluationError( _t("The function code (%s) must be between 1 to 11 or 101 to 111.", code) @@ -1410,7 +1415,7 @@ export const SUBTOTAL = { for (let col = left; col <= right; col++) { const cell = this.getters.getCell({ sheetId, col, row }); - if (!cell || !isSubtotalCell(cell)) { + if (!cell || !this.getters.isSubtotalCell({ sheetId, col, row })) { evaluatedCellToKeep.push(this.getters.getEvaluatedCell({ sheetId, col, row })); } } diff --git a/packages/o-spreadsheet-engine/src/helpers/pivot/evaluation_listener_registry.ts b/packages/o-spreadsheet-engine/src/helpers/pivot/evaluation_listener_registry.ts new file mode 100644 index 0000000000..6055df3577 --- /dev/null +++ b/packages/o-spreadsheet-engine/src/helpers/pivot/evaluation_listener_registry.ts @@ -0,0 +1,13 @@ +import { CellPosition } from "../.."; +import { Registry } from "../../registry"; + +export type EvaluationMessage = + | { type: "invalidateCell"; position: CellPosition } + | { type: "invalidateAllCells" } + | { type: string; [key: string]: any }; + +export interface EvaluationListener { + handleEvaluationMessage(message: EvaluationMessage): void; +} + +export const evaluationListenerRegistry = new Registry(); diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts index 520822bf9f..63c85138a9 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/cell_evaluation/evaluator.ts @@ -24,6 +24,10 @@ import { matrixMap } from "../../../functions/helpers"; import { PositionMap } from "../../../helpers/cells/position_map"; import { toXC } from "../../../helpers/coordinates"; import { lazy } from "../../../helpers/misc"; +import { + evaluationListenerRegistry, + EvaluationMessage, +} from "../../../helpers/pivot/evaluation_listener_registry"; import { excludeTopLeft, positionToZone, union } from "../../../helpers/zones"; import { onIterationEndEvaluationRegistry } from "../../../registries/evaluation_registry"; import { _t } from "../../../translation"; @@ -139,6 +143,14 @@ export class Evaluator { forwardSearch: new Map(), reverseSearch: new Map(), }; + this.compilationParams.evalContext.sendEvaluationMessage = (message: EvaluationMessage) => + this.sendToListeners(message); + } + + private sendToListeners(message: EvaluationMessage) { + for (const listener of evaluationListenerRegistry.getAll()) { + listener.handleEvaluationMessage(message); + } } private createEmptyPositionSet() { @@ -218,6 +230,7 @@ export class Evaluator { evaluateAllCells() { const start = performance.now(); this.evaluatedCells = new PositionMap(); + this.sendToListeners({ type: "invalidateAllCells" }); const ranges: BoundedRange[] = []; for (const sheetId of this.getters.getSheetIds()) { const zone = this.getters.getSheetZone(sheetId); @@ -325,7 +338,9 @@ export class Evaluator { const { left, bottom, right, top } = range.zone; for (let col = left; col <= right; col++) { for (let row = top; row <= bottom; row++) { - this.evaluatedCells.delete({ sheetId: range.sheetId, col, row }); + const position = { sheetId: range.sheetId, col, row }; + this.sendToListeners({ type: "invalidateCell", position }); + this.evaluatedCells.delete(position); } } } @@ -545,6 +560,7 @@ export class Evaluator { continue; } this.evaluatedCells.delete(resultPosition); + this.sendToListeners({ type: "invalidateCell", position: resultPosition }); } } const sheetId = position.sheetId; diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/pivot_ui.ts b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/pivot_ui.ts index 8a1a252c45..b317946f3b 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_core_views/pivot_ui.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_core_views/pivot_ui.ts @@ -1,13 +1,13 @@ import { astToFormula } from "../../formulas/formula_formatter"; import { Token } from "../../formulas/tokenizer"; -import { toScalar } from "../../functions/helper_matrices"; +import { PositionMap } from "../../helpers/cells/position_map"; import { deepEquals, getUniqueText } from "../../helpers/misc"; import { - getFirstPivotFunction, - getNumberOfPivotFunctions, -} from "../../helpers/pivot/pivot_composer_helpers"; + evaluationListenerRegistry, + EvaluationMessage, +} from "../../helpers/pivot/evaluation_listener_registry"; +import { getFirstPivotFunction } from "../../helpers/pivot/pivot_composer_helpers"; import { domainToColRowDomain } from "../../helpers/pivot/pivot_domain_helpers"; -import { getPivotStyleFromFnArgs } from "../../helpers/pivot/pivot_helpers"; import withPivotPresentationLayer from "../../helpers/pivot/pivot_presentation"; import { pivotRegistry } from "../../helpers/pivot/pivot_registry"; import { resetMapValueDimensionDate } from "../../helpers/pivot/spreadsheet_pivot/date_spreadsheet_pivot"; @@ -17,10 +17,10 @@ import { AddPivotCommand, Command, CoreCommand, - UpdatePivotCommand, invalidateEvaluationCommands, + UpdatePivotCommand, } from "../../types/commands"; -import { CellPosition, FunctionResultObject, SortDirection, UID, isMatrix } from "../../types/misc"; +import { CellPosition, PivotCacheItem, SortDirection, UID } from "../../types/misc"; import { PivotCoreMeasure, PivotTableCell } from "../../types/pivot"; import { Pivot } from "../../types/pivot_runtime"; import { CoreViewPlugin, CoreViewPluginConfig } from "../core_view_plugin"; @@ -42,15 +42,21 @@ export class PivotUIPlugin extends CoreViewPlugin { "generateNewCalculatedMeasureName", "isPivotUnused", "isSpillPivotFormula", + "getPivotInfoAtPosition", ] as const; private pivots: Record = {}; private unusedPivotsInFormulas?: UID[]; private custom: UIPluginConfig["custom"]; + private pivotCellsCache = new PositionMap(); + constructor(config: CoreViewPluginConfig) { super(config); this.custom = config.custom; + evaluationListenerRegistry.replace("PivotUIPlugin", { + handleEvaluationMessage: this.handleEvaluationMessage.bind(this), + }); } beforeHandle(cmd: Command) { @@ -116,39 +122,39 @@ export class PivotUIPlugin extends CoreViewPlugin { } } + handleEvaluationMessage(message: EvaluationMessage) { + if (message.type === "invalidateAllCells") { + this.pivotCellsCache = new PositionMap(); + } else if (message.type === "invalidateCell") { + this.pivotCellsCache.delete(message.position); + } else if (message.type === "addPivotToPosition") { + this.pivotCellsCache.set(message.position, message.item); + } + } + // --------------------------------------------------------------------- // Getters // --------------------------------------------------------------------- + getPivotInfoAtPosition(position: CellPosition): PivotCacheItem | undefined { + const cachedAtPosition = this.pivotCellsCache.get(position); + if (cachedAtPosition) { + return cachedAtPosition; + } + const mainPosition = this.getters.getArrayFormulaSpreadingOn(position); + return mainPosition ? this.pivotCellsCache.get(mainPosition) : undefined; + } + /** * Get the id of the pivot at the given position. Returns undefined if there * is no pivot at this position */ getPivotIdFromPosition(position: CellPosition) { - const cell = this.getters.getCorrespondingFormulaCell(position); - if (cell && cell.isFormula) { - const pivotFunction = this.getFirstPivotFunction( - position.sheetId, - cell.compiledFormula.tokens - ); - if (pivotFunction) { - const pivotId = pivotFunction.args[0]?.toString(); - return pivotId && this.getters.getPivotId(pivotId); - } - } - return undefined; + return this.getPivotInfoAtPosition(position)?.pivotId; } isSpillPivotFormula(position: CellPosition) { - const cell = this.getters.getCorrespondingFormulaCell(position); - if (cell && cell.isFormula) { - const pivotFunction = this.getFirstPivotFunction( - position.sheetId, - cell.compiledFormula.tokens - ); - return pivotFunction?.functionName === "PIVOT"; - } - return false; + return this.getPivotInfoAtPosition(position)?.type === "dynamic"; } getFirstPivotFunction(sheetId: UID, tokens: Token[]) { @@ -188,87 +194,40 @@ export class PivotUIPlugin extends CoreViewPlugin { */ getPivotCellFromPosition(position: CellPosition): PivotTableCell { const cell = this.getters.getCorrespondingFormulaCell(position); - if (!cell || !cell.isFormula || getNumberOfPivotFunctions(cell.compiledFormula.tokens) === 0) { - return EMPTY_PIVOT_CELL; - } - const mainPosition = this.getters.getCellPosition(cell.id); - const result = this.getters.getFirstPivotFunction( - position.sheetId, - cell.compiledFormula.tokens - ); - if (!result) { + if (!cell || !cell.isFormula) { return EMPTY_PIVOT_CELL; } - let { functionName, args } = result; - const formulaId = args[0]; - if (!formulaId) { + const pivotInfo = this.getPivotInfoAtPosition(position); + if (!pivotInfo || pivotInfo.type === "error") { return EMPTY_PIVOT_CELL; } - const pivotId = this.getters.getPivotId(formulaId.toString()); - if (!pivotId) { - return EMPTY_PIVOT_CELL; - } - const pivot = this.getPivot(pivotId); - if (!pivot.isValid()) { - return EMPTY_PIVOT_CELL; + if (pivotInfo.type === "static") { + return pivotInfo.pivotCell; } - if ( - functionName === "PIVOT" && - !cell.content.replaceAll(" ", "").toUpperCase().startsWith("=PIVOT") - ) { + + const mainPosition = this.getters.getArrayFormulaSpreadingOn(position) || position; + if (!this.checkIfCellStartsWithPivotFunction(mainPosition)) { return EMPTY_PIVOT_CELL; } - if (functionName === "PIVOT") { - const pivotStyle = getPivotStyleFromFnArgs( - this.getters.getPivotCoreDefinition(pivotId), - toScalar(args[1]), - toScalar(args[2]), - toScalar(args[3]), - toScalar(args[4]), - toScalar(args[5]), - this.getters.getLocale() - ); - const pivotCells = pivot.getCollapsedTableStructure().getPivotCells(pivotStyle); - const pivotCol = position.col - mainPosition.col; - const pivotRow = position.row - mainPosition.row; - return pivotCells[pivotCol][pivotRow]; + + const offsetRow = position.row - mainPosition.row; + const offsetCol = position.col - mainPosition.col; + const pivot = this.getPivot(pivotInfo.pivotId); + const pivotCells = pivot.getCollapsedTableStructure().getPivotCells(pivotInfo.pivotStyle); + return pivotCells[offsetCol][offsetRow]; + } + + private checkIfCellStartsWithPivotFunction(position: CellPosition): boolean { + const cell = this.getters.getCell(position); + if (!cell || !cell.isFormula) { + return false; } - try { - const offsetRow = position.row - mainPosition.row; - const offsetCol = position.col - mainPosition.col; - args = args.map((arg) => (isMatrix(arg) ? arg[offsetCol][offsetRow] : arg)); - if (functionName === "PIVOT.HEADER" && args.at(-2) === "measure") { - const domain = pivot.parseArgsToPivotDomain( - args.slice(1, -2).map((value) => ({ value } as FunctionResultObject)) - ); - return { - type: "MEASURE_HEADER", - domain, - measure: args.at(-1)?.toString() || "", - }; - } else if (functionName === "PIVOT.HEADER") { - const domain = pivot.parseArgsToPivotDomain( - args.slice(1).map((value) => ({ value } as FunctionResultObject)) - ); - const colRowDomain = domainToColRowDomain(pivot, domain); - return { - type: "HEADER", - domain, - dimension: colRowDomain.colDomain.length ? "COL" : "ROW", - }; - } - const [measure, ...domainArgs] = args.slice(1); - const domain = pivot.parseArgsToPivotDomain( - domainArgs.map((value) => ({ value } as FunctionResultObject)) - ); - return { - type: "VALUE", - domain, - measure: measure?.toString() || "", - }; - } catch (_) { - return EMPTY_PIVOT_CELL; + const tokens = cell.compiledFormula.tokens; + for (let i = 1; i < tokens.length; i++) { + if (tokens[i].type === "SPACE") continue; + return tokens[i].type === "SYMBOL" && tokens[i].value.toUpperCase().startsWith("PIVOT"); } + return false; } generateNewCalculatedMeasureName(measures: PivotCoreMeasure[]) { diff --git a/packages/o-spreadsheet-engine/src/plugins/ui_feature/subtotal_evaluation.ts b/packages/o-spreadsheet-engine/src/plugins/ui_feature/subtotal_evaluation.ts index dff4cc2392..6a1eb37387 100644 --- a/packages/o-spreadsheet-engine/src/plugins/ui_feature/subtotal_evaluation.ts +++ b/packages/o-spreadsheet-engine/src/plugins/ui_feature/subtotal_evaluation.ts @@ -1,48 +1,54 @@ -import { Cell } from "../../types/cells"; +import { CellPosition } from "../.."; +import { PositionMap } from "../../helpers/cells/position_map"; +import { + evaluationListenerRegistry, + EvaluationMessage, +} from "../../helpers/pivot/evaluation_listener_registry"; import { Command, invalidSubtotalFormulasCommands } from "../../types/commands"; -import { UIPlugin } from "../ui_plugin"; +import { UIPlugin, UIPluginConfig } from "../ui_plugin"; export class SubtotalEvaluationPlugin extends UIPlugin { - private subtotalCells: Set = new Set(); + static getters = ["isSubtotalCell"] as const; + + private subtotalPositions = new PositionMap(); + + constructor(config: UIPluginConfig) { + super(config); + evaluationListenerRegistry.replace("SubtotalEvaluationPlugin", { + handleEvaluationMessage: this.handleEvaluationMessage.bind(this), + }); + } handle(cmd: Command) { - switch (cmd.type) { - case "START": { - this.subtotalCells.clear(); - for (const sheetId of this.getters.getSheetIds()) { - const cells = this.getters.getCells(sheetId); - for (const cellId in cells) { - const cell = cells[cellId]; - if (isSubtotalCell(cell)) { - this.subtotalCells.add(cell.id); - } - } - } - break; - } - case "UPDATE_CELL": { - if (!("content" in cmd)) return; - const cell = this.getters.getCell(cmd); - if (!cell) return; - if (isSubtotalCell(cell)) { - this.subtotalCells.add(cell.id); - } else { - this.subtotalCells.delete(cell.id); - } - break; - } - } if (invalidSubtotalFormulasCommands.has(cmd.type)) { - this.dispatch("EVALUATE_CELLS", { cellIds: Array.from(this.subtotalCells) }); + this.dispatch("EVALUATE_CELLS", { + cellIds: this.getSubtotalCellIds(), + }); } } -} -export function isSubtotalCell(cell: Cell): boolean { - return ( - cell.isFormula && - cell.compiledFormula.tokens.some( - (t) => t.type === "SYMBOL" && t.value.toUpperCase() === "SUBTOTAL" - ) - ); + handleEvaluationMessage(message: EvaluationMessage) { + if (message.type === "invalidateAllCells") { + this.subtotalPositions = new PositionMap(); + } else if (message.type === "invalidateCell") { + this.subtotalPositions.delete(message.position); + } else if (message.type === "addSubTotalToPosition") { + this.subtotalPositions.set(message.position, true); + } + } + + isSubtotalCell(position: CellPosition): boolean { + return this.subtotalPositions.has(position); + } + + private getSubtotalCellIds(): string[] { + const cellIds: string[] = []; + for (const position of this.subtotalPositions.keys()) { + const cellId = this.getters.getCell(position)?.id; + if (cellId) { + cellIds.push(cellId); + } + } + return cellIds; + } } diff --git a/packages/o-spreadsheet-engine/src/types/commands.ts b/packages/o-spreadsheet-engine/src/types/commands.ts index 820644c733..1cf03f8a68 100644 --- a/packages/o-spreadsheet-engine/src/types/commands.ts +++ b/packages/o-spreadsheet-engine/src/types/commands.ts @@ -144,6 +144,7 @@ export const invalidateEvaluationCommands = new Set([ "RENAME_PIVOT", "REMOVE_PIVOT", "DUPLICATE_PIVOT", + "REFRESH_PIVOT", ]); export const invalidateChartEvaluationCommands = new Set([ diff --git a/packages/o-spreadsheet-engine/src/types/functions.ts b/packages/o-spreadsheet-engine/src/types/functions.ts index fb3246731e..c11c9e8005 100644 --- a/packages/o-spreadsheet-engine/src/types/functions.ts +++ b/packages/o-spreadsheet-engine/src/types/functions.ts @@ -1,3 +1,4 @@ +import { EvaluationMessage } from "../helpers/pivot/evaluation_listener_registry"; import { CellValue } from "./cells"; import { Getters } from "./getters"; import { Locale } from "./locale"; @@ -66,6 +67,7 @@ export type EvalContext = { addDependencies?: (position: CellPosition, ranges: Range[]) => void; debug?: boolean; lookupCaches?: LookupCaches; + sendEvaluationMessage: (message: EvaluationMessage) => void; }; /** diff --git a/packages/o-spreadsheet-engine/src/types/getters.ts b/packages/o-spreadsheet-engine/src/types/getters.ts index dba0248937..f61e13aacf 100644 --- a/packages/o-spreadsheet-engine/src/types/getters.ts +++ b/packages/o-spreadsheet-engine/src/types/getters.ts @@ -72,4 +72,5 @@ export type Getters = { PluginGetters & PluginGetters & PluginGetters & - PluginGetters; + PluginGetters & + PluginGetters; diff --git a/packages/o-spreadsheet-engine/src/types/misc.ts b/packages/o-spreadsheet-engine/src/types/misc.ts index 79cc1494ec..9e3a9f5bc5 100644 --- a/packages/o-spreadsheet-engine/src/types/misc.ts +++ b/packages/o-spreadsheet-engine/src/types/misc.ts @@ -6,6 +6,7 @@ import { CellValue, EvaluatedCell } from "./cells"; import { Token } from "../formulas/tokenizer"; import { CommandResult } from "./commands"; import { Format } from "./format"; +import { PivotStyle, PivotTableCell } from "./pivot"; import { Range } from "./range"; /** @@ -418,3 +419,22 @@ export interface ValueAndLabel { value: T; label: string; } + +export interface StaticPivotCacheItem { + type: "static"; + pivotId: UID; + pivotCell: PivotTableCell; +} + +export interface DynamicPivotCacheItem { + type: "dynamic"; + pivotId: UID; + pivotStyle: Required; +} + +export interface ErrorPivotCacheItem { + type: "error"; + pivotId: UID; +} + +export type PivotCacheItem = StaticPivotCacheItem | DynamicPivotCacheItem | ErrorPivotCacheItem; diff --git a/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts b/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts index 0ff023f6a9..a8a4fa9534 100644 --- a/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts +++ b/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts @@ -18,7 +18,6 @@ import { NotificationStore } from "../../../../stores/notification_store"; import { SpreadsheetStore } from "../../../../stores/spreadsheet_store"; import { Command, UID } from "../../../../types"; -import { getFirstPivotFunction } from "@odoo/o-spreadsheet-engine/helpers/pivot/pivot_composer_helpers"; import { getPivotTooBigErrorMessage } from "../../../../../packages/o-spreadsheet-engine/src/components/translations_terms"; export class PivotSidePanelStore extends SpreadsheetStore { @@ -250,22 +249,14 @@ export class PivotSidePanelStore extends SpreadsheetStore { */ private isUpdatedPivotVisibleInViewportOnlyAsStaticPivot() { let staticPivotCount = 0; - const updatedPivotFormulaId = this.getters.getPivotFormulaId(this.pivotId); for (const position of this.getters.getVisibleCellPositions()) { - const cell = this.getters.getCell(position); - if (cell?.isFormula) { - const pivotFunction = getFirstPivotFunction(cell.compiledFormula.tokens); - const pivotFormulaId = pivotFunction?.args[0]?.value; - if (pivotFunction && updatedPivotFormulaId === pivotFormulaId.toString()) { - if (pivotFunction.functionName === "PIVOT") { - // if we have at least one dynamic pivot visible inserted the viewport - // we return false - return false; - } else { - staticPivotCount++; - } - } + const pivotInfo = this.getters.getPivotInfoAtPosition(position); + if (!pivotInfo || pivotInfo.pivotId !== this.pivotId) { + continue; + } else if (pivotInfo.type === "dynamic") { + return false; } + staticPivotCount++; } // we return true if there are only static pivots visible inserted the viewport, // otherwise false diff --git a/tests/functions/vectorization.test.ts b/tests/functions/vectorization.test.ts index d735af09cb..55991288bc 100644 --- a/tests/functions/vectorization.test.ts +++ b/tests/functions/vectorization.test.ts @@ -2,7 +2,7 @@ import { OPERATOR_MAP, UNARY_OPERATOR_MAP } from "@odoo/o-spreadsheet-engine"; import { functionRegistry } from "@odoo/o-spreadsheet-engine/functions/function_registry"; import { toScalar } from "@odoo/o-spreadsheet-engine/functions/helper_matrices"; import { toString } from "@odoo/o-spreadsheet-engine/functions/helpers"; -import { splitReference } from "../../src/helpers"; +import { positionToZone, splitReference, zoneToXc } from "../../src/helpers"; import { setCellContent } from "../test_helpers/commands_helpers"; import { addToRegistry, @@ -211,4 +211,21 @@ describe("vectorization", () => { ]); expect(checkFunctionDoesntSpreadBeyondRange(model, "D1:E2")).toBeTruthy(); }); + + test("evalContext.__originCellPosition points to the current offset during vectorization, not on the root cell", () => { + addToRegistry(functionRegistry, "TEST.FN", { + description: "a function with simple args", + args: [{ name: "arg1", description: "", type: ["ANY"] }], + compute: function () { + return this.__originCellPosition ? zoneToXc(positionToZone(this.__originCellPosition)) : ""; + }, + }); + + const model = createModelFromGrid(grid); + setCellContent(model, "D1", "=TEST.FN(A1:B2)"); + expect(getRangeValuesAsMatrix(model, "D1:E2")).toEqual([ + ["D1", "E1"], + ["D2", "E2"], + ]); + }); }); diff --git a/tests/pivots/pivot_menu_items.test.ts b/tests/pivots/pivot_menu_items.test.ts index f9debb438e..b0f1009e1e 100644 --- a/tests/pivots/pivot_menu_items.test.ts +++ b/tests/pivots/pivot_menu_items.test.ts @@ -70,7 +70,7 @@ describe("Pivot properties menu item", () => { addPivot(model, "M1:N1", {}, "1"); addPivot(model, "M1:N1", {}, "2"); setCellContent(model, "A1", `=PIVOT("1") + PIVOT("2")`); - expect(model.getters.getPivotIdFromPosition(model.getters.getActivePosition())).toBe("1"); + expect(model.getters.getPivotIdFromPosition(model.getters.getActivePosition())).toBe("2"); expect(cellMenuRegistry.get("pivot_properties").isVisible!(env)).toBe(true); }); @@ -83,14 +83,14 @@ describe("Pivot properties menu item", () => { expect(openSidePanel).toHaveBeenCalledWith("PivotSidePanel", { pivotId: "1" }); }); - test("It should open the pivot side panel when clicking on pivot_properties with the first pivot id", () => { + test("It should open the pivot side panel when clicking on pivot_properties with the last pivot id", () => { selectCell(model, "A1"); addPivot(model, "M1:N1", {}, "1"); addPivot(model, "M1:N1", {}, "2"); setCellContent(model, "A1", `=PIVOT("1") + PIVOT("2")`); const openSidePanel = jest.spyOn(env, "openSidePanel"); cellMenuRegistry.get("pivot_properties").execute!(env); - expect(openSidePanel).toHaveBeenCalledWith("PivotSidePanel", { pivotId: "1" }); + expect(openSidePanel).toHaveBeenCalledWith("PivotSidePanel", { pivotId: "2" }); }); }); diff --git a/tests/pivots/pivot_plugin.test.ts b/tests/pivots/pivot_plugin.test.ts index 2f06fe2fe2..af60b7c343 100644 --- a/tests/pivots/pivot_plugin.test.ts +++ b/tests/pivots/pivot_plugin.test.ts @@ -208,7 +208,7 @@ describe("Pivot plugin", () => { test("getPivotCellFromPosition can handle vectorization", () => { // prettier-ignore const grid = { - A1: "Stage", B1: "Price", C1: '=PIVOT.VALUE(1,"Price","Stage",SEQUENCE(2))', + A1: "Stage", B1: "Price", C1: '=PIVOT.VALUE(1,"price:sum","Stage",SEQUENCE(2))', A2: "1", B2: "10", A3: "2", B3: "30", }; diff --git a/tests/pivots/spreadsheet_pivot/spreadsheet_pivot_side_panel.test.ts b/tests/pivots/spreadsheet_pivot/spreadsheet_pivot_side_panel.test.ts index f7aa314fd4..f7ab09eab4 100644 --- a/tests/pivots/spreadsheet_pivot/spreadsheet_pivot_side_panel.test.ts +++ b/tests/pivots/spreadsheet_pivot/spreadsheet_pivot_side_panel.test.ts @@ -701,7 +701,7 @@ describe("Spreadsheet pivot side panel", () => { // add a static pivot in the viewport const { bottom: row, right: col } = model.getters.getActiveMainViewport(); - setCellContent(model, toXC(col, row), "=PIVOT.VALUE(1)"); + setCellContent(model, toXC(col, row), '=PIVOT.VALUE(1, "__count:sum")'); await click(fixture.querySelector(".o-pivot-measure .add-dimension")!); await click(fixture.querySelectorAll(".o-autocomplete-value")[1]); expect(mockNotify).toHaveBeenCalledWith({