diff --git a/docs/data/data-grid/events/events.json b/docs/data/data-grid/events/events.json index a5d65adc3a370..ab57aa53d57e3 100644 --- a/docs/data/data-grid/events/events.json +++ b/docs/data/data-grid/events/events.json @@ -267,6 +267,13 @@ "params": "GridFilterModel", "event": "MuiEvent<{}>" }, + { + "projects": ["x-data-grid-premium"], + "name": "formulaEvaluationEnd", + "description": "Fired when a formula evaluation pass ends.", + "params": "{ changedCells: GridCellCoordinates[] }", + "event": "MuiEvent<{}>" + }, { "projects": ["x-data-grid", "x-data-grid-pro", "x-data-grid-premium"], "name": "headerSelectionCheckboxChange", diff --git a/docs/data/data-grid/formulas/FormulaAutocomplete.js b/docs/data/data-grid/formulas/FormulaAutocomplete.js new file mode 100644 index 0000000000000..9edd036a20ada --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaAutocomplete.js @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { DataGridPremium, GRID_FORMULA_FUNCTIONS } from '@mui/x-data-grid-premium'; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +// A custom function appears in the suggestion dropdown with the optional +// `signature`, `description` and `category` metadata it declares. +const DISCOUNT = { + name: 'DISCOUNT', + minArgs: 2, + maxArgs: 2, + signature: 'DISCOUNT(amount, percent)', + description: 'Subtracts a percentage from an amount.', + category: 'Custom', + apply: ([amount, percent], context) => { + const base = context.coerce.toNumber(amount); + const rate = context.coerce.toNumber(percent); + if (typeof base !== 'number') { + return base; + } + if (typeof rate !== 'number') { + return rate; + } + return base * (1 - rate / 100); + }, +}; + +const formulaFunctions = { ...GRID_FORMULA_FUNCTIONS, DISCOUNT }; + +const columns = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 220, + allowFormulas: true, + editable: true, + valueFormatter: (value) => + typeof value === 'number' ? currencyFormatter.format(value) : value, + }, +]; + +const rows = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { + id: 2, + item: 'Mouse', + price: 45, + quantity: 5, + total: '=DISCOUNT(price * quantity, 10)', + }, + { + id: 3, + item: 'Monitor', + price: 320, + quantity: 2, + total: '=ROUND(price * quantity, 2)', + }, + { + id: 4, + item: 'Webcam', + price: 60, + quantity: 2, + total: '=SUM(COLUMN_VALUES("price"))', + }, +]; + +export default function FormulaAutocomplete() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaAutocomplete.tsx b/docs/data/data-grid/formulas/FormulaAutocomplete.tsx new file mode 100644 index 0000000000000..13008a0c55ac0 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaAutocomplete.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { + DataGridPremium, + GridColDef, + GridRowsProp, + GRID_FORMULA_FUNCTIONS, + GridFormulaFunctionDefinition, +} from '@mui/x-data-grid-premium'; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +// A custom function appears in the suggestion dropdown with the optional +// `signature`, `description` and `category` metadata it declares. +const DISCOUNT: GridFormulaFunctionDefinition = { + name: 'DISCOUNT', + minArgs: 2, + maxArgs: 2, + signature: 'DISCOUNT(amount, percent)', + description: 'Subtracts a percentage from an amount.', + category: 'Custom', + apply: ([amount, percent], context) => { + const base = context.coerce.toNumber(amount); + const rate = context.coerce.toNumber(percent); + if (typeof base !== 'number') { + return base; + } + if (typeof rate !== 'number') { + return rate; + } + return base * (1 - rate / 100); + }, +}; + +const formulaFunctions = { ...GRID_FORMULA_FUNCTIONS, DISCOUNT }; + +const columns: GridColDef[] = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 220, + allowFormulas: true, + editable: true, + valueFormatter: (value) => + typeof value === 'number' ? currencyFormatter.format(value) : value, + }, +]; + +const rows: GridRowsProp = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { + id: 2, + item: 'Mouse', + price: 45, + quantity: 5, + total: '=DISCOUNT(price * quantity, 10)', + }, + { + id: 3, + item: 'Monitor', + price: 320, + quantity: 2, + total: '=ROUND(price * quantity, 2)', + }, + { + id: 4, + item: 'Webcam', + price: 60, + quantity: 2, + total: '=SUM(COLUMN_VALUES("price"))', + }, +]; + +export default function FormulaAutocomplete() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaAutocomplete.tsx.preview b/docs/data/data-grid/formulas/FormulaAutocomplete.tsx.preview new file mode 100644 index 0000000000000..f53faba260385 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaAutocomplete.tsx.preview @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/formulas/FormulaBasic.js b/docs/data/data-grid/formulas/FormulaBasic.js new file mode 100644 index 0000000000000..3ad9a59a5d7f8 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaBasic.js @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { DataGridPremium } from '@mui/x-data-grid-premium'; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +const columns = [ + { field: 'item', headerName: 'Item', width: 150 }, + { field: 'price', headerName: 'Price', type: 'number', width: 90, editable: true }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 130, + allowFormulas: true, + editable: true, + valueFormatter: (value) => + typeof value === 'number' ? currencyFormatter.format(value) : value, + }, + { field: 'description', headerName: 'Formula explanation', width: 380 }, +]; + +// The stored formula is always canonical (REF/ROW/COLUMN_VALUES/RANGE…). With +// `formulaA1Notation` on, editing a cell shows the A1 form noted in each row's +// description. Same-row references (price, quantity) keep their column names in +// A1; only cross-row and positional references use A1 cell addresses. +const rows = [ + { + id: 1, + item: 'Keyboard', + price: 89, + quantity: 3, + total: '=price * quantity', + description: + 'Inline same-row arithmetic. Same-row fields keep their names in A1 notation.', + }, + { + id: 2, + item: 'Mouse', + price: 45, + quantity: 5, + total: '=ROUND(price * quantity * 0.9, 2)', + description: 'Function (ROUND): 10% off, rounded to 2 decimals.', + }, + { + id: 3, + item: 'Monitor', + price: 320, + quantity: 2, + total: '=IF(price > 100, price * quantity * 0.9, price * quantity)', + description: 'Conditional (IF): 10% off when the price is above 100.', + }, + { + id: 4, + item: 'Webcam', + price: 60, + quantity: 2, + total: '=price * quantity + REF(COLUMN("price"), ROW(1))', + description: + "Cross-row reference to row 1's price. Edits as: price * quantity + B1.", + }, + { + id: 5, + item: 'USB-C cable', + price: 12, + quantity: 10, + total: '=REF(COLUMN("price"), ROW_POSITION(1)) * quantity', + description: + 'Positional row (top visible row). Edits as B$1 * quantity; follows re-sorts.', + }, + { + id: 6, + item: 'Laptop stand', + price: 40, + quantity: 3, + total: '=REF(COLUMN_POSITION(2), ROW(6)) * quantity', + description: + 'Positional column (column 2 = price) of this row. Edits as $B6 * quantity.', + }, + { + id: 7, + item: 'Desk mat', + price: 25, + quantity: 4, + total: '=REF(COLUMN_POSITION(2), ROW_POSITION(1))', + description: 'Both axes positional: column 2, top visible row. Edits as $B$1.', + }, + { + id: 8, + item: 'Cable pack', + price: 18, + quantity: 6, + total: '=REF(COLUMN("price"), ROW_POSITION(2)) * quantity', + description: 'Mixed: field column + positional row. Edits as B$2 * quantity.', + }, + { + id: 9, + item: 'Webcam stand', + price: 35, + quantity: 2, + total: '=SUM(COLUMN_VALUES("price"))', + description: 'Whole column: sum of every price. Edits as SUM(B:B).', + }, + { + id: 10, + item: 'HDMI adapter', + price: 15, + quantity: 7, + total: '=AVERAGE(COLUMN_VALUES("price"))', + description: 'Whole column with AVERAGE. Edits as AVERAGE(B:B).', + }, + { + id: 11, + item: 'Laptop sleeve', + price: 30, + quantity: 3, + total: '=SUM(RANGE(REF(COLUMN("price"), ROW(1)), REF(COLUMN("price"), ROW(3))))', + description: 'Range: sum of prices in rows 1–3. Edits as SUM(B1:B3).', + }, + { + id: 12, + item: 'Power bank', + price: 50, + quantity: 0, + total: '=IFERROR(price / quantity, 0)', + description: 'IFERROR: returns 0 instead of #DIV/0! when quantity is 0.', + }, + { + id: 13, + item: 'Shipping', + price: 25, + quantity: 1, + total: 25, + description: 'Plain value — not a formula.', + }, +]; + +export default function FormulaBasic() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaBasic.tsx b/docs/data/data-grid/formulas/FormulaBasic.tsx new file mode 100644 index 0000000000000..bea393221c8ba --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaBasic.tsx @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { DataGridPremium, GridColDef, GridRowsProp } from '@mui/x-data-grid-premium'; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +const columns: GridColDef[] = [ + { field: 'item', headerName: 'Item', width: 150 }, + { field: 'price', headerName: 'Price', type: 'number', width: 90, editable: true }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 130, + allowFormulas: true, + editable: true, + valueFormatter: (value) => + typeof value === 'number' ? currencyFormatter.format(value) : value, + }, + { field: 'description', headerName: 'Formula explanation', width: 380 }, +]; + +// The stored formula is always canonical (REF/ROW/COLUMN_VALUES/RANGE…). With +// `formulaA1Notation` on, editing a cell shows the A1 form noted in each row's +// description. Same-row references (price, quantity) keep their column names in +// A1; only cross-row and positional references use A1 cell addresses. +const rows: GridRowsProp = [ + { + id: 1, + item: 'Keyboard', + price: 89, + quantity: 3, + total: '=price * quantity', + description: + 'Inline same-row arithmetic. Same-row fields keep their names in A1 notation.', + }, + { + id: 2, + item: 'Mouse', + price: 45, + quantity: 5, + total: '=ROUND(price * quantity * 0.9, 2)', + description: 'Function (ROUND): 10% off, rounded to 2 decimals.', + }, + { + id: 3, + item: 'Monitor', + price: 320, + quantity: 2, + total: '=IF(price > 100, price * quantity * 0.9, price * quantity)', + description: 'Conditional (IF): 10% off when the price is above 100.', + }, + { + id: 4, + item: 'Webcam', + price: 60, + quantity: 2, + total: '=price * quantity + REF(COLUMN("price"), ROW(1))', + description: + "Cross-row reference to row 1's price. Edits as: price * quantity + B1.", + }, + { + id: 5, + item: 'USB-C cable', + price: 12, + quantity: 10, + total: '=REF(COLUMN("price"), ROW_POSITION(1)) * quantity', + description: + 'Positional row (top visible row). Edits as B$1 * quantity; follows re-sorts.', + }, + { + id: 6, + item: 'Laptop stand', + price: 40, + quantity: 3, + total: '=REF(COLUMN_POSITION(2), ROW(6)) * quantity', + description: + 'Positional column (column 2 = price) of this row. Edits as $B6 * quantity.', + }, + { + id: 7, + item: 'Desk mat', + price: 25, + quantity: 4, + total: '=REF(COLUMN_POSITION(2), ROW_POSITION(1))', + description: 'Both axes positional: column 2, top visible row. Edits as $B$1.', + }, + { + id: 8, + item: 'Cable pack', + price: 18, + quantity: 6, + total: '=REF(COLUMN("price"), ROW_POSITION(2)) * quantity', + description: 'Mixed: field column + positional row. Edits as B$2 * quantity.', + }, + { + id: 9, + item: 'Webcam stand', + price: 35, + quantity: 2, + total: '=SUM(COLUMN_VALUES("price"))', + description: 'Whole column: sum of every price. Edits as SUM(B:B).', + }, + { + id: 10, + item: 'HDMI adapter', + price: 15, + quantity: 7, + total: '=AVERAGE(COLUMN_VALUES("price"))', + description: 'Whole column with AVERAGE. Edits as AVERAGE(B:B).', + }, + { + id: 11, + item: 'Laptop sleeve', + price: 30, + quantity: 3, + total: '=SUM(RANGE(REF(COLUMN("price"), ROW(1)), REF(COLUMN("price"), ROW(3))))', + description: 'Range: sum of prices in rows 1–3. Edits as SUM(B1:B3).', + }, + { + id: 12, + item: 'Power bank', + price: 50, + quantity: 0, + total: '=IFERROR(price / quantity, 0)', + description: 'IFERROR: returns 0 instead of #DIV/0! when quantity is 0.', + }, + { + id: 13, + item: 'Shipping', + price: 25, + quantity: 1, + total: 25, + description: 'Plain value — not a formula.', + }, +]; + +export default function FormulaBasic() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaExcelExport.js b/docs/data/data-grid/formulas/FormulaExcelExport.js new file mode 100644 index 0000000000000..981daf704d370 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaExcelExport.js @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { DataGridPremium } from '@mui/x-data-grid-premium'; + +const columns = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 150, + allowFormulas: true, + editable: true, + }, +]; + +const rows = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { id: 2, item: 'Mouse', price: 45, quantity: 5, total: '=price * quantity' }, + { id: 3, item: 'Monitor', price: 320, quantity: 2, total: '=price * quantity' }, + { + id: 4, + item: 'Total spend', + price: 0, + quantity: 0, + total: '=SUM(COLUMN_VALUES("price"))', + }, +]; + +export default function FormulaExcelExport() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaExcelExport.tsx b/docs/data/data-grid/formulas/FormulaExcelExport.tsx new file mode 100644 index 0000000000000..718380a92c4e1 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaExcelExport.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { DataGridPremium, GridColDef, GridRowsProp } from '@mui/x-data-grid-premium'; + +const columns: GridColDef[] = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 80, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 150, + allowFormulas: true, + editable: true, + }, +]; + +const rows: GridRowsProp = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { id: 2, item: 'Mouse', price: 45, quantity: 5, total: '=price * quantity' }, + { id: 3, item: 'Monitor', price: 320, quantity: 2, total: '=price * quantity' }, + { + id: 4, + item: 'Total spend', + price: 0, + quantity: 0, + total: '=SUM(COLUMN_VALUES("price"))', + }, +]; + +export default function FormulaExcelExport() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaFillHandle.js b/docs/data/data-grid/formulas/FormulaFillHandle.js new file mode 100644 index 0000000000000..53161f4e29735 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaFillHandle.js @@ -0,0 +1,149 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { DataGridPremium } from '@mui/x-data-grid-premium'; + +const initialColumns = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 90, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 130, + allowFormulas: true, + editable: true, + }, +]; + +// Only the first row holds a formula. Select its Total cell and drag the fill +// handle down (or press Ctrl+D) to copy the formula into the rows below — the +// references adjust to each target row. +const rows = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { id: 2, item: 'Mouse', price: 45, quantity: 5, total: '' }, + { id: 3, item: 'Monitor', price: 320, quantity: 2, total: '' }, + { id: 4, item: 'Webcam', price: 60, quantity: 2, total: '' }, + { id: 5, item: 'USB-C cable', price: 12, quantity: 10, total: '' }, +]; + +function getUniqueField(name, columns) { + const existingFields = new Set(columns.map((column) => column.field)); + let field = name; + let suffix = 2; + while (existingFields.has(field)) { + field = `${name}_${suffix}`; + suffix += 1; + } + return field; +} + +function AddFormulaColumnPopover({ anchorEl, onClose, onAdd }) { + const inputRef = React.useRef(null); + const [columnName, setColumnName] = React.useState(''); + + const handleAdd = () => { + const name = columnName.trim(); + if (name === '') { + return; + } + onAdd(name); + onClose(); + }; + + return ( + inputRef.current?.focus() } }} + > + + setColumnName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + // Prevent the keystroke's default activation from re-triggering the + // "Add Formula Column" button once focus is restored to it as the + // popover closes (which would immediately reopen the popover). + event.preventDefault(); + handleAdd(); + } + }} + /> + + + + ); +} + +export default function FormulaFillHandle() { + const [columns, setColumns] = React.useState(initialColumns); + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleAddColumn = (name) => { + setColumns((prevColumns) => [ + ...prevColumns, + { + field: getUniqueField(name.replaceAll(' ', '_'), prevColumns), + headerName: name, + width: 150, + editable: true, + allowFormulas: true, + }, + ]); + }; + + return ( +
+ + {anchorEl && ( + setAnchorEl(null)} + onAdd={handleAddColumn} + /> + )} +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/formulas/FormulaFillHandle.tsx b/docs/data/data-grid/formulas/FormulaFillHandle.tsx new file mode 100644 index 0000000000000..4d1d4df2760e4 --- /dev/null +++ b/docs/data/data-grid/formulas/FormulaFillHandle.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import { DataGridPremium, GridColDef, GridRowsProp } from '@mui/x-data-grid-premium'; + +const initialColumns: GridColDef[] = [ + { field: 'item', headerName: 'Item', width: 150 }, + { + field: 'price', + headerName: 'Price', + type: 'number', + width: 100, + editable: true, + }, + { + field: 'quantity', + headerName: 'Qty', + type: 'number', + width: 90, + editable: true, + }, + { + field: 'total', + headerName: 'Total', + type: 'number', + width: 130, + allowFormulas: true, + editable: true, + }, +]; + +// Only the first row holds a formula. Select its Total cell and drag the fill +// handle down (or press Ctrl+D) to copy the formula into the rows below — the +// references adjust to each target row. +const rows: GridRowsProp = [ + { id: 1, item: 'Keyboard', price: 89, quantity: 3, total: '=price * quantity' }, + { id: 2, item: 'Mouse', price: 45, quantity: 5, total: '' }, + { id: 3, item: 'Monitor', price: 320, quantity: 2, total: '' }, + { id: 4, item: 'Webcam', price: 60, quantity: 2, total: '' }, + { id: 5, item: 'USB-C cable', price: 12, quantity: 10, total: '' }, +]; + +function getUniqueField(name: string, columns: GridColDef[]) { + const existingFields = new Set(columns.map((column) => column.field)); + let field = name; + let suffix = 2; + while (existingFields.has(field)) { + field = `${name}_${suffix}`; + suffix += 1; + } + return field; +} + +interface AddFormulaColumnPopoverProps { + anchorEl: HTMLElement; + onClose: () => void; + onAdd: (name: string) => void; +} + +function AddFormulaColumnPopover({ + anchorEl, + onClose, + onAdd, +}: AddFormulaColumnPopoverProps) { + const inputRef = React.useRef(null); + const [columnName, setColumnName] = React.useState(''); + + const handleAdd = () => { + const name = columnName.trim(); + if (name === '') { + return; + } + onAdd(name); + onClose(); + }; + + return ( + inputRef.current?.focus() } }} + > + + setColumnName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + // Prevent the keystroke's default activation from re-triggering the + // "Add Formula Column" button once focus is restored to it as the + // popover closes (which would immediately reopen the popover). + event.preventDefault(); + handleAdd(); + } + }} + /> + + + + ); +} + +export default function FormulaFillHandle() { + const [columns, setColumns] = React.useState(initialColumns); + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleAddColumn = (name: string) => { + setColumns((prevColumns) => [ + ...prevColumns, + { + field: getUniqueField(name.replaceAll(' ', '_'), prevColumns), + headerName: name, + width: 150, + editable: true, + allowFormulas: true, + }, + ]); + }; + + return ( +
+ + {anchorEl && ( + setAnchorEl(null)} + onAdd={handleAddColumn} + /> + )} +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/formulas/formulas.md b/docs/data/data-grid/formulas/formulas.md new file mode 100644 index 0000000000000..100af6a066f2d --- /dev/null +++ b/docs/data/data-grid/formulas/formulas.md @@ -0,0 +1,204 @@ +--- +title: Data Grid - Formulas +--- + +# Data Grid - Formulas [](/x/introduction/licensing/#premium-plan 'Premium plan') + +

Let users derive cell values from other cells with spreadsheet-like formulas.

+ +On columns that opt in with `allowFormulas`, cell values that are strings starting with `=` are parsed and evaluated. +The evaluated value flows through rendering, sorting, filtering, aggregation, clipboard copy, and export, while the formula source remains the value stored in the row data. + +{{"demo": "FormulaBasic.js", "bg": "inline", "defaultCodeOpen": false}} + +## Enabling formulas + +Formula support is opt-in per column: + +```tsx +const columns: GridColDef[] = [ + { field: 'price', type: 'number' }, + { field: 'quantity', type: 'number' }, + { field: 'total', type: 'number', allowFormulas: true, editable: true }, +]; + +const rows = [{ id: 1, price: 2, quantity: 3, total: '=price * quantity' }]; +``` + +Without `allowFormulas`, values starting with `=` render as plain strings. +To store a literal string starting with `=` in a formula column, prefix it with an apostrophe: `'=not a formula`. + +Use the `disableFormulas` prop to turn the feature off for the whole grid. + +## Formula syntax + +Formulas use an Excel-like, en-US syntax (`,` as the argument separator, `.` as the decimal separator): + +- Operators with Excel precedence and semantics: `+`, `-`, `*`, `/`, `^`, `&` (text concatenation), and the comparisons `=`, `<>`, `<`, `<=`, `>`, `>=`. +- Literals: numbers, double-quoted strings (`""` to escape a quote), `TRUE` and `FALSE`. +- A bare identifier such as `price` references the value of that field **in the same row**. + For field names that are not valid identifiers, use `FIELD("unit price")`. +- `REF(COLUMN("price"), ROW(42))` references the `price` cell of the row with id `42`. +- Function calls such as `=ROUND(price * quantity, 2)`. Function names are case-insensitive; field names are case-sensitive. + +Values referenced through another column's `valueGetter` resolve to the derived value—formulas see what users see. + +:::warning +Cross-row references identify rows by their id, so they require stable row ids: if you provide [`getRowId()`](/x/react-data-grid/row-definition/#row-identifier), it must return the same id for the same logical row across updates. +A row whose id changes is a removed row from the formula engine's perspective, and references to it resolve to `#REF!`. +::: + +### Ranges and position-based references + +Two range forms aggregate over many cells at once. +Ranges are only valid as arguments of range-accepting functions such as `SUM`—a range in a scalar position is a `#VALUE!` error, and an error value inside a range propagates to the result. + +- `COLUMN_VALUES("price")` is the list of the field's values over the current **sorted and filtered** rows, in view order. + This form is sort-proof and filter-aware, making it the recommended way to aggregate a column: `=SUM(COLUMN_VALUES("price"))`. +- `RANGE(REF(...), REF(...))` is the inclusive rectangle between two cell anchors, resolved against the current view: the anchors map to their row and column positions, and the rectangle spans everything between them. + An anchor on a row that is filtered out, or on a hidden column, has no position and the range evaluates to `#REF!`. + +Position-based selectors reference cells by their place in the current view instead of by row id: `ROW_POSITION(1)` is the first row of the sorted and filtered view (1-based), and `COLUMN_POSITION(2)` is the second visible column. +They can be mixed freely with stable selectors inside `REF()`, for example `REF(COLUMN("price"), ROW_POSITION(1))`. +Autogenerated rows—group headers, aggregation footers, pinned rows—have no position and are never part of a range. + +Position-dependent formulas follow a **one-shot** update policy: sorting, filtering, and row grouping consume formula values as they were when they ran; afterwards, position-dependent formulas re-evaluate against the new view order exactly once, and the grid never re-sorts, re-filters, or re-groups in response. +If a re-evaluated value would change the order or the group keys, re-apply the sort or the grouping. +References by field name and row id are unaffected by this policy. + +Formulas that materialize very large ranges (above roughly 100,000 cells per evaluation) log a development-mode warning—consider the [aggregation](/x/react-data-grid/aggregation/) feature for whole-column summaries displayed outside the rows. + +### Built-in functions + +`SUM`, `AVERAGE`, `MIN`, `MAX`, `COUNT`, `COUNTA`, `ROUND`, `ABS`, `MOD`, `POWER`, `IF`, `AND`, `OR`, `NOT`, `IFERROR`, `ISBLANK`, `CONCAT`/`CONCATENATE`, `LEN`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`. + +### Error values + +When a formula cannot be evaluated, the cell renders one of the following error codes: `#ERROR!` (syntax error), `#NAME?` (unknown function), `#VALUE!` (invalid operand), `#DIV/0!`, `#REF!` (unknown row or field, or a position-based reference with no matching row or column in the current view), and `#CYCLE!` (circular reference). +Errors sort, filter, and export as their code strings. + +## Editing + +When a formula cell enters edit mode, the editor shows the formula source instead of the evaluated value, and always uses a text input—even on `number` columns. +To turn a plain cell into a formula cell, type `=`: the formula editor opens regardless of the column type. +Double-clicking a plain cell opens the column type's default editor. +Committing an edit without changes keeps the formula intact, including in row edit mode. +Invalid formulas can still be committed: the cell shows the corresponding error code until the formula is fixed. + +`processRowUpdate` and undo/redo operate on the formula source, so persisting and restoring rows keeps formulas working for free. + +### Autocomplete + +While editing a formula, a suggestion dropdown offers ranked completions for the partial token at the caret—functions, references, constants, and the grid's column fields (and, with `formulaA1Notation`, the column letters). +Accepting a function inserts it with an open parenthesis and places the caret inside, and signature help appears while the caret is within a call. +Suggestions are spliced at the caret, so the rest of the formula is preserved. + +The example below adds a custom function `DISCOUNT` that which appears in the autocomplete dropdown. + +{{"demo": "FormulaAutocomplete.js", "bg": "inline", "defaultCodeOpen": false}} + +The dropdown is on by default. +While it is open, Down and Up move the highlight, Enter and Tab accept the highlighted suggestion, and Escape closes it—so those keys do not commit the edit or move between cells until the dropdown is closed. +Pass `disableFormulaAutocomplete` prop to turn the dropdown off. + +### Fill handle + +When the [cell selection](/x/react-data-grid/cell-selection/#fill-handle) fill handle is enabled (`cellSelection` and `cellSelectionFillHandle`), dragging a formula cell—or using the Ctrl+D (fill down) and Ctrl+R (fill right) shortcuts—copies the formula and adjusts its references for each target cell, the way a spreadsheet does. + +{{"demo": "FormulaFillHandle.js", "bg": "inline", "defaultCodeOpen": false}} + +- **Relative references shift** by the distance the cell moved: a formula written as `=A1 * B1` in A1 notation becomes `=A2 * B2` one row down, and same-row field references such as `=price * quantity` move to the next column when filled sideways. +- **Absolute references stay fixed**: positional references (the `$A$1` form, stored as `COLUMN_POSITION`/`ROW_POSITION`) are never shifted. +- Offsets are measured in the current **sorted and filtered** view order, so a fill respects the rows as they are displayed. +- A reference that would move past the first row or column keeps its original target; one that moves past the last row or column resolves to `#REF!`. + +Filling a formula into a column that is **not** `allowFormulas` copies the source cell's evaluated value instead of the formula, so a `=…` string is never stored in a plain column. + +## Excel export + +By default, [Excel export](/x/react-data-grid/export/#excel-export) writes the **evaluated value** of each formula cell. +To export the formulas themselves—as real Excel formulas that the spreadsheet recalculates—set `escapeFormulas: false`. + +Pass it through the toolbar's `excelOptions` so the built-in **Export** button uses it: + +```tsx + +``` + +Or pass it directly when exporting through the API: + +```tsx +apiRef.current.exportDataAsExcel({ escapeFormulas: false }); +``` + +Whether `formulaA1Notation` is passed or not, the formula references are rewritten to Excel A1 notation that points at each cell's position in the **exported sheet**, accounting for header rows and the exported column and row order. +Relative references stay relative (`B2`) and absolute references stay absolute (`$B$2`), matching the grid. + +- A reference to a cell **outside the export**—a filtered-out row, or a column removed with `disableExport` or the `fields` option—is marked as `#REF!` error. +- Functions are exported unchanged: a function that Excel does not recognize keeps its cached value but shows `#NAME?` if the spreadsheet recalculates. +- `COLUMN_VALUES` and `RANGE` export as contiguous A1 ranges. When the export includes grouped or pinned rows, those ranges also cover them, so the value is correct but a manual recalculation in Excel can differ. +- CSV export always writes evaluated values. + +The following demo uses the `escapeFormulas: false` option to export the formulas as live formulas. Try exporting the grid as Excel file and opening it in Excel to observe the live formulas. + +{{"demo": "FormulaExcelExport.js", "bg": "inline", "defaultCodeOpen": false}} + +:::warning +`escapeFormulas` defaults to `true` to prevent [CSV and Excel formula injection](https://owasp.org/www-community/attacks/CSV_Injection): any string value that starts with `=`, `+`, `-`, or `@` is written as text. +Setting it to `false` exports grid formulas as live formulas, but also lets such strings in other columns run as formulas in Excel—only turn it off for trusted data. +::: + +## Custom functions + +Provide custom functions with the `formulaFunctions` prop. +The prop **replaces** the built-in set—spread `GRID_FORMULA_FUNCTIONS` to extend it: + +```tsx +import { + DataGridPremium, + GRID_FORMULA_FUNCTIONS, + GridFormulaFunctionDefinition, +} from '@mui/x-data-grid-premium'; + +const DOUBLE: GridFormulaFunctionDefinition = { + name: 'DOUBLE', + minArgs: 1, + maxArgs: 1, + apply: ([value], context) => { + const number = context.coerce.toNumber(value); + return typeof number === 'number' ? number * 2 : number; + }, +}; + +; +``` + +Custom functions appear in the [autocomplete](#autocomplete) dropdown. +Add the optional `signature`, `description`, and `category` fields to a definition to surface richer hints there. + +## API methods + +The grid API exposes formula methods—see the [API reference](/x/api/data-grid/grid-api/) for details: + +- `setCellFormula()` stores a formula and re-evaluates. +- `getCellFormula()` returns the stored source of a formula cell. +- `getCellFormulaResult()` returns the evaluation result. +- `validateCellFormula()` statically validates a formula source. +- `reevaluateFormulas()` re-evaluates everything—an escape hatch after in-place row mutations. + +## Current limitations + +- Formulas are not supported with the [server-side data source](/x/react-data-grid/server-side-data/) or while [pivoting](/x/react-data-grid/pivoting/) is active. +- The formula syntax is en-US only. + +## API + +- [DataGridPremium](/x/api/data-grid/data-grid-premium/) +- [GridApi](/x/api/data-grid/grid-api/) diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 904d52f32d6e8..a2457d3c7767c 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -251,6 +251,7 @@ const pages: MuiPage[] = [ ], }, { pathname: '/x/react-data-grid/aggregation', plan: 'premium' }, + { pathname: '/x/react-data-grid/formulas', plan: 'premium', newFeature: true }, { pathname: '/x/react-data-grid/pivoting-group', title: 'Pivoting', diff --git a/docs/pages/x/api/data-grid/data-grid-premium.json b/docs/pages/x/api/data-grid/data-grid-premium.json index 2d655b78ec871..e76a1a99cfb5c 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -46,7 +46,7 @@ "checkboxColDef": { "type": { "name": "shape", - "description": "{ aggregable?: bool, align?: 'center'
| 'left'
| 'right', availableAggregationFunctions?: Array<string>, cellClassName?: func
| string, chartable?: bool, colSpan?: func
| number, description?: string, disableColumnMenu?: bool, disableExport?: bool, disableReorder?: bool, display?: 'flex'
| 'text', editable?: bool, examples?: array, filterable?: bool, filterOperators?: Array<{ getApplyFilterFn: func, getValueAsString?: func, headerLabel?: string, InputComponent?: elementType, InputComponentProps?: object, label?: string, requiresFilterValue?: bool, value: string }>, flex?: number, getApplyQuickFilterFn?: func, getSortComparator?: func, groupable?: bool, groupingValueGetter?: func, groupingValueSetter?: func, headerAlign?: 'center'
| 'left'
| 'right', headerClassName?: func
| string, headerName?: string, hideable?: bool, hideSortIcons?: bool, maxWidth?: number, minWidth?: number, pastedValueParser?: func, pinnable?: bool, pivotable?: bool, preProcessEditCellProps?: func, renderCell?: func, renderEditCell?: func, renderHeader?: func, renderHeaderFilter?: func, resizable?: bool, rowSpanValueGetter?: func, sortable?: bool, sortComparator?: func, sortingOrder?: Array<'asc'
| 'desc'>, valueFormatter?: func, valueGetter?: func, valueParser?: func, valueSetter?: func, width?: number }" + "description": "{ aggregable?: bool, align?: 'center'
| 'left'
| 'right', allowFormulas?: bool, availableAggregationFunctions?: Array<string>, cellClassName?: func
| string, chartable?: bool, colSpan?: func
| number, description?: string, disableColumnMenu?: bool, disableExport?: bool, disableReorder?: bool, display?: 'flex'
| 'text', editable?: bool, examples?: array, filterable?: bool, filterOperators?: Array<{ getApplyFilterFn: func, getValueAsString?: func, headerLabel?: string, InputComponent?: elementType, InputComponentProps?: object, label?: string, requiresFilterValue?: bool, value: string }>, flex?: number, getApplyQuickFilterFn?: func, getSortComparator?: func, groupable?: bool, groupingValueGetter?: func, groupingValueSetter?: func, headerAlign?: 'center'
| 'left'
| 'right', headerClassName?: func
| string, headerName?: string, hideable?: bool, hideSortIcons?: bool, maxWidth?: number, minWidth?: number, pastedValueParser?: func, pinnable?: bool, pivotable?: bool, preProcessEditCellProps?: func, renderCell?: func, renderEditCell?: func, renderHeader?: func, renderHeaderFilter?: func, resizable?: bool, rowSpanValueGetter?: func, sortable?: bool, sortComparator?: func, sortingOrder?: Array<'asc'
| 'desc'>, valueFormatter?: func, valueGetter?: func, valueParser?: func, valueSetter?: func, width?: number }" } }, "checkboxSelection": { "type": { "name": "bool" }, "default": "false" }, @@ -91,6 +91,8 @@ "disableColumnSorting": { "type": { "name": "bool" }, "default": "false" }, "disableDensitySelector": { "type": { "name": "bool" }, "default": "false" }, "disableEval": { "type": { "name": "bool" }, "default": "false" }, + "disableFormulaAutocomplete": { "type": { "name": "bool" }, "default": "false" }, + "disableFormulas": { "type": { "name": "bool" }, "default": "false" }, "disableMultipleColumnsFiltering": { "type": { "name": "bool" }, "default": "false" }, "disableMultipleColumnsSorting": { "type": { "name": "bool" }, "default": "false" }, "disableMultipleRowSelection": { @@ -124,6 +126,11 @@ "description": "{ items: Array<{ field: string, id?: number
| string, operator: string, value?: any }>, logicOperator?: 'and'
| 'or', quickFilterExcludeHiddenColumns?: bool, quickFilterLogicOperator?: 'and'
| 'or', quickFilterValues?: array }" } }, + "formulaA1Notation": { "type": { "name": "bool" }, "default": "false" }, + "formulaFunctions": { + "type": { "name": "object" }, + "default": "GRID_FORMULA_FUNCTIONS when `dataSource` is not provided, `{}` when `dataSource` is provided" + }, "getAggregationPosition": { "type": { "name": "func" }, "default": "(groupNode) => (groupNode.depth === -1 ? 'footer' : 'inline')", @@ -218,7 +225,7 @@ "historyValidationEvents": { "type": { "name": "arrayOf", - "description": "Array<'activeChartIdChange'
| 'activeStrategyProcessorChange'
| 'aggregationLookupSet'
| 'aggregationModelChange'
| 'aiAssistantActiveConversationIndexChange'
| 'aiAssistantConversationsChange'
| 'cellClick'
| 'cellDoubleClick'
| 'cellDragEnter'
| 'cellDragOver'
| 'cellEditStart'
| 'cellEditStop'
| 'cellFocusIn'
| 'cellFocusOut'
| 'cellKeyDown'
| 'cellKeyUp'
| 'cellModeChange'
| 'cellModesModelChange'
| 'cellMouseDown'
| 'cellMouseOver'
| 'cellMouseUp'
| 'cellSelectionChange'
| 'chartSynchronizationStateChange'
| 'clipboardCopy'
| 'clipboardPasteEnd'
| 'clipboardPasteStart'
| 'columnGroupHeaderBlur'
| 'columnGroupHeaderFocus'
| 'columnGroupHeaderKeyDown'
| 'columnHeaderBlur'
| 'columnHeaderClick'
| 'columnHeaderContextMenu'
| 'columnHeaderDoubleClick'
| 'columnHeaderDragEnd'
| 'columnHeaderDragEndNative'
| 'columnHeaderDragEnter'
| 'columnHeaderDragOver'
| 'columnHeaderDragStart'
| 'columnHeaderEnter'
| 'columnHeaderFocus'
| 'columnHeaderKeyDown'
| 'columnHeaderLeave'
| 'columnHeaderOut'
| 'columnHeaderOver'
| 'columnIndexChange'
| 'columnOrderChange'
| 'columnResize'
| 'columnResizeStart'
| 'columnResizeStop'
| 'columnsChange'
| 'columnSeparatorDoubleClick'
| 'columnSeparatorMouseDown'
| 'columnVisibilityModelChange'
| 'columnWidthChange'
| 'debouncedResize'
| 'densityChange'
| 'detailPanelsExpandedRowIdsChange'
| 'excelExportStateChange'
| 'fetchRows'
| 'filteredRowsSet'
| 'filterModelChange'
| 'headerFilterBlur'
| 'headerFilterClick'
| 'headerFilterKeyDown'
| 'headerFilterMouseDown'
| 'headerSelectionCheckboxChange'
| 'menuClose'
| 'menuOpen'
| 'paginationMetaChange'
| 'paginationModelChange'
| 'pinnedColumnsChange'
| 'pivotModeChange'
| 'pivotModelChange'
| 'pivotPanelOpenChange'
| 'preferencePanelClose'
| 'preferencePanelOpen'
| 'redo'
| 'renderedRowsIntervalChange'
| 'resize'
| 'rootMount'
| 'rowClick'
| 'rowCountChange'
| 'rowDoubleClick'
| 'rowDragEnd'
| 'rowDragOver'
| 'rowDragStart'
| 'rowEditStart'
| 'rowEditStop'
| 'rowExpansionChange'
| 'rowGroupingModelChange'
| 'rowModesModelChange'
| 'rowMouseEnter'
| 'rowMouseLeave'
| 'rowMouseOut'
| 'rowMouseOver'
| 'rowOrderChange'
| 'rowSelectionChange'
| 'rowSelectionCheckboxChange'
| 'rowsScrollEnd'
| 'rowsScrollEndIntersection'
| 'rowsSet'
| 'scrollPositionChange'
| 'sidebarClose'
| 'sidebarOpen'
| 'sortedRowsSet'
| 'sortModelChange'
| 'stateChange'
| 'strategyAvailabilityChange'
| 'undo'
| 'unmount'
| 'viewportInnerSizeChange'
| 'virtualScrollerContentSizeChange'
| 'virtualScrollerTouchMove'
| 'virtualScrollerWheel'>" + "description": "Array<'activeChartIdChange'
| 'activeStrategyProcessorChange'
| 'aggregationLookupSet'
| 'aggregationModelChange'
| 'aiAssistantActiveConversationIndexChange'
| 'aiAssistantConversationsChange'
| 'cellClick'
| 'cellDoubleClick'
| 'cellDragEnter'
| 'cellDragOver'
| 'cellEditStart'
| 'cellEditStop'
| 'cellFocusIn'
| 'cellFocusOut'
| 'cellKeyDown'
| 'cellKeyUp'
| 'cellModeChange'
| 'cellModesModelChange'
| 'cellMouseDown'
| 'cellMouseOver'
| 'cellMouseUp'
| 'cellSelectionChange'
| 'chartSynchronizationStateChange'
| 'clipboardCopy'
| 'clipboardPasteEnd'
| 'clipboardPasteStart'
| 'columnGroupHeaderBlur'
| 'columnGroupHeaderFocus'
| 'columnGroupHeaderKeyDown'
| 'columnHeaderBlur'
| 'columnHeaderClick'
| 'columnHeaderContextMenu'
| 'columnHeaderDoubleClick'
| 'columnHeaderDragEnd'
| 'columnHeaderDragEndNative'
| 'columnHeaderDragEnter'
| 'columnHeaderDragOver'
| 'columnHeaderDragStart'
| 'columnHeaderEnter'
| 'columnHeaderFocus'
| 'columnHeaderKeyDown'
| 'columnHeaderLeave'
| 'columnHeaderOut'
| 'columnHeaderOver'
| 'columnIndexChange'
| 'columnOrderChange'
| 'columnResize'
| 'columnResizeStart'
| 'columnResizeStop'
| 'columnsChange'
| 'columnSeparatorDoubleClick'
| 'columnSeparatorMouseDown'
| 'columnVisibilityModelChange'
| 'columnWidthChange'
| 'debouncedResize'
| 'densityChange'
| 'detailPanelsExpandedRowIdsChange'
| 'excelExportStateChange'
| 'fetchRows'
| 'filteredRowsSet'
| 'filterModelChange'
| 'formulaEvaluationEnd'
| 'headerFilterBlur'
| 'headerFilterClick'
| 'headerFilterKeyDown'
| 'headerFilterMouseDown'
| 'headerSelectionCheckboxChange'
| 'menuClose'
| 'menuOpen'
| 'paginationMetaChange'
| 'paginationModelChange'
| 'pinnedColumnsChange'
| 'pivotModeChange'
| 'pivotModelChange'
| 'pivotPanelOpenChange'
| 'preferencePanelClose'
| 'preferencePanelOpen'
| 'redo'
| 'renderedRowsIntervalChange'
| 'resize'
| 'rootMount'
| 'rowClick'
| 'rowCountChange'
| 'rowDoubleClick'
| 'rowDragEnd'
| 'rowDragOver'
| 'rowDragStart'
| 'rowEditStart'
| 'rowEditStop'
| 'rowExpansionChange'
| 'rowGroupingModelChange'
| 'rowModesModelChange'
| 'rowMouseEnter'
| 'rowMouseLeave'
| 'rowMouseOut'
| 'rowMouseOver'
| 'rowOrderChange'
| 'rowSelectionChange'
| 'rowSelectionCheckboxChange'
| 'rowsScrollEnd'
| 'rowsScrollEndIntersection'
| 'rowsSet'
| 'scrollPositionChange'
| 'sidebarClose'
| 'sidebarOpen'
| 'sortedRowsSet'
| 'sortModelChange'
| 'stateChange'
| 'strategyAvailabilityChange'
| 'undo'
| 'unmount'
| 'viewportInnerSizeChange'
| 'virtualScrollerContentSizeChange'
| 'virtualScrollerTouchMove'
| 'virtualScrollerWheel'>" }, "default": "['columnsChange', 'rowsSet', 'sortedRowsSet', 'filteredRowsSet', 'paginationModelChange']" }, @@ -2353,6 +2360,18 @@ "description": "Styles applied to the footer container element.", "isGlobal": false }, + { + "key": "formulaColumnHeaderLetter", + "className": "MuiDataGridPremium-formulaColumnHeaderLetter", + "description": "Styles applied to the A1-notation column-letter adornment in the column header (Premium formulas).", + "isGlobal": false + }, + { + "key": "formulaRowNumberCell", + "className": "MuiDataGridPremium-formulaRowNumberCell", + "description": "Styles applied to the cells of the A1-notation row-number column (Premium formulas).", + "isGlobal": false + }, { "key": "groupingCriteriaCell", "className": "MuiDataGridPremium-groupingCriteriaCell", diff --git a/docs/pages/x/api/data-grid/data-grid-pro.json b/docs/pages/x/api/data-grid/data-grid-pro.json index 88b3760560026..2b1c9d81786be 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -1901,6 +1901,18 @@ "description": "Styles applied to the footer container element.", "isGlobal": false }, + { + "key": "formulaColumnHeaderLetter", + "className": "MuiDataGridPro-formulaColumnHeaderLetter", + "description": "Styles applied to the A1-notation column-letter adornment in the column header (Premium formulas).", + "isGlobal": false + }, + { + "key": "formulaRowNumberCell", + "className": "MuiDataGridPro-formulaRowNumberCell", + "description": "Styles applied to the cells of the A1-notation row-number column (Premium formulas).", + "isGlobal": false + }, { "key": "groupingCriteriaCell", "className": "MuiDataGridPro-groupingCriteriaCell", diff --git a/docs/pages/x/api/data-grid/data-grid.json b/docs/pages/x/api/data-grid/data-grid.json index 8ba1f036f91b6..35d615e4c5f21 100644 --- a/docs/pages/x/api/data-grid/data-grid.json +++ b/docs/pages/x/api/data-grid/data-grid.json @@ -1722,6 +1722,18 @@ "description": "Styles applied to the footer container element.", "isGlobal": false }, + { + "key": "formulaColumnHeaderLetter", + "className": "MuiDataGrid-formulaColumnHeaderLetter", + "description": "Styles applied to the A1-notation column-letter adornment in the column header (Premium formulas).", + "isGlobal": false + }, + { + "key": "formulaRowNumberCell", + "className": "MuiDataGrid-formulaRowNumberCell", + "description": "Styles applied to the cells of the A1-notation row-number column (Premium formulas).", + "isGlobal": false + }, { "key": "groupingCriteriaCell", "className": "MuiDataGrid-groupingCriteriaCell", diff --git a/docs/pages/x/api/data-grid/grid-actions-col-def.json b/docs/pages/x/api/data-grid/grid-actions-col-def.json index 51f1b379c7a44..601ea0de64d1d 100644 --- a/docs/pages/x/api/data-grid/grid-actions-col-def.json +++ b/docs/pages/x/api/data-grid/grid-actions-col-def.json @@ -21,6 +21,11 @@ "isPremiumPlan": true }, "align": { "type": { "description": "GridAlignment" } }, + "allowFormulas": { + "type": { "description": "boolean" }, + "default": "false", + "isPremiumPlan": true + }, "availableAggregationFunctions": { "type": { "description": "string[]" }, "isPremiumPlan": true diff --git a/docs/pages/x/api/data-grid/grid-api.json b/docs/pages/x/api/data-grid/grid-api.json index 8a82254598cbc..9bad1d8f18592 100644 --- a/docs/pages/x/api/data-grid/grid-api.json +++ b/docs/pages/x/api/data-grid/grid-api.json @@ -73,6 +73,16 @@ "type": { "description": "(id: GridRowId, field: string) => HTMLDivElement | null" }, "required": true }, + "getCellFormula": { + "type": { "description": "(id: GridRowId, field: string) => string | null" }, + "required": true, + "isPremiumPlan": true + }, + "getCellFormulaResult": { + "type": { "description": "(id: GridRowId, field: string) => GridFormulaResult | null" }, + "required": true, + "isPremiumPlan": true + }, "getCellMode": { "type": { "description": "(id: GridRowId, field: string) => GridCellMode" }, "required": true @@ -279,6 +289,11 @@ "isProPlan": true }, "publishEvent": { "type": { "description": "GridEventPublisher" }, "required": true }, + "reevaluateFormulas": { + "type": { "description": "() => void" }, + "required": true, + "isPremiumPlan": true + }, "removeRowGroupingCriteria": { "type": { "description": "(groupingCriteriaField: string) => void" }, "required": true, @@ -338,6 +353,11 @@ "type": { "description": "(id: GridRowId, field: string) => void" }, "required": true }, + "setCellFormula": { + "type": { "description": "(id: GridRowId, field: string, formula: string) => void" }, + "required": true, + "isPremiumPlan": true + }, "setCellSelectionModel": { "type": { "description": "(newModel: GridCellSelectionModel) => void" }, "required": true, @@ -606,6 +626,11 @@ "upsertFilterItems": { "type": { "description": "(items: GridFilterItem[]) => void" }, "required": true + }, + "validateCellFormula": { + "type": { "description": "(formula: string) => GridFormulaValidationResult" }, + "required": true, + "isPremiumPlan": true } } } diff --git a/docs/pages/x/api/data-grid/grid-col-def.json b/docs/pages/x/api/data-grid/grid-col-def.json index c7d4fb92c628d..2818034793989 100644 --- a/docs/pages/x/api/data-grid/grid-col-def.json +++ b/docs/pages/x/api/data-grid/grid-col-def.json @@ -14,6 +14,11 @@ "isPremiumPlan": true }, "align": { "type": { "description": "GridAlignment" } }, + "allowFormulas": { + "type": { "description": "boolean" }, + "default": "false", + "isPremiumPlan": true + }, "availableAggregationFunctions": { "type": { "description": "string[]" }, "isPremiumPlan": true diff --git a/docs/pages/x/api/data-grid/grid-single-select-col-def.json b/docs/pages/x/api/data-grid/grid-single-select-col-def.json index 83d0bc4d2175c..9fa83c84599de 100644 --- a/docs/pages/x/api/data-grid/grid-single-select-col-def.json +++ b/docs/pages/x/api/data-grid/grid-single-select-col-def.json @@ -25,6 +25,11 @@ "isPremiumPlan": true }, "align": { "type": { "description": "GridAlignment" } }, + "allowFormulas": { + "type": { "description": "boolean" }, + "default": "false", + "isPremiumPlan": true + }, "availableAggregationFunctions": { "type": { "description": "string[]" }, "isPremiumPlan": true diff --git a/docs/pages/x/api/data-grid/selectors.json b/docs/pages/x/api/data-grid/selectors.json index f30f60e1420f1..71a799b31ea50 100644 --- a/docs/pages/x/api/data-grid/selectors.json +++ b/docs/pages/x/api/data-grid/selectors.json @@ -16,6 +16,12 @@ "returnType": "GridAggregationState", "description": "" }, + { + "name": "gridCellFormulaResultSelector", + "returnType": "FormulaResult | null", + "category": "Formulas", + "description": "Get the evaluated result of one formula cell, or `null` when the cell does\nnot hold a formula. Presence in the lookup is the masking criterion — a\nformula evaluating to `null` still returns a result entry." + }, { "name": "gridColumnDefinitionsSelector", "returnType": "GridStateColDef[]", @@ -204,6 +210,13 @@ "description": "" }, { "name": "gridFocusStateSelector", "returnType": "GridFocusState", "description": "" }, + { + "name": "gridFormulaLookupSelector", + "returnType": "GridFormulaLookup", + "category": "Formulas", + "description": "Get the evaluated formula results as a lookup, keyed by row id and field." + }, + { "name": "gridFormulaStateSelector", "returnType": "GridFormulaState", "description": "" }, { "name": "gridHeaderFilteringEditFieldSelector", "returnType": "string | null", diff --git a/docs/pages/x/react-data-grid/formulas.js b/docs/pages/x/react-data-grid/formulas.js new file mode 100644 index 0000000000000..f99a14f45f6ee --- /dev/null +++ b/docs/pages/x/react-data-grid/formulas.js @@ -0,0 +1,6 @@ +import { MarkdownDocs } from '@mui/internal-core-docs/MarkdownDocs'; +import * as pageProps from 'docs/data/data-grid/formulas/formulas.md?muiMarkdown'; + +export default function Page() { + return ; +} diff --git a/docs/public/static/error-codes.json b/docs/public/static/error-codes.json index 1fa97a5f9e2ea..495c761378e5c 100644 --- a/docs/public/static/error-codes.json +++ b/docs/public/static/error-codes.json @@ -283,5 +283,9 @@ "283": "MUI X Scheduler: TitleColumnWidthProvider is missing.", "284": "MUI X Data Grid: Nested lazy loading does not support unknown children count for now.\nIf this is a use-case that you are interested in, please open an issue: https://github.com/mui/mui-x/issues/new?template=2.feature.yml", "285": "MUI X Data Grid: Row count is unknown. Please provide a valid row count for lazy loading to work.", - "286": "MUI X Scheduler: useSharedComponentsStyledContext must be used within a SharedComponentsStyledContext.Provider. The shared internal components require the product to inject their utility classes. Ensure the component is rendered inside an EventCalendar, EventCalendarPremium, or EventTimelinePremium component, a standalone view, or another component that provides SharedComponentsStyledContext." + "286": "MUI X Scheduler: useSharedComponentsStyledContext must be used within a SharedComponentsStyledContext.Provider. The shared internal components require the product to inject their utility classes. Ensure the component is rendered inside an EventCalendar, EventCalendarPremium, or EventTimelinePremium component, a standalone view, or another component that provides SharedComponentsStyledContext.", + "287": "MUI X Data Grid: The formula function name \"%s\" is reserved by the formula syntax. Registering it would make the function unreachable in formulas. Rename the custom function to a non-reserved name.", + "288": "MUI X Data Grid: \"%s\" is not a valid formula function name. The formula parser can never produce a call to it, which would make the function unreachable in formulas. Function names must start with a letter or underscore and contain only letters, digits, and underscores.", + "289": "MUI X Data Grid: The column \"%s\" does not allow formulas. Writing a formula to it would store a string rendered as-is instead of an evaluated value. Set `allowFormulas: true` on the column definition. See https://mui.com/x/react-data-grid/formulas/.", + "290": "MUI X Data Grid: `setCellFormula()` expects a formula source starting with `=`, for example `=price * quantity`. Other values would not be recognized as formulas. To store a plain value, use `updateRows()` instead." } diff --git a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json index 3361797b713db..cac6ac7c49371 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json @@ -117,6 +117,12 @@ "disableEval": { "description": "If true, eval() is not used for performance optimization." }, + "disableFormulaAutocomplete": { + "description": "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." + }, + "disableFormulas": { + "description": "If true, the formula evaluation is disabled: = cell values render as raw strings." + }, "disableMultipleColumnsFiltering": { "description": "If true, filtering with multiple columns is disabled." }, @@ -151,6 +157,12 @@ "description": "Filtering can be processed on the server or client-side. Set it to 'server' if you would like to handle filtering on the server-side." }, "filterModel": { "description": "Set the filter model of the Data Grid." }, + "formulaA1Notation": { + "description": "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." + }, + "formulaFunctions": { + "description": "Functions available to formulas, keyed by name. The prop replaces the built-in set: spread GRID_FORMULA_FUNCTIONS to extend it." + }, "getAggregationPosition": { "description": "Determines the position of an aggregated value.", "typeDescriptions": { @@ -1527,6 +1539,14 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the footer container element" }, + "formulaColumnHeaderLetter": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the A1-notation column-letter adornment in the column header (Premium formulas)" + }, + "formulaRowNumberCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the cells of the A1-notation row-number column (Premium formulas)" + }, "groupingCriteriaCell": { "description": "Styles applied to the root element of the grouping criteria cell" }, diff --git a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json index 269d56733bb8a..e1f9b31e1d2d0 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json @@ -1327,6 +1327,14 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the footer container element" }, + "formulaColumnHeaderLetter": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the A1-notation column-letter adornment in the column header (Premium formulas)" + }, + "formulaRowNumberCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the cells of the A1-notation row-number column (Premium formulas)" + }, "groupingCriteriaCell": { "description": "Styles applied to the root element of the grouping criteria cell" }, diff --git a/docs/translations/api-docs/data-grid/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid/data-grid.json index 17fcb3f78c84a..37aac4bb63d6f 100644 --- a/docs/translations/api-docs/data-grid/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid/data-grid.json @@ -1126,6 +1126,14 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the footer container element" }, + "formulaColumnHeaderLetter": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the A1-notation column-letter adornment in the column header (Premium formulas)" + }, + "formulaRowNumberCell": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the cells of the A1-notation row-number column (Premium formulas)" + }, "groupingCriteriaCell": { "description": "Styles applied to the root element of the grouping criteria cell" }, diff --git a/docs/translations/api-docs/data-grid/grid-actions-col-def.json b/docs/translations/api-docs/data-grid/grid-actions-col-def.json index f92c946d19b8b..e03534cf7c67b 100644 --- a/docs/translations/api-docs/data-grid/grid-actions-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-actions-col-def.json @@ -10,6 +10,9 @@ "description": "If true, the cells of the column can be aggregated based." }, "align": { "description": "Align cell content." }, + "allowFormulas": { + "description": "If true, cell values that are strings starting with = are parsed and evaluated as formulas.
The evaluated value flows through rendering, sorting, filtering and export, while the formula
source remains the stored row-data value." + }, "availableAggregationFunctions": { "description": "Limit the aggregation function usable on this column.
By default, the column will have all the aggregation functions that are compatible with its type." }, diff --git a/docs/translations/api-docs/data-grid/grid-api.json b/docs/translations/api-docs/data-grid/grid-api.json index b45e4016089b6..4884addc7829b 100644 --- a/docs/translations/api-docs/data-grid/grid-api.json +++ b/docs/translations/api-docs/data-grid/grid-api.json @@ -29,6 +29,12 @@ "getCellElement": { "description": "Gets the GridCellParams object that is passed as argument in events." }, + "getCellFormula": { + "description": "Returns the formula source stored in the cell's row data,
or null when the cell does not hold a formula." + }, + "getCellFormulaResult": { + "description": "Returns the evaluated result of a formula cell,
or null when the cell does not hold a formula." + }, "getCellMode": { "description": "Gets the mode of a cell." }, "getCellParams": { "description": "Gets the GridCellParams object that is passed as argument in events." @@ -131,6 +137,9 @@ "isRowSelected": { "description": "Determines if a row is selected or not." }, "pinColumn": { "description": "Pins a column to the left or right side of the grid." }, "publishEvent": { "description": "Emits an event." }, + "reevaluateFormulas": { + "description": "Discards every formula cache and re-evaluates all formulas.
Escape hatch for in-place row mutations the grid cannot observe." + }, "removeRowGroupingCriteria": { "description": "Remove the field from the row grouping model." }, "resetRowHeights": { "description": "Forces the recalculation of the heights of all rows." }, "restoreState": { "description": "Inject the given values into the state of the DataGrid." }, @@ -155,6 +164,9 @@ "setCellFocus": { "description": "Sets the focus to the cell at the given id and field." }, + "setCellFormula": { + "description": "Stores a formula as the cell's row-data value and re-evaluates." + }, "setCellSelectionModel": { "description": "Updates the cell selection model according to the value passed to the newModel argument.
Any cell already selected will be unselected." }, @@ -281,6 +293,9 @@ }, "upsertFilterItems": { "description": "Updates or inserts many GridFilterItem." + }, + "validateCellFormula": { + "description": "Statically validates a formula source against the current function registry.
Validation is informative — invalid formulas can still be committed." } } } diff --git a/docs/translations/api-docs/data-grid/grid-col-def.json b/docs/translations/api-docs/data-grid/grid-col-def.json index 52d61a18aa085..3e49c4db1a674 100644 --- a/docs/translations/api-docs/data-grid/grid-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-col-def.json @@ -8,6 +8,9 @@ "description": "If true, the cells of the column can be aggregated based." }, "align": { "description": "Align cell content." }, + "allowFormulas": { + "description": "If true, cell values that are strings starting with = are parsed and evaluated as formulas.
The evaluated value flows through rendering, sorting, filtering and export, while the formula
source remains the stored row-data value." + }, "availableAggregationFunctions": { "description": "Limit the aggregation function usable on this column.
By default, the column will have all the aggregation functions that are compatible with its type." }, diff --git a/docs/translations/api-docs/data-grid/grid-single-select-col-def.json b/docs/translations/api-docs/data-grid/grid-single-select-col-def.json index faf60a48d1af6..148ecddebc801 100644 --- a/docs/translations/api-docs/data-grid/grid-single-select-col-def.json +++ b/docs/translations/api-docs/data-grid/grid-single-select-col-def.json @@ -13,6 +13,9 @@ "description": "If true, the cells of the column can be aggregated based." }, "align": { "description": "Align cell content." }, + "allowFormulas": { + "description": "If true, cell values that are strings starting with = are parsed and evaluated as formulas.
The evaluated value flows through rendering, sorting, filtering and export, while the formula
source remains the stored row-data value." + }, "availableAggregationFunctions": { "description": "Limit the aggregation function usable on this column.
By default, the column will have all the aggregation functions that are compatible with its type." }, diff --git a/docsTech/README.md b/docsTech/README.md index d3bc71fa68164..2847fa74a2edf 100644 --- a/docsTech/README.md +++ b/docsTech/README.md @@ -65,4 +65,5 @@ Here you will find more precision about how some features are designed. - [filtering](./filtering.md) - [virtualization](./virtualization.md) - [processing](./processing.md) +- [formulas](./data-grid-formula-feature.md) - print (TODO) diff --git a/docsTech/data-grid-formula-feature.md b/docsTech/data-grid-formula-feature.md new file mode 100644 index 0000000000000..11d8c6fa116b0 --- /dev/null +++ b/docsTech/data-grid-formula-feature.md @@ -0,0 +1,427 @@ +# Data Grid Premium — Formula (Excel-like) support + +This document records the locked design decisions for the formula feature so that every iteration +implements against the same contracts. The feature lets cells whose row-data value starts with `=` +be parsed and evaluated; the evaluated value flows through rendering, sorting, filtering, export and +aggregation, while the formula source remains the canonical stored value in row data. + +The code lives in `packages/x-data-grid-premium/src/hooks/features/formula/`, with the pure engine +(no React, no grid imports) in its `engine/` subfolder. + +The feature ships in 6 independently mergeable iterations: + +- **I1** — pure engine: tokenizer, parser, serializer, dependency extraction/binding, function + registry, evaluator, graph utilities. The full grammar parses (including ranges and positional + selectors), but ranges and positional refs evaluate to `#REF!` until I3. AST and resolver types + freeze at the end of I1. +- **I2** — grid adapter: state/cache/selectors, value overlay, editing, invalidation, public API. + Ships a usable computed-column feature with same-row and stable cross-row references. +- **I3** — ranges + position context: `COLUMN_VALUES`/`RANGE` evaluation, rebind machinery, + `#REF!` propagation on row/column removal, re-grouping/re-spanning on evaluated values + (the two D18 deferrals from I2). +- **I4** — A1 editor syntax (explicitly cuttable): bidirectional A1 ↔ canonical transform, + commit-time freezing of relative refs. +- **I5** — formula-editor autocomplete: completion vocabulary + caret context engine, adapter token + sourcing, the suggestion dropdown (headless `useAutocomplete` + `Popper`), and signature help (D20). +- **I6** — polish: docs expansion, a11y, generated artifacts. +- **I7** — fill-handle reference adjustment: dragging (or Ctrl+D/Ctrl+R filling) a formula copies it + with its relative references shifted for each target cell, Excel-style (D21). +- **I8** — Excel formula export: with `escapeFormulas: false`, live formula cells export as real + Excel formulas with references re-anchored to the exported sheet's coordinates (D22). + +## Locked design decisions + +**D1. Layout.** `packages/x-data-grid-premium/src/hooks/features/formula/` with a pure `engine/` +subfolder (no React, no grid imports; engine files import only from `engine/`). Engine files use the +bare `formula*` prefix; adapter files use the repo's `gridFormula*` / `useGridFormula*` prefixes. +The engine defines its own `FormulaRowId = string | number` (structural twin of `GridRowId`) to stay +extractable; the engine is **not** exported from the public barrel — internal seam only (future +pluggable-engine escape hatch). The engine deliberately does **not** live in `@mui/x-internals`: +that package is MIT-licensed while the engine is premium IP, and a shared package adds release +coordination for no benefit. The zero-grid-imports purity rule means relocating it later is a +mechanical import-path change. + +**D2. Grammar.** Operators with Excel precedence: comparison < `&` < `+ -` < `* /` < `^` +(left-associative, `2^3^2 = 64`) < unary (`-2^2 = 4` — Excel behavior). Unary `+` is an identity +operation (no coercion, Excel behavior); only unary `-` coerces numerically. Literals: number +(finite only — out-of-range literals are parse errors), double-quoted string with `""` escape, +TRUE/FALSE. Bare identifier = same-row field ref, always (existence checked at evaluation → +`#REF!`); `FIELD("unit price")` is the escape hatch for arbitrary field names. Function names are +case-insensitive; fields are case-sensitive. en-US only (`,` argument separator, `.` decimal). +Hand-written recursive descent; every AST node carries a source `span` (parenthesized expressions +span their parentheses). Parser recursion depth and AST height are bounded (`MAX_FORMULA_DEPTH`), +so hostile inputs become ordinary parse errors instead of stack overflows — this is what upholds +the never-throws contracts of `evaluateFormula` and `serializeFormulaAst` downstream. `'=foo` +apostrophe-escape stores a literal `=` string. + +**D3. Canonical special forms are dedicated AST nodes, not registry functions** — `REF`, `COLUMN`, +`ROW`, `COLUMN_POSITION`, `ROW_POSITION`, `FIELD`, `RANGE`, `COLUMN_VALUES` with **literal-only +arguments enforced by the parser** (computed refs → parse error; a sign before a numeric `ROW()` id +is still a literal). This keeps static dependency extraction decidable. The registry rejects +reserved names and names the parser can never produce as calls. Unified `cellRef` node with +per-axis selectors (`FormulaColumnSelector = field | position`, `FormulaRowSelector = id | +position`) — separate node types cannot encode mixed-axis refs like `A$1`. + +**D4. Position-context policy (applies from I3).** Position context = `gridFilteredSortedRowIdsSelector` +order **excluding autogenerated (group/footer) and pinned rows** × visible column order. **One-shot, +no fixpoint:** sorting/filtering consume formula values as of when the comparator ran; after +`sortedRowsSet`/`filteredRowsSet`, position-dependent formulas rebind and re-evaluate exactly once; +the grid never auto re-sorts/re-filters in response. Deterministic, loop-free, Excel-consistent +(Excel never auto-resorts after recalc). Documented remedy: re-apply the sort. Stable refs and field +refs (the dominant cases) are unaffected. Relative A1 refs freeze to **stable** refs at commit (I4). +_Amended during I3 (implementation specifics):_ + +- **Rebind triggers** are `sortedRowsSet`, `filteredRowsSet`, `columnVisibilityModelChange` and + `columnsChange` — NOT the drafted `columnOrderChange`: programmatic `setColumnIndex` never fires + it (only `columnIndexChange` + `columnsChange`), while drag reorder funnels through + `setGridColumnsState` → `columnsChange` on every step. Conversely `setColumnVisibilityModel` + replaces the columns state directly without `columnsChange`, so the visibility model event is + subscribed separately. The rebind pass itself compares the previous row-id/field-order snapshot + and exits cheaply when nothing moved (also making the `columnsChange` noise from width changes + harmless), and exits before building a snapshot when no record uses positions. +- The position context is **built lazily** (first position-dependent record binds it), cached on + the formula cache, and replaced only by rebind passes — every consumer within a pass sees one + snapshot, so bind-time and eval-time resolution can never disagree. +- The **column axis excludes utility columns** (selection checkbox, detail panel toggle, row + reorder handle, tree-data and row-grouping columns), mirroring the row-side autogenerated + exclusion — otherwise enabling `checkboxSelection` would shift every positional column + reference by one, and I4's A1 sugar would map column `A` to the checkbox column. +- **Initialization order**: sorting and filtering states initialize after the formula state, so + the initial evaluation binds against the row-tree order; the mount-time sorting/filtering + cascade publishes `sortedRowsSet`/`filteredRowsSet` and rebinds before interaction. During the + mount window, filtering applies before the first `applySorting`, so `sortedRows` can still be + empty while rows exist — the snapshot builder detects that window and falls back to the + row-tree order (matching the initial snapshot), so the `filteredRowsSet` rebind compares equal + and the single real rebind happens on the mount `sortedRowsSet`. +- After a rebind pass that changed cells, the hook calls `applyAggregation()` (aggregation read + values earlier in the same cascade); row spanning resets itself on the same events, registered + after the formula hook. + +**D5. References.** `A1` → `REF(COLUMN("fieldAtA"), ROW("rowIdAt1"))`; `$A1` → positional column; +`A$1` → positional row; `$A$1` → both positional. Same-row: bare field ref. + +**I4 amendment.** The `$`-axis semantics are intentionally **inverted from Excel** so the grid stays +loop-free under re-sort — a **relative** (no-`$`) axis **freezes** to the stable identity currently at +that position (`A`→`COLUMN("fieldAtA")`, `1`→`ROW(idAt1)`) and shifts by the paste offset; an +**absolute** (`$`) axis stays **positional** (`$A`→`COLUMN_POSITION`, `$1`→`ROW_POSITION`) and never +shifts. A1 is **editor-only and never stored** (row data, copy, export, `getCellFormula` always +canonical). Same-row references have **no A1 cell-address form** — they display as the bare field +name in both dialects ("Keep D5", confirmed 2026-06-15): `=price * quantity` edits as +`=price * quantity`, not `=B3*C3` (a `fieldRef` cannot round-trip through `B3`, which would freeze +to a specific row). + +**D6. Ranges.** `RANGE(anchorRef, anchorRef)` = inclusive rectangle resolved **positionally at bind +time** (stable anchors mapped to current positions; normalized min/max; anchor without a position → +`#REF!`); any formula containing `RANGE` is position-context-dependent even with stable anchors. +`COLUMN_VALUES("field")` = the field's values over the current **sorted+filtered** row set +(consistent with `aggregationRowsScope: 'filtered'`) — the recommended, sort-proof "sum the column" +form; editor sugar `A:A`. Ranges legal only as args to `acceptsRanges` functions (enforced on both +the eager and lazy argument paths); range in scalar position → `#VALUE!`. Dev warning above ~100k +materialized cells. + +**D7. Range dependencies are interval records, never exploded edges.** +`FormulaBoundDependencies = { cells: Set, columnIntervals: {field, fromIndex, toIndex}[], +wholeColumns, errors }` + reverse maps `dependents` and `rangeDependentsByField`. Range graph edges +connect only to **formula** cells inside bounds (raw cells can't create cycles), so +`SUM(COLUMN_VALUES("total"))` placed in column `total` → `#CYCLE!` (correct, Excel-consistent). +_Amended during I3 (implementation specifics):_ + +- Cache tiers: `rangeDependentsByField: Map>` + (reverse direction — what to dirty when a cell of `field` changes, position checked against the + dependent's intervals) and `recordsByField: Map>` (forward direction — expands an + interval into the formula cells it covers for `orderForRecompute`, restricted to rows that have a + position). Both maintained by the record attach/detach path; `positionDependentKeys` tracks the + rebind set. +- The graph callbacks passed to `collectAffectedCells`/`orderForRecompute` expand the interval tier + in **both** directions — this is what makes a formula cell inside a range evaluate before its + range consumer, and what turns a self-covering range into an unpeelable self-edge (`#CYCLE!`). +- Raw cells read through range materialization land in `trackedValues` like cell-edge reads (this + is how single-cell edits inside a range dirty its consumers); when the **last** range dependent + of a field detaches, the field's tracked values are swept, sparing entries that still serve a + cell-edge dependent. +- Range materialization is row-major (left to right, then top to bottom); the first error value + inside a range propagates (strict-propagation rule — uniform for SUM and COUNT alike, a deliberate + simplification vs. Excel's error-ignoring COUNT). `COLUMN_VALUES` accepts hidden fields (values + exist without a position); `RANGE` anchors need positions, hence hidden/filtered anchors → + `#REF!`. Membership changes (row add/remove, filter flips) are NOT handled by per-cell dirtying — + the rebind pass dirties every position-dependent record when the view order changes. +- Dev-mode warning when one formula materializes more than 100k range cells. + +**D8. Evaluation & errors.** Resolver interface supplied by adapter; contract: side-effect free, +never recurses into the engine (topo order guarantees dep freshness). Coercion matrix in +`formulaValues.ts` (notable: string comparison case-insensitive like Excel, isolated in one +`compareScalars`; cross-type ordered comparison → `#VALUE!`; empty operands take a type-neutral +substitute in ordered comparisons — number → 0, string → `''`, boolean → FALSE, Date → epoch; +Dates via `getTime()`, invalid Dates → `#VALUE!` in ordered comparison and never equal in equality; +division-by-zero → `#DIV/0!`, other non-finite → `#VALUE!`, including function results — built-in +overflow like `SUM(1e308, 1e308)` and custom registry functions alike). Error codes: `#REF! +#DIV/0! #CYCLE! #NAME? #VALUE! #ERROR!`; strict left-to-right first-error propagation, except `lazy` +functions (IF/AND/OR — untaken branches never evaluated) and `acceptsErrors` (IFERROR, ISBLANK). +`#CYCLE!` is assigned only by the graph layer. Cycle detection: **single-pass Kahn** over the +affected subgraph (unpeeled nodes = cyclic); iterative implementations only (10k-deep dependency +chains must not blow the stack). Volatile policy: **zero volatile built-ins** (no NOW/RAND); +`volatile?: boolean` flag exists with defined semantics (always dirty per pass) for user registries. + +**D9. Value overlay** in `hooks/features/rows/useGridParamsOverridableMethods.ts`: **aggregation → +formula → community** in all three methods; the formula branch is a **membership check** +(`gridCellFormulaResultSelector(...) != null`), returning `result.type === 'error' ? result.code : +result.value`. `getRowFormattedValue` returns the error code **bypassing `colDef.valueFormatter`** +for error results; value results flow through the formatter normally. + +**D10. Re-render + filtering patches in `DataGridPremium.tsx`.** Extend the premium +`useCellAggregationResult` config hook to also subscribe to `gridCellFormulaResultSelector` (this is +what re-renders formula cells after each pass). Patch `useFilterValueGetter` to consult the formula +lookup first (quick filter inherits). _Amended during I2:_ no community-package change is needed — +`getCellParamsForRow` falls back to `getRowValue`/`getRowFormattedValue` (the overlay) whenever the +forced value is `undefined`, so the premium hook only adds a subscription and keeps returning the +aggregation result unchanged. + +**D11. Invalidation.** Cache keeps `lastRowIdToModelLookup` (row-object **references**) + +`trackedValues` (last resolved value per non-formula dependency cell) + `dependentsByRowId` +(reverse index added during I2: row additions/removals dirty every formula referencing any cell of +that row — this is what lets a `#REF!` to a missing row recover when the row appears). On +`rowsSet`: reference-diff +new lookup vs snapshot → changed/added/removed ids; per changed row, rescan formula-enabled fields +for source changes and compare tracked dependency cells; dirty + transitive closure via +`dependents`/`rangeDependentsByField`; Kahn recompute; update snapshots; **one `setState`** +(copy-on-write) + publish `formulaEvaluationEnd: { changedCells }`. The same pipeline covers editing +commits, `updateRows`, paste, undo/redo. Other triggers: `columnsChange` — guarded by **two** +equality checks (the `allowFormulas` field set, and a `field → valueGetter` signature of the whole +columns lookup, because `#REF!` for unknown fields and raw reads through `valueGetter` make results +depend on every column definition); `props.formulaFunctions` change (compared **per definition**, +not by record identity — inline props change identity every parent render); `reevaluateFormulas()` +(full rebuild). A pass that only removes rows still reports their formula cells as changed — +otherwise the lookup entries would survive and mask a later row reusing the same id. Initial +evaluation in +`formulaStateInitializer` registered **after** `rowsStateInitializer` (`caches.rows` is populated +synchronously at init — no first-paint flash of `=` strings). All passes synchronous in handlers; +dirty flushes once per loop. + +**D12. Editing.** `hydrateColumns` pipe processor wraps `allowFormulas` columns. The wrapped +property set is **deliberately disjoint from aggregation's** (`renderCell`/`renderHeader`): two +features stacking wrappers on the same property cannot unwrap cleanly (the identity check skips a +wrapper that has another one on top, accumulating layers on every `hydrateColumns` pass). The +error-tooltip `renderCell` wrap drafted for I2 was therefore dropped — error codes display through +`getRowFormattedValue`; tooltip/a11y polish lands in I5 through a non-`renderCell` mechanism. +Typing `=` as the first character on a **plain** cell opens the formula text editor (recorded from +the `cellEditStart` keyboard event) — without this there is no way to enter a formula in a number +column, whose default editor rejects the character. `lastCellEditStart` is consumed on editor +mount and cleared on `cellEditStop`, so it cannot go stale and corrupt a later programmatic edit. + +- `renderEditCell` → `GridFormulaEditCell`: seeds the edit state with the **source** via + `setEditCellValue({ id, field, value: source, unstable_skipValueParser: true })` unless the edit + started with a delete/printable/paste key. Falls back to the column type's default edit component + for non-formula cells; renders a text input whenever the value is a formula. +- `valueParser` → strings starting with `=` bypass the original parser. +- `valueSetter` → formula sources write `{ ...row, [field]: source }` directly; **equality guard**: + if the incoming value equals the current evaluated result and stored `row[field]` is a formula → + return the row unchanged (data-loss protection on paths that bypass the custom editor). +- `preProcessEditCellProps` → **only wrapped when the column defines its own processor** (amended + during I2: adding a processor unconditionally puts an async `isProcessingProps` gate on every + commit, which blocks Enter pressed right after a keystroke). The wrap forces **`error: false`** + for formula values (permissive commit). No validation metadata is attached — editor hints use + `validateCellFormula()` directly (I5). +- `pastedValueParser` → `=`-strings become formulas. + `getCellFormula(id, field)` reads the **raw** row value — never the overlay. + +**I4 amendment (A1 editor mode — corrects the `valueParser`/`valueSetter` bullets above for A1).** +`valueParser` runs on **every keystroke** (`GridEditInputCell.handleChange` calls it and shows its +result), so it is **not** a commit hook: with `formulaA1Notation` on it passes the A1 text through +**unchanged** (converting there surfaced canonical `=REF(...)` to the user mid-edit — a found+fixed +bug). The A1→canonical **freeze happens in `valueSetter`** — the real commit hook +(`getRowWithUpdatedValuesFromCellEditing` calls only the setter) — via `convertA1ToCanonicalCommit`, +which restores the stored canonical on an unchanged commit (seed match) and re-freezes an edited +formula otherwise. `GridFormulaEditCell` seeds the A1 rendering of the canonical source +(`toDisplayFormula`) on edit-begin. `pastedValueParser` freezes with the Excel fill offset anchored +to the **focused (top-left) cell** (`gridFocusCellSelector`), not the first editable formula cell. +A1 UI when the prop is on: a dimmed header-letter adornment that **leads** the title (rendered inside +the non-reversed title content via a new community `useColumnHeaderAdornment` configuration hook) and +a leftmost autogenerated row-number column (utility field in `UTILITY_FIELDS`, pinned left, excluded +from export/print/letters). + +**D13. Resolver value semantics.** Dependency on a non-formula cell: community `getRowValue` util +(valueGetter applied — formulas see what the user sees). Dependency on a formula cell: cached result +from the in-pass map. Formula column that itself defines `valueGetter`: ignored for formula cells +with a one-time dev warning. + +**D14. Aggregation chaining.** Aggregation reads via `getRowValue` → sees formula values +automatically. Hook-registration order makes formula recompute run before filter/sort in the same +`rowsSet` cascade; aggregation defers to `filteredRowsSet`. For non-rows-driven passes, call private +`applyAggregation()` once after `setState`. Pivoting + formulas unsupported in v1 (evaluation +skipped while pivot is active, dev warning). + +**D15. Public API & types.** `GridFormulaApi`: `setCellFormula`, `getCellFormula`, +`getCellFormulaResult`, `validateCellFormula`, `reevaluateFormulas`. Private: +`applyFormulaEvaluation`. Event `formulaEvaluationEnd: { changedCells: GridCellCoordinates[] }` in +`GridEventLookupPremium` (no model → no `registerControlState`). Types: `GridFormulaResult` union, +branded `GridFormulaCellKey` with the format `` `${id}${field}` `` — **NUL separator** +(amended during I1 from the originally drafted space separator: `FIELD("unit price")` legalizes +space-containing field names, which makes a space-separated format ambiguous, while NUL cannot +appear in field names; numeric ids are stringified — row ids must be unique under string coercion). +`GridFormulaCellRef = GridCellCoordinates`; `FormulaValidationResult` defined in the engine. No +`unstable_` prefixes. + +**D16. Props & colDef.** `GridColDefPremium.allowFormulas?: boolean` default **false** (opt-in — +implicit true would silently reinterpret `=`-prefixed data). `disableFormulas: boolean` default +false; `formulaFunctions: Record` default +`GRID_FORMULA_FUNCTIONS` (empty when `dataSource` is set, cf. aggregation). The prop has +**replacement semantics** like `aggregationFunctions`, and `createFormulaFunctionRegistry` mirrors +that: its argument is the complete function set (spread `FORMULA_BUILT_IN_FUNCTIONS` to extend). +The function definition is the **engine-pure** shape (`{ name, minArgs, maxArgs, lazy?, +acceptsRanges?, acceptsErrors?, volatile?, apply(args, ctx) }` — no `GridApiPremium` in `apply`). +Built-ins (23 + alias): SUM, AVERAGE, MIN, MAX, COUNT, COUNTA, ROUND, ABS, MOD, POWER, IF, AND, OR, +NOT, IFERROR, ISBLANK, CONCAT/CONCATENATE, LEN, UPPER, LOWER, TRIM, LEFT, RIGHT. + +**I4 amendment.** `formulaA1Notation: boolean` default **false** — opt-in A1 editor dialect (header +letters + row-number column + A1 editing), gated by `!disableFormulas && !dataSource`; off ⇒ zero +behavior/UI change. (I5 adds `disableFormulaAutocomplete: boolean` default false — see D20.) + +**D17. Performance.** Intern parsed ASTs by source string (one parse per distinct formula). +Reference-based row diffing. One `setState` per pass, copy-on-write lookup. No lazy evaluation +(sort/filter/aggregation pull eagerly). Benchmark and document limits at 100k formula cells. +_Measured during I2_ (evaluation layer, M-series laptop, jsdom vitest): 100k rows × 1 formula +column (`=price * quantity`) — full pass ≈ 390 ms, single-cell edit diff pass ≈ 10 ms (dominated +by the O(rows) reference compare). The jsdom harness itself cannot render 100k rows (OOM with or +without formulas), so the numbers are for `computeFullFormulaPass`/`computeRowsDiffFormulaPass` +with a stubbed `apiRef`. +_Measured during I3_ (same harness, committed as +`createFormulaEvaluation.test.ts` with catastrophic-regression bounds): one +`=SUM(COLUMN_VALUES("price"))` over 100k rows — full pass ≈ 71 ms, single-cell edit diff pass +≈ 45 ms. The diff pass recomputes only the dirty subgraph (the sum cell), but the sum's +re-evaluation necessarily re-reads the 100k-cell column; "incremental" means +dirty-subgraph-only, not incremental aggregation. + +**D18. Ecosystem semantics (v1).** Clipboard copy: evaluated value. Paste: `=`-strings into +`allowFormulas` columns become formulas; stable refs paste unadjusted. CSV export: evaluated values. +Excel export: evaluated values by default; with `escapeFormulas: false`, live formulas export as +real Excel formulas (I8/D22). `escapeFormulas: true` default retained. Undo/redo: free (raw row +replay). Row grouping: +keys from evaluated values. Tree data: works on data rows. `dataSource`: warn + disable. Errors +render as code strings; error results sort/filter as code strings. Only error codes are +user-visible strings (locale-neutral) — no `GridLocaleText` additions in v1. Formula bar: out of +scope; `getCellFormula` + `cellFocusIn` make it userland-buildable. _Amended during I3 (resolves the two I2 deferrals):_ **row grouping** groups by evaluated values: +`getCellGroupingCriteria` consults the formula lookup first (null-guarded — the initial tree build +runs before the formula state initializes); value results feed `groupingValueGetter` as its value +argument, error results group by their code string bypassing it (consistent with the +`valueFormatter` bypass). Because the tree is built inside the rows state computation — before the +formula pass in the same cascade — any pass whose changed cells intersect the sanitized grouping +model re-triggers the build via `publishEvent('activeStrategyProcessorChange', 'rowTreeCreation')`, +guarded by a `suppressRegroupTrigger` cache flag and gated on the active RowTree strategy being the +grouping one. The suppression is what bounds the cascade (one-shot, the D4 philosophy): the +rebuild's nested rowsSet/sortedRowsSet cascade usually produces zero formula changes, but +position-dependent formulas in grouped columns CAN change again when regrouping reorders the +leaves — that nested change does not re-trigger another rebuild, so in that (exotic) configuration +group keys may lag the displayed values by one rebind, exactly like D4's documented sort staleness. +A mount effect kicks one rebuild for the initial tree. **Row spanning** +compares evaluated values: the formula column wrapper additionally wraps `rowSpanValueGetter` +(formula result first, then the original getter, then the raw `getRowValue` fallback the spanning +util would have used). Spanning self-resets on `sortedRowsSet`/`filteredRowsSet`/`columnsChange` +(all ordered after the formula handlers); for passes outside those cascades (registry change, +enablement toggles, `reevaluateFormulas`, visibility-driven rebinds) the community +`useGridRowSpanning` hook now registers a private `resetRowSpanningState()` API method — the one +community-package change of I3 — which the formula hook calls after such passes. +Known editing trade-offs accepted in I2 (revisit in I5): converting an escaped literal +`'=x` to the live formula `=x` by deleting the apostrophe is silently undone by the escape +guard (workaround: clear the cell first or use `setCellFormula`); a formula whose result equals +the empty string cannot be cleared through the editor (the equality guard reads the commit as +unchanged). + +**D19. Registration order in `useDataGridPremiumComponent.tsx`.** Pipe processor +`useGridFormulaPreProcessors` after `useGridAggregationPreProcessors`; `formulaStateInitializer` +**after `rowsStateInitializer`**; `useGridFormula` after `useGridAggregation` and **before +`useGridFilter`/`useGridSorting`** so the formula `rowsSet` handler runs before filtering/sorting +read values in the same cascade. State initializers must be StrictMode-idempotent. + +**D20. Formula-editor autocomplete (I5; designed 2026-06-15).** A suggestion dropdown in +the formula text editor, **on by default** whenever the formula text input is shown (gated by +`!disableFormulas && !dataSource`), opt-out via `disableFormulaAutocomplete` (default `false`). The +opt-out exists because, while the popup is open, Enter/Tab/ArrowUp/Down are consumed by the +suggestion list instead of committing/navigating the cell (documented caveat). + +- **Engine seam (pure, D1).** New `engine/formulaCompletion.ts`: `getFormulaCompletionTokens()` + returns the static vocabulary — functions (name, arity, + new optional `signature`/`description`/ + `category`), operators (symbol/kind from `FORMULA_BINARY_PRECEDENCE`), special forms + (`FORMULA_RESERVED_NAMES`), constants (TRUE/FALSE); `getFormulaCompletionContext(text, caret)` + returns the partial token + `[replaceStart, replaceEnd]` span + a coarse `expectValue`/ + `expectOperator` context, built on `tokenizeFormula` (suppress inside an unterminated string + literal). `FormulaFunctionDefinition` gains **optional** `signature?/description?/category?` — + populated for the 23 built-ins; **custom functions from `formulaFunctions` appear too** (via + `registry.names()`), surfacing whatever optional metadata the user supplied. Engine stays grid-free. +- **Adapter token sourcing.** Static vocabulary + column tokens: **bare field names in both modes** + (same-row, D5/Keep-D5); A1 mode additionally offers **column letters** (A, B, C… via + `columnIndexToLetters`) as a lower-weight category for cross-row addresses. Utility/row-number/ + grouping columns excluded (`gridFormulaVisibleDataFieldsSelector`); no suggestions for escaped + `'=` literals. +- **Editor integration (chosen).** Headless `useAutocomplete` hook + a `Popper` anchored to the + existing `GridEditInputCell` input — NOT the freeSolo `` component: the editor must + **splice** a token at the caret into free text, not replace the whole value. On select, + `text.slice(0, start) + token + text.slice(end)` → `setEditCellValue(..., unstable_skipValueParser: +true)` → reposition caret via `setSelectionRange`; function/special-form tokens insert a trailing + `(` with the caret inside. +- **Keyboard conflict.** Popup-open is local React state. While open, the input's `onKeyDown` + `stopPropagation()`s ArrowUp/Down (highlight), Enter/Tab (accept), Escape (close) so the grid's + `cellKeyDown` (published from the cell's `onKeyDown`) never fires; closed → keys propagate. +- **Weighting.** prefix-match strength (exact-prefix > case-insensitive prefix > substring) × context + tier (value-position → functions + same-row columns high, operators suppressed; operator-position → + operators high). Frequency/recency deferred. +- **Signature help.** When the caret is inside a function's parens, surface its `signature`. + +**D21. Fill-handle reference adjustment (I7).** Dragging a formula cell via the cell-selection fill +handle (`cellSelectionFillHandle`), or the Ctrl+D/Ctrl+R fill shortcuts, copies the formula with its +relative references shifted by the source→target positional delta (Excel semantics): `=A1*B1` filled +down becomes `=A2*B2`. Pure engine primitive `offsetFormulaReferences(ast, rowDelta, columnDelta, +context)` (`engine/formulaOffset.ts`) mirrors `buildColumnSelector`/`buildRowSelector`: stable +selectors (`COLUMN("f")`/`ROW(id)`, from relative refs) re-anchor to the field/row at +`position + delta`; positional selectors (`COLUMN_POSITION`/`ROW_POSITION`, from `$`-absolute) never +shift; same-row `fieldRef` and `COLUMN_VALUES` shift only on horizontal fill; overshoot past the last +row/column → positional selector → `#REF!`; underflow past position 1 keeps the original reference +(the 1-based store has no representable position < 1 — the parser rejects `ROW_POSITION(0)`). Adapter +glue `getFilledFormulaSource(apiRef, source, target)` (`gridFormulaFill.ts`) gates on eligibility: +the **target** column must be `allowFormulas` (else the caller copies the evaluated value — a `=…` +string must never land in a plain column), and the **source** must be a live formula +(`getCellFormulaResult(...) !== null`, which also rejects a literal `=text` in a non-formula column; +`getCellFormula` alone is naive). Deltas use the live `gridFormulaA1PositionContextSelector` +(sorted + filtered visible order), so offsets match A1 display, paste adjustment and `ROW_POSITION`. +The adjusted source is fully canonical, so feeding it back through the column's `pastedValueParser` +(`convertA1ToCanonicalPaste`) is a no-op — canonical formulas carry no A1 tokens — so there is no +double-adjustment whether `formulaA1Notation` is on or off. Always-on (no opt-out prop) and built-in +(no public fill callback) per the v1 product decision; wired at every fill site in +`useGridCellSelection.ts` (drag `applyFill` plus the six Ctrl+D/Ctrl+R sites), reusing the existing +`CellValueUpdater` write path so column editability, `valueSetter`, `processRowUpdate` and undo/redo +are unchanged. + +**D22. Excel formula export (I8).** When `escapeFormulas: false`, live formula cells export as real +Excel formulas (`cell.value = { formula, result }`) instead of evaluated values; the default +(`escapeFormulas: true`) keeps the prior value-only export and the CSV-injection escaping. Reusing +`escapeFormulas` rather than adding a prop is consistent — that flag already governs whether +`=`-content may be live in the export. Pure engine converter `serializeFormulaAstToExcel(ast, +context)` (`engine/formulaExcel.ts`) walks the canonical AST to an Excel A1 string, mirroring the +serializer's minimal parenthesization. It cannot reuse the A1 display serializer, which renders +positional refs from their literal canonical index and emits the grid's inverted-`$` dialect. +References resolve in two stages: a positional (`$`-absolute) ref → identity via the live +`gridFormulaA1PositionContextSelector` (as `bindFormulaDependencies` resolves it), then identity → +**export** coordinate (export column order → `columnIndexToLetters`; export row index + header-row +offset). Mirrors the grid's relative/absolute distinction: stable → relative (`B2`), positional → +absolute (`$B$2`) — identical computed value, `$` only governs Excel copy/fill. `COLUMN_VALUES` → a +bounded data range (`B2:B`, no header); a reference to a cell outside the export bakes +Excel's `#REF!` with the cached result `{ error: '#REF!' }`; engine-only error codes map to the +nearest Excel sentinel (`#CYCLE!`→`#REF!`, `#ERROR!`→`#VALUE!`). Adapter `gridFormulaExcelExport.ts` +builds a per-export layout (`createFormulaExcelExportLayout`, returns `null` when no exported column +is `allowFormulas` → zero overhead) and per-cell `getCellExcelFormula` (gated on +`getCellFormulaResult(...) !== null`). Wired in `serializeRowUnsafe` (additive — the existing value +path is the fallback) and applied in `addSerializedRowToWorksheet`, the single cell-writer shared by +the sync and web-worker paths, so the descriptor is computed on the main thread (where `apiRef` +lives) and travels in `SerializedRow.formulas` to the worker. The worker forces `includeHeaders: +true` and reads raw `includeColumnGroupsHeaders`, so its layout matches the sheet it writes. Parsing +reuses the grid's interning parser (`apiRef.current.caches.formula.parser`) so export is all cache +hits; date-valued results get the same local→UTC reconstruction as the plain date path (the sheet is +timezone-naive). _Limitations:_ header rows injected by `exceljsPreProcess` shift the baked A1 row +numbers; and `COLUMN_VALUES`/`RANGE` emit a single contiguous A1 range over all exported rows, but +the cached aggregate covers data rows only (the position context excludes group/pinned rows) — when +the export interleaves group/pinned rows, a contiguous range cannot express the data-only set, so a +manual Excel recalc may diverge (the cached result stays correct), consistent with the +non-Excel-function recalc caveat. + +**Invariant (all iterations): formula source lives only in row data; every cache and state slice is +derived.** This is what keeps undo/redo, `processRowUpdate`, and controlled-rows scenarios working +for free. Never introduce non-row-data formula source state. diff --git a/packages/x-data-grid-premium/package.json b/packages/x-data-grid-premium/package.json index b36263ed2f23c..1a35d44239b87 100644 --- a/packages/x-data-grid-premium/package.json +++ b/packages/x-data-grid-premium/package.json @@ -30,7 +30,7 @@ ], "scripts": { "typescript": "tsc -p tsconfig.json", - "build": "code-infra build --flat", + "build": "code-infra build --flat --ignore 'src/**/testUtils.ts'", "prebuild": "rimraf build tsconfig.build.tsbuildinfo" }, "repository": { diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 2a713b5189ad6..694ed8daf79cb 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -6,6 +6,7 @@ import { GridRoot, GridContextProvider, type GridValidRowModel, + gridRowIdSelector, useGridSelector, } from '@mui/x-data-grid-pro'; import { @@ -30,6 +31,8 @@ import { Sidebar } from '../components/sidebar'; import { useGridAriaAttributesPremium } from '../hooks/utils/useGridAriaAttributes'; import { useGridRowAriaAttributesPremium } from '../hooks/features/rows/useGridRowAriaAttributes'; import { gridCellAggregationResultSelector } from '../hooks/features/aggregation/gridAggregationSelectors'; +import { gridCellFormulaResultSelector } from '../hooks/features/formula/gridFormulaSelectors'; +import { useGridFormulaColumnHeaderAdornment } from '../hooks/features/formula/useGridFormulaColumnHeaderAdornment'; import { useGridApiContext } from '../hooks/utils/useGridApiContext'; import type { GridApiPremium, GridPrivateApiPremium } from '../models/gridApiPremium'; import { useGridRowsOverridableMethods } from '../hooks/features/rows/useGridRowsOverridableMethods'; @@ -46,9 +49,21 @@ const configuration: GridConfiguration { const apiRef = useGridApiContext(); + // Subscribing to the formula result is what re-renders formula cells + // after an evaluation pass — the value itself flows through the params + // overlay in `useGridParamsOverridableMethods`. + useGridSelector(apiRef, gridCellFormulaResultSelector, { id, field }); return useGridSelector(apiRef, gridCellAggregationResultSelector, { id, field }); }, useFilterValueGetter: (apiRef, props) => (row, column) => { + const formulaResult = gridCellFormulaResultSelector(apiRef, { + id: gridRowIdSelector(apiRef, row), + field: column.field, + }); + if (formulaResult != null) { + return formulaResult.type === 'error' ? formulaResult.code : formulaResult.value; + } + if (props.aggregationRowsScope === 'all') { return apiRef.current.getRowValue(row, column); } @@ -58,6 +73,7 @@ const configuration: GridConfiguration { () => ({ ...DATA_GRID_PREMIUM_PROPS_DEFAULT_VALUES, ...(themedProps.dataSource - ? { aggregationFunctions: {} } + ? { aggregationFunctions: {}, formulaFunctions: {} } : { getPivotDerivedColumns: defaultGetPivotDerivedColumns }), ...themedProps, + ...getFormulaA1PinningOverride(themedProps), localeText, slots, ...getDataGridPremiumForcedProps(themedProps), diff --git a/packages/x-data-grid-premium/src/components/GridFormulaAutocomplete.tsx b/packages/x-data-grid-premium/src/components/GridFormulaAutocomplete.tsx new file mode 100644 index 0000000000000..6941d63f17dae --- /dev/null +++ b/packages/x-data-grid-premium/src/components/GridFormulaAutocomplete.tsx @@ -0,0 +1,410 @@ +'use client'; +import * as React from 'react'; +import useAutocomplete from '@mui/material/useAutocomplete'; +import { styled } from '@mui/material/styles'; +import setRef from '@mui/utils/setRef'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import { gridClasses } from '@mui/x-data-grid'; +import type { GridRenderEditCellParams, GridSlotProps } from '@mui/x-data-grid-pro'; +import { NotRendered, vars } from '@mui/x-data-grid/internals'; +import { useGridPrivateApiContext } from '../hooks/utils/useGridPrivateApiContext'; +import { useGridRootProps } from '../hooks/utils/useGridRootProps'; +import type { FormulaCompletionToken } from '../hooks/features/formula/engine'; +import { + useGridFormulaAutocomplete, + type GridFormulaSuggestionState, +} from '../hooks/features/formula/gridFormulaAutocomplete'; + +const GridFormulaAutocompleteInput = styled(NotRendered, { + name: 'MuiDataGrid', + slot: 'EditInputCell', +})({ + font: vars.typography.font.body, + padding: '1px 0', + '& input': { + padding: '0 16px', + height: '100%', + }, +}); + +const GridFormulaAutocompletePopper = styled(NotRendered, { + name: 'MuiDataGrid', + slot: 'FormulaAutocompletePopper', +})({ + zIndex: vars.zIndex.menu, +}); + +const GridFormulaAutocompletePanel = styled('div')(({ theme }) => ({ + minWidth: 220, + maxWidth: 360, + background: (theme.vars || theme).palette.background.paper, + border: `1px solid ${(theme.vars || theme).palette.divider}`, + borderRadius: (theme.vars || theme).shape.borderRadius, + boxShadow: (theme.vars || theme).shadows[4], + boxSizing: 'border-box', + overflow: 'hidden', +})); + +const GridFormulaAutocompleteSignature = styled('div')(({ theme }) => ({ + ...theme.typography.caption, + padding: '6px 10px', + borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, + color: (theme.vars || theme).palette.text.secondary, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +})); + +const GridFormulaAutocompleteList = styled('ul')({ + listStyle: 'none', + margin: 0, + padding: 4, + maxHeight: 240, + overflowY: 'auto', +}); + +const GridFormulaAutocompleteOption = styled('li')(({ theme }) => ({ + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: 8, + padding: '4px 8px', + borderRadius: (theme.vars || theme).shape.borderRadius, + cursor: 'pointer', + ...theme.typography.body2, + '&[data-focused="true"]': { + background: (theme.vars || theme).palette.action.hover, + }, +})); + +const GridFormulaAutocompleteOptionLabel = styled('span')({ + fontVariantLigatures: 'none', + whiteSpace: 'nowrap', +}); + +const GridFormulaAutocompleteOptionDetail = styled('span')(({ theme }) => ({ + ...theme.typography.caption, + color: (theme.vars || theme).palette.text.secondary, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +})); + +export type GridFormulaAutocompleteProps = GridRenderEditCellParams; + +/** + * Formula text editor with a suggestion dropdown (D20). It renders the grid's + * base input (so it stays visually identical to `GridEditInputCell`) and layers + * a headless `useAutocomplete` listbox in a `basePopper`. Suggestions are + * spliced at the caret into the free text — the whole value is never replaced — + * and the popup intercepts Arrow/Enter/Tab/Escape so the grid does not navigate + * or commit while it is open. + */ +function GridFormulaAutocomplete(props: GridFormulaAutocompleteProps) { + const { id, value, field, hasFocus } = props; + const apiRef = useGridPrivateApiContext(); + const rootProps = useGridRootProps(); + const a1NotationEnabled = + !!rootProps.formulaA1Notation && !rootProps.disableFormulas && !rootProps.dataSource; + const getSuggestions = useGridFormulaAutocomplete(apiRef, a1NotationEnabled); + + const inputRef = React.useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); + const [valueState, setValueState] = React.useState(value); + const [open, setOpen] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(0); + const [suggestion, setSuggestion] = React.useState(null); + const pendingCaretRef = React.useRef(null); + + const options = React.useMemo(() => suggestion?.options ?? [], [suggestion]); + const signatureHelp = suggestion?.signatureHelp ?? null; + // The list only opens for a non-empty partial token (the user is actively + // typing an identifier). An empty prefix — right after `=`, `(`, `,` or an + // operator — shows at most signature help and never traps Enter/Tab, so a + // completed formula commits on Enter instead of accepting a stray suggestion. + const hasList = (suggestion?.token ?? '') !== '' && options.length > 0; + const showPopup = hasFocus && open && (hasList || signatureHelp !== null); + + const popupId = `${id}-${field}-formula-autocomplete`; + + /** + * Recomputes the suggestions from the input's current value and caret, and + * opens the popup when there is something to show. Only called from user + * actions (typing, caret moves, accepting) — never on mount/focus — so the + * dropdown does not pop open just from entering edit mode. + */ + const refresh = React.useCallback( + (caretOverride?: number) => { + const input = inputRef.current; + if (!input) { + return; + } + const caret = caretOverride ?? input.selectionStart ?? input.value.length; + const next = getSuggestions(input.value, caret); + setSuggestion(next); + setActiveIndex(0); + const nextHasList = next !== null && next.token !== '' && next.options.length > 0; + setOpen(next !== null && (nextHasList || next.signatureHelp !== null)); + }, + [getSuggestions], + ); + + const meta = apiRef.current.unstable_getEditCellMeta(id, field); + React.useEffect(() => { + if (meta?.changeReason !== 'debouncedSetEditCellValue') { + setValueState(value); + } + }, [meta, value]); + + useEnhancedEffect(() => { + if (hasFocus && inputRef.current) { + inputRef.current.focus(); + } + }, [hasFocus]); + + // Apply the caret position queued by an accepted suggestion after the new + // value has rendered, then recompute (e.g. show the arguments after `SUM(`). + useEnhancedEffect(() => { + if (pendingCaretRef.current === null || !inputRef.current) { + return; + } + const caret = pendingCaretRef.current; + pendingCaretRef.current = null; + inputRef.current.setSelectionRange(caret, caret); + refresh(); + }, [valueState, refresh]); + + // Keep the highlighted option scrolled into view. + useEnhancedEffect(() => { + if (!showPopup || !hasList) { + return; + } + const node = document.getElementById(`${popupId}-option-${activeIndex}`); + node?.scrollIntoView?.({ block: 'nearest' }); + }, [showPopup, hasList, activeIndex, popupId]); + + // The headless `useAutocomplete` needs its own input ref bound (it validates + // it in a dev effect). We forward to it from one stable callback — composing + // the hook's (possibly per-render) ref directly would detach/reattach every + // render and thrash `anchorEl`. + const autocompleteInputRef = React.useRef>(null); + const handleInputRef = React.useCallback((node: HTMLInputElement | null) => { + inputRef.current = node; + setAnchorEl(node); + setRef(autocompleteInputRef.current, node); + }, []); + + // Writes are NOT debounced. Debouncing the keystroke write while accepting a + // suggestion with an immediate write would strand the keystroke's timer, which + // fires later and overwrites the accepted value (data loss). Per-keystroke + // immediate writes are negligible for a single editing cell. + const commitEditValue = React.useCallback( + (nextValue: string, event: React.SyntheticEvent) => { + setValueState(nextValue); + apiRef.current.setEditCellValue( + { id, field, value: nextValue, unstable_skipValueParser: true }, + event, + ); + }, + [apiRef, field, id], + ); + + const handleChange = React.useCallback( + (event: React.ChangeEvent) => { + const newValue = event.target.value; + const column = apiRef.current.getColumn(field); + const parsedValue = column.valueParser + ? column.valueParser(newValue, apiRef.current.getRow(id), column, apiRef) + : newValue; + commitEditValue(parsedValue, event); + refresh(event.target.selectionStart ?? undefined); + }, + [apiRef, commitEditValue, field, id, refresh], + ); + + const acceptOption = React.useCallback( + (option: FormulaCompletionToken, event: React.SyntheticEvent) => { + const current = suggestion; + const input = inputRef.current; + if (!current || !input) { + return; + } + const sourceValue = input.value; + const insertText = option.insertText + (option.callable ? '(' : ''); + const nextValue = + sourceValue.slice(0, current.replaceStart) + + insertText + + sourceValue.slice(current.replaceEnd); + pendingCaretRef.current = current.replaceStart + insertText.length; + commitEditValue(nextValue, event); + }, + [commitEditValue, suggestion], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (open && hasList) { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + event.stopPropagation(); + setActiveIndex((index) => (index + 1) % options.length); + return; + case 'ArrowUp': + event.preventDefault(); + event.stopPropagation(); + setActiveIndex((index) => (index - 1 + options.length) % options.length); + return; + case 'Enter': + case 'Tab': { + const option = options[Math.min(activeIndex, options.length - 1)]; + const input = inputRef.current; + const current = suggestion; + if (option && input && current) { + const insertText = option.insertText + (option.callable ? '(' : ''); + const nextValue = + input.value.slice(0, current.replaceStart) + + insertText + + input.value.slice(current.replaceEnd); + // Accepting a token that is already fully typed is a no-op — let + // the key reach the grid so a completed formula commits / Tab + // navigates instead of re-accepting the same suggestion. + if (nextValue !== input.value) { + event.preventDefault(); + event.stopPropagation(); + acceptOption(option, event); + } + } + return; + } + case 'Escape': + // First Escape closes the list; a second (list closed) propagates + // to the grid and cancels the edit. + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + return; + default: + break; + } + } + }, + [acceptOption, activeIndex, hasList, open, options, suggestion], + ); + + const handleKeyUp = React.useCallback( + (event: React.KeyboardEvent) => { + // A caret move (without a value change) can change the suggestion context. + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'Home' || + event.key === 'End' + ) { + refresh(); + } + }, + [refresh], + ); + + const handleBlur = React.useCallback(() => setOpen(false), []); + + const autocomplete = useAutocomplete({ + id: popupId, + options, + open: showPopup && hasList, + inputValue: suggestion?.token ?? '', + filterOptions: (currentOptions) => currentOptions, + freeSolo: true, + disableClearable: true, + clearOnBlur: false, + getOptionLabel: (option) => (typeof option === 'string' ? option : option.label), + isOptionEqualToValue: () => false, + }); + autocompleteInputRef.current = autocomplete.getInputProps().ref; + + const optionProps = hasList + ? options.map((option, index) => autocomplete.getOptionProps({ option, index })) + : []; + const activeDescendant = + showPopup && hasList + ? `${popupId}-option-${Math.min(activeIndex, options.length - 1)}` + : undefined; + + return ( + + + + event.preventDefault()} + > + {signatureHelp !== null && ( + + {signatureHelp.signature} + + )} + {hasList && ( + + {options.map((option, index) => { + const { key, ...liProps } = optionProps[index]; + return ( + setActiveIndex(index)} + onClick={(event) => acceptOption(option, event)} + > + + {option.label} + + {(option.detail || option.signature || option.category) && ( + + {option.detail || option.signature || option.category} + + )} + + ); + })} + + )} + + + + ); +} + +export { GridFormulaAutocomplete }; diff --git a/packages/x-data-grid-premium/src/components/GridFormulaColumnHeaderLetter.tsx b/packages/x-data-grid-premium/src/components/GridFormulaColumnHeaderLetter.tsx new file mode 100644 index 0000000000000..bd9720c12a371 --- /dev/null +++ b/packages/x-data-grid-premium/src/components/GridFormulaColumnHeaderLetter.tsx @@ -0,0 +1,48 @@ +'use client'; +import { styled } from '@mui/material/styles'; +import { useGridSelector } from '@mui/x-data-grid-pro'; +import { gridClasses } from '@mui/x-data-grid'; +import { useGridApiContext } from '../hooks/utils/useGridApiContext'; +import { + getFormulaColumnLetter, + gridFormulaA1PositionContextSelector, +} from '../hooks/features/formula/gridFormulaPositionContext'; + +const GridFormulaColumnHeaderLetterRoot = styled('span', { + name: 'MuiDataGrid', + slot: 'FormulaColumnHeaderLetter', +})(({ theme }) => ({ + flexShrink: 0, + marginInlineEnd: theme.spacing(0.75), + color: theme.palette.text.disabled, + fontSize: theme.typography.pxToRem(11), + fontWeight: theme.typography.fontWeightMedium, + fontVariantNumeric: 'tabular-nums', + letterSpacing: 0.5, + lineHeight: 1, + userSelect: 'none', +})); + +/** + * Column-letter adornment (`A`, `B`, …) rendered in the header title container + * next to the title, in a dimmed colour. Subscribes to the shared A1 position + * context so the letters stay in sync on reorder/visibility changes. Hidden from + * assistive tech (the letter is editor sugar, not column meaning) and blank for + * columns with no position (utility/grouping/hidden). + */ +export function GridFormulaColumnHeaderLetter({ field }: { field: string }) { + const apiRef = useGridApiContext(); + const positionContext = useGridSelector(apiRef, gridFormulaA1PositionContextSelector); + const letter = getFormulaColumnLetter(positionContext, field); + if (letter === '') { + return null; + } + return ( + + {letter} + + ); +} diff --git a/packages/x-data-grid-premium/src/components/GridFormulaEditCell.tsx b/packages/x-data-grid-premium/src/components/GridFormulaEditCell.tsx new file mode 100644 index 0000000000000..746c6623cc2a9 --- /dev/null +++ b/packages/x-data-grid-premium/src/components/GridFormulaEditCell.tsx @@ -0,0 +1,135 @@ +'use client'; +import * as React from 'react'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import { GridEditInputCell } from '@mui/x-data-grid-pro'; +import type { GridColDef, GridRenderEditCellParams } from '@mui/x-data-grid-pro'; +import { useGridPrivateApiContext } from '../hooks/utils/useGridPrivateApiContext'; +import { useGridRootProps } from '../hooks/utils/useGridRootProps'; +import { isEscapedFormulaSource, isFormulaSource } from '../hooks/features/formula/engine'; +import { gridCellFormulaResultSelector } from '../hooks/features/formula/gridFormulaSelectors'; +import { convertCanonicalToA1Display } from '../hooks/features/formula/gridFormulaA1Transforms'; +import { GridFormulaAutocomplete } from './GridFormulaAutocomplete'; + +export interface GridFormulaEditCellProps extends GridRenderEditCellParams { + /** + * The edit component the column would render without formula support. + */ + originalRenderEditCell?: GridColDef['renderEditCell']; +} + +function isFormulaEditValue(value: unknown): boolean { + return isFormulaSource(value) || isEscapedFormulaSource(value); +} + +function areSeededValuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); +} + +/** + * Edit component for `allowFormulas` columns. When the cell holds a formula, + * it seeds the edit state with the stored source (the view value is the + * evaluated result — committing it back would destroy the formula) and renders + * a text input even on non-string columns. + */ +function GridFormulaEditCell(props: GridFormulaEditCellProps) { + const { originalRenderEditCell, ...params } = props; + const { id, field, value, colDef } = params; + const apiRef = useGridPrivateApiContext(); + const rootProps = useGridRootProps(); + const a1NotationEnabled = + rootProps.formulaA1Notation && !rootProps.disableFormulas && !rootProps.dataSource; + + const rawValue = apiRef.current.getRow(id)?.[field]; + const rawIsFormula = isFormulaEditValue(rawValue); + // Decided once so the input does not swap (and drop focus) mid-edit. + // Typing `=` on a plain cell also gets the formula text input — the default + // editor of a number column could not even hold the character. + const [showFormulaInput] = React.useState(() => { + if (rawIsFormula || isFormulaEditValue(value)) { + return true; + } + const startInfo = apiRef.current.caches.formula.lastCellEditStart; + return ( + startInfo !== null && + startInfo.id === id && + startInfo.field === field && + startInfo.startedWithEquals + ); + }); + + const seededRef = React.useRef(false); + useEnhancedEffect(() => { + if (seededRef.current) { + return; + } + seededRef.current = true; + + const cache = apiRef.current.caches.formula; + const startInfo = cache.lastCellEditStart; + const isOwnStart = startInfo !== null && startInfo.id === id && startInfo.field === field; + if (isOwnStart) { + // Always consume the record so it cannot go stale and affect a later + // programmatic edit of the same cell. + cache.lastCellEditStart = null; + } + if (!rawIsFormula) { + return; + } + if (isOwnStart) { + if (startInfo.replaceValue) { + // The edit started by typing/deleting/pasting — the value was + // intentionally replaced. + return; + } + } else { + // Programmatic edit start (no `cellEditStart` event): only re-seed when + // the edit state still holds the seeded evaluated result. + const result = gridCellFormulaResultSelector(apiRef, { id, field }); + let evaluated: unknown; + if (result !== null) { + evaluated = result.type === 'error' ? result.code : result.value; + } + if (!areSeededValuesEqual(value, evaluated)) { + return; + } + } + // A1 mode: seed the editor with the A1 rendering of the canonical source and + // record it so an unchanged commit restores the canonical (no re-freeze). + // Escaped literals (`'=…`) are never transformed. + let seededValue = rawValue; + if (a1NotationEnabled && isFormulaSource(rawValue)) { + const display = convertCanonicalToA1Display(rawValue, apiRef); + cache.lastA1Seed = { id, field, display, canonical: rawValue }; + seededValue = display; + } + apiRef.current.setEditCellValue({ + id, + field, + value: seededValue, + unstable_skipValueParser: true, + }); + }); + + if (!showFormulaInput && originalRenderEditCell) { + return originalRenderEditCell(params); + } + + // The suggestion dropdown is on by default; the opt-out (and `dataSource`) + // falls back to the plain text input. + const autocompleteEnabled = + !rootProps.disableFormulaAutocomplete && !rootProps.disableFormulas && !rootProps.dataSource; + if (autocompleteEnabled) { + return ; + } + + return ; +} + +export { GridFormulaEditCell }; + +export const renderFormulaEditCell = (params: GridFormulaEditCellProps) => ( + +); diff --git a/packages/x-data-grid-premium/src/components/GridFormulaRowNumberCell.tsx b/packages/x-data-grid-premium/src/components/GridFormulaRowNumberCell.tsx new file mode 100644 index 0000000000000..a7f39460d7204 --- /dev/null +++ b/packages/x-data-grid-premium/src/components/GridFormulaRowNumberCell.tsx @@ -0,0 +1,44 @@ +'use client'; +import { styled } from '@mui/material/styles'; +import { useGridSelector } from '@mui/x-data-grid-pro'; +import type { GridRenderCellParams } from '@mui/x-data-grid-pro'; +import { gridClasses } from '@mui/x-data-grid'; +import { useGridApiContext } from '../hooks/utils/useGridApiContext'; +import { gridFormulaA1PositionContextSelector } from '../hooks/features/formula/gridFormulaPositionContext'; + +const GridFormulaRowNumberRoot = styled('div', { + name: 'MuiDataGrid', + slot: 'FormulaRowNumberCell', +})(({ theme }) => ({ + width: '100%', + textAlign: 'center', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + userSelect: 'none', +})); + +/** + * Cell of the autogenerated row-number column. Displays the row's 1-based view + * position from the shared A1 position context — the exact number + * `ROW_POSITION(n)`/A1 references resolve to. Autogenerated rows (group headers, + * footers) have no position and render blank. + */ +function GridFormulaRowNumberCell(props: GridRenderCellParams) { + const apiRef = useGridApiContext(); + const positionContext = useGridSelector(apiRef, gridFormulaA1PositionContextSelector); + const position = positionContext.getPositionOfRowId(props.id); + if (position === undefined) { + return null; + } + return ( + + {position} + + ); +} + +export { GridFormulaRowNumberCell }; + +export const renderFormulaRowNumberCell = (params: GridRenderCellParams) => ( + +); diff --git a/packages/x-data-grid-premium/src/hooks/features/cellSelection/useGridCellSelection.ts b/packages/x-data-grid-premium/src/hooks/features/cellSelection/useGridCellSelection.ts index 81719b7b71933..7f57af40a314a 100644 --- a/packages/x-data-grid-premium/src/hooks/features/cellSelection/useGridCellSelection.ts +++ b/packages/x-data-grid-premium/src/hooks/features/cellSelection/useGridCellSelection.ts @@ -38,6 +38,7 @@ import type { GridCellSelectionApi } from './gridCellSelectionInterfaces'; import type { DataGridPremiumProcessedProps } from '../../../models/dataGridPremiumProps'; import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; import { CellValueUpdater } from '../clipboard/useGridClipboardImport'; +import { getFilledFormulaSource } from '../formula/gridFormulaFill'; export const cellSelectionStateInitializer: GridStateInitializer< Pick @@ -560,6 +561,22 @@ export const useGridCellSelection = ( [serializeCellForClipboard], ); + // Helper: the source cells for a field, in the same order as + // getSourceValuesForField — so sourceCells[i] is the origin of sourceValues[i] + // when adjusting a dragged formula's references for its target cell. + const getSourceCellsForField = React.useCallback( + (field: string): { id: GridRowId; field: string }[] => { + const sourceCells: { id: GridRowId; field: string }[] = []; + for (const cell of fillSource.current?.cells ?? []) { + if (cell.field === field) { + sourceCells.push({ id: cell.id, field: cell.field }); + } + } + return sourceCells; + }, + [], + ); + const getFillSourceData = React.useCallback((): string[][] => { const selectedCells = fillSource.current?.cells ?? []; if (selectedCells.length === 0) { @@ -659,8 +676,14 @@ export const useGridCellSelection = ( if (sourceValues.length === 0) { continue; } + const sourceCells = getSourceCellsForField(field); targetRowIds.forEach((rowId, i) => { - const pastedCellValue = sourceValues[i % sourceValues.length]; + const sourceCell = sourceCells[i % sourceCells.length]; + // A dragged formula is copied with its references adjusted for the + // target cell; otherwise (plain cell, or non-formula target column) + // the source's evaluated value is copied as before. + const filledFormula = getFilledFormulaSource(apiRef, sourceCell, { id: rowId, field }); + const pastedCellValue = filledFormula ?? sourceValues[i % sourceValues.length]; cellUpdater.updateCell({ rowId, field, pastedCellValue }); }); } @@ -676,8 +699,14 @@ export const useGridCellSelection = ( if (sourceValues.length === 0) { return; } + const sourceCells = getSourceCellsForField(sourceField); targetRowIds.forEach((rowId, rowIdx) => { - const pastedCellValue = sourceValues[rowIdx % sourceValues.length]; + const sourceCell = sourceCells[rowIdx % sourceCells.length]; + const filledFormula = getFilledFormulaSource(apiRef, sourceCell, { + id: rowId, + field: targetField, + }); + const pastedCellValue = filledFormula ?? sourceValues[rowIdx % sourceValues.length]; cellUpdater.updateCell({ rowId, field: targetField, pastedCellValue }); }); }); @@ -704,6 +733,7 @@ export const useGridCellSelection = ( props.getRowId, getFillSourceData, getSourceValuesForField, + getSourceCellsForField, ]); // Helper: clear fill preview classes from previously decorated elements @@ -1120,7 +1150,16 @@ export const useGridCellSelection = ( getRowId: props.getRowId, }); - cellUpdater.updateCell({ rowId: nextRowId, field: cell.field, pastedCellValue: sourceValue }); + cellUpdater.updateCell({ + rowId: nextRowId, + field: cell.field, + pastedCellValue: + getFilledFormulaSource( + apiRef, + { id: cell.id, field: cell.field }, + { id: nextRowId, field: cell.field }, + ) ?? sourceValue, + }); cellUpdater.applyUpdates(); // Move selection and focus to the filled cell @@ -1162,7 +1201,13 @@ export const useGridCellSelection = ( continue; } const sourceValue = serializeCellForClipboard(cells[0].id, field); - cellUpdater.updateCell({ rowId: nextRowId, field, pastedCellValue: sourceValue }); + cellUpdater.updateCell({ + rowId: nextRowId, + field, + pastedCellValue: + getFilledFormulaSource(apiRef, { id: cells[0].id, field }, { id: nextRowId, field }) ?? + sourceValue, + }); if (!newSelectionModel[nextRowId]) { newSelectionModel[nextRowId] = {}; } @@ -1227,7 +1272,12 @@ export const useGridCellSelection = ( cellUpdater.updateCell({ rowId: sortedCells[i].id, field, - pastedCellValue: sourceValue, + pastedCellValue: + getFilledFormulaSource( + apiRef, + { id: sourceCell.id, field: sourceCell.field }, + { id: sortedCells[i].id, field }, + ) ?? sourceValue, }); } } @@ -1290,7 +1340,16 @@ export const useGridCellSelection = ( getRowId: props.getRowId, }); - cellUpdater.updateCell({ rowId: cell.id, field: nextField, pastedCellValue: sourceValue }); + cellUpdater.updateCell({ + rowId: cell.id, + field: nextField, + pastedCellValue: + getFilledFormulaSource( + apiRef, + { id: cell.id, field: cell.field }, + { id: cell.id, field: nextField }, + ) ?? sourceValue, + }); cellUpdater.applyUpdates(); // Move selection and focus to the filled cell @@ -1334,7 +1393,16 @@ export const useGridCellSelection = ( const newSelectionModel: Record> = {}; for (const [rowId, cells] of cellsByRow) { const sourceValue = serializeCellForClipboard(cells[0].id, cells[0].field); - cellUpdater.updateCell({ rowId, field: nextField, pastedCellValue: sourceValue }); + cellUpdater.updateCell({ + rowId, + field: nextField, + pastedCellValue: + getFilledFormulaSource( + apiRef, + { id: cells[0].id, field: cells[0].field }, + { id: rowId, field: nextField }, + ) ?? sourceValue, + }); if (!newSelectionModel[rowId]) { newSelectionModel[rowId] = {}; } @@ -1388,7 +1456,12 @@ export const useGridCellSelection = ( cellUpdater.updateCell({ rowId, field: sortedCells[i].field, - pastedCellValue: sourceValue, + pastedCellValue: + getFilledFormulaSource( + apiRef, + { id: sourceCell.id, field: sourceCell.field }, + { id: rowId, field: sortedCells[i].field }, + ) ?? sourceValue, }); } } diff --git a/packages/x-data-grid-premium/src/hooks/features/export/serializer/excelSerializer.ts b/packages/x-data-grid-premium/src/hooks/features/export/serializer/excelSerializer.ts index 226d24edb71b3..d66129ce1b16a 100644 --- a/packages/x-data-grid-premium/src/hooks/features/export/serializer/excelSerializer.ts +++ b/packages/x-data-grid-premium/src/hooks/features/export/serializer/excelSerializer.ts @@ -28,6 +28,11 @@ import { type SerializedRow, type ValueOptionsData, } from './utils'; +import { + createFormulaExcelExportLayout, + getCellExcelFormula, + type FormulaExcelExportLayout, +} from '../../formula/gridFormulaExcelExport'; export type { ExcelExportInitEvent } from './utils'; @@ -74,10 +79,12 @@ export const serializeRowUnsafe = ( apiRef: RefObject, defaultValueOptionsFormulae: { [field: string]: { address: string } }, options: Pick, + formulaExport: FormulaExcelExportLayout | null = null, ): SerializedRow => { const serializedRow: SerializedRow['row'] = {}; const dataValidation: SerializedRow['dataValidation'] = {}; const mergedCells: SerializedRow['mergedCells'] = []; + const formulas: NonNullable = {}; const row = apiRef.current.getRow(id); const rowNode = apiRef.current.getRowNode(id); @@ -220,6 +227,16 @@ export const serializeRowUnsafe = ( if (typeof cellValue !== 'undefined') { serializedRow[column.field] = cellValue; } + + // A live formula cell overlays its plain value with a real Excel formula. + // The value above stays as the fallback (used when the cell is not a formula + // or its formula cannot be expressed against the export layout). + if (formulaExport) { + const cellFormula = getCellExcelFormula(apiRef, formulaExport, id, column.field); + if (cellFormula) { + formulas[column.field] = cellFormula; + } + } }); return { @@ -227,6 +244,7 @@ export const serializeRowUnsafe = ( dataValidation, outlineLevel, mergedCells, + ...(Object.keys(formulas).length > 0 ? { formulas } : {}), }; }; @@ -365,9 +383,26 @@ export async function buildExcel( ); createValueOptionsSheetIfNeeded(valueOptionsData, valueOptionsSheetName, workbook); + // Formulas are exported as real Excel formulas only when injection-escaping is + // off (`escapeFormulas: false`) — that flag already governs whether `=`-content + // may be live in the export. Layout maps identities to this sheet's coordinates. + const formulaExport = options.escapeFormulas + ? null + : createFormulaExcelExportLayout(apiRef, columns, rowIds, { + includeHeaders, + includeColumnGroupsHeaders, + }); + apiRef.current.resetColSpan(); rowIds.forEach((id) => { - const serializedRow = serializeRowUnsafe(id, columns, apiRef, valueOptionsData, options); + const serializedRow = serializeRowUnsafe( + id, + columns, + apiRef, + valueOptionsData, + options, + formulaExport, + ); addSerializedRowToWorksheet(serializedRow, worksheet); }); apiRef.current.resetColSpan(); diff --git a/packages/x-data-grid-premium/src/hooks/features/export/serializer/utils.ts b/packages/x-data-grid-premium/src/hooks/features/export/serializer/utils.ts index 87cfe3f185916..e315d59c44dd8 100644 --- a/packages/x-data-grid-premium/src/hooks/features/export/serializer/utils.ts +++ b/packages/x-data-grid-premium/src/hooks/features/export/serializer/utils.ts @@ -1,6 +1,7 @@ import type * as Excel from '@mui/x-internal-exceljs-fork'; import type { GridColumnGroupLookup } from '@mui/x-data-grid/internals'; import type { GridExcelExportOptions } from '../gridExcelExportInterface'; +import type { ExcelFormulaCell } from '../../formula/gridFormulaExcelExport'; export const getExcelJs = async () => { const excelJsModule = await import('@mui/x-internal-exceljs-fork'); @@ -12,6 +13,12 @@ export interface SerializedRow { dataValidation: Record; outlineLevel: number; mergedCells: { leftIndex: number; rightIndex: number }[]; + /** + * Cells to write as real Excel formulas (overlaying the plain value in `row`). + * Present only when at least one cell in the row is a live formula and formula + * export is enabled, so value-only exports keep their previous serialized shape. + */ + formulas?: Record; } export const addColumnGroupingHeaders = ( @@ -82,7 +89,7 @@ export function addSerializedRowToWorksheet( serializedRow: SerializedRow, worksheet: Excel.Worksheet, ) { - const { row, dataValidation, outlineLevel, mergedCells } = serializedRow; + const { row, dataValidation, outlineLevel, mergedCells, formulas } = serializedRow; const newRow = worksheet.addRow(row); @@ -92,6 +99,15 @@ export function addSerializedRowToWorksheet( }; }); + if (formulas) { + // Overwrite the plain value with a real Excel formula cell. The cached + // `result` inherits the column number format, so it still displays formatted + // until Excel recalculates. + Object.keys(formulas).forEach((field) => { + newRow.getCell(field).value = formulas[field] as Excel.CellValue; + }); + } + if (outlineLevel) { newRow.outlineLevel = outlineLevel; } diff --git a/packages/x-data-grid-premium/src/hooks/features/export/useGridExcelExport.tsx b/packages/x-data-grid-premium/src/hooks/features/export/useGridExcelExport.tsx index 7a6a3cbd499ed..5e6837ff1a10f 100644 --- a/packages/x-data-grid-premium/src/hooks/features/export/useGridExcelExport.tsx +++ b/packages/x-data-grid-premium/src/hooks/features/export/useGridExcelExport.tsx @@ -29,6 +29,7 @@ import { } from './serializer/excelSerializer'; import { GridExcelExportMenuItem } from '../../../components'; import type { SerializedRow } from './serializer/utils'; +import { createFormulaExcelExportLayout } from '../formula/gridFormulaExcelExport'; /** * @requires useGridColumns (state) @@ -143,13 +144,30 @@ export const useGridExcelExport = ( const serializedColumns = serializeColumns(exportedColumns, options.columnsStyles || {}); + // Mirror the worker's own header logic so the formula A1 row numbers line + // up with the sheet it builds: the worker always writes the column-header + // row (`includeHeaders` is not forwarded and defaults to `true` there) and + // writes group headers only when `includeColumnGroupsHeaders` is truthy. + const formulaExport = + (options.escapeFormulas ?? true) + ? null + : createFormulaExcelExportLayout(apiRef, exportedColumns, exportedRowIds, { + includeHeaders: true, + includeColumnGroupsHeaders: Boolean(options.includeColumnGroupsHeaders), + }); + apiRef.current.resetColSpan(); const serializedRows: SerializedRow[] = []; for (let i = 0; i < exportedRowIds.length; i += 1) { const id = exportedRowIds[i]; - const serializedRow = serializeRowUnsafe(id, exportedColumns, apiRef, valueOptionsData, { - escapeFormulas: options.escapeFormulas ?? true, - }); + const serializedRow = serializeRowUnsafe( + id, + exportedColumns, + apiRef, + valueOptionsData, + { escapeFormulas: options.escapeFormulas ?? true }, + formulaExport, + ); serializedRows.push(serializedRow); } apiRef.current.resetColSpan(); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.test.ts new file mode 100644 index 0000000000000..b002609763348 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.test.ts @@ -0,0 +1,92 @@ +import type { RefObject } from '@mui/x-internals/types'; +import type { GridColDef, GridRowId, GridValidRowModel } from '@mui/x-data-grid-pro'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { + computeFullFormulaPass, + computeRowsDiffFormulaPass, + type FormulaPassContext, +} from './createFormulaEvaluation'; +import { createFormulaInternalCache, GRID_FORMULA_FUNCTIONS } from './gridFormulaUtils'; + +/** + * Evaluation-layer harness: jsdom cannot render 100k rows, so the perf + * gates run the pass functions directly against a stubbed apiRef. + * `gridRowIdSelector` only reads `state.props.getRowId`, and raw values + * resolve from the row objects — nothing else of the grid is touched. + */ +function createStubApiRef(): RefObject { + return { + current: { state: { props: {} }, instanceId: { id: 0 } }, + } as unknown as RefObject; +} + +describe('createFormulaEvaluation - ranges at scale', () => { + const ROW_COUNT = 100_000; + const PRICE_SUM = (ROW_COUNT * (ROW_COUNT - 1)) / 2; + + function buildFixture() { + const rowsLookup: Record = {}; + const rowIds: GridRowId[] = []; + for (let i = 0; i < ROW_COUNT; i += 1) { + rowsLookup[i] = + i === 0 + ? { id: 0, price: 0, summary: '=SUM(COLUMN_VALUES("price"))' } + : { id: i, price: i }; + rowIds.push(i); + } + + const apiRef = createStubApiRef(); + const cache = createFormulaInternalCache(GRID_FORMULA_FUNCTIONS); + const createContext = ( + contextRows: Record, + previousLookup: FormulaPassContext['previousLookup'], + ): FormulaPassContext => ({ + apiRef, + cache, + rowsLookup: contextRows, + columnsLookup: { + price: { field: 'price' }, + summary: { field: 'summary', allowFormulas: true } as GridColDef, + }, + formulaFields: ['summary'], + previousLookup, + getPositionSnapshot: () => ({ rowIds, fields: ['price', 'summary'] }), + }); + + return { rowsLookup, createContext }; + } + + it('evaluates SUM(COLUMN_VALUES()) over 100k rows within the perf budget', () => { + const { rowsLookup, createContext } = buildFixture(); + + const start = performance.now(); + const result = computeFullFormulaPass(createContext(rowsLookup, {})); + const elapsed = performance.now() - start; + + expect(result.lookup['0'].summary).to.deep.equal({ type: 'value', value: PRICE_SUM }); + // Catastrophic-regression bound only — the measured time is an order of + // magnitude smaller (see docsTech/data-grid-formula-feature.md). + expect(elapsed).to.be.lessThan(5_000); + }); + + it('recomputes a column sum incrementally on a single-cell change', () => { + const { rowsLookup, createContext } = buildFixture(); + const full = computeFullFormulaPass(createContext(rowsLookup, {})); + + // Replace one row immutably, like a grid rows update does. + const nextRows = { ...rowsLookup, 5: { id: 5, price: 5 + PRICE_SUM } }; + const diffCtx = createContext(nextRows, full.lookup); + + const start = performance.now(); + const diff = computeRowsDiffFormulaPass(diffCtx); + const elapsed = performance.now() - start; + + // Only the dirty subgraph recomputed: the sum cell is the only change. + expect(diff?.changedCells).to.deep.equal([{ id: 0, field: 'summary' }]); + expect(diff?.lookup['0'].summary).to.deep.equal({ + type: 'value', + value: 2 * PRICE_SUM, + }); + expect(elapsed).to.be.lessThan(2_000); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.ts b/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.ts new file mode 100644 index 0000000000000..2402710b49e59 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/createFormulaEvaluation.ts @@ -0,0 +1,973 @@ +import type { RefObject } from '@mui/x-internals/types'; +import { warnOnce } from '@mui/x-internals/warning'; +import { gridRowIdSelector } from '@mui/x-data-grid-pro'; +import type { + GridCellCoordinates, + GridColDef, + GridRowId, + GridValidRowModel, +} from '@mui/x-data-grid-pro'; +import { getRowValue as getRowValueUtil } from '@mui/x-data-grid-pro/internals'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { + bindFormulaDependencies, + collectAffectedCells, + createFormulaCellKey, + createFormulaError, + evaluateFormula, + extractFormulaDependencies, + getFormulaExpression, + isEscapedFormulaSource, + isFormulaSource, + orderForRecompute, + parseFormulaCellKey, + unescapeLiteralSource, +} from './engine'; +import type { + FormulaAstNode, + FormulaBoundDependencies, + FormulaCellRef, + FormulaErrorValue, + FormulaPositionContext, + FormulaScalar, + FormulaStaticDependencies, +} from './engine'; +import type { + GridFormulaCellKey, + GridFormulaCellRecord, + GridFormulaInternalCache, + GridFormulaLookup, + GridFormulaRangeDependency, + GridFormulaResult, +} from './gridFormulaInterfaces'; +import { areFormulaFieldsEqual, resetFormulaEvaluationCache } from './gridFormulaUtils'; +import { arePositionArraysEqual, createFormulaPositionContext } from './gridFormulaPositionContext'; +import type { GridFormulaPositionSnapshot } from './gridFormulaPositionContext'; + +/** + * Context for formulas without position-dependent syntax — their binding and + * evaluation never consult positions, so building a real snapshot for them + * would be wasted work. + */ +const EMPTY_POSITION_CONTEXT: FormulaPositionContext = { + version: 0, + rowCount: 0, + columnCount: 0, + getRowIdAtPosition: () => undefined, + getPositionOfRowId: () => undefined, + getFieldAtPosition: () => undefined, + getPositionOfField: () => undefined, +}; + +/** + * Above this many materialized range cells per formula, a dev-mode warning + * suggests restructuring (D6). + */ +const RANGE_CELLS_WARNING_THRESHOLD = 100_000; + +/** + * Placeholder until the pass evaluates the record — topological order + * guarantees no dependent reads it. + */ +const PENDING_RESULT: GridFormulaResult = { type: 'value', value: null }; + +export interface FormulaPassContext { + apiRef: RefObject; + cache: GridFormulaInternalCache; + rowsLookup: Record; + columnsLookup: Record; + formulaFields: string[]; + previousLookup: GridFormulaLookup; + /** + * Produces the current position-context inputs. Called lazily — only when + * a position-dependent formula needs binding or evaluation. + * @returns {GridFormulaPositionSnapshot} The current position inputs. + */ + getPositionSnapshot: () => GridFormulaPositionSnapshot; +} + +/** + * Returns the position context records are bound against, building it from + * the snapshot on first need. Rebind passes are the only place the cached + * context is replaced — within a pass every consumer sees one snapshot. + */ +function getPassPositionContext(ctx: FormulaPassContext): FormulaPositionContext { + const { cache } = ctx; + if (cache.positionContext === null) { + const snapshot = ctx.getPositionSnapshot(); + cache.positionContextVersion += 1; + cache.positionContext = createFormulaPositionContext(snapshot, cache.positionContextVersion); + cache.positionContextRowIds = snapshot.rowIds; + cache.positionContextFields = snapshot.fields; + } + return cache.positionContext; +} + +export interface FormulaPassResult { + lookup: GridFormulaLookup; + changedCells: GridCellCoordinates[]; +} + +function readRawCellValue( + apiRef: RefObject, + row: GridValidRowModel, + colDef: GridColDef | undefined, +) { + if (colDef === undefined) { + return undefined; + } + return getRowValueUtil(row, colDef, apiRef); +} + +function areResultsEqual(a: GridFormulaResult, b: GridFormulaResult): boolean { + if (a.type === 'error' || b.type === 'error') { + return a.type === 'error' && b.type === 'error' && a.code === b.code && a.message === b.message; + } + if (Object.is(a.value, b.value)) { + return true; + } + return ( + a.value instanceof Date && b.value instanceof Date && a.value.getTime() === b.value.getTime() + ); +} + +/** + * Groups a record's interval and whole-column dependencies by field, in the + * shape `rangeDependentsByField` stores per dependent. + */ +function collectRangeDependenciesByField( + record: GridFormulaCellRecord, +): Map | null { + const dependencies = record.dependencies; + if ( + dependencies === null || + (dependencies.columnIntervals.length === 0 && dependencies.wholeColumns.length === 0) + ) { + return null; + } + const byField = new Map(); + const ensureEntry = (field: string) => { + let entry = byField.get(field); + if (entry === undefined) { + entry = { intervals: [], wholeColumn: false }; + byField.set(field, entry); + } + return entry; + }; + for (const interval of dependencies.columnIntervals) { + ensureEntry(interval.field).intervals.push({ + fromIndex: interval.fromIndex, + toIndex: interval.toIndex, + }); + } + for (const wholeColumn of dependencies.wholeColumns) { + ensureEntry(wholeColumn.field).wholeColumn = true; + } + return byField; +} + +function attachRecordEdges( + cache: GridFormulaInternalCache, + key: GridFormulaCellKey, + record: GridFormulaCellRecord, +) { + let fieldRecords = cache.recordsByField.get(record.field); + if (fieldRecords === undefined) { + fieldRecords = new Set(); + cache.recordsByField.set(record.field, fieldRecords); + } + fieldRecords.add(key); + if (record.usesPositionContext) { + cache.positionDependentKeys.add(key); + } + if (record.dependencies === null) { + return; + } + for (const dependency of record.dependencies.cells) { + let dependents = cache.dependents.get(dependency); + if (dependents === undefined) { + dependents = new Set(); + cache.dependents.set(dependency, dependents); + } + dependents.add(key); + + const rowKey = parseFormulaCellKey(dependency).id; + let rowDependents = cache.dependentsByRowId.get(rowKey); + if (rowDependents === undefined) { + rowDependents = new Set(); + cache.dependentsByRowId.set(rowKey, rowDependents); + } + rowDependents.add(key); + } + const rangeDependencies = collectRangeDependenciesByField(record); + if (rangeDependencies !== null) { + for (const [field, dependency] of rangeDependencies) { + let fieldDependents = cache.rangeDependentsByField.get(field); + if (fieldDependents === undefined) { + fieldDependents = new Map(); + cache.rangeDependentsByField.set(field, fieldDependents); + } + fieldDependents.set(key, dependency); + } + } +} + +/** + * Drops every tracked raw value of `field` that no longer has any reader. + * Range reads have no per-cell edges, so when the last range dependent of a + * field detaches, its tracked values must be swept explicitly — entries that + * still serve a cell-edge dependent stay. + */ +function sweepTrackedValuesForField(cache: GridFormulaInternalCache, field: string) { + for (const [rowKey, tracked] of cache.trackedValues) { + if (tracked.has(field) && !cache.dependents.has(createFormulaCellKey(rowKey, field))) { + tracked.delete(field); + if (tracked.size === 0) { + cache.trackedValues.delete(rowKey); + } + } + } +} + +function detachRecordEdges( + cache: GridFormulaInternalCache, + key: GridFormulaCellKey, + record: GridFormulaCellRecord, +) { + const fieldRecords = cache.recordsByField.get(record.field); + if (fieldRecords !== undefined) { + fieldRecords.delete(key); + if (fieldRecords.size === 0) { + cache.recordsByField.delete(record.field); + } + } + cache.positionDependentKeys.delete(key); + if (record.dependencies === null) { + return; + } + for (const dependency of record.dependencies.cells) { + const { id: rowKey, field } = parseFormulaCellKey(dependency); + const dependents = cache.dependents.get(dependency); + if (dependents !== undefined) { + dependents.delete(key); + if (dependents.size === 0) { + cache.dependents.delete(dependency); + // No formula reads this cell anymore — stop tracking its raw value, + // unless a range dependent of the field still does. + if (!cache.rangeDependentsByField.has(field)) { + const tracked = cache.trackedValues.get(rowKey); + if (tracked !== undefined) { + tracked.delete(field); + if (tracked.size === 0) { + cache.trackedValues.delete(rowKey); + } + } + } + } + } + const rowDependents = cache.dependentsByRowId.get(rowKey); + if (rowDependents !== undefined) { + rowDependents.delete(key); + if (rowDependents.size === 0) { + cache.dependentsByRowId.delete(rowKey); + } + } + } + const rangeDependencies = collectRangeDependenciesByField(record); + if (rangeDependencies !== null) { + for (const field of rangeDependencies.keys()) { + const fieldDependents = cache.rangeDependentsByField.get(field); + if (fieldDependents !== undefined) { + fieldDependents.delete(key); + if (fieldDependents.size === 0) { + cache.rangeDependentsByField.delete(field); + sweepTrackedValuesForField(cache, field); + } + } + } + } +} + +function setRecord( + ctx: FormulaPassContext, + key: GridFormulaCellKey, + record: GridFormulaCellRecord, +) { + const existing = ctx.cache.records.get(key); + if (existing !== undefined) { + detachRecordEdges(ctx.cache, key, existing); + } + ctx.cache.records.set(key, record); + attachRecordEdges(ctx.cache, key, record); +} + +function deleteRecord(ctx: FormulaPassContext, key: GridFormulaCellKey) { + const existing = ctx.cache.records.get(key); + if (existing === undefined) { + return; + } + detachRecordEdges(ctx.cache, key, existing); + ctx.cache.records.delete(key); +} + +function addDependentsToDirty( + cache: GridFormulaInternalCache, + key: GridFormulaCellKey, + dirty: Set, +) { + const dependents = cache.dependents.get(key); + if (dependents === undefined) { + return; + } + for (const dependent of dependents) { + if (cache.records.has(dependent)) { + dirty.add(dependent); + } + } +} + +function addRowDependentsToDirty( + cache: GridFormulaInternalCache, + rowKey: string, + dirty: Set, +) { + const rowDependents = cache.dependentsByRowId.get(rowKey); + if (rowDependents === undefined) { + return; + } + for (const dependent of rowDependents) { + if (cache.records.has(dependent)) { + dirty.add(dependent); + } + } +} + +/** + * Dirties the range dependents whose interval contains the changed cell. + * A cell without a position cannot be inside any materialized range — and + * when its membership changes (row added/removed, filter change), the + * rebind pass dirties every position-dependent formula anyway. + */ +function addRangeDependentsToDirty( + ctx: FormulaPassContext, + field: string, + id: GridRowId, + dirty: Set, +) { + const { cache } = ctx; + const fieldDependents = cache.rangeDependentsByField.get(field); + if (fieldDependents === undefined || fieldDependents.size === 0) { + return; + } + const position = getPassPositionContext(ctx).getPositionOfRowId(id); + if (position === undefined) { + return; + } + for (const [dependent, dependency] of fieldDependents) { + if (!cache.records.has(dependent)) { + continue; + } + if ( + dependency.wholeColumn || + dependency.intervals.some( + (interval) => position >= interval.fromIndex && position <= interval.toIndex, + ) + ) { + dirty.add(dependent); + } + } +} + +function warnOnLargeRangeDependencies( + dependencies: FormulaBoundDependencies, + context: FormulaPositionContext, +) { + let materializedCells = 0; + for (const interval of dependencies.columnIntervals) { + materializedCells += interval.toIndex - interval.fromIndex + 1; + } + materializedCells += dependencies.wholeColumns.length * context.rowCount; + if (materializedCells > RANGE_CELLS_WARNING_THRESHOLD) { + warnOnce([ + `MUI X Data Grid: A formula materializes over ${RANGE_CELLS_WARNING_THRESHOLD.toLocaleString('en-US')} range cells per evaluation.`, + 'Formulas of this size can make editing and scrolling noticeably slow.', + 'Consider aggregating over fewer rows or using the aggregation feature instead.', + ]); + } +} + +/** + * Static dependencies per AST. The parser interns ASTs by source string, so + * one extraction serves every cell sharing a formula and every rebind. + */ +const staticDependenciesCache = new WeakMap(); + +function getStaticDependencies(ast: FormulaAstNode): FormulaStaticDependencies { + let dependencies = staticDependenciesCache.get(ast); + if (dependencies === undefined) { + dependencies = extractFormulaDependencies(ast); + staticDependenciesCache.set(ast, dependencies); + } + return dependencies; +} + +/** + * Re-resolves a record's dependencies. Stable refs bind context-free; + * positional selectors, `RANGE` and `COLUMN_VALUES` resolve against the + * pass's position context. + */ +function bindRecordDependencies( + ctx: FormulaPassContext, + record: Pick, +): FormulaBoundDependencies | null { + if (record.parse === null || record.parse.ast === null) { + return null; + } + const dependencies = bindFormulaDependencies( + { id: record.id, field: record.field }, + getStaticDependencies(record.parse.ast), + record.usesPositionContext ? getPassPositionContext(ctx) : EMPTY_POSITION_CONTEXT, + ); + if (process.env.NODE_ENV !== 'production' && record.usesPositionContext) { + warnOnLargeRangeDependencies(dependencies, getPassPositionContext(ctx)); + } + return dependencies; +} + +function buildRecord( + ctx: FormulaPassContext, + id: GridRowId, + field: string, + source: string, +): GridFormulaCellRecord { + if (isEscapedFormulaSource(source)) { + return { + id, + field, + source, + parse: null, + dependencies: null, + usesPositionContext: false, + result: { type: 'value', value: unescapeLiteralSource(source) }, + }; + } + const parse = ctx.cache.parser.parse(getFormulaExpression(source)); + const record: GridFormulaCellRecord = { + id, + field, + source, + parse, + dependencies: null, + usesPositionContext: parse.ast !== null && getStaticDependencies(parse.ast).usesPositionContext, + result: PENDING_RESULT, + }; + record.dependencies = bindRecordDependencies(ctx, record); + return record; +} + +function scanRow( + ctx: FormulaPassContext, + id: GridRowId, + row: GridValidRowModel, + dirty: Set, + removedCells: GridCellCoordinates[] | null, +) { + const { cache, formulaFields, columnsLookup } = ctx; + for (const field of formulaFields) { + const raw = row[field]; + const key = createFormulaCellKey(id, field); + const existing = cache.records.get(key); + if (isFormulaSource(raw) || isEscapedFormulaSource(raw)) { + if (existing !== undefined && existing.source === raw) { + continue; + } + if (process.env.NODE_ENV !== 'production' && columnsLookup[field]?.valueGetter) { + warnOnce([ + `MUI X Data Grid: The column "${field}" defines both \`allowFormulas\` and \`valueGetter\`.`, + 'The `valueGetter` is ignored for cells holding a formula and only applies to plain cells.', + ]); + } + setRecord(ctx, key, buildRecord(ctx, id, field, raw)); + dirty.add(key); + } else if (existing !== undefined) { + deleteRecord(ctx, key); + removedCells?.push({ id, field }); + addDependentsToDirty(cache, key, dirty); + // The cell holds a raw value now — ranges over the field see the change. + addRangeDependentsToDirty(ctx, field, id, dirty); + } + } +} + +interface FormulaPassResolver { + getCellValue: (ref: FormulaCellRef) => FormulaScalar | FormulaErrorValue | undefined; + hasRow: (id: GridRowId) => boolean; + hasField: (field: string) => boolean; +} + +function createPassResolver(ctx: FormulaPassContext): FormulaPassResolver { + const { cache, rowsLookup, columnsLookup, apiRef } = ctx; + return { + getCellValue: (ref) => { + const key = createFormulaCellKey(ref.id, ref.field); + const record = cache.records.get(key); + if (record !== undefined) { + const { result } = record; + return result.type === 'error' + ? createFormulaError(result.code, result.message) + : result.value; + } + const row = rowsLookup[ref.id]; + if (row === undefined) { + return null; + } + const value = readRawCellValue(apiRef, row, columnsLookup[ref.field]); + const rowKey = String(ref.id); + let tracked = cache.trackedValues.get(rowKey); + if (tracked === undefined) { + tracked = new Map(); + cache.trackedValues.set(rowKey, tracked); + } + tracked.set(ref.field, value); + return value as FormulaScalar; + }, + hasRow: (id) => rowsLookup[id] !== undefined, + hasField: (field) => columnsLookup[field] !== undefined, + }; +} + +function evaluateRecordResult( + ctx: FormulaPassContext, + record: GridFormulaCellRecord, + resolver: FormulaPassResolver, +): GridFormulaResult { + if (record.parse === null) { + // Escaped literal — the result was final at build time. + return record.result; + } + if (record.parse.ast === null) { + return { + type: 'error', + code: '#ERROR!', + message: record.parse.error?.message ?? 'The formula could not be parsed.', + }; + } + // Binding errors are not short-circuited: the evaluator re-resolves + // positions against the same context snapshot, so it reaches the same + // errors itself, with strict left-to-right propagation order. + return evaluateFormula(record.parse.ast, { + currentCell: { id: record.id, field: record.field }, + getCellValue: resolver.getCellValue, + hasRow: resolver.hasRow, + hasField: resolver.hasField, + position: record.usesPositionContext ? getPassPositionContext(ctx) : EMPTY_POSITION_CONTEXT, + functions: ctx.cache.registry, + }); +} + +function finalizeRecordResult( + ctx: FormulaPassContext, + key: GridFormulaCellKey, + record: GridFormulaCellRecord, + next: GridFormulaResult, + changedKeys: Set, +) { + const previous = ctx.previousLookup[String(record.id)]?.[record.field]; + if (previous !== undefined && areResultsEqual(previous, next)) { + // Reuse the previous result object so unchanged cells keep their + // reference and never re-render. + record.result = previous; + return; + } + record.result = next; + changedKeys.add(key); +} + +/** + * Resolves the graph edges of `key` in the dependents direction, expanding + * the interval tier: a formula cell is read by a range dependent when its + * row position falls inside the dependent's interval (or the dependent reads + * the whole column). Raw cells never appear here — they cannot be `key`. + */ +function getGraphDependents( + ctx: FormulaPassContext, + key: GridFormulaCellKey, +): Iterable | undefined { + const { cache } = ctx; + const direct = cache.dependents.get(key); + const { id, field } = parseFormulaCellKey(key); + const fieldDependents = cache.rangeDependentsByField.get(field); + if (fieldDependents === undefined || fieldDependents.size === 0) { + return direct; + } + const position = getPassPositionContext(ctx).getPositionOfRowId(id); + if (position === undefined) { + return direct; + } + let expanded: Set | null = null; + for (const [dependent, dependency] of fieldDependents) { + if ( + dependency.wholeColumn || + dependency.intervals.some( + (interval) => position >= interval.fromIndex && position <= interval.toIndex, + ) + ) { + if (expanded === null) { + expanded = direct === undefined ? new Set() : new Set(direct); + } + expanded.add(dependent); + } + } + return expanded ?? direct; +} + +/** + * Resolves the graph edges of `key` in the dependencies direction, expanding + * interval and whole-column dependencies into the formula cells they cover. + * This is what makes a range read order correctly against — and cycle with — + * the formula cells inside its bounds (a `SUM(COLUMN_VALUES("total"))` + * placed in column `total` is a self-edge, hence `#CYCLE!`). + */ +function getGraphDependencies( + ctx: FormulaPassContext, + key: GridFormulaCellKey, +): Iterable | undefined { + const { cache } = ctx; + const dependencies = cache.records.get(key)?.dependencies; + if (dependencies === undefined || dependencies === null) { + return undefined; + } + if (dependencies.columnIntervals.length === 0 && dependencies.wholeColumns.length === 0) { + return dependencies.cells; + } + const positionContext = getPassPositionContext(ctx); + const expanded = new Set(dependencies.cells); + for (const wholeColumn of dependencies.wholeColumns) { + const fieldRecords = cache.recordsByField.get(wholeColumn.field); + if (fieldRecords === undefined) { + continue; + } + for (const dependency of fieldRecords) { + if (positionContext.getPositionOfRowId(parseFormulaCellKey(dependency).id) !== undefined) { + expanded.add(dependency); + } + } + } + for (const interval of dependencies.columnIntervals) { + const fieldRecords = cache.recordsByField.get(interval.field); + if (fieldRecords === undefined) { + continue; + } + for (const dependency of fieldRecords) { + const position = positionContext.getPositionOfRowId(parseFormulaCellKey(dependency).id); + if ( + position !== undefined && + position >= interval.fromIndex && + position <= interval.toIndex + ) { + expanded.add(dependency); + } + } + } + return expanded; +} + +/** + * Recomputes the transitive closure of `dirty` in topological order. + * Cells locked on a cycle become `#CYCLE!`. Returns the keys whose result + * object changed. + */ +function runEvaluation( + ctx: FormulaPassContext, + dirty: Set, +): Set { + const { cache } = ctx; + const changedKeys = new Set(); + if (dirty.size === 0) { + return changedKeys; + } + const affected = collectAffectedCells(dirty, (key) => getGraphDependents(ctx, key)); + const { order, cyclic } = orderForRecompute(affected, (key) => getGraphDependencies(ctx, key)); + const resolver = createPassResolver(ctx); + for (const key of order) { + const record = cache.records.get(key); + if (record === undefined) { + continue; + } + finalizeRecordResult( + ctx, + key, + record, + evaluateRecordResult(ctx, record, resolver), + changedKeys, + ); + } + for (const key of cyclic) { + const record = cache.records.get(key); + if (record === undefined) { + continue; + } + finalizeRecordResult( + ctx, + key, + record, + { + type: 'error', + code: '#CYCLE!', + message: 'The formula is part of a circular reference.', + }, + changedKeys, + ); + } + return changedKeys; +} + +/** + * Rescans every row and recomputes every formula from scratch. + * Used for the initial evaluation, `reevaluateFormulas()`, column-set changes + * and function-registry changes. + */ +export function computeFullFormulaPass(ctx: FormulaPassContext): FormulaPassResult { + const { cache, rowsLookup, formulaFields, apiRef } = ctx; + resetFormulaEvaluationCache(cache); + + const dirty = new Set(); + if (formulaFields.length > 0) { + for (const rowKey of Object.keys(rowsLookup)) { + const row = rowsLookup[rowKey]; + scanRow(ctx, gridRowIdSelector(apiRef, row), row, dirty, null); + } + } + runEvaluation(ctx, dirty); + + const lookup: GridFormulaLookup = {}; + const changedCells: GridCellCoordinates[] = []; + for (const record of cache.records.values()) { + const rowKey = String(record.id); + let rowEntry = lookup[rowKey]; + if (rowEntry === undefined) { + rowEntry = {}; + lookup[rowKey] = rowEntry; + } + rowEntry[record.field] = record.result; + if (ctx.previousLookup[rowKey]?.[record.field] !== record.result) { + changedCells.push({ id: record.id, field: record.field }); + } + } + for (const rowKey of Object.keys(ctx.previousLookup)) { + const row = rowsLookup[rowKey]; + // The stringified key is the best available id once the row is gone. + const id = row === undefined ? rowKey : gridRowIdSelector(apiRef, row); + for (const field of Object.keys(ctx.previousLookup[rowKey])) { + if (lookup[rowKey]?.[field] === undefined) { + changedCells.push({ id, field }); + } + } + } + + cache.lastRowIdToModelLookup = rowsLookup; + cache.formulaFields = formulaFields; + return { lookup, changedCells }; +} + +/** + * Reference-diffs the rows lookup against the snapshot from the last pass and + * recomputes only the dirty subgraph. Falls back to a full pass when there is + * no snapshot or the formula column set changed. Returns `null` when nothing + * needs to change. + */ +export function computeRowsDiffFormulaPass(ctx: FormulaPassContext): FormulaPassResult | null { + const { cache, rowsLookup, formulaFields, apiRef } = ctx; + const previousRows = cache.lastRowIdToModelLookup; + if (previousRows === null || !areFormulaFieldsEqual(cache.formulaFields, formulaFields)) { + return computeFullFormulaPass(ctx); + } + if (previousRows === rowsLookup) { + return null; + } + + const dirty = new Set(); + const removedCells: GridCellCoordinates[] = []; + const removedRowKeys: string[] = []; + + for (const rowKey of Object.keys(rowsLookup)) { + const row = rowsLookup[rowKey]; + const previousRow = previousRows[rowKey]; + if (previousRow === row) { + continue; + } + const id = gridRowIdSelector(apiRef, row); + if (previousRow === undefined) { + // Added row: formulas referencing it (currently `#REF!`) must recompute. + addRowDependentsToDirty(cache, rowKey, dirty); + scanRow(ctx, id, row, dirty, removedCells); + continue; + } + scanRow(ctx, id, row, dirty, removedCells); + const tracked = cache.trackedValues.get(rowKey); + if (tracked !== undefined) { + for (const [field, lastValue] of tracked) { + const current = readRawCellValue(apiRef, row, ctx.columnsLookup[field]); + if (!Object.is(current, lastValue)) { + tracked.set(field, current); + addDependentsToDirty(cache, createFormulaCellKey(id, field), dirty); + addRangeDependentsToDirty(ctx, field, id, dirty); + } + } + } + } + + const removedRowCells: GridCellCoordinates[] = []; + for (const rowKey of Object.keys(previousRows)) { + if (rowsLookup[rowKey] !== undefined) { + continue; + } + removedRowKeys.push(rowKey); + addRowDependentsToDirty(cache, rowKey, dirty); + for (const field of formulaFields) { + const key = createFormulaCellKey(rowKey, field); + const record = cache.records.get(key); + if (record !== undefined) { + // Removed lookup entries count as changes — otherwise a pass that only + // removes a row would report nothing and the stale entry would mask + // the values of a later row with the same id. + removedRowCells.push({ id: record.id, field }); + deleteRecord(ctx, key); + dirty.delete(key); + } + } + cache.trackedValues.delete(rowKey); + } + + if (dirty.size === 0 && removedCells.length === 0 && removedRowKeys.length === 0) { + cache.lastRowIdToModelLookup = rowsLookup; + return null; + } + + const changedKeys = runEvaluation(ctx, dirty); + + const builder = createIncrementalLookup(ctx.previousLookup); + const { lookup, touchedRows, ensureRowEntry } = builder; + + const changedCells: GridCellCoordinates[] = [...removedRowCells]; + for (const rowKey of removedRowKeys) { + if (lookup[rowKey] !== undefined) { + delete lookup[rowKey]; + } + } + for (const removedCell of removedCells) { + const rowKey = String(removedCell.id); + if (lookup[rowKey]?.[removedCell.field] !== undefined) { + const rowEntry = ensureRowEntry(rowKey); + delete rowEntry[removedCell.field]; + if (Object.keys(rowEntry).length === 0) { + delete lookup[rowKey]; + touchedRows.delete(rowKey); + } + changedCells.push(removedCell); + } + } + applyChangedRecords(ctx, changedKeys, builder, changedCells); + + cache.lastRowIdToModelLookup = rowsLookup; + if (changedCells.length === 0) { + return null; + } + return { lookup, changedCells }; +} + +interface IncrementalLookupBuilder { + lookup: GridFormulaLookup; + touchedRows: Set; + ensureRowEntry: (rowKey: string) => Record; +} + +/** + * Copy-on-write view over the previous lookup: rows are only cloned when a + * cell of theirs changes, so unchanged rows keep their object identity and + * their cells never re-render. + */ +function createIncrementalLookup(previousLookup: GridFormulaLookup): IncrementalLookupBuilder { + const lookup: GridFormulaLookup = { ...previousLookup }; + const touchedRows = new Set(); + const ensureRowEntry = (rowKey: string) => { + if (!touchedRows.has(rowKey)) { + lookup[rowKey] = { ...lookup[rowKey] }; + touchedRows.add(rowKey); + } + return lookup[rowKey]; + }; + return { lookup, touchedRows, ensureRowEntry }; +} + +function applyChangedRecords( + ctx: FormulaPassContext, + changedKeys: Set, + builder: IncrementalLookupBuilder, + changedCells: GridCellCoordinates[], +) { + for (const key of changedKeys) { + const record = ctx.cache.records.get(key); + if (record === undefined) { + continue; + } + builder.ensureRowEntry(String(record.id))[record.field] = record.result; + changedCells.push({ id: record.id, field: record.field }); + } +} + +/** + * Rebinds every position-dependent formula against a fresh position-context + * snapshot and recomputes the affected subgraph. One-shot by design (D4): + * the pass runs once per position-changing event, and the grid never + * re-sorts or re-filters in response to the values it produces. Returns + * `null` when nothing is bound positionally or the view order is unchanged. + */ +export function computePositionRebindFormulaPass( + ctx: FormulaPassContext, +): FormulaPassResult | null { + const { cache } = ctx; + if (cache.positionDependentKeys.size === 0) { + // Nothing is bound positionally — invalidate the snapshot so the next + // position-dependent formula binds against fresh positions. + cache.positionContext = null; + cache.positionContextRowIds = null; + cache.positionContextFields = null; + return null; + } + const snapshot = ctx.getPositionSnapshot(); + if ( + cache.positionContextRowIds !== null && + cache.positionContextFields !== null && + arePositionArraysEqual(cache.positionContextRowIds, snapshot.rowIds) && + arePositionArraysEqual(cache.positionContextFields, snapshot.fields) + ) { + return null; + } + cache.positionContextVersion += 1; + cache.positionContext = createFormulaPositionContext(snapshot, cache.positionContextVersion); + cache.positionContextRowIds = snapshot.rowIds; + cache.positionContextFields = snapshot.fields; + + const dirty = new Set(); + // Copy before iterating: rebinding detaches and re-attaches each key, and + // mutating a Set during iteration revisits re-added keys. + for (const key of Array.from(cache.positionDependentKeys)) { + const record = cache.records.get(key); + if (record === undefined) { + continue; + } + const rebound: GridFormulaCellRecord = { ...record }; + rebound.dependencies = bindRecordDependencies(ctx, rebound); + setRecord(ctx, key, rebound); + dirty.add(key); + } + const changedKeys = runEvaluation(ctx, dirty); + if (changedKeys.size === 0) { + return null; + } + const builder = createIncrementalLookup(ctx.previousLookup); + const changedCells: GridCellCoordinates[] = []; + applyChangedRecords(ctx, changedKeys, builder, changedCells); + return { lookup: builder.lookup, changedCells }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.test.ts new file mode 100644 index 0000000000000..da3eefa9986c0 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.test.ts @@ -0,0 +1,265 @@ +import { parseFormula } from './formulaParser'; +import { serializeFormulaAst } from './formulaSerializer'; +import { + columnIndexToLetters, + columnLettersToIndex, + toCanonicalFormula, + toDisplayFormula, +} from './formulaA1'; +import { createTestPositionContext } from './testUtils'; +import type { FormulaAstNode } from './formulaAst'; +import type { FormulaPositionContext, FormulaRowId } from './formulaTypes'; + +const stripSpans = (node: unknown): unknown => { + if (Array.isArray(node)) { + return node.map(stripSpans); + } + if (typeof node === 'object' && node !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key !== 'span') { + result[key] = stripSpans(value); + } + } + return result; + } + return node; +}; + +const parseOk = (expression: string): FormulaAstNode => { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return ast; +}; + +// A 3-column × 5-row context: columns A,B,C map to price,qty,total; rows 1..5 +// map to r1..r5. The transforms operate on the expression (no leading `=`). +const FIELDS = ['price', 'qty', 'total']; +const ROW_IDS: FormulaRowId[] = ['r1', 'r2', 'r3', 'r4', 'r5']; +const CONTEXT: FormulaPositionContext = createTestPositionContext(ROW_IDS, FIELDS); + +const toCanonical = ( + expression: string, + options?: { columnOffset?: number; rowOffset?: number }, + context: FormulaPositionContext = CONTEXT, +) => toCanonicalFormula(expression, { positionContext: context }, options).source; + +const toDisplay = (expression: string, context: FormulaPositionContext = CONTEXT) => + toDisplayFormula(expression, { positionContext: context }); + +describe('formulaA1', () => { + describe('columnIndexToLetters', () => { + it('maps 1-based indexes to bijective base-26 letters', () => { + expect(columnIndexToLetters(1)).to.equal('A'); + expect(columnIndexToLetters(26)).to.equal('Z'); + expect(columnIndexToLetters(27)).to.equal('AA'); + expect(columnIndexToLetters(52)).to.equal('AZ'); + expect(columnIndexToLetters(53)).to.equal('BA'); + expect(columnIndexToLetters(702)).to.equal('ZZ'); + expect(columnIndexToLetters(703)).to.equal('AAA'); + }); + + it('returns an empty string for invalid input', () => { + expect(columnIndexToLetters(0)).to.equal(''); + expect(columnIndexToLetters(-1)).to.equal(''); + expect(columnIndexToLetters(1.5)).to.equal(''); + }); + }); + + describe('columnLettersToIndex', () => { + it('is the inverse of columnIndexToLetters (case-insensitive)', () => { + expect(columnLettersToIndex('A')).to.equal(1); + expect(columnLettersToIndex('z')).to.equal(26); + expect(columnLettersToIndex('AA')).to.equal(27); + expect(columnLettersToIndex('zz')).to.equal(702); + expect(columnLettersToIndex('AAA')).to.equal(703); + }); + + it('round-trips every index in a wide range', () => { + for (let index = 1; index <= 1000; index += 1) { + expect(columnLettersToIndex(columnIndexToLetters(index))).to.equal(index); + } + }); + + it('returns 0 for non-letters', () => { + expect(columnLettersToIndex('')).to.equal(0); + expect(columnLettersToIndex('A1')).to.equal(0); + }); + }); + + describe('toCanonicalFormula — D5 mapping', () => { + it('freezes a fully relative ref to stable column + row', () => { + expect(toCanonical('A1')).to.equal('REF(COLUMN("price"), ROW("r1"))'); + expect(toCanonical('B3')).to.equal('REF(COLUMN("qty"), ROW("r3"))'); + expect(toCanonical('C5')).to.equal('REF(COLUMN("total"), ROW("r5"))'); + }); + + it('keeps an absolute column positional ($A → COLUMN_POSITION)', () => { + expect(toCanonical('$A1')).to.equal('REF(COLUMN_POSITION(1), ROW("r1"))'); + }); + + it('keeps an absolute row positional (A$1 → ROW_POSITION)', () => { + expect(toCanonical('A$1')).to.equal('REF(COLUMN("price"), ROW_POSITION(1))'); + }); + + it('keeps both axes positional ($A$1)', () => { + expect(toCanonical('$A$1')).to.equal('REF(COLUMN_POSITION(1), ROW_POSITION(1))'); + }); + + it('falls back to a positional selector for an out-of-bounds relative axis', () => { + // Column D (4) and row 9 do not exist → positional → #REF! at bind time. + expect(toCanonical('D1')).to.equal('REF(COLUMN_POSITION(4), ROW("r1"))'); + expect(toCanonical('A9')).to.equal('REF(COLUMN("price"), ROW_POSITION(9))'); + }); + }); + + describe('toCanonicalFormula — ranges', () => { + it('rewrites a rectangle A1:B5 into RANGE', () => { + expect(toCanonical('SUM(A1:B5)')).to.equal( + 'SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("qty"), ROW("r5"))))', + ); + }); + + it('tolerates spaces around the colon', () => { + expect(toCanonical('A1 : B2')).to.equal( + 'RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("qty"), ROW("r2")))', + ); + }); + + it('rewrites a whole-column range A:A into COLUMN_VALUES', () => { + expect(toCanonical('SUM(C:C)')).to.equal('SUM(COLUMN_VALUES("total"))'); + }); + + it('leaves a mixed whole-column range untouched', () => { + // A:B has no single COLUMN_VALUES form — copied verbatim, fails downstream. + expect(toCanonical('A:B')).to.equal('A:B'); + }); + }); + + describe('toCanonicalFormula — passthrough', () => { + it('leaves canonical syntax unchanged', () => { + const canonical = 'REF(COLUMN("price"), ROW("r1")) + COLUMN_VALUES("total")'; + expect(toCanonical(canonical)).to.equal(canonical); + }); + + it('does not touch references inside string literals', () => { + expect(toCanonical('"A1" & B2')).to.equal('"A1" & REF(COLUMN("qty"), ROW("r2"))'); + }); + + it('does not capture a function call whose name ends in digits', () => { + // LOG10( stays a call; only the bare A1 freezes. + expect(toCanonical('LOG10(A1)')).to.equal('LOG10(REF(COLUMN("price"), ROW("r1")))'); + }); + + it('treats a bare identifier as a same-row field reference', () => { + expect(toCanonical('price + qty')).to.equal('price + qty'); + }); + + it('does not capture special-form names', () => { + const canonical = 'COLUMN_VALUES("price") + ROW_POSITION'; + expect(toCanonical(canonical)).to.equal(canonical); + }); + + it('reports whether anything changed', () => { + expect(toCanonicalFormula('A1', { positionContext: CONTEXT }).changed).to.equal(true); + expect(toCanonicalFormula('price + qty', { positionContext: CONTEXT }).changed).to.equal( + false, + ); + }); + }); + + describe('toCanonicalFormula — paste offset', () => { + it('shifts relative axes by the paste offset before freezing', () => { + // Pasting `A1` one row down and one column right → B2. + expect(toCanonical('A1', { rowOffset: 1, columnOffset: 1 })).to.equal( + 'REF(COLUMN("qty"), ROW("r2"))', + ); + }); + + it('does not shift absolute axes', () => { + expect(toCanonical('$A$1', { rowOffset: 2, columnOffset: 2 })).to.equal( + 'REF(COLUMN_POSITION(1), ROW_POSITION(1))', + ); + }); + + it('only shifts the relative axis of a mixed ref', () => { + expect(toCanonical('$A1', { rowOffset: 1, columnOffset: 1 })).to.equal( + 'REF(COLUMN_POSITION(1), ROW("r2"))', + ); + }); + }); + + describe('toDisplayFormula', () => { + it('renders stable refs as relative A1', () => { + expect(toDisplay('REF(COLUMN("price"), ROW("r1"))')).to.equal('A1'); + expect(toDisplay('REF(COLUMN("total"), ROW("r5"))')).to.equal('C5'); + }); + + it('renders positional refs as absolute A1', () => { + expect(toDisplay('REF(COLUMN_POSITION(1), ROW_POSITION(1))')).to.equal('$A$1'); + expect(toDisplay('REF(COLUMN("price"), ROW_POSITION(1))')).to.equal('A$1'); + expect(toDisplay('REF(COLUMN_POSITION(1), ROW("r1"))')).to.equal('$A1'); + }); + + it('renders RANGE as an A1 rectangle', () => { + expect( + toDisplay('SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("qty"), ROW("r5"))))'), + ).to.equal('SUM(A1:B5)'); + }); + + it('renders COLUMN_VALUES as a whole-column range', () => { + expect(toDisplay('SUM(COLUMN_VALUES("total"))')).to.equal('SUM(C:C)'); + }); + + it('falls back to canonical for a ref with no current position', () => { + // `missing` is not a visible field → not displayable as A1. + expect(toDisplay('REF(COLUMN("missing"), ROW("r1"))')).to.equal( + 'REF(COLUMN("missing"), ROW("r1"))', + ); + expect(toDisplay('REF(COLUMN("price"), ROW("r99"))')).to.equal( + 'REF(COLUMN("price"), ROW("r99"))', + ); + }); + + it('preserves operators, precedence and bare field refs', () => { + expect(toDisplay('(A1 + B1) * price')).to.equal('(A1 + B1) * price'); + }); + + it('returns the input unchanged when it does not parse', () => { + expect(toDisplay('A1 +')).to.equal('A1 +'); + }); + }); + + describe('round-trip', () => { + const roundTrips = (a1: string) => { + const canonical = toCanonical(a1); + const display = toDisplay(canonical); + // A1 → canonical → A1 is stable. + expect(display).to.equal(a1); + // canonical → A1 → canonical is stable (semantic identity through the AST). + expect(toCanonical(display)).to.equal(canonical); + }; + + it('is stable for every D5 reference shape', () => { + roundTrips('A1'); + roundTrips('$A1'); + roundTrips('A$1'); + roundTrips('$A$1'); + roundTrips('B3 + C5'); + roundTrips('SUM(A1:B5)'); + roundTrips('SUM(C:C)'); + }); + + it('preserves the canonical AST through a display round-trip', () => { + const stored = toCanonical('SUM(A1:C3) + price * $B$2'); + const reCanonical = toCanonical(toDisplay(stored)); + expect(stripSpans(parseOk(reCanonical))).to.deep.equal(stripSpans(parseOk(stored))); + // And the canonical text is byte-stable too. + expect(serializeFormulaAst(parseOk(reCanonical))).to.equal( + serializeFormulaAst(parseOk(stored)), + ); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.ts new file mode 100644 index 0000000000000..c5e7781a72f8b --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaA1.ts @@ -0,0 +1,465 @@ +import { FORMULA_BINARY_PRECEDENCE } from './formulaAst'; +import type { + FormulaAstNode, + FormulaCellRefNode, + FormulaColumnSelector, + FormulaColumnValuesNode, + FormulaRangeNode, + FormulaRowSelector, +} from './formulaAst'; +import { parseFormula } from './formulaParser'; +import { serializeFormulaAst } from './formulaSerializer'; +import type { FormulaPositionContext, FormulaSourceSpan } from './formulaTypes'; + +/** + * A1 notation is an editor-facing dialect layered on top of the canonical + * (`REF`/`RANGE`/`COLUMN_VALUES`) syntax. It is never stored: `toCanonicalFormula` + * runs at commit/paste and `toDisplayFormula` runs at edit-begin. The canonical + * dialect is a superset, so any canonical formula round-trips through `toDisplay` + * losslessly (refs without a current position render in canonical form inline). + * + * Reference convention (D5), inverted from Excel's `$` semantics on purpose so + * that the grid stays loop-free under re-sorting: + * + * - A **relative** axis (no `$`) **freezes** to the stable identity currently at + * that position — `A` → `COLUMN("fieldAtColumnA")`, `1` → `ROW(idAtRow1)`. The + * reference no longer moves when the grid is re-sorted, and it shifts by the + * paste offset like an Excel relative reference. + * - An **absolute** axis (`$`) stays **positional** — `$A` → `COLUMN_POSITION(1)`, + * `$1` → `ROW_POSITION(1)`. It follows the grid position (tracks re-sorts) and + * does not shift on paste. + * + * The transform is purely textual: it rewrites the A1 reference tokens and copies + * every other token (operators, function calls, string literals, numbers, bare + * field references, already-canonical forms) verbatim. A token only reads as a + * cell reference when it is `` not followed by more identifier + * characters or `(` — so `LOG10(...)` stays a call and a field literally named + * `A1` must be written `FIELD("A1")`. + */ + +const ZERO_SPAN: FormulaSourceSpan = { start: 0, end: 0 }; + +// `$?` column letters, `$?` row digits, with a boundary that rejects a longer +// identifier (`LOG10X`) or a call (`LOG10(`). +const CELL_REF_REGEX = /^(\$?)([A-Za-z]+)(\$?)([0-9]+)(?![A-Za-z0-9_(])/; +// Column letters with the same boundary, for `A:A` whole-column ranges. +const COLUMN_LETTERS_REGEX = /^(\$?)([A-Za-z]+)(?![A-Za-z0-9_(])/; +const IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*/; +const WHITESPACE_REGEX = /^\s+/; + +/** + * 1-based column index to bijective base-26 letters: `1` → `"A"`, `26` → `"Z"`, + * `27` → `"AA"`. Returns `""` for non-positive or non-integer input. + */ +export function columnIndexToLetters(index: number): string { + if (!Number.isInteger(index) || index < 1) { + return ''; + } + let letters = ''; + let remaining = index; + while (remaining > 0) { + const remainder = (remaining - 1) % 26; + letters = String.fromCharCode(65 + remainder) + letters; + remaining = Math.floor((remaining - 1) / 26); + } + return letters; +} + +/** + * Inverse of `columnIndexToLetters` (case-insensitive). `"A"` → `1`, `"AA"` → `27`. + * Returns `0` when any character is not a Latin letter. + */ +export function columnLettersToIndex(letters: string): number { + if (letters.length === 0) { + return 0; + } + let index = 0; + for (let i = 0; i < letters.length; i += 1) { + const code = letters.charCodeAt(i); + let value: number; + if (code >= 65 && code <= 90) { + value = code - 64; // A=1 + } else if (code >= 97 && code <= 122) { + value = code - 96; // a=1 + } else { + return 0; + } + index = index * 26 + value; + } + return index; +} + +export interface A1TransformContext { + positionContext: FormulaPositionContext; +} + +export interface ToCanonicalOptions { + /** + * Added to relative (no-`$`) column positions before freezing — the Excel-style + * fill adjustment applied when an A1 formula is pasted away from its origin. + * @default 0 + */ + columnOffset?: number; + /** + * Added to relative (no-`$`) row positions before freezing. + * @default 0 + */ + rowOffset?: number; +} + +export interface A1TransformResult { + /** + * The expression in canonical syntax (without the leading `=`). Unrecognized + * text is copied through unchanged, so a malformed A1 expression yields a + * malformed canonical expression that fails as `#ERROR!` at evaluation — + * consistent with the permissive-commit rule. + */ + source: string; + /** + * `true` when at least one A1 reference token was rewritten — lets the adapter + * skip the canonical store when nothing changed. + */ + changed: boolean; +} + +interface ParsedRef { + columnAbsolute: boolean; + letters: string; + rowAbsolute: boolean; + rowNumber: number; +} + +function buildColumnSelector( + ref: ParsedRef, + context: FormulaPositionContext, + columnOffset: number, +): FormulaColumnSelector { + const baseIndex = columnLettersToIndex(ref.letters); + if (ref.columnAbsolute) { + // Absolute (`$`) axis is positional and never shifts on paste. + return { kind: 'position', index: baseIndex }; + } + // Relative axis freezes to the field currently at the (offset) position. + const position = baseIndex + columnOffset; + if (position >= 1) { + const field = context.getFieldAtPosition(position); + if (field !== undefined) { + return { kind: 'field', field }; + } + } + // Out of bounds: a positional selector resolves to `#REF!` at bind time. + return { kind: 'position', index: position >= 1 ? position : baseIndex }; +} + +function buildRowSelector( + ref: ParsedRef, + context: FormulaPositionContext, + rowOffset: number, +): FormulaRowSelector { + if (ref.rowAbsolute) { + return { kind: 'position', index: ref.rowNumber }; + } + const position = ref.rowNumber + rowOffset; + if (position >= 1) { + const id = context.getRowIdAtPosition(position); + if (id !== undefined) { + return { kind: 'id', id }; + } + } + return { kind: 'position', index: position >= 1 ? position : ref.rowNumber }; +} + +function buildCellRefNode( + ref: ParsedRef, + context: FormulaPositionContext, + columnOffset: number, + rowOffset: number, +): FormulaCellRefNode { + return { + type: 'cellRef', + column: buildColumnSelector(ref, context, columnOffset), + row: buildRowSelector(ref, context, rowOffset), + span: ZERO_SPAN, + }; +} + +function readParsedRef(match: RegExpExecArray): ParsedRef { + return { + columnAbsolute: match[1] === '$', + letters: match[2], + rowAbsolute: match[3] === '$', + rowNumber: parseInt(match[4], 10), + }; +} + +function skipInlineWhitespace(expression: string, from: number): number { + let index = from; + while (index < expression.length && /\s/.test(expression[index])) { + index += 1; + } + return index; +} + +/** + * Advances past a `"`-delimited string literal (with `""` escapes), returning the + * index just after the closing quote (or the end of the input when unterminated). + */ +function scanStringLiteral(expression: string, start: number): number { + let index = start + 1; + while (index < expression.length) { + if (expression[index] === '"') { + if (expression[index + 1] === '"') { + index += 2; + continue; + } + return index + 1; + } + index += 1; + } + return expression.length; +} + +/** + * After a cell reference at `afterFirst`, matches an optional `: ` tail + * that turns it into a `RANGE`. + */ +function matchRangeTail( + expression: string, + afterFirst: number, +): { endRef: ParsedRef; end: number } | null { + let index = skipInlineWhitespace(expression, afterFirst); + if (expression[index] !== ':') { + return null; + } + index = skipInlineWhitespace(expression, index + 1); + const match = CELL_REF_REGEX.exec(expression.slice(index)); + if (match === null) { + return null; + } + return { endRef: readParsedRef(match), end: index + match[0].length }; +} + +/** + * Matches a whole-column range `A:A`. Only same-column ranges map to a single + * `COLUMN_VALUES`; mixed columns return `null` and are copied verbatim. + */ +function matchColumnRange( + expression: string, + start: number, +): { letters: string; absolute: boolean; end: number } | null { + const first = COLUMN_LETTERS_REGEX.exec(expression.slice(start)); + if (first === null) { + return null; + } + let index = skipInlineWhitespace(expression, start + first[0].length); + if (expression[index] !== ':') { + return null; + } + index = skipInlineWhitespace(expression, index + 1); + const second = COLUMN_LETTERS_REGEX.exec(expression.slice(index)); + if (second === null || first[2].toUpperCase() !== second[2].toUpperCase()) { + return null; + } + return { letters: first[2], absolute: first[1] === '$', end: index + second[0].length }; +} + +function buildColumnValuesNode( + range: { letters: string; absolute: boolean }, + context: FormulaPositionContext, + columnOffset: number, +): FormulaColumnValuesNode | null { + const baseIndex = columnLettersToIndex(range.letters); + // No positional `COLUMN_VALUES` form exists, so a whole-column range always + // freezes to a field name regardless of `$`. + const position = range.absolute ? baseIndex : baseIndex + columnOffset; + if (position < 1) { + return null; + } + const field = context.getFieldAtPosition(position); + if (field === undefined) { + return null; + } + return { type: 'columnValues', field, span: ZERO_SPAN }; +} + +/** + * Rewrites an A1-dialect expression (without the leading `=`) into the canonical + * dialect. Never throws. + */ +export function toCanonicalFormula( + expression: string, + context: A1TransformContext, + options: ToCanonicalOptions = {}, +): A1TransformResult { + const { positionContext } = context; + const columnOffset = options.columnOffset ?? 0; + const rowOffset = options.rowOffset ?? 0; + + let result = ''; + let changed = false; + let index = 0; + + while (index < expression.length) { + const rest = expression.slice(index); + const char = expression[index]; + + if (char === '"') { + const end = scanStringLiteral(expression, index); + result += expression.slice(index, end); + index = end; + continue; + } + + const whitespace = WHITESPACE_REGEX.exec(rest); + if (whitespace !== null) { + result += whitespace[0]; + index += whitespace[0].length; + continue; + } + + const cellMatch = CELL_REF_REGEX.exec(rest); + if (cellMatch !== null) { + const startRef = readParsedRef(cellMatch); + const rangeTail = matchRangeTail(expression, index + cellMatch[0].length); + if (rangeTail !== null) { + const rangeNode: FormulaRangeNode = { + type: 'range', + start: buildCellRefNode(startRef, positionContext, columnOffset, rowOffset), + end: buildCellRefNode(rangeTail.endRef, positionContext, columnOffset, rowOffset), + span: ZERO_SPAN, + }; + result += serializeFormulaAst(rangeNode); + index = rangeTail.end; + } else { + result += serializeFormulaAst( + buildCellRefNode(startRef, positionContext, columnOffset, rowOffset), + ); + index += cellMatch[0].length; + } + changed = true; + continue; + } + + const columnRange = matchColumnRange(expression, index); + if (columnRange !== null) { + const node = buildColumnValuesNode(columnRange, positionContext, columnOffset); + if (node !== null) { + result += serializeFormulaAst(node); + index = columnRange.end; + changed = true; + continue; + } + } + + const identifier = IDENTIFIER_REGEX.exec(rest); + if (identifier !== null) { + result += identifier[0]; + index += identifier[0].length; + continue; + } + + result += char; + index += 1; + } + + return { source: result, changed }; +} + +function cellRefToA1(node: FormulaCellRefNode, context: FormulaPositionContext): string | null { + let columnPart: string; + if (node.column.kind === 'position') { + // Positional column is rendered as the absolute (`$`) A1 axis. + columnPart = `$${columnIndexToLetters(node.column.index)}`; + } else { + const position = context.getPositionOfField(node.column.field); + if (position === undefined) { + return null; + } + columnPart = columnIndexToLetters(position); + } + + let rowPart: string; + if (node.row.kind === 'position') { + rowPart = `$${node.row.index}`; + } else { + const position = context.getPositionOfRowId(node.row.id); + if (position === undefined) { + return null; + } + rowPart = String(position); + } + + return `${columnPart}${rowPart}`; +} + +function serializeA1Operand( + node: FormulaAstNode, + minPrecedence: number, + context: FormulaPositionContext, +): string { + const text = serializeA1Node(node, context); + if ( + node.type === 'binaryExpression' && + FORMULA_BINARY_PRECEDENCE[node.operator] < minPrecedence + ) { + return `(${text})`; + } + return text; +} + +function serializeA1Node(node: FormulaAstNode, context: FormulaPositionContext): string { + switch (node.type) { + case 'cellRef': { + const a1 = cellRefToA1(node, context); + return a1 ?? serializeFormulaAst(node); + } + case 'range': { + const start = cellRefToA1(node.start, context); + const end = cellRefToA1(node.end, context); + if (start !== null && end !== null) { + return `${start}:${end}`; + } + return serializeFormulaAst(node); + } + case 'columnValues': { + const position = context.getPositionOfField(node.field); + if (position !== undefined) { + const letters = columnIndexToLetters(position); + return `${letters}:${letters}`; + } + return serializeFormulaAst(node); + } + case 'unaryExpression': { + const operand = serializeA1Node(node.operand, context); + if (node.operand.type === 'binaryExpression' || node.operand.type === 'unaryExpression') { + return `${node.operator}(${operand})`; + } + return `${node.operator}${operand}`; + } + case 'binaryExpression': { + const precedence = FORMULA_BINARY_PRECEDENCE[node.operator]; + const left = serializeA1Operand(node.left, precedence, context); + const right = serializeA1Operand(node.right, precedence + 1, context); + return `${left} ${node.operator} ${right}`; + } + case 'functionCall': + return `${node.name}(${node.args.map((arg) => serializeA1Node(arg, context)).join(', ')})`; + default: + // Literals and bare field references render identically in both dialects. + return serializeFormulaAst(node); + } +} + +/** + * Renders a canonical expression (without the leading `=`) into A1 notation for + * editing. References whose identity has no current position (hidden column, + * filtered-out row) fall back to canonical form inline. Never throws; returns the + * input unchanged when it is not parseable as canonical. + */ +export function toDisplayFormula(expression: string, context: A1TransformContext): string { + const { ast } = parseFormula(expression); + if (ast === null) { + return expression; + } + return serializeA1Node(ast, context.positionContext); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaAst.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaAst.ts new file mode 100644 index 0000000000000..54426d5f1695d --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaAst.ts @@ -0,0 +1,156 @@ +import type { FormulaRowId, FormulaSourceSpan } from './formulaTypes'; + +/** + * Names that are part of the formula grammar and cannot be used as function names. + */ +export const FORMULA_RESERVED_NAMES: readonly string[] = [ + 'REF', + 'COLUMN', + 'ROW', + 'COLUMN_POSITION', + 'ROW_POSITION', + 'FIELD', + 'RANGE', + 'COLUMN_VALUES', + 'TRUE', + 'FALSE', +]; + +interface FormulaAstBase { + span: FormulaSourceSpan; +} + +export interface FormulaNumberLiteralNode extends FormulaAstBase { + type: 'numberLiteral'; + value: number; +} + +export interface FormulaStringLiteralNode extends FormulaAstBase { + type: 'stringLiteral'; + value: string; +} + +export interface FormulaBooleanLiteralNode extends FormulaAstBase { + type: 'booleanLiteral'; + value: boolean; +} + +/** + * Same-row reference: a bare identifier (`price`) or `FIELD("unit price")`. + */ +export interface FormulaFieldRefNode extends FormulaAstBase { + type: 'fieldRef'; + field: string; +} + +/** + * Per-axis selectors. Positional-ness is per-axis, not per-node, so mixed refs + * like the editor's `A$1` (stable column + positional row) stay encodable. + * Positions are 1-based: columns in visible column order, rows in + * sorted + filtered data-row order. + */ +export type FormulaColumnSelector = + | { kind: 'field'; field: string } // COLUMN("total") + | { kind: 'position'; index: number }; // COLUMN_POSITION(2) + +export type FormulaRowSelector = + | { kind: 'id'; id: FormulaRowId } // ROW("order-1001") | ROW(42) + | { kind: 'position'; index: number }; // ROW_POSITION(1) + +/** + * `REF(, )`. + */ +export interface FormulaCellRefNode extends FormulaAstBase { + type: 'cellRef'; + column: FormulaColumnSelector; + row: FormulaRowSelector; +} + +/** + * `RANGE(REF(...), REF(...))` — the inclusive rectangle between the two anchors, + * resolved against the position context at bind time. + */ +export interface FormulaRangeNode extends FormulaAstBase { + type: 'range'; + start: FormulaCellRefNode; + end: FormulaCellRefNode; +} + +/** + * `COLUMN_VALUES("total")` — every value of the field over the current + * sorted + filtered row set. + */ +export interface FormulaColumnValuesNode extends FormulaAstBase { + type: 'columnValues'; + field: string; +} + +export interface FormulaUnaryExpressionNode extends FormulaAstBase { + type: 'unaryExpression'; + operator: '-' | '+'; + operand: FormulaAstNode; +} + +export type FormulaBinaryOperator = + | '+' + | '-' + | '*' + | '/' + | '^' + | '&' + | '=' + | '<>' + | '<' + | '<=' + | '>' + | '>='; + +/** + * Binary operator precedence, lowest first. All binary operators are + * left-associative, including `^` (Excel-compatible: `2^3^2 = 64`). + * Unary `-`/`+` bind tighter than `^` (Excel-compatible: `-2^2 = 4`). + * Single source of truth for the parser and the serializer's minimal + * parenthesization — they must never diverge or round-trips corrupt. + */ +export const FORMULA_BINARY_PRECEDENCE: Record = { + '=': 1, + '<>': 1, + '<': 1, + '<=': 1, + '>': 1, + '>=': 1, + '&': 2, + '+': 3, + '-': 3, + '*': 4, + '/': 4, + '^': 5, +}; + +export interface FormulaBinaryExpressionNode extends FormulaAstBase { + type: 'binaryExpression'; + operator: FormulaBinaryOperator; + left: FormulaAstNode; + right: FormulaAstNode; +} + +export interface FormulaFunctionCallNode extends FormulaAstBase { + type: 'functionCall'; + /** + * Normalized to uppercase at parse time. + */ + name: string; + args: FormulaAstNode[]; +} + +export type FormulaAstNode = + | FormulaNumberLiteralNode + | FormulaStringLiteralNode + | FormulaBooleanLiteralNode + | FormulaFieldRefNode + | FormulaCellRefNode + | FormulaRangeNode + | FormulaColumnValuesNode + | FormulaUnaryExpressionNode + | FormulaBinaryExpressionNode + | FormulaFunctionCallNode; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.test.ts new file mode 100644 index 0000000000000..7998b28289171 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import { + getFormulaCompletionContext, + getFormulaCompletionTokens, + rankFormulaCompletions, + type FormulaCompletionToken, +} from './formulaCompletion'; +import { createFormulaFunctionRegistry, FORMULA_BUILT_IN_FUNCTIONS } from './formulaFunctions'; + +describe('getFormulaCompletionTokens', () => { + it('includes the built-in functions with metadata', () => { + const tokens = getFormulaCompletionTokens(); + const sum = tokens.find((token) => token.label === 'SUM'); + expect(sum).toMatchObject({ + kind: 'function', + callable: true, + category: 'Math', + signature: 'SUM(value1, value2, …)', + }); + expect(sum!.description).toBeTruthy(); + }); + + it('includes every built-in function', () => { + const tokens = getFormulaCompletionTokens(); + const functionLabels = new Set( + tokens.filter((token) => token.kind === 'function').map((token) => token.label), + ); + for (const definition of FORMULA_BUILT_IN_FUNCTIONS) { + expect(functionLabels.has(definition.name)).toEqual(true); + } + }); + + it('includes the special forms as callable references', () => { + const tokens = getFormulaCompletionTokens(); + const ref = tokens.find((token) => token.label === 'REF'); + expect(ref).toMatchObject({ + kind: 'specialForm', + callable: true, + signature: 'REF(column, row)', + }); + const columnValues = tokens.find((token) => token.label === 'COLUMN_VALUES'); + expect(columnValues?.kind).toEqual('specialForm'); + }); + + it('includes the boolean constants (not callable) and operators', () => { + const tokens = getFormulaCompletionTokens(); + const trueToken = tokens.find((token) => token.label === 'TRUE'); + expect(trueToken?.kind).toEqual('constant'); + expect(trueToken?.callable).toBeUndefined(); + const operators = tokens + .filter((token) => token.kind === 'operator') + .map((token) => token.label); + expect(operators).toEqual( + expect.arrayContaining(['+', '-', '*', '/', '^', '&', '=', '<', '<=', '<>']), + ); + }); + + it('surfaces custom functions and their user-supplied metadata', () => { + const registry = createFormulaFunctionRegistry([ + ...FORMULA_BUILT_IN_FUNCTIONS, + { + name: 'TAX', + minArgs: 1, + maxArgs: 1, + signature: 'TAX(amount)', + description: 'Applies the configured tax rate.', + category: 'Custom', + apply: () => 0, + }, + ]); + const tokens = getFormulaCompletionTokens(registry); + expect(tokens.find((token) => token.label === 'TAX')).toMatchObject({ + kind: 'function', + signature: 'TAX(amount)', + description: 'Applies the configured tax rate.', + category: 'Custom', + }); + }); + + it('derives a generic signature for a custom function without one', () => { + const registry = createFormulaFunctionRegistry([ + { name: 'DOUBLE', minArgs: 1, maxArgs: 1, apply: () => 0 }, + { name: 'BETWEEN', minArgs: 2, maxArgs: 3, apply: () => 0 }, + { name: 'JOIN', minArgs: 1, maxArgs: null, apply: () => 0 }, + ]); + const tokens = getFormulaCompletionTokens(registry); + expect(tokens.find((token) => token.label === 'DOUBLE')?.signature).toEqual('DOUBLE(value)'); + expect(tokens.find((token) => token.label === 'BETWEEN')?.signature).toEqual( + 'BETWEEN(value1, value2, [value3])', + ); + expect(tokens.find((token) => token.label === 'JOIN')?.signature).toEqual( + 'JOIN(value1, value2, …)', + ); + }); +}); + +describe('getFormulaCompletionContext', () => { + it('extracts the partial token and replace span at the caret', () => { + // expression: "SU" with caret at the end + const context = getFormulaCompletionContext('SU', 2); + expect(context.token).toEqual('SU'); + expect(context.replaceStart).toEqual(0); + expect(context.replaceEnd).toEqual(2); + expect(context.expectValue).toEqual(true); + expect(context.expectOperator).toEqual(false); + }); + + it('replaces the whole identifier even when the caret is mid-token', () => { + // "price" with caret after "pri" + const context = getFormulaCompletionContext('price', 3); + expect(context.token).toEqual('pri'); + expect(context.replaceStart).toEqual(0); + expect(context.replaceEnd).toEqual(5); + }); + + it('reports a value position after an opening parenthesis', () => { + const context = getFormulaCompletionContext('SUM(', 4); + expect(context.token).toEqual(''); + expect(context.expectValue).toEqual(true); + expect(context.expectOperator).toEqual(false); + expect(context.functionContext).toEqual({ name: 'SUM', argIndex: 0 }); + }); + + it('reports an operator position after a complete operand', () => { + const context = getFormulaCompletionContext('price ', 6); + expect(context.token).toEqual(''); + expect(context.expectOperator).toEqual(true); + expect(context.expectValue).toEqual(false); + }); + + it('reports a value position after a binary operator', () => { + const context = getFormulaCompletionContext('price + ', 8); + expect(context.expectValue).toEqual(true); + expect(context.expectOperator).toEqual(false); + }); + + it('counts the argument index from commas at the call depth', () => { + const context = getFormulaCompletionContext('IF(a > 1, ', 10); + expect(context.functionContext).toEqual({ name: 'IF', argIndex: 1 }); + }); + + it('returns the innermost named call for nested calls', () => { + // SUM(ROUND(pr| + const expression = 'SUM(ROUND(pr'; + const context = getFormulaCompletionContext(expression, expression.length); + expect(context.functionContext).toEqual({ name: 'ROUND', argIndex: 0 }); + expect(context.token).toEqual('pr'); + }); + + it('ignores commas inside a nested grouping for the outer argument index', () => { + // SUM((1, ... ) — grouping paren is unnamed; the comma belongs to it, not SUM + const expression = 'SUM((a, '; + const context = getFormulaCompletionContext(expression, expression.length); + // Top of stack is the unnamed grouping paren; the nearest named call is SUM at arg 0. + expect(context.functionContext).toEqual({ name: 'SUM', argIndex: 0 }); + }); + + it('detects the caret inside a terminated string literal', () => { + // FIELD("ab") with caret between the quotes + const expression = 'FIELD("ab")'; + const context = getFormulaCompletionContext(expression, 8); // inside "ab" + expect(context.insideString).toEqual(true); + }); + + it('detects the caret inside an unterminated string literal', () => { + const expression = 'FIELD("ab'; + const context = getFormulaCompletionContext(expression, expression.length); + expect(context.insideString).toEqual(true); + }); + + it('does not treat the caret right after a closing quote as inside the string', () => { + const expression = 'FIELD("ab")'; + const context = getFormulaCompletionContext(expression, 10); // right after closing quote + expect(context.insideString).toEqual(false); + }); + + it('clamps an out-of-range caret', () => { + expect(() => getFormulaCompletionContext('SUM', 99)).not.toThrow(); + expect(getFormulaCompletionContext('SUM', 99).token).toEqual('SUM'); + }); +}); + +describe('rankFormulaCompletions', () => { + const tokens = getFormulaCompletionTokens(); + const fieldTokens: FormulaCompletionToken[] = [ + { label: 'price', insertText: 'price', kind: 'field' }, + { label: 'priceTotal', insertText: 'priceTotal', kind: 'field' }, + { label: 'quantity', insertText: 'quantity', kind: 'field' }, + ]; + const all = [...tokens, ...fieldTokens]; + + it('ranks prefix matches and tiers fields/functions above special forms', () => { + const context = getFormulaCompletionContext('SU', 2); + const ranked = rankFormulaCompletions(all, context); + expect(ranked[0].label).toEqual('SUM'); + }); + + it('places matching fields before functions on equal prefix strength', () => { + const context = getFormulaCompletionContext('pri', 3); + const ranked = rankFormulaCompletions(all, context); + expect(ranked.map((token) => token.label)).toEqual( + expect.arrayContaining(['price', 'priceTotal']), + ); + // Fields outrank everything else for the same prefix. + expect(ranked[0].kind).toEqual('field'); + }); + + it('suppresses operators in a value position', () => { + const context = getFormulaCompletionContext('SUM(', 4); + const ranked = rankFormulaCompletions(all, context); + expect(ranked.some((token) => token.kind === 'operator')).toEqual(false); + }); + + it('returns nothing in an operator position with no typed prefix', () => { + const context = getFormulaCompletionContext('price ', 6); + expect(rankFormulaCompletions(all, context)).toEqual([]); + }); + + it('returns nothing inside a string literal', () => { + const context = getFormulaCompletionContext('FIELD("pr', 9); + expect(rankFormulaCompletions(all, context)).toEqual([]); + }); + + it('offers value tokens with an empty prefix right after a parenthesis', () => { + const context = getFormulaCompletionContext('SUM(', 4); + const ranked = rankFormulaCompletions(all, context); + expect(ranked.length).toBeGreaterThan(0); + expect(ranked.some((token) => token.kind === 'field')).toEqual(true); + }); + + it('honors the limit option', () => { + const context = getFormulaCompletionContext('', 0); + const ranked = rankFormulaCompletions(all, context, { limit: 3 }); + expect(ranked).toHaveLength(3); + }); + + it('is case-insensitive but prefers an exact-case prefix', () => { + const context = getFormulaCompletionContext('su', 2); + const ranked = rankFormulaCompletions(all, context); + // "sum" matches SUM case-insensitively; nothing matches case-sensitively, so SUM still wins. + expect(ranked[0].label).toEqual('SUM'); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.ts new file mode 100644 index 0000000000000..d6a1600d57c64 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaCompletion.ts @@ -0,0 +1,389 @@ +import { FORMULA_BINARY_PRECEDENCE, FORMULA_RESERVED_NAMES } from './formulaAst'; +import type { FormulaBinaryOperator } from './formulaAst'; +import { FORMULA_BUILT_IN_FUNCTIONS } from './formulaFunctions'; +import type { FormulaFunctionDefinition, FormulaFunctionRegistry } from './formulaFunctions'; +import { tokenizeFormula } from './formulaTokenizer'; +import type { FormulaToken } from './formulaTokenizer'; + +/** + * The category a completion token belongs to. Drives the ranking tier and lets + * the editor group/icon suggestions. `field` and `columnLetter` are produced by + * the grid adapter (the engine never sees columns) but live here so the pure + * ranker can tier them consistently with the static vocabulary. + */ +export type FormulaCompletionKind = + | 'function' + | 'specialForm' + | 'constant' + | 'operator' + | 'field' + | 'columnLetter'; + +export interface FormulaCompletionToken { + /** + * Text shown in the dropdown. + */ + label: string; + /** + * Text spliced at the caret. For `callable` tokens the editor appends `(` + * with the caret placed inside. + */ + insertText: string; + kind: FormulaCompletionKind; + /** + * Functions and special forms take arguments — inserting one opens a `(`. + */ + callable?: boolean; + /** + * One-line call signature, e.g. `SUM(value1, value2, …)`. Shown as signature + * help when the caret is inside the call and as a secondary line in the list. + */ + signature?: string; + description?: string; + category?: string; + /** + * Optional secondary text (a field's header name, or a column letter's field). + */ + detail?: string; +} + +export interface FormulaCompletionContext { + /** + * The partial identifier the caret sits in or right after — the prefix used + * for matching (`''` when the caret is not on an identifier). + */ + token: string; + /** + * Replace span in EXPRESSION coordinates (the source WITHOUT its leading `=`). + * Accepting a suggestion replaces `expression.slice(replaceStart, replaceEnd)`. + */ + replaceStart: number; + replaceEnd: number; + /** + * The caret is in a value position: the start of the expression, or right + * after `(`, `,` or a binary operator. Functions, fields and constants are + * offered; operators are suppressed. + */ + expectValue: boolean; + /** + * The caret follows a complete operand (`)`, a number, a string, an + * identifier): a binary operator is expected next, so value tokens are + * suppressed. + */ + expectOperator: boolean; + /** + * The caret is inside a string literal (terminated or not) — suggestions are + * suppressed entirely. + */ + insideString: boolean; + /** + * The innermost enclosing function/special-form call, for signature help. + * `argIndex` is the zero-based argument the caret is in. + */ + functionContext: { name: string; argIndex: number } | null; +} + +/** + * Signatures and descriptions of the canonical special forms (dedicated AST + * nodes, not registry functions — so they carry their metadata here). + */ +const SPECIAL_FORM_META: Record = { + REF: { + signature: 'REF(column, row)', + description: 'A single cell, by a column reference and a row reference.', + }, + COLUMN: { signature: 'COLUMN("field")', description: 'A column, by its field name.' }, + ROW: { signature: 'ROW(id)', description: 'A row, by its row id.' }, + COLUMN_POSITION: { + signature: 'COLUMN_POSITION(index)', + description: 'A column, by its 1-based position in the visible column order.', + }, + ROW_POSITION: { + signature: 'ROW_POSITION(index)', + description: 'A row, by its 1-based position in the sorted and filtered rows.', + }, + FIELD: { + signature: 'FIELD("field name")', + description: 'A same-row field by name. Use it for fields whose name is not a bare identifier.', + }, + RANGE: { + signature: 'RANGE(startRef, endRef)', + description: 'The inclusive rectangle of cells between two cell references.', + }, + COLUMN_VALUES: { + signature: 'COLUMN_VALUES("field")', + description: 'Every value of a field over the current sorted and filtered rows.', + }, +}; + +const CONSTANT_NAMES = ['TRUE', 'FALSE']; +const SPECIAL_FORM_NAMES = FORMULA_RESERVED_NAMES.filter((name) => !CONSTANT_NAMES.includes(name)); + +/** + * Falls back to a generic signature for custom functions that do not declare one. + */ +function buildDefaultSignature(definition: FormulaFunctionDefinition): string { + if (definition.maxArgs === null) { + return `${definition.name}(value1, value2, …)`; + } + if (definition.maxArgs <= 1) { + return `${definition.name}(value)`; + } + const params: string[] = []; + for (let i = 1; i <= definition.maxArgs; i += 1) { + const param = `value${i}`; + params.push(i > definition.minArgs ? `[${param}]` : param); + } + return `${definition.name}(${params.join(', ')})`; +} + +/** + * Builds the static completion vocabulary: registry functions (with whatever + * optional metadata they declare — custom functions included), the canonical + * special forms, the boolean constants and the binary operators. When no + * registry is passed the built-in function set is used. + */ +export function getFormulaCompletionTokens( + functions?: FormulaFunctionRegistry, +): FormulaCompletionToken[] { + const tokens: FormulaCompletionToken[] = []; + + const definitions: FormulaFunctionDefinition[] = functions + ? functions + .names() + .map((name) => functions.get(name)) + .filter((definition): definition is FormulaFunctionDefinition => definition !== undefined) + : [...FORMULA_BUILT_IN_FUNCTIONS]; + for (const definition of definitions) { + tokens.push({ + label: definition.name, + insertText: definition.name, + kind: 'function', + callable: true, + signature: definition.signature ?? buildDefaultSignature(definition), + description: definition.description, + category: definition.category ?? 'Functions', + }); + } + + for (const name of SPECIAL_FORM_NAMES) { + const meta = SPECIAL_FORM_META[name]; + tokens.push({ + label: name, + insertText: name, + kind: 'specialForm', + callable: true, + signature: meta?.signature, + description: meta?.description, + category: 'References', + }); + } + + for (const name of CONSTANT_NAMES) { + tokens.push({ label: name, insertText: name, kind: 'constant', category: 'Constants' }); + } + + for (const operator of Object.keys(FORMULA_BINARY_PRECEDENCE) as FormulaBinaryOperator[]) { + tokens.push({ label: operator, insertText: operator, kind: 'operator', category: 'Operators' }); + } + + return tokens; +} + +const VALUE_ENDING_TOKEN_TYPES = new Set(['number', 'string', 'identifier']); + +function isValueEndingToken(token: FormulaToken | null): boolean { + if (token === null) { + return false; + } + if (token.type === 'punctuation') { + return token.value === ')'; + } + return VALUE_ENDING_TOKEN_TYPES.has(token.type); +} + +/** + * Analyzes the caret in a formula expression (the source WITHOUT its leading + * `=`) and returns the partial token, replace span, coarse value/operator + * context, string-literal guard and enclosing call for signature help. Built on + * the never-throwing tokenizer, so partial and malformed input is safe. + */ +export function getFormulaCompletionContext( + expression: string, + caret: number, +): FormulaCompletionContext { + const clampedCaret = Math.max(0, Math.min(caret, expression.length)); + const { tokens, error } = tokenizeFormula(expression); + + // String-literal guard: the caret is inside an unterminated string (the + // tokenizer reports it as an error spanning to the end) or strictly inside a + // terminated string token (between the quotes). + let insideString = + error !== null && + error.message.startsWith('Unterminated string') && + clampedCaret > error.span.start; + if (!insideString) { + for (const token of tokens) { + if ( + token.type === 'string' && + clampedCaret > token.span.start && + clampedCaret < token.span.end + ) { + insideString = true; + break; + } + } + } + + // The partial identifier under the caret: an identifier token the caret is + // inside of or sits right at the end of. The whole token is replaced; the + // matching prefix is only the part the user has typed (start..caret). + let token = ''; + let replaceStart = clampedCaret; + let replaceEnd = clampedCaret; + for (const candidate of tokens) { + if ( + candidate.type === 'identifier' && + clampedCaret > candidate.span.start && + clampedCaret <= candidate.span.end + ) { + token = expression.slice(candidate.span.start, clampedCaret); + replaceStart = candidate.span.start; + replaceEnd = candidate.span.end; + break; + } + } + + const hasPartialToken = token !== ''; + const boundary = hasPartialToken ? replaceStart : clampedCaret; + let previousToken: FormulaToken | null = null; + for (const candidate of tokens) { + if (candidate.span.end <= boundary) { + previousToken = candidate; + } else { + break; + } + } + + const expectOperator = !insideString && !hasPartialToken && isValueEndingToken(previousToken); + const expectValue = !insideString && !expectOperator; + + // Enclosing-call stack for signature help: walk tokens that start before the + // caret, tracking parenthesis depth. An identifier immediately before a `(` + // names the call; commas advance the current call's argument index. + const callStack: { name: string | null; argIndex: number }[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const current = tokens[i]; + if (current.span.start >= clampedCaret) { + break; + } + if (current.type === 'punctuation') { + if (current.value === '(') { + const previous = tokens[i - 1]; + callStack.push({ + name: previous && previous.type === 'identifier' ? previous.value.toUpperCase() : null, + argIndex: 0, + }); + } else if (current.value === ')') { + callStack.pop(); + } else if (current.value === ',' && callStack.length > 0) { + callStack[callStack.length - 1].argIndex += 1; + } + } + } + let functionContext: FormulaCompletionContext['functionContext'] = null; + for (let i = callStack.length - 1; i >= 0; i -= 1) { + if (callStack[i].name !== null) { + functionContext = { name: callStack[i].name!, argIndex: callStack[i].argIndex }; + break; + } + } + + return { + token, + replaceStart, + replaceEnd, + expectValue, + expectOperator, + insideString, + functionContext, + }; +} + +export interface RankFormulaCompletionsOptions { + /** + * Maximum number of ranked suggestions to return. + * @default 8 + */ + limit?: number; +} + +const KIND_TIER: Record = { + field: 6, + function: 5, + specialForm: 3, + constant: 2, + operator: 2, + columnLetter: 1, +}; + +/** + * Prefix-match strength: an exact (case-sensitive) prefix beats a + * case-insensitive prefix beats a substring; no match scores 0. + */ +function prefixStrength(label: string, query: string, queryLower: string): number { + if (query === '') { + return 1; + } + if (label.startsWith(query)) { + return 4; + } + const labelLower = label.toLowerCase(); + if (labelLower.startsWith(queryLower)) { + return 3; + } + if (labelLower.includes(queryLower)) { + return 2; + } + return 0; +} + +/** + * Pure ranking: filters the token list by the caret context's partial token and + * orders by `prefix-match strength × category tier`. Suppresses operators in a + * value position, value tokens in an operator position, and everything inside a + * string literal or in an operator position with no typed prefix. + */ +export function rankFormulaCompletions( + tokens: readonly FormulaCompletionToken[], + context: FormulaCompletionContext, + options: RankFormulaCompletionsOptions = {}, +): FormulaCompletionToken[] { + if (context.insideString) { + return []; + } + const query = context.token; + // After a complete operand with no typed prefix there is nothing useful to + // offer (the user needs an operator, which letters never match). + if (context.expectOperator && query === '') { + return []; + } + const queryLower = query.toLowerCase(); + + const scored: { token: FormulaCompletionToken; score: number }[] = []; + for (const token of tokens) { + const isOperator = token.kind === 'operator'; + if (isOperator !== context.expectOperator) { + // Operators only in operator position; value tokens only in value position. + continue; + } + const strength = prefixStrength(token.label, query, queryLower); + if (strength === 0) { + continue; + } + scored.push({ token, score: strength * 100 + KIND_TIER[token.kind] }); + } + + scored.sort((a, b) => b.score - a.score || a.token.label.localeCompare(b.token.label)); + return scored.slice(0, options.limit ?? 8).map((entry) => entry.token); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.test.ts new file mode 100644 index 0000000000000..040a3ad1e4bc9 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.test.ts @@ -0,0 +1,190 @@ +import { bindFormulaDependencies, extractFormulaDependencies } from './formulaDependencies'; +import type { FormulaStaticDependencies } from './formulaDependencies'; +import { parseFormula } from './formulaParser'; +import { createFormulaCellKey } from './formulaTypes'; +import { createTestPositionContext } from './testUtils'; + +const extract = (expression: string): FormulaStaticDependencies => { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return extractFormulaDependencies(ast); +}; + +describe('formulaDependencies', () => { + describe('extractFormulaDependencies', () => { + it('collects same-row field refs', () => { + const deps = extract('price * quantity + price'); + expect(Array.from(deps.fieldRefs).sort()).to.deep.equal(['price', 'quantity']); + expect(deps.usesPositionContext).to.equal(false); + }); + + it('collects field refs nested in calls and operators', () => { + const deps = extract('IF(a > 1, SUM(b, -c), FIELD("d e"))'); + expect(Array.from(deps.fieldRefs).sort()).to.deep.equal(['a', 'b', 'c', 'd e']); + }); + + it('collects explicit cell refs', () => { + const deps = extract('REF(COLUMN("total"), ROW("r1")) + REF(COLUMN("total"), ROW("r2"))'); + expect(deps.cellRefs).to.have.length(2); + expect(deps.cellRefs[0]).to.include({ type: 'cellRef' }); + expect(deps.usesPositionContext).to.equal(false); + }); + + it('flags positional selectors as position-context-dependent', () => { + expect(extract('REF(COLUMN("a"), ROW_POSITION(1))').usesPositionContext).to.equal(true); + expect(extract('REF(COLUMN_POSITION(1), ROW("r1"))').usesPositionContext).to.equal(true); + }); + + it('flags ranges and column slices as position-context-dependent', () => { + expect( + extract('SUM(RANGE(REF(COLUMN("a"), ROW(1)), REF(COLUMN("a"), ROW(2))))') + .usesPositionContext, + ).to.equal(true); + const deps = extract('SUM(COLUMN_VALUES("price"))'); + expect(deps.usesPositionContext).to.equal(true); + expect(Array.from(deps.columnValues)).to.deep.equal(['price']); + }); + + it('collects uppercase function names', () => { + const deps = extract('if(sum(a), nope(b), 1)'); + expect(Array.from(deps.calls).sort()).to.deep.equal(['IF', 'NOPE', 'SUM']); + }); + + it('collects nothing from pure literals', () => { + const deps = extract('1 + 2 & "a"'); + expect(deps.fieldRefs.size).to.equal(0); + expect(deps.cellRefs).to.have.length(0); + expect(deps.calls.size).to.equal(0); + expect(deps.usesPositionContext).to.equal(false); + }); + }); + + describe('bindFormulaDependencies', () => { + const owner = { id: 'r2', field: 'total' }; + // Position context: rows r1, r2, r3 (in that order), fields a, b, c. + const context = createTestPositionContext(['r1', 'r2', 'r3'], ['a', 'b', 'c']); + + it('binds same-row field refs to the owner row', () => { + const bound = bindFormulaDependencies(owner, extract('a + b'), context); + expect(Array.from(bound.cells).sort()).to.deep.equal([ + createFormulaCellKey('r2', 'a'), + createFormulaCellKey('r2', 'b'), + ]); + expect(bound.errors).to.have.length(0); + }); + + it('binds stable cell refs without consulting positions', () => { + // A stable ref to a row id missing from the position context still binds: + // existence is an evaluation concern. + const bound = bindFormulaDependencies( + owner, + extract('REF(COLUMN("a"), ROW("filtered-out"))'), + context, + ); + expect(Array.from(bound.cells)).to.deep.equal([createFormulaCellKey('filtered-out', 'a')]); + expect(bound.errors).to.have.length(0); + }); + + it('binds positional selectors through the context', () => { + const bound = bindFormulaDependencies( + owner, + extract('REF(COLUMN_POSITION(2), ROW_POSITION(3))'), + context, + ); + expect(Array.from(bound.cells)).to.deep.equal([createFormulaCellKey('r3', 'b')]); + }); + + it('records #REF! for out-of-bounds positional selectors', () => { + const bound = bindFormulaDependencies( + owner, + extract('REF(COLUMN("a"), ROW_POSITION(5)) + REF(COLUMN_POSITION(9), ROW("r1"))'), + context, + ); + expect(bound.cells.size).to.equal(0); + expect(bound.errors).to.have.length(2); + expect(bound.errors[0].code).to.equal('#REF!'); + expect(bound.errors[1].code).to.equal('#REF!'); + }); + + it('binds ranges to column interval records, never exploded cells', () => { + const bound = bindFormulaDependencies( + owner, + extract('SUM(RANGE(REF(COLUMN("a"), ROW("r1")), REF(COLUMN("b"), ROW("r3"))))'), + context, + ); + expect(bound.cells.size).to.equal(0); + expect(bound.columnIntervals).to.deep.equal([ + { field: 'a', fromIndex: 1, toIndex: 3 }, + { field: 'b', fromIndex: 1, toIndex: 3 }, + ]); + }); + + it('normalizes inverted range anchors', () => { + const bound = bindFormulaDependencies( + owner, + extract('SUM(RANGE(REF(COLUMN("b"), ROW("r3")), REF(COLUMN("a"), ROW("r1"))))'), + context, + ); + expect(bound.columnIntervals).to.deep.equal([ + { field: 'a', fromIndex: 1, toIndex: 3 }, + { field: 'b', fromIndex: 1, toIndex: 3 }, + ]); + }); + + it('resolves positional range anchors against the context', () => { + const bound = bindFormulaDependencies( + owner, + extract( + 'SUM(RANGE(REF(COLUMN_POSITION(1), ROW_POSITION(2)), REF(COLUMN("a"), ROW("r3"))))', + ), + context, + ); + expect(bound.columnIntervals).to.deep.equal([{ field: 'a', fromIndex: 2, toIndex: 3 }]); + }); + + it('records #REF! when a range anchor has no position', () => { + const bound = bindFormulaDependencies( + owner, + extract('SUM(RANGE(REF(COLUMN("a"), ROW("filtered-out")), REF(COLUMN("a"), ROW("r2"))))'), + context, + ); + expect(bound.columnIntervals).to.have.length(0); + expect(bound.errors).to.have.length(1); + expect(bound.errors[0].code).to.equal('#REF!'); + }); + + it('records #REF! when a range anchor column is hidden', () => { + const bound = bindFormulaDependencies( + owner, + extract('SUM(RANGE(REF(COLUMN("hidden"), ROW("r1")), REF(COLUMN("a"), ROW("r2"))))'), + context, + ); + expect(bound.errors).to.have.length(1); + expect(bound.errors[0].code).to.equal('#REF!'); + }); + + it('binds COLUMN_VALUES to whole-column records', () => { + const bound = bindFormulaDependencies( + owner, + extract('SUM(COLUMN_VALUES("a"), COLUMN_VALUES("b"))'), + context, + ); + expect(bound.wholeColumns).to.deep.equal([ + { field: 'a', whole: true }, + { field: 'b', whole: true }, + ]); + }); + + it('rebinds differently under a changed context', () => { + const deps = extract('REF(COLUMN("a"), ROW_POSITION(1))'); + const before = bindFormulaDependencies(owner, deps, context); + expect(Array.from(before.cells)).to.deep.equal([createFormulaCellKey('r1', 'a')]); + + const reordered = createTestPositionContext(['r3', 'r1', 'r2'], ['a', 'b', 'c'], 1); + const after = bindFormulaDependencies(owner, deps, reordered); + expect(Array.from(after.cells)).to.deep.equal([createFormulaCellKey('r3', 'a')]); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.ts new file mode 100644 index 0000000000000..f9bd086e4921c --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaDependencies.ts @@ -0,0 +1,317 @@ +import type { + FormulaAstNode, + FormulaCellRefNode, + FormulaColumnSelector, + FormulaRangeNode, + FormulaRowSelector, +} from './formulaAst'; +import { createFormulaCellKey } from './formulaTypes'; +import type { + FormulaCellKey, + FormulaCellRef, + FormulaPositionContext, + FormulaRowId, +} from './formulaTypes'; +import { createFormulaError, isFormulaErrorValue } from './formulaErrors'; +import type { FormulaErrorValue } from './formulaErrors'; + +/** + * Context-free dependency description, extracted with a pure AST walk. + * Positional selectors are reported structurally — they cannot become + * concrete cell keys until bound against a position context. + */ +export interface FormulaStaticDependencies { + /** + * Same-row field references; they bind to `(ownerRow, field)`. + */ + fieldRefs: Set; + /** + * Explicit `REF(...)` nodes, any selector mix. + */ + cellRefs: FormulaCellRefNode[]; + ranges: FormulaRangeNode[]; + columnValues: Set; + /** + * `true` when any positional selector, `RANGE` or `COLUMN_VALUES` is present — + * the formula must rebind when the position context changes. + */ + usesPositionContext: boolean; + /** + * Uppercase function names — used for `#NAME?` analysis and + * registry-change invalidation. + */ + calls: Set; +} + +export function extractFormulaDependencies(ast: FormulaAstNode): FormulaStaticDependencies { + const dependencies: FormulaStaticDependencies = { + fieldRefs: new Set(), + cellRefs: [], + ranges: [], + columnValues: new Set(), + usesPositionContext: false, + calls: new Set(), + }; + + const stack: FormulaAstNode[] = [ast]; + while (stack.length > 0) { + const node = stack.pop()!; + switch (node.type) { + case 'fieldRef': + dependencies.fieldRefs.add(node.field); + break; + case 'cellRef': + dependencies.cellRefs.push(node); + if (node.column.kind === 'position' || node.row.kind === 'position') { + dependencies.usesPositionContext = true; + } + break; + case 'range': + dependencies.ranges.push(node); + dependencies.usesPositionContext = true; + break; + case 'columnValues': + dependencies.columnValues.add(node.field); + dependencies.usesPositionContext = true; + break; + case 'functionCall': + dependencies.calls.add(node.name); + for (let i = node.args.length - 1; i >= 0; i -= 1) { + stack.push(node.args[i]); + } + break; + case 'unaryExpression': + stack.push(node.operand); + break; + case 'binaryExpression': + stack.push(node.right); + stack.push(node.left); + break; + default: + break; + } + } + + return dependencies; +} + +/** + * A bounded single-column slice of a range: rows `fromIndex..toIndex` + * (1-based, inclusive) of `field` in the position context's row order. + */ +export interface FormulaColumnIntervalDependency { + field: string; + fromIndex: number; + toIndex: number; +} + +/** + * A `COLUMN_VALUES` dependency on every row of `field` in the position + * context. The `whole: true` literal is a discriminant: it keeps interval + * records (otherwise structurally assignable to `{ field }`) out of + * `wholeColumns`, and lets interval and whole-column records be told apart + * when mixed in a single per-field list (the adapter's reverse range map). + */ +export interface FormulaWholeColumnDependency { + field: string; + whole: true; +} + +/** + * Dependencies resolved against a position-context snapshot. + * Ranges bind to interval records, never to exploded per-cell edges. + */ +export interface FormulaBoundDependencies { + cells: Set; + columnIntervals: FormulaColumnIntervalDependency[]; + wholeColumns: FormulaWholeColumnDependency[]; + /** + * Unresolvable references found during binding. Binding never throws; + * evaluation short-circuits to the first of these errors. + */ + errors: FormulaErrorValue[]; +} + +interface ResolvedAnchor { + columnIndex: number; + rowIndex: number; +} + +/** + * The normalized rectangle a `RANGE(...)` node spans in a position context. + * All indexes are 1-based and inclusive. + */ +export interface FormulaRangeRectangle { + fromColumn: number; + toColumn: number; + fromIndex: number; + toIndex: number; +} + +function resolveColumnIndex( + selector: FormulaColumnSelector, + context: FormulaPositionContext, +): number | FormulaErrorValue { + if (selector.kind === 'position') { + if (context.getFieldAtPosition(selector.index) === undefined) { + return createFormulaError('#REF!', `There is no column at position ${selector.index}.`); + } + return selector.index; + } + const index = context.getPositionOfField(selector.field); + if (index === undefined) { + return createFormulaError( + '#REF!', + `The column "${selector.field}" has no position in the current view.`, + ); + } + return index; +} + +function resolveRowIndex( + selector: FormulaRowSelector, + context: FormulaPositionContext, +): number | FormulaErrorValue { + if (selector.kind === 'position') { + if (context.getRowIdAtPosition(selector.index) === undefined) { + return createFormulaError('#REF!', `There is no row at position ${selector.index}.`); + } + return selector.index; + } + const index = context.getPositionOfRowId(selector.id); + if (index === undefined) { + return createFormulaError( + '#REF!', + `The row with id "${selector.id}" has no position in the current view.`, + ); + } + return index; +} + +function isErrorValue(value: number | FormulaErrorValue): value is FormulaErrorValue { + return typeof value !== 'number'; +} + +function resolveAnchor( + anchor: FormulaCellRefNode, + context: FormulaPositionContext, +): ResolvedAnchor | FormulaErrorValue { + const columnIndex = resolveColumnIndex(anchor.column, context); + if (isErrorValue(columnIndex)) { + return columnIndex; + } + const rowIndex = resolveRowIndex(anchor.row, context); + if (isErrorValue(rowIndex)) { + return rowIndex; + } + return { columnIndex, rowIndex }; +} + +/** + * Resolves a `RANGE` node's anchors against a position context and normalizes + * them (`RANGE(B5, A1)` spans the same rectangle as `RANGE(A1, B5)`). Shared + * by dependency binding and range materialization so the two can never + * disagree about the rectangle a range covers. + */ +export function resolveFormulaRangeRectangle( + range: FormulaRangeNode, + context: FormulaPositionContext, +): FormulaRangeRectangle | FormulaErrorValue { + const start = resolveAnchor(range.start, context); + if (isFormulaErrorValue(start)) { + return start; + } + const end = resolveAnchor(range.end, context); + if (isFormulaErrorValue(end)) { + return end; + } + return { + fromColumn: Math.min(start.columnIndex, end.columnIndex), + toColumn: Math.max(start.columnIndex, end.columnIndex), + fromIndex: Math.min(start.rowIndex, end.rowIndex), + toIndex: Math.max(start.rowIndex, end.rowIndex), + }; +} + +/** + * Resolves static dependencies into concrete cell keys and column records + * against a position-context snapshot. Stable cell refs (`ROW(id)` + + * `COLUMN(field)`) bind without consulting positions — a stable ref to a row + * that is currently filtered out still binds (its existence is checked at + * evaluation time). Only positional selectors and range anchors need the + * context. + */ +export function bindFormulaDependencies( + ownerCell: FormulaCellRef, + dependencies: FormulaStaticDependencies, + context: FormulaPositionContext, +): FormulaBoundDependencies { + const bound: FormulaBoundDependencies = { + cells: new Set(), + columnIntervals: [], + wholeColumns: [], + errors: [], + }; + + for (const field of dependencies.fieldRefs) { + bound.cells.add(createFormulaCellKey(ownerCell.id, field)); + } + + for (const cellRef of dependencies.cellRefs) { + let field: string | undefined; + if (cellRef.column.kind === 'field') { + field = cellRef.column.field; + } else { + field = context.getFieldAtPosition(cellRef.column.index); + if (field === undefined) { + bound.errors.push( + createFormulaError('#REF!', `There is no column at position ${cellRef.column.index}.`), + ); + continue; + } + } + + let id: FormulaRowId | undefined; + if (cellRef.row.kind === 'id') { + id = cellRef.row.id; + } else { + id = context.getRowIdAtPosition(cellRef.row.index); + if (id === undefined) { + bound.errors.push( + createFormulaError('#REF!', `There is no row at position ${cellRef.row.index}.`), + ); + continue; + } + } + + bound.cells.add(createFormulaCellKey(id, field)); + } + + for (const range of dependencies.ranges) { + const rectangle = resolveFormulaRangeRectangle(range, context); + if (isFormulaErrorValue(rectangle)) { + bound.errors.push(rectangle); + continue; + } + for ( + let columnIndex = rectangle.fromColumn; + columnIndex <= rectangle.toColumn; + columnIndex += 1 + ) { + const field = context.getFieldAtPosition(columnIndex); + if (field !== undefined) { + bound.columnIntervals.push({ + field, + fromIndex: rectangle.fromIndex, + toIndex: rectangle.toIndex, + }); + } + } + } + + for (const field of dependencies.columnValues) { + bound.wholeColumns.push({ field, whole: true }); + } + + return bound; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaErrors.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaErrors.ts new file mode 100644 index 0000000000000..2b0afbcf66d0e --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaErrors.ts @@ -0,0 +1,40 @@ +/** + * Error codes produced by formula parsing and evaluation. + * They follow the spreadsheet convention and are rendered as the cell content. + */ +export type FormulaErrorCode = '#REF!' | '#DIV/0!' | '#CYCLE!' | '#NAME?' | '#VALUE!' | '#ERROR!'; + +export const FORMULA_ERROR_CODES: readonly FormulaErrorCode[] = [ + '#REF!', + '#DIV/0!', + '#CYCLE!', + '#NAME?', + '#VALUE!', + '#ERROR!', +]; + +/** + * An error produced while evaluating a formula. + * Evaluation failures are values, never thrown exceptions. + */ +export interface FormulaErrorValue { + kind: 'error'; + code: FormulaErrorCode; + message?: string; +} + +export function createFormulaError(code: FormulaErrorCode, message?: string): FormulaErrorValue { + if (message === undefined) { + return { kind: 'error', code }; + } + return { kind: 'error', code, message }; +} + +export function isFormulaErrorValue(value: unknown): value is FormulaErrorValue { + return ( + typeof value === 'object' && + value !== null && + (value as { kind?: unknown }).kind === 'error' && + typeof (value as { code?: unknown }).code === 'string' + ); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.test.ts new file mode 100644 index 0000000000000..c1c28adb68758 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.test.ts @@ -0,0 +1,461 @@ +import { createFormulaError } from './formulaErrors'; +import { evaluateFormula } from './formulaEvaluator'; +import { parseFormula } from './formulaParser'; +import type { FormulaResult, FormulaScalar } from './formulaTypes'; +import { createTestContext } from './testUtils'; +import type { CreateTestContextOptions, TestRow } from './testUtils'; + +const ROWS: TestRow[] = [ + { id: 'r1', price: 50, quantity: 4, name: 'Chair', inStock: true, note: null }, + { id: 'r2', price: 200, quantity: 1, name: 'Desk', inStock: false, note: 'fragile' }, +]; + +function evaluate( + expression: string, + rows: TestRow[] = ROWS, + options: CreateTestContextOptions = {}, +): FormulaResult { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return evaluateFormula(ast, createTestContext(rows, undefined, options)); +} + +const expectValue = (expression: string, value: FormulaScalar) => { + expect(evaluate(expression)).to.deep.equal({ type: 'value', value }); +}; + +const expectErrorCode = (expression: string, code: string) => { + const result = evaluate(expression); + expect(result.type).to.equal('error'); + expect((result as { code: string }).code).to.equal(code); +}; + +describe('formulaEvaluator', () => { + describe('literals and operators', () => { + it('evaluates arithmetic with precedence', () => { + expectValue('1 + 2 * 3', 7); + expectValue('(1 + 2) * 3', 9); + expectValue('10 / 4', 2.5); + }); + + it('evaluates the Excel precedence quirks', () => { + expectValue('-2 ^ 2', 4); + expectValue('2 ^ -2', 0.25); + expectValue('2 ^ 3 ^ 2', 64); + }); + + it('evaluates string concatenation', () => { + expectValue('"a" & "b" & 1', 'ab1'); + expectValue('1 & 2', '12'); + expectValue('TRUE & "x"', 'TRUEx'); + }); + + it('evaluates comparisons', () => { + expectValue('1 < 2', true); + expectValue('"a" = "A"', true); + expectValue('1 <> 1', false); + expectValue('2 >= 3', false); + }); + + it('coerces operands numerically', () => { + expectValue('"5" + 1', 6); + expectValue('TRUE + 1', 2); + }); + + it('returns #DIV/0! for division by zero', () => { + expectErrorCode('1 / 0', '#DIV/0!'); + // Empty coerces to 0 in numeric context. + expectErrorCode('1 / note', '#DIV/0!'); + }); + + it('returns #VALUE! for failed coercion', () => { + expectErrorCode('"abc" + 1', '#VALUE!'); + expectErrorCode('1 < "a"', '#VALUE!'); + }); + + it('returns #VALUE! for non-finite results', () => { + expectErrorCode('2 ^ 10000', '#VALUE!'); + }); + + it('treats unary + as an identity operation (Excel behavior, no coercion)', () => { + expectValue('+"abc"', 'abc'); + expectValue('+TRUE', true); + expectValue('+"5"', '5'); + // Unary minus does coerce. + expectValue('-"5"', -5); + }); + }); + + describe('references', () => { + it('resolves same-row field refs against the current cell row', () => { + expectValue('price * quantity', 200); + }); + + it('resolves field refs for a different current cell', () => { + const result = evaluate('price * quantity', ROWS, { + currentCell: { id: 'r2', field: 'total' }, + }); + expect(result).to.deep.equal({ type: 'value', value: 200 }); + }); + + it('resolves FIELD("...") like a bare field ref', () => { + expectValue('FIELD("price") * 2', 100); + }); + + it('returns #REF! for an unknown field', () => { + const result = evaluate('missing + 1'); + expect(result).to.deep.equal({ + type: 'error', + code: '#REF!', + message: 'The field "missing" does not exist.', + }); + }); + + it('resolves stable cell refs through the resolver', () => { + expectValue('REF(COLUMN("price"), ROW("r2")) - REF(COLUMN("price"), ROW("r1"))', 150); + }); + + it('returns #REF! for a missing row id', () => { + const result = evaluate('REF(COLUMN("price"), ROW("missing"))'); + expect(result).to.deep.equal({ + type: 'error', + code: '#REF!', + message: 'The row with id "missing" does not exist.', + }); + }); + + it('returns #REF! for a missing field in a cell ref', () => { + expectErrorCode('REF(COLUMN("missing"), ROW("r1"))', '#REF!'); + }); + + it('treats empty cells as null (0 in numeric context)', () => { + expectValue('note + 5', 5); + }); + + it('resolves positional refs against the position context', () => { + // Default row order is the fixture order: r1 at position 1, r2 at 2. + expectValue('REF(COLUMN("price"), ROW_POSITION(1))', 50); + expectValue('REF(COLUMN("price"), ROW_POSITION(2))', 200); + // Default field order: price=1, quantity=2. + expectValue('REF(COLUMN_POSITION(1), ROW("r2"))', 200); + expectValue('REF(COLUMN_POSITION(2), ROW_POSITION(2))', 1); + }); + + it('resolves positional row refs in the supplied row order', () => { + const result = evaluate('REF(COLUMN("price"), ROW_POSITION(1))', ROWS, { + rowOrder: ['r2', 'r1'], + }); + expect(result).to.deep.equal({ type: 'value', value: 200 }); + }); + + it('returns #REF! for out-of-bounds positions', () => { + const result = evaluate('REF(COLUMN("price"), ROW_POSITION(3))'); + expect(result).to.deep.equal({ + type: 'error', + code: '#REF!', + message: 'There is no row at position 3.', + }); + expectErrorCode('REF(COLUMN_POSITION(99), ROW("r1"))', '#REF!'); + }); + }); + + describe('ranges', () => { + it('materializes single-column ranges', () => { + expectValue( + 'SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("price"), ROW("r2"))))', + 250, + ); + }); + + it('materializes rectangles across columns', () => { + expectValue( + 'SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("quantity"), ROW("r2"))))', + 255, + ); + }); + + it('normalizes reversed anchors', () => { + expectValue( + 'SUM(RANGE(REF(COLUMN("quantity"), ROW("r2")), REF(COLUMN("price"), ROW("r1"))))', + 255, + ); + }); + + it('accepts positional anchors', () => { + expectValue( + 'SUM(RANGE(REF(COLUMN_POSITION(1), ROW_POSITION(1)), REF(COLUMN_POSITION(2), ROW_POSITION(2))))', + 255, + ); + }); + + it('materializes rectangles row-major (left to right, then top to bottom)', () => { + expectValue( + 'CONCAT(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("quantity"), ROW("r2"))))', + '5042001', + ); + }); + + it('returns #REF! when an anchor row has no position in the current view', () => { + const result = evaluate( + 'SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("price"), ROW("r2"))))', + ROWS, + { rowOrder: ['r1'] }, + ); + expect(result).to.deep.equal({ + type: 'error', + code: '#REF!', + message: 'The row with id "r2" has no position in the current view.', + }); + }); + + it('materializes COLUMN_VALUES over the position-context rows in view order', () => { + expectValue('SUM(COLUMN_VALUES("price"))', 250); + expectValue('CONCAT(COLUMN_VALUES("name"))', 'ChairDesk'); + }); + + it('materializes COLUMN_VALUES over only the rows in the view', () => { + const result = evaluate('SUM(COLUMN_VALUES("price"))', ROWS, { rowOrder: ['r1'] }); + expect(result).to.deep.equal({ type: 'value', value: 50 }); + }); + + it('returns #REF! for COLUMN_VALUES of an unknown field', () => { + const result = evaluate('SUM(COLUMN_VALUES("missing"))'); + expect(result).to.deep.equal({ + type: 'error', + code: '#REF!', + message: 'The field "missing" does not exist.', + }); + }); + + it('skips empty cells in aggregating functions', () => { + // `note` is null on r1 — COUNTA only sees the non-empty cell. + expectValue('COUNTA(COLUMN_VALUES("note"))', 1); + expectValue('COUNT(COLUMN_VALUES("price"))', 2); + }); + + it('propagates the first error value inside a range', () => { + const rows: TestRow[] = [ + { id: 'r1', price: 50 }, + { id: 'r2', price: createFormulaError('#DIV/0!') }, + ]; + const result = evaluate('SUM(COLUMN_VALUES("price"))', rows); + expect((result as { code: string }).code).to.equal('#DIV/0!'); + }); + + it('returns #VALUE! for a range in scalar position', () => { + expectErrorCode('1 + COLUMN_VALUES("price")', '#VALUE!'); + expectErrorCode('ABS(COLUMN_VALUES("price"))', '#VALUE!'); + }); + + it('rejects ranges in lazy scalar positions', () => { + // The range gate applies at thunk resolution time for lazy functions. + expectErrorCode('IF(TRUE, COLUMN_VALUES("price"), 0)', '#VALUE!'); + // IFERROR catches the gate error produced by its own lazy argument. + expectValue('IFERROR(COLUMN_VALUES("price"), "fallback")', 'fallback'); + }); + + it('returns #VALUE! for a range as the formula result', () => { + const result = evaluate('COLUMN_VALUES("price")'); + expect(result).to.deep.equal({ + type: 'error', + code: '#VALUE!', + message: 'A range cannot be the result of a formula.', + }); + }); + }); + + describe('error propagation', () => { + it('propagates the first error left-to-right', () => { + // Left operand fails with #REF!, right would fail with #DIV/0!. + const result = evaluate('missing + 1 / 0'); + expect((result as { code: string }).code).to.equal('#REF!'); + }); + + it('propagates dependency errors through eager function arguments', () => { + expectErrorCode('SUM(1, missing)', '#REF!'); + }); + + it('propagates errors through unary operators', () => { + expectErrorCode('-missing', '#REF!'); + }); + }); + + describe('functions', () => { + it('evaluates aggregates over scalar arguments', () => { + expectValue('SUM(1, 2, 3)', 6); + expectValue('AVERAGE(2, 4)', 3); + expectValue('MIN(3, 1, 2)', 1); + expectValue('MAX(3, 1, 2)', 3); + expectValue('COUNT(1, "a", 2)', 2); + expectValue('COUNTA(1, "a", note)', 2); + }); + + it('skips empty cells in aggregates', () => { + expectValue('SUM(1, note, 2)', 3); + expectValue('AVERAGE(4, note)', 4); + }); + + it('evaluates math functions', () => { + expectValue('ROUND(2.345, 2)', 2.35); + expectValue('ROUND(2.5)', 3); + expectValue('ROUND(-2.5)', -3); + expectValue('ABS(-3)', 3); + expectValue('MOD(-3, 2)', 1); + expectValue('POWER(2, 10)', 1024); + }); + + it('evaluates logical functions', () => { + // The default current cell is row r1 (price 50). + expectValue('IF(price > 100, "expensive", "cheap")', 'cheap'); + expectValue('IF(price > 40, "expensive", "cheap")', 'expensive'); + expectValue('AND(TRUE, 1, "true")', true); + expectValue('AND(TRUE, FALSE)', false); + expectValue('OR(FALSE, 0)', false); + expectValue('OR(FALSE, 1)', true); + expectValue('NOT(FALSE)', true); + }); + + it('returns FALSE for IF without an else branch', () => { + expectValue('IF(FALSE, 1)', false); + }); + + it('does not evaluate the untaken IF branch (laziness)', () => { + const reads: string[] = []; + const result = evaluate('IF(TRUE, price, missing + 1 / 0)', ROWS, { + onGetCellValue: (ref) => reads.push(ref.field), + }); + expect(result).to.deep.equal({ type: 'value', value: 50 }); + expect(reads).to.deep.equal(['price']); + }); + + it('short-circuits AND/OR', () => { + const reads: string[] = []; + evaluate('OR(TRUE, price)', ROWS, { onGetCellValue: (ref) => reads.push(ref.field) }); + expect(reads).to.deep.equal([]); + }); + + it('lets IFERROR swallow errors', () => { + expectValue('IFERROR(1 / 0, "fallback")', 'fallback'); + expectValue('IFERROR(5, "fallback")', 5); + expectValue('IFERROR(missing, 0)', 0); + }); + + it('lets ISBLANK observe errors as non-blank', () => { + expectValue('ISBLANK(note)', true); + expectValue('ISBLANK(price)', false); + expectValue('ISBLANK(1 / 0)', false); + }); + + it('evaluates text functions', () => { + expectValue('CONCAT("a", 1, TRUE)', 'a1TRUE'); + expectValue('CONCATENATE("a", "b")', 'ab'); + expectValue('LEN("abc")', 3); + expectValue('UPPER("abc")', 'ABC'); + expectValue('LOWER("ABC")', 'abc'); + expectValue('TRIM(" a b ")', 'a b'); + expectValue('LEFT("abcdef", 2)', 'ab'); + expectValue('LEFT("abcdef")', 'a'); + expectValue('RIGHT("abcdef", 2)', 'ef'); + expectValue('RIGHT("abcdef", 0)', ''); + }); + + it('returns #NAME? for unknown functions', () => { + const result = evaluate('NOPE(1)'); + expect(result).to.deep.equal({ + type: 'error', + code: '#NAME?', + message: 'Unknown function "NOPE".', + }); + }); + + it('returns #VALUE! for arity violations', () => { + expectErrorCode('ABS()', '#VALUE!'); + expectErrorCode('ABS(1, 2)', '#VALUE!'); + expectErrorCode('IF(TRUE)', '#VALUE!'); + }); + + it('rejects negative character counts in LEFT/RIGHT', () => { + expectErrorCode('LEFT("abc", -1)', '#VALUE!'); + }); + + it('supports custom functions', () => { + const result = evaluate('DOUBLE(price)', ROWS, { + customFunctions: [ + { + name: 'DOUBLE', + minArgs: 1, + maxArgs: 1, + apply: (args, context) => { + const value = context.coerce.toNumber(args[0] as never); + return typeof value === 'number' ? value * 2 : value; + }, + }, + ], + }); + expect(result).to.deep.equal({ type: 'value', value: 100 }); + }); + + it('returns #VALUE! when a function result overflows to a non-finite number', () => { + const sumOverflow = evaluate('SUM(1e308, 1e308)'); + expect(sumOverflow).to.deep.equal({ + type: 'error', + code: '#VALUE!', + message: 'SUM() produced a non-finite number.', + }); + expectErrorCode('POWER(2, 10000)', '#VALUE!'); + // The non-finite gate also covers consumption by other operators. + expectErrorCode('SUM(1e308, 1e308) & "x"', '#VALUE!'); + }); + + it('returns #VALUE! when a custom function returns a non-finite number', () => { + const result = evaluate('INF()', ROWS, { + customFunctions: [{ name: 'INF', minArgs: 0, maxArgs: 0, apply: () => Infinity }], + }); + expect(result).to.deep.equal({ + type: 'error', + code: '#VALUE!', + message: 'INF() produced a non-finite number.', + }); + }); + + it('wraps exceptions thrown by functions as #ERROR!', () => { + const result = evaluate('BOOM()', ROWS, { + customFunctions: [ + { + name: 'BOOM', + minArgs: 0, + maxArgs: 0, + apply: () => { + throw new Error('exploded'); + }, + }, + ], + }); + expect(result).to.deep.equal({ type: 'error', code: '#ERROR!', message: 'exploded' }); + }); + + it('normalizes an undefined function result to null', () => { + const result = evaluate('NOTHING()', ROWS, { + customFunctions: [ + { + name: 'NOTHING', + minArgs: 0, + maxArgs: 0, + apply: () => undefined as never, + }, + ], + }); + expect(result).to.deep.equal({ type: 'value', value: null }); + }); + }); + + describe('empty cell semantics', () => { + it('empty equals empty, not zero or the empty string', () => { + expectValue('note = note', true); + expectValue('note = 0', false); + expectValue('note = ""', false); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.ts new file mode 100644 index 0000000000000..b9cceefdd32ce --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaEvaluator.ts @@ -0,0 +1,400 @@ +import type { + FormulaAstNode, + FormulaBinaryExpressionNode, + FormulaCellRefNode, + FormulaColumnValuesNode, + FormulaFunctionCallNode, + FormulaRangeNode, +} from './formulaAst'; +import { resolveFormulaRangeRectangle } from './formulaDependencies'; +import { createFormulaError, isFormulaErrorValue } from './formulaErrors'; +import type { FormulaErrorValue } from './formulaErrors'; +import { isFormulaRangeValue } from './formulaTypes'; +import type { + FormulaCellRef, + FormulaPositionContext, + FormulaRangeValue, + FormulaResult, + FormulaRowId, + FormulaScalar, +} from './formulaTypes'; +import { + compareFormulaScalars, + isEmptyFormulaValue, + toFormulaBoolean, + toFormulaNumber, + toFormulaText, +} from './formulaValues'; +import { getFormulaFunctionArityError } from './formulaFunctions'; +import type { + FormulaFunctionArg, + FormulaFunctionCoercionHelpers, + FormulaFunctionRegistry, +} from './formulaFunctions'; + +/** + * The resolver and context the grid adapter supplies for one evaluation. + * + * Contract: `getCellValue` is side-effect free and never re-enters the engine. + * The adapter evaluates dirty cells in topological order, so a dependency's + * value is already final when its dependents read it — formula dependencies + * resolve from the in-pass results cache, raw dependencies from row data + * (with `valueGetter` applied). + */ +export interface FormulaEvaluationContext { + currentCell: FormulaCellRef; + getCellValue: (ref: FormulaCellRef) => FormulaScalar | FormulaErrorValue | undefined; + hasRow: (id: FormulaRowId) => boolean; + hasField: (field: string) => boolean; + position: FormulaPositionContext; + functions: FormulaFunctionRegistry; +} + +type EvaluatedValue = FormulaScalar | FormulaErrorValue | FormulaRangeValue; + +const COERCION_HELPERS: FormulaFunctionCoercionHelpers = { + toNumber: toFormulaNumber, + toText: toFormulaText, + toBoolean: toFormulaBoolean, + isEmpty: isEmptyFormulaValue, + compare: compareFormulaScalars, +}; + +function normalizeResolvedValue( + value: FormulaScalar | FormulaErrorValue | undefined, +): FormulaScalar | FormulaErrorValue { + return value === undefined ? null : value; +} + +function evaluateCellRef( + node: FormulaCellRefNode, + context: FormulaEvaluationContext, +): FormulaScalar | FormulaErrorValue { + let field: string; + if (node.column.kind === 'field') { + field = node.column.field; + } else { + // The same message dependency binding produces for the same failure — + // which of the two surfaces depends only on evaluation order. + const resolvedField = context.position.getFieldAtPosition(node.column.index); + if (resolvedField === undefined) { + return createFormulaError('#REF!', `There is no column at position ${node.column.index}.`); + } + field = resolvedField; + } + let id: FormulaRowId; + if (node.row.kind === 'id') { + id = node.row.id; + } else { + const resolvedId = context.position.getRowIdAtPosition(node.row.index); + if (resolvedId === undefined) { + return createFormulaError('#REF!', `There is no row at position ${node.row.index}.`); + } + id = resolvedId; + } + if (!context.hasField(field)) { + return createFormulaError('#REF!', `The field "${field}" does not exist.`); + } + if (!context.hasRow(id)) { + return createFormulaError('#REF!', `The row with id "${id}" does not exist.`); + } + return normalizeResolvedValue(context.getCellValue({ id, field })); +} + +/** + * Materializes a `RANGE(...)` rectangle into a flat value list, row-major + * (left to right, then top to bottom). The first error value inside the + * rectangle propagates, consistent with the strict propagation rule. + */ +function evaluateRange( + node: FormulaRangeNode, + context: FormulaEvaluationContext, +): FormulaRangeValue | FormulaErrorValue { + const rectangle = resolveFormulaRangeRectangle(node, context.position); + if (isFormulaErrorValue(rectangle)) { + return rectangle; + } + const values: FormulaScalar[] = []; + for (let rowIndex = rectangle.fromIndex; rowIndex <= rectangle.toIndex; rowIndex += 1) { + // Anchor rows resolved, so every position between them exists. + const id = context.position.getRowIdAtPosition(rowIndex); + if (id === undefined) { + continue; + } + for ( + let columnIndex = rectangle.fromColumn; + columnIndex <= rectangle.toColumn; + columnIndex += 1 + ) { + const field = context.position.getFieldAtPosition(columnIndex); + if (field === undefined) { + continue; + } + const value = context.getCellValue({ id, field }); + if (isFormulaErrorValue(value)) { + return value; + } + values.push(normalizeResolvedValue(value) as FormulaScalar); + } + } + return { kind: 'range', values }; +} + +/** + * Materializes `COLUMN_VALUES("field")`: the field's values over every row of + * the position context (sorted + filtered data rows), in view order. The + * field does not need a position — hidden columns still hold values. + */ +function evaluateColumnValues( + node: FormulaColumnValuesNode, + context: FormulaEvaluationContext, +): FormulaRangeValue | FormulaErrorValue { + if (!context.hasField(node.field)) { + return createFormulaError('#REF!', `The field "${node.field}" does not exist.`); + } + const values: FormulaScalar[] = []; + for (let rowIndex = 1; rowIndex <= context.position.rowCount; rowIndex += 1) { + const id = context.position.getRowIdAtPosition(rowIndex); + if (id === undefined) { + continue; + } + const value = context.getCellValue({ id, field: node.field }); + if (isFormulaErrorValue(value)) { + return value; + } + values.push(normalizeResolvedValue(value) as FormulaScalar); + } + return { kind: 'range', values }; +} + +function evaluateBinaryExpression( + node: FormulaBinaryExpressionNode, + context: FormulaEvaluationContext, +): FormulaScalar | FormulaErrorValue { + // Strict left-to-right: the first error wins. + const left = evaluateNode(node.left, context); + if (isFormulaErrorValue(left)) { + return left; + } + if (isFormulaRangeValue(left)) { + return createFormulaError('#VALUE!', 'A range cannot be used in an expression.'); + } + const right = evaluateNode(node.right, context); + if (isFormulaErrorValue(right)) { + return right; + } + if (isFormulaRangeValue(right)) { + return createFormulaError('#VALUE!', 'A range cannot be used in an expression.'); + } + + switch (node.operator) { + case '=': + case '<>': + case '<': + case '<=': + case '>': + case '>=': + return compareFormulaScalars(node.operator, left, right); + case '&': { + const leftText = toFormulaText(left); + if (isFormulaErrorValue(leftText)) { + return leftText; + } + const rightText = toFormulaText(right); + if (isFormulaErrorValue(rightText)) { + return rightText; + } + return leftText + rightText; + } + default: { + const leftNumber = toFormulaNumber(left); + if (isFormulaErrorValue(leftNumber)) { + return leftNumber; + } + const rightNumber = toFormulaNumber(right); + if (isFormulaErrorValue(rightNumber)) { + return rightNumber; + } + let result: number; + switch (node.operator) { + case '+': + result = leftNumber + rightNumber; + break; + case '-': + result = leftNumber - rightNumber; + break; + case '*': + result = leftNumber * rightNumber; + break; + case '/': + if (rightNumber === 0) { + return createFormulaError('#DIV/0!'); + } + result = leftNumber / rightNumber; + break; + default: + result = leftNumber ** rightNumber; + break; + } + if (!Number.isFinite(result)) { + return createFormulaError('#VALUE!', 'The operation produced a non-finite number.'); + } + return result; + } + } +} + +function evaluateFunctionCall( + node: FormulaFunctionCallNode, + context: FormulaEvaluationContext, +): FormulaScalar | FormulaErrorValue { + const definition = context.functions.get(node.name); + if (definition === undefined) { + return createFormulaError('#NAME?', `Unknown function "${node.name}".`); + } + + const arityError = getFormulaFunctionArityError(definition, node.args.length); + if (arityError !== null) { + return arityError; + } + + let args: FormulaFunctionArg[]; + if (definition.lazy) { + // The range gate applies at thunk resolution time, so lazy custom + // functions cannot observe ranges in scalar position either. Errors pass + // through raw — lazy functions control their own propagation. + args = node.args.map((argNode) => () => { + const value = evaluateNode(argNode, context); + if (isFormulaRangeValue(value) && !definition.acceptsRanges) { + return createFormulaError( + '#VALUE!', + `${definition.name}() does not accept range arguments.`, + ); + } + return value; + }); + } else { + args = []; + for (const argNode of node.args) { + const value = evaluateNode(argNode, context); + if (isFormulaErrorValue(value) && !definition.acceptsErrors) { + return value; + } + if (isFormulaRangeValue(value) && !definition.acceptsRanges) { + return createFormulaError( + '#VALUE!', + `${definition.name}() does not accept range arguments.`, + ); + } + args.push(value); + } + } + + let result: ReturnType; + try { + result = definition.apply(args, { + coerce: COERCION_HELPERS, + currentCell: context.currentCell, + }); + } catch (error) { + return createFormulaError( + '#ERROR!', + error instanceof Error ? error.message : 'The function threw an error.', + ); + } + // Uniform non-finite gate for every function result (built-in overflow such + // as SUM(1e308, 1e308) and custom registry functions alike), consistent + // with the binary operators. + if (typeof result === 'number' && !Number.isFinite(result)) { + return createFormulaError('#VALUE!', `${definition.name}() produced a non-finite number.`); + } + return result === undefined ? null : result; +} + +function evaluateNode(node: FormulaAstNode, context: FormulaEvaluationContext): EvaluatedValue { + switch (node.type) { + case 'numberLiteral': + case 'stringLiteral': + case 'booleanLiteral': + return node.value; + case 'fieldRef': { + if (!context.hasField(node.field)) { + return createFormulaError('#REF!', `The field "${node.field}" does not exist.`); + } + return normalizeResolvedValue( + context.getCellValue({ id: context.currentCell.id, field: node.field }), + ); + } + case 'cellRef': + return evaluateCellRef(node, context); + case 'range': + return evaluateRange(node, context); + case 'columnValues': + return evaluateColumnValues(node, context); + case 'unaryExpression': { + const operand = evaluateNode(node.operand, context); + if (isFormulaErrorValue(operand)) { + return operand; + } + if (isFormulaRangeValue(operand)) { + return createFormulaError('#VALUE!', 'A range cannot be used in an expression.'); + } + // Excel-compatible: unary `+` is an identity operation and performs no + // numeric coercion (`+"abc"` stays "abc"); only unary `-` coerces. + if (node.operator === '+') { + return operand; + } + const numeric = toFormulaNumber(operand); + if (isFormulaErrorValue(numeric)) { + return numeric; + } + return -numeric; + } + case 'binaryExpression': + return evaluateBinaryExpression(node, context); + case 'functionCall': + return evaluateFunctionCall(node, context); + default: + return createFormulaError('#ERROR!', 'Unknown formula expression.'); + } +} + +/** + * Evaluates a parsed formula. Never throws: every failure mode is a + * `{ type: 'error' }` result carrying one of the spreadsheet error codes. + * `#CYCLE!` is never produced here — it is assigned by the graph layer. + */ +export function evaluateFormula( + ast: FormulaAstNode, + context: FormulaEvaluationContext, +): FormulaResult { + let value: EvaluatedValue; + try { + value = evaluateNode(ast, context); + } catch (error) { + // Backstop for the never-throws contract. The parser's depth bound makes + // a RangeError unreachable for parser-produced ASTs; hand-built ASTs and + // throwing adapter resolvers land here. + if (error instanceof RangeError) { + return { type: 'error', code: '#ERROR!', message: 'The formula is too complex to evaluate.' }; + } + return { + type: 'error', + code: '#ERROR!', + message: error instanceof Error ? error.message : 'The formula could not be evaluated.', + }; + } + if (isFormulaErrorValue(value)) { + return value.message === undefined + ? { type: 'error', code: value.code } + : { type: 'error', code: value.code, message: value.message }; + } + if (isFormulaRangeValue(value)) { + return { + type: 'error', + code: '#VALUE!', + message: 'A range cannot be the result of a formula.', + }; + } + return { type: 'value', value }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.test.ts new file mode 100644 index 0000000000000..8173d9fba5f65 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.test.ts @@ -0,0 +1,185 @@ +import { parseFormula } from './formulaParser'; +import { serializeFormulaAstToExcel, mapFormulaErrorCodeToExcel } from './formulaExcel'; +import type { FormulaExcelSerializeContext } from './formulaExcel'; +import type { FormulaAstNode } from './formulaAst'; + +const parseOk = (expression: string): FormulaAstNode => { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return ast; +}; + +// A 4-column × 3-row export: item→A, price→B, qty→C, total→D; data rows r1..r3 +// land on Excel rows 2..4 (a single header row). Positional indexes mirror the +// same letters/rows but are flagged absolute, exactly as the adapter resolves +// them. Field "missing"/row "rX"/out-of-range positions resolve to `null`. +const FIELD_LETTERS: Record = { item: 'A', price: 'B', qty: 'C', total: 'D' }; +const POSITION_LETTERS: Record = { 1: 'A', 2: 'B', 3: 'C', 4: 'D' }; +const ROW_NUMBERS: Record = { r1: 2, r2: 3, r3: 4 }; +const POSITION_ROWS: Record = { 1: 2, 2: 3, 3: 4 }; + +const context: FormulaExcelSerializeContext = { + resolveColumn: (selector) => { + if (selector.kind === 'field') { + const letter = FIELD_LETTERS[selector.field]; + return letter === undefined ? null : { letter, absolute: false }; + } + const letter = POSITION_LETTERS[selector.index]; + return letter === undefined ? null : { letter, absolute: true }; + }, + resolveRow: (selector) => { + if (selector.kind === 'id') { + const number = ROW_NUMBERS[String(selector.id)]; + return number === undefined ? null : { number, absolute: false }; + } + const number = POSITION_ROWS[selector.index]; + return number === undefined ? null : { number, absolute: true }; + }, + ownerRowNumber: 2, + firstDataRowNumber: 2, + lastDataRowNumber: 4, +}; + +const toExcel = (expression: string) => serializeFormulaAstToExcel(parseOk(expression), context); + +describe('serializeFormulaAstToExcel', () => { + describe('cell references', () => { + it('renders a stable ref as a relative A1 address', () => { + expect(toExcel('REF(COLUMN("price"), ROW("r1"))')).to.deep.equal({ + formula: 'B2', + hasRefError: false, + }); + }); + + it('renders a positional ref as an absolute A1 address', () => { + expect(toExcel('REF(COLUMN_POSITION(2), ROW_POSITION(1))').formula).to.equal('$B$2'); + }); + + it('renders mixed axes (absolute column, relative row)', () => { + expect(toExcel('REF(COLUMN_POSITION(2), ROW("r1"))').formula).to.equal('$B2'); + }); + + it('renders mixed axes (relative column, absolute row)', () => { + expect(toExcel('REF(COLUMN("price"), ROW_POSITION(1))').formula).to.equal('B$2'); + }); + + it('re-anchors each row to its export row number', () => { + expect(toExcel('REF(COLUMN("qty"), ROW("r3"))').formula).to.equal('C4'); + }); + }); + + describe('same-row field references', () => { + it('renders against the owner cell row', () => { + expect(toExcel('price * qty')).to.deep.equal({ formula: 'B2*C2', hasRefError: false }); + }); + }); + + describe('ranges and whole columns', () => { + it('renders a RANGE as start:end', () => { + expect( + toExcel('SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("price"), ROW("r3"))))') + .formula, + ).to.equal('SUM(B2:B4)'); + }); + + it('renders COLUMN_VALUES as a bounded data range (no header)', () => { + expect(toExcel('SUM(COLUMN_VALUES("total"))').formula).to.equal('SUM(D2:D4)'); + }); + }); + + describe('operators and precedence', () => { + it('omits parentheses when precedence allows', () => { + expect(toExcel('price + qty * total').formula).to.equal('B2+C2*D2'); + }); + + it('adds parentheses to preserve a lower-precedence left operand', () => { + expect(toExcel('(price + qty) * total').formula).to.equal('(B2+C2)*D2'); + }); + + it('adds parentheses to preserve left-associativity on the right', () => { + expect(toExcel('price - (qty - total)').formula).to.equal('B2-(C2-D2)'); + }); + + it('renders unary minus', () => { + expect(toExcel('-price').formula).to.equal('-B2'); + }); + + it('parenthesizes a compound unary operand', () => { + expect(toExcel('-(price + qty)').formula).to.equal('-(B2+C2)'); + }); + + it('renders comparison and concatenation operators', () => { + expect(toExcel('price > qty').formula).to.equal('B2>C2'); + expect(toExcel('price <> qty').formula).to.equal('B2<>C2'); + expect(toExcel('item & "x"').formula).to.equal('A2&"x"'); + }); + }); + + describe('function calls and literals', () => { + it('renders functions with comma-separated args', () => { + expect(toExcel('IF(price > qty, price, qty)').formula).to.equal('IF(B2>C2,B2,C2)'); + }); + + it('renders string, boolean and number literals', () => { + expect(toExcel('"hi"').formula).to.equal('"hi"'); + expect(toExcel('"a""b"').formula).to.equal('"a""b"'); + expect(toExcel('TRUE').formula).to.equal('TRUE'); + expect(toExcel('FALSE').formula).to.equal('FALSE'); + expect(toExcel('42').formula).to.equal('42'); + expect(toExcel('3.14').formula).to.equal('3.14'); + }); + }); + + describe('references outside the export → #REF!', () => { + it('bakes #REF! for a missing column', () => { + expect(toExcel('REF(COLUMN("missing"), ROW("r1"))')).to.deep.equal({ + formula: '#REF!', + hasRefError: true, + }); + }); + + it('bakes #REF! for a missing row', () => { + expect(toExcel('REF(COLUMN("price"), ROW("rX"))')).to.deep.equal({ + formula: '#REF!', + hasRefError: true, + }); + }); + + it('bakes #REF! for an out-of-range positional ref', () => { + expect(toExcel('REF(COLUMN_POSITION(9), ROW_POSITION(1))').hasRefError).to.equal(true); + }); + + it('bakes #REF! for an unknown same-row field', () => { + expect(toExcel('missing').formula).to.equal('#REF!'); + }); + + it('keeps the resolvable endpoint of a half-broken range', () => { + expect( + toExcel('SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("price"), ROW("rX"))))'), + ).to.deep.equal({ formula: 'SUM(B2:#REF!)', hasRefError: true }); + }); + + it('bakes #REF! into one operand and keeps the rest', () => { + expect(toExcel('price + REF(COLUMN("missing"), ROW("r1"))')).to.deep.equal({ + formula: 'B2+#REF!', + hasRefError: true, + }); + }); + }); +}); + +describe('mapFormulaErrorCodeToExcel', () => { + it('passes through codes Excel shares', () => { + expect(mapFormulaErrorCodeToExcel('#REF!')).to.equal('#REF!'); + expect(mapFormulaErrorCodeToExcel('#DIV/0!')).to.equal('#DIV/0!'); + expect(mapFormulaErrorCodeToExcel('#NAME?')).to.equal('#NAME?'); + expect(mapFormulaErrorCodeToExcel('#VALUE!')).to.equal('#VALUE!'); + }); + + it('maps engine-only codes to the nearest Excel error', () => { + expect(mapFormulaErrorCodeToExcel('#CYCLE!')).to.equal('#REF!'); + expect(mapFormulaErrorCodeToExcel('#ERROR!')).to.equal('#VALUE!'); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.ts new file mode 100644 index 0000000000000..d836e70da62de --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaExcel.ts @@ -0,0 +1,191 @@ +import { FORMULA_BINARY_PRECEDENCE } from './formulaAst'; +import type { FormulaAstNode, FormulaColumnSelector, FormulaRowSelector } from './formulaAst'; +import type { FormulaErrorCode } from './formulaErrors'; + +/** + * Excel's built-in error sentinels — the set `@mui/x-internal-exceljs-fork` + * accepts as a formula `result`. The engine's own `FormulaErrorCode`s are mapped + * onto these by `mapFormulaErrorCodeToExcel`. + */ +export type ExcelFormulaErrorCode = + | '#N/A' + | '#REF!' + | '#NAME?' + | '#DIV/0!' + | '#NULL!' + | '#VALUE!' + | '#NUM!'; + +/** + * Resolves a canonical reference axis to a coordinate in the *exported* sheet. + * `null` means the referenced column/row is not part of the export, so the + * converter bakes Excel's `#REF!` token into the formula at that position. + */ +export interface FormulaExcelSerializeContext { + resolveColumn: (selector: FormulaColumnSelector) => { letter: string; absolute: boolean } | null; + resolveRow: (selector: FormulaRowSelector) => { number: number; absolute: boolean } | null; + /** 1-based Excel row of the cell that owns the formula (for same-row `fieldRef`). */ + ownerRowNumber: number; + /** 1-based Excel row bounds of the exported data area (for whole-column `columnValues`). */ + firstDataRowNumber: number; + lastDataRowNumber: number; +} + +export interface FormulaExcelSerializeResult { + /** Excel formula string, without the leading `=`. */ + formula: string; + /** `true` when a reference fell outside the export and `#REF!` was baked in. */ + hasRefError: boolean; +} + +const EXCEL_REF_ERROR = '#REF!'; + +interface SerializeState { + hasRefError: boolean; +} + +function serializeExcelString(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +function resolveCellRef( + column: FormulaColumnSelector, + row: FormulaRowSelector, + context: FormulaExcelSerializeContext, + state: SerializeState, +): string { + const resolvedColumn = context.resolveColumn(column); + const resolvedRow = context.resolveRow(row); + // Excel collapses a cell reference with a deleted axis to `#REF!` entirely. + if (resolvedColumn === null || resolvedRow === null) { + state.hasRefError = true; + return EXCEL_REF_ERROR; + } + const columnPart = `${resolvedColumn.absolute ? '$' : ''}${resolvedColumn.letter}`; + const rowPart = `${resolvedRow.absolute ? '$' : ''}${resolvedRow.number}`; + return `${columnPart}${rowPart}`; +} + +/** + * Wraps the operand in parentheses when its precedence is below the minimum the + * surrounding context requires — the same minimal parenthesization the canonical + * serializer uses (`serializeFormulaAst`), so operator semantics never drift. + */ +function serializeOperand( + node: FormulaAstNode, + minPrecedence: number, + context: FormulaExcelSerializeContext, + state: SerializeState, +): string { + const text = serializeNode(node, context, state); + if ( + node.type === 'binaryExpression' && + FORMULA_BINARY_PRECEDENCE[node.operator] < minPrecedence + ) { + return `(${text})`; + } + return text; +} + +function serializeNode( + node: FormulaAstNode, + context: FormulaExcelSerializeContext, + state: SerializeState, +): string { + switch (node.type) { + case 'numberLiteral': + return String(node.value); + case 'stringLiteral': + return serializeExcelString(node.value); + case 'booleanLiteral': + return node.value ? 'TRUE' : 'FALSE'; + case 'fieldRef': { + // Same-row reference: the field's column on the owner cell's row, both relative. + const resolvedColumn = context.resolveColumn({ kind: 'field', field: node.field }); + if (resolvedColumn === null) { + state.hasRefError = true; + return EXCEL_REF_ERROR; + } + return `${resolvedColumn.letter}${context.ownerRowNumber}`; + } + case 'cellRef': + return resolveCellRef(node.column, node.row, context, state); + case 'range': { + // Each endpoint resolves independently, so a half-broken range reads + // `B2:#REF!`, exactly as Excel renders a deleted range endpoint. + const start = resolveCellRef(node.start.column, node.start.row, context, state); + const end = resolveCellRef(node.end.column, node.end.row, context, state); + return `${start}:${end}`; + } + case 'columnValues': { + const resolvedColumn = context.resolveColumn({ kind: 'field', field: node.field }); + if (resolvedColumn === null) { + state.hasRefError = true; + return EXCEL_REF_ERROR; + } + // Whole-column reference, bounded to the exported data rows (no header row). + return `${resolvedColumn.letter}${context.firstDataRowNumber}:${resolvedColumn.letter}${context.lastDataRowNumber}`; + } + case 'unaryExpression': { + const operand = serializeNode(node.operand, context, state); + if (node.operand.type === 'binaryExpression' || node.operand.type === 'unaryExpression') { + return `${node.operator}(${operand})`; + } + return `${node.operator}${operand}`; + } + case 'binaryExpression': { + const precedence = FORMULA_BINARY_PRECEDENCE[node.operator]; + const left = serializeOperand(node.left, precedence, context, state); + // +1 re-derives left-associativity: an equal-precedence right child needs parens. + const right = serializeOperand(node.right, precedence + 1, context, state); + return `${left}${node.operator}${right}`; + } + case 'functionCall': + return `${node.name}(${node.args.map((arg) => serializeNode(arg, context, state)).join(',')})`; + default: + return ''; + } +} + +/** + * Converts a canonical formula AST to an Excel A1 formula string (without the + * leading `=`), re-anchoring every reference to its coordinate in the *exported* + * sheet via `context`. References to cells outside the export bake Excel's + * `#REF!` token in place and set `hasRefError`. Pure: engine types only. + * + * Mirrors the grid's relative/absolute distinction: stable selectors emit + * relative refs (`B2`), positional selectors emit absolute refs (`$B$2`). The + * computed value is identical either way; `$` only governs copy/fill inside Excel. + */ +export function serializeFormulaAstToExcel( + ast: FormulaAstNode, + context: FormulaExcelSerializeContext, +): FormulaExcelSerializeResult { + const state: SerializeState = { hasRefError: false }; + const formula = serializeNode(ast, context, state); + return { formula, hasRefError: state.hasRefError }; +} + +/** + * Maps an engine error code to the nearest Excel error sentinel. `#CYCLE!` has + * no Excel equivalent (a circular reference is a reference problem → `#REF!`); + * the generic `#ERROR!` maps to `#VALUE!`. + */ +export function mapFormulaErrorCodeToExcel(code: FormulaErrorCode): ExcelFormulaErrorCode { + switch (code) { + case '#REF!': + return '#REF!'; + case '#DIV/0!': + return '#DIV/0!'; + case '#NAME?': + return '#NAME?'; + case '#VALUE!': + return '#VALUE!'; + case '#CYCLE!': + return '#REF!'; + case '#ERROR!': + return '#VALUE!'; + default: + return '#VALUE!'; + } +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.test.ts new file mode 100644 index 0000000000000..35d5d277e504d --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.test.ts @@ -0,0 +1,99 @@ +import { createFormulaFunctionRegistry, FORMULA_BUILT_IN_FUNCTIONS } from './formulaFunctions'; + +describe('formulaFunctions', () => { + describe('createFormulaFunctionRegistry', () => { + it('exposes every built-in by name', () => { + const registry = createFormulaFunctionRegistry(); + for (const definition of FORMULA_BUILT_IN_FUNCTIONS) { + expect(registry.get(definition.name)?.name).to.equal(definition.name); + } + expect(registry.names()).to.have.length(FORMULA_BUILT_IN_FUNCTIONS.length); + }); + + it('looks functions up case-insensitively', () => { + const registry = createFormulaFunctionRegistry(); + expect(registry.get('sum')?.name).to.equal('SUM'); + expect(registry.get('Sum')?.name).to.equal('SUM'); + }); + + it('registers CONCATENATE as an alias of CONCAT', () => { + const registry = createFormulaFunctionRegistry(); + expect(registry.get('CONCATENATE')?.apply).to.equal(registry.get('CONCAT')?.apply); + }); + + it('replaces the built-ins when a function set is provided', () => { + // The argument is the COMPLETE function set (cf. the aggregationFunctions + // prop): an empty set yields an empty registry. + expect(createFormulaFunctionRegistry([]).names()).to.deep.equal([]); + expect(createFormulaFunctionRegistry([]).get('SUM')).to.equal(undefined); + + const sum = FORMULA_BUILT_IN_FUNCTIONS.find((definition) => definition.name === 'SUM')!; + const curated = createFormulaFunctionRegistry([sum]); + expect(curated.names()).to.deep.equal(['SUM']); + expect(curated.get('POWER')).to.equal(undefined); + }); + + it('lets later definitions override earlier ones by name', () => { + const custom = { + name: 'SUM', + minArgs: 0, + maxArgs: 0, + apply: () => 42 as const, + }; + const registry = createFormulaFunctionRegistry([...FORMULA_BUILT_IN_FUNCTIONS, custom]); + expect(registry.get('SUM')).to.equal(custom); + }); + + it('normalizes custom function names to uppercase for lookup', () => { + const custom = { name: 'double', minArgs: 1, maxArgs: 1, apply: () => 0 }; + const registry = createFormulaFunctionRegistry([custom]); + expect(registry.get('DOUBLE')).to.equal(custom); + }); + + it('throws on reserved names', () => { + const reserved = [ + 'REF', + 'COLUMN', + 'ROW', + 'COLUMN_POSITION', + 'ROW_POSITION', + 'FIELD', + 'RANGE', + 'COLUMN_VALUES', + 'TRUE', + 'FALSE', + ]; + for (const name of reserved) { + expect(() => + createFormulaFunctionRegistry([{ name, minArgs: 0, maxArgs: 0, apply: () => 0 }]), + ).to.throw('reserved by the formula syntax'); + } + }); + + it('throws on reserved names regardless of case', () => { + expect(() => + createFormulaFunctionRegistry([{ name: 'ref', minArgs: 0, maxArgs: 0, apply: () => 0 }]), + ).to.throw('reserved by the formula syntax'); + }); + + it('throws on names the parser can never produce as a call', () => { + for (const name of ['MY FUNC', '2X', 'A-B', '']) { + expect(() => + createFormulaFunctionRegistry([{ name, minArgs: 0, maxArgs: 0, apply: () => 0 }]), + ).to.throw('is not a valid formula function name'); + } + // Lookup is case-insensitive, so lowercase and underscore names are reachable. + for (const name of ['double', '_x1']) { + expect(() => + createFormulaFunctionRegistry([{ name, minArgs: 0, maxArgs: 0, apply: () => 0 }]), + ).not.to.throw(); + } + }); + + it('declares no volatile built-ins', () => { + for (const definition of FORMULA_BUILT_IN_FUNCTIONS) { + expect(definition.volatile ?? false).to.equal(false); + } + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.ts new file mode 100644 index 0000000000000..ec6050411fe24 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaFunctions.ts @@ -0,0 +1,677 @@ +import { FORMULA_RESERVED_NAMES } from './formulaAst'; +import { createFormulaError, isFormulaErrorValue } from './formulaErrors'; +import type { FormulaErrorValue } from './formulaErrors'; +import { isFormulaRangeValue } from './formulaTypes'; +import type { FormulaCellRef, FormulaRangeValue, FormulaScalar } from './formulaTypes'; +import type { compareFormulaScalars } from './formulaValues'; +import { + isEmptyFormulaValue, + toFormulaBoolean, + toFormulaNumber, + toFormulaText, +} from './formulaValues'; + +/** + * Coercion helpers shared with function implementations, so that custom + * functions follow the same coercion rules as the built-ins. + */ +export interface FormulaFunctionCoercionHelpers { + toNumber: typeof toFormulaNumber; + toText: typeof toFormulaText; + toBoolean: typeof toFormulaBoolean; + isEmpty: typeof isEmptyFormulaValue; + compare: typeof compareFormulaScalars; +} + +export interface FormulaFunctionContext { + coerce: FormulaFunctionCoercionHelpers; + currentCell: FormulaCellRef; +} + +export type FormulaFunctionEagerArg = FormulaScalar | FormulaErrorValue | FormulaRangeValue; + +/** + * Eager functions receive resolved values; `lazy` functions receive thunks + * (so untaken `IF` branches are never evaluated). + */ +export type FormulaFunctionArg = FormulaFunctionEagerArg | (() => FormulaFunctionEagerArg); + +export interface FormulaFunctionDefinition { + /** + * Canonical uppercase name; lookup is case-insensitive. + */ + name: string; + minArgs: number; + /** + * `null` means variadic. + */ + maxArgs: number | null; + /** + * Arguments are delivered as thunks; the function controls their evaluation. + */ + lazy?: boolean; + /** + * The function may receive `FormulaRangeValue` arguments. + */ + acceptsRanges?: boolean; + /** + * Error arguments are passed through instead of short-circuiting (IFERROR, ISBLANK). + */ + acceptsErrors?: boolean; + /** + * Reserved: formulas calling a volatile function are re-evaluated on every + * recompute pass. No built-in is volatile. + */ + volatile?: boolean; + /** + * One-line call signature shown by the formula editor autocomplete, e.g. + * `SUM(value1, value2, …)`. Optional — a generic signature is derived from + * `minArgs`/`maxArgs` when omitted. + */ + signature?: string; + /** + * Short description shown by the formula editor autocomplete. + */ + description?: string; + /** + * Category label used to group the function in the formula editor autocomplete. + */ + category?: string; + apply: ( + args: FormulaFunctionArg[], + context: FormulaFunctionContext, + ) => FormulaScalar | FormulaErrorValue; +} + +export interface FormulaFunctionRegistry { + get: (name: string) => FormulaFunctionDefinition | undefined; + names: () => string[]; +} + +const RESERVED_NAME_SET = new Set(FORMULA_RESERVED_NAMES); + +function resolveArg(arg: FormulaFunctionArg): FormulaFunctionEagerArg { + return typeof arg === 'function' ? arg() : arg; +} + +/** + * Flattens scalar and range arguments into a single value list, skipping + * empty cells. The evaluator short-circuits error arguments before `apply` + * for non-`acceptsErrors` functions; should one slip through anyway, + * it propagates instead of being silently dropped. + */ +function flattenArgValues(args: FormulaFunctionArg[]): FormulaScalar[] | FormulaErrorValue { + const values: FormulaScalar[] = []; + for (const arg of args) { + const value = resolveArg(arg); + if (isFormulaErrorValue(value)) { + return value; + } + if (isFormulaRangeValue(value)) { + for (const rangeValue of value.values) { + if (!isEmptyFormulaValue(rangeValue)) { + values.push(rangeValue); + } + } + } else if (!isEmptyFormulaValue(value)) { + values.push(value); + } + } + return values; +} + +function collectNumericValues(args: FormulaFunctionArg[]): number[] | FormulaErrorValue { + const values = flattenArgValues(args); + if (isFormulaErrorValue(values)) { + return values; + } + const numbers: number[] = []; + for (const value of values) { + const numeric = toFormulaNumber(value); + if (isFormulaErrorValue(numeric)) { + return numeric; + } + numbers.push(numeric); + } + return numbers; +} + +const sumDefinition: FormulaFunctionDefinition = { + name: 'SUM', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'SUM(value1, value2, …)', + description: 'Adds numbers, ranges and columns.', + category: 'Math', + apply: (args) => { + const numbers = collectNumericValues(args); + if (isFormulaErrorValue(numbers)) { + return numbers; + } + return numbers.reduce((total, value) => total + value, 0); + }, +}; + +const averageDefinition: FormulaFunctionDefinition = { + name: 'AVERAGE', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'AVERAGE(value1, value2, …)', + description: 'Returns the arithmetic mean of its numeric values.', + category: 'Math', + apply: (args) => { + const numbers = collectNumericValues(args); + if (isFormulaErrorValue(numbers)) { + return numbers; + } + if (numbers.length === 0) { + return createFormulaError('#DIV/0!', 'AVERAGE() of no values.'); + } + return numbers.reduce((total, value) => total + value, 0) / numbers.length; + }, +}; + +const minDefinition: FormulaFunctionDefinition = { + name: 'MIN', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'MIN(value1, value2, …)', + description: 'Returns the smallest numeric value.', + category: 'Math', + apply: (args) => { + const numbers = collectNumericValues(args); + if (isFormulaErrorValue(numbers)) { + return numbers; + } + // Excel-compatible: MIN over no values is 0. + return numbers.length === 0 ? 0 : Math.min(...numbers); + }, +}; + +const maxDefinition: FormulaFunctionDefinition = { + name: 'MAX', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'MAX(value1, value2, …)', + description: 'Returns the largest numeric value.', + category: 'Math', + apply: (args) => { + const numbers = collectNumericValues(args); + if (isFormulaErrorValue(numbers)) { + return numbers; + } + return numbers.length === 0 ? 0 : Math.max(...numbers); + }, +}; + +const countDefinition: FormulaFunctionDefinition = { + name: 'COUNT', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'COUNT(value1, value2, …)', + description: 'Counts how many values are numbers or dates.', + category: 'Math', + apply: (args) => { + const values = flattenArgValues(args); + if (isFormulaErrorValue(values)) { + return values; + } + let count = 0; + for (const value of values) { + // Excel-compatible: COUNT counts numbers (and dates), not numeric text. + if ((typeof value === 'number' && !Number.isNaN(value)) || value instanceof Date) { + count += 1; + } + } + return count; + }, +}; + +const countaDefinition: FormulaFunctionDefinition = { + name: 'COUNTA', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'COUNTA(value1, value2, …)', + description: 'Counts how many values are not empty.', + category: 'Math', + apply: (args) => { + const values = flattenArgValues(args); + return isFormulaErrorValue(values) ? values : values.length; + }, +}; + +const roundDefinition: FormulaFunctionDefinition = { + name: 'ROUND', + minArgs: 1, + maxArgs: 2, + signature: 'ROUND(value, [digits])', + description: 'Rounds a number to the given number of decimal digits (0 by default).', + category: 'Math', + apply: (args) => { + const value = toFormulaNumber(resolveArg(args[0])); + if (isFormulaErrorValue(value)) { + return value; + } + let digits = 0; + if (args.length > 1) { + const digitsValue = toFormulaNumber(resolveArg(args[1])); + if (isFormulaErrorValue(digitsValue)) { + return digitsValue; + } + digits = Math.trunc(digitsValue); + } + const factor = 10 ** digits; + // Excel rounds halves away from zero: ROUND(-2.5, 0) is -3. + // Non-finite results are caught by the evaluator's result check. + return (Math.sign(value) * Math.round(Math.abs(value) * factor)) / factor; + }, +}; + +const absDefinition: FormulaFunctionDefinition = { + name: 'ABS', + minArgs: 1, + maxArgs: 1, + signature: 'ABS(value)', + description: 'Returns the absolute value of a number.', + category: 'Math', + apply: (args) => { + const value = toFormulaNumber(resolveArg(args[0])); + return isFormulaErrorValue(value) ? value : Math.abs(value); + }, +}; + +const modDefinition: FormulaFunctionDefinition = { + name: 'MOD', + minArgs: 2, + maxArgs: 2, + signature: 'MOD(value, divisor)', + description: 'Returns the remainder of a division (sign of the divisor).', + category: 'Math', + apply: (args) => { + const value = toFormulaNumber(resolveArg(args[0])); + if (isFormulaErrorValue(value)) { + return value; + } + const divisor = toFormulaNumber(resolveArg(args[1])); + if (isFormulaErrorValue(divisor)) { + return divisor; + } + if (divisor === 0) { + return createFormulaError('#DIV/0!', 'MOD() by zero.'); + } + // Excel-compatible: the result takes the sign of the divisor (MOD(-3, 2) is 1). + return value - divisor * Math.floor(value / divisor); + }, +}; + +const powerDefinition: FormulaFunctionDefinition = { + name: 'POWER', + minArgs: 2, + maxArgs: 2, + signature: 'POWER(base, exponent)', + description: 'Raises a number to a power.', + category: 'Math', + apply: (args) => { + const base = toFormulaNumber(resolveArg(args[0])); + if (isFormulaErrorValue(base)) { + return base; + } + const exponent = toFormulaNumber(resolveArg(args[1])); + if (isFormulaErrorValue(exponent)) { + return exponent; + } + // Non-finite results are caught by the evaluator's result check. + return base ** exponent; + }, +}; + +function resolveBranch(arg: FormulaFunctionArg): FormulaScalar | FormulaErrorValue { + const value = resolveArg(arg); + if (isFormulaRangeValue(value)) { + return createFormulaError('#VALUE!', 'A range cannot be used here.'); + } + return value; +} + +const ifDefinition: FormulaFunctionDefinition = { + name: 'IF', + minArgs: 2, + maxArgs: 3, + lazy: true, + signature: 'IF(condition, valueIfTrue, [valueIfFalse])', + description: 'Returns one value when the condition is true and another when it is false.', + category: 'Logical', + apply: (args) => { + const conditionValue = resolveBranch(args[0]); + if (isFormulaErrorValue(conditionValue)) { + return conditionValue; + } + const condition = toFormulaBoolean(conditionValue); + if (isFormulaErrorValue(condition)) { + return condition; + } + if (condition) { + return resolveBranch(args[1]); + } + // Excel-compatible: a missing "else" branch yields FALSE. + return args.length > 2 ? resolveBranch(args[2]) : false; + }, +}; + +const andDefinition: FormulaFunctionDefinition = { + name: 'AND', + minArgs: 1, + maxArgs: null, + lazy: true, + signature: 'AND(condition1, condition2, …)', + description: 'Returns TRUE when every condition is true.', + category: 'Logical', + apply: (args) => { + for (const arg of args) { + const value = resolveBranch(arg); + if (isFormulaErrorValue(value)) { + return value; + } + const condition = toFormulaBoolean(value); + if (isFormulaErrorValue(condition)) { + return condition; + } + if (!condition) { + return false; + } + } + return true; + }, +}; + +const orDefinition: FormulaFunctionDefinition = { + name: 'OR', + minArgs: 1, + maxArgs: null, + lazy: true, + signature: 'OR(condition1, condition2, …)', + description: 'Returns TRUE when at least one condition is true.', + category: 'Logical', + apply: (args) => { + for (const arg of args) { + const value = resolveBranch(arg); + if (isFormulaErrorValue(value)) { + return value; + } + const condition = toFormulaBoolean(value); + if (isFormulaErrorValue(condition)) { + return condition; + } + if (condition) { + return true; + } + } + return false; + }, +}; + +const notDefinition: FormulaFunctionDefinition = { + name: 'NOT', + minArgs: 1, + maxArgs: 1, + signature: 'NOT(condition)', + description: 'Reverses a boolean value.', + category: 'Logical', + apply: (args) => { + const condition = toFormulaBoolean(resolveArg(args[0])); + return isFormulaErrorValue(condition) ? condition : !condition; + }, +}; + +const ifErrorDefinition: FormulaFunctionDefinition = { + name: 'IFERROR', + minArgs: 2, + maxArgs: 2, + lazy: true, + acceptsErrors: true, + signature: 'IFERROR(value, valueIfError)', + description: 'Returns a fallback value when the first argument is an error.', + category: 'Logical', + apply: (args) => { + const value = resolveArg(args[0]); + if (isFormulaErrorValue(value)) { + return resolveBranch(args[1]); + } + if (isFormulaRangeValue(value)) { + return createFormulaError('#VALUE!', 'A range cannot be used here.'); + } + return value; + }, +}; + +const isBlankDefinition: FormulaFunctionDefinition = { + name: 'ISBLANK', + minArgs: 1, + maxArgs: 1, + acceptsErrors: true, + signature: 'ISBLANK(value)', + description: 'Returns TRUE when the value is empty.', + category: 'Logical', + apply: (args) => { + const value = resolveArg(args[0]); + if (isFormulaErrorValue(value)) { + return false; + } + if (isFormulaRangeValue(value)) { + return createFormulaError('#VALUE!', 'A range cannot be used here.'); + } + return isEmptyFormulaValue(value); + }, +}; + +const concatDefinition: FormulaFunctionDefinition = { + name: 'CONCAT', + minArgs: 1, + maxArgs: null, + acceptsRanges: true, + signature: 'CONCAT(text1, text2, …)', + description: 'Joins values into a single text string.', + category: 'Text', + apply: (args) => { + let result = ''; + for (const arg of args) { + const value = resolveArg(arg); + const values = isFormulaRangeValue(value) ? value.values : [value]; + for (const item of values) { + const text = toFormulaText(item); + if (isFormulaErrorValue(text)) { + return text; + } + result += text; + } + } + return result; + }, +}; + +function applyTextFunction( + arg: FormulaFunctionArg, + transform: (text: string) => string, +): FormulaScalar | FormulaErrorValue { + const text = toFormulaText(resolveArg(arg)); + return isFormulaErrorValue(text) ? text : transform(text); +} + +const lenDefinition: FormulaFunctionDefinition = { + name: 'LEN', + minArgs: 1, + maxArgs: 1, + signature: 'LEN(text)', + description: 'Returns the number of characters in a text string.', + category: 'Text', + apply: (args) => { + const text = toFormulaText(resolveArg(args[0])); + return isFormulaErrorValue(text) ? text : text.length; + }, +}; + +const upperDefinition: FormulaFunctionDefinition = { + name: 'UPPER', + minArgs: 1, + maxArgs: 1, + signature: 'UPPER(text)', + description: 'Converts text to uppercase.', + category: 'Text', + apply: (args) => applyTextFunction(args[0], (text) => text.toUpperCase()), +}; + +const lowerDefinition: FormulaFunctionDefinition = { + name: 'LOWER', + minArgs: 1, + maxArgs: 1, + signature: 'LOWER(text)', + description: 'Converts text to lowercase.', + category: 'Text', + apply: (args) => applyTextFunction(args[0], (text) => text.toLowerCase()), +}; + +const trimDefinition: FormulaFunctionDefinition = { + name: 'TRIM', + minArgs: 1, + maxArgs: 1, + signature: 'TRIM(text)', + description: 'Removes leading, trailing and repeated spaces from text.', + category: 'Text', + // Excel-compatible: TRIM also collapses internal runs of spaces. + apply: (args) => applyTextFunction(args[0], (text) => text.trim().replace(/ {2,}/g, ' ')), +}; + +function sliceTextFunction( + args: FormulaFunctionArg[], + slice: (text: string, count: number) => string, +): FormulaScalar | FormulaErrorValue { + const text = toFormulaText(resolveArg(args[0])); + if (isFormulaErrorValue(text)) { + return text; + } + let count = 1; + if (args.length > 1) { + const countValue = toFormulaNumber(resolveArg(args[1])); + if (isFormulaErrorValue(countValue)) { + return countValue; + } + count = Math.trunc(countValue); + if (count < 0) { + return createFormulaError('#VALUE!', 'The number of characters cannot be negative.'); + } + } + return slice(text, count); +} + +const leftDefinition: FormulaFunctionDefinition = { + name: 'LEFT', + minArgs: 1, + maxArgs: 2, + signature: 'LEFT(text, [count])', + description: 'Returns the first characters of a text string (1 by default).', + category: 'Text', + apply: (args) => sliceTextFunction(args, (text, count) => text.slice(0, count)), +}; + +const rightDefinition: FormulaFunctionDefinition = { + name: 'RIGHT', + minArgs: 1, + maxArgs: 2, + signature: 'RIGHT(text, [count])', + description: 'Returns the last characters of a text string (1 by default).', + category: 'Text', + apply: (args) => + sliceTextFunction(args, (text, count) => (count === 0 ? '' : text.slice(-count))), +}; + +export const FORMULA_BUILT_IN_FUNCTIONS: readonly FormulaFunctionDefinition[] = [ + sumDefinition, + averageDefinition, + minDefinition, + maxDefinition, + countDefinition, + countaDefinition, + roundDefinition, + absDefinition, + modDefinition, + powerDefinition, + ifDefinition, + andDefinition, + orDefinition, + notDefinition, + ifErrorDefinition, + isBlankDefinition, + concatDefinition, + { ...concatDefinition, name: 'CONCATENATE', signature: 'CONCATENATE(text1, text2, …)' }, + lenDefinition, + upperDefinition, + lowerDefinition, + trimDefinition, + leftDefinition, + rightDefinition, +]; + +/** + * Static arity check shared by the evaluator and the validation layer so + * their messages stay in sync. Returns `null` when the call is well-formed. + */ +export function getFormulaFunctionArityError( + definition: FormulaFunctionDefinition, + argCount: number, +): FormulaErrorValue | null { + if (argCount < definition.minArgs) { + return createFormulaError( + '#VALUE!', + `${definition.name}() expects at least ${definition.minArgs} argument(s).`, + ); + } + if (definition.maxArgs !== null && argCount > definition.maxArgs) { + return createFormulaError( + '#VALUE!', + `${definition.name}() expects at most ${definition.maxArgs} argument(s).`, + ); + } + return null; +} + +const VALID_FUNCTION_NAME_REGEX = /^[A-Z_][A-Z0-9_]*$/; + +/** + * Builds the registry from the complete function set: passing an argument + * REPLACES the built-ins (callers wanting built-ins plus extras spread + * `FORMULA_BUILT_IN_FUNCTIONS` explicitly). This mirrors the + * `aggregationFunctions` prop semantics the adapter exposes. + */ +export function createFormulaFunctionRegistry( + functions: readonly FormulaFunctionDefinition[] = FORMULA_BUILT_IN_FUNCTIONS, +): FormulaFunctionRegistry { + const definitions = new Map(); + for (const definition of functions) { + const name = definition.name.toUpperCase(); + if (RESERVED_NAME_SET.has(name)) { + throw new Error( + `MUI X Data Grid: The formula function name "${name}" is reserved by the formula syntax. ` + + 'Registering it would make the function unreachable in formulas. ' + + 'Rename the custom function to a non-reserved name.', + ); + } + if (!VALID_FUNCTION_NAME_REGEX.test(name)) { + throw new Error( + `MUI X Data Grid: "${definition.name}" is not a valid formula function name. ` + + 'The formula parser can never produce a call to it, which would make the function unreachable in formulas. ' + + 'Function names must start with a letter or underscore and contain only letters, digits, and underscores.', + ); + } + definitions.set(name, definition); + } + return { + get: (name) => definitions.get(name.toUpperCase()), + names: () => Array.from(definitions.keys()), + }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.test.ts new file mode 100644 index 0000000000000..6aaa34664f555 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.test.ts @@ -0,0 +1,147 @@ +import { collectAffectedCells, orderForRecompute } from './formulaGraph'; + +/** + * Builds lookup callbacks from plain edge maps: + * `dependencies[x]` lists what x reads; dependents is the reverse. + */ +const buildGraph = (dependencies: Record) => { + const dependents = new Map(); + for (const [key, deps] of Object.entries(dependencies)) { + for (const dep of deps) { + const list = dependents.get(dep); + if (list === undefined) { + dependents.set(dep, [key]); + } else { + list.push(key); + } + } + } + return { + getDependencies: (key: string) => dependencies[key], + getDependents: (key: string) => dependents.get(key), + }; +}; + +describe('formulaGraph', () => { + describe('collectAffectedCells', () => { + it('includes the dirty cells themselves', () => { + const { getDependents } = buildGraph({}); + expect(Array.from(collectAffectedCells(['a'], getDependents))).to.deep.equal(['a']); + }); + + it('collects the transitive dependent closure', () => { + // a -> b -> c, plus an unrelated x -> y. + const { getDependents } = buildGraph({ b: ['a'], c: ['b'], y: ['x'] }); + const affected = collectAffectedCells(['a'], getDependents); + expect(Array.from(affected).sort()).to.deep.equal(['a', 'b', 'c']); + }); + + it('handles diamonds without duplicates', () => { + // d reads b and c; both read a. + const { getDependents } = buildGraph({ b: ['a'], c: ['a'], d: ['b', 'c'] }); + const affected = collectAffectedCells(['a'], getDependents); + expect(Array.from(affected).sort()).to.deep.equal(['a', 'b', 'c', 'd']); + }); + + it('terminates on cyclic graphs', () => { + const { getDependents } = buildGraph({ a: ['b'], b: ['a'] }); + const affected = collectAffectedCells(['a'], getDependents); + expect(Array.from(affected).sort()).to.deep.equal(['a', 'b']); + }); + + it('survives chains tens of thousands deep (iterative, no stack overflow)', () => { + const dependencies: Record = {}; + for (let i = 1; i < 50000; i += 1) { + dependencies[`n${i}`] = [`n${i - 1}`]; + } + const { getDependents } = buildGraph(dependencies); + const affected = collectAffectedCells(['n0'], getDependents); + expect(affected.size).to.equal(50000); + }); + }); + + describe('orderForRecompute', () => { + it('orders a chain dependency-first', () => { + const { getDependencies } = buildGraph({ b: ['a'], c: ['b'] }); + const { order, cyclic } = orderForRecompute(new Set(['a', 'b', 'c']), getDependencies); + expect(order).to.deep.equal(['a', 'b', 'c']); + expect(cyclic.size).to.equal(0); + }); + + it('orders a diamond so each cell recomputes once after its deps', () => { + const { getDependencies } = buildGraph({ b: ['a'], c: ['a'], d: ['b', 'c'] }); + const { order, cyclic } = orderForRecompute(new Set(['a', 'b', 'c', 'd']), getDependencies); + expect(cyclic.size).to.equal(0); + expect(order).to.have.length(4); + expect(order.indexOf('a')).to.be.lessThan(order.indexOf('b')); + expect(order.indexOf('a')).to.be.lessThan(order.indexOf('c')); + expect(order.indexOf('b')).to.be.lessThan(order.indexOf('d')); + expect(order.indexOf('c')).to.be.lessThan(order.indexOf('d')); + }); + + it('ignores dependencies outside the affected set', () => { + // b reads a, but a is not affected (its value is final). + const { getDependencies } = buildGraph({ b: ['a'] }); + const { order, cyclic } = orderForRecompute(new Set(['b']), getDependencies); + expect(order).to.deep.equal(['b']); + expect(cyclic.size).to.equal(0); + }); + + it('reports a two-cycle as cyclic', () => { + const { getDependencies } = buildGraph({ a: ['b'], b: ['a'] }); + const { order, cyclic } = orderForRecompute(new Set(['a', 'b']), getDependencies); + expect(order).to.deep.equal([]); + expect(Array.from(cyclic).sort()).to.deep.equal(['a', 'b']); + }); + + it('reports a self-cycle as cyclic', () => { + const { getDependencies } = buildGraph({ a: ['a'] }); + const { order, cyclic } = orderForRecompute(new Set(['a']), getDependencies); + expect(order).to.deep.equal([]); + expect(Array.from(cyclic)).to.deep.equal(['a']); + }); + + it('locks cells downstream of a cycle into the cyclic set', () => { + // a <-> b cycle; c reads b; d is independent. + const { getDependencies } = buildGraph({ a: ['b'], b: ['a'], c: ['b'], d: [] }); + const { order, cyclic } = orderForRecompute(new Set(['a', 'b', 'c', 'd']), getDependencies); + expect(order).to.deep.equal(['d']); + expect(Array.from(cyclic).sort()).to.deep.equal(['a', 'b', 'c']); + }); + + it('peels the acyclic part feeding into a cycle', () => { + // x is a plain dependency of the cyclic pair: x peels, the cycle does not. + const { getDependencies } = buildGraph({ a: ['b', 'x'], b: ['a'], x: [] }); + const { order, cyclic } = orderForRecompute(new Set(['a', 'b', 'x']), getDependencies); + expect(order).to.deep.equal(['x']); + expect(Array.from(cyclic).sort()).to.deep.equal(['a', 'b']); + }); + + it('orders chains tens of thousands deep in linear time without stack overflow', () => { + const dependencies: Record = {}; + const affected = new Set(['n0']); + for (let i = 1; i < 50000; i += 1) { + dependencies[`n${i}`] = [`n${i - 1}`]; + affected.add(`n${i}`); + } + const { getDependencies } = buildGraph(dependencies); + const { order, cyclic } = orderForRecompute(affected, getDependencies); + expect(cyclic.size).to.equal(0); + expect(order).to.have.length(50000); + expect(order[0]).to.equal('n0'); + expect(order[order.length - 1]).to.equal('n49999'); + }); + + it('leaves untouched siblings out of the order (dirty-subgraph scoping)', () => { + const { getDependencies, getDependents } = buildGraph({ + b: ['a'], + c: ['a'], + z: ['y'], + }); + const affected = collectAffectedCells(['a'], getDependents); + const { order } = orderForRecompute(affected, getDependencies); + expect(order).to.not.include('y'); + expect(order).to.not.include('z'); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.ts new file mode 100644 index 0000000000000..34bd2608ab0cc --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaGraph.ts @@ -0,0 +1,114 @@ +/** + * Pure graph utilities over adjacency maps keyed by cell key. The adapter + * owns the maps; these functions are generic so they stay trivially testable. + * Implementations are iterative (explicit queues) — dependency chains + * thousands of cells deep must not overflow the JS stack. + */ + +/** + * Transitive closure of `dirty` over the reverse-dependency edges: + * everything that must be recomputed. Includes the dirty cells themselves. + */ +export function collectAffectedCells( + dirty: Iterable, + getDependents: (key: K) => Iterable | undefined, +): Set { + const affected = new Set(dirty); + const queue: K[] = Array.from(affected); + while (queue.length > 0) { + const key = queue.pop()!; + const dependents = getDependents(key); + if (dependents === undefined) { + continue; + } + for (const dependent of dependents) { + if (!affected.has(dependent)) { + affected.add(dependent); + queue.push(dependent); + } + } + } + return affected; +} + +export interface FormulaRecomputeOrder { + /** + * A valid evaluation order: every cell appears after all of its + * dependencies that are part of the affected set. + */ + order: K[]; + /** + * Cells on a cycle, or locked downstream of one — Kahn's algorithm never + * peels them. The adapter marks all of them `#CYCLE!`. + */ + cyclic: Set; +} + +/** + * Single-pass Kahn's algorithm over the affected subgraph; doubles as cycle + * detection. `getDependencies` should yield formula-cell dependencies only + * (raw cells are sinks and never expand); edges to cells outside `affected` + * are ignored — their values are already final. + */ +export function orderForRecompute( + affected: Set, + getDependencies: (key: K) => Iterable | undefined, +): FormulaRecomputeOrder { + const inDegree = new Map(); + const dependentsWithin = new Map(); + + for (const key of affected) { + let degree = 0; + const dependencies = getDependencies(key); + if (dependencies !== undefined) { + for (const dependency of dependencies) { + if (affected.has(dependency)) { + degree += 1; + const dependents = dependentsWithin.get(dependency); + if (dependents === undefined) { + dependentsWithin.set(dependency, [key]); + } else { + dependents.push(key); + } + } + } + } + inDegree.set(key, degree); + } + + const order: K[] = []; + const queue: K[] = []; + for (const [key, degree] of inDegree) { + if (degree === 0) { + queue.push(key); + } + } + + // Index-based pointer keeps dequeue O(1). + for (let head = 0; head < queue.length; head += 1) { + const key = queue[head]; + order.push(key); + const dependents = dependentsWithin.get(key); + if (dependents === undefined) { + continue; + } + for (const dependent of dependents) { + const degree = inDegree.get(dependent)! - 1; + inDegree.set(dependent, degree); + if (degree === 0) { + queue.push(dependent); + } + } + } + + const cyclic = new Set(); + if (order.length < affected.size) { + for (const [key, degree] of inDegree) { + if (degree > 0) { + cyclic.add(key); + } + } + } + + return { order, cyclic }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.test.ts new file mode 100644 index 0000000000000..eea39020c6f5e --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.test.ts @@ -0,0 +1,170 @@ +import { parseFormula } from './formulaParser'; +import { serializeFormulaAst } from './formulaSerializer'; +import { offsetFormulaReferences } from './formulaOffset'; +import { createTestPositionContext } from './testUtils'; +import type { FormulaAstNode } from './formulaAst'; +import type { FormulaRowId } from './formulaTypes'; + +const parseOk = (expression: string): FormulaAstNode => { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return ast; +}; + +// A 3-column × 5-row context: columns A,B,C map to price,qty,total; rows 1..5 +// map to r1..r5. Offsets operate on the parsed expression (no leading `=`). +const FIELDS = ['price', 'qty', 'total']; +const ROW_IDS: FormulaRowId[] = ['r1', 'r2', 'r3', 'r4', 'r5']; +const context = createTestPositionContext(ROW_IDS, FIELDS); + +const offset = (expression: string, rowDelta: number, columnDelta: number): string => + serializeFormulaAst(offsetFormulaReferences(parseOk(expression), rowDelta, columnDelta, context)); + +describe('offsetFormulaReferences', () => { + it('returns the same node reference for a zero delta', () => { + const ast = parseOk('REF(COLUMN("price"), ROW("r1")) * 2'); + expect(offsetFormulaReferences(ast, 0, 0, context)).toBe(ast); + }); + + describe('stable references (relative)', () => { + it('shifts a stable row reference down', () => { + expect(offset('REF(COLUMN("price"), ROW("r1"))', 1, 0)).toBe( + 'REF(COLUMN("price"), ROW("r2"))', + ); + expect(offset('REF(COLUMN("price"), ROW("r1"))', 2, 0)).toBe( + 'REF(COLUMN("price"), ROW("r3"))', + ); + }); + + it('shifts a stable row reference up', () => { + expect(offset('REF(COLUMN("price"), ROW("r3"))', -2, 0)).toBe( + 'REF(COLUMN("price"), ROW("r1"))', + ); + }); + + it('shifts a stable column reference right', () => { + expect(offset('REF(COLUMN("price"), ROW("r1"))', 0, 1)).toBe('REF(COLUMN("qty"), ROW("r1"))'); + expect(offset('REF(COLUMN("price"), ROW("r1"))', 0, 2)).toBe( + 'REF(COLUMN("total"), ROW("r1"))', + ); + }); + + it('shifts both axes together (=A1*B1 dragged down → =A2*B2)', () => { + expect(offset('REF(COLUMN("price"), ROW("r1")) * REF(COLUMN("qty"), ROW("r1"))', 1, 0)).toBe( + 'REF(COLUMN("price"), ROW("r2")) * REF(COLUMN("qty"), ROW("r2"))', + ); + }); + }); + + describe('positional references (absolute, from `$`)', () => { + it('never shifts a fully positional reference', () => { + expect(offset('REF(COLUMN_POSITION(1), ROW_POSITION(1))', 3, 1)).toBe( + 'REF(COLUMN_POSITION(1), ROW_POSITION(1))', + ); + }); + + it('shifts only the relative axis of a mixed reference', () => { + // COLUMN("price") + ROW_POSITION(1): column relative, row absolute. + expect(offset('REF(COLUMN("price"), ROW_POSITION(1))', 2, 1)).toBe( + 'REF(COLUMN("qty"), ROW_POSITION(1))', + ); + // COLUMN_POSITION(1) + ROW("r1"): column absolute, row relative. + expect(offset('REF(COLUMN_POSITION(1), ROW("r1"))', 2, 1)).toBe( + 'REF(COLUMN_POSITION(1), ROW("r3"))', + ); + }); + }); + + describe('ranges', () => { + it('shifts both endpoints independently (=SUM(A1:A3) down one → =SUM(A2:A4))', () => { + expect( + offset( + 'SUM(RANGE(REF(COLUMN("price"), ROW("r1")), REF(COLUMN("price"), ROW("r3"))))', + 1, + 0, + ), + ).toBe('SUM(RANGE(REF(COLUMN("price"), ROW("r2")), REF(COLUMN("price"), ROW("r4"))))'); + }); + + it('keeps an absolute start anchor while shifting a relative end (running total)', () => { + expect( + offset( + 'SUM(RANGE(REF(COLUMN_POSITION(1), ROW_POSITION(1)), REF(COLUMN("price"), ROW("r1"))))', + 1, + 0, + ), + ).toBe( + 'SUM(RANGE(REF(COLUMN_POSITION(1), ROW_POSITION(1)), REF(COLUMN("price"), ROW("r2"))))', + ); + }); + }); + + describe('same-row field references', () => { + it('leaves a same-row field reference unchanged on vertical fill', () => { + expect(offset('price * qty', 2, 0)).toBe('price * qty'); + }); + + it('shifts a same-row field reference on horizontal fill', () => { + expect(offset('price', 0, 1)).toBe('qty'); + expect(offset('price * qty', 0, 1)).toBe('qty * total'); + }); + }); + + describe('whole-column references', () => { + it('leaves COLUMN_VALUES unchanged on vertical fill', () => { + expect(offset('SUM(COLUMN_VALUES("price"))', 3, 0)).toBe('SUM(COLUMN_VALUES("price"))'); + }); + + it('shifts COLUMN_VALUES on horizontal fill', () => { + expect(offset('SUM(COLUMN_VALUES("price"))', 0, 1)).toBe('SUM(COLUMN_VALUES("qty"))'); + }); + }); + + describe('out of bounds', () => { + it('freezes overshoot to a positional reference that resolves to #REF!', () => { + // r5 is the last row; +1 lands beyond the row set. + expect(offset('REF(COLUMN("price"), ROW("r5"))', 1, 0)).toBe( + 'REF(COLUMN("price"), ROW_POSITION(6))', + ); + // total is the last column; +1 lands beyond the columns. + expect(offset('REF(COLUMN("total"), ROW("r1"))', 0, 1)).toBe( + 'REF(COLUMN_POSITION(4), ROW("r1"))', + ); + }); + + it('keeps the original reference on underflow (no representable position < 1)', () => { + // r1 is the first row; -1 would land above it. + expect(offset('REF(COLUMN("price"), ROW("r1"))', -1, 0)).toBe( + 'REF(COLUMN("price"), ROW("r1"))', + ); + // price is the first column; -1 would land left of it. + expect(offset('REF(COLUMN("price"), ROW("r1"))', 0, -1)).toBe( + 'REF(COLUMN("price"), ROW("r1"))', + ); + }); + + it('keeps a same-row field reference on horizontal overshoot', () => { + expect(offset('total', 0, 1)).toBe('total'); + }); + }); + + describe('references inside expressions and functions', () => { + it('shifts references nested in function calls and operators', () => { + expect( + offset('IF(REF(COLUMN("price"), ROW("r1")) > 0, REF(COLUMN("qty"), ROW("r1")), 0)', 1, 0), + ).toBe('IF(REF(COLUMN("price"), ROW("r2")) > 0, REF(COLUMN("qty"), ROW("r2")), 0)'); + }); + + it('shifts references under a unary expression', () => { + expect(offset('-REF(COLUMN("price"), ROW("r1"))', 1, 0)).toBe( + '-REF(COLUMN("price"), ROW("r2"))', + ); + }); + + it('leaves literals untouched', () => { + expect(offset('1 + 2 * 3', 5, 5)).toBe('1 + 2 * 3'); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.ts new file mode 100644 index 0000000000000..06b3c2f6f6898 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaOffset.ts @@ -0,0 +1,184 @@ +import type { + FormulaAstNode, + FormulaCellRefNode, + FormulaColumnSelector, + FormulaColumnValuesNode, + FormulaFieldRefNode, + FormulaRangeNode, + FormulaRowSelector, +} from './formulaAst'; +import type { FormulaPositionContext } from './formulaTypes'; + +/** + * Shifts every relative reference in a parsed formula by a positional delta — + * the Excel fill-handle rule: a reference moves by the same `(rowDelta, + * columnDelta)` as the cell it lives in, so `=A1*B1` dragged down one row + * becomes `=A2*B2`. The delta is measured in the position context's units + * (sorted + filtered visible order, 1-based), the same space the A1 display, + * paste adjustment and `ROW_POSITION` use, so offsets can never disagree with + * what the user sees. + * + * Selector semantics mirror `buildColumnSelector`/`buildRowSelector` in + * `formulaA1.ts`: + * - **Positional** selectors (`COLUMN_POSITION`/`ROW_POSITION`, the canonical + * form of `$`-absolute refs) never shift — they are absolute. + * - **Stable** selectors (`COLUMN("field")`/`ROW(id)`, the canonical form of + * relative refs) re-anchor to the field/row now at `position + delta`. + * - **Overshoot** past the last row/column freezes to a positional selector + * that resolves to `#REF!` (and recovers if the grid later grows — consistent + * with the engine's positional-out-of-range semantics). + * - **Underflow** past the first row/column keeps the original reference: the + * 1-based canonical store has no representable out-of-bounds-low position + * (the parser rejects `ROW_POSITION(0)`), so the reference stays put rather + * than corrupting the whole formula into `#ERROR!`. + * + * Pure: engine types only, no grid imports. The walk is recursive, bounded by + * the parser's AST-height limit exactly like the serializer and evaluator. + */ +export function offsetFormulaReferences( + ast: FormulaAstNode, + rowDelta: number, + columnDelta: number, + context: FormulaPositionContext, +): FormulaAstNode { + if (rowDelta === 0 && columnDelta === 0) { + return ast; + } + return offsetNode(ast, rowDelta, columnDelta, context); +} + +function offsetColumnSelector( + selector: FormulaColumnSelector, + columnDelta: number, + context: FormulaPositionContext, +): FormulaColumnSelector { + if (selector.kind === 'position' || columnDelta === 0) { + return selector; + } + const position = context.getPositionOfField(selector.field); + if (position === undefined) { + return selector; + } + const newPosition = position + columnDelta; + if (newPosition < 1) { + return selector; + } + const field = context.getFieldAtPosition(newPosition); + if (field !== undefined) { + return { kind: 'field', field }; + } + // Overshoot: a positional selector resolves to `#REF!` at evaluation time. + return { kind: 'position', index: newPosition }; +} + +function offsetRowSelector( + selector: FormulaRowSelector, + rowDelta: number, + context: FormulaPositionContext, +): FormulaRowSelector { + if (selector.kind === 'position' || rowDelta === 0) { + return selector; + } + const position = context.getPositionOfRowId(selector.id); + if (position === undefined) { + return selector; + } + const newPosition = position + rowDelta; + if (newPosition < 1) { + return selector; + } + const id = context.getRowIdAtPosition(newPosition); + if (id !== undefined) { + return { kind: 'id', id }; + } + // Overshoot: a positional selector resolves to `#REF!` at evaluation time. + return { kind: 'position', index: newPosition }; +} + +function offsetCellRef( + node: FormulaCellRefNode, + rowDelta: number, + columnDelta: number, + context: FormulaPositionContext, +): FormulaCellRefNode { + return { + ...node, + column: offsetColumnSelector(node.column, columnDelta, context), + row: offsetRowSelector(node.row, rowDelta, context), + }; +} + +function offsetRange( + node: FormulaRangeNode, + rowDelta: number, + columnDelta: number, + context: FormulaPositionContext, +): FormulaRangeNode { + return { + ...node, + start: offsetCellRef(node.start, rowDelta, columnDelta, context), + end: offsetCellRef(node.end, rowDelta, columnDelta, context), + }; +} + +/** + * A same-row field reference (`price`) or whole-column reference + * (`COLUMN_VALUES("price")`) has no row axis: it only shifts on horizontal + * fill, to the field now at `position + columnDelta`. It has no positional + * form, so an out-of-bounds shift keeps the original field. + */ +function offsetFieldOnly( + node: T, + columnDelta: number, + context: FormulaPositionContext, +): T { + if (columnDelta === 0) { + return node; + } + const position = context.getPositionOfField(node.field); + if (position === undefined) { + return node; + } + const newPosition = position + columnDelta; + if (newPosition < 1) { + return node; + } + const field = context.getFieldAtPosition(newPosition); + if (field === undefined || field === node.field) { + return node; + } + return { ...node, field }; +} + +function offsetNode( + node: FormulaAstNode, + rowDelta: number, + columnDelta: number, + context: FormulaPositionContext, +): FormulaAstNode { + switch (node.type) { + case 'cellRef': + return offsetCellRef(node, rowDelta, columnDelta, context); + case 'range': + return offsetRange(node, rowDelta, columnDelta, context); + case 'fieldRef': + case 'columnValues': + return offsetFieldOnly(node, columnDelta, context); + case 'unaryExpression': + return { ...node, operand: offsetNode(node.operand, rowDelta, columnDelta, context) }; + case 'binaryExpression': + return { + ...node, + left: offsetNode(node.left, rowDelta, columnDelta, context), + right: offsetNode(node.right, rowDelta, columnDelta, context), + }; + case 'functionCall': + return { + ...node, + args: node.args.map((arg) => offsetNode(arg, rowDelta, columnDelta, context)), + }; + default: + // Literals carry no references. + return node; + } +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.test.ts new file mode 100644 index 0000000000000..d6688a0ad20a4 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.test.ts @@ -0,0 +1,444 @@ +import { parseFormula, createFormulaParser } from './formulaParser'; +import type { FormulaAstNode } from './formulaAst'; + +/** + * Recursively removes `span` properties so structural assertions stay readable. + */ +const stripSpans = (node: unknown): unknown => { + if (Array.isArray(node)) { + return node.map(stripSpans); + } + if (typeof node === 'object' && node !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key !== 'span') { + result[key] = stripSpans(value); + } + } + return result; + } + return node; +}; + +const parseOk = (expression: string): unknown => { + const { ast, error } = parseFormula(expression); + expect(error).to.equal(null); + return stripSpans(ast); +}; + +const parseError = (expression: string): string => { + const { ast, error } = parseFormula(expression); + expect(ast).to.equal(null); + return error!.message; +}; + +describe('formulaParser', () => { + describe('literals', () => { + it('parses number, string and boolean literals', () => { + expect(parseOk('1.5')).to.deep.equal({ type: 'numberLiteral', value: 1.5 }); + expect(parseOk('"a""b"')).to.deep.equal({ type: 'stringLiteral', value: 'a"b' }); + expect(parseOk('TRUE')).to.deep.equal({ type: 'booleanLiteral', value: true }); + expect(parseOk('false')).to.deep.equal({ type: 'booleanLiteral', value: false }); + }); + }); + + describe('field references', () => { + it('parses a bare identifier as a same-row field ref', () => { + expect(parseOk('price')).to.deep.equal({ type: 'fieldRef', field: 'price' }); + }); + + it('preserves the case of field names', () => { + expect(parseOk('unitPrice')).to.deep.equal({ type: 'fieldRef', field: 'unitPrice' }); + }); + + it('parses FIELD("...") for arbitrary field names', () => { + expect(parseOk('FIELD("unit price")')).to.deep.equal({ + type: 'fieldRef', + field: 'unit price', + }); + }); + + it('requires a string literal inside FIELD()', () => { + expect(parseError('FIELD(price)')).to.equal('FIELD() expects a string literal.'); + }); + }); + + describe('operator precedence and associativity', () => { + it('gives multiplication precedence over addition', () => { + expect(parseOk('1 + 2 * 3')).to.deep.equal({ + type: 'binaryExpression', + operator: '+', + left: { type: 'numberLiteral', value: 1 }, + right: { + type: 'binaryExpression', + operator: '*', + left: { type: 'numberLiteral', value: 2 }, + right: { type: 'numberLiteral', value: 3 }, + }, + }); + }); + + it('parses comparison with the lowest precedence', () => { + expect(parseOk('1 + 2 > 2 & "x"')).to.deep.equal({ + type: 'binaryExpression', + operator: '>', + left: { + type: 'binaryExpression', + operator: '+', + left: { type: 'numberLiteral', value: 1 }, + right: { type: 'numberLiteral', value: 2 }, + }, + right: { + type: 'binaryExpression', + operator: '&', + left: { type: 'numberLiteral', value: 2 }, + right: { type: 'stringLiteral', value: 'x' }, + }, + }); + }); + + it('keeps binary operators left-associative', () => { + expect(parseOk('1 - 2 - 3')).to.deep.equal({ + type: 'binaryExpression', + operator: '-', + left: { + type: 'binaryExpression', + operator: '-', + left: { type: 'numberLiteral', value: 1 }, + right: { type: 'numberLiteral', value: 2 }, + }, + right: { type: 'numberLiteral', value: 3 }, + }); + }); + + it('keeps ^ left-associative (Excel-compatible: 2^3^2 = (2^3)^2)', () => { + expect(parseOk('2 ^ 3 ^ 2')).to.deep.equal({ + type: 'binaryExpression', + operator: '^', + left: { + type: 'binaryExpression', + operator: '^', + left: { type: 'numberLiteral', value: 2 }, + right: { type: 'numberLiteral', value: 3 }, + }, + right: { type: 'numberLiteral', value: 2 }, + }); + }); + + it('binds unary minus tighter than ^ (Excel-compatible: -2^2 = (-2)^2)', () => { + expect(parseOk('-2 ^ 2')).to.deep.equal({ + type: 'binaryExpression', + operator: '^', + left: { + type: 'unaryExpression', + operator: '-', + operand: { type: 'numberLiteral', value: 2 }, + }, + right: { type: 'numberLiteral', value: 2 }, + }); + }); + + it('parses a unary operator on the right side of ^', () => { + expect(parseOk('2 ^ -2')).to.deep.equal({ + type: 'binaryExpression', + operator: '^', + left: { type: 'numberLiteral', value: 2 }, + right: { + type: 'unaryExpression', + operator: '-', + operand: { type: 'numberLiteral', value: 2 }, + }, + }); + }); + + it('parses stacked unary operators', () => { + expect(parseOk('--1')).to.deep.equal({ + type: 'unaryExpression', + operator: '-', + operand: { + type: 'unaryExpression', + operator: '-', + operand: { type: 'numberLiteral', value: 1 }, + }, + }); + }); + + it('honors parentheses', () => { + expect(parseOk('(1 + 2) * 3')).to.deep.equal({ + type: 'binaryExpression', + operator: '*', + left: { + type: 'binaryExpression', + operator: '+', + left: { type: 'numberLiteral', value: 1 }, + right: { type: 'numberLiteral', value: 2 }, + }, + right: { type: 'numberLiteral', value: 3 }, + }); + }); + }); + + describe('function calls', () => { + it('normalizes function names to uppercase', () => { + expect(parseOk('sum(price, 1)')).to.deep.equal({ + type: 'functionCall', + name: 'SUM', + args: [ + { type: 'fieldRef', field: 'price' }, + { type: 'numberLiteral', value: 1 }, + ], + }); + }); + + it('parses a call with no arguments', () => { + expect(parseOk('FOO()')).to.deep.equal({ type: 'functionCall', name: 'FOO', args: [] }); + }); + + it('parses nested calls', () => { + expect(parseOk('IF(a > 1, SUM(b, c), 0)')).to.deep.equal({ + type: 'functionCall', + name: 'IF', + args: [ + { + type: 'binaryExpression', + operator: '>', + left: { type: 'fieldRef', field: 'a' }, + right: { type: 'numberLiteral', value: 1 }, + }, + { + type: 'functionCall', + name: 'SUM', + args: [ + { type: 'fieldRef', field: 'b' }, + { type: 'fieldRef', field: 'c' }, + ], + }, + { type: 'numberLiteral', value: 0 }, + ], + }); + }); + + it('rejects TRUE/FALSE used as functions', () => { + expect(parseError('TRUE(1)')).to.equal('"TRUE" is not a function.'); + }); + }); + + describe('special forms', () => { + it('parses REF with stable selectors', () => { + expect(parseOk('REF(COLUMN("total"), ROW("order-1"))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'field', field: 'total' }, + row: { kind: 'id', id: 'order-1' }, + }); + }); + + it('parses REF with a negative numeric row id', () => { + expect(parseOk('REF(COLUMN("total"), ROW(-1))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'field', field: 'total' }, + row: { kind: 'id', id: -1 }, + }); + }); + + it('rejects a sign before a string row id', () => { + expect(parseError('REF(COLUMN("a"), ROW(-"x"))')).to.equal( + 'ROW() expects a row id as a string or number literal.', + ); + expect(parseError('REF(COLUMN("a"), ROW(-))')).to.equal( + 'ROW() expects a row id as a string or number literal.', + ); + }); + + it('rejects a non-finite numeric row id', () => { + expect(parseError('REF(COLUMN("a"), ROW(1e999))')).to.equal( + 'ROW() expects a finite number literal.', + ); + }); + + it('parses REF with a numeric row id', () => { + expect(parseOk('REF(COLUMN("total"), ROW(42))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'field', field: 'total' }, + row: { kind: 'id', id: 42 }, + }); + }); + + it('parses mixed-axis positional selectors', () => { + expect(parseOk('REF(COLUMN("total"), ROW_POSITION(1))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'field', field: 'total' }, + row: { kind: 'position', index: 1 }, + }); + expect(parseOk('REF(COLUMN_POSITION(2), ROW("a"))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'position', index: 2 }, + row: { kind: 'id', id: 'a' }, + }); + }); + + it('is case-insensitive for special form names', () => { + expect(parseOk('ref(column("a"), row("b"))')).to.deep.equal({ + type: 'cellRef', + column: { kind: 'field', field: 'a' }, + row: { kind: 'id', id: 'b' }, + }); + }); + + it('parses RANGE with REF anchors', () => { + expect( + parseOk('RANGE(REF(COLUMN("a"), ROW(1)), REF(COLUMN("b"), ROW_POSITION(5)))'), + ).to.deep.equal({ + type: 'range', + start: { + type: 'cellRef', + column: { kind: 'field', field: 'a' }, + row: { kind: 'id', id: 1 }, + }, + end: { + type: 'cellRef', + column: { kind: 'field', field: 'b' }, + row: { kind: 'position', index: 5 }, + }, + }); + }); + + it('parses COLUMN_VALUES', () => { + expect(parseOk('SUM(COLUMN_VALUES("price"))')).to.deep.equal({ + type: 'functionCall', + name: 'SUM', + args: [{ type: 'columnValues', field: 'price' }], + }); + }); + + it('enforces literal-only arguments (computed refs are parse errors)', () => { + expect(parseError('REF(COLUMN(price & "x"), ROW("a"))')).to.equal( + 'COLUMN() expects a string literal.', + ); + expect(parseError('REF(COLUMN("a"), ROW_POSITION("x"))')).to.equal( + 'ROW_POSITION() expects a number literal.', + ); + // A computed position is consumed up to the literal, then rejected. + expect(parseError('REF(COLUMN("a"), ROW_POSITION(1 + 1))')).to.equal('Expected ")".'); + }); + + it('rejects non-positive or fractional positions', () => { + expect(parseError('REF(COLUMN("a"), ROW_POSITION(0))')).to.equal( + 'ROW_POSITION() expects a positive integer (1-based position).', + ); + expect(parseError('REF(COLUMN_POSITION(1.5), ROW("a"))')).to.equal( + 'COLUMN_POSITION() expects a positive integer (1-based position).', + ); + }); + + it('rejects selector forms at expression level', () => { + expect(parseError('COLUMN("a")')).to.equal('"COLUMN" can only be used inside REF().'); + expect(parseError('ROW_POSITION(1)')).to.equal( + '"ROW_POSITION" can only be used inside REF().', + ); + }); + + it('rejects RANGE anchors that are not REF()', () => { + expect(parseError('RANGE(1, 2)')).to.equal('RANGE() anchors must be REF() references.'); + }); + + it('treats reserved names without parentheses as field refs', () => { + expect(parseOk('REF')).to.deep.equal({ type: 'fieldRef', field: 'REF' }); + }); + }); + + describe('errors', () => { + it('rejects an empty formula', () => { + expect(parseError('')).to.equal('The formula is empty.'); + expect(parseError(' ')).to.equal('The formula is empty.'); + }); + + it('rejects trailing tokens', () => { + expect(parseError('1 2')).to.equal('Unexpected "2" after the expression.'); + }); + + it('rejects an incomplete expression', () => { + expect(parseError('1 +')).to.equal('Unexpected end of formula.'); + }); + + it('rejects an unclosed parenthesis', () => { + expect(parseError('(1 + 2')).to.equal('Expected ")".'); + }); + + it('rejects a missing function argument', () => { + expect(parseError('SUM(1,)')).to.equal('Unexpected ")".'); + }); + + it('surfaces tokenizer errors', () => { + expect(parseError('1 + @')).to.equal('Unexpected character "@".'); + }); + + it('rejects out-of-range number literals', () => { + expect(parseError('1e999')).to.equal('The number literal is out of range.'); + expect(parseError(`${'9'.repeat(400)}`)).to.equal('The number literal is out of range.'); + }); + + it('keeps boundary number literals', () => { + expect(parseOk('1e308')).to.deep.equal({ type: 'numberLiteral', value: 1e308 }); + }); + + it('rejects deeply nested formulas instead of overflowing the stack', () => { + const nested = `${'('.repeat(4000)}1${')'.repeat(4000)}`; + expect(parseError(nested)).to.equal('The formula is too deeply nested.'); + }); + + it('rejects overly long operator chains instead of overflowing the evaluator', () => { + // Parses iteratively, but the resulting left-deep AST would overflow + // the recursive evaluator/serializer — rejected via the height bound. + const chain = `1${' + 1'.repeat(4000)}`; + expect(parseError(chain)).to.equal('The formula is too complex.'); + }); + + it('accepts moderately deep formulas', () => { + const nested = `${'('.repeat(100)}1${')'.repeat(100)}`; + expect(parseOk(nested)).to.deep.equal({ type: 'numberLiteral', value: 1 }); + const chain = `1${' + 1'.repeat(100)}`; + const { ast, error } = parseFormula(chain); + expect(error).to.equal(null); + expect(ast).not.to.equal(null); + }); + + it('reports the failing span', () => { + const { error } = parseFormula('1 ~ 2'); + expect(error?.span).to.deep.equal({ start: 2, end: 3 }); + }); + }); + + describe('spans', () => { + it('covers the full expression on the root node', () => { + const { ast } = parseFormula('1 + price'); + expect(ast?.span).to.deep.equal({ start: 0, end: 9 }); + }); + + it('covers function calls including the closing parenthesis', () => { + const { ast } = parseFormula('SUM(a, b)'); + expect(ast?.span).to.deep.equal({ start: 0, end: 9 }); + }); + + it('includes the parentheses in a parenthesized expression span', () => { + expect(parseFormula('(1 + 2) * 3').ast?.span).to.deep.equal({ start: 0, end: 11 }); + expect(parseFormula('(price)').ast?.span).to.deep.equal({ start: 0, end: 7 }); + }); + }); + + describe('createFormulaParser (AST interning)', () => { + it('returns the identical result object for identical source', () => { + const parser = createFormulaParser(); + const first = parser.parse('price * quantity'); + const second = parser.parse('price * quantity'); + expect(second).to.equal(first); + expect(second.ast).to.equal(first.ast as FormulaAstNode); + }); + + it('forgets cached results after clear()', () => { + const parser = createFormulaParser(); + const first = parser.parse('1 + 1'); + parser.clear(); + expect(parser.parse('1 + 1')).not.to.equal(first); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.ts new file mode 100644 index 0000000000000..82021ea0f2fd1 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaParser.ts @@ -0,0 +1,508 @@ +import { tokenizeFormula } from './formulaTokenizer'; +import type { FormulaToken } from './formulaTokenizer'; +import { FORMULA_BINARY_PRECEDENCE, FORMULA_RESERVED_NAMES } from './formulaAst'; +import type { + FormulaAstNode, + FormulaBinaryOperator, + FormulaCellRefNode, + FormulaColumnSelector, + FormulaRowSelector, +} from './formulaAst'; +import type { FormulaSourceSpan } from './formulaTypes'; + +export interface FormulaParseError { + message: string; + span: FormulaSourceSpan; +} + +export interface FormulaParseResult { + /** + * `null` when the source could not be parsed — the cell evaluates to `#ERROR!`. + */ + ast: FormulaAstNode | null; + error: FormulaParseError | null; +} + +// Special forms are the reserved names minus the boolean literal keywords. +const SPECIAL_FORM_NAMES = new Set( + FORMULA_RESERVED_NAMES.filter((name) => name !== 'TRUE' && name !== 'FALSE'), +); + +/** + * Bounds both parser recursion depth and constructed AST height, so that the + * recursive evaluator and serializer can never overflow the JS stack on a + * parser-produced AST. Hostile inputs (thousands of nested parentheses or + * `1+1+...` chains) become ordinary parse errors instead of RangeErrors. + */ +const MAX_FORMULA_DEPTH = 500; + +class ParseFailure { + message: string; + + span: FormulaSourceSpan; + + constructor(message: string, span: FormulaSourceSpan) { + this.message = message; + this.span = span; + } +} + +class Parser { + private tokens: FormulaToken[]; + + private index = 0; + + private endSpan: FormulaSourceSpan; + + private recursionDepth = 0; + + private heights = new WeakMap(); + + constructor(tokens: FormulaToken[], expressionLength: number) { + this.tokens = tokens; + this.endSpan = { start: expressionLength, end: expressionLength }; + } + + private peek(): FormulaToken | null { + return this.tokens[this.index] ?? null; + } + + private next(): FormulaToken | null { + const token = this.tokens[this.index] ?? null; + if (token !== null) { + this.index += 1; + } + return token; + } + + private currentSpan(): FormulaSourceSpan { + return this.peek()?.span ?? this.endSpan; + } + + private failure(message: string, span?: FormulaSourceSpan): ParseFailure { + return new ParseFailure(message, span ?? this.currentSpan()); + } + + private enterRecursion(): void { + this.recursionDepth += 1; + if (this.recursionDepth > MAX_FORMULA_DEPTH) { + throw this.failure('The formula is too deeply nested.'); + } + } + + private exitRecursion(): void { + this.recursionDepth -= 1; + } + + /** + * Records the height of a constructed node and rejects ASTs that would be + * too tall for the recursive evaluator/serializer. + */ + private withHeight(node: T, height: number): T { + if (height > MAX_FORMULA_DEPTH) { + throw this.failure('The formula is too complex.', node.span); + } + this.heights.set(node, height); + return node; + } + + private heightOf(node: FormulaAstNode): number { + return this.heights.get(node) ?? 1; + } + + private expectPunctuation(value: '(' | ')' | ','): FormulaToken { + const token = this.peek(); + if (token === null || token.type !== 'punctuation' || token.value !== value) { + throw this.failure(`Expected "${value}".`); + } + return this.next()!; + } + + parse(): FormulaAstNode { + if (this.tokens.length === 0) { + throw this.failure('The formula is empty.'); + } + const node = this.parseExpression(1); + const trailing = this.peek(); + if (trailing !== null) { + throw this.failure(`Unexpected "${trailing.value}" after the expression.`, trailing.span); + } + return node; + } + + private parseExpression(minPrecedence: number): FormulaAstNode { + this.enterRecursion(); + let left = this.parseUnary(); + while (true) { + const token = this.peek(); + if (token === null || token.type !== 'operator') { + break; + } + const operator = token.value as FormulaBinaryOperator; + const precedence = FORMULA_BINARY_PRECEDENCE[operator]; + if (precedence === undefined || precedence < minPrecedence) { + break; + } + this.next(); + // +1 keeps every operator left-associative. + const right = this.parseExpression(precedence + 1); + left = this.withHeight( + { + type: 'binaryExpression', + operator, + left, + right, + span: { start: left.span.start, end: right.span.end }, + }, + Math.max(this.heightOf(left), this.heightOf(right)) + 1, + ); + } + this.exitRecursion(); + return left; + } + + private parseUnary(): FormulaAstNode { + const token = this.peek(); + if ( + token !== null && + token.type === 'operator' && + (token.value === '-' || token.value === '+') + ) { + this.enterRecursion(); + this.next(); + const operand = this.parseUnary(); + this.exitRecursion(); + return this.withHeight( + { + type: 'unaryExpression', + operator: token.value, + operand, + span: { start: token.span.start, end: operand.span.end }, + }, + this.heightOf(operand) + 1, + ); + } + return this.parsePrimary(); + } + + private parsePrimary(): FormulaAstNode { + const token = this.peek(); + if (token === null) { + throw this.failure('Unexpected end of formula.'); + } + + if (token.type === 'number') { + const value = parseFloat(token.value); + if (!Number.isFinite(value)) { + throw this.failure('The number literal is out of range.', token.span); + } + this.next(); + return { type: 'numberLiteral', value, span: token.span }; + } + + if (token.type === 'string') { + this.next(); + return { type: 'stringLiteral', value: token.value, span: token.span }; + } + + if (token.type === 'punctuation' && token.value === '(') { + const open = this.next()!; + const node = this.parseExpression(1); + const close = this.expectPunctuation(')'); + // The span widens to include the parentheses; the height is unchanged. + return this.withHeight( + { ...node, span: { start: open.span.start, end: close.span.end } }, + this.heightOf(node), + ); + } + + if (token.type === 'identifier') { + return this.parseIdentifier(); + } + + throw this.failure(`Unexpected "${token.value}".`, token.span); + } + + private parseIdentifier(): FormulaAstNode { + const token = this.next()!; + const upperName = token.value.toUpperCase(); + const nextToken = this.peek(); + const isCall = + nextToken !== null && nextToken.type === 'punctuation' && nextToken.value === '('; + + if (!isCall) { + if (upperName === 'TRUE' || upperName === 'FALSE') { + return { type: 'booleanLiteral', value: upperName === 'TRUE', span: token.span }; + } + // Any other bare identifier is a same-row field reference. The field's + // existence is an evaluation concern (#REF!), never a parse concern. + return { type: 'fieldRef', field: token.value, span: token.span }; + } + + if (SPECIAL_FORM_NAMES.has(upperName)) { + return this.parseSpecialForm(upperName, token); + } + + if (upperName === 'TRUE' || upperName === 'FALSE') { + throw this.failure(`"${upperName}" is not a function.`, token.span); + } + + this.expectPunctuation('('); + const args: FormulaAstNode[] = []; + const closingForEmpty = this.peek(); + if ( + closingForEmpty !== null && + closingForEmpty.type === 'punctuation' && + closingForEmpty.value === ')' + ) { + const closing = this.next()!; + return { + type: 'functionCall', + name: upperName, + args, + span: { start: token.span.start, end: closing.span.end }, + }; + } + while (true) { + args.push(this.parseExpression(1)); + const separator = this.peek(); + if (separator !== null && separator.type === 'punctuation' && separator.value === ',') { + this.next(); + continue; + } + break; + } + const closing = this.expectPunctuation(')'); + return this.withHeight( + { + type: 'functionCall', + name: upperName, + args, + span: { start: token.span.start, end: closing.span.end }, + }, + Math.max(...args.map((arg) => this.heightOf(arg))) + 1, + ); + } + + /** + * Special forms enforce literal-only arguments so that static dependency + * extraction stays decidable. Computed references are parse errors. + */ + private parseSpecialForm(name: string, nameToken: FormulaToken): FormulaAstNode { + switch (name) { + case 'FIELD': { + this.expectPunctuation('('); + const field = this.expectStringLiteral('FIELD'); + const closing = this.expectPunctuation(')'); + return { + type: 'fieldRef', + field, + span: { start: nameToken.span.start, end: closing.span.end }, + }; + } + case 'REF': + return this.parseRef(nameToken); + case 'RANGE': { + this.expectPunctuation('('); + const start = this.parseRefAnchor(); + this.expectPunctuation(','); + const end = this.parseRefAnchor(); + const closing = this.expectPunctuation(')'); + return { + type: 'range', + start, + end, + span: { start: nameToken.span.start, end: closing.span.end }, + }; + } + case 'COLUMN_VALUES': { + this.expectPunctuation('('); + const field = this.expectStringLiteral('COLUMN_VALUES'); + const closing = this.expectPunctuation(')'); + return { + type: 'columnValues', + field, + span: { start: nameToken.span.start, end: closing.span.end }, + }; + } + default: + // COLUMN, ROW, COLUMN_POSITION, ROW_POSITION + throw this.failure(`"${name}" can only be used inside REF().`, nameToken.span); + } + } + + private parseRefAnchor(): FormulaCellRefNode { + const token = this.peek(); + if (token === null || token.type !== 'identifier' || token.value.toUpperCase() !== 'REF') { + throw this.failure('RANGE() anchors must be REF() references.'); + } + const nameToken = this.next()!; + return this.parseRef(nameToken); + } + + private parseRef(nameToken: FormulaToken): FormulaCellRefNode { + this.expectPunctuation('('); + const column = this.parseColumnSelector(); + this.expectPunctuation(','); + const row = this.parseRowSelector(); + const closing = this.expectPunctuation(')'); + return { + type: 'cellRef', + column, + row, + span: { start: nameToken.span.start, end: closing.span.end }, + }; + } + + private parseColumnSelector(): FormulaColumnSelector { + const token = this.peek(); + if (token !== null && token.type === 'identifier') { + const upper = token.value.toUpperCase(); + if (upper === 'COLUMN') { + this.next(); + this.expectPunctuation('('); + const field = this.expectStringLiteral('COLUMN'); + this.expectPunctuation(')'); + return { kind: 'field', field }; + } + if (upper === 'COLUMN_POSITION') { + this.next(); + this.expectPunctuation('('); + const index = this.expectPositionLiteral('COLUMN_POSITION'); + this.expectPunctuation(')'); + return { kind: 'position', index }; + } + } + throw this.failure('Expected COLUMN("field") or COLUMN_POSITION(index).'); + } + + private parseRowSelector(): FormulaRowSelector { + const token = this.peek(); + if (token !== null && token.type === 'identifier') { + const upper = token.value.toUpperCase(); + if (upper === 'ROW') { + this.next(); + this.expectPunctuation('('); + // A sign before a number literal is still a literal: numeric row ids + // may be negative and the serializer must be able to round-trip them. + let negate = false; + let idToken = this.peek(); + if (idToken !== null && idToken.type === 'operator' && idToken.value === '-') { + const afterSign = this.tokens[this.index + 1]; + if (afterSign !== undefined && afterSign.type === 'number') { + this.next(); + negate = true; + idToken = this.peek(); + } + } + if (idToken === null || (idToken.type !== 'string' && idToken.type !== 'number')) { + throw this.failure('ROW() expects a row id as a string or number literal.'); + } + this.next(); + let id: string | number; + if (idToken.type === 'string') { + id = idToken.value; + } else { + const numericId = parseFloat(idToken.value); + if (!Number.isFinite(numericId)) { + throw this.failure('ROW() expects a finite number literal.', idToken.span); + } + id = negate ? -numericId : numericId; + } + this.expectPunctuation(')'); + return { kind: 'id', id }; + } + if (upper === 'ROW_POSITION') { + this.next(); + this.expectPunctuation('('); + const index = this.expectPositionLiteral('ROW_POSITION'); + this.expectPunctuation(')'); + return { kind: 'position', index }; + } + } + throw this.failure('Expected ROW(id) or ROW_POSITION(index).'); + } + + private expectStringLiteral(formName: string): string { + const token = this.peek(); + if (token === null || token.type !== 'string') { + throw this.failure(`${formName}() expects a string literal.`); + } + this.next(); + return token.value; + } + + private expectPositionLiteral(formName: string): number { + const token = this.peek(); + if (token === null || token.type !== 'number') { + throw this.failure(`${formName}() expects a number literal.`); + } + const index = parseFloat(token.value); + if (!Number.isInteger(index) || index < 1) { + throw this.failure( + `${formName}() expects a positive integer (1-based position).`, + token.span, + ); + } + this.next(); + return index; + } +} + +/** + * Parses a formula expression (the source without its leading `=`). + * Never throws: malformed input yields `{ ast: null, error }`. + */ +export function parseFormula(expression: string): FormulaParseResult { + const { tokens, error } = tokenizeFormula(expression); + if (error !== null) { + return { ast: null, error }; + } + try { + const ast = new Parser(tokens, expression.length).parse(); + return { ast, error: null }; + } catch (failure) { + if (failure instanceof ParseFailure) { + return { ast: null, error: { message: failure.message, span: failure.span } }; + } + if (failure instanceof RangeError) { + // Backstop for the never-throws contract; the depth/height bounds + // should make this unreachable. + return { + ast: null, + error: { + message: 'The formula is too complex.', + span: { start: 0, end: expression.length }, + }, + }; + } + throw failure; + } +} + +export interface FormulaParser { + parse: (expression: string) => FormulaParseResult; + clear: () => void; +} + +/** + * Parser with AST interning: identical source strings share one immutable + * parse result. With computed columns the same formula repeats once per row, + * so this turns N parses into 1 parse + N cache hits. + */ +export function createFormulaParser(): FormulaParser { + const cache = new Map(); + return { + parse(expression: string): FormulaParseResult { + let result = cache.get(expression); + if (result === undefined) { + result = parseFormula(expression); + cache.set(expression, result); + } + return result; + }, + clear() { + cache.clear(); + }, + }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.test.ts new file mode 100644 index 0000000000000..306a9bbe9ac06 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.test.ts @@ -0,0 +1,131 @@ +import { parseFormula } from './formulaParser'; +import { serializeFormulaAst } from './formulaSerializer'; +import type { FormulaAstNode } from './formulaAst'; + +const stripSpans = (node: unknown): unknown => { + if (Array.isArray(node)) { + return node.map(stripSpans); + } + if (typeof node === 'object' && node !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key !== 'span') { + result[key] = stripSpans(value); + } + } + return result; + } + return node; +}; + +const parseOk = (expression: string): FormulaAstNode => { + const { ast, error } = parseFormula(expression); + if (ast === null) { + throw new Error(`Test expression did not parse: ${error?.message}`); + } + return ast; +}; + +const expectSerialized = (expression: string, expected: string) => { + expect(serializeFormulaAst(parseOk(expression))).to.equal(expected); +}; + +/** + * parse -> serialize -> parse yields a structurally identical AST. + */ +const expectRoundTrip = (expression: string) => { + const ast = parseOk(expression); + const serialized = serializeFormulaAst(ast); + const reparsed = parseOk(serialized); + expect(stripSpans(reparsed)).to.deep.equal(stripSpans(ast)); + // Serialization is stable: a second round produces the identical string. + expect(serializeFormulaAst(reparsed)).to.equal(serialized); +}; + +describe('formulaSerializer', () => { + it('serializes literals', () => { + expectSerialized('1.5', '1.5'); + expectSerialized('"a""b"', '"a""b"'); + expectSerialized('true', 'TRUE'); + }); + + it('serializes operators with minimal parentheses', () => { + expectSerialized('1 + 2 * 3', '1 + 2 * 3'); + expectSerialized('(1 + 2) * 3', '(1 + 2) * 3'); + expectSerialized('1 * 2 + 3', '1 * 2 + 3'); + }); + + it('re-derives parentheses for right-nested same-precedence operands', () => { + expectSerialized('1 - (2 - 3)', '1 - (2 - 3)'); + // Left-nested needs none. + expectSerialized('1 - 2 - 3', '1 - 2 - 3'); + }); + + it('parenthesizes compound unary operands', () => { + expectSerialized('-(1 + 2)', '-(1 + 2)'); + expectSerialized('-(-1)', '-(-1)'); + expectSerialized('-price', '-price'); + }); + + it('preserves the unary-vs-power shape', () => { + expectSerialized('-2 ^ 2', '-2 ^ 2'); + expectSerialized('2 ^ -2', '2 ^ -2'); + }); + + it('serializes field refs bare when possible', () => { + expectSerialized('price', 'price'); + expectSerialized('FIELD("price")', 'price'); + }); + + it('serializes field refs through FIELD() when not bare-safe', () => { + expectSerialized('FIELD("unit price")', 'FIELD("unit price")'); + expectSerialized('FIELD("TRUE")', 'FIELD("TRUE")'); + expectSerialized('FIELD("a""b")', 'FIELD("a""b")'); + }); + + it('serializes special forms canonically', () => { + expectSerialized( + 'ref(column("total"), row("order-1"))', + 'REF(COLUMN("total"), ROW("order-1"))', + ); + expectSerialized('REF(COLUMN("a"), ROW(42))', 'REF(COLUMN("a"), ROW(42))'); + expectSerialized('REF(COLUMN("a"), ROW(-1))', 'REF(COLUMN("a"), ROW(-1))'); + expectSerialized( + 'REF(COLUMN_POSITION(2), ROW_POSITION(1))', + 'REF(COLUMN_POSITION(2), ROW_POSITION(1))', + ); + expectSerialized( + 'RANGE(REF(COLUMN("a"), ROW(1)), REF(COLUMN("b"), ROW(2)))', + 'RANGE(REF(COLUMN("a"), ROW(1)), REF(COLUMN("b"), ROW(2)))', + ); + expectSerialized('COLUMN_VALUES("price")', 'COLUMN_VALUES("price")'); + }); + + it('serializes function calls with uppercase names and ", " separators', () => { + expectSerialized('sum(a,b , 1)', 'SUM(a, b, 1)'); + expectSerialized('FOO()', 'FOO()'); + }); + + it('round-trips a corpus of expressions', () => { + const corpus = [ + '1 + 2 * 3 - 4 / 5', + '-2 ^ 2 ^ 3', + '(1 + 2) * (3 - 4)', + '"a" & "b" & price', + 'price * quantity >= 100', + 'IF(price > 100, "high", "low")', + 'IF(AND(a, OR(b, c)), SUM(d, e, 1.5), CONCAT("x", "y"))', + 'FIELD("unit price") * 2', + 'REF(COLUMN("total"), ROW("order-1")) + REF(COLUMN_POSITION(1), ROW_POSITION(2))', + 'REF(COLUMN("a"), ROW(-1))', + 'SUM(RANGE(REF(COLUMN("a"), ROW(1)), REF(COLUMN("b"), ROW_POSITION(5))))', + 'SUM(COLUMN_VALUES("price"), 1, two, "three")', + '1e308 * 5e-324', + 'a = b', + 'a <> b', + '1 - (2 - 3) - 4', + 'NOT(ISBLANK(note))', + ]; + corpus.forEach(expectRoundTrip); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.ts new file mode 100644 index 0000000000000..c5ac8beda4676 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaSerializer.ts @@ -0,0 +1,104 @@ +import { FORMULA_BINARY_PRECEDENCE } from './formulaAst'; +import type { FormulaAstNode, FormulaColumnSelector, FormulaRowSelector } from './formulaAst'; + +const BARE_FIELD_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function serializeString(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +function serializeFieldRef(field: string): string { + const upper = field.toUpperCase(); + // TRUE/FALSE parse as boolean literals when bare; other reserved names are + // only special when followed by `(`, so a bare `REF` round-trips fine. + if (BARE_FIELD_REGEX.test(field) && upper !== 'TRUE' && upper !== 'FALSE') { + return field; + } + return `FIELD(${serializeString(field)})`; +} + +function serializeColumnSelector(selector: FormulaColumnSelector): string { + if (selector.kind === 'field') { + return `COLUMN(${serializeString(selector.field)})`; + } + return `COLUMN_POSITION(${selector.index})`; +} + +function serializeRowSelector(selector: FormulaRowSelector): string { + if (selector.kind === 'id') { + return typeof selector.id === 'string' + ? `ROW(${serializeString(selector.id)})` + : `ROW(${selector.id})`; + } + return `ROW_POSITION(${selector.index})`; +} + +/** + * Wraps the operand in parentheses when its precedence is below the minimum + * the surrounding context requires. Unary expressions and atoms never need them. + */ +function serializeOperand(node: FormulaAstNode, minPrecedence: number): string { + const text = serializeNode(node); + if ( + node.type === 'binaryExpression' && + FORMULA_BINARY_PRECEDENCE[node.operator] < minPrecedence + ) { + return `(${text})`; + } + return text; +} + +function serializeNode(node: FormulaAstNode): string { + switch (node.type) { + case 'numberLiteral': + return String(node.value); + case 'stringLiteral': + return serializeString(node.value); + case 'booleanLiteral': + return node.value ? 'TRUE' : 'FALSE'; + case 'fieldRef': + return serializeFieldRef(node.field); + case 'cellRef': + return `REF(${serializeColumnSelector(node.column)}, ${serializeRowSelector(node.row)})`; + case 'range': + return `RANGE(${serializeNode(node.start)}, ${serializeNode(node.end)})`; + case 'columnValues': + return `COLUMN_VALUES(${serializeString(node.field)})`; + case 'unaryExpression': { + const operand = serializeNode(node.operand); + // Parenthesize compound operands: `-(1 + 2)`, `-(-1)`. + if (node.operand.type === 'binaryExpression' || node.operand.type === 'unaryExpression') { + return `${node.operator}(${operand})`; + } + return `${node.operator}${operand}`; + } + case 'binaryExpression': { + const precedence = FORMULA_BINARY_PRECEDENCE[node.operator]; + const left = serializeOperand(node.left, precedence); + // +1 re-derives left-associativity: an equal-precedence right child + // needs parentheses (`1 - (2 - 3)`). + const right = serializeOperand(node.right, precedence + 1); + return `${left} ${node.operator} ${right}`; + } + case 'functionCall': + return `${node.name}(${node.args.map(serializeNode).join(', ')})`; + default: + return ''; + } +} + +/** + * Serializes an AST back to a canonical expression string (without the leading + * `=`). Stable formatting: uppercase function names, `, ` separators, minimal + * parentheses re-derived from precedence. `parseFormula(serializeFormulaAst(ast))` + * yields a structurally identical AST (modulo source spans). + * + * The round-trip guarantee covers parser-produced ASTs. Hand-built ASTs must + * respect the parser's invariants: heights below the parser's depth bound, + * finite number literals, and negation encoded as a `unaryExpression` over a + * non-negative `numberLiteral` (negative `numberLiteral` values serialize to + * text the parser reads as a unary expression). + */ +export function serializeFormulaAst(node: FormulaAstNode): string { + return serializeNode(node); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.test.ts new file mode 100644 index 0000000000000..2f364effb9670 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.test.ts @@ -0,0 +1,133 @@ +import { tokenizeFormula } from './formulaTokenizer'; +import type { FormulaToken } from './formulaTokenizer'; + +const tokenValues = (expression: string): Array<[FormulaToken['type'], string]> => { + const { tokens, error } = tokenizeFormula(expression); + expect(error).to.equal(null); + return tokens.map((token) => [token.type, token.value]); +}; + +describe('formulaTokenizer', () => { + it('tokenizes numbers', () => { + expect(tokenValues('1 1.5 .5 12. 1e3 1.5e-3 2E+10')).to.deep.equal([ + ['number', '1'], + ['number', '1.5'], + ['number', '.5'], + ['number', '12.'], + ['number', '1e3'], + ['number', '1.5e-3'], + ['number', '2E+10'], + ]); + }); + + it('rejects an exponent without digits', () => { + const { error } = tokenizeFormula('1e'); + expect(error?.message).to.equal('Invalid number literal.'); + }); + + it('tokenizes strings and unescapes ""', () => { + expect(tokenValues('"hello" "a""b" ""')).to.deep.equal([ + ['string', 'hello'], + ['string', 'a"b'], + ['string', ''], + ]); + }); + + it('rejects an unterminated string', () => { + const { error } = tokenizeFormula('"abc'); + expect(error?.message).to.equal('Unterminated string literal.'); + expect(error?.span).to.deep.equal({ start: 0, end: 4 }); + }); + + it('treats a closing quote followed by an escape pair correctly', () => { + // `"a""` is an unterminated string: `""` escapes, then EOF. + const { error } = tokenizeFormula('"a""'); + expect(error?.message).to.equal('Unterminated string literal.'); + }); + + it('tokenizes identifiers', () => { + expect(tokenValues('price _total SUM x1')).to.deep.equal([ + ['identifier', 'price'], + ['identifier', '_total'], + ['identifier', 'SUM'], + ['identifier', 'x1'], + ]); + }); + + it('tokenizes all operators including multi-character ones', () => { + expect(tokenValues('+ - * / ^ & = < <= > >= <>')).to.deep.equal([ + ['operator', '+'], + ['operator', '-'], + ['operator', '*'], + ['operator', '/'], + ['operator', '^'], + ['operator', '&'], + ['operator', '='], + ['operator', '<'], + ['operator', '<='], + ['operator', '>'], + ['operator', '>='], + ['operator', '<>'], + ]); + }); + + it('tokenizes multi-character operators without spaces', () => { + expect(tokenValues('a<=b<>c>=d')).to.deep.equal([ + ['identifier', 'a'], + ['operator', '<='], + ['identifier', 'b'], + ['operator', '<>'], + ['identifier', 'c'], + ['operator', '>='], + ['identifier', 'd'], + ]); + }); + + it('tokenizes punctuation', () => { + expect(tokenValues('SUM(a, b)')).to.deep.equal([ + ['identifier', 'SUM'], + ['punctuation', '('], + ['identifier', 'a'], + ['punctuation', ','], + ['identifier', 'b'], + ['punctuation', ')'], + ]); + }); + + it('skips whitespace', () => { + expect(tokenValues(' 1\t+\n2\r ')).to.deep.equal([ + ['number', '1'], + ['operator', '+'], + ['number', '2'], + ]); + }); + + it('rejects unexpected characters with their position', () => { + const { error } = tokenizeFormula('1 + @'); + expect(error?.message).to.equal('Unexpected character "@".'); + expect(error?.span).to.deep.equal({ start: 4, end: 5 }); + }); + + it('rejects the a1-dialect characters in the canonical dialect', () => { + expect(tokenizeFormula('a:b').error?.message).to.equal('Unexpected character ":".'); + expect(tokenizeFormula('$a').error?.message).to.equal('Unexpected character "$".'); + }); + + it('records spans pointing into the source', () => { + const { tokens } = tokenizeFormula('ab + 12'); + expect(tokens[0].span).to.deep.equal({ start: 0, end: 2 }); + expect(tokens[1].span).to.deep.equal({ start: 3, end: 4 }); + expect(tokens[2].span).to.deep.equal({ start: 5, end: 7 }); + }); + + it('tokenizes an empty expression to no tokens', () => { + expect(tokenizeFormula('').tokens).to.have.length(0); + expect(tokenizeFormula(' ').tokens).to.have.length(0); + }); + + it('keeps the tokens produced before an error', () => { + const { tokens, error } = tokenizeFormula('1 + #'); + expect(error).not.to.equal(null); + expect(tokens.map((token) => token.value)).to.deep.equal(['1', '+']); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.ts new file mode 100644 index 0000000000000..77fb0769886e6 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTokenizer.ts @@ -0,0 +1,173 @@ +import type { FormulaSourceSpan } from './formulaTypes'; + +export type FormulaTokenType = 'number' | 'string' | 'identifier' | 'operator' | 'punctuation'; + +export interface FormulaToken { + type: FormulaTokenType; + /** + * For `string` tokens this is the unescaped value (`""` -> `"`); + * for every other type it is the raw source text. + */ + value: string; + span: FormulaSourceSpan; +} + +export interface FormulaTokenizeError { + message: string; + span: FormulaSourceSpan; +} + +export interface FormulaTokenizeResult { + tokens: FormulaToken[]; + error: FormulaTokenizeError | null; +} + +const isDigit = (charCode: number) => charCode >= 48 && charCode <= 57; // 0-9 + +const isIdentifierStart = (charCode: number) => + (charCode >= 65 && charCode <= 90) || // A-Z + (charCode >= 97 && charCode <= 122) || // a-z + charCode === 95; // _ + +const isIdentifierPart = (charCode: number) => isIdentifierStart(charCode) || isDigit(charCode); + +const isWhitespace = (char: string) => + char === ' ' || char === '\t' || char === '\n' || char === '\r'; + +const SINGLE_CHAR_OPERATORS = new Set(['+', '-', '*', '/', '^', '&', '=']); +const PUNCTUATION = new Set(['(', ')', ',']); + +/** + * Tokenizes a formula expression (the source without its leading `=`). + * On error, `tokens` contains everything tokenized up to the error position. + */ +export function tokenizeFormula(expression: string): FormulaTokenizeResult { + const tokens: FormulaToken[] = []; + let index = 0; + const { length } = expression; + + const failure = (message: string, start: number, end: number): FormulaTokenizeResult => ({ + tokens, + error: { message, span: { start, end } }, + }); + + while (index < length) { + const char = expression[index]; + + if (isWhitespace(char)) { + index += 1; + continue; + } + + const start = index; + const charCode = expression.charCodeAt(index); + + // Number literal: starts with a digit, or `.` followed by a digit. + if (isDigit(charCode) || (char === '.' && isDigit(expression.charCodeAt(index + 1)))) { + while (index < length && isDigit(expression.charCodeAt(index))) { + index += 1; + } + if (expression[index] === '.') { + index += 1; + while (index < length && isDigit(expression.charCodeAt(index))) { + index += 1; + } + } + if (expression[index] === 'e' || expression[index] === 'E') { + let exponentIndex = index + 1; + if (expression[exponentIndex] === '+' || expression[exponentIndex] === '-') { + exponentIndex += 1; + } + if (!isDigit(expression.charCodeAt(exponentIndex))) { + return failure('Invalid number literal.', start, exponentIndex); + } + index = exponentIndex; + while (index < length && isDigit(expression.charCodeAt(index))) { + index += 1; + } + } + tokens.push({ + type: 'number', + value: expression.slice(start, index), + span: { start, end: index }, + }); + continue; + } + + if (char === '"') { + let value = ''; + index += 1; + let closed = false; + while (index < length) { + const current = expression[index]; + if (current === '"') { + if (expression[index + 1] === '"') { + // `""` escapes a literal quote (spreadsheet convention). + value += '"'; + index += 2; + continue; + } + index += 1; + closed = true; + break; + } + value += current; + index += 1; + } + if (!closed) { + return failure('Unterminated string literal.', start, length); + } + tokens.push({ type: 'string', value, span: { start, end: index } }); + continue; + } + + if (isIdentifierStart(charCode)) { + index += 1; + while (index < length && isIdentifierPart(expression.charCodeAt(index))) { + index += 1; + } + tokens.push({ + type: 'identifier', + value: expression.slice(start, index), + span: { start, end: index }, + }); + continue; + } + + if (char === '<') { + const next = expression[index + 1]; + let value = '<'; + if (next === '=') { + value = '<='; + } else if (next === '>') { + value = '<>'; + } + index += value.length; + tokens.push({ type: 'operator', value, span: { start, end: index } }); + continue; + } + + if (char === '>') { + const value = expression[index + 1] === '=' ? '>=' : '>'; + index += value.length; + tokens.push({ type: 'operator', value, span: { start, end: index } }); + continue; + } + + if (SINGLE_CHAR_OPERATORS.has(char)) { + index += 1; + tokens.push({ type: 'operator', value: char, span: { start, end: index } }); + continue; + } + + if (PUNCTUATION.has(char)) { + index += 1; + tokens.push({ type: 'punctuation', value: char, span: { start, end: index } }); + continue; + } + + return failure(`Unexpected character "${char}".`, start, start + 1); + } + + return { tokens, error: null }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.test.ts new file mode 100644 index 0000000000000..196ba652b77e0 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.test.ts @@ -0,0 +1,54 @@ +import { + createFormulaCellKey, + getFormulaCellKey, + getFormulaExpression, + isEscapedFormulaSource, + isFormulaSource, + parseFormulaCellKey, + unescapeLiteralSource, +} from './formulaTypes'; + +describe('formulaTypes', () => { + describe('cell keys', () => { + it('round-trips id and field', () => { + const key = createFormulaCellKey('order-1', 'total'); + expect(parseFormulaCellKey(key)).to.deep.equal({ id: 'order-1', field: 'total' }); + }); + + it('builds the same key from a ref object', () => { + expect(getFormulaCellKey({ id: 'a', field: 'b' })).to.equal(createFormulaCellKey('a', 'b')); + }); + + it('does not collide for ids and fields that concatenate identically', () => { + expect(createFormulaCellKey('ab', 'c')).not.to.equal(createFormulaCellKey('a', 'bc')); + }); + + it('stringifies numeric ids (documented coercion)', () => { + expect(createFormulaCellKey(1, 'total')).to.equal(createFormulaCellKey('1', 'total')); + }); + }); + + describe('formula source detection', () => { + it('recognizes strings with a leading =', () => { + expect(isFormulaSource('=1 + 1')).to.equal(true); + expect(isFormulaSource('=')).to.equal(true); + expect(isFormulaSource('1 + 1')).to.equal(false); + expect(isFormulaSource(' =1')).to.equal(false); + expect(isFormulaSource(42)).to.equal(false); + expect(isFormulaSource(null)).to.equal(false); + expect(isFormulaSource(undefined)).to.equal(false); + }); + + it('recognizes the apostrophe escape for literal =', () => { + expect(isEscapedFormulaSource("'=not a formula")).to.equal(true); + expect(isEscapedFormulaSource('=formula')).to.equal(false); + expect(isEscapedFormulaSource("'plain")).to.equal(false); + expect(unescapeLiteralSource("'=text")).to.equal('=text'); + }); + + it('strips the leading = from formula source', () => { + expect(getFormulaExpression('=1 + 1')).to.equal('1 + 1'); + expect(getFormulaExpression('1 + 1')).to.equal('1 + 1'); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.ts new file mode 100644 index 0000000000000..19e0b69308384 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaTypes.ts @@ -0,0 +1,136 @@ +import type { FormulaErrorCode } from './formulaErrors'; + +/** + * Structural twin of `GridRowId`. + * The engine defines it locally so it has zero grid imports and stays extractable. + */ +export type FormulaRowId = string | number; + +/** + * Structural twin of `GridCellCoordinates`. + */ +export interface FormulaCellRef { + id: FormulaRowId; + field: string; +} + +/** + * Serialized cell key used in maps and sets. + * Runtime value is a string; the brand prevents arbitrary strings in TS. + */ +export type FormulaCellKey = string & { __formulaCellKey: true }; + +/** + * `\u0000` (NUL) cannot appear in field names coming from column definitions, + * so it is a collision-free separator. Note that row ids are stringified: + * the numeric id `1` and the string id `"1"` produce the same key. Grid row + * ids are documented as unique under string coercion. + */ +const CELL_KEY_SEPARATOR = '\u0000'; + +export function createFormulaCellKey(id: FormulaRowId, field: string): FormulaCellKey { + return `${id}${CELL_KEY_SEPARATOR}${field}` as FormulaCellKey; +} + +export function getFormulaCellKey(ref: FormulaCellRef): FormulaCellKey { + return createFormulaCellKey(ref.id, ref.field); +} + +/** + * Inverse of `createFormulaCellKey`. The id is returned as a string + * (numeric ids are stringified by the key format). + */ +export function parseFormulaCellKey(key: FormulaCellKey): { id: string; field: string } { + const separatorIndex = key.indexOf(CELL_KEY_SEPARATOR); + return { + id: key.slice(0, separatorIndex), + field: key.slice(separatorIndex + 1), + }; +} + +/** + * The scalar value domain of formula evaluation. `null` represents an empty cell. + */ +export type FormulaScalar = number | string | boolean | Date | null; + +/** + * A materialized range of already-evaluated cell values. + * Only valid as a direct argument to functions declaring `acceptsRanges`. + */ +export interface FormulaRangeValue { + kind: 'range'; + values: FormulaScalar[]; +} + +export function isFormulaRangeValue(value: unknown): value is FormulaRangeValue { + return ( + typeof value === 'object' && value !== null && (value as { kind?: unknown }).kind === 'range' + ); +} + +/** + * The outcome of evaluating one formula cell. + */ +export type FormulaResult = + | { type: 'value'; value: FormulaScalar } + | { type: 'error'; code: FormulaErrorCode; message?: string }; + +/** + * Snapshot of the grid's position semantics supplied by the adapter: + * sorted + filtered data-row order and visible column order. + * All indexes are 1-based to match the editor-facing A1 semantics. + */ +export interface FormulaPositionContext { + version: number; + rowCount: number; + columnCount: number; + getRowIdAtPosition: (index: number) => FormulaRowId | undefined; + getPositionOfRowId: (id: FormulaRowId) => number | undefined; + getFieldAtPosition: (index: number) => string | undefined; + getPositionOfField: (field: string) => number | undefined; +} + +export interface FormulaSourceSpan { + start: number; + end: number; +} + +export interface FormulaValidationIssue { + code: FormulaErrorCode; + message: string; + span?: FormulaSourceSpan; +} + +export interface FormulaValidationResult { + valid: boolean; + issues: FormulaValidationIssue[]; +} + +/** + * A raw cell value is formula source when it is a string starting with `=`. + */ +export function isFormulaSource(raw: unknown): raw is string { + return typeof raw === 'string' && raw.charCodeAt(0) === 61; // '=' +} + +/** + * A raw cell value starting with `'=` is an escaped literal (spreadsheet convention): + * the cell displays everything after the apostrophe and is never evaluated. + */ +export function isEscapedFormulaSource(raw: unknown): raw is string { + return typeof raw === 'string' && raw.startsWith("'="); +} + +/** + * `"'=foo"` -> `"=foo"`. + */ +export function unescapeLiteralSource(raw: string): string { + return raw.slice(1); +} + +/** + * Strips the leading `=` from formula source, yielding the parseable expression. + */ +export function getFormulaExpression(source: string): string { + return source.charCodeAt(0) === 61 ? source.slice(1) : source; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.test.ts new file mode 100644 index 0000000000000..9748f697c24b0 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.test.ts @@ -0,0 +1,78 @@ +import { createFormulaFunctionRegistry } from './formulaFunctions'; +import { validateFormulaExpression } from './formulaValidation'; + +describe('formulaValidation', () => { + it('accepts a well-formed expression', () => { + expect(validateFormulaExpression('price * quantity')).to.deep.equal({ + valid: true, + issues: [], + }); + }); + + it('reports a syntax error with its span', () => { + const result = validateFormulaExpression('1 +'); + expect(result.valid).to.equal(false); + expect(result.issues).to.have.length(1); + expect(result.issues[0].code).to.equal('#ERROR!'); + expect(result.issues[0].message).to.equal('Unexpected end of formula.'); + expect(result.issues[0].span).to.deep.equal({ start: 3, end: 3 }); + }); + + it('does not check function names without a registry', () => { + expect(validateFormulaExpression('NOPE(1)').valid).to.equal(true); + }); + + it('reports unknown function names with their span when a registry is provided', () => { + const functions = createFormulaFunctionRegistry(); + const result = validateFormulaExpression('NOPE(SUM(1))', { functions }); + expect(result.valid).to.equal(false); + expect(result.issues).to.deep.equal([ + { code: '#NAME?', message: 'Unknown function "NOPE".', span: { start: 0, end: 12 } }, + ]); + }); + + it('reports an unknown function once even when called repeatedly', () => { + const functions = createFormulaFunctionRegistry(); + const result = validateFormulaExpression('NOPE(1) + NOPE(2)', { functions }); + expect(result.issues).to.have.length(1); + }); + + it('reports arity violations as #VALUE! issues with their span', () => { + const functions = createFormulaFunctionRegistry(); + const tooMany = validateFormulaExpression('ABS(1, 2)', { functions }); + expect(tooMany.valid).to.equal(false); + expect(tooMany.issues).to.deep.equal([ + { + code: '#VALUE!', + message: 'ABS() expects at most 1 argument(s).', + span: { start: 0, end: 9 }, + }, + ]); + + const tooFew = validateFormulaExpression('IF(TRUE)', { functions }); + expect(tooFew.issues[0].message).to.equal('IF() expects at least 2 argument(s).'); + }); + + it('does not limit variadic functions', () => { + const functions = createFormulaFunctionRegistry(); + expect(validateFormulaExpression('SUM(1, 2, 3, 4, 5)', { functions }).valid).to.equal(true); + }); + + it('does not report a spurious arity issue for unknown functions', () => { + const functions = createFormulaFunctionRegistry(); + const result = validateFormulaExpression('NOPE()', { functions }); + expect(result.issues).to.have.length(1); + expect(result.issues[0].code).to.equal('#NAME?'); + }); + + it('accepts known functions case-insensitively', () => { + const functions = createFormulaFunctionRegistry(); + expect(validateFormulaExpression('sum(1, 2)', { functions }).valid).to.equal(true); + }); + + it('never throws for malformed input (validation is informative, not blocking)', () => { + expect(validateFormulaExpression('').valid).to.equal(false); + expect(validateFormulaExpression('"unterminated').valid).to.equal(false); + expect(validateFormulaExpression('@@@').valid).to.equal(false); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.ts new file mode 100644 index 0000000000000..82171e5a16020 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValidation.ts @@ -0,0 +1,87 @@ +import { parseFormula } from './formulaParser'; +import type { FormulaAstNode } from './formulaAst'; +import type { FormulaValidationIssue, FormulaValidationResult } from './formulaTypes'; +import { getFormulaFunctionArityError } from './formulaFunctions'; +import type { FormulaFunctionRegistry } from './formulaFunctions'; + +export interface ValidateFormulaExpressionOptions { + /** + * When provided, function calls are checked: unknown names are reported as + * `#NAME?` issues and arity violations as `#VALUE!` issues. + */ + functions?: FormulaFunctionRegistry; +} + +function collectFunctionCallIssues( + ast: FormulaAstNode, + functions: FormulaFunctionRegistry, +): FormulaValidationIssue[] { + const issues: FormulaValidationIssue[] = []; + const reportedUnknownNames = new Set(); + const stack: FormulaAstNode[] = [ast]; + while (stack.length > 0) { + const node = stack.pop()!; + switch (node.type) { + case 'functionCall': { + const definition = functions.get(node.name); + if (definition === undefined) { + if (!reportedUnknownNames.has(node.name)) { + reportedUnknownNames.add(node.name); + issues.push({ + code: '#NAME?', + message: `Unknown function "${node.name}".`, + span: node.span, + }); + } + } else { + const arityError = getFormulaFunctionArityError(definition, node.args.length); + if (arityError !== null) { + issues.push({ code: '#VALUE!', message: arityError.message!, span: node.span }); + } + } + for (let i = node.args.length - 1; i >= 0; i -= 1) { + stack.push(node.args[i]); + } + break; + } + case 'unaryExpression': + stack.push(node.operand); + break; + case 'binaryExpression': + stack.push(node.right); + stack.push(node.left); + break; + default: + break; + } + } + return issues; +} + +/** + * Static validation of a formula expression (the source without its leading + * `=`): syntax and, when a registry is provided, function-name resolution and + * argument arity. This powers editor hints — validation is informative, + * never commit-blocking. + */ +export function validateFormulaExpression( + expression: string, + options: ValidateFormulaExpressionOptions = {}, +): FormulaValidationResult { + const { ast, error } = parseFormula(expression); + if (ast === null) { + const issue: FormulaValidationIssue = { + code: '#ERROR!', + message: error?.message ?? 'The formula could not be parsed.', + }; + if (error !== null) { + issue.span = error.span; + } + return { valid: false, issues: [issue] }; + } + + const issues = + options.functions === undefined ? [] : collectFunctionCallIssues(ast, options.functions); + + return { valid: issues.length === 0, issues }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.test.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.test.ts new file mode 100644 index 0000000000000..cd450048e2d1c --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.test.ts @@ -0,0 +1,225 @@ +import { isFormulaErrorValue } from './formulaErrors'; +import { + compareFormulaScalars, + isEmptyFormulaValue, + toFormulaBoolean, + toFormulaNumber, + toFormulaText, +} from './formulaValues'; + +const expectError = (value: unknown, code: string) => { + expect(isFormulaErrorValue(value)).to.equal(true); + expect((value as { code: string }).code).to.equal(code); +}; + +describe('formulaValues', () => { + describe('toFormulaNumber', () => { + it('passes finite numbers through', () => { + expect(toFormulaNumber(1.5)).to.equal(1.5); + expect(toFormulaNumber(-2)).to.equal(-2); + expect(toFormulaNumber(0)).to.equal(0); + }); + + it('rejects non-finite numbers', () => { + expectError(toFormulaNumber(NaN), '#VALUE!'); + expectError(toFormulaNumber(Infinity), '#VALUE!'); + }); + + it('parses numeric strings strictly', () => { + expect(toFormulaNumber('5')).to.equal(5); + expect(toFormulaNumber(' 5.5 ')).to.equal(5.5); + expect(toFormulaNumber('-3')).to.equal(-3); + expect(toFormulaNumber('1e3')).to.equal(1000); + expect(toFormulaNumber('.5')).to.equal(0.5); + }); + + it('rejects non-numeric and lenient-only strings', () => { + expectError(toFormulaNumber('abc'), '#VALUE!'); + expectError(toFormulaNumber(''), '#VALUE!'); + expectError(toFormulaNumber('0x10'), '#VALUE!'); + expectError(toFormulaNumber('Infinity'), '#VALUE!'); + expectError(toFormulaNumber('1,000'), '#VALUE!'); + }); + + it('coerces booleans to 1/0', () => { + expect(toFormulaNumber(true)).to.equal(1); + expect(toFormulaNumber(false)).to.equal(0); + }); + + it('coerces empty to 0', () => { + expect(toFormulaNumber(null)).to.equal(0); + expect(toFormulaNumber(undefined)).to.equal(0); + }); + + it('coerces dates to epoch milliseconds', () => { + const date = new Date(1000); + expect(toFormulaNumber(date)).to.equal(1000); + }); + + it('rejects other objects', () => { + expectError(toFormulaNumber({}), '#VALUE!'); + }); + }); + + describe('toFormulaText', () => { + it('serializes numbers with a dot decimal separator', () => { + expect(toFormulaText(1.5)).to.equal('1.5'); + expect(toFormulaText(-2)).to.equal('-2'); + }); + + it('passes strings through', () => { + expect(toFormulaText('a')).to.equal('a'); + }); + + it('serializes booleans as TRUE/FALSE', () => { + expect(toFormulaText(true)).to.equal('TRUE'); + expect(toFormulaText(false)).to.equal('FALSE'); + }); + + it('serializes empty as the empty string', () => { + expect(toFormulaText(null)).to.equal(''); + expect(toFormulaText(undefined)).to.equal(''); + }); + + it('serializes dates as ISO strings', () => { + expect(toFormulaText(new Date(Date.UTC(2024, 0, 1)))).to.equal('2024-01-01T00:00:00.000Z'); + }); + + it('rejects other objects', () => { + expectError(toFormulaText({}), '#VALUE!'); + }); + }); + + describe('toFormulaBoolean', () => { + it('passes booleans through', () => { + expect(toFormulaBoolean(true)).to.equal(true); + expect(toFormulaBoolean(false)).to.equal(false); + }); + + it('coerces numbers (zero is false)', () => { + expect(toFormulaBoolean(0)).to.equal(false); + expect(toFormulaBoolean(2)).to.equal(true); + expect(toFormulaBoolean(-1)).to.equal(true); + }); + + it('rejects NaN', () => { + expectError(toFormulaBoolean(NaN), '#VALUE!'); + }); + + it('coerces TRUE/FALSE strings case-insensitively', () => { + expect(toFormulaBoolean('true')).to.equal(true); + expect(toFormulaBoolean(' FALSE ')).to.equal(false); + }); + + it('rejects other strings', () => { + expectError(toFormulaBoolean('yes'), '#VALUE!'); + expectError(toFormulaBoolean(''), '#VALUE!'); + }); + + it('coerces empty to false', () => { + expect(toFormulaBoolean(null)).to.equal(false); + expect(toFormulaBoolean(undefined)).to.equal(false); + }); + + it('rejects dates', () => { + expectError(toFormulaBoolean(new Date()), '#VALUE!'); + }); + }); + + describe('isEmptyFormulaValue', () => { + it('treats only null/undefined as empty', () => { + expect(isEmptyFormulaValue(null)).to.equal(true); + expect(isEmptyFormulaValue(undefined)).to.equal(true); + expect(isEmptyFormulaValue(0)).to.equal(false); + expect(isEmptyFormulaValue('')).to.equal(false); + expect(isEmptyFormulaValue(false)).to.equal(false); + }); + }); + + describe('compareFormulaScalars', () => { + describe('equality', () => { + it('compares numbers', () => { + expect(compareFormulaScalars('=', 1, 1)).to.equal(true); + expect(compareFormulaScalars('<>', 1, 2)).to.equal(true); + }); + + it('compares strings case-insensitively (Excel behavior)', () => { + expect(compareFormulaScalars('=', 'a', 'A')).to.equal(true); + expect(compareFormulaScalars('=', 'a', 'b')).to.equal(false); + expect(compareFormulaScalars('<>', 'a', 'A')).to.equal(false); + }); + + it('compares dates by timestamp', () => { + expect(compareFormulaScalars('=', new Date(1000), new Date(1000))).to.equal(true); + expect(compareFormulaScalars('=', new Date(1000), new Date(2000))).to.equal(false); + }); + + it('treats empty as equal only to empty', () => { + expect(compareFormulaScalars('=', null, null)).to.equal(true); + expect(compareFormulaScalars('=', null, 0)).to.equal(false); + expect(compareFormulaScalars('=', null, '')).to.equal(false); + expect(compareFormulaScalars('=', null, false)).to.equal(false); + expect(compareFormulaScalars('<>', null, 0)).to.equal(true); + }); + + it('returns false (not an error) for cross-type equality', () => { + expect(compareFormulaScalars('=', 1, '1')).to.equal(false); + expect(compareFormulaScalars('<>', 1, '1')).to.equal(true); + expect(compareFormulaScalars('=', true, 1)).to.equal(false); + }); + }); + + describe('ordered comparison', () => { + it('compares numbers', () => { + expect(compareFormulaScalars('<', 1, 2)).to.equal(true); + expect(compareFormulaScalars('>=', 2, 2)).to.equal(true); + expect(compareFormulaScalars('>', 1, 2)).to.equal(false); + }); + + it('compares strings case-insensitively', () => { + expect(compareFormulaScalars('<', 'a', 'B')).to.equal(true); + expect(compareFormulaScalars('<=', 'A', 'a')).to.equal(true); + expect(compareFormulaScalars('>', 'b', 'A')).to.equal(true); + }); + + it('compares booleans (FALSE < TRUE)', () => { + expect(compareFormulaScalars('<', false, true)).to.equal(true); + expect(compareFormulaScalars('>', true, false)).to.equal(true); + }); + + it('compares dates by timestamp', () => { + expect(compareFormulaScalars('<', new Date(1000), new Date(2000))).to.equal(true); + }); + + it('substitutes a type-neutral value for empty operands', () => { + expect(compareFormulaScalars('<', null, 5)).to.equal(true); // empty -> 0 + expect(compareFormulaScalars('>', 5, null)).to.equal(true); + expect(compareFormulaScalars('<', null, 'a')).to.equal(true); // empty -> '' + expect(compareFormulaScalars('<', null, new Date(1000))).to.equal(true); // empty -> epoch + expect(compareFormulaScalars('>', new Date(1000), null)).to.equal(true); + expect(compareFormulaScalars('<=', null, new Date(0))).to.equal(true); + expect(compareFormulaScalars('<=', null, null)).to.equal(true); + expect(compareFormulaScalars('<', null, null)).to.equal(false); + }); + + it('rejects cross-type ordered comparison', () => { + expectError(compareFormulaScalars('<', 1, 'a'), '#VALUE!'); + expectError(compareFormulaScalars('>', true, 1), '#VALUE!'); + expectError(compareFormulaScalars('<', new Date(), 1), '#VALUE!'); + }); + + it('rejects ordered comparison with an invalid Date', () => { + const invalid = new Date(NaN); + expectError(compareFormulaScalars('<', invalid, new Date(0)), '#VALUE!'); + expectError(compareFormulaScalars('>=', new Date(0), invalid), '#VALUE!'); + }); + + it('treats an invalid Date as never equal, including to itself', () => { + const invalid = new Date(NaN); + expect(compareFormulaScalars('=', invalid, invalid)).to.equal(false); + expect(compareFormulaScalars('<>', invalid, invalid)).to.equal(true); + expect(compareFormulaScalars('=', invalid, new Date(0))).to.equal(false); + }); + }); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.ts new file mode 100644 index 0000000000000..aefac7519cd5c --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/formulaValues.ts @@ -0,0 +1,224 @@ +import { createFormulaError } from './formulaErrors'; +import type { FormulaErrorValue } from './formulaErrors'; +import type { FormulaBinaryOperator } from './formulaAst'; + +/** + * Strict numeric string: optional sign, decimal point with `.` only, optional exponent. + * Deliberately rejects `0x10`, `Infinity`, thousands separators and empty strings. + */ +const NUMERIC_STRING_REGEX = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/; + +/** + * Coercion to the numeric context (`+ - * / ^`, math functions). + * number -> itself; string -> trimmed strict parse; boolean -> 1/0; + * empty -> 0; Date -> epoch milliseconds (documented deviation from Excel serial dates). + */ +export function toFormulaNumber(value: unknown): number | FormulaErrorValue { + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return createFormulaError('#VALUE!', 'The value is not a finite number.'); + } + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (NUMERIC_STRING_REGEX.test(trimmed)) { + return parseFloat(trimmed); + } + return createFormulaError('#VALUE!', `Cannot convert "${value}" to a number.`); + } + if (typeof value === 'boolean') { + return value ? 1 : 0; + } + if (value === null || value === undefined) { + return 0; + } + if (value instanceof Date) { + return value.getTime(); + } + return createFormulaError('#VALUE!', 'Cannot convert the value to a number.'); +} + +/** + * Coercion to the text context (`&`, text functions). + * Numbers serialize with `.` decimal separator, locale-independent. + */ +export function toFormulaText(value: unknown): string | FormulaErrorValue { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number') { + return String(value); + } + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE'; + } + if (value === null || value === undefined) { + return ''; + } + if (value instanceof Date) { + return value.toISOString(); + } + return createFormulaError('#VALUE!', 'Cannot convert the value to text.'); +} + +/** + * Coercion to the boolean context (IF condition, AND/OR/NOT). + */ +export function toFormulaBoolean(value: unknown): boolean | FormulaErrorValue { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) { + return createFormulaError('#VALUE!', 'The value is not a valid number.'); + } + return value !== 0; + } + if (typeof value === 'string') { + const upper = value.trim().toUpperCase(); + if (upper === 'TRUE') { + return true; + } + if (upper === 'FALSE') { + return false; + } + return createFormulaError('#VALUE!', `Cannot convert "${value}" to a boolean.`); + } + if (value === null || value === undefined) { + return false; + } + return createFormulaError('#VALUE!', 'Cannot convert the value to a boolean.'); +} + +/** + * Empty-cell test: `null`/`undefined` are empty; `0`, `''` and `false` are not. + */ +export function isEmptyFormulaValue(value: unknown): boolean { + return value === null || value === undefined; +} + +function areFormulaScalarsEqual(left: unknown, right: unknown): boolean { + const l = left === undefined ? null : left; + const r = right === undefined ? null : right; + if (l === null && r === null) { + return true; + } + if (l === null || r === null) { + // Deviation from Excel, documented: empty only equals empty + // (never 0, '' or FALSE) to avoid three-way ambiguity. + return false; + } + if (typeof l === 'string' && typeof r === 'string') { + // Excel behavior: string comparison is case-insensitive ("a" = "A" is TRUE). + return l.toLowerCase() === r.toLowerCase(); + } + if (typeof l === 'number' && typeof r === 'number') { + return l === r; + } + if (typeof l === 'boolean' && typeof r === 'boolean') { + return l === r; + } + if (l instanceof Date && r instanceof Date) { + // Invalid Dates (NaN time) are never equal to anything, including + // themselves — mirrors NaN number equality. Ordered comparison of + // Invalid Dates is #VALUE! (see compareFormulaScalars), mirroring + // the NaN number guard there. + return l.getTime() === r.getTime(); + } + // Cross-type equality is FALSE, never an error. + return false; +} + +/** + * Neutral substitute for an empty operand in an ordered comparison, derived + * from the other operand's type: number -> 0, string -> '', boolean -> FALSE, + * Date -> epoch (consistent with the `Date coerces via getTime` rule). + */ +function neutralForOrderedComparison(other: unknown): unknown { + if (typeof other === 'number') { + return 0; + } + if (typeof other === 'string') { + return ''; + } + if (typeof other === 'boolean') { + return false; + } + if (other instanceof Date) { + return new Date(0); + } + return null; +} + +type ComparisonOperator = Extract' | '<' | '<=' | '>' | '>='>; + +function compareOrdered(left: T, right: T): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +/** + * Comparison semantics shared by the evaluator and function implementations. + * Equality across different types is FALSE; ordered comparison across + * different types is `#VALUE!` (deliberate deviation from Excel's total order). + * All string comparisons are case-insensitive (Excel behavior); the decision + * is isolated here so it stays one-line reversible. + */ +export function compareFormulaScalars( + operator: ComparisonOperator, + left: unknown, + right: unknown, +): boolean | FormulaErrorValue { + if (operator === '=') { + return areFormulaScalarsEqual(left, right); + } + if (operator === '<>') { + return !areFormulaScalarsEqual(left, right); + } + + let l: unknown = left === undefined ? null : left; + let r: unknown = right === undefined ? null : right; + if (l === null && r === null) { + return operator === '<=' || operator === '>='; + } + if (l === null) { + l = neutralForOrderedComparison(r); + } else if (r === null) { + r = neutralForOrderedComparison(l); + } + + let comparison: number; + if (typeof l === 'number' && typeof r === 'number') { + if (Number.isNaN(l) || Number.isNaN(r)) { + return createFormulaError('#VALUE!', 'Cannot compare invalid numbers.'); + } + comparison = compareOrdered(l, r); + } else if (typeof l === 'string' && typeof r === 'string') { + comparison = compareOrdered(l.toLowerCase(), r.toLowerCase()); + } else if (typeof l === 'boolean' && typeof r === 'boolean') { + comparison = Number(l) - Number(r); + } else if (l instanceof Date && r instanceof Date) { + const leftTime = l.getTime(); + const rightTime = r.getTime(); + if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) { + return createFormulaError('#VALUE!', 'Cannot compare invalid dates.'); + } + comparison = leftTime - rightTime; + } else { + return createFormulaError('#VALUE!', 'Cannot compare values of different types.'); + } + + switch (operator) { + case '<': + return comparison < 0; + case '<=': + return comparison <= 0; + case '>': + return comparison > 0; + default: + return comparison >= 0; + } +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/index.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/index.ts new file mode 100644 index 0000000000000..4ed180d5b0cb9 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/index.ts @@ -0,0 +1,128 @@ +/** + * Pure formula engine: tokenize, parse, serialize, extract dependencies, + * evaluate, and order recomputation. No React, no grid imports — files in + * this folder may only import from this folder. + * + * The engine surface is internal to the package; it is deliberately not + * re-exported from the public barrel. + */ +export type { + FormulaCellKey, + FormulaCellRef, + FormulaPositionContext, + FormulaRangeValue, + FormulaResult, + FormulaRowId, + FormulaScalar, + FormulaSourceSpan, + FormulaValidationIssue, + FormulaValidationResult, +} from './formulaTypes'; +export { + createFormulaCellKey, + getFormulaCellKey, + parseFormulaCellKey, + isFormulaSource, + isEscapedFormulaSource, + unescapeLiteralSource, + getFormulaExpression, + isFormulaRangeValue, +} from './formulaTypes'; + +export type { FormulaErrorCode, FormulaErrorValue } from './formulaErrors'; +export { FORMULA_ERROR_CODES, createFormulaError, isFormulaErrorValue } from './formulaErrors'; + +export type { + FormulaAstNode, + FormulaBinaryExpressionNode, + FormulaBinaryOperator, + FormulaBooleanLiteralNode, + FormulaCellRefNode, + FormulaColumnSelector, + FormulaColumnValuesNode, + FormulaFieldRefNode, + FormulaFunctionCallNode, + FormulaNumberLiteralNode, + FormulaRangeNode, + FormulaRowSelector, + FormulaStringLiteralNode, + FormulaUnaryExpressionNode, +} from './formulaAst'; +export { FORMULA_RESERVED_NAMES } from './formulaAst'; + +export { + toFormulaNumber, + toFormulaText, + toFormulaBoolean, + isEmptyFormulaValue, + compareFormulaScalars, +} from './formulaValues'; + +export type { + FormulaToken, + FormulaTokenType, + FormulaTokenizeError, + FormulaTokenizeResult, +} from './formulaTokenizer'; +export { tokenizeFormula } from './formulaTokenizer'; + +export type { FormulaParseError, FormulaParseResult, FormulaParser } from './formulaParser'; +export { parseFormula, createFormulaParser } from './formulaParser'; + +export { serializeFormulaAst } from './formulaSerializer'; + +export { offsetFormulaReferences } from './formulaOffset'; + +export type { + ExcelFormulaErrorCode, + FormulaExcelSerializeContext, + FormulaExcelSerializeResult, +} from './formulaExcel'; +export { serializeFormulaAstToExcel, mapFormulaErrorCodeToExcel } from './formulaExcel'; + +export type { + FormulaBoundDependencies, + FormulaColumnIntervalDependency, + FormulaStaticDependencies, + FormulaWholeColumnDependency, +} from './formulaDependencies'; +export { extractFormulaDependencies, bindFormulaDependencies } from './formulaDependencies'; + +export type { + FormulaFunctionArg, + FormulaFunctionCoercionHelpers, + FormulaFunctionContext, + FormulaFunctionDefinition, + FormulaFunctionEagerArg, + FormulaFunctionRegistry, +} from './formulaFunctions'; +export { FORMULA_BUILT_IN_FUNCTIONS, createFormulaFunctionRegistry } from './formulaFunctions'; + +export type { FormulaEvaluationContext } from './formulaEvaluator'; +export { evaluateFormula } from './formulaEvaluator'; + +export type { ValidateFormulaExpressionOptions } from './formulaValidation'; +export { validateFormulaExpression } from './formulaValidation'; + +export type { FormulaRecomputeOrder } from './formulaGraph'; +export { collectAffectedCells, orderForRecompute } from './formulaGraph'; + +export type { + FormulaCompletionContext, + FormulaCompletionKind, + FormulaCompletionToken, + RankFormulaCompletionsOptions, +} from './formulaCompletion'; +export { + getFormulaCompletionContext, + getFormulaCompletionTokens, + rankFormulaCompletions, +} from './formulaCompletion'; + +export type { A1TransformContext, A1TransformResult, ToCanonicalOptions } from './formulaA1'; +export { + columnIndexToLetters, + columnLettersToIndex, + toCanonicalFormula, + toDisplayFormula, +} from './formulaA1'; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/engine/testUtils.ts b/packages/x-data-grid-premium/src/hooks/features/formula/engine/testUtils.ts new file mode 100644 index 0000000000000..68c2107f4b5ba --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/engine/testUtils.ts @@ -0,0 +1,100 @@ +import type { FormulaEvaluationContext } from './formulaEvaluator'; +import { createFormulaFunctionRegistry, FORMULA_BUILT_IN_FUNCTIONS } from './formulaFunctions'; +import type { FormulaFunctionDefinition } from './formulaFunctions'; +import type { + FormulaCellRef, + FormulaPositionContext, + FormulaRowId, + FormulaScalar, +} from './formulaTypes'; +import { isFormulaErrorValue } from './formulaErrors'; +import type { FormulaErrorValue } from './formulaErrors'; + +export interface TestRow { + id: FormulaRowId; + [field: string]: unknown; +} + +export interface CreateTestContextOptions { + currentCell?: FormulaCellRef; + customFunctions?: FormulaFunctionDefinition[]; + /** + * Row order of the position context; defaults to the `rows` array order. + */ + rowOrder?: FormulaRowId[]; + /** + * Spy invoked for every resolver read. + * @param {FormulaCellRef} ref The cell being resolved. + */ + onGetCellValue?: (ref: FormulaCellRef) => void; +} + +export function createTestPositionContext( + rowIds: FormulaRowId[], + fields: string[], + version = 0, +): FormulaPositionContext { + // Ids are matched under string coercion, like the real grid's row lookup + // and the engine's cell-key format. + const rowIdToPosition = new Map(); + rowIds.forEach((id, index) => rowIdToPosition.set(String(id), index + 1)); + const fieldToPosition = new Map(); + fields.forEach((field, index) => fieldToPosition.set(field, index + 1)); + return { + version, + rowCount: rowIds.length, + columnCount: fields.length, + getRowIdAtPosition: (index) => rowIds[index - 1], + getPositionOfRowId: (id) => rowIdToPosition.get(String(id)), + getFieldAtPosition: (index) => fields[index - 1], + getPositionOfField: (field) => fieldToPosition.get(field), + }; +} + +/** + * Builds a fake `FormulaEvaluationContext` over plain row objects. + * Values resolve straight from the row data; formula strings are NOT + * evaluated recursively (the adapter's topological recompute owns that), + * which keeps these tests focused on single-formula behavior. + */ +export function createTestContext( + rows: TestRow[], + visibleFields?: string[], + options: CreateTestContextOptions = {}, +): FormulaEvaluationContext { + const fields = + visibleFields ?? + Array.from(new Set(rows.flatMap((row) => Object.keys(row).filter((key) => key !== 'id')))); + // Ids are matched under string coercion, like the real grid's row lookup + // (a plain object keyed by row id) and the engine's cell-key format. + const rowsById = new Map(rows.map((row) => [String(row.id), row])); + if (rowsById.size !== rows.length) { + throw /* minify-error-disabled */ new Error( + 'createTestContext: two fixture rows share a string-coerced id.', + ); + } + const rowOrder = options.rowOrder ?? rows.map((row) => row.id); + const fieldSet = new Set(fields); + + return { + currentCell: options.currentCell ?? { id: rows[0]?.id ?? 0, field: fields[0] ?? '' }, + getCellValue: (ref) => { + options.onGetCellValue?.(ref); + const value = rowsById.get(String(ref.id))?.[ref.field]; + if (isFormulaErrorValue(value)) { + return value as FormulaErrorValue; + } + return (value ?? null) as FormulaScalar; + }, + hasRow: (id) => rowsById.has(String(id)), + hasField: (field) => fieldSet.has(field), + position: createTestPositionContext(rowOrder, fields), + // The helper's contract is "built-ins plus extras" (the registry itself + // has replacement semantics). + functions: createFormulaFunctionRegistry( + options.customFunctions + ? [...FORMULA_BUILT_IN_FUNCTIONS, ...options.customFunctions] + : undefined, + ), + }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaA1Transforms.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaA1Transforms.ts new file mode 100644 index 0000000000000..de1b6de9b06a2 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaA1Transforms.ts @@ -0,0 +1,104 @@ +import { gridFocusCellSelector, gridRowIdSelector } from '@mui/x-data-grid-pro'; +import type { GridColDef, GridRowModel } from '@mui/x-data-grid-pro'; +import type { RefObject } from '@mui/x-internals/types'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { getFormulaExpression, toCanonicalFormula, toDisplayFormula } from './engine'; +import { gridFormulaA1PositionContextSelector } from './gridFormulaPositionContext'; + +/** + * Adapter-layer glue between the editor pipeline and the pure A1 engine + * transforms. A1 is editing-UI only: `toCanonicalFormula` runs at commit/paste, + * `toDisplayFormula` at edit-begin, and the stored row value, copy, export and + * `getCellFormula` always stay canonical. + */ + +/** + * Renders a stored canonical formula source as A1 for the editor seed. + */ +export function convertCanonicalToA1Display( + source: string, + apiRef: RefObject, +): string { + const positionContext = gridFormulaA1PositionContextSelector(apiRef); + return `=${toDisplayFormula(getFormulaExpression(source), { positionContext })}`; +} + +/** + * Converts an A1 formula committed through the editor to canonical form. When + * the committed text is identical to what was seeded (Enter without an edit), + * the stored canonical source is returned unchanged — re-freezing relative + * references against a possibly re-sorted view would silently change them. + */ +export function convertA1ToCanonicalCommit( + source: string, + row: GridRowModel, + colDef: GridColDef, + apiRef: RefObject, +): string { + const cache = apiRef.current.caches.formula; + const seed = cache.lastA1Seed; + const id = gridRowIdSelector(apiRef, row); + if (seed !== null && seed.id === id && seed.field === colDef.field && seed.display === source) { + return seed.canonical; + } + const positionContext = gridFormulaA1PositionContextSelector(apiRef); + return `=${toCanonicalFormula(getFormulaExpression(source), { positionContext }).source}`; +} + +/** + * Converts an A1 formula pasted into a cell to canonical form, applying the + * Excel fill offset: relative references shift by the target cell's distance + * from the paste origin (the top-left target cell). Canonical formulas + * pasted from an in-grid copy carry no A1 tokens and pass through unchanged. + */ +export function convertA1ToCanonicalPaste( + source: string, + row: GridRowModel, + colDef: GridColDef, + apiRef: RefObject, +): string { + const cache = apiRef.current.caches.formula; + const positionContext = gridFormulaA1PositionContextSelector(apiRef); + const id = gridRowIdSelector(apiRef, row); + const rowPosition = positionContext.getPositionOfRowId(id); + const columnPosition = positionContext.getPositionOfField(colDef.field); + + if (cache.pasteOrigin === null) { + // Anchor the Excel fill offset to the paste's top-left target cell, not the + // first editable formula cell to reach this transform: leading cells skipped + // for being non-editable or carrying a non-formula value never call this + // function, so anchoring lazily here would displace the origin and shift + // every subsequent relative reference. The focused cell is that top-left + // anchor on the common (focused-cell) paste path; fall back to this cell when + // there is no focus (e.g. a multi-cell selection paste). + const focusedCell = gridFocusCellSelector(apiRef); + cache.pasteOrigin = + focusedCell !== null + ? { + rowPosition: positionContext.getPositionOfRowId(focusedCell.id), + columnPosition: positionContext.getPositionOfField(focusedCell.field), + } + : { rowPosition, columnPosition }; + } + const origin = cache.pasteOrigin; + + // Offsets are non-negative because the paste resolver iterates from the + // top-left target cell; the `> 0` guard keeps an unexpected non-top-left + // origin from producing an unrepresentable position. + let rowOffset = 0; + if (origin.rowPosition !== undefined && rowPosition !== undefined) { + rowOffset = Math.max(0, rowPosition - origin.rowPosition); + } + let columnOffset = 0; + if (origin.columnPosition !== undefined && columnPosition !== undefined) { + columnOffset = Math.max(0, columnPosition - origin.columnPosition); + } + + return `=${ + toCanonicalFormula( + getFormulaExpression(source), + { positionContext }, + { rowOffset, columnOffset }, + ).source + }`; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.test.tsx b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.test.tsx new file mode 100644 index 0000000000000..60d3d692e805f --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.test.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { type RefObject } from '@mui/x-internals/types'; +import { createRenderer } from '@mui/internal-test-utils'; +import { microtasks } from 'test/utils/helperFn'; +import { describe, expect, it } from 'vitest'; +import { + DataGridPremium, + type DataGridPremiumProps, + type GridApi, + GRID_FORMULA_FUNCTIONS, + useGridApiRef, +} from '@mui/x-data-grid-premium'; +import { unwrapPrivateAPI } from '@mui/x-data-grid/internals'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { getFormulaSuggestions, toFormulaFieldReference } from './gridFormulaAutocomplete'; + +describe('toFormulaFieldReference', () => { + it('keeps bare identifiers as-is', () => { + expect(toFormulaFieldReference('price')).toEqual('price'); + expect(toFormulaFieldReference('unit_price')).toEqual('unit_price'); + expect(toFormulaFieldReference('_x1')).toEqual('_x1'); + }); + + it('escapes field names that are not bare identifiers', () => { + expect(toFormulaFieldReference('unit price')).toEqual('FIELD("unit price")'); + expect(toFormulaFieldReference('2024')).toEqual('FIELD("2024")'); + expect(toFormulaFieldReference('a-b')).toEqual('FIELD("a-b")'); + }); + + it('escapes field names colliding with reserved names or constants', () => { + expect(toFormulaFieldReference('RANGE')).toEqual('FIELD("RANGE")'); + expect(toFormulaFieldReference('TRUE')).toEqual('FIELD("TRUE")'); + expect(toFormulaFieldReference('ref')).toEqual('FIELD("ref")'); + }); + + it('doubles embedded quotes', () => { + expect(toFormulaFieldReference('a"b')).toEqual('FIELD("a""b")'); + }); +}); + +describe('getFormulaSuggestions', () => { + const { render } = createRenderer(); + + let apiRef: RefObject; + + const baselineProps: DataGridPremiumProps = { + autoHeight: true, + rows: [ + { id: 0, item: 'Apple', price: 2, quantity: 3, total: '=price * quantity' }, + { id: 1, item: 'Banana', price: 1, quantity: 5, total: '=price * quantity' }, + ], + columns: [ + { field: 'item' }, + { field: 'price', type: 'number' }, + { field: 'quantity', type: 'number' }, + { field: 'total', type: 'number', allowFormulas: true, editable: true }, + ], + }; + + function Test(props: Partial) { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + } + + const privateApiRef = (): RefObject => ({ + current: unwrapPrivateAPI(apiRef.current!) as GridPrivateApiPremium, + }); + + it('returns null for non-formula values', async () => { + render(); + await microtasks(); + expect(getFormulaSuggestions(privateApiRef(), '42', 2, false)).toEqual(null); + expect(getFormulaSuggestions(privateApiRef(), "'=price", 7, false)).toEqual(null); + }); + + it('suggests functions for a typed prefix', async () => { + render(); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=SU', 3, false)!; + expect(state).not.toEqual(null); + expect(state.options[0].label).toEqual('SUM'); + expect(state.options[0].kind).toEqual('function'); + }); + + it('suggests same-row field references in both modes', async () => { + render(); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=pr', 3, false)!; + expect( + state.options.some((option) => option.label === 'price' && option.kind === 'field'), + ).toEqual(true); + }); + + it('does not suggest A1 column letters when A1 notation is off', async () => { + render(); + await microtasks(); + // "B" is the letter of the "price" column; with A1 off it matches nothing. + const state = getFormulaSuggestions(privateApiRef(), '=B', 2, false)!; + expect(state.options.some((option) => option.kind === 'columnLetter')).toEqual(false); + }); + + it('suggests A1 column letters when A1 notation is on', async () => { + render(); + await microtasks(); + // Data columns map to A (item), B (price), C (quantity), D (total). + const state = getFormulaSuggestions(privateApiRef(), '=B', 2, true)!; + const letter = state.options.find((option) => option.kind === 'columnLetter'); + expect(letter?.label).toEqual('B'); + expect(letter?.detail).toEqual('price'); + }); + + it('maps the replace span to full-source coordinates (including the `=`)', async () => { + render(); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=SU', 3, false)!; + expect(state.replaceStart).toEqual(1); + expect(state.replaceEnd).toEqual(3); + expect(state.token).toEqual('SU'); + }); + + it('surfaces custom functions with their metadata', async () => { + render( + 0, + }, + }} + />, + ); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=TA', 3, false)!; + const tax = state.options.find((option) => option.label === 'TAX'); + expect(tax).toMatchObject({ kind: 'function', signature: 'TAX(amount)' }); + }); + + it('provides signature help while the caret is inside a call', async () => { + render(); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=ROUND(price, ', 14, false)!; + expect(state.signatureHelp).toMatchObject({ + name: 'ROUND', + signature: 'ROUND(value, [digits])', + argIndex: 1, + }); + }); + + it('suppresses suggestions inside a string literal', async () => { + render(); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=FIELD("pr', 10, false)!; + expect(state.options).toEqual([]); + }); + + it('provides signature help for a custom function with a non-uppercase name', async () => { + render( + 0, + }, + }} + />, + ); + await microtasks(); + const state = getFormulaSuggestions(privateApiRef(), '=calcShipping(', 14, false)!; + expect(state.signatureHelp).toMatchObject({ + name: 'calcShipping', + signature: 'calcShipping(weight)', + }); + }); + + it('suggests hidden columns as same-row field references', async () => { + render(); + await microtasks(); + // A bare reference to a hidden field still evaluates, so it is offered. + const state = getFormulaSuggestions(privateApiRef(), '=pr', 3, false)!; + expect( + state.options.some((option) => option.label === 'price' && option.kind === 'field'), + ).toEqual(true); + }); +}); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.ts new file mode 100644 index 0000000000000..b6b4a4b907b84 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaAutocomplete.ts @@ -0,0 +1,178 @@ +import * as React from 'react'; +import type { RefObject } from '@mui/x-internals/types'; +import { gridColumnLookupSelector } from '@mui/x-data-grid-pro'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { + FORMULA_RESERVED_NAMES, + getFormulaCompletionContext, + getFormulaCompletionTokens, + isFormulaSource, + rankFormulaCompletions, +} from './engine'; +import type { FormulaCompletionToken } from './engine'; +import { + getFormulaColumnLetter, + gridFormulaA1PositionContextSelector, + gridFormulaReferenceableFieldsSelector, +} from './gridFormulaPositionContext'; + +/** + * Signature help shown while the caret is inside a function/special-form call. + */ +export interface GridFormulaSignatureHelp { + name: string; + signature: string; + description?: string; + /** + * Zero-based index of the argument the caret is in. + */ + argIndex: number; +} + +/** + * The autocomplete state for one caret position, ready for the editor to render. + */ +export interface GridFormulaSuggestionState { + /** + * The partial token used for matching (`''` when none). + */ + token: string; + /** + * Replace span in FULL source coordinates (including the leading `=`): + * accepting a suggestion replaces `value.slice(replaceStart, replaceEnd)`. + */ + replaceStart: number; + replaceEnd: number; + /** + * Ranked suggestions for the caret. + */ + options: FormulaCompletionToken[]; + /** + * Signature help for the enclosing call, or `null`. + */ + signatureHelp: GridFormulaSignatureHelp | null; +} + +const RESERVED_NAME_SET = new Set(FORMULA_RESERVED_NAMES); +const BARE_IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The reference text that resolves to `field` as a same-row reference. A field + * whose name is not a bare identifier — or collides with a reserved name or a + * boolean constant — must go through the `FIELD("…")` escape. + */ +export function toFormulaFieldReference(field: string): string { + if (BARE_IDENTIFIER_REGEX.test(field) && !RESERVED_NAME_SET.has(field.toUpperCase())) { + return field; + } + return `FIELD("${field.replace(/"/g, '""')}")`; +} + +/** + * Computes the ranked autocomplete suggestions for a formula source and caret + * position. Returns `null` when the value is not a formula (so escaped `'=` + * literals and plain values show nothing). + * + * Token sourcing (D20): the static vocabulary (functions from the cell's + * registry — custom functions included — special forms, constants, operators) + * plus same-row field references in both modes, plus A1 column letters when A1 + * notation is on. Ranking is the pure engine ranker. + */ +export function getFormulaSuggestions( + apiRef: RefObject, + value: string, + caret: number, + a1NotationEnabled: boolean, +): GridFormulaSuggestionState | null { + if (!isFormulaSource(value)) { + return null; + } + // The editor value carries the leading `=`; the engine works on the + // expression. Map the caret in and the replace span back out by one. + const expression = value.slice(1); + const expressionCaret = caret - 1; + if (expressionCaret < 0) { + return null; + } + + const context = getFormulaCompletionContext(expression, expressionCaret); + + const staticTokens = getFormulaCompletionTokens(apiRef.current.caches.formula.registry); + const columnLookup = gridColumnLookupSelector(apiRef); + const fields = gridFormulaReferenceableFieldsSelector(apiRef); + const positionContext = a1NotationEnabled ? gridFormulaA1PositionContextSelector(apiRef) : null; + + const columnTokens: FormulaCompletionToken[] = []; + for (const field of fields) { + const headerName = columnLookup[field]?.headerName; + columnTokens.push({ + label: field, + insertText: toFormulaFieldReference(field), + kind: 'field', + detail: headerName && headerName !== field ? headerName : undefined, + }); + if (positionContext !== null) { + const letter = getFormulaColumnLetter(positionContext, field); + if (letter !== '') { + columnTokens.push({ + label: letter, + insertText: letter, + kind: 'columnLetter', + detail: headerName || field, + }); + } + } + } + + const options = rankFormulaCompletions([...staticTokens, ...columnTokens], context); + + let signatureHelp: GridFormulaSignatureHelp | null = null; + if (context.functionContext !== null) { + const match = staticTokens.find( + (token) => + (token.kind === 'function' || token.kind === 'specialForm') && + // `functionContext.name` is upper-cased by the engine (function names are + // case-insensitive); token labels keep the registered casing, so compare + // case-insensitively or signature help is lost for non-uppercase custom names. + token.label.toUpperCase() === context.functionContext!.name && + token.signature !== undefined, + ); + if (match) { + signatureHelp = { + name: match.label, + signature: match.signature!, + description: match.description, + argIndex: context.functionContext.argIndex, + }; + } + } + + return { + token: context.token, + replaceStart: context.replaceStart + 1, + replaceEnd: context.replaceEnd + 1, + options, + signatureHelp, + }; +} + +export type GetFormulaSuggestions = ( + value: string, + caret: number, +) => GridFormulaSuggestionState | null; + +/** + * Returns a stable callback computing autocomplete suggestions for the formula + * editor. The suggestions read the current registry, visible fields and A1 + * position context through `apiRef` on each call (the editor is short-lived and + * recomputes per keystroke), so no selector subscription is needed. + */ +export function useGridFormulaAutocomplete( + apiRef: RefObject, + a1NotationEnabled: boolean, +): GetFormulaSuggestions { + return React.useCallback( + (value, caret) => getFormulaSuggestions(apiRef, value, caret, a1NotationEnabled), + [apiRef, a1NotationEnabled], + ); +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaExcelExport.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaExcelExport.ts new file mode 100644 index 0000000000000..66d7e47ff7f90 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaExcelExport.ts @@ -0,0 +1,218 @@ +import type { GridRowId } from '@mui/x-data-grid-pro'; +import type { GridStateColDef } from '@mui/x-data-grid/internals'; +import type { RefObject } from '@mui/x-internals/types'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { + columnIndexToLetters, + getFormulaExpression, + mapFormulaErrorCodeToExcel, + serializeFormulaAstToExcel, + type ExcelFormulaErrorCode, + type FormulaColumnSelector, + type FormulaPositionContext, + type FormulaRowId, + type FormulaRowSelector, +} from './engine'; +import { gridFormulaA1PositionContextSelector } from './gridFormulaPositionContext'; + +/** + * The cached `result` written alongside an exported Excel formula. Structurally + * compatible with the exceljs fork's formula-cell value (`{ formula, result }`). + */ +export type ExcelFormulaResult = + | number + | string + | boolean + | Date + | null + | { error: ExcelFormulaErrorCode }; + +/** + * One exported formula cell: the A1 formula string (without a leading `=` — + * exceljs writes it verbatim into the `` element) and its cached result, + * ready to assign to `cell.value`. + */ +export interface ExcelFormulaCell { + formula: string; + result: ExcelFormulaResult; +} + +/** + * Maps grid identities to their coordinates in the *exported* sheet. Built once + * per export: the export's own column/row order and header-row offset differ + * from the grid's visible order, so the A1 references baked into formulas must be + * computed against this layout, not the live position context. + * + * `null` from `createFormulaExcelExportLayout` means no exported column accepts + * formulas, so the serializer skips all formula work and the output is identical + * to a value-only export. + */ +export interface FormulaExcelExportLayout { + /** Live position context — resolves positional (`$`-absolute) refs to an identity. */ + positionContext: FormulaPositionContext; + /** field -> Excel column letter, in export order (`A`, `B`, …). */ + fieldToColumnLetter: Map; + /** stringified row id -> 1-based Excel row number (header offset included). */ + rowIdToRowNumber: Map; + firstDataRowNumber: number; + lastDataRowNumber: number; +} + +export function createFormulaExcelExportLayout( + apiRef: RefObject, + columns: GridStateColDef[], + rowIds: GridRowId[], + options: { includeHeaders: boolean; includeColumnGroupsHeaders: boolean }, +): FormulaExcelExportLayout | null { + const hasFormulaColumn = columns.some( + (column) => apiRef.current.getColumn(column.field)?.allowFormulas === true, + ); + if (!hasFormulaColumn) { + return null; + } + + // Group-header rows = the deepest column-group path among exported columns + // (mirrors `addColumnGroupingHeaders`), plus one row for the column headers. + let groupHeaderRows = 0; + if (options.includeColumnGroupsHeaders) { + for (let i = 0; i < columns.length; i += 1) { + groupHeaderRows = Math.max( + groupHeaderRows, + apiRef.current.getColumnGroupPath(columns[i].field).length, + ); + } + } + const headerOffset = groupHeaderRows + (options.includeHeaders ? 1 : 0); + + const fieldToColumnLetter = new Map(); + columns.forEach((column, index) => { + fieldToColumnLetter.set(column.field, columnIndexToLetters(index + 1)); + }); + + const rowIdToRowNumber = new Map(); + rowIds.forEach((id, index) => { + rowIdToRowNumber.set(String(id), headerOffset + 1 + index); + }); + + return { + positionContext: gridFormulaA1PositionContextSelector(apiRef), + fieldToColumnLetter, + rowIdToRowNumber, + firstDataRowNumber: headerOffset + 1, + lastDataRowNumber: headerOffset + rowIds.length, + }; +} + +/** + * Computes the Excel formula cell to write for `(id, field)`, or `null` when the + * cell is not a live formula (the caller then keeps the evaluated value). + * + * Returns `null` when: + * - the cell has no evaluated formula result (`getCellFormulaResult` is the + * authoritative gate — covers `allowFormulas`, `disableFormulas`/`dataSource`/ + * pivot, and a genuine `=` value, and rejects a literal `=text` in a plain + * column); + * - the canonical source is missing or fails to parse (defensive — stored + * canonical formulas should always parse). + * + * References to cells outside the export bake `#REF!` into the formula and the + * result becomes `{ error: '#REF!' }` (never silently dropped). + */ +export function getCellExcelFormula( + apiRef: RefObject, + layout: FormulaExcelExportLayout, + id: GridRowId, + field: string, +): ExcelFormulaCell | null { + const result = apiRef.current.getCellFormulaResult(id, field); + if (result === null) { + return null; + } + const source = apiRef.current.getCellFormula(id, field); + if (source === null) { + return null; + } + // Reuse the grid's interning parser: every formula's AST was already interned + // during evaluation, so each export parse is a cache hit — no re-parsing across + // rows that share a formula. + const { ast } = apiRef.current.caches.formula.parser.parse(getFormulaExpression(source)); + if (ast === null) { + return null; + } + const ownerRowNumber = layout.rowIdToRowNumber.get(String(id)); + if (ownerRowNumber === undefined) { + return null; + } + + const resolveColumn = (selector: FormulaColumnSelector) => { + let resolvedField: string | undefined; + let absolute: boolean; + if (selector.kind === 'field') { + resolvedField = selector.field; + absolute = false; + } else { + // Positional refs were authored against the visible order: resolve the + // index to an identity there, then map that identity to the export sheet. + resolvedField = layout.positionContext.getFieldAtPosition(selector.index); + absolute = true; + } + if (resolvedField === undefined) { + return null; + } + const letter = layout.fieldToColumnLetter.get(resolvedField); + return letter === undefined ? null : { letter, absolute }; + }; + + const resolveRow = (selector: FormulaRowSelector) => { + let resolvedId: FormulaRowId | undefined; + let absolute: boolean; + if (selector.kind === 'id') { + resolvedId = selector.id; + absolute = false; + } else { + resolvedId = layout.positionContext.getRowIdAtPosition(selector.index); + absolute = true; + } + if (resolvedId === undefined) { + return null; + } + const number = layout.rowIdToRowNumber.get(String(resolvedId)); + return number === undefined ? null : { number, absolute }; + }; + + const { formula, hasRefError } = serializeFormulaAstToExcel(ast, { + resolveColumn, + resolveRow, + ownerRowNumber, + firstDataRowNumber: layout.firstDataRowNumber, + lastDataRowNumber: layout.lastDataRowNumber, + }); + + let excelResult: ExcelFormulaResult; + if (hasRefError) { + excelResult = { error: '#REF!' }; + } else if (result.type === 'error') { + excelResult = { error: mapFormulaErrorCodeToExcel(result.code) }; + } else if (result.value instanceof Date) { + // Excel sheets are timezone-naive: rebuild the date from its local components + // as UTC, matching the plain date/dateTime export path, so a date-valued + // formula and a plain date column produce the same serial. + const value = result.value; + excelResult = new Date( + Date.UTC( + value.getFullYear(), + value.getMonth(), + value.getDate(), + value.getHours(), + value.getMinutes(), + value.getSeconds(), + ), + ); + } else { + excelResult = result.value; + } + + // exceljs expects the bare formula (no leading `=`) — it is written verbatim + // into the `` element. + return { formula, result: excelResult }; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaFill.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaFill.ts new file mode 100644 index 0000000000000..1ace4bc87992b --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaFill.ts @@ -0,0 +1,78 @@ +import type { GridRowId } from '@mui/x-data-grid-pro'; +import type { RefObject } from '@mui/x-internals/types'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { + getFormulaExpression, + offsetFormulaReferences, + parseFormula, + serializeFormulaAst, +} from './engine'; +import { gridFormulaA1PositionContextSelector } from './gridFormulaPositionContext'; + +interface GridFormulaFillCell { + id: GridRowId; + field: string; +} + +/** + * Computes the value to fill into `targetCell` when the fill handle drags + * `sourceCell`. When the source is a live formula and the target column accepts + * formulas, the formula's relative references are shifted by the source→target + * positional delta (Excel fill semantics) and the adjusted canonical source is + * returned. In every other case it returns `null`, and the caller copies the + * source cell's evaluated value as before. + * + * Returns `null` when: + * - the target column is not `allowFormulas` — a `=…` string there would be + * inert literal text, so the evaluated value is copied instead; + * - the source cell is not a live, evaluated formula (`getCellFormulaResult` + * covers `allowFormulas`, `disableFormulas`/`dataSource`/pivot, and a real `=` + * value, and rejects a literal `=text` parked in a non-formula column). + * + * When the source is a formula but cannot be adjusted (parse error, or a cell + * outside the position context such as a group/pinned row), the original + * canonical source is returned unchanged so the formula is never dropped. + */ +export function getFilledFormulaSource( + apiRef: RefObject, + sourceCell: GridFormulaFillCell, + targetCell: GridFormulaFillCell, +): string | null { + if (apiRef.current.getColumn(targetCell.field)?.allowFormulas !== true) { + return null; + } + if (apiRef.current.getCellFormulaResult(sourceCell.id, sourceCell.field) === null) { + return null; + } + const source = apiRef.current.getCellFormula(sourceCell.id, sourceCell.field); + if (source === null) { + return null; + } + + const { ast } = parseFormula(getFormulaExpression(source)); + if (ast === null) { + return source; + } + + const positionContext = gridFormulaA1PositionContextSelector(apiRef); + const sourceRowPosition = positionContext.getPositionOfRowId(sourceCell.id); + const sourceColumnPosition = positionContext.getPositionOfField(sourceCell.field); + const targetRowPosition = positionContext.getPositionOfRowId(targetCell.id); + const targetColumnPosition = positionContext.getPositionOfField(targetCell.field); + if ( + sourceRowPosition === undefined || + sourceColumnPosition === undefined || + targetRowPosition === undefined || + targetColumnPosition === undefined + ) { + return source; + } + + const rowDelta = targetRowPosition - sourceRowPosition; + const columnDelta = targetColumnPosition - sourceColumnPosition; + if (rowDelta === 0 && columnDelta === 0) { + return source; + } + + return `=${serializeFormulaAst(offsetFormulaReferences(ast, rowDelta, columnDelta, positionContext))}`; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaInterfaces.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaInterfaces.ts new file mode 100644 index 0000000000000..c5be33cc3cfce --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaInterfaces.ts @@ -0,0 +1,260 @@ +import type { GridRowId, GridValidRowModel, GridColDef } from '@mui/x-data-grid-pro'; +import type { + FormulaBoundDependencies, + FormulaCellKey, + FormulaErrorCode, + FormulaFunctionArg, + FormulaFunctionContext, + FormulaFunctionDefinition, + FormulaFunctionRegistry, + FormulaParseResult, + FormulaParser, + FormulaPositionContext, + FormulaResult, + FormulaValidationIssue, + FormulaValidationResult, +} from './engine'; + +/** + * The outcome of evaluating one formula cell. + */ +export type GridFormulaResult = FormulaResult; + +/** + * Error codes produced by formula parsing and evaluation, rendered as the cell content. + */ +export type GridFormulaErrorCode = FormulaErrorCode; + +/** + * Serialized `${id}\u0000${field}` cell key used in formula caches. + * Always created through the engine helpers — never derive the format manually. + */ +export type GridFormulaCellKey = FormulaCellKey; + +export type GridFormulaValidationResult = FormulaValidationResult; + +export type GridFormulaValidationIssue = FormulaValidationIssue; + +/** + * Definition of a function callable from formulas. + * The `apply` implementation only receives engine values — never the grid API. + */ +export type GridFormulaFunctionDefinition = FormulaFunctionDefinition; + +export type GridFormulaFunctionContext = FormulaFunctionContext; + +export type GridFormulaFunctionArg = FormulaFunctionArg; + +/** + * Evaluated formula results, keyed by row id and field. + * Membership in this lookup is what masks the raw `=` source from the rest of + * the grid — a formula evaluating to `null` still has an entry. + */ +export type GridFormulaLookup = { + [rowId: GridRowId]: { + [field: string]: GridFormulaResult; + }; +}; + +export interface GridFormulaState { + lookup: GridFormulaLookup; +} + +/** + * One scanned formula cell. `parse` is `null` for `'=` escaped literals, + * which evaluate to their unescaped string without entering the graph. + */ +export interface GridFormulaCellRecord { + id: GridRowId; + field: string; + source: string; + parse: FormulaParseResult | null; + dependencies: FormulaBoundDependencies | null; + /** + * `true` when the formula contains positional selectors, `RANGE` or + * `COLUMN_VALUES` — its dependencies were resolved against a position + * context and must rebind when that context changes. + */ + usesPositionContext: boolean; + result: GridFormulaResult; +} + +/** + * The slices of one record's range dependencies that read a given field. + * Stored per dependent in `rangeDependentsByField` — interval records, + * never exploded per-cell edges. + */ +export interface GridFormulaRangeDependency { + /** + * Bounded `RANGE` slices: rows `fromIndex..toIndex` (1-based, inclusive) + * of the field in the position context's row order. + */ + intervals: { fromIndex: number; toIndex: number }[]; + /** + * `true` when the record reads the whole column (`COLUMN_VALUES`). + */ + wholeColumn: boolean; +} + +export interface GridFormulaCellEditStartInfo { + id: GridRowId; + field: string; + /** + * `true` when the edit started by typing/deleting/pasting — the edit value + * was intentionally replaced and must not be re-seeded with the source. + */ + replaceValue: boolean; + /** + * `true` when the edit started by typing `=` — the user is entering a + * formula, so the formula text editor renders even on a plain cell. + */ + startedWithEquals: boolean; +} + +export interface GridFormulaInternalCache { + /** + * Interning parser: identical sources share one parse result. + */ + parser: FormulaParser; + registry: FormulaFunctionRegistry; + /** + * The `formulaFunctions` prop value `registry` was built from, + * compared by reference to detect prop changes. + */ + registrySource: Record; + records: Map; + /** + * Reverse dependency edges: for a cell key, the formula cells that read it. + */ + dependents: Map>; + /** + * Formula cells depending on any cell of a row, keyed by stringified row id. + * Lets row additions/removals dirty their dependents in O(dependents). + */ + dependentsByRowId: Map>; + /** + * Formula records grouped by the field they live in. This is what expands + * an interval dependency into graph edges: only the formula cells of the + * field can participate in cycles or require ordered recomputation — + * raw cells never do. + */ + recordsByField: Map>; + /** + * Keys of the records with `usesPositionContext` — the set a rebind pass + * re-binds when the position context changes. + */ + positionDependentKeys: Set; + /** + * Reverse range-dependency tier: for each field, the formula cells whose + * `RANGE`/`COLUMN_VALUES` dependencies read it, as interval records. + * A change to cell `(id, field)` dirties the dependents whose interval + * contains the row's position (or any whole-column dependent). + */ + rangeDependentsByField: Map>; + /** + * The position-context snapshot records are currently bound against. + * Built lazily (only when a position-dependent formula exists) and + * replaced by rebind passes; `null` means "build on first need". + */ + positionContext: FormulaPositionContext | null; + /** + * The exact row order behind `positionContext` — compared on rebind events + * to skip rebinding when positions did not actually change. + */ + positionContextRowIds: GridRowId[] | null; + /** + * The exact visible-field order behind `positionContext`. + */ + positionContextFields: string[] | null; + /** + * Monotonic counter stamped into each built position context. + */ + positionContextVersion: number; + /** + * Guards the post-pass re-grouping trigger: the row-tree rebuild it fires + * cascades into another formula pass, which must not fire it again. + */ + suppressRegroupTrigger: boolean; + /** + * Last value resolved for each raw (non-formula) dependency cell, + * keyed by stringified row id then field. Compared on row change to decide + * whether dependents must recompute. + */ + trackedValues: Map>; + /** + * Rows lookup snapshot from the last pass — rows are replaced immutably, + * so a reference diff finds the changed/added/removed ids. + */ + lastRowIdToModelLookup: Record | null; + /** + * Fields with `allowFormulas` at the last pass. + */ + formulaFields: string[]; + /** + * `field → valueGetter` of every column at the last pass. Evaluation reads + * raw dependencies through column definitions, so adding/removing a column + * or changing a `valueGetter` must trigger a re-evaluation even when the + * `allowFormulas` field set is unchanged. + */ + lastColumnsSignature: Map; + lastCellEditStart: GridFormulaCellEditStartInfo | null; + /** + * The A1 value last seeded into the editor and the canonical source it came + * from (A1 notation only). Lets the commit parser detect an unchanged edit + * and restore the stored canonical instead of re-freezing relative references + * against a possibly re-sorted view. Cleared on `cellEditStop`. + */ + lastA1Seed: { id: GridRowId; field: string; display: string; canonical: string } | null; + /** + * Position of the first cell of the current clipboard paste (A1 notation + * only). Subsequent pasted cells offset their relative references by their + * distance from this origin — the Excel fill adjustment. Armed on + * `clipboardPasteStart`, consumed lazily by the first pasted cell. + */ + pasteOrigin: { rowPosition: number | undefined; columnPosition: number | undefined } | null; +} + +export interface GridFormulaApi { + /** + * Stores a formula as the cell's row-data value and re-evaluates. + * @param {GridRowId} id The row id. + * @param {string} field The column field. Must have `allowFormulas` enabled. + * @param {string} formula The formula source, starting with `=`. + */ + setCellFormula: (id: GridRowId, field: string, formula: string) => void; + /** + * Returns the formula source stored in the cell's row data, + * or `null` when the cell does not hold a formula. + * @param {GridRowId} id The row id. + * @param {string} field The column field. + * @returns {string | null} The formula source, including the leading `=`. + */ + getCellFormula: (id: GridRowId, field: string) => string | null; + /** + * Returns the evaluated result of a formula cell, + * or `null` when the cell does not hold a formula. + * @param {GridRowId} id The row id. + * @param {string} field The column field. + * @returns {GridFormulaResult | null} The evaluation result. + */ + getCellFormulaResult: (id: GridRowId, field: string) => GridFormulaResult | null; + /** + * Statically validates a formula source against the current function registry. + * Validation is informative — invalid formulas can still be committed. + * @param {string} formula The formula source, with or without the leading `=`. + * @returns {GridFormulaValidationResult} The validation result. + */ + validateCellFormula: (formula: string) => GridFormulaValidationResult; + /** + * Discards every formula cache and re-evaluates all formulas. + * Escape hatch for in-place row mutations the grid cannot observe. + */ + reevaluateFormulas: () => void; +} + +export interface GridFormulaPrivateApi { + /** + * Runs a full formula evaluation pass and refreshes dependent features. + */ + applyFormulaEvaluation: () => void; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaPositionContext.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaPositionContext.ts new file mode 100644 index 0000000000000..8b67c479863c8 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaPositionContext.ts @@ -0,0 +1,206 @@ +import type { RefObject } from '@mui/x-internals/types'; +import { + GRID_CHECKBOX_SELECTION_FIELD, + GRID_DETAIL_PANEL_TOGGLE_FIELD, + GRID_REORDER_COL_DEF, + gridColumnFieldsSelector, + gridDataRowIdsSelector, + gridFilteredSortedRowIdsSelector, + gridRowTreeSelector, + gridSortedRowIdsSelector, + gridVisibleColumnFieldsSelector, +} from '@mui/x-data-grid-pro'; +import type { GridRowId, GridRowTreeConfig } from '@mui/x-data-grid-pro'; +import { + GRID_TREE_DATA_GROUPING_FIELD, + isGroupingColumn, + createSelectorMemoized, +} from '@mui/x-data-grid-pro/internals'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import type { GridStatePremium } from '../../../models/gridStatePremium'; +import { columnIndexToLetters, type FormulaPositionContext } from './engine'; + +/** + * Field of the autogenerated row-number column shown in A1 notation mode. It is + * a utility column: excluded from the position context (takes no column letter), + * from export/print, and from formulas. + */ +export const GRID_FORMULA_ROW_NUMBER_FIELD = '__formula_row_number__'; + +/** + * The inputs a position context is built from: sorted + filtered data-row + * order and visible-column order. Rebind events compare snapshots to skip + * rebinding when nothing actually moved. + */ +export interface GridFormulaPositionSnapshot { + rowIds: GridRowId[]; + fields: string[]; +} + +/** + * Only data rows take part in the position context: autogenerated grouping + * nodes, footers, skeleton and pinned rows are excluded. Tree-data parents + * are real data rows and keep a position. + */ +function collectPositionRowIds( + sourceIds: readonly GridRowId[], + tree: GridRowTreeConfig, +): GridRowId[] { + const rowIds: GridRowId[] = []; + for (const id of sourceIds) { + const node = tree[id]; + if ( + node !== undefined && + (node.type === 'leaf' || (node.type === 'group' && !node.isAutoGenerated)) + ) { + rowIds.push(id); + } + } + return rowIds; +} + +const UTILITY_FIELDS = new Set([ + GRID_CHECKBOX_SELECTION_FIELD, + GRID_DETAIL_PANEL_TOGGLE_FIELD, + GRID_REORDER_COL_DEF.field, + GRID_TREE_DATA_GROUPING_FIELD, + GRID_FORMULA_ROW_NUMBER_FIELD, +]); + +/** + * Mirror of the row-side exclusion on the column axis: utility columns the + * grid generates (selection checkbox, detail panel toggle, row reorder + * handle, grouping columns) hold no data and take no column position — + * otherwise enabling `checkboxSelection` would shift every positional + * column reference by one. + */ +function isPositionedDataField(field: string): boolean { + return !UTILITY_FIELDS.has(field) && !isGroupingColumn(field); +} + +export function buildFormulaPositionSnapshot( + apiRef: RefObject, +): GridFormulaPositionSnapshot { + const dataRowIds = gridDataRowIdsSelector(apiRef); + // Mount window: filtering applies before the first `applySorting`, so + // `sortedRows` can be empty while rows exist. Fall back to the row-tree + // order the initial evaluation used — the snapshots compare equal and the + // rebind waits for the mount `sortedRowsSet` instead of binding everything + // against an empty view. + const sourceIds = + gridSortedRowIdsSelector(apiRef).length === 0 && dataRowIds.length > 0 + ? dataRowIds + : gridFilteredSortedRowIdsSelector(apiRef); + return { + rowIds: collectPositionRowIds(sourceIds, gridRowTreeSelector(apiRef)), + fields: gridVisibleColumnFieldsSelector(apiRef).filter(isPositionedDataField), + }; +} + +/** + * State-based variant for the initial evaluation: the sorting and filtering + * states initialize after the formula state, so the initial snapshot uses + * the row-tree order. The mount-time sorting/filtering cascade publishes + * `sortedRowsSet`/`filteredRowsSet`, which rebind position-dependent + * formulas against the real view order. + */ +export function buildFormulaPositionSnapshotFromState( + state: Partial, +): GridFormulaPositionSnapshot { + const visibilityModel = state.columns?.columnVisibilityModel ?? {}; + return { + rowIds: state.rows ? collectPositionRowIds(state.rows.dataRowIds, state.rows.tree) : [], + fields: (state.columns?.orderedFields ?? []).filter( + (field) => visibilityModel[field] !== false && isPositionedDataField(field), + ), + }; +} + +export function createFormulaPositionContext( + snapshot: GridFormulaPositionSnapshot, + version: number, +): FormulaPositionContext { + const { rowIds, fields } = snapshot; + // The reverse map keys by stringified id, consistent with the engine + // cell-key format and the grid's rows lookup — both coerce ids to strings. + const rowIdToPosition = new Map(); + rowIds.forEach((id, index) => rowIdToPosition.set(String(id), index + 1)); + const fieldToPosition = new Map(); + fields.forEach((field, index) => fieldToPosition.set(field, index + 1)); + return { + version, + rowCount: rowIds.length, + columnCount: fields.length, + getRowIdAtPosition: (index) => rowIds[index - 1], + getPositionOfRowId: (id) => rowIdToPosition.get(String(id)), + getFieldAtPosition: (index) => fields[index - 1], + getPositionOfField: (field) => fieldToPosition.get(field), + }; +} + +export function arePositionArraysEqual(a: readonly T[], b: readonly T[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +/** + * Live position context for the A1 UI and transforms, memoized over its inputs + * so the whole paste batch and every header/row-number cell in one render share + * a single snapshot. Its row positions are exactly what `ROW_POSITION(n)`/A1 + * resolve to (sorted + filtered order, autogenerated and pinned rows excluded, + * pagination ignored) and its column positions exactly what the header letters + * use, so the displayed numbers, letters and frozen references can never + * disagree. + * + * Distinct from the evaluation cache's `positionContext`, which is built lazily + * (only when position-dependent formulas exist) and replaced by rebind passes — + * it can be `null` at edit time and lags the view by one pass. + */ +export const gridFormulaA1PositionContextSelector = createSelectorMemoized( + gridDataRowIdsSelector, + gridSortedRowIdsSelector, + gridFilteredSortedRowIdsSelector, + gridRowTreeSelector, + gridVisibleColumnFieldsSelector, + (dataRowIds, sortedRowIds, filteredSortedRowIds, tree, visibleFields): FormulaPositionContext => { + // Same mount-window fallback as `buildFormulaPositionSnapshot`. + const sourceIds = + sortedRowIds.length === 0 && dataRowIds.length > 0 ? dataRowIds : filteredSortedRowIds; + return createFormulaPositionContext( + { + rowIds: collectPositionRowIds(sourceIds, tree), + fields: visibleFields.filter(isPositionedDataField), + }, + 0, + ); + }, +); + +/** + * The A1 column letter for a field, or `''` when the field has no position + * (utility/grouping/hidden column). + */ +export function getFormulaColumnLetter(context: FormulaPositionContext, field: string): string { + const position = context.getPositionOfField(field); + return position === undefined ? '' : columnIndexToLetters(position); +} + +/** + * The data fields a formula can reference by name, in column order. Utility, + * grouping and row-number columns are excluded, but HIDDEN columns are kept: + * a same-row bare reference (`=price`) resolves against the full column lookup + * regardless of visibility, so the editor autocomplete offers them too. (A1 + * column letters, in contrast, are visibility-gated through the position + * context — a hidden column has no letter.) + */ +export const gridFormulaReferenceableFieldsSelector = createSelectorMemoized( + gridColumnFieldsSelector, + (fields): string[] => fields.filter(isPositionedDataField), +); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaRowNumberColDef.tsx b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaRowNumberColDef.tsx new file mode 100644 index 0000000000000..d3bebd211fac2 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaRowNumberColDef.tsx @@ -0,0 +1,35 @@ +import { type GridColDef, GRID_STRING_COL_DEF } from '@mui/x-data-grid-pro'; +import { renderFormulaRowNumberCell } from '../../../components/GridFormulaRowNumberCell'; +import { GRID_FORMULA_ROW_NUMBER_FIELD } from './gridFormulaPositionContext'; + +/** + * Autogenerated leftmost column that numbers rows by their view position when + * `formulaA1Notation` is on. A utility column: not sortable/filterable/editable, + * excluded from export/print and the column menu, hidden from the columns panel, + * and excluded from the formula position context (takes no column letter). + * The header is an empty corner, like a spreadsheet. + */ +export const GRID_FORMULA_ROW_NUMBER_COL_DEF: GridColDef = { + ...GRID_STRING_COL_DEF, + type: 'custom', + field: GRID_FORMULA_ROW_NUMBER_FIELD, + headerName: '', + width: 40, + minWidth: 40, + align: 'center', + headerAlign: 'center', + editable: false, + sortable: false, + filterable: false, + hideable: false, + resizable: false, + disableColumnMenu: true, + disableExport: true, + disableReorder: true, + // @ts-ignore — premium-only flags not on the community `GridColDef` type. + aggregable: false, + groupable: false, + chartable: false, + renderHeader: () => null, + renderCell: renderFormulaRowNumberCell, +}; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaSelectors.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaSelectors.ts new file mode 100644 index 0000000000000..fedf1f29b0bdb --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaSelectors.ts @@ -0,0 +1,29 @@ +import { createSelector, createRootSelector } from '@mui/x-data-grid-pro/internals'; +import type { GridRowId } from '@mui/x-data-grid-pro'; +import type { GridStatePremium } from '../../../models/gridStatePremium'; +import type { GridFormulaResult } from './gridFormulaInterfaces'; + +export const gridFormulaStateSelector = createRootSelector( + (state: GridStatePremium) => state.formula, +); + +/** + * Get the evaluated formula results as a lookup, keyed by row id and field. + * @category Formulas + */ +export const gridFormulaLookupSelector = createSelector( + gridFormulaStateSelector, + (formulaState) => formulaState.lookup, +); + +/** + * Get the evaluated result of one formula cell, or `null` when the cell does + * not hold a formula. Presence in the lookup is the masking criterion — a + * formula evaluating to `null` still returns a result entry. + * @category Formulas + */ +export const gridCellFormulaResultSelector = createSelector( + gridFormulaLookupSelector, + (formulaLookup, { id, field }: { id: GridRowId; field: string }): GridFormulaResult | null => + formulaLookup[id]?.[field] ?? null, +); diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaUtils.ts b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaUtils.ts new file mode 100644 index 0000000000000..285eb58677399 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/gridFormulaUtils.ts @@ -0,0 +1,128 @@ +import type { GridColDef } from '@mui/x-data-grid-pro'; +import { + FORMULA_BUILT_IN_FUNCTIONS, + createFormulaFunctionRegistry, + createFormulaParser, +} from './engine'; +import type { + GridFormulaFunctionDefinition, + GridFormulaInternalCache, +} from './gridFormulaInterfaces'; + +/** + * The built-in formula functions. + * The `formulaFunctions` prop has replacement semantics: spread this object to extend it. + */ +export const GRID_FORMULA_FUNCTIONS: Record = + Object.fromEntries(FORMULA_BUILT_IN_FUNCTIONS.map((definition) => [definition.name, definition])); + +export function createFormulaInternalCache( + formulaFunctions: Record, +): GridFormulaInternalCache { + return { + parser: createFormulaParser(), + registry: createFormulaFunctionRegistry(Object.values(formulaFunctions)), + registrySource: formulaFunctions, + records: new Map(), + dependents: new Map(), + dependentsByRowId: new Map(), + recordsByField: new Map(), + positionDependentKeys: new Set(), + rangeDependentsByField: new Map(), + positionContext: null, + positionContextRowIds: null, + positionContextFields: null, + positionContextVersion: 0, + suppressRegroupTrigger: false, + trackedValues: new Map(), + lastRowIdToModelLookup: null, + formulaFields: [], + lastColumnsSignature: new Map(), + lastCellEditStart: null, + lastA1Seed: null, + pasteOrigin: null, + }; +} + +export function resetFormulaEvaluationCache(cache: GridFormulaInternalCache) { + cache.records = new Map(); + cache.dependents = new Map(); + cache.dependentsByRowId = new Map(); + cache.recordsByField = new Map(); + cache.positionDependentKeys = new Set(); + cache.rangeDependentsByField = new Map(); + // The version counter survives: it must keep increasing across resets. + cache.positionContext = null; + cache.positionContextRowIds = null; + cache.positionContextFields = null; + cache.trackedValues = new Map(); + cache.lastRowIdToModelLookup = null; +} + +export function getFormulaFields(columnsLookup: Record): string[] { + const fields: string[] = []; + for (const field of Object.keys(columnsLookup)) { + if (columnsLookup[field].allowFormulas) { + fields.push(field); + } + } + return fields; +} + +export function areFormulaFieldsEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +/** + * The column inputs evaluation depends on: field existence and `valueGetter` + * identity (raw dependency reads go through it). + */ +export function computeColumnsSignature( + columnsLookup: Record, +): Map { + const signature = new Map(); + for (const field of Object.keys(columnsLookup)) { + signature.set(field, columnsLookup[field].valueGetter); + } + return signature; +} + +export function areColumnsSignaturesEqual( + a: Map, + b: Map, +): boolean { + if (a.size !== b.size) { + return false; + } + for (const [field, valueGetter] of a) { + if (!b.has(field) || b.get(field) !== valueGetter) { + return false; + } + } + return true; +} + +export function areFormulaFunctionRecordsEqual( + a: Record, + b: Record, +): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (a[key] !== b[key]) { + return false; + } + } + return true; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/index.ts b/packages/x-data-grid-premium/src/hooks/features/formula/index.ts new file mode 100644 index 0000000000000..61c916eeef994 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/index.ts @@ -0,0 +1,19 @@ +export type { + GridFormulaApi, + GridFormulaCellKey, + GridFormulaErrorCode, + GridFormulaFunctionArg, + GridFormulaFunctionContext, + GridFormulaFunctionDefinition, + GridFormulaLookup, + GridFormulaResult, + GridFormulaState, + GridFormulaValidationIssue, + GridFormulaValidationResult, +} from './gridFormulaInterfaces'; +export { + gridFormulaStateSelector, + gridFormulaLookupSelector, + gridCellFormulaResultSelector, +} from './gridFormulaSelectors'; +export { GRID_FORMULA_FUNCTIONS } from './gridFormulaUtils'; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormula.ts b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormula.ts new file mode 100644 index 0000000000000..4f8f628790c77 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormula.ts @@ -0,0 +1,456 @@ +'use client'; +import * as React from 'react'; +import type { RefObject } from '@mui/x-internals/types'; +import { warnOnce } from '@mui/x-internals/warning'; +import { + GridCellEditStartReasons, + gridColumnLookupSelector, + gridRowIdSelector, + gridRowsLookupSelector, + useGridApiMethod, + useGridEvent, +} from '@mui/x-data-grid-pro'; +import type { GridCellCoordinates, GridEventListener } from '@mui/x-data-grid-pro'; +import { + gridPivotActiveSelector, + GridStrategyGroup, + RowGroupingStrategy, + type GridStateInitializer, +} from '@mui/x-data-grid-pro/internals'; +import type { DataGridPremiumProcessedProps } from '../../../models/dataGridPremiumProps'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import type { GridStatePremium } from '../../../models/gridStatePremium'; +import { + createFormulaFunctionRegistry, + getFormulaExpression, + isFormulaSource, + validateFormulaExpression, +} from './engine'; +import { + computeFullFormulaPass, + computePositionRebindFormulaPass, + computeRowsDiffFormulaPass, + type FormulaPassContext, +} from './createFormulaEvaluation'; +import { + buildFormulaPositionSnapshot, + buildFormulaPositionSnapshotFromState, +} from './gridFormulaPositionContext'; +import type { + GridFormulaApi, + GridFormulaLookup, + GridFormulaPrivateApi, +} from './gridFormulaInterfaces'; +import { gridCellFormulaResultSelector, gridFormulaLookupSelector } from './gridFormulaSelectors'; +import { gridRowGroupingSanitizedModelSelector } from '../rowGrouping/gridRowGroupingSelector'; +import { + areColumnsSignaturesEqual, + areFormulaFieldsEqual, + areFormulaFunctionRecordsEqual, + computeColumnsSignature, + createFormulaInternalCache, + getFormulaFields, + resetFormulaEvaluationCache, +} from './gridFormulaUtils'; + +export const formulaStateInitializer: GridStateInitializer< + Pick, + GridPrivateApiPremium +> = (state, props, apiRef) => { + const cache = createFormulaInternalCache(props.formulaFunctions); + apiRef.current.caches.formula = cache; + + const premiumState = state as Partial; + const columnsLookup = premiumState.columns?.lookup ?? {}; + cache.lastColumnsSignature = computeColumnsSignature(columnsLookup); + const pivotActive = premiumState.pivoting?.active ?? false; + const enabled = !props.disableFormulas && !props.dataSource && !pivotActive; + const formulaFields = enabled ? getFormulaFields(columnsLookup) : []; + + let lookup: GridFormulaLookup = {}; + if (formulaFields.length > 0) { + lookup = computeFullFormulaPass({ + apiRef, + cache, + rowsLookup: premiumState.rows?.dataRowIdToModelLookup ?? {}, + columnsLookup, + formulaFields, + previousLookup: {}, + // Sorting and filtering initialize after the formula state — the + // mount-time `sortedRowsSet`/`filteredRowsSet` cascade rebinds + // position-dependent formulas against the real view order. + getPositionSnapshot: () => buildFormulaPositionSnapshotFromState(premiumState), + }).lookup; + } + + return { ...state, formula: { lookup } }; +}; + +export const useGridFormula = ( + apiRef: RefObject, + props: Pick, +) => { + const computeEffectiveFormulaFields = React.useCallback(() => { + if (props.disableFormulas || props.dataSource || gridPivotActiveSelector(apiRef)) { + return []; + } + return getFormulaFields(gridColumnLookupSelector(apiRef)); + }, [apiRef, props.disableFormulas, props.dataSource]); + + const runPass = React.useCallback( + (mode: 'diff' | 'full' | 'rebind'): GridCellCoordinates[] | null => { + const cache = apiRef.current.caches.formula; + const formulaFields = computeEffectiveFormulaFields(); + const previousLookup = gridFormulaLookupSelector(apiRef); + + if (formulaFields.length === 0) { + if ( + process.env.NODE_ENV !== 'production' && + !props.disableFormulas && + getFormulaFields(gridColumnLookupSelector(apiRef)).length > 0 + ) { + if (props.dataSource) { + warnOnce([ + 'MUI X Data Grid: Formulas are not supported with the `dataSource` prop.', + 'The `allowFormulas` column option is ignored and `=` cell values render as raw strings.', + ]); + } else if (gridPivotActiveSelector(apiRef)) { + warnOnce([ + 'MUI X Data Grid: Formulas are not supported while pivoting is active.', + 'Formula evaluation is paused and resumes when pivoting is deactivated.', + ]); + } + } + + resetFormulaEvaluationCache(cache); + cache.formulaFields = []; + const clearedRowKeys = Object.keys(previousLookup); + if (clearedRowKeys.length === 0) { + return null; + } + const rowsLookup = gridRowsLookupSelector(apiRef); + const changedCells: GridCellCoordinates[] = []; + for (const rowKey of clearedRowKeys) { + const row = rowsLookup[rowKey]; + const id = row === undefined ? rowKey : gridRowIdSelector(apiRef, row); + for (const field of Object.keys(previousLookup[rowKey])) { + changedCells.push({ id, field }); + } + } + apiRef.current.setState((state) => ({ + ...state, + formula: { ...state.formula, lookup: {} }, + })); + apiRef.current.publishEvent('formulaEvaluationEnd', { changedCells }); + return changedCells; + } + + const ctx: FormulaPassContext = { + apiRef, + cache, + rowsLookup: gridRowsLookupSelector(apiRef), + columnsLookup: gridColumnLookupSelector(apiRef), + formulaFields, + previousLookup, + getPositionSnapshot: () => buildFormulaPositionSnapshot(apiRef), + }; + let result; + if (mode === 'full') { + result = computeFullFormulaPass(ctx); + } else if (mode === 'diff') { + result = computeRowsDiffFormulaPass(ctx); + } else { + result = computePositionRebindFormulaPass(ctx); + } + if (result === null || result.changedCells.length === 0) { + return null; + } + apiRef.current.setState((state) => ({ + ...state, + formula: { ...state.formula, lookup: result.lookup }, + })); + apiRef.current.publishEvent('formulaEvaluationEnd', { changedCells: result.changedCells }); + return result.changedCells; + }, + [apiRef, computeEffectiveFormulaFields, props.disableFormulas, props.dataSource], + ); + + /** + * Refreshes the features that consumed formula values before a pass + * changed them. Aggregation and row spanning recompute on their own after + * rows-driven cascades — callers opt in only where no such cascade runs. + * Row grouping builds its tree before the formula pass in the same + * cascade, so a pass that changed cells of a grouped field re-triggers the + * tree build; the rebuild's own cascade is suppressed from re-triggering. + */ + const triggerDependentFeatures = React.useCallback( + ( + changedCells: GridCellCoordinates[] | null, + options: { aggregation: boolean; rowSpanning: boolean }, + ) => { + if (changedCells === null || changedCells.length === 0) { + return; + } + if (options.aggregation) { + apiRef.current.applyAggregation(); + } + if (options.rowSpanning) { + apiRef.current.resetRowSpanningState(); + } + const cache = apiRef.current.caches.formula; + if (cache.suppressRegroupTrigger) { + return; + } + if ( + apiRef.current.getActiveStrategy(GridStrategyGroup.RowTree) !== RowGroupingStrategy.Default + ) { + return; + } + const groupedFields = gridRowGroupingSanitizedModelSelector(apiRef); + if (groupedFields.length === 0) { + return; + } + if (!changedCells.some((cell) => groupedFields.includes(cell.field))) { + return; + } + cache.suppressRegroupTrigger = true; + try { + apiRef.current.publishEvent('activeStrategyProcessorChange', 'rowTreeCreation'); + } finally { + cache.suppressRegroupTrigger = false; + } + }, + [apiRef], + ); + + /** + * API METHODS + */ + const setCellFormula = React.useCallback( + (id, field, formula) => { + const colDef = apiRef.current.getColumn(field); + if (!colDef || !colDef.allowFormulas) { + throw new Error( + `MUI X Data Grid: The column "${field}" does not allow formulas. ` + + 'Writing a formula to it would store a string rendered as-is instead of an evaluated value. ' + + 'Set `allowFormulas: true` on the column definition. ' + + 'See https://mui.com/x/react-data-grid/formulas/.', + ); + } + if (typeof formula !== 'string' || !isFormulaSource(formula)) { + throw new Error( + 'MUI X Data Grid: `setCellFormula()` expects a formula source starting with `=`, for example `=price * quantity`. ' + + 'Other values would not be recognized as formulas. ' + + 'To store a plain value, use `updateRows()` instead.', + ); + } + const row = apiRef.current.getRow(id); + if (!row) { + throw new Error(`MUI X: No row with id #${id} found.`); + } + apiRef.current.updateRows([{ ...row, [field]: formula }]); + }, + [apiRef], + ); + + const getCellFormula = React.useCallback( + (id, field) => { + const raw = apiRef.current.getRow(id)?.[field]; + return isFormulaSource(raw) ? raw : null; + }, + [apiRef], + ); + + const getCellFormulaResult = React.useCallback( + (id, field) => gridCellFormulaResultSelector(apiRef, { id, field }), + [apiRef], + ); + + const validateCellFormula = React.useCallback( + (formula) => + validateFormulaExpression(getFormulaExpression(typeof formula === 'string' ? formula : ''), { + functions: apiRef.current.caches.formula.registry, + }), + [apiRef], + ); + + const applyFormulaEvaluation = React.useCallback< + GridFormulaPrivateApi['applyFormulaEvaluation'] + >(() => { + // Aggregation, row spanning and grouping consumed formula values through + // `getRowValue` — refresh them after passes that are not part of a rows + // update cascade (registry change, enablement toggle, `reevaluateFormulas`). + triggerDependentFeatures(runPass('full'), { aggregation: true, rowSpanning: true }); + }, [runPass, triggerDependentFeatures]); + + const reevaluateFormulas = React.useCallback(() => { + apiRef.current.applyFormulaEvaluation(); + }, [apiRef]); + + const formulaApi: GridFormulaApi = { + setCellFormula, + getCellFormula, + getCellFormulaResult, + validateCellFormula, + reevaluateFormulas, + }; + + const formulaPrivateApi: GridFormulaPrivateApi = { + applyFormulaEvaluation, + }; + + useGridApiMethod(apiRef, formulaApi, 'public'); + useGridApiMethod(apiRef, formulaPrivateApi, 'private'); + + /** + * EVENTS + */ + const handleRowsSet = React.useCallback>(() => { + // Synchronous on purpose: the filtering and sorting `rowsSet` handlers run + // after this one in the same cascade and must read fresh formula values. + // No aggregation/row-spanning refresh: both recompute later in the + // cascade, on `filteredRowsSet`/`sortedRowsSet`. + triggerDependentFeatures(runPass('diff'), { aggregation: false, rowSpanning: false }); + }, [runPass, triggerDependentFeatures]); + + const handleSortedRowsSet = React.useCallback>(() => { + // One-shot rebind (D4): position-dependent formulas re-evaluate against + // the new view order exactly once; the grid never re-sorts in response. + // Aggregation already consumed values during this cascade — refresh it + // when the rebind changed something. Row spanning resets on this event + // after this handler, so it picks fresh values up by itself. + triggerDependentFeatures(runPass('rebind'), { aggregation: true, rowSpanning: false }); + }, [runPass, triggerDependentFeatures]); + + const handleFilteredRowsSet = React.useCallback>(() => { + triggerDependentFeatures(runPass('rebind'), { aggregation: true, rowSpanning: false }); + }, [runPass, triggerDependentFeatures]); + + const handleColumnVisibilityModelChange = React.useCallback< + GridEventListener<'columnVisibilityModelChange'> + >(() => { + // Visibility changes do not publish `columnsChange` — they replace the + // columns state directly. Row spanning does not reset on this event + // either, so it is refreshed here when the rebind changed values. + triggerDependentFeatures(runPass('rebind'), { aggregation: true, rowSpanning: true }); + }, [runPass, triggerDependentFeatures]); + + const handleColumnsChange = React.useCallback>(() => { + const cache = apiRef.current.caches.formula; + const fieldsChanged = !areFormulaFieldsEqual( + computeEffectiveFormulaFields(), + cache.formulaFields, + ); + // Evaluation also depends on the full column set (`#REF!` for unknown + // fields) and on the referenced columns' `valueGetter`s — re-evaluate + // when those change even if the `allowFormulas` set is stable. + const columnsSignature = computeColumnsSignature(gridColumnLookupSelector(apiRef)); + const signatureChanged = !areColumnsSignaturesEqual( + columnsSignature, + cache.lastColumnsSignature, + ); + if (!fieldsChanged && !signatureChanged) { + // The column set is stable, but columns may have moved: visibility + // toggles and reorders (programmatic `setColumnIndex` included) all + // funnel through this event. The rebind pass compares the visible + // field order itself and exits cheaply when nothing moved. + triggerDependentFeatures(runPass('rebind'), { aggregation: true, rowSpanning: false }); + return; + } + cache.lastColumnsSignature = columnsSignature; + if (fieldsChanged) { + apiRef.current.requestPipeProcessorsApplication('hydrateColumns'); + } + // Row spanning resets on `columnsChange` after this handler. + triggerDependentFeatures(runPass('full'), { aggregation: true, rowSpanning: false }); + }, [apiRef, computeEffectiveFormulaFields, runPass, triggerDependentFeatures]); + + const handleCellEditStart = React.useCallback>( + (params, event) => { + const isPrintableKeyDown = params.reason === GridCellEditStartReasons.printableKeyDown; + apiRef.current.caches.formula.lastCellEditStart = { + id: params.id, + field: params.field, + replaceValue: + isPrintableKeyDown || + params.reason === GridCellEditStartReasons.deleteKeyDown || + params.reason === GridCellEditStartReasons.pasteKeyDown, + startedWithEquals: isPrintableKeyDown && (event as React.KeyboardEvent).key === '=', + }; + }, + [apiRef], + ); + + const handleCellEditStop = React.useCallback>(() => { + const cache = apiRef.current.caches.formula; + cache.lastCellEditStart = null; + // A1 seed is consumed by the commit parser; clear it so it cannot affect a + // later edit of the same cell. + cache.lastA1Seed = null; + }, [apiRef]); + + // Arm the A1 paste origin: the first cell of the batch sets it, the rest + // offset their relative references from it (Excel fill). + const handleClipboardPasteStart = React.useCallback(() => { + apiRef.current.caches.formula.pasteOrigin = null; + }, [apiRef]); + + useGridEvent(apiRef, 'rowsSet', handleRowsSet); + useGridEvent(apiRef, 'sortedRowsSet', handleSortedRowsSet); + useGridEvent(apiRef, 'filteredRowsSet', handleFilteredRowsSet); + useGridEvent(apiRef, 'columnVisibilityModelChange', handleColumnVisibilityModelChange); + useGridEvent(apiRef, 'columnsChange', handleColumnsChange); + useGridEvent(apiRef, 'cellEditStart', handleCellEditStart); + useGridEvent(apiRef, 'cellEditStop', handleCellEditStop); + useGridEvent(apiRef, 'clipboardPasteStart', handleClipboardPasteStart); + + /** + * EFFECTS + */ + React.useEffect(() => { + const cache = apiRef.current.caches.formula; + if (cache.registrySource === props.formulaFunctions) { + return; + } + // Inline `formulaFunctions={{ ... }}` props change identity on every + // parent render — only rebuild when a definition actually changed. + const sameDefinitions = areFormulaFunctionRecordsEqual( + cache.registrySource, + props.formulaFunctions, + ); + cache.registrySource = props.formulaFunctions; + if (sameDefinitions) { + return; + } + cache.registry = createFormulaFunctionRegistry(Object.values(props.formulaFunctions)); + apiRef.current.applyFormulaEvaluation(); + }, [apiRef, props.formulaFunctions]); + + const isFirstEnablementEffect = React.useRef(true); + React.useEffect(() => { + if (isFirstEnablementEffect.current) { + isFirstEnablementEffect.current = false; + return; + } + apiRef.current.applyFormulaEvaluation(); + }, [apiRef, props.disableFormulas, props.dataSource]); + + const hasKickedInitialRegroup = React.useRef(false); + React.useEffect(() => { + if (hasKickedInitialRegroup.current) { + return; + } + hasKickedInitialRegroup.current = true; + // The initial row tree is built during rows state initialization, before + // the initial formula evaluation — when a grouped column holds formula + // results, rebuild the tree once so group keys use evaluated values. + const lookup = gridFormulaLookupSelector(apiRef); + const initialCells: GridCellCoordinates[] = []; + for (const rowKey of Object.keys(lookup)) { + for (const field of Object.keys(lookup[rowKey])) { + initialCells.push({ id: rowKey, field }); + } + } + triggerDependentFeatures(initialCells, { aggregation: false, rowSpanning: false }); + }, [apiRef, triggerDependentFeatures]); +}; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaColumnHeaderAdornment.tsx b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaColumnHeaderAdornment.tsx new file mode 100644 index 0000000000000..97bab93c9e8b5 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaColumnHeaderAdornment.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { useGridRootProps } from '../../utils/useGridRootProps'; +import { GridFormulaColumnHeaderLetter } from '../../../components/GridFormulaColumnHeaderLetter'; + +/** + * Premium implementation of the `useColumnHeaderAdornment` configuration hook: + * the A1 column-letter shown next to each data column's header title. + * + * Returns `null` (and so renders nothing, and crucially does not subscribe to + * the position context) whenever A1 notation is inactive, so the common + * feature-off path adds no header re-renders. The subscription lives inside + * `GridFormulaColumnHeaderLetter`, which only mounts when A1 is active. + */ +export function useGridFormulaColumnHeaderAdornment(field: string): React.ReactNode { + const rootProps = useGridRootProps(); + const a1Active = + rootProps.formulaA1Notation && !rootProps.disableFormulas && !rootProps.dataSource; + if (!a1Active) { + return null; + } + return ; +} diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaPreProcessors.tsx b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaPreProcessors.tsx new file mode 100644 index 0000000000000..f603443fc7969 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/useGridFormulaPreProcessors.tsx @@ -0,0 +1,56 @@ +'use client'; +import * as React from 'react'; +import type { RefObject } from '@mui/x-internals/types'; +import { + type GridPipeProcessor, + useGridRegisterPipeProcessor, +} from '@mui/x-data-grid-pro/internals'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import type { DataGridPremiumProcessedProps } from '../../../models/dataGridPremiumProps'; +import { wrapColumnWithFormula, unwrapColumnFromFormula } from './wrapColumnWithFormula'; +import { GRID_FORMULA_ROW_NUMBER_FIELD } from './gridFormulaPositionContext'; +import { GRID_FORMULA_ROW_NUMBER_COL_DEF } from './gridFormulaRowNumberColDef'; + +export const useGridFormulaPreProcessors = ( + apiRef: RefObject, + props: Pick< + DataGridPremiumProcessedProps, + 'disableFormulas' | 'dataSource' | 'formulaA1Notation' + >, +) => { + const updateFormulaColumns = React.useCallback>( + (columnsState) => { + const formulasEnabled = !props.disableFormulas && !props.dataSource; + + const a1NotationEnabled = formulasEnabled && !!props.formulaA1Notation; + columnsState.orderedFields.forEach((field) => { + let column = unwrapColumnFromFormula(columnsState.lookup[field]); + if (formulasEnabled && column.allowFormulas) { + column = wrapColumnWithFormula(column, apiRef, a1NotationEnabled); + } + columnsState.lookup[field] = column; + }); + + // Inject/remove the autogenerated row-number column for A1 notation. Its + // leftmost position comes from the pinned-left model (set in + // `useDataGridPremiumProps`); the column-pinning pre-processor, registered + // after this one, moves it to the front of the left section. + const shouldHaveRowNumberColumn = formulasEnabled && props.formulaA1Notation; + const hasRowNumberColumn = columnsState.lookup[GRID_FORMULA_ROW_NUMBER_FIELD] != null; + if (shouldHaveRowNumberColumn && !hasRowNumberColumn) { + columnsState.lookup[GRID_FORMULA_ROW_NUMBER_FIELD] = GRID_FORMULA_ROW_NUMBER_COL_DEF; + columnsState.orderedFields = [GRID_FORMULA_ROW_NUMBER_FIELD, ...columnsState.orderedFields]; + } else if (!shouldHaveRowNumberColumn && hasRowNumberColumn) { + delete columnsState.lookup[GRID_FORMULA_ROW_NUMBER_FIELD]; + columnsState.orderedFields = columnsState.orderedFields.filter( + (field) => field !== GRID_FORMULA_ROW_NUMBER_FIELD, + ); + } + + return columnsState; + }, + [apiRef, props.disableFormulas, props.dataSource, props.formulaA1Notation], + ); + + useGridRegisterPipeProcessor(apiRef, 'hydrateColumns', updateFormulaColumns); +}; diff --git a/packages/x-data-grid-premium/src/hooks/features/formula/wrapColumnWithFormula.tsx b/packages/x-data-grid-premium/src/hooks/features/formula/wrapColumnWithFormula.tsx new file mode 100644 index 0000000000000..41f3ed6f5d311 --- /dev/null +++ b/packages/x-data-grid-premium/src/hooks/features/formula/wrapColumnWithFormula.tsx @@ -0,0 +1,218 @@ +import * as React from 'react'; +import type { RefObject } from '@mui/x-internals/types'; +import { gridRowIdSelector } from '@mui/x-data-grid-pro'; +import type { GridColDef, GridRowId, GridValidRowModel } from '@mui/x-data-grid-pro'; +import { getRowValue as getRowValueUtil } from '@mui/x-data-grid-pro/internals'; +import type { GridBaseColDef } from '@mui/x-data-grid-pro/internals'; +import type { GridPrivateApiPremium } from '../../../models/gridApiPremium'; +import { GridFormulaEditCell } from '../../../components/GridFormulaEditCell'; +import { isEscapedFormulaSource, isFormulaSource, unescapeLiteralSource } from './engine'; +import { gridCellFormulaResultSelector } from './gridFormulaSelectors'; +import { convertA1ToCanonicalCommit, convertA1ToCanonicalPaste } from './gridFormulaA1Transforms'; + +// Deliberately disjoint from the properties the aggregation wrapper touches +// (`renderCell`/`renderHeader`): two features stacking wrappers on the same +// property cannot unwrap cleanly (the identity check skips a wrapper that has +// another one on top, accumulating layers on every `hydrateColumns` pass). +type WrappableColumnProperty = + | 'renderEditCell' + | 'valueParser' + | 'valueSetter' + | 'preProcessEditCellProps' + | 'pastedValueParser' + | 'rowSpanValueGetter'; + +interface GridColDefWithFormulaWrappers extends GridBaseColDef { + formulaWrappedProperties: { + name: WrappableColumnProperty; + originalValue: GridBaseColDef[WrappableColumnProperty]; + wrappedValue: GridBaseColDef[WrappableColumnProperty]; + }[]; +} + +function isFormulaEditValue(value: unknown): boolean { + return isFormulaSource(value) || isEscapedFormulaSource(value); +} + +function areCommittedValuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime(); +} + +/** + * Wraps the editing-related properties of an `allowFormulas` column so that + * formula sources survive the edit pipeline (D12): the editor seeds and + * commits sources, parsers pass `=` strings through, and the value setter + * protects formulas from being overwritten by their own evaluated value. + */ +export const wrapColumnWithFormula = ( + column: GridBaseColDef, + apiRef: RefObject, + 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" },