,
+ a1NotationEnabled: boolean,
+): GridBaseColDef => {
+ const getFormulaResult = (id: GridRowId) =>
+ gridCellFormulaResultSelector(apiRef, { id, field: column.field });
+
+ const wrappedColumn: GridColDefWithFormulaWrappers = {
+ ...column,
+ formulaWrappedProperties: [],
+ };
+ const trackWrappedProperty = (
+ name: P,
+ originalValue: GridBaseColDef[P],
+ wrappedValue: GridBaseColDef[P],
+ ) => {
+ wrappedColumn[name] = wrappedValue as any;
+ wrappedColumn.formulaWrappedProperties.push({ name, originalValue, wrappedValue });
+ };
+
+ const originalRenderEditCell = column.renderEditCell;
+ const wrappedRenderEditCell: GridBaseColDef['renderEditCell'] = (params) => (
+
+ );
+ trackWrappedProperty('renderEditCell', originalRenderEditCell, wrappedRenderEditCell);
+
+ const originalValueParser = column.valueParser;
+ const wrappedValueParser: GridBaseColDef['valueParser'] = (value, row, colDef, parserApiRef) => {
+ if (isFormulaEditValue(value)) {
+ // Pass formula sources through untouched — including A1 text. A1→canonical
+ // freezing happens at commit in `valueSetter`, NOT here: `GridEditInputCell`
+ // runs `valueParser` on every keystroke and shows its result to the user, so
+ // converting here would surface canonical `REF(...)` text mid-edit.
+ return value;
+ }
+ return originalValueParser ? originalValueParser(value, row, colDef, parserApiRef) : value;
+ };
+ trackWrappedProperty('valueParser', originalValueParser, wrappedValueParser);
+
+ const originalValueSetter = column.valueSetter;
+ const wrappedValueSetter: GridBaseColDef['valueSetter'] = (value, row, colDef, setterApiRef) => {
+ const stored = (row as GridValidRowModel)[colDef.field];
+ // An escaped literal displays its unescaped form — committing that display
+ // value back (e.g. row edit mode) must keep the stored escape.
+ if (isEscapedFormulaSource(stored) && value === unescapeLiteralSource(stored)) {
+ return row;
+ }
+ if (isFormulaEditValue(value)) {
+ // A1 mode: the editor holds and commits A1 text (`valueParser` passes it
+ // through so the user keeps seeing A1, not canonical, while typing). Freeze
+ // to canonical here — `valueSetter` is the real commit hook
+ // (`getRowWithUpdatedValuesFromCellEditing` calls only the setter, never the
+ // parser). `convertA1ToCanonicalCommit` restores the stored canonical on an
+ // unchanged commit (the seed still matches) and re-freezes an edited formula
+ // otherwise. Escaped literals (`'=…`) are never transformed.
+ if (a1NotationEnabled && isFormulaSource(value)) {
+ return { ...row, [colDef.field]: convertA1ToCanonicalCommit(value, row, colDef, apiRef) };
+ }
+ return { ...row, [colDef.field]: value };
+ }
+ if (isFormulaSource(stored)) {
+ const result = getFormulaResult(gridRowIdSelector(apiRef, row));
+ if (result !== null) {
+ const evaluated = result.type === 'error' ? result.code : result.value;
+ if (areCommittedValuesEqual(value, evaluated)) {
+ // Data-loss protection: committing the evaluated value over its own
+ // formula (edit paths that bypass the formula editor, e.g. row edit
+ // mode) keeps the formula source.
+ return row;
+ }
+ }
+ }
+ return originalValueSetter
+ ? originalValueSetter(value, row, colDef, setterApiRef)
+ : { ...row, [colDef.field]: value };
+ };
+ trackWrappedProperty('valueSetter', originalValueSetter, wrappedValueSetter);
+
+ // Only wrapped when the column defines its own processor: adding one
+ // unconditionally would put an async `isProcessingProps` gate on every
+ // commit, blocking Enter right after a keystroke.
+ const originalPreProcessEditCellProps = column.preProcessEditCellProps;
+ if (originalPreProcessEditCellProps) {
+ const wrappedPreProcessEditCellProps: GridBaseColDef['preProcessEditCellProps'] = (params) => {
+ if (isFormulaEditValue(params.props.value)) {
+ // Permissive commit: formula syntax issues never block the edit.
+ return { ...params.props, error: false };
+ }
+ return originalPreProcessEditCellProps(params);
+ };
+ trackWrappedProperty(
+ 'preProcessEditCellProps',
+ originalPreProcessEditCellProps,
+ wrappedPreProcessEditCellProps,
+ );
+ }
+
+ // Row spanning compares `rowSpanValueGetter` outputs — formula cells must
+ // compare by evaluated value, not by raw source.
+ const originalRowSpanValueGetter = column.rowSpanValueGetter;
+ const wrappedRowSpanValueGetter: GridBaseColDef['rowSpanValueGetter'] = (
+ value,
+ row,
+ colDef,
+ getterApiRef,
+ ) => {
+ const result = getFormulaResult(gridRowIdSelector(apiRef, row));
+ if (result !== null) {
+ return result.type === 'error' ? result.code : (result.value as any);
+ }
+ if (originalRowSpanValueGetter) {
+ return originalRowSpanValueGetter(value, row, colDef, getterApiRef);
+ }
+ // Replicate the row spanning fallback chain — defining a
+ // `rowSpanValueGetter` would otherwise bypass `valueGetter`.
+ return getRowValueUtil(row, colDef, apiRef) as any;
+ };
+ trackWrappedProperty('rowSpanValueGetter', originalRowSpanValueGetter, wrappedRowSpanValueGetter);
+
+ const originalPastedValueParser = column.pastedValueParser;
+ const wrappedPastedValueParser: GridBaseColDef['pastedValueParser'] = (
+ value,
+ row,
+ colDef,
+ parserApiRef,
+ ) => {
+ if (isFormulaEditValue(value)) {
+ // A1 mode: a pasted formula is frozen to canonical with the Excel fill
+ // offset. Canonical text pasted from an in-grid copy passes through.
+ if (a1NotationEnabled && isFormulaSource(value) && row !== undefined) {
+ return convertA1ToCanonicalPaste(value, row, colDef, apiRef);
+ }
+ return value;
+ }
+ if (originalPastedValueParser) {
+ return originalPastedValueParser(value, row, colDef, parserApiRef);
+ }
+ // Replicate the clipboard fallback chain — defining `pastedValueParser`
+ // would otherwise bypass `valueParser`.
+ return colDef.valueParser ? colDef.valueParser(value, row, colDef, parserApiRef) : value;
+ };
+ trackWrappedProperty('pastedValueParser', originalPastedValueParser, wrappedPastedValueParser);
+
+ return wrappedColumn;
+};
+
+const isColumnWrappedWithFormula = (column: GridColDef): column is GridColDefWithFormulaWrappers =>
+ typeof (column as GridColDefWithFormulaWrappers).formulaWrappedProperties !== 'undefined';
+
+/**
+ * Remove the formula wrappers around the wrappable properties of the column.
+ */
+export const unwrapColumnFromFormula = (column: GridColDef) => {
+ if (!isColumnWrappedWithFormula(column)) {
+ return column;
+ }
+ const { formulaWrappedProperties, ...unwrappedColumn } = column as GridColDefWithFormulaWrappers;
+
+ formulaWrappedProperties.forEach(({ name, originalValue, wrappedValue }) => {
+ // The value changed since we wrapped it
+ if (wrappedValue !== unwrappedColumn[name]) {
+ return;
+ }
+ unwrappedColumn[name] = originalValue as any;
+ });
+
+ return unwrappedColumn;
+};
diff --git a/packages/x-data-grid-premium/src/hooks/features/index.ts b/packages/x-data-grid-premium/src/hooks/features/index.ts
index 5357a78959ff7..1b0e201cec3dc 100644
--- a/packages/x-data-grid-premium/src/hooks/features/index.ts
+++ b/packages/x-data-grid-premium/src/hooks/features/index.ts
@@ -1,5 +1,6 @@
// Only export the variable and types that should be publicly exposed and re-exported from `@mui/x-data-grid-premium`
export * from './aggregation';
+export * from './formula';
export * from './rowGrouping';
export * from './export';
export * from './cellSelection';
diff --git a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts
index 37e96d7d47db7..1da0b05b0dccb 100644
--- a/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts
+++ b/packages/x-data-grid-premium/src/hooks/features/rowGrouping/gridRowGroupingUtils.ts
@@ -1,4 +1,5 @@
import type { RefObject } from '@mui/x-internals/types';
+import { gridRowIdSelector } from '@mui/x-data-grid-pro';
import type {
GridRowTreeConfig,
GridFilterState,
@@ -229,9 +230,23 @@ export const getCellGroupingCriteria = ({
groupingRule: GridGroupingRule;
apiRef: RefObject;
}) => {
+ // Formula cells group by their evaluated value — error cells by their error
+ // code, bypassing `groupingValueGetter` like they bypass `valueFormatter`.
+ // The optional chain matters: the initial tree build runs before the
+ // formula state initializes, and the formula hook re-triggers the build
+ // once evaluated values exist.
+ const formulaResult = (apiRef.current.state as Partial).formula?.lookup[
+ gridRowIdSelector(apiRef, row)
+ ]?.[groupingRule.field];
+
let key: GridKeyValue | null | undefined;
- if (groupingRule.groupingValueGetter) {
- key = groupingRule.groupingValueGetter(row[groupingRule.field] as never, row, colDef, apiRef);
+ if (formulaResult !== undefined && formulaResult.type === 'error') {
+ key = formulaResult.code;
+ } else if (groupingRule.groupingValueGetter) {
+ const value = formulaResult === undefined ? row[groupingRule.field] : formulaResult.value;
+ key = groupingRule.groupingValueGetter(value as never, row, colDef, apiRef);
+ } else if (formulaResult !== undefined) {
+ key = formulaResult.value as GridKeyValue | null | undefined;
} else {
key = getRowValue(row, colDef, apiRef) as GridKeyValue | null | undefined;
}
diff --git a/packages/x-data-grid-premium/src/hooks/features/rows/useGridParamsOverridableMethods.ts b/packages/x-data-grid-premium/src/hooks/features/rows/useGridParamsOverridableMethods.ts
index d658a52af95da..af18bc574d147 100644
--- a/packages/x-data-grid-premium/src/hooks/features/rows/useGridParamsOverridableMethods.ts
+++ b/packages/x-data-grid-premium/src/hooks/features/rows/useGridParamsOverridableMethods.ts
@@ -3,26 +3,45 @@ import { gridRowIdSelector, type GridParamsApi } from '@mui/x-data-grid-pro';
import { useGridParamsOverridableMethods as useGridParamsOverridableMethodsCommunity } from '@mui/x-data-grid-pro/internals';
import type { RefObject } from '@mui/x-internals/types';
import { gridCellAggregationResultSelector } from '../aggregation/gridAggregationSelectors';
+import { gridCellFormulaResultSelector } from '../formula/gridFormulaSelectors';
import type { GridPrivateApiPremium } from '../../../models/gridApiPremium';
export const useGridParamsOverridableMethods = (apiRef: RefObject) => {
const communityMethods = useGridParamsOverridableMethodsCommunity(apiRef);
const getCellValue = React.useCallback(
- (id, field) =>
- gridCellAggregationResultSelector(apiRef, {
- id,
- field,
- })?.value ?? communityMethods.getCellValue(id, field),
+ (id, field) => {
+ const aggregationValue = gridCellAggregationResultSelector(apiRef, { id, field })?.value;
+ if (aggregationValue != null) {
+ return aggregationValue;
+ }
+ // Membership check, not value truthiness: a formula evaluating to `null`
+ // must still mask the raw `=` source.
+ const formulaResult = gridCellFormulaResultSelector(apiRef, { id, field });
+ if (formulaResult != null) {
+ return formulaResult.type === 'error' ? formulaResult.code : formulaResult.value;
+ }
+ return communityMethods.getCellValue(id, field);
+ },
[apiRef, communityMethods],
);
const getRowValue = React.useCallback(
- (row, colDef) =>
- gridCellAggregationResultSelector(apiRef, {
- id: gridRowIdSelector(apiRef, row),
+ (row, colDef) => {
+ const id = gridRowIdSelector(apiRef, row);
+ const aggregationValue = gridCellAggregationResultSelector(apiRef, {
+ id,
field: colDef.field,
- })?.value ?? communityMethods.getRowValue(row, colDef),
+ })?.value;
+ if (aggregationValue != null) {
+ return aggregationValue;
+ }
+ const formulaResult = gridCellFormulaResultSelector(apiRef, { id, field: colDef.field });
+ if (formulaResult != null) {
+ return formulaResult.type === 'error' ? formulaResult.code : formulaResult.value;
+ }
+ return communityMethods.getRowValue(row, colDef);
+ },
[apiRef, communityMethods],
);
@@ -37,6 +56,16 @@ export const useGridParamsOverridableMethods = (apiRef: RefObject (groupNode.depth === -1 ? 'footer' : 'inline')
*/
getAggregationPosition: (groupNode: GridGroupNode) => GridAggregationPosition | null;
+ /**
+ * If `true`, the formula evaluation is disabled: `=` cell values render as raw strings.
+ * @default false
+ */
+ disableFormulas: boolean;
+ /**
+ * Functions available to formulas, keyed by name.
+ * The prop replaces the built-in set: spread `GRID_FORMULA_FUNCTIONS` to extend it.
+ * @default GRID_FORMULA_FUNCTIONS when `dataSource` is not provided, `{}` when `dataSource` is provided
+ */
+ formulaFunctions: Record;
+ /**
+ * If `true`, formulas can be entered and are displayed using A1 notation
+ * (`=A1 + B2`) while still being stored in the canonical syntax.
+ * A leftmost row-number column and column-letter header adornments are shown.
+ * Has no effect when `disableFormulas` is `true` or a `dataSource` is set.
+ * @default false
+ */
+ formulaA1Notation: boolean;
+ /**
+ * If `true`, the suggestion dropdown shown while editing a formula cell is disabled.
+ * Has no effect when `disableFormulas` is `true` or a `dataSource` is set.
+ * @default false
+ */
+ disableFormulaAutocomplete: boolean;
/**
* If `true`, the clipboard paste is disabled.
* @default false
diff --git a/packages/x-data-grid-premium/src/models/gridApiPremium.ts b/packages/x-data-grid-premium/src/models/gridApiPremium.ts
index a3b1fba25e40b..8b48fb7a13958 100644
--- a/packages/x-data-grid-premium/src/models/gridApiPremium.ts
+++ b/packages/x-data-grid-premium/src/models/gridApiPremium.ts
@@ -19,6 +19,10 @@ import type {
GridDataSourceApiPremium,
} from '../hooks/features/dataSource/models';
import type { GridAggregationPrivateApi } from '../hooks/features/aggregation/gridAggregationInterfaces';
+import type {
+ GridFormulaApi,
+ GridFormulaPrivateApi,
+} from '../hooks/features/formula/gridFormulaInterfaces';
import type {
GridPivotingApi,
GridPivotingPrivateApi,
@@ -47,6 +51,7 @@ export interface GridApiPremium
GridRowPinningApi,
GridDataSourceApiPremium,
GridCellSelectionApi,
+ GridFormulaApi,
GridPivotingApi,
GridAiAssistantApi,
GridSidebarApi,
@@ -62,6 +67,7 @@ export interface GridPrivateApiPremium
GridPrivateOnlyApiCommon,
GridDataSourcePremiumPrivateApi,
GridAggregationPrivateApi,
+ GridFormulaPrivateApi,
GridDetailPanelPrivateApi,
GridRowReorderPrivateApi,
GridPivotingPrivateApi,
diff --git a/packages/x-data-grid-premium/src/models/gridStatePremium.ts b/packages/x-data-grid-premium/src/models/gridStatePremium.ts
index 1bfc242ad6d4b..3f794113ec25b 100644
--- a/packages/x-data-grid-premium/src/models/gridStatePremium.ts
+++ b/packages/x-data-grid-premium/src/models/gridStatePremium.ts
@@ -26,6 +26,7 @@ import type {
GridChartsIntegrationInitialState,
} from '../hooks/features/chartsIntegration/gridChartsIntegrationInterfaces';
import type { GridHistoryState } from '../hooks/features/history/gridHistoryInterfaces';
+import type { GridFormulaState } from '../hooks/features/formula/gridFormulaInterfaces';
/**
* The state of Data Grid Premium.
@@ -39,6 +40,7 @@ export interface GridStatePremium extends GridStatePro {
sidebar: GridSidebarState;
chartsIntegration: GridChartsIntegrationState;
history: GridHistoryState;
+ formula: GridFormulaState;
}
/**
diff --git a/packages/x-data-grid-premium/src/tests/exportExcel.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/exportExcel.DataGridPremium.test.tsx
index 95604b0ec4af4..1781f0ad7812a 100644
--- a/packages/x-data-grid-premium/src/tests/exportExcel.DataGridPremium.test.tsx
+++ b/packages/x-data-grid-premium/src/tests/exportExcel.DataGridPremium.test.tsx
@@ -395,6 +395,211 @@ describe(' - Export Excel', () => {
});
});
+ describe('formula export', () => {
+ function FormulaTest(props: Partial) {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+
+ it('exports live formulas as real Excel formulas when escapeFormulas is false', async () => {
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ expect(worksheet.getCell('C2').type).to.equal(Excel.ValueType.Formula);
+ expect((worksheet.getCell('C2').value as any).formula).to.equal('A2*B2');
+ expect((worksheet.getCell('C2').value as any).result).to.equal(20);
+ expect((worksheet.getCell('C3').value as any).formula).to.equal('A3*B3');
+ expect((worksheet.getCell('C4').value as any).formula).to.equal('A4*B4');
+ expect((worksheet.getCell('C4').value as any).result).to.equal(120);
+ });
+
+ it('exports evaluated values (no formulas) by default', async () => {
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel();
+ const worksheet = workbook!.worksheets[0];
+
+ expect(worksheet.getCell('C2').type).to.equal(Excel.ValueType.Number);
+ expect(worksheet.getCell('C2').value).to.equal(20);
+ expect(worksheet.getCell('C4').value).to.equal(120);
+ });
+
+ it('honors the export row order (sorting) for references', async () => {
+ render(
+ ,
+ );
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ // Sorted desc: id 2 (price 30) is the first exported row → Excel row 2.
+ expect((worksheet.getCell('C2').value as any).formula).to.equal('A2*B2');
+ expect((worksheet.getCell('C2').value as any).result).to.equal(120);
+ });
+
+ it('preserves absolute refs and re-anchors stable cross-row refs', async () => {
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ // Positional ref → absolute A1; stable ref to row id 0 → relative A1 at row 2.
+ expect((worksheet.getCell('B2').value as any).formula).to.equal('$A$2');
+ expect((worksheet.getCell('B2').value as any).result).to.equal(10);
+ expect((worksheet.getCell('B3').value as any).formula).to.equal('A2');
+ expect((worksheet.getCell('B3').value as any).result).to.equal(10);
+ });
+
+ it('shifts references for column-group header rows', async () => {
+ render(
+ ,
+ );
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ // 1 group-header row + 1 column-header row → first data row is Excel row 3.
+ expect((worksheet.getCell('C3').value as any).formula).to.equal('A3*B3');
+ expect((worksheet.getCell('C3').value as any).result).to.equal(20);
+ });
+
+ it('bakes #REF! for references to columns outside the export', async () => {
+ render();
+ // Export only the formula column: price/qty are not in the sheet.
+ const workbook = await apiRef.current?.getDataAsExcel({
+ escapeFormulas: false,
+ fields: ['total'],
+ });
+ const worksheet = workbook!.worksheets[0];
+
+ expect(worksheet.getCell('A2').type).to.equal(Excel.ValueType.Formula);
+ expect((worksheet.getCell('A2').value as any).formula).to.contain('#REF!');
+ expect((worksheet.getCell('A2').value as any).result).to.deep.equal({ error: '#REF!' });
+ });
+
+ it('exports an evaluation error as a formula with an error result', async () => {
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ expect(worksheet.getCell('B2').type).to.equal(Excel.ValueType.Formula);
+ expect((worksheet.getCell('B2').value as any).formula).to.equal('A2/0');
+ expect((worksheet.getCell('B2').value as any).result).to.deep.equal({ error: '#DIV/0!' });
+ });
+
+ it('does not promote a literal = string in a non-formula column to a formula', async () => {
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ // `note` is not an allowFormulas column, so its `=1+1` is never written as a formula.
+ expect(worksheet.getCell('A2').type).not.to.equal(Excel.ValueType.Formula);
+ expect(worksheet.getCell('A2').value).to.equal('=1+1');
+ });
+
+ it('exports a date-valued formula consistently with a plain date column', async () => {
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+ render();
+ const workbook = await apiRef.current?.getDataAsExcel({ escapeFormulas: false });
+ const worksheet = workbook!.worksheets[0];
+
+ // The formula's cached date result gets the same UTC reconstruction as the
+ // plain date column, so both cells hold the same serial.
+ const start = worksheet.getCell('A2').value as Date;
+ const copy = (worksheet.getCell('B2').value as any).result as Date;
+ expect(worksheet.getCell('B2').type).to.equal(Excel.ValueType.Formula);
+ expect(copy).to.be.instanceOf(Date);
+ expect(copy.getTime()).to.equal(start.getTime());
+ });
+ });
+
describe('web worker', () => {
let workerMock: { postMessage: SinonSpy };
@@ -444,5 +649,36 @@ describe(' - Export Excel', () => {
},
]);
});
+
+ it('includes formula descriptors in the serialized rows when escapeFormulas is false', async () => {
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+ render();
+ await act(() =>
+ apiRef.current?.exportDataAsExcel({
+ worker: () => workerMock as any,
+ escapeFormulas: false,
+ }),
+ );
+ const { serializedRows } = workerMock.postMessage.lastCall.args[0];
+ expect(serializedRows[0].formulas).to.deep.equal({
+ total: { formula: 'A2*B2', result: 20 },
+ });
+ });
});
});
diff --git a/packages/x-data-grid-premium/src/tests/formula.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/formula.DataGridPremium.test.tsx
new file mode 100644
index 0000000000000..482556a4f9cb1
--- /dev/null
+++ b/packages/x-data-grid-premium/src/tests/formula.DataGridPremium.test.tsx
@@ -0,0 +1,1956 @@
+import * as React from 'react';
+import { type RefObject } from '@mui/x-internals/types';
+import { createRenderer, fireEvent, act, waitFor } from '@mui/internal-test-utils';
+import { getCell, getColumnHeaderCell, getColumnValues, microtasks } from 'test/utils/helperFn';
+import { spy, type SinonSpy } from 'sinon';
+import { onTestFinished } from 'vitest';
+import {
+ DataGridPremium,
+ type DataGridPremiumProps,
+ type GridApi,
+ type GridFormulaFunctionDefinition,
+ GRID_FORMULA_FUNCTIONS,
+ useGridApiRef,
+} from '@mui/x-data-grid-premium';
+import { unwrapPrivateAPI } from '@mui/x-data-grid/internals';
+import { isJSDOM } from 'test/utils/skipIf';
+
+const baselineProps: DataGridPremiumProps = {
+ autoHeight: isJSDOM,
+ disableVirtualization: true,
+ rows: [
+ { id: 0, item: 'Apple', price: 2, quantity: 3, total: '=price * quantity' },
+ { id: 1, item: 'Banana', price: 1, quantity: 5, total: '=price * quantity' },
+ { id: 2, item: 'Cherry', price: 4, quantity: 2, total: 8 },
+ ],
+ columns: [
+ { field: 'item' },
+ { field: 'price', type: 'number' },
+ { field: 'quantity', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true, editable: true },
+ ],
+};
+
+describe(' - Formulas', () => {
+ const { render: originalRender } = createRenderer();
+
+ const render = async (...args: Parameters) => {
+ const utils = originalRender(...args);
+ await microtasks();
+ return utils;
+ };
+
+ let apiRef: RefObject;
+
+ function Test(props: Partial) {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+
+ function getCellInput(rowIndex: number, colIndex: number) {
+ return getCell(rowIndex, colIndex).querySelector('input')!;
+ }
+
+ describe('rendering', () => {
+ it('should render evaluated values in `allowFormulas` columns', async () => {
+ await render();
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+
+ it('should keep the formula source as the stored row-data value', async () => {
+ await render();
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ });
+
+ it('should never expose the raw source through getCellValue', async () => {
+ await render();
+ expect(apiRef.current!.getCellValue(0, 'total')).to.equal(6);
+ expect(apiRef.current!.getCellValue(2, 'total')).to.equal(8);
+ });
+
+ it('should treat `=` values in columns without `allowFormulas` as plain strings', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['=price']);
+ });
+
+ it("should display the unescaped literal for `'=` escaped sources", async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(3)).to.deep.equal(['=not a formula']);
+ expect(apiRef.current!.getCellValue(0, 'total')).to.equal('=not a formula');
+ expect(apiRef.current!.getRow(0).total).to.equal("'=not a formula");
+ });
+
+ it('should render each error code', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(3)).to.deep.equal([
+ '#ERROR!',
+ '#NAME?',
+ '#VALUE!',
+ '#DIV/0!',
+ '#REF!',
+ '#CYCLE!',
+ ]);
+ });
+
+ it('should resolve positional references against the view order', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['2']);
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.deep.equal({
+ type: 'value',
+ value: 2,
+ });
+ });
+
+ it('should bypass the column valueFormatter for error results but not for value results', async () => {
+ await render(
+ `formatted:${value}`,
+ },
+ ]}
+ />,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['formatted:3', '#DIV/0!']);
+ });
+ });
+
+ describe('dependencies and invalidation', () => {
+ it('should re-evaluate dependents transitively on updateRows', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['3']);
+ expect(getColumnValues(2)).to.deep.equal(['6']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 5 }]));
+
+ expect(getColumnValues(1)).to.deep.equal(['6']);
+ expect(getColumnValues(2)).to.deep.equal(['12']);
+ });
+
+ it('should evaluate stable cross-row REF() references and track their changes', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['1', '20']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 7 }]));
+ expect(getColumnValues(1)).to.deep.equal(['1', '70']);
+ });
+
+ it('should resolve references to a removed row as #REF!', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['1', '2']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, _action: 'delete' }]));
+ expect(getColumnValues(1)).to.deep.equal(['#REF!']);
+ });
+
+ it('should mark mutual references as #CYCLE!', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['#CYCLE!']);
+ expect(getColumnValues(1)).to.deep.equal(['#CYCLE!']);
+ });
+
+ it('should recover from a cycle when one side is replaced', async () => {
+ await render(
+ ,
+ );
+ await act(async () => apiRef.current!.updateRows([{ id: 0, b: 5 }]));
+ expect(getColumnValues(0)).to.deep.equal(['5']);
+ expect(getColumnValues(1)).to.deep.equal(['5']);
+ });
+
+ it('should re-evaluate cross-row formula-to-formula chains', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['4', '1']);
+ expect(getColumnValues(2)).to.deep.equal(['1', '5']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 10 }]));
+ expect(getColumnValues(1)).to.deep.equal(['20', '1']);
+ expect(getColumnValues(2)).to.deep.equal(['1', '21']);
+ });
+
+ it('should not mask a re-added row with the deleted row results', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['4', '50']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, _action: 'delete' }]));
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.equal(null);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 9, total: 100 }]));
+ expect(apiRef.current!.getCellValue(0, 'total')).to.equal(100);
+ expect(getColumnValues(1)).to.deep.equal(['50', '100']);
+ });
+
+ it('should re-evaluate when a referenced column is added or removed', async () => {
+ const columnsWithoutTax = [
+ { field: 'price', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true },
+ ] as DataGridPremiumProps['columns'];
+ const columnsWithTax = [
+ { field: 'price', type: 'number' },
+ { field: 'tax', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true },
+ ] as DataGridPremiumProps['columns'];
+
+ const { setProps } = await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['#REF!']);
+
+ setProps({ columns: columnsWithTax });
+ await microtasks();
+ expect(getColumnValues(2)).to.deep.equal(['10']);
+
+ setProps({ columns: columnsWithoutTax });
+ await microtasks();
+ expect(getColumnValues(1)).to.deep.equal(['#REF!']);
+ });
+
+ it('should publish formulaEvaluationEnd with the changed cells', async () => {
+ await render();
+ const listener = spy();
+ apiRef.current!.subscribeEvent('formulaEvaluationEnd', listener);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 10 }]));
+
+ expect(listener.callCount).to.equal(1);
+ expect(listener.lastCall.args[0].changedCells).to.deep.equal([{ id: 0, field: 'total' }]);
+ });
+
+ it('should not re-evaluate when an unrelated cell changes', async () => {
+ await render();
+ const listener = spy();
+ apiRef.current!.subscribeEvent('formulaEvaluationEnd', listener);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, item: 'Apricot' }]));
+
+ expect(listener.callCount).to.equal(0);
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+ });
+
+ describe('sorting and filtering', () => {
+ it('should sort by evaluated values', async () => {
+ await render();
+ await act(async () => apiRef.current!.setSortModel([{ field: 'total', sort: 'asc' }]));
+ expect(getColumnValues(3)).to.deep.equal(['5', '6', '8']);
+ });
+
+ it('should filter on evaluated values', async () => {
+ await render();
+ await act(async () =>
+ apiRef.current!.setFilterModel({
+ items: [{ field: 'total', operator: '>', value: 5 }],
+ }),
+ );
+ expect(getColumnValues(3)).to.deep.equal(['6', '8']);
+ });
+
+ it('should quick-filter on evaluated values', async () => {
+ await render();
+ await act(async () =>
+ apiRef.current!.setFilterModel({ items: [], quickFilterValues: ['6'] }),
+ );
+ expect(getColumnValues(3)).to.deep.equal(['6']);
+ });
+
+ it('should keep sorting and filtering working after a dependency update', async () => {
+ await render();
+ await act(async () => apiRef.current!.setSortModel([{ field: 'total', sort: 'asc' }]));
+ await act(async () => apiRef.current!.updateRows([{ id: 1, price: 100 }]));
+ // One-shot policy: values re-evaluate and re-sort within the same rows
+ // update cascade.
+ expect(getColumnValues(3)).to.deep.equal(['6', '8', '500']);
+ });
+ });
+
+ describe('ranges and positional references', () => {
+ const summaryColumns = [
+ { field: 'price', type: 'number' },
+ { field: 'summary', type: 'number', allowFormulas: true },
+ ] as DataGridPremiumProps['columns'];
+
+ it('should sum a column with COLUMN_VALUES', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['10', '', '']);
+ });
+
+ it('should recompute only the range dependents on a single-cell edit', async () => {
+ await render(
+ ,
+ );
+ const listener = spy();
+ apiRef.current!.subscribeEvent('formulaEvaluationEnd', listener);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 1, price: 10 }]));
+
+ expect(getColumnValues(1)).to.deep.equal(['17', '', '']);
+ expect(listener.callCount).to.equal(1);
+ expect(listener.lastCall.args[0].changedCells).to.deep.equal([{ id: 0, field: 'summary' }]);
+ });
+
+ it('should evaluate formula cells inside a range before the range consumer', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['8', '']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, base: 10 }]));
+ expect(getColumnValues(1)).to.deep.equal(['20', '4']);
+ expect(getColumnValues(2)).to.deep.equal(['24', '']);
+ });
+
+ it('should materialize COLUMN_VALUES over the filtered row set', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['10', '', '']);
+
+ await act(async () =>
+ apiRef.current!.setFilterModel({
+ items: [{ field: 'category', operator: 'equals', value: 'keep' }],
+ }),
+ );
+ expect(getColumnValues(2)).to.deep.equal(['5', '']);
+
+ await act(async () => apiRef.current!.setFilterModel({ items: [] }));
+ expect(getColumnValues(2)).to.deep.equal(['10', '', '']);
+ });
+
+ it('should sum COLUMN_VALUES of a hidden column', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['5', '']);
+ });
+
+ it('should evaluate RANGE rectangles and track edits inside the bounds', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['10', '', '']);
+
+ const listener = spy();
+ apiRef.current!.subscribeEvent('formulaEvaluationEnd', listener);
+
+ // A change inside the rectangle recomputes the consumer.
+ await act(async () => apiRef.current!.updateRows([{ id: 1, p2: 14 }]));
+ expect(getColumnValues(2)).to.deep.equal(['20', '', '']);
+ expect(listener.callCount).to.equal(1);
+
+ // A change outside the rectangle does not.
+ await act(async () => apiRef.current!.updateRows([{ id: 2, p1: 7 }]));
+ expect(listener.callCount).to.equal(1);
+ expect(getColumnValues(2)).to.deep.equal(['20', '', '']);
+ });
+
+ it('should resolve a RANGE anchor without a view position as #REF! and recover', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['10', '', '']);
+
+ // The anchor row is filtered out — it has no position to resolve against.
+ await act(async () =>
+ apiRef.current!.setFilterModel({
+ items: [{ field: 'category', operator: 'equals', value: 'keep' }],
+ }),
+ );
+ expect(getColumnValues(2)).to.deep.equal(['#REF!', '']);
+ const result = apiRef.current!.getCellFormulaResult(0, 'summary');
+ expect(result?.type === 'error' && result.message).to.include(
+ 'has no position in the current view',
+ );
+
+ await act(async () => apiRef.current!.setFilterModel({ items: [] }));
+ expect(getColumnValues(2)).to.deep.equal(['10', '', '']);
+ });
+
+ it('should mark a COLUMN_VALUES aggregation over its own column as #CYCLE!', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['#CYCLE!', '5']);
+ });
+
+ it('should rebind positional references after sorting', async () => {
+ await render(
+ ,
+ );
+ expect(apiRef.current!.getCellFormulaResult(0, 'top')).to.deep.equal({
+ type: 'value',
+ value: 30,
+ });
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'price', sort: 'asc' }]));
+ // The first view row is now id 1.
+ expect(apiRef.current!.getCellFormulaResult(0, 'top')).to.deep.equal({
+ type: 'value',
+ value: 10,
+ });
+ });
+
+ it('should not re-sort after rebinding a position-dependent sorted column', async () => {
+ await render(
+ ,
+ );
+ // Initial view order [a, b, c]: posVal = [price@3, price@2, price@1].
+ expect(getColumnValues(2)).to.deep.equal(['20', '10', '30']);
+
+ const sortListener = spy();
+ apiRef.current!.subscribeEvent('sortedRowsSet', sortListener);
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'posVal', sort: 'asc' }]));
+
+ // The comparator consumed the values as of when it ran: [b, a, c].
+ expect(getColumnValues(0)).to.deep.equal(['b', 'a', 'c']);
+ // One-shot rebind (D4): the values re-evaluated against the new order
+ // exactly once — and even though they now disagree with the ascending
+ // sort, the grid did not re-sort.
+ expect(getColumnValues(2)).to.deep.equal(['30', '20', '10']);
+ expect(sortListener.callCount).to.equal(1);
+ });
+
+ it('should rebind COLUMN_POSITION references when column visibility changes', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['5']);
+
+ await act(async () => apiRef.current!.setColumnVisibility('price', false));
+ // `tax` is the first visible column now.
+ expect(getColumnValues(1)).to.deep.equal(['7']);
+
+ await act(async () => apiRef.current!.setColumnVisibility('price', true));
+ expect(getColumnValues(2)).to.deep.equal(['5']);
+ });
+
+ it('should rebind COLUMN_POSITION references on programmatic column reorder', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(2)).to.deep.equal(['5']);
+
+ await act(async () => apiRef.current!.setColumnIndex('tax', 0));
+ expect(getColumnValues(2)).to.deep.equal(['7']);
+ });
+
+ it('should exclude pinned rows from the position context', async () => {
+ await render(
+ ,
+ );
+ expect(apiRef.current!.getCellFormulaResult(0, 'top')).to.deep.equal({
+ type: 'value',
+ value: 5,
+ });
+ });
+
+ it('should include rows added with updateRows in COLUMN_VALUES', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['5', '']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 2, price: 10 }]));
+ expect(getColumnValues(1)).to.deep.equal(['15', '', '']);
+ });
+
+ it('should drop rows removed with updateRows from COLUMN_VALUES', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['15', '', '']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 2, _action: 'delete' }]));
+ expect(getColumnValues(1)).to.deep.equal(['5', '']);
+ });
+
+ it('should resolve RANGE rectangles positionally: a re-sort changes the covered rows', async () => {
+ await render(
+ ,
+ );
+ // Anchors at view positions 1 and 2: rows 0 and 1.
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 9,
+ });
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'price', sort: 'asc' }]));
+ // View order is now [0, 2, 3, 1]: the anchors sit at positions 1 and 4,
+ // so the rectangle covers every row (D6: positional bind-time resolution).
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 15,
+ });
+ });
+
+ it('should not re-filter after rebinding a position-dependent filtered column', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['10', '5', '7']);
+
+ const filterListener = spy();
+ apiRef.current!.subscribeEvent('filteredRowsSet', filterListener);
+
+ await act(async () =>
+ apiRef.current!.setFilterModel({
+ items: [{ field: 'posVal', operator: '>=', value: 7 }],
+ }),
+ );
+
+ // The filter consumed [10, 5, 7] and kept rows 0 and 2. The rebind then
+ // re-evaluated row 2 against the two-row view, where position 3 does not
+ // exist — but the grid never re-filters (one-shot, D4): the row stays
+ // visible showing #REF!.
+ expect(getColumnValues(1)).to.deep.equal(['10', '#REF!']);
+ expect(filterListener.callCount).to.equal(1);
+ });
+
+ it('should materialize COLUMN_VALUES over all pages', async () => {
+ await render(
+ ,
+ );
+ // The position context ignores pagination: all 4 rows take part.
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 15,
+ });
+ });
+
+ it('should resolve references with a custom getRowId', async () => {
+ await render(
+ row.code}
+ columns={[
+ { field: 'price', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true },
+ ]}
+ />,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['7', '2']);
+ });
+
+ it('should materialize escaped literals inside COLUMN_VALUES as their display value', async () => {
+ await render(
+ ,
+ );
+ // The escaped literal contributes its unescaped display value, not the
+ // raw `'=x` source.
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: '=xy',
+ });
+ });
+
+ it('should give tree-data parents a row position', async () => {
+ await render(
+ row.path}
+ defaultGroupingExpansionDepth={-1}
+ rows={[
+ { id: 0, path: ['A'], price: 10, top: '=REF(COLUMN("price"), ROW_POSITION(1))' },
+ { id: 1, path: ['A', 'B'], price: 5, top: '=REF(COLUMN("price"), ROW_POSITION(2))' },
+ ]}
+ columns={[
+ { field: 'price', type: 'number' },
+ { field: 'top', type: 'number', allowFormulas: true },
+ ]}
+ />,
+ );
+ // The parent is a real data row: position 1 is the parent, 2 the child.
+ expect(apiRef.current!.getCellFormulaResult(0, 'top')).to.deep.equal({
+ type: 'value',
+ value: 10,
+ });
+ expect(apiRef.current!.getCellFormulaResult(1, 'top')).to.deep.equal({
+ type: 'value',
+ value: 5,
+ });
+ });
+
+ it('should exclude the checkbox selection column from column positions', async () => {
+ await render(
+ ,
+ );
+ // Position 1 is the first data column, not the `__check__` column.
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 5,
+ });
+ });
+
+ it('should report #REF! in dependents only when a referenced row is removed', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['', '2', '8']);
+
+ const listener = spy();
+ apiRef.current!.subscribeEvent('formulaEvaluationEnd', listener);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, _action: 'delete' }]));
+
+ expect(getColumnValues(1)).to.deep.equal(['#REF!', '8']);
+ expect(listener.callCount).to.equal(1);
+ expect(listener.lastCall.args[0].changedCells).to.deep.equal([{ id: 1, field: 'calc' }]);
+ });
+ });
+
+ describe('export', () => {
+ it('should export evaluated values to CSV', async () => {
+ await render();
+ const csv = apiRef.current!.getDataAsCsv();
+ expect(csv).to.include('6');
+ expect(csv).not.to.include('price * quantity');
+ });
+
+ it('should escape string results starting with `=` in CSV exports', async () => {
+ await render(
+ ,
+ );
+ const csv = apiRef.current!.getDataAsCsv();
+ expect(csv).to.include("'=2");
+ });
+
+ it('should export error codes to CSV, bypassing the valueFormatter', async () => {
+ await render(
+ `formatted:${value}`,
+ },
+ ]}
+ />,
+ );
+ const csv = apiRef.current!.getDataAsCsv();
+ expect(csv).to.include('#DIV/0!');
+ expect(csv).not.to.include('formatted:');
+ });
+
+ it('should export evaluated values to Excel', async () => {
+ await render();
+ const workbook = await act(() => apiRef.current!.getDataAsExcel());
+ const worksheet = workbook!.worksheets[0];
+ // Column D is `total`, data starts at row 2.
+ expect(worksheet.getCell('D2').value).to.equal(6);
+ expect(worksheet.getCell('D3').value).to.equal(5);
+ expect(worksheet.getCell('D4').value).to.equal(8);
+ });
+ });
+
+ describe('clipboard', () => {
+ let writeText: SinonSpy | undefined;
+
+ afterEach(function afterEachHook() {
+ writeText?.restore();
+ writeText = undefined;
+ });
+
+ it('should copy the evaluated value, not the formula source', async () => {
+ const { user } = await render();
+ writeText = spy(navigator.clipboard, 'writeText');
+
+ const cell = getCell(0, 3);
+ await user.click(cell);
+ fireEvent.keyDown(cell, { key: 'c', keyCode: 67, ctrlKey: true });
+
+ expect(writeText.lastCall.args[0]).to.equal('6');
+ });
+
+ it('should paste `=` strings as formulas', async () => {
+ const { user } = await render();
+
+ const cell = getCell(2, 3);
+ await user.click(cell);
+
+ const pasteEvent = new Event('paste');
+ // @ts-ignore
+ pasteEvent.clipboardData = { getData: () => '=price + quantity' };
+ fireEvent.keyDown(cell, { key: 'v', keyCode: 86, ctrlKey: true });
+ await act(async () => document.activeElement!.dispatchEvent(pasteEvent));
+
+ await waitFor(() => {
+ expect(apiRef.current!.getRow(2).total).to.equal('=price + quantity');
+ });
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '6']);
+ });
+
+ it('should paste range formulas and bind them to the current view', async () => {
+ const { user } = await render();
+
+ const cell = getCell(2, 3);
+ await user.click(cell);
+
+ const pasteEvent = new Event('paste');
+ // @ts-ignore
+ pasteEvent.clipboardData = { getData: () => '=SUM(COLUMN_VALUES("price"))' };
+ fireEvent.keyDown(cell, { key: 'v', keyCode: 86, ctrlKey: true });
+ await act(async () => document.activeElement!.dispatchEvent(pasteEvent));
+
+ await waitFor(() => {
+ expect(apiRef.current!.getRow(2).total).to.equal('=SUM(COLUMN_VALUES("price"))');
+ });
+ // price column: 2 + 1 + 4.
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '7']);
+ });
+
+ it('should paste a plain value over an existing formula', async () => {
+ const { user } = await render();
+
+ const cell = getCell(0, 3);
+ await user.click(cell);
+
+ const pasteEvent = new Event('paste');
+ // @ts-ignore
+ pasteEvent.clipboardData = { getData: () => '42' };
+ fireEvent.keyDown(cell, { key: 'v', keyCode: 86, ctrlKey: true });
+ await act(async () => document.activeElement!.dispatchEvent(pasteEvent));
+
+ await waitFor(() => {
+ expect(apiRef.current!.getRow(0).total).to.equal(42);
+ });
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.equal(null);
+ expect(getColumnValues(3)).to.deep.equal(['42', '5', '8']);
+ });
+ });
+
+ describe('aggregation', () => {
+ it('should aggregate evaluated formula values', async () => {
+ await render();
+ await waitFor(() => {
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8', '19' /* footer */]);
+ });
+ });
+ });
+
+ describe('row grouping', () => {
+ const bucketRows = [
+ { id: 0, price: 2, bucket: '=IF(price > 2, "high", "low")' },
+ { id: 1, price: 3, bucket: '=IF(price > 2, "high", "low")' },
+ { id: 2, price: 1, bucket: 'low' },
+ ];
+ const bucketColumns = [
+ { field: 'price', type: 'number' },
+ { field: 'bucket', allowFormulas: true },
+ ] as DataGridPremiumProps['columns'];
+
+ it('should group by evaluated values from the initial render', async () => {
+ await render(
+ ,
+ );
+ // Plain `'low'` cells and formula cells evaluating to `'low'` share a group.
+ expect(getColumnValues(0)).to.deep.equal(['low (2)', '', '', 'high (1)', '']);
+ });
+
+ it('should move rows between groups when a dependency changes', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['low (2)', '', '', 'high (1)', '']);
+
+ await act(async () => apiRef.current!.updateRows([{ id: 1, price: 0 }]));
+ expect(getColumnValues(0)).to.deep.equal(['low (3)', '', '', '']);
+ });
+
+ it('should group error results by their error code', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['#DIV/0! (2)', '', '']);
+ });
+
+ it('should pass the evaluated value to groupingValueGetter', async () => {
+ await render(
+ `bucket-${value}`,
+ },
+ ]}
+ initialState={{ rowGrouping: { model: ['bucket'] } }}
+ defaultGroupingExpansionDepth={-1}
+ />,
+ );
+ expect(getColumnValues(0)).to.deep.equal(['bucket-low (2)', '', '', 'bucket-high (1)', '']);
+ });
+
+ it('should exclude autogenerated group rows from COLUMN_VALUES', async () => {
+ await render(
+ ,
+ );
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 10,
+ });
+ });
+
+ it('should exclude autogenerated group rows from row positions', async () => {
+ await render(
+ ,
+ );
+ // Position 1 is the first leaf, not the autogenerated group header
+ // (whose `price` would resolve to null).
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 2,
+ });
+ });
+
+ it('should exclude the grouping column from column positions', async () => {
+ await render(
+ ,
+ );
+ // The autogenerated grouping column takes no position: position 1 is
+ // the first data column, `category` (a leaf cell of the grouping
+ // column would resolve to null).
+ expect(apiRef.current!.getCellFormulaResult(0, 'summary')).to.deep.equal({
+ type: 'value',
+ value: 'x',
+ });
+ });
+ });
+
+ describe.skipIf(isJSDOM)('row spanning', () => {
+ function getSpannedCells() {
+ const privateApi = unwrapPrivateAPI(apiRef.current!);
+ return privateApi.virtualizer.store.state.rowSpanning.caches.spannedCells;
+ }
+
+ const spanColumns = [
+ { field: 'price', type: 'number' },
+ { field: 'quantity', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true },
+ ] as DataGridPremiumProps['columns'];
+
+ it('should span cells whose evaluated values are equal', async () => {
+ await render(
+ ,
+ );
+ await waitFor(() => {
+ // Different sources, equal evaluated values (6): rows 0 and 1 span.
+ expect(getSpannedCells()).to.deep.equal({ 0: { 2: 2 } });
+ });
+ });
+
+ it('should not span identical sources with different evaluated values', async () => {
+ await render(
+ ,
+ );
+ await microtasks();
+ expect(getSpannedCells()).to.deep.equal({});
+ });
+
+ it('should split the span when an edit changes an evaluated value', async () => {
+ await render(
+ ,
+ );
+ await waitFor(() => {
+ expect(getSpannedCells()).to.deep.equal({ 0: { 2: 2 } });
+ });
+
+ await act(async () => apiRef.current!.updateRows([{ id: 0, price: 5 }]));
+ await waitFor(() => {
+ expect(getSpannedCells()).to.deep.equal({});
+ });
+ });
+
+ it('should refresh spans after reevaluateFormulas', async () => {
+ await render(
+ ,
+ );
+ await waitFor(() => {
+ expect(getSpannedCells()).to.deep.equal({ 0: { 2: 2 } });
+ });
+
+ // In-place mutation: no rows cascade runs, the formula pass triggers
+ // the row spanning reset itself.
+ apiRef.current!.getRow(0).price = 5;
+ await act(async () => apiRef.current!.reevaluateFormulas());
+ await waitFor(() => {
+ expect(getSpannedCells()).to.deep.equal({});
+ });
+ });
+ });
+
+ describe('editing', () => {
+ it('should seed the editor with the formula source', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+ });
+
+ it('should render a text input for formulas even on number columns', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ expect(getCellInput(0, 3).type).to.equal('text');
+ });
+
+ it('should keep the default editor for plain cells of `allowFormulas` columns', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(2, 3));
+ expect(getCellInput(2, 3).type).to.equal('number');
+ });
+
+ it('should preserve the formula when the edit is committed without changes', async () => {
+ const { user } = await render( newRow} />);
+ const cell = getCell(0, 3);
+ await user.dblClick(cell);
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+
+ it('should preserve the formula when a row edit is committed without changes', async () => {
+ const { user } = await render();
+ const cell = getCell(0, 3);
+ await user.dblClick(cell);
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+
+ it('should discard changes on Escape', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=price + 100' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Escape' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+
+ it('should commit a new formula and re-evaluate', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=price + quantity' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price + quantity');
+ expect(getColumnValues(3)).to.deep.equal(['5', '5', '8']);
+ });
+
+ it('should commit a plain value over a formula', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '42' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal(42);
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.equal(null);
+ expect(getColumnValues(3)).to.deep.equal(['42', '5', '8']);
+ });
+
+ it('should commit invalid formulas permissively', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=1 +' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=1 +');
+ expect(getColumnValues(3)).to.deep.equal(['#ERROR!', '5', '8']);
+ });
+
+ it('should open the formula editor when typing `=` on a plain cell', async () => {
+ await render();
+ const cell = getCell(2, 3); // plain value 8 in the number column
+ await act(async () => cell.focus());
+ fireEvent.keyDown(cell, { key: '=' });
+ await microtasks();
+
+ expect(getCellInput(2, 3).type).to.equal('text');
+ });
+
+ it('should clear a formula when committing an emptied editor', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getCellFormula(0, 'total')).to.equal(null);
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.equal(null);
+ });
+
+ it('should round-trip an escaped literal through the editor', async () => {
+ const { user } = await render(
+ ,
+ );
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal("'=not a formula");
+ });
+
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal("'=not a formula");
+ expect(getColumnValues(3)).to.deep.equal(['=not a formula']);
+ });
+
+ it('should not seed the source when the edit starts by typing', async () => {
+ await render();
+ const cell = getCell(0, 3);
+ await act(async () => cell.focus());
+ fireEvent.keyDown(cell, { key: '5' });
+ await microtasks();
+
+ expect(getCellInput(0, 3).value).not.to.equal('=price * quantity');
+ });
+
+ it('should pass the formula source to processRowUpdate', async () => {
+ const processRowUpdate = spy((newRow) => newRow);
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=price + 1' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(processRowUpdate.lastCall.args[0].total).to.equal('=price + 1');
+ });
+
+ it('should keep the evaluation consistent when processRowUpdate rejects', async () => {
+ const { user } = await render(
+ Promise.reject(new Error('Rejected'))}
+ onProcessRowUpdateError={() => {}}
+ />,
+ );
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=price + 1' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ expect(apiRef.current!.getCellValue(0, 'total')).to.equal(6);
+ });
+
+ it('should restore formulas through undo/redo', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ await waitFor(() => {
+ expect(getCellInput(0, 3).value).to.equal('=price * quantity');
+ });
+
+ fireEvent.change(getCellInput(0, 3), { target: { value: '=price + quantity' } });
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+ expect(getColumnValues(3)).to.deep.equal(['5', '5', '8']);
+
+ await act(() => apiRef.current!.history.undo());
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+
+ await act(() => apiRef.current!.history.redo());
+ expect(apiRef.current!.getRow(0).total).to.equal('=price + quantity');
+ expect(getColumnValues(3)).to.deep.equal(['5', '5', '8']);
+ });
+ });
+
+ describe('valueGetter interplay', () => {
+ it('should resolve dependencies through the dependency column valueGetter', async () => {
+ await render(
+ value * 10,
+ },
+ { field: 'total', type: 'number', allowFormulas: true },
+ ]}
+ />,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['20']);
+ });
+
+ it('should ignore the valueGetter of the formula column for formula cells only', async () => {
+ const warnSpy = spy();
+ const originalWarn = console.warn;
+ console.warn = warnSpy;
+ onTestFinished(() => {
+ console.warn = originalWarn;
+ });
+ await render(
+ (typeof value === 'number' ? value * 100 : value),
+ },
+ ]}
+ />,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['3', '700']);
+ expect(
+ warnSpy
+ .getCalls()
+ .some((call) =>
+ call.args.some(
+ (arg) => typeof arg === 'string' && arg.includes('`allowFormulas` and `valueGetter`'),
+ ),
+ ),
+ ).to.equal(true);
+ });
+ });
+
+ describe('custom functions', () => {
+ const DOUBLE: GridFormulaFunctionDefinition = {
+ name: 'DOUBLE',
+ minArgs: 1,
+ maxArgs: 1,
+ apply: ([first]) => (typeof first === 'number' ? first * 2 : 0),
+ };
+
+ it('should support user-registered functions', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['6']);
+ });
+
+ it('should replace, not merge, the built-in function set', async () => {
+ await render(
+ ,
+ );
+ expect(getColumnValues(1)).to.deep.equal(['#NAME?']);
+ });
+ });
+
+ describe('api', () => {
+ it('should set a formula with setCellFormula', async () => {
+ await render();
+ await act(async () => apiRef.current!.setCellFormula(2, 'total', '=price + quantity'));
+ expect(apiRef.current!.getRow(2).total).to.equal('=price + quantity');
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '6']);
+ });
+
+ it('should throw when setCellFormula targets a column without allowFormulas', async () => {
+ await render();
+ expect(() => apiRef.current!.setCellFormula(0, 'item', '=price')).to.throw(
+ 'does not allow formulas',
+ );
+ });
+
+ it('should throw when setCellFormula receives a non-formula value', async () => {
+ await render();
+ expect(() => apiRef.current!.setCellFormula(0, 'total', 'price')).to.throw(
+ 'expects a formula source starting with `=`',
+ );
+ });
+
+ it('should return the source from getCellFormula and null for plain cells', async () => {
+ await render();
+ expect(apiRef.current!.getCellFormula(0, 'total')).to.equal('=price * quantity');
+ expect(apiRef.current!.getCellFormula(2, 'total')).to.equal(null);
+ });
+
+ it('should return the evaluation result from getCellFormulaResult', async () => {
+ await render();
+ expect(apiRef.current!.getCellFormulaResult(0, 'total')).to.deep.equal({
+ type: 'value',
+ value: 6,
+ });
+ expect(apiRef.current!.getCellFormulaResult(2, 'total')).to.equal(null);
+ });
+
+ it('should validate formulas with validateCellFormula', async () => {
+ await render();
+ expect(apiRef.current!.validateCellFormula('=price * quantity').valid).to.equal(true);
+ const invalid = apiRef.current!.validateCellFormula('=NOPE(1)');
+ expect(invalid.valid).to.equal(false);
+ expect(invalid.issues[0].code).to.equal('#NAME?');
+ });
+
+ it('should pick up in-place row mutations with reevaluateFormulas', async () => {
+ // Local rows: the test mutates a row object in place.
+ await render(
+ ,
+ );
+ expect(getColumnValues(3)).to.deep.equal(['6']);
+ apiRef.current!.getRow(0).price = 100;
+ await act(async () => apiRef.current!.reevaluateFormulas());
+ expect(getColumnValues(3)).to.deep.equal(['300']);
+ });
+ });
+
+ describe('guards', () => {
+ it('should render raw strings when disableFormulas is enabled', async () => {
+ await render();
+ expect(getColumnValues(3)).to.deep.equal(['=price * quantity', '=price * quantity', '8']);
+ expect(apiRef.current!.getCellValue(0, 'total')).to.equal('=price * quantity');
+ });
+
+ it('should toggle evaluation when disableFormulas changes', async () => {
+ const { setProps } = await render();
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+
+ setProps({ disableFormulas: true });
+ await microtasks();
+ expect(getColumnValues(3)).to.deep.equal(['=price * quantity', '=price * quantity', '8']);
+
+ setProps({ disableFormulas: false });
+ await microtasks();
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+
+ it('should not evaluate formulas when dataSource is set', async () => {
+ const warnSpy = spy();
+ const originalWarn = console.warn;
+ console.warn = warnSpy;
+ onTestFinished(() => {
+ console.warn = originalWarn;
+ });
+ const getRows = spy(async () => ({
+ rows: baselineProps.rows as Record[],
+ rowCount: 3,
+ }));
+ await render();
+ await waitFor(() => {
+ expect(getColumnValues(3).length).to.be.greaterThan(0);
+ });
+ expect(getColumnValues(3)[0]).to.equal('=price * quantity');
+ });
+ });
+
+ describe('A1 notation', () => {
+ const LETTER_CLASS = '.MuiDataGrid-formulaColumnHeaderLetter';
+ const ROW_NUMBER_FIELD = '__formula_row_number__';
+
+ // With A1 on, the pinned-left row-number column takes data-colindex 0, so the
+ // data columns shift right by one: item=1, price=2, quantity=3, total=4.
+
+ describe('prop off (default)', () => {
+ it('should not render the row-number column or header letters', async () => {
+ await render();
+ expect(
+ apiRef.current!.getAllColumns().some((column) => column.field === ROW_NUMBER_FIELD),
+ ).to.equal(false);
+ expect(getColumnHeaderCell(0).querySelector(LETTER_CLASS)).to.equal(null);
+ // Unchanged data layout: item is the first column.
+ expect(getColumnValues(0)).to.deep.equal(['Apple', 'Banana', 'Cherry']);
+ expect(getColumnValues(3)).to.deep.equal(['6', '5', '8']);
+ });
+ });
+
+ describe('header letters', () => {
+ it('should label data columns A, B, C… and skip the row-number column', async () => {
+ await render();
+ // colindex 0 is the row-number column: no letter, empty header.
+ expect(getColumnHeaderCell(0).querySelector(LETTER_CLASS)).to.equal(null);
+ expect(getColumnHeaderCell(1).querySelector(LETTER_CLASS)!.textContent).to.equal('A');
+ expect(getColumnHeaderCell(2).querySelector(LETTER_CLASS)!.textContent).to.equal('B');
+ expect(getColumnHeaderCell(3).querySelector(LETTER_CLASS)!.textContent).to.equal('C');
+ expect(getColumnHeaderCell(4).querySelector(LETTER_CLASS)!.textContent).to.equal('D');
+ });
+ });
+
+ describe('row-number column', () => {
+ it('should show sequential numbers that stay put after a re-sort', async () => {
+ await render();
+ expect(getColumnValues(0)).to.deep.equal(['1', '2', '3']);
+ expect(getColumnValues(1)).to.deep.equal(['Apple', 'Banana', 'Cherry']);
+ expect(getColumnValues(4)).to.deep.equal(['6', '5', '8']);
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'total', sort: 'asc' }]));
+
+ // Rows move between the numbers; the numbers themselves never travel.
+ expect(getColumnValues(0)).to.deep.equal(['1', '2', '3']);
+ expect(getColumnValues(1)).to.deep.equal(['Banana', 'Apple', 'Cherry']);
+ expect(getColumnValues(4)).to.deep.equal(['5', '6', '8']);
+ });
+
+ it('should match the positions ROW_POSITION resolves to', async () => {
+ await render(
+ ,
+ );
+ // Row showing number 1 is Apple, and ROW_POSITION(1) resolves to it.
+ expect(getColumnValues(0)).to.deep.equal(['1', '2']);
+ expect(getColumnValues(4)).to.deep.equal(['Apple', 'Apple']);
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'item', sort: 'desc' }]));
+
+ // Number 1 now shows Banana — and ROW_POSITION(1) re-binds to it.
+ expect(getColumnValues(0)).to.deep.equal(['1', '2']);
+ expect(getColumnValues(1)).to.deep.equal(['Banana', 'Apple']);
+ expect(getColumnValues(4)).to.deep.equal(['Banana', 'Banana']);
+ });
+
+ it('should be excluded from CSV export', async () => {
+ await render();
+ const csv = apiRef.current!.getDataAsCsv();
+ // The first column of every row is `item`, not an empty row-number cell.
+ expect(csv.split('\n')[1].startsWith('Apple')).to.equal(true);
+ });
+ });
+
+ describe('entry and storage', () => {
+ it('should store an A1 formula as canonical, never as A1', async () => {
+ const { user } = await render( row} />);
+ const cell = getCell(0, 4);
+ await user.dblClick(cell);
+ const input = cell.querySelector('input')!;
+ fireEvent.change(input, { target: { value: '=B1' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+
+ // B = price (column 2), row 1 = id 0 → frozen to the stable reference.
+ expect(apiRef.current!.getRow(0).total).to.equal('=REF(COLUMN("price"), ROW(0))');
+ expect(getColumnValues(4)).to.deep.equal(['2', '5', '8']);
+ });
+
+ it('should keep showing the typed A1 text in the editor, not its canonical form', async () => {
+ const { user } = await render( row} />);
+ const cell = getCell(0, 4);
+ await user.dblClick(cell);
+ const input = cell.querySelector('input')!;
+
+ // `valueParser` runs on every keystroke and its result is what the user
+ // sees — converting A1→canonical there surfaced `=REF(...)` mid-edit.
+ fireEvent.change(input, { target: { value: '=A2' } });
+ expect(input.value).to.equal('=A2');
+
+ fireEvent.change(input, { target: { value: '=A2 + B1' } });
+ expect(input.value).to.equal('=A2 + B1');
+
+ // The freeze to canonical happens at commit, never before.
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+ const stored = apiRef.current!.getRow(0).total as string;
+ expect(stored).to.contain('REF(');
+ expect(stored).not.to.contain('A2');
+ expect(stored).not.to.contain('B1');
+ });
+
+ it('should seed the editor with the A1 rendering of a stored canonical formula', async () => {
+ const { user } = await render(
+ ,
+ );
+ await user.dblClick(getCell(0, 4));
+ await waitFor(() => {
+ expect(getCell(0, 4).querySelector('input')!.value).to.equal('=B1');
+ });
+ });
+
+ it('should not re-freeze a stored formula on an unchanged commit', async () => {
+ const { user } = await render(
+ row}
+ rows={[
+ {
+ id: 0,
+ item: 'Apple',
+ price: 2,
+ quantity: 3,
+ total: '=REF(COLUMN("price"), ROW(0))',
+ },
+ ]}
+ />,
+ );
+ const cell = getCell(0, 4);
+ await user.dblClick(cell);
+ await waitFor(() => {
+ expect(cell.querySelector('input')!.value).to.equal('=B1');
+ });
+ fireEvent.keyDown(cell.querySelector('input')!, { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=REF(COLUMN("price"), ROW(0))');
+ });
+ });
+
+ describe('paste', () => {
+ it('should freeze pasted A1 formulas with the Excel fill offset', async () => {
+ const { user } = await render(
+ ,
+ );
+ const cell = getCell(0, 4);
+ await user.click(cell);
+
+ const pasteEvent = new Event('paste');
+ // @ts-ignore
+ pasteEvent.clipboardData = { getData: () => '=B1\n=B1' };
+ fireEvent.keyDown(cell, { key: 'v', keyCode: 86, ctrlKey: true });
+ await act(async () => document.activeElement!.dispatchEvent(pasteEvent));
+
+ await waitFor(() => {
+ expect(apiRef.current!.getRow(0).total).to.equal('=REF(COLUMN("price"), ROW(0))');
+ });
+ // The second target shifted its relative row by +1 → frozen to row id 1.
+ expect(apiRef.current!.getRow(1).total).to.equal('=REF(COLUMN("price"), ROW(1))');
+ expect(getColumnValues(4)).to.deep.equal(['2', '1', '8']);
+ });
+
+ it('should anchor the fill offset to the top-left cell even when it is not a formula', async () => {
+ const { user } = await render(
+ ,
+ );
+ const cell = getCell(0, 4);
+ await user.click(cell);
+
+ const pasteEvent = new Event('paste');
+ // Top-left target is a plain literal — it never reaches the A1 paste
+ // transform, so the offset origin must still be this cell, not the
+ // formula one row below it.
+ // @ts-ignore
+ pasteEvent.clipboardData = { getData: () => '5\n=B1' };
+ fireEvent.keyDown(cell, { key: 'v', keyCode: 86, ctrlKey: true });
+ await act(async () => document.activeElement!.dispatchEvent(pasteEvent));
+
+ await waitFor(() => {
+ expect(apiRef.current!.getRow(0).total).to.equal(5);
+ });
+ // Origin is row 0; the formula one row down freezes to row id 1, not id 0.
+ expect(apiRef.current!.getRow(1).total).to.equal('=REF(COLUMN("price"), ROW(1))');
+ });
+ });
+
+ describe('single-pass policy', () => {
+ it('should not re-sort a position-dependent column with the prop on', async () => {
+ await render(
+ ,
+ );
+ // item is colindex 1, posVal colindex 3 (row-number column at 0).
+ expect(getColumnValues(3)).to.deep.equal(['20', '10', '30']);
+
+ const sortListener = spy();
+ apiRef.current!.subscribeEvent('sortedRowsSet', sortListener);
+
+ await act(async () => apiRef.current!.setSortModel([{ field: 'posVal', sort: 'asc' }]));
+
+ expect(getColumnValues(1)).to.deep.equal(['b', 'a', 'c']);
+ expect(getColumnValues(3)).to.deep.equal(['30', '20', '10']);
+ expect(sortListener.callCount).to.equal(1);
+ });
+ });
+ });
+});
diff --git a/packages/x-data-grid-premium/src/tests/formula.fillHandle.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/formula.fillHandle.DataGridPremium.test.tsx
new file mode 100644
index 0000000000000..f6a4d2a488c95
--- /dev/null
+++ b/packages/x-data-grid-premium/src/tests/formula.fillHandle.DataGridPremium.test.tsx
@@ -0,0 +1,197 @@
+import * as React from 'react';
+import { type RefObject } from '@mui/x-internals/types';
+import { getCell } from 'test/utils/helperFn';
+import { createRenderer, act, fireEvent, waitFor } from '@mui/internal-test-utils';
+import {
+ DataGridPremium,
+ type DataGridPremiumProps,
+ type GridApi,
+ type GridColDef,
+ useGridApiRef,
+ gridClasses,
+} from '@mui/x-data-grid-premium';
+import { isJSDOM } from 'test/utils/skipIf';
+
+/**
+ * Fill-handle formula reference adjustment (I7). The fill handle and the
+ * Ctrl+D / Ctrl+R shortcuts both route through `getFilledFormulaSource`, so the
+ * jsdom-runnable shortcut tests exercise the same adjustment logic as the
+ * browser-only drag tests.
+ */
+describe(' - Formula fill handle', () => {
+ const { render } = createRenderer();
+
+ let apiRef: RefObject;
+
+ const columns: GridColDef[] = [
+ { field: 'price', type: 'number', editable: true },
+ { field: 'qty', type: 'number', editable: true },
+ { field: 'total', editable: true, allowFormulas: true },
+ { field: 'plain', editable: true },
+ ];
+
+ const PRODUCT_FORMULA = '=REF(COLUMN("price"), ROW("r0")) * REF(COLUMN("qty"), ROW("r0"))';
+
+ function makeRows(totalR0 = PRODUCT_FORMULA) {
+ return [
+ { id: 'r0', price: 2, qty: 3, total: totalR0, plain: '' },
+ { id: 'r1', price: 4, qty: 5, total: '', plain: '' },
+ { id: 'r2', price: 6, qty: 7, total: '', plain: '' },
+ { id: 'r3', price: 8, qty: 9, total: '', plain: '' },
+ ];
+ }
+
+ function TestGrid(props: Partial) {
+ apiRef = useGridApiRef();
+ return (
+
+ row.id}
+ rowSelection={false}
+ cellSelection
+ cellSelectionFillHandle
+ disableVirtualization
+ hideFooter
+ {...props}
+ />
+
+ );
+ }
+
+ const fillDownShortcut = (cell: HTMLElement) =>
+ fireEvent.keyDown(cell, { key: 'd', keyCode: 68, ctrlKey: true });
+ const fillRightShortcut = (cell: HTMLElement) =>
+ fireEvent.keyDown(cell, { key: 'r', keyCode: 82, ctrlKey: true });
+
+ it('adjusts relative references when filling a formula down (Ctrl+D)', async () => {
+ const { user } = render();
+ // r0 total evaluates price * qty = 2 * 3.
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('6'));
+
+ await user.click(getCell(0, 2));
+ fillDownShortcut(getCell(0, 2));
+
+ // r1 references shift down one row: price(r1) * qty(r1) = 4 * 5.
+ await waitFor(() => expect(getCell(1, 2).textContent).to.equal('20'));
+ const filled = apiRef.current!.getRow('r1')!.total as string;
+ expect(filled).to.contain('ROW("r1")');
+ expect(filled).not.to.contain('ROW("r0")');
+ });
+
+ it('keeps the stored source canonical and untouched on the origin cell', async () => {
+ const { user } = render();
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('6'));
+
+ await user.click(getCell(0, 2));
+ fillDownShortcut(getCell(0, 2));
+
+ await waitFor(() => expect(getCell(1, 2).textContent).to.equal('20'));
+ // The dragged-from cell is never rewritten.
+ expect(apiRef.current!.getRow('r0')!.total).to.equal(PRODUCT_FORMULA);
+ });
+
+ it('does not shift absolute (positional) references on fill', async () => {
+ const absolute = '=REF(COLUMN_POSITION(1), ROW_POSITION(1))'; // $A$1 → price of row 1
+ const { user } = render();
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('2'));
+
+ await user.click(getCell(0, 2));
+ fillDownShortcut(getCell(0, 2));
+
+ // Positional references stay pinned, so r1 still resolves to price of row 1 = 2.
+ await waitFor(() => expect(getCell(1, 2).textContent).to.equal('2'));
+ expect(apiRef.current!.getRow('r1')!.total).to.equal(absolute);
+ });
+
+ it('freezes overshoot references to #REF! when filling past the data', async () => {
+ // References the last row (r3); filling down one row overshoots the row set.
+ const lastRowRef = '=REF(COLUMN("price"), ROW("r3"))';
+ const { user } = render();
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('8'));
+
+ await user.click(getCell(0, 2));
+ fillDownShortcut(getCell(0, 2));
+
+ await waitFor(() => expect(getCell(1, 2).textContent).to.equal('#REF!'));
+ expect(apiRef.current!.getRow('r1')!.total).to.contain('ROW_POSITION(5)');
+ });
+
+ it('copies the evaluated value when filling into a non-allowFormulas column (Ctrl+R)', async () => {
+ const { user } = render();
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('6'));
+
+ // Fill right from `total` (formula) into `plain` (not allowFormulas).
+ await user.click(getCell(0, 2));
+ fillRightShortcut(getCell(0, 2));
+
+ await waitFor(() => expect(getCell(0, 3).textContent).to.equal('6'));
+ // The plain column receives the evaluated value, never a formula string.
+ expect(apiRef.current!.getRow('r0')!.plain).to.equal('6');
+ });
+
+ it('still adjusts correctly with A1 notation enabled (no double-adjustment)', async () => {
+ const { user } = render();
+ // `formulaA1Notation` injects a leftmost row-number column, so `total` is at
+ // visual column index 3 (row-number, price, qty, total).
+ const totalColIndex = 3;
+ await waitFor(() => expect(getCell(0, totalColIndex).textContent).to.equal('6'));
+
+ await user.click(getCell(0, totalColIndex));
+ fillDownShortcut(getCell(0, totalColIndex));
+
+ await waitFor(() => expect(getCell(1, totalColIndex).textContent).to.equal('20'));
+ const filled = apiRef.current!.getRow('r1')!.total as string;
+ expect(filled).to.contain('ROW("r1")');
+ expect(filled).not.to.contain('ROW("r0")');
+ });
+
+ describe.skipIf(isJSDOM)('Fill via mouse drag', () => {
+ /* eslint-disable testing-library/no-unnecessary-act */
+ async function simulateFillDrag(sourceCell: HTMLElement, targetCell: HTMLElement) {
+ act(() => {
+ const rect = sourceCell.getBoundingClientRect();
+ fireEvent.mouseDown(sourceCell, { clientX: rect.right - 4, clientY: rect.bottom - 4 });
+ });
+ act(() => {
+ const targetRect = targetCell.getBoundingClientRect();
+ document.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: targetRect.x + targetRect.width / 2,
+ clientY: targetRect.y + targetRect.height / 2,
+ bubbles: true,
+ }),
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(resolve as FrameRequestCallback));
+ });
+ });
+ act(() => {
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ });
+ }
+ /* eslint-enable testing-library/no-unnecessary-act */
+
+ it('adjusts references for every target row when dragging the handle down', async () => {
+ const { user } = render();
+ await waitFor(() => expect(getCell(0, 2).textContent).to.equal('6'));
+
+ await user.click(getCell(0, 2));
+ const handle = document.querySelector(
+ `.${gridClasses['cell--withFillHandle']}`,
+ )! as HTMLElement;
+
+ await simulateFillDrag(handle, getCell(3, 2));
+
+ await waitFor(() => expect(getCell(1, 2).textContent).to.equal('20'));
+ expect(getCell(2, 2).textContent).to.equal('42'); // 6 * 7
+ expect(getCell(3, 2).textContent).to.equal('72'); // 8 * 9
+ expect(apiRef.current!.getRow('r2')!.total).to.contain('ROW("r2")');
+ expect(apiRef.current!.getRow('r3')!.total).to.contain('ROW("r3")');
+ });
+ });
+});
diff --git a/packages/x-data-grid-premium/src/tests/formulaAutocomplete.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/formulaAutocomplete.DataGridPremium.test.tsx
new file mode 100644
index 0000000000000..90a8b577a3fb9
--- /dev/null
+++ b/packages/x-data-grid-premium/src/tests/formulaAutocomplete.DataGridPremium.test.tsx
@@ -0,0 +1,310 @@
+import * as React from 'react';
+import { type RefObject } from '@mui/x-internals/types';
+import { createRenderer, fireEvent, waitFor } from '@mui/internal-test-utils';
+import { getCell, microtasks } from 'test/utils/helperFn';
+import { describe, expect, it } from 'vitest';
+import {
+ DataGridPremium,
+ type DataGridPremiumProps,
+ type GridApi,
+ useGridApiRef,
+} from '@mui/x-data-grid-premium';
+import { isJSDOM } from 'test/utils/skipIf';
+
+const baselineProps: DataGridPremiumProps = {
+ autoHeight: isJSDOM,
+ disableVirtualization: true,
+ rows: [
+ { id: 0, item: 'Apple', price: 2, quantity: 3, total: '=price * quantity' },
+ { id: 1, item: 'Banana', price: 1, quantity: 5, total: '=price * quantity' },
+ { id: 2, item: 'Cherry', price: 4, quantity: 2, total: 8 },
+ ],
+ columns: [
+ { field: 'item' },
+ { field: 'price', type: 'number' },
+ { field: 'quantity', type: 'number' },
+ { field: 'total', type: 'number', allowFormulas: true, editable: true },
+ ],
+};
+
+describe(' - Formula autocomplete', () => {
+ const { render: originalRender } = createRenderer();
+
+ const render = async (...args: Parameters) => {
+ const utils = originalRender(...args);
+ await microtasks();
+ return utils;
+ };
+
+ let apiRef: RefObject;
+
+ function Test(props: Partial) {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+
+ function getCellInput(rowIndex: number, colIndex: number) {
+ return getCell(rowIndex, colIndex).querySelector('input')!;
+ }
+
+ function getListbox() {
+ return document.querySelector('[role="listbox"]');
+ }
+
+ function getOptionLabels() {
+ const listbox = getListbox();
+ if (!listbox) {
+ return [];
+ }
+ // The first span is the suggestion label; the second is the detail/signature.
+ return Array.from(listbox.querySelectorAll('li')).map((li) =>
+ (li.querySelector('span')?.textContent || '').trim(),
+ );
+ }
+
+ /**
+ * Sets the editor value and the caret position, then dispatches the change so
+ * the suggestion context is computed from a deterministic caret (jsdom does
+ * not always move the caret to the end on a programmatic value set).
+ */
+ function typeFormula(input: HTMLInputElement, value: string, caret = value.length) {
+ // Pass the caret through the change event's target: `onChange` reads
+ // `event.target.selectionStart` (correct in real browsers), and jsdom does
+ // not otherwise maintain the caret on a programmatic value set.
+ fireEvent.change(input, { target: { value, selectionStart: caret, selectionEnd: caret } });
+ }
+
+ it('opens a ranked dropdown when typing a function prefix', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+ expect(getOptionLabels()[0]).to.contain('SUM');
+ });
+
+ it('suggests same-row field references', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=pr');
+
+ await waitFor(() => {
+ expect(getOptionLabels().some((label) => label.startsWith('price'))).to.equal(true);
+ });
+ });
+
+ it('inserts a function with an open parenthesis on Enter', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+
+ expect(input.value).to.equal('=SUM(');
+ // The cell stays in edit mode (the popup captured Enter, no commit).
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ });
+
+ it('moves the highlight with ArrowDown instead of navigating the grid', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=t');
+ await waitFor(() => {
+ expect(getOptionLabels().length).to.be.greaterThan(1);
+ });
+
+ const firstActive = document.querySelector('li[data-focused="true"]');
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
+ await microtasks();
+ const secondActive = document.querySelector('li[data-focused="true"]');
+
+ expect(secondActive).not.to.equal(firstActive);
+ // Still editing the same cell — the grid did not move focus.
+ expect(getCellInput(0, 3)).to.equal(input);
+ });
+
+ it('accepts the highlighted option on click', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+
+ const option = getListbox()!.querySelector('li')!;
+ fireEvent.click(option);
+ await microtasks();
+
+ expect(input.value).to.equal('=SUM(');
+ });
+
+ it('closes the popup on the first Escape and cancels the edit on the second', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ await waitFor(() => {
+ expect(getListbox()).to.equal(null);
+ });
+ // The edit is still active after the first Escape closed the popup.
+ expect(getCellInput(0, 3)).not.to.equal(null);
+
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Escape' });
+ await microtasks();
+ // The formula is unchanged and the editor closed.
+ expect(apiRef.current!.getRow(0).total).to.equal('=price * quantity');
+ });
+
+ it('commits a completed formula on Enter without re-accepting a suggestion', async () => {
+ const { user } = await render( row} />);
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=price + quantity');
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=price + quantity');
+ });
+
+ it('keeps the accepted suggestion after the keystroke debounce window', async () => {
+ const { user } = await render( row} />);
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=quantit');
+ await waitFor(() => {
+ expect(getOptionLabels().some((label) => label.startsWith('quantity'))).to.equal(true);
+ });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+ expect(input.value).to.equal('=quantity');
+
+ // Wait past the former 200ms keystroke-debounce window: a stranded debounce
+ // timer would overwrite the accepted value with the partial token here.
+ await new Promise((resolve) => {
+ setTimeout(resolve, 250);
+ });
+
+ fireEvent.keyDown(getCellInput(0, 3), { key: 'Enter' });
+ await microtasks();
+ expect(apiRef.current!.getRow(0).total).to.equal('=quantity');
+ });
+
+ it('shows signature help while the caret is inside a function call', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=ROUND(');
+
+ await waitFor(() => {
+ expect(document.body.textContent).to.contain('ROUND(value, [digits])');
+ });
+ });
+
+ it('does not show the dropdown when `disableFormulaAutocomplete` is set', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+ await microtasks();
+
+ expect(getListbox()).to.equal(null);
+ });
+
+ it('suggests A1 column letters when A1 notation is on', async () => {
+ const { user } = await render();
+ // The autogenerated row-number column shifts `total` to column index 4.
+ await user.dblClick(getCell(0, 4));
+ const input = getCellInput(0, 4);
+
+ // "B" is the price column's letter.
+ typeFormula(input, '=B');
+ await waitFor(() => {
+ expect(getOptionLabels()).to.contain('B');
+ });
+ });
+
+ it('freezes an A1 formula edited through a spliced suggestion to canonical on commit', async () => {
+ const { user } = await render();
+ // The autogenerated row-number column shifts `total` to column index 4.
+ await user.dblClick(getCell(0, 4));
+ const input = getCellInput(0, 4);
+
+ typeFormula(input, '=SU');
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+ expect(input.value).to.equal('=SUM(');
+
+ // Complete the call with an A1 whole-column reference and commit.
+ typeFormula(input, '=SUM(B:B)');
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+
+ expect(apiRef.current!.getRow(0).total).to.equal('=SUM(COLUMN_VALUES("price"))');
+ });
+
+ // Caret-sensitive behavior needs a real browser layout/selection.
+ it.skipIf(isJSDOM)('places the caret inside the inserted parentheses', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ typeFormula(input, '=SU');
+ await waitFor(() => {
+ expect(getListbox()).not.to.equal(null);
+ });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ await microtasks();
+
+ expect(input.value).to.equal('=SUM(');
+ expect(input.selectionStart).to.equal(5);
+ });
+
+ it.skipIf(isJSDOM)('splices a suggestion at the caret, preserving the suffix', async () => {
+ const { user } = await render();
+ await user.dblClick(getCell(0, 3));
+ const input = getCellInput(0, 3);
+
+ // Caret right after "pr", before " + 1".
+ typeFormula(input, '=pr + 1', 3);
+ await waitFor(() => {
+ expect(getOptionLabels().some((label) => label.startsWith('price'))).to.equal(true);
+ });
+ fireEvent.keyDown(input, { key: 'Tab' });
+ await microtasks();
+
+ expect(input.value).to.equal('=price + 1');
+ });
+});
diff --git a/packages/x-data-grid-premium/src/typeOverloads/modules.ts b/packages/x-data-grid-premium/src/typeOverloads/modules.ts
index f74fe87842282..c6b754c1f6260 100644
--- a/packages/x-data-grid-premium/src/typeOverloads/modules.ts
+++ b/packages/x-data-grid-premium/src/typeOverloads/modules.ts
@@ -1,4 +1,5 @@
import type {
+ GridCellCoordinates,
GridEventLookup,
GridExportDisplayOptions,
GridRowId,
@@ -25,6 +26,7 @@ import type {
} from '../hooks';
import type { GridRowGroupingInternalCache } from '../hooks/features/rowGrouping/gridRowGroupingInterfaces';
import type { GridAggregationInternalCache } from '../hooks/features/aggregation/gridAggregationInterfaces';
+import type { GridFormulaInternalCache } from '../hooks/features/formula/gridFormulaInterfaces';
import type { GridExcelExportOptions } from '../hooks/features/export/gridExcelExportInterface';
import type {
GridPivotingInternalCache,
@@ -106,6 +108,10 @@ interface GridEventLookupPremium extends GridEventLookupPro {
* Fired when a redo operation is executed.
*/
redo: { params: { eventName: keyof GridEventLookup; data: any } };
+ /**
+ * Fired when a formula evaluation pass ends.
+ */
+ formulaEvaluationEnd: { params: { changedCells: GridCellCoordinates[] } };
}
export interface GridColDefPremium {
@@ -146,6 +152,13 @@ export interface GridColDefPremium null,
useFilterValueGetter: (apiRef) => apiRef.current.getRowValue,
+ useColumnHeaderAdornment: () => null,
},
};
const packageInfo = {
diff --git a/packages/x-data-grid/src/DataGrid/DataGrid.tsx b/packages/x-data-grid/src/DataGrid/DataGrid.tsx
index 60835b78f1578..40954216d565d 100644
--- a/packages/x-data-grid/src/DataGrid/DataGrid.tsx
+++ b/packages/x-data-grid/src/DataGrid/DataGrid.tsx
@@ -31,6 +31,7 @@ const configuration: GridConfiguration = {
useIsCellEditable,
useCellAggregationResult: () => null,
useFilterValueGetter: (apiRef) => apiRef.current.getRowValue,
+ useColumnHeaderAdornment: () => null,
},
};
diff --git a/packages/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx b/packages/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx
index 97c5361feddb0..069601c40b13d 100644
--- a/packages/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx
+++ b/packages/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx
@@ -11,6 +11,7 @@ import { doesSupportPreventScroll } from '../../utils/doesSupportPreventScroll';
import type { GridStateColDef } from '../../models/colDef/gridColDef';
import type { GridSortDirection } from '../../models/gridSortModel';
import { useGridPrivateApiContext } from '../../hooks/utils/useGridPrivateApiContext';
+import { useGridConfiguration } from '../../hooks/utils/useGridConfiguration';
import { getColumnMenuItemKeys } from '../../hooks/features/columnMenu/getColumnMenuItemKeys';
import type { GridColumnHeaderSeparatorProps } from './GridColumnHeaderSeparator';
import { ColumnHeaderMenuIcon } from './ColumnHeaderMenuIcon';
@@ -122,8 +123,10 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) {
pinnedOffset,
} = props;
const apiRef = useGridPrivateApiContext();
+ const configuration = useGridConfiguration();
const rootProps = useGridRootProps();
const isRtl = useRtl();
+ const adornment = configuration.hooks.useColumnHeaderAdornment(colDef.field);
const headerCellRef = React.useRef(null);
const columnMenuId = useId();
const columnMenuButtonId = useId();
@@ -354,6 +357,7 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) {
separatorSide={separatorSide}
isDraggable={isDraggable}
headerComponent={headerComponent}
+ adornment={adornment}
description={colDef.description}
elementId={colDef.field}
width={colDef.computedWidth}
diff --git a/packages/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx b/packages/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx
index d6108a9fe4524..8b33c9183899a 100644
--- a/packages/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx
+++ b/packages/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx
@@ -39,6 +39,13 @@ interface GridGenericColumnHeaderItemProps extends Pick<
columnMenuIconButton?: React.ReactNode;
columnMenu?: React.ReactNode;
columnTitleIconButtons?: React.ReactNode;
+ /**
+ * Optional adornment rendered at the start of the title content, before the
+ * title label (used by the Premium formula feature for A1 column letters).
+ * Placing it inside the (non-reversed) title content keeps it on the same
+ * side of the title for both left- and right-aligned headers.
+ */
+ adornment?: React.ReactNode;
label: string;
draggableContainerProps?: Partial>;
columnHeaderSeparatorProps?: Partial;
@@ -65,6 +72,7 @@ const GridGenericColumnHeaderItem = forwardRef
+ {adornment}
{headerComponent !== undefined ? (
headerComponent
) : (
diff --git a/packages/x-data-grid/src/constants/gridClasses.ts b/packages/x-data-grid/src/constants/gridClasses.ts
index 9cd2a9003e143..59cab8c27b709 100644
--- a/packages/x-data-grid/src/constants/gridClasses.ts
+++ b/packages/x-data-grid/src/constants/gridClasses.ts
@@ -86,6 +86,14 @@ export interface GridClasses {
* Styles applied to the aggregation row overlay wrapper.
*/
aggregationRowOverlayWrapper: string;
+ /**
+ * Styles applied to the A1-notation column-letter adornment in the column header (Premium formulas).
+ */
+ formulaColumnHeaderLetter: string;
+ /**
+ * Styles applied to the cells of the A1-notation row-number column (Premium formulas).
+ */
+ formulaRowNumberCell: string;
/**
* Styles applied to the root element if `autoHeight={true}`.
*/
@@ -1209,6 +1217,8 @@ export const gridClasses = generateUtilityClasses
('MuiDataGrid', [
'aggregationColumnHeader--alignRight',
'aggregationColumnHeaderLabel',
'aggregationRowOverlayWrapper',
+ 'formulaColumnHeaderLetter',
+ 'formulaRowNumberCell',
'mainContent',
'withSidePanel',
'collapsible',
diff --git a/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts b/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts
index a8c792953a75f..dce2721420bd4 100644
--- a/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts
+++ b/packages/x-data-grid/src/hooks/features/rows/useGridRowSpanning.ts
@@ -12,8 +12,10 @@ import type { GridValidRowModel, GridRowEntry } from '../../../models/gridRows';
import type { DataGridProcessedProps } from '../../../models/props/DataGridProps';
import type { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity';
import type { GridStateInitializer } from '../../utils/useGridInitializeState';
+import type { GridRowSpanningPrivateApi } from '../../../models/api/gridRowApi';
import { getUnprocessedRange, isRowContextInitialized, getCellValue } from './gridRowSpanningUtils';
import { useGridEvent } from '../../utils/useGridEvent';
+import { useGridApiMethod } from '../../utils/useGridApiMethod';
import { runIf } from '../../../utils/utils';
import { useRunOncePerLoop } from '../../utils/useRunOncePerLoop';
@@ -249,6 +251,11 @@ export const useGridRowSpanning = (
useGridEvent(apiRef, 'columnsChange', runIf(props.rowSpanning, resetRowSpanningState));
useGridEvent(apiRef, 'rowExpansionChange', runIf(props.rowSpanning, resetRowSpanningState));
+ const rowSpanningPrivateApi: GridRowSpanningPrivateApi = {
+ resetRowSpanningState: runIf(props.rowSpanning, resetRowSpanningState),
+ };
+ useGridApiMethod(apiRef, rowSpanningPrivateApi, 'private');
+
React.useEffect(() => {
const store = apiRef.current.virtualizer?.store;
if (!store) {
diff --git a/packages/x-data-grid/src/models/api/gridApiCommon.ts b/packages/x-data-grid/src/models/api/gridApiCommon.ts
index 3b34fbb437531..7af7b5d4b3b9b 100644
--- a/packages/x-data-grid/src/models/api/gridApiCommon.ts
+++ b/packages/x-data-grid/src/models/api/gridApiCommon.ts
@@ -11,7 +11,7 @@ import type { GridLocaleTextApi } from './gridLocaleTextApi';
import type { GridParamsApi, GridParamsPrivateApi } from './gridParamsApi';
import type { GridPreferencesPanelApi } from './gridPreferencesPanelApi';
import type { GridPrintExportApi } from './gridPrintExportApi';
-import type { GridRowApi, GridRowProPrivateApi } from './gridRowApi';
+import type { GridRowApi, GridRowProPrivateApi, GridRowSpanningPrivateApi } from './gridRowApi';
import type { GridRowsMetaApi, GridRowsMetaPrivateApi } from './gridRowsMetaApi';
import type { GridRowSelectionApi } from './gridRowSelectionApi';
import type { GridSortApi } from './gridSortApi';
@@ -93,6 +93,7 @@ export interface GridPrivateOnlyApiCommon<
GridHeaderFilteringPrivateApi,
GridVirtualizationPrivateApi,
GridRowProPrivateApi,
+ GridRowSpanningPrivateApi,
GridParamsPrivateApi,
GridPivotingPrivateApiCommunity {
virtualizer: Virtualizer;
diff --git a/packages/x-data-grid/src/models/api/gridRowApi.ts b/packages/x-data-grid/src/models/api/gridRowApi.ts
index 31d54d8a2c834..4fd75c4ea1baf 100644
--- a/packages/x-data-grid/src/models/api/gridRowApi.ts
+++ b/packages/x-data-grid/src/models/api/gridRowApi.ts
@@ -160,3 +160,13 @@ export interface GridRowProPrivateApi {
*/
updateNestedRows: (updates: GridRowModelUpdate[], nestedLevel?: string[]) => void;
}
+
+export interface GridRowSpanningPrivateApi {
+ /**
+ * Recomputes the row spanning state from scratch.
+ * For features whose value overlays change row spanning comparisons outside
+ * the events row spanning resets on by itself. No-op when the `rowSpanning`
+ * prop is not enabled.
+ */
+ resetRowSpanningState: () => void;
+}
diff --git a/packages/x-data-grid/src/models/api/index.ts b/packages/x-data-grid/src/models/api/index.ts
index 1ef6dd016fefa..fb5ae60c7010b 100644
--- a/packages/x-data-grid/src/models/api/index.ts
+++ b/packages/x-data-grid/src/models/api/index.ts
@@ -9,6 +9,7 @@ export type {
GridRowApi,
GridRowProApi,
GridRowProPrivateApi,
+ GridRowSpanningPrivateApi,
} from './gridRowApi';
export type { GridRowsMetaApi } from './gridRowsMetaApi';
export * from './gridRowSelectionApi';
diff --git a/packages/x-data-grid/src/models/configuration/gridConfiguration.ts b/packages/x-data-grid/src/models/configuration/gridConfiguration.ts
index 2a7cdb9521426..7da9fd9c74e05 100644
--- a/packages/x-data-grid/src/models/configuration/gridConfiguration.ts
+++ b/packages/x-data-grid/src/models/configuration/gridConfiguration.ts
@@ -15,12 +15,28 @@ export interface GridAriaAttributesInternalHook {
useGridAriaAttributes: () => React.HTMLAttributes;
}
+export interface GridColumnHeaderAdornmentInternalHook {
+ /**
+ * Returns an optional adornment rendered inside a column header's title
+ * content, at the start, before the title label. The Premium formula feature
+ * uses it to display A1-notation column letters without wrapping
+ * `colDef.renderHeader` (reserved by aggregation). Placing it inside the
+ * (non-reversed) title content keeps it on the same side of the title for
+ * both left- and right-aligned headers. Called once per rendered header
+ * cell, so the implementation may use hooks.
+ * @param {string} field The column field.
+ * @returns {React.ReactNode} The adornment node, or `null` for none.
+ */
+ useColumnHeaderAdornment: (field: string) => React.ReactNode;
+}
+
export interface GridInternalHook
extends
GridAriaAttributesInternalHook,
GridRowAriaAttributesInternalHook,
GridCellEditableInternalHook,
GridAggregationInternalHooks,
+ GridColumnHeaderAdornmentInternalHook,
GridRowsOverridableMethodsInternalHook,
GridParamsOverridableMethodsInternalHook {
useCSSVariables: () => { id: string; variables: GridCSSVariablesInterface };
diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json
index 3bfa289a362e4..75071a338af8f 100644
--- a/scripts/x-data-grid-premium.exports.json
+++ b/scripts/x-data-grid-premium.exports.json
@@ -109,6 +109,7 @@
{ "name": "GRID_DETAIL_PANEL_TOGGLE_COL_DEF", "kind": "Variable" },
{ "name": "GRID_DETAIL_PANEL_TOGGLE_FIELD", "kind": "ImportSpecifier" },
{ "name": "GRID_EXPERIMENTAL_ENABLED", "kind": "Variable" },
+ { "name": "GRID_FORMULA_FUNCTIONS", "kind": "Variable" },
{ "name": "GRID_LONG_TEXT_COL_DEF", "kind": "Variable" },
{ "name": "GRID_MULTI_SELECT_COL_DEF", "kind": "Variable" },
{ "name": "GRID_NUMERIC_COL_DEF", "kind": "Variable" },
@@ -170,6 +171,7 @@
{ "name": "GridCellEditStopParams", "kind": "Interface" },
{ "name": "GridCellEditStopReasons", "kind": "Enum" },
{ "name": "GridCellEventLookup", "kind": "Interface" },
+ { "name": "gridCellFormulaResultSelector", "kind": "Variable" },
{ "name": "GridCellIndexCoordinates", "kind": "Interface" },
{ "name": "GridCellMode", "kind": "TypeAlias" },
{ "name": "GridCellModes", "kind": "Enum" },
@@ -432,6 +434,19 @@
{ "name": "GridFooterContainerProps", "kind": "TypeAlias" },
{ "name": "GridFooterNode", "kind": "Interface" },
{ "name": "GridFooterPlaceholder", "kind": "Function" },
+ { "name": "GridFormulaApi", "kind": "Interface" },
+ { "name": "GridFormulaCellKey", "kind": "TypeAlias" },
+ { "name": "GridFormulaErrorCode", "kind": "TypeAlias" },
+ { "name": "GridFormulaFunctionArg", "kind": "TypeAlias" },
+ { "name": "GridFormulaFunctionContext", "kind": "TypeAlias" },
+ { "name": "GridFormulaFunctionDefinition", "kind": "TypeAlias" },
+ { "name": "GridFormulaLookup", "kind": "TypeAlias" },
+ { "name": "gridFormulaLookupSelector", "kind": "Variable" },
+ { "name": "GridFormulaResult", "kind": "TypeAlias" },
+ { "name": "GridFormulaState", "kind": "Interface" },
+ { "name": "gridFormulaStateSelector", "kind": "Variable" },
+ { "name": "GridFormulaValidationIssue", "kind": "TypeAlias" },
+ { "name": "GridFormulaValidationResult", "kind": "TypeAlias" },
{ "name": "GridFunctionsIcon", "kind": "Variable" },
{ "name": "GridGenericColumnMenu", "kind": "Variable" },
{ "name": "GridGenericColumnMenuProps", "kind": "Interface" },
@@ -648,6 +663,7 @@
{ "name": "GridRowsMetaState", "kind": "Interface" },
{ "name": "GridRowSpacing", "kind": "Interface" },
{ "name": "GridRowSpacingParams", "kind": "Interface" },
+ { "name": "GridRowSpanningPrivateApi", "kind": "Interface" },
{ "name": "GridRowsProp", "kind": "TypeAlias" },
{ "name": "GridRowsState", "kind": "Interface" },
{ "name": "GridRowTreeConfig", "kind": "TypeAlias" },
diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json
index ea1a2813f14c0..cfe9d5967af4d 100644
--- a/scripts/x-data-grid-pro.exports.json
+++ b/scripts/x-data-grid-pro.exports.json
@@ -558,6 +558,7 @@
{ "name": "GridRowsMetaState", "kind": "Interface" },
{ "name": "GridRowSpacing", "kind": "Interface" },
{ "name": "GridRowSpacingParams", "kind": "Interface" },
+ { "name": "GridRowSpanningPrivateApi", "kind": "Interface" },
{ "name": "GridRowsProp", "kind": "TypeAlias" },
{ "name": "GridRowsState", "kind": "Interface" },
{ "name": "GridRowTreeConfig", "kind": "TypeAlias" },
diff --git a/scripts/x-data-grid.exports.json b/scripts/x-data-grid.exports.json
index 4b62ebadbccc8..9fb00be007438 100644
--- a/scripts/x-data-grid.exports.json
+++ b/scripts/x-data-grid.exports.json
@@ -507,6 +507,7 @@
{ "name": "GridRowsMetaState", "kind": "Interface" },
{ "name": "GridRowSpacing", "kind": "Interface" },
{ "name": "GridRowSpacingParams", "kind": "Interface" },
+ { "name": "GridRowSpanningPrivateApi", "kind": "Interface" },
{ "name": "GridRowsProp", "kind": "TypeAlias" },
{ "name": "GridRowsState", "kind": "Interface" },
{ "name": "GridRowTreeConfig", "kind": "TypeAlias" },