+ Use allowFormula: true columns with optional FormulaService, while keeping Excel export optional.
+
+
+ This demo is based on Example23. It stores formulas in FormulaService (per rowId + columnId), and Excel Export migrates these formulas to
+ native Excel formulas when ungrouped. Group total rows still use groupTotalsExcelExportOptions.valueParserCallback.
+
+
+ Excel-like UX: when editing any allowFormula: true column, headers are prefixed with Excel column letters (A,
+ B, ...).
+
+
+ MVP note: formula editing is text-based and does not yet include token coloring, click-to-reference, or range composer UX.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tax Rate (%):
+
+
+
+
+
+
+
+
+ Try formula input:
+ edit any Sub-Total, Taxes, or Total cell and type for example
+ =REF(COLUMN("price"),ROW("1"))*REF(COLUMN("qty"),ROW("1"))
+
+
+
+
+
+ Compatibility note:
+ workbook custom functions (for example CUSTOMSUM(...)) rely on modern Excel LAMBDA conventions. LibreOffice/OpenOffice
+ might show errors for these formulas. Use Export Portable Values when cross-suite compatibility is required.
+
+
+
+
+
+ Last Formula Event:
+ ${lastFormulaEvent}
+
+
+
+
diff --git a/demos/vanilla/src/examples/example47.scss b/demos/vanilla/src/examples/example47.scss
new file mode 100644
index 000000000..be1af8464
--- /dev/null
+++ b/demos/vanilla/src/examples/example47.scss
@@ -0,0 +1,43 @@
+.grid47 {
+ --example47-row-index-bg: #ececec;
+ --example47-row-index-color: inherit;
+ --example47-sub-total-color: rgb(33, 80, 115);
+ --example47-taxes-color: rgb(198, 89, 17);
+ --example47-total-color: rgb(0, 90, 158);
+ --slick-text-editor-background: #fff;
+ --slick-cell-selected-color: #fff;
+
+ .slick-row:not(.slick-group) > .cell-unselectable {
+ background: var(--example47-row-index-bg) !important;
+ color: var(--example47-row-index-color);
+ font-weight: bold;
+ }
+
+ .text-sub-total {
+ font-style: italic;
+ color: var(--example47-sub-total-color);
+ }
+
+ .text-taxes {
+ font-style: italic;
+ color: var(--example47-taxes-color);
+ }
+
+ .text-total {
+ font-weight: bold;
+ color: var(--example47-total-color);
+ }
+}
+
+body[data-theme='dark'] .grid47,
+.dark-mode .grid47,
+.slick-dark-mode .grid47 {
+ --example47-row-index-bg: #334155;
+ --example47-row-index-color: #e2e8f0;
+ --example47-sub-total-color: #93c5fd;
+ --example47-taxes-color: #fdba74;
+ --example47-total-color: #60a5fa;
+ --slick-text-editor-background: #111827;
+ --slick-cell-selected-color: #2c2c2c;
+ --slick-cell-selected-editable-color: #333333;
+}
diff --git a/demos/vanilla/src/examples/example47.ts b/demos/vanilla/src/examples/example47.ts
new file mode 100644
index 000000000..ff202ae04
--- /dev/null
+++ b/demos/vanilla/src/examples/example47.ts
@@ -0,0 +1,660 @@
+import { BindingEventService } from '@slickgrid-universal/binding';
+import {
+ Aggregators,
+ Editors,
+ Formatters,
+ GroupTotalFormatters,
+ type Aggregator,
+ type Column,
+ type ExcelGroupValueParserArgs,
+ type Formatter,
+ type GridOption,
+ type Grouping,
+ type SlickGrid,
+ type SlickGroupTotals,
+} from '@slickgrid-universal/common';
+import { ExcelExportService } from '@slickgrid-universal/excel-export';
+import { FormulaService } from '@slickgrid-universal/formula-plugin';
+import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';
+import { ExampleGridOptions } from './example-grid-options.js';
+import './example47.scss';
+
+interface GroceryItem {
+ id: number;
+ name: string;
+ qty: number;
+ price: number;
+ taxable: boolean;
+ subTotal: number | string;
+ taxes: number | string;
+ total: number | string;
+ customSum?: number | string;
+}
+
+/** Check if the current item (cell) is editable or not */
+function checkItemIsEditable(_dataContext: GroceryItem, columnDef: Column, grid: SlickGrid) {
+ const gridOptions = grid.getOptions();
+ // Formula editor can be auto-wired by FormulaService; detect both pre/post wiring states.
+ const hasEditor = !!(columnDef.editor || columnDef.editorClass || (columnDef.allowFormula && gridOptions.enableFormulas));
+ const isGridEditable = gridOptions.editable;
+ const isEditable = isGridEditable && hasEditor;
+
+ return isEditable;
+}
+
+const customEditableInputFormatter: Formatter = (_row, _cell, value, columnDef, dataContext: GroceryItem, grid) => {
+ const isEditableItem = checkItemIsEditable(dataContext, columnDef, grid);
+ value = value === null || value === undefined ? '' : value;
+ const divElm = document.createElement('div');
+ divElm.className = 'editing-field';
+ if (value instanceof HTMLElement) {
+ divElm.appendChild(value);
+ } else {
+ divElm.textContent = value;
+ }
+ return isEditableItem ? divElm : value;
+};
+
+/** Create a Custom Aggregator in order to calculate all Totals by accessing other fields of the item dataContext */
+export class CustomSumAggregator implements Aggregator {
+ private _sum = 0;
+ private _type = 'sum' as const;
+
+ constructor(
+ public readonly field: number | string,
+ public taxRate: number
+ ) {}
+
+ get type(): string {
+ return this._type;
+ }
+
+ init() {
+ this._sum = 0;
+ }
+
+ accumulate(item: GroceryItem) {
+ if (this.field === 'taxes' && item.taxable) {
+ this._sum += item.price * item.qty * (this.taxRate / 100);
+ }
+ if (this.field === 'subTotal') {
+ this._sum += item.price * item.qty;
+ }
+ if (this.field === 'total') {
+ let taxes = 0;
+ if (item.taxable) {
+ taxes = item.price * item.qty * (this.taxRate / 100);
+ }
+ this._sum += item.price * item.qty + taxes;
+ }
+ }
+
+ storeResult(groupTotals: any) {
+ if (!groupTotals || groupTotals[this._type] === undefined) {
+ groupTotals[this._type] = {};
+ }
+ groupTotals[this._type][this.field] = this._sum;
+ }
+}
+
+export default class Example47 {
+ private _bindingEventService: BindingEventService;
+ private _darkMode = false;
+ private _headerPrefixResetTimer?: ReturnType;
+ columns: Column[] = [];
+ dataset: GroceryItem[] = [];
+ gridOptions!: GridOption;
+ gridContainerElm!: HTMLDivElement;
+ sgb!: SlickVanillaGridBundle;
+ excelExportService: ExcelExportService;
+ formulaService: FormulaService;
+ isDataGrouped = false;
+ taxRate = 7.5;
+ lastFormulaEvent = 'none';
+
+ constructor() {
+ this.excelExportService = new ExcelExportService();
+ this.formulaService = new FormulaService({
+ excelCustomFunctions: [{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }],
+ customFunctions: {
+ CUSTOMSUM: {
+ func: (params) => {
+ let total = 0;
+ for (const value of params.values) {
+ const num = Number(value);
+ total += Number.isFinite(num) ? num : 0;
+ }
+ return total;
+ },
+ },
+ },
+ });
+ this._bindingEventService = new BindingEventService();
+ }
+
+ attached() {
+ this.defineGrid();
+ this.dataset = this.getData();
+ this.gridContainerElm = document.querySelector('.grid47') as HTMLDivElement;
+
+ this.sgb = new Slicker.GridBundle(this.gridContainerElm, this.columns, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset);
+
+ this._bindingEventService.bind(this.gridContainerElm, 'onbeforeeditcell', this.handleOnBeforeEditCell.bind(this));
+ this._bindingEventService.bind(this.gridContainerElm, 'onbeforecelleditordestroy', this.handleOnBeforeCellEditorDestroy.bind(this));
+ this._bindingEventService.bind(this.gridContainerElm, 'oncellchange', this.handleOnCellChange.bind(this));
+ this._bindingEventService.bind(this.gridContainerElm, 'onclick', this.handleOnCellClicked.bind(this));
+ this.loadDefaultFormulas();
+ this.invalidateAll();
+ document.body.classList.add('salesforce-theme');
+ }
+
+ dispose() {
+ clearTimeout(this._headerPrefixResetTimer);
+ this.formulaService.clearFormulaReferenceHighlights();
+ this.formulaService.disableExcelHeaderPrefix();
+ this._bindingEventService.unbindAll();
+ this.sgb?.dispose();
+ this.gridContainerElm?.remove();
+ document.querySelector('.demo-container')?.classList.remove('dark-mode');
+ document.body.setAttribute('data-theme', 'light');
+ document.body.classList.remove('salesforce-theme');
+ }
+
+ defineGrid() {
+ this.columns = [
+ {
+ id: 'sel',
+ name: '#',
+ field: 'id',
+ headerCssClass: 'header-centered',
+ cssClass: 'cell-unselectable',
+ excludeFromExport: true,
+ maxWidth: 30,
+ },
+ {
+ id: 'name',
+ name: 'Name',
+ field: 'name',
+ sortable: true,
+ width: 140,
+ filterable: true,
+ excelExportOptions: { width: 18 },
+ },
+ {
+ id: 'price',
+ name: 'Price',
+ field: 'price',
+ type: 'number',
+ editor: { model: Editors.float, decimal: 2 },
+ sortable: true,
+ width: 70,
+ filterable: true,
+ formatter: Formatters.dollar,
+ groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold,
+ groupTotalsExcelExportOptions: {
+ style: {
+ font: { bold: true, size: 11.5 },
+ format: '$0.00',
+ border: { top: { color: 'FF747474', style: 'thick' } },
+ },
+ valueParserCallback: this.excelGroupCellParser.bind(this),
+ },
+ },
+ {
+ id: 'qty',
+ name: 'Quantity',
+ field: 'qty',
+ type: 'number',
+ groupTotalsFormatter: GroupTotalFormatters.sumTotalsBold,
+ groupTotalsExcelExportOptions: {
+ style: {
+ font: { bold: true, size: 11.5 },
+ border: { top: { color: 'FF747474', style: 'thick' } },
+ },
+ valueParserCallback: this.excelGroupCellParser.bind(this),
+ },
+ params: { minDecimal: 0, maxDecimal: 0 },
+ editor: { model: Editors.integer },
+ sortable: true,
+ width: 60,
+ filterable: true,
+ },
+ {
+ id: 'subTotal',
+ name: 'Sub-Total',
+ field: 'subTotal',
+ cssClass: 'text-sub-total',
+ type: 'number',
+ sortable: true,
+ width: 120,
+ filterable: true,
+ allowFormula: true,
+ formatter: Formatters.dollar,
+ groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold,
+ excelExportOptions: {
+ style: {
+ font: { outline: false, italic: true, color: 'FF215073' },
+ format: '$0.00',
+ },
+ width: 12,
+ },
+ groupTotalsExcelExportOptions: {
+ style: {
+ font: { bold: true, italic: true, size: 11.5 },
+ format: '$0.00',
+ border: { top: { color: 'FF747474', style: 'thick' } },
+ },
+ valueParserCallback: this.excelGroupCellParser.bind(this),
+ },
+ },
+ {
+ id: 'taxable',
+ name: 'Taxable',
+ field: 'taxable',
+ cssClass: 'text-center',
+ sortable: true,
+ width: 60,
+ filterable: true,
+ // Important: export raw boolean values for formula interoperability in Excel.
+ // If formatter output is exported (checkmark/icon/string), IF(Fx=TRUE, ...) formulas evaluate incorrectly.
+ exportWithFormatter: false,
+ formatter: Formatters.checkmarkMaterial,
+ excelExportOptions: {
+ style: {
+ alignment: { horizontal: 'center' },
+ },
+ valueParserCallback: (val, { excelFormatId }) => ({
+ value: String(val).toLowerCase() === 'true',
+ metadata: { style: excelFormatId },
+ }),
+ },
+ },
+ {
+ id: 'taxes',
+ name: 'Taxes',
+ field: 'taxes',
+ cssClass: 'text-taxes',
+ type: 'number',
+ sortable: true,
+ width: 90,
+ filterable: true,
+ allowFormula: true,
+ formatter: Formatters.dollar,
+ groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold,
+ excelExportOptions: {
+ style: {
+ font: { outline: false, italic: true, color: 'FFC65911' },
+ format: '$0.00',
+ },
+ width: 12,
+ },
+ groupTotalsExcelExportOptions: {
+ style: {
+ font: { bold: true, italic: true, color: 'FFC65911', size: 11.5 },
+ format: '$0.00',
+ border: { top: { color: 'FF747474', style: 'thick' } },
+ },
+ valueParserCallback: this.excelGroupCellParser.bind(this),
+ },
+ },
+ {
+ id: 'total',
+ name: 'Total',
+ field: 'total',
+ type: 'number',
+ sortable: true,
+ width: 90,
+ filterable: true,
+ cssClass: 'text-total',
+ allowFormula: true,
+ formatter: Formatters.dollar,
+ groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold,
+ excelExportOptions: {
+ style: {
+ font: { outline: false, bold: true, color: 'FF005A9E' },
+ format: '$0.00',
+ },
+ width: 12,
+ },
+ groupTotalsExcelExportOptions: {
+ style: {
+ font: { bold: true, color: 'FF005A9E', size: 12 },
+ format: '$0.00',
+ border: { top: { color: 'FF747474', style: 'thick' } },
+ },
+ valueParserCallback: this.excelGroupCellParser.bind(this),
+ },
+ },
+ {
+ id: 'customSum',
+ name: 'Custom Sum',
+ field: 'customSum',
+ type: 'number',
+ sortable: true,
+ width: 115,
+ filterable: true,
+ cssClass: 'text-total',
+ allowFormula: true,
+ formatter: Formatters.dollar,
+ excelExportOptions: {
+ style: {
+ font: { outline: false, bold: true, color: 'FF6A1B9A' },
+ format: '$0.00',
+ },
+ width: 14,
+ },
+ },
+ ];
+
+ this.gridOptions = {
+ autoAddCustomEditorFormatter: customEditableInputFormatter,
+ darkMode: this._darkMode,
+ gridHeight: 470,
+ gridWidth: 1080,
+ enableCellNavigation: true,
+ autoEdit: false,
+ autoCommitEdit: true,
+ editable: true,
+ rowHeight: 38,
+ formatterOptions: {
+ maxDecimal: 2,
+ minDecimal: 2,
+ },
+ enableGrouping: true,
+ enableFormulas: true,
+ enableExcelExport: true,
+ externalResources: [this.excelExportService, this.formulaService],
+ excelExportOptions: {
+ filename: 'grocery-list-formula-service',
+ sanitizeDataExport: true,
+ sheetName: 'Grocery List Formula Service',
+ columnHeaderStyle: {
+ font: { color: 'FFFFFFFF' },
+ fill: { type: 'pattern', patternType: 'solid', fgColor: 'FF4a6c91' },
+ },
+ customExcelHeader: (workbook, sheet) => {
+ const excelFormat = workbook.getStyleSheet().createFormat({
+ font: { size: 18, fontName: 'Calibri', bold: true, color: 'FFFFFFFF' },
+ alignment: { wrapText: true, horizontal: 'center' },
+ fill: { type: 'pattern', patternType: 'solid', fgColor: 'FF203764' },
+ });
+ sheet.setRowInstructions(0, { height: 40 });
+
+ const customTitle = 'Grocery Shopping List (Formula Service)';
+ const lastCellMerge = this.isDataGrouped ? 'I1' : 'H1';
+ sheet.mergeCells('A1', lastCellMerge);
+ sheet.data.push([{ value: customTitle, metadata: { style: excelFormat.id } }]);
+ },
+ },
+ enableSelection: true,
+ selectionOptions: {
+ selectionType: 'mixed',
+ },
+ };
+ }
+
+ handleOnCellChange(event: any) {
+ const args = event?.detail?.args;
+ const columnDef = args?.column as Column | undefined;
+ if (!columnDef?.allowFormula) {
+ this.invalidateAll();
+ return;
+ }
+
+ const item = args.item as GroceryItem;
+ const rowId = item?.id;
+ const columnId = String(columnDef.id);
+ const value = item?.[columnId as keyof GroceryItem] as string | number | undefined;
+
+ if (typeof value === 'string' && value.trim().startsWith('=')) {
+ this.formulaService.setFormula(rowId, columnId, value.trim());
+ this.lastFormulaEvent = `saved formula for row ${rowId}, column ${columnId}`;
+ } else if (typeof value === 'string' && value.trim() === '') {
+ this.formulaService.removeFormula(rowId, columnId);
+ this.lastFormulaEvent = `removed formula for row ${rowId}, column ${columnId}`;
+ } else {
+ // If user replaces a formula with a plain value, clear stale formula from store.
+ this.formulaService.removeFormula(rowId, columnId);
+ this.lastFormulaEvent = `set static value for row ${rowId}, column ${columnId}`;
+ }
+
+ this.formulaService.clearFormulaReferenceHighlights();
+ this.formulaService.disableExcelHeaderPrefix();
+ this.invalidateAll();
+ }
+
+ handleOnBeforeEditCell(event: any) {
+ // Cancel pending deferred header reset from a previous editor destroy.
+ // Otherwise the delayed setColumns() can run after a new editor opens and close it immediately.
+ clearTimeout(this._headerPrefixResetTimer);
+
+ const args = event?.detail?.args;
+ const columnDef = args?.column as Column | undefined;
+
+ if (columnDef?.allowFormula) {
+ this.formulaService.enableExcelHeaderPrefix();
+ this.lastFormulaEvent = `formula edit mode enabled (${String(columnDef.id)})`;
+ } else {
+ this.formulaService.clearFormulaReferenceHighlights();
+ this.formulaService.disableExcelHeaderPrefix();
+ }
+
+ return true;
+ }
+
+ handleOnCellClicked(event: any) {
+ const args = event?.detail?.args;
+ const columnDef = args?.column as Column | undefined;
+
+ if (!columnDef?.allowFormula) {
+ this.formulaService.clearFormulaReferenceHighlights();
+ this.formulaService.disableExcelHeaderPrefix();
+ }
+ }
+
+ handleOnBeforeCellEditorDestroy() {
+ // Avoid calling setColumns() synchronously during editor teardown (ESC path),
+ // it can re-enter makeActiveCellNormal and recurse.
+ this.formulaService.clearFormulaReferenceHighlights();
+ clearTimeout(this._headerPrefixResetTimer);
+ this._headerPrefixResetTimer = setTimeout(() => this.formulaService.disableExcelHeaderPrefix(), 0);
+ }
+
+ invalidateAll() {
+ this.sgb.dataView?.refresh();
+ this.sgb.slickGrid?.invalidate();
+ this.sgb.slickGrid?.render();
+ }
+
+ updateTaxRate() {
+ if (this.isDataGrouped) {
+ this.groupByTaxable();
+ }
+
+ this.loadDefaultFormulas();
+ this.invalidateAll();
+ }
+
+ toggleDarkMode() {
+ this._darkMode = !this._darkMode;
+ this.toggleBodyBackground();
+ this.sgb.gridOptions = { ...this.sgb.gridOptions, darkMode: this._darkMode };
+ this.sgb.slickGrid?.setOptions({ darkMode: this._darkMode });
+ }
+
+ toggleBodyBackground() {
+ if (this._darkMode) {
+ document.body.setAttribute('data-theme', 'dark');
+ document.querySelector('.demo-container')?.classList.add('dark-mode');
+ } else {
+ document.body.setAttribute('data-theme', 'light');
+ document.querySelector('.demo-container')?.classList.remove('dark-mode');
+ }
+ }
+
+ exportToExcel() {
+ this.excelExportService.exportToExcel();
+ }
+
+ async exportToExcelPortable() {
+ const customFunctionColumnId = 'customSum';
+ const liveItems = (this.sgb?.dataView?.getItems?.() as GroceryItem[] | undefined) || this.dataset;
+ const formulaBackups = new Map();
+
+ for (const item of liveItems) {
+ const rowId = item.id;
+ const formula = this.formulaService.getFormula(rowId, customFunctionColumnId);
+ if (typeof formula !== 'string' || !formula.toUpperCase().includes('CUSTOMSUM(')) {
+ continue;
+ }
+
+ const evaluated = this.formulaService.getEvaluatedCellValue(rowId, customFunctionColumnId, item.customSum, item.customSum);
+ formulaBackups.set(rowId, formula);
+ item.customSum = evaluated as number | string;
+ this.formulaService.removeFormula(rowId, customFunctionColumnId);
+ }
+
+ try {
+ this.lastFormulaEvent = 'portable export mode (CUSTOMSUM values precomputed)';
+ await this.excelExportService.exportToExcel();
+ } finally {
+ for (const item of liveItems) {
+ const formula = formulaBackups.get(item.id);
+ if (!formula) {
+ continue;
+ }
+ item.customSum = formula;
+ this.formulaService.setFormula(item.id, customFunctionColumnId, formula);
+ }
+ if (formulaBackups.size > 0) {
+ this.invalidateAll();
+ }
+ }
+ }
+
+ clearAllFormulas() {
+ this.formulaService.clearFormulas();
+ this.lastFormulaEvent = 'formula store cleared';
+ }
+
+ loadDefaultFormulas() {
+ const liveItems = (this.sgb?.dataView?.getItems?.() as GroceryItem[] | undefined) || this.dataset;
+
+ liveItems.forEach((item, rowIdx) => {
+ // Grid includes all columns (#, Name, Price, Qty, Sub-Total, Taxable, Taxes, Total, Custom Sum)
+ // which maps to Excel-like references A..I in this demo.
+ const excelRowIdx = rowIdx + 1;
+
+ // Approach 1 (Direct Excel-like A1 references)
+ const subTotalFormula = `=C${excelRowIdx}*D${excelRowIdx}`;
+ const taxesFormula = `=IF(F${excelRowIdx}=TRUE,E${excelRowIdx}*${this.taxRate / 100},0)`;
+ const totalFormula = `=E${excelRowIdx}+G${excelRowIdx}`;
+ const customSumFormula = `=CUSTOMSUM(C${excelRowIdx}:D${excelRowIdx})`;
+
+ // Approach 2 (Dynamic REF/COLUMN/ROW references like AG-Grid)
+ // const subTotalFormula = `=REF(COLUMN("price"),ROW(${excelRowIdx}))*REF(COLUMN("qty"),ROW(${excelRowIdx}))`;
+ // const taxesFormula = `=IF(REF(COLUMN("taxable"),ROW(${excelRowIdx}))=TRUE,REF(COLUMN("subTotal"),ROW(${excelRowIdx}))*${
+ // this.taxRate / 100
+ // },0)`;
+ // const totalFormula = `=REF(COLUMN("subTotal"),ROW(${excelRowIdx}))+REF(COLUMN("taxes"),ROW(${excelRowIdx}))`;
+ // const customSumFormula = `=CUSTOMSUM(REF(COLUMN("price"),ROW(${excelRowIdx})):REF(COLUMN("qty"),ROW(${excelRowIdx})))`;
+
+ // keep values in dataset so opening a formula cell editor shows formula text directly.
+ item.subTotal = subTotalFormula;
+ item.taxes = taxesFormula;
+ item.total = totalFormula;
+ item.customSum = customSumFormula;
+ });
+
+ this.formulaService.syncFormulasFromDataset();
+
+ this.lastFormulaEvent = `loaded default formulas for ${liveItems.length} rows`;
+ }
+
+ excelGroupCellParser(totals: SlickGroupTotals, { columnDef, excelFormatId, dataRowIdx }: ExcelGroupValueParserArgs) {
+ const colOffset = 0;
+ const rowOffset = 3;
+ const priceIdx = this.sgb.slickGrid?.getColumnIndex('price') || 0;
+ const qtyIdx = this.sgb.slickGrid?.getColumnIndex('qty') || 0;
+ const taxesIdx = this.sgb.slickGrid?.getColumnIndex('taxes') || 0;
+ const subTotalIdx = this.sgb.slickGrid?.getColumnIndex('subTotal') || 0;
+ const totalIdx = this.sgb.slickGrid?.getColumnIndex('total') || 0;
+ const groupItemCount = totals?.group?.count || 0;
+
+ const excelPriceCol = `${String.fromCharCode('A'.charCodeAt(0) + priceIdx - colOffset)}`;
+ const excelQtyCol = `${String.fromCharCode('A'.charCodeAt(0) + qtyIdx - colOffset)}`;
+ const excelSubTotalCol = `${String.fromCharCode('A'.charCodeAt(0) + subTotalIdx - colOffset)}`;
+ const excelTaxesCol = `${String.fromCharCode('A'.charCodeAt(0) + taxesIdx - colOffset)}`;
+ const excelTotalCol = `${String.fromCharCode('A'.charCodeAt(0) + totalIdx - colOffset)}`;
+
+ let excelCol = '';
+ switch (columnDef.id) {
+ case 'price':
+ excelCol = excelPriceCol;
+ break;
+ case 'qty':
+ excelCol = excelQtyCol;
+ break;
+ case 'subTotal':
+ excelCol = excelSubTotalCol;
+ break;
+ case 'taxes':
+ excelCol = excelTaxesCol;
+ break;
+ case 'total':
+ excelCol = excelTotalCol;
+ break;
+ }
+ return {
+ value: `SUM(${excelCol}${dataRowIdx + rowOffset - groupItemCount}:${excelCol}${dataRowIdx + rowOffset - 1})`,
+ metadata: { type: 'formula', style: excelFormatId },
+ };
+ }
+
+ getData() {
+ let i = 1;
+ return [
+ { id: i++, name: 'Oranges', qty: 4, taxable: false, price: 2.22 },
+ { id: i++, name: 'Apples', qty: 3, taxable: false, price: 1.55 },
+ { id: i++, name: 'Honeycomb Cereals', qty: 2, taxable: true, price: 4.55 },
+ { id: i++, name: 'Raisins', qty: 77, taxable: false, price: 0.23 },
+ { id: i++, name: 'Corn Flake Cereals', qty: 1, taxable: true, price: 6.62 },
+ { id: i++, name: 'Tomatoes', qty: 3, taxable: false, price: 1.88 },
+ { id: i++, name: 'Butter', qty: 1, taxable: false, price: 3.33 },
+ { id: i++, name: 'BBQ Chicken', qty: 1, taxable: false, price: 12.33 },
+ { id: i++, name: 'Chicken Wings', qty: 12, taxable: true, price: 0.53 },
+ { id: i++, name: 'Drinkable Yogurt', qty: 6, taxable: true, price: 1.22 },
+ { id: i++, name: 'Milk', qty: 3, taxable: true, price: 3.11 },
+ ] as GroceryItem[];
+ }
+
+ clearGrouping() {
+ this.isDataGrouped = false;
+ this.sgb?.dataView?.setGrouping([]);
+ this.formulaService.disableExcelHeaderPrefix();
+ }
+
+ groupByTaxable() {
+ const checkIcon = 'mdi-check-box-outline';
+ const uncheckIcon = 'mdi-checkbox-blank-outline';
+ this.isDataGrouped = true;
+
+ this.sgb?.dataView?.setGrouping({
+ getter: 'taxable',
+ formatter: (g) =>
+ `Taxable: (${g.count} items)`,
+ comparer: (a, b) => b.value - a.value,
+ aggregators: [
+ new Aggregators.Sum('price'),
+ new Aggregators.Sum('qty'),
+ new CustomSumAggregator('subTotal', this.taxRate),
+ new CustomSumAggregator('taxes', this.taxRate),
+ new CustomSumAggregator('total', this.taxRate),
+ ],
+ aggregateCollapsed: false,
+ lazyTotalsCalculation: false,
+ } as Grouping);
+
+ this.sgb?.dataView?.refresh();
+ }
+}
diff --git a/demos/vue/test/cypress/e2e/example19.cy.ts b/demos/vue/test/cypress/e2e/example19.cy.ts
index babe74c51..d195ec26e 100644
--- a/demos/vue/test/cypress/e2e/example19.cy.ts
+++ b/demos/vue/test/cypress/e2e/example19.cy.ts
@@ -251,13 +251,10 @@ describe('Example 19 - Row Detail View', () => {
it('should expect the Row Detail to be re-rendered after expanding/collapsing multiple times', () => {
const clickTask1Toggle = () => cy.get('#grid19').find('.slick-row[data-row="1"] .slick-cell.l0').click();
- clickTask1Toggle();
- clickTask1Toggle();
clickTask1Toggle();
cy.get('#grid19').find('.dynamic-cell-detail .innerDetailView_1').as('detailContainer');
cy.get('@detailContainer').find('h3').contains('Task 1');
-
clickTask1Toggle();
cy.get('@detailContainer').should('not.exist');
diff --git a/docs/TOC.md b/docs/TOC.md
index 5dee420bb..d47d4ef63 100644
--- a/docs/TOC.md
+++ b/docs/TOC.md
@@ -59,6 +59,8 @@
* [Context Menu](grid-functionalities/context-menu.md)
* [Custom Footer](grid-functionalities/custom-footer.md)
* [Excel Copy Buffer Plugin](grid-functionalities/excel-copy-buffer.md)
+* [Formula Service Plugin (Vanilla)](grid-functionalities/formula-service.md)
+* [Formula Custom Functions](grid-functionalities/formula-functions.md)
* [Export to Excel](grid-functionalities/export-to-excel.md)
* [Export to PDF](grid-functionalities/export-to-pdf.md)
* [Export to File (csv/txt)](grid-functionalities/export-to-text-file.md)
diff --git a/docs/grid-functionalities/export-to-excel.md b/docs/grid-functionalities/export-to-excel.md
index 337fbeea1..34450abec 100644
--- a/docs/grid-functionalities/export-to-excel.md
+++ b/docs/grid-functionalities/export-to-excel.md
@@ -19,6 +19,17 @@ You can optionally install the Export to Excel resource, it will give you the fl
**NOTE:** this is an opt-in Service, you must download the necessary Service from `@slickgrid-universal/excel-export` and instantiate it in your grid options via `externalResources`, see multiple examples below.
+### Compatibility Warning (Custom Workbook Functions)
+Workbook-defined custom functions are serialized with modern Excel-only LAMBDA conventions (for example `_xlfn.LAMBDA` and `_xlpm.` argument tokens).
+
+LibreOffice/OpenOffice can open the exported file structure, but they do not reliably execute workbook-defined custom functions.
+
+Built-in formulas (for example `SUM`, `IF`, arithmetic expressions) generally evaluate, while custom function formulas (for example `CUSTOMSUM(A2:C2)`) are not supported there and can return errors such as `Err:509`.
+
+If cross-suite reliability is required, treat custom functions as Excel-only and export precomputed scalar values from your app/example instead of exporting custom-function formulas.
+
+For Formula Service custom functions, see [Formula Service Plugin (Vanilla)](formula-service.md) and the portable export pattern used in [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts).
+
### Demo
[Demo Page](https://ghiscoding.github.io/slickgrid-universal/#/example02) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example02.ts)
diff --git a/docs/grid-functionalities/formula-functions.md b/docs/grid-functionalities/formula-functions.md
new file mode 100644
index 000000000..5891f051b
--- /dev/null
+++ b/docs/grid-functionalities/formula-functions.md
@@ -0,0 +1,158 @@
+#### index
+- [Description](#description)
+- [Built-in Functions](#built-in-functions)
+- [Custom Function Registration](#custom-function-registration)
+- [Function Name Rules](#function-name-rules)
+- [Range Arguments and Flattening](#range-arguments-and-flattening)
+- [Runtime API](#runtime-api)
+- [Excel Export Interop](#excel-export-interop)
+- [Compatibility Warning](#compatibility-warning)
+- [Portable Export Pattern](#portable-export-pattern)
+- [Examples](#examples)
+- [Troubleshooting](#troubleshooting)
+
+### Description
+Formula Service supports both built-in formula functions and user-defined custom functions.
+
+Custom functions can be used for:
+- runtime grid formula evaluation
+- Excel workbook export metadata (defined names/custom functions)
+
+### Built-in Functions
+Current built-ins include:
+- `IF`
+- `SUM`
+- `SUMPRODUCT`
+- `SUMIF`
+- `PRODUCT`
+- `MIN`
+- `MAX`
+- `AVERAGE`
+- `MEDIAN`
+- `POWER`
+- `RAND`
+- `NOW`
+- `TODAY`
+- `CONCAT`
+- `COUNT`
+- `COUNTA`
+- `COUNTBLANK`
+- `COUNTIF`
+- `NA`
+
+### Custom Function Registration
+You can register functions in constructor options.
+
+Direct callback style:
+
+```ts
+const formulaService = new FormulaService({
+ customFunctions: {
+ NET: (amount: number, taxes: number) => amount - taxes,
+ },
+});
+```
+
+AG-like params style:
+
+```ts
+const formulaService = new FormulaService({
+ customFunctions: {
+ CUSTOMSUM: {
+ func: ({ values }: { values: unknown[] }) => {
+ return values.reduce((total, value) => total + Number(value ?? 0), 0);
+ },
+ },
+ },
+});
+```
+
+### Function Name Rules
+Guidelines:
+- use uppercase names for readability
+- use identifier-safe names: letters, digits, underscore
+- avoid spaces/special punctuation
+
+Runtime notes:
+- names are normalized to uppercase internally
+- custom names can override built-ins when same name is used
+
+### Range Arguments and Flattening
+For params-object style (`func: ({ values }) => ...`), range inputs are flattened to a single value list.
+
+Example:
+- formula `=CUSTOMSUM(A1:C1)`
+- handler receives `values` containing each referenced cell value
+
+### Runtime API
+Useful runtime methods:
+- `registerCustomFunction(name, functionInput)`
+- `registerCustomFunctions(map)`
+- `unregisterCustomFunction(name)`
+- `getCustomFunction(name)`
+
+This allows dynamic enable/disable of custom function packs.
+
+### Excel Export Interop
+Formula Service exposes export helpers:
+- `getExcelDefinedNames()`
+- `getExcelCustomFunctions()`
+
+These are consumed by Excel export integration when both services are registered.
+
+Related doc:
+- [Export to Excel](./export-to-excel.md)
+
+### Compatibility Warning
+Workbook custom functions are exported using modern Excel conventions such as:
+- `_xlfn.LAMBDA`
+- `_xlpm.` argument tokens
+
+LibreOffice/OpenOffice may open file structure but do not reliably evaluate workbook-defined custom functions.
+
+Practical impact:
+- built-in formulas usually work
+- workbook custom function formulas may fail (for example `Err:509`)
+
+### Portable Export Pattern
+For cross-suite reliability:
+1. Precompute custom-function formulas to scalar values.
+2. Export plain values.
+3. Restore original formula strings in-memory after export.
+
+Reference implementation:
+- [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts)
+
+### Examples
+Runtime registration after init:
+
+```ts
+formulaService.registerCustomFunctions({
+ CUSTOMNET: {
+ func: ({ values }: { values: unknown[] }) => {
+ const gross = Number(values[0] ?? 0);
+ const taxes = Number(values[1] ?? 0);
+ return gross - taxes;
+ },
+ },
+});
+```
+
+Formula usage in dataset:
+
+```ts
+item.net = '=CUSTOMNET(A2,B2)';
+```
+
+### Troubleshooting
+1. Formula returns `#NAME?`
+- function was not registered
+- function name mismatch between formula and registry key
+
+2. Custom function works in grid but fails after Excel export
+- workbook custom function compatibility varies by spreadsheet app
+- use portable export pattern for non-Excel targets
+
+3. Unexpected numeric precision in custom sum results
+- floating-point math can produce tiny precision noise
+- for tests, prefer precision-based assertions (`toBeCloseTo`)
\ No newline at end of file
diff --git a/docs/grid-functionalities/formula-service.md b/docs/grid-functionalities/formula-service.md
new file mode 100644
index 000000000..f210ff41a
--- /dev/null
+++ b/docs/grid-functionalities/formula-service.md
@@ -0,0 +1,160 @@
+#### index
+- [Description](#description)
+- [Doc Structure](#doc-structure)
+- [Install and Register](#install-and-register)
+- [Minimum Column Setup](#minimum-column-setup)
+- [Core Options](#core-options)
+- [Formula Editor and References](#formula-editor-and-references)
+- [Formula Drag-Fill](#formula-drag-fill)
+- [Runtime API at a Glance](#runtime-api-at-a-glance)
+- [Evaluation and Export Summary](#evaluation-and-export-summary)
+- [Troubleshooting](#troubleshooting)
+- [Demo](#demo)
+
+### Description
+Formula Service is an optional external resource plugin that adds spreadsheet-like formula support to Slickgrid-Universal.
+
+At a high level it provides:
+- formula storage by row id and column id
+- runtime formula evaluation in grid cells
+- formula authoring via Formula Editor
+- formula export bridge for Excel export workflows
+
+### Doc Structure
+To keep docs practical, formula docs are organized into 2 pages:
+
+1. Overview (this page)
+- plugin scope
+- setup and options
+- runtime API summary
+
+2. Custom Functions and Export Notes
+- [Formula Custom Functions](./formula-functions.md)
+
+Related:
+- [Export to Excel](./export-to-excel.md)
+
+### Install and Register
+Install package and register Formula Service in `externalResources`.
+
+```ts
+import { FormulaService } from '@slickgrid-universal/formula-plugin';
+
+const formulaService = new FormulaService();
+
+this.gridOptions = {
+ enableFormulas: true,
+ externalResources: [formulaService],
+};
+```
+
+### Minimum Column Setup
+Enable formulas only on columns that should accept formula strings.
+
+```ts
+this.columns = [
+ { id: 'price', field: 'price', type: 'number' },
+ { id: 'qty', field: 'qty', type: 'number' },
+ { id: 'total', field: 'total', type: 'number', allowFormula: true },
+];
+```
+
+### Core Options
+Common `FormulaServiceOption` settings:
+
+| Option | Default | Purpose |
+|---|---|---|
+| `autoAssignEditor` | `true` | Auto-attach Formula Editor and formatter pipeline to formula columns. |
+| `editorParams` | `undefined` | Default editor params merged with column-level params. |
+| `autoSyncFormulasFromDataset` | `true` | Sync initial formula strings from dataset on init. |
+| `customFunctions` | `{}` | Register runtime custom functions. |
+| `excelDefinedNames` | `[]` | Export helper for workbook defined names. |
+| `excelCustomFunctions` | `[]` | Export helper for workbook custom functions. |
+
+### Formula Editor and References
+Formula editor is auto-assigned when:
+- Formula Service is registered
+- column has `allowFormula: true`
+- `autoAssignEditor` is not disabled
+
+Editor behaviors:
+- reference token highlighting in formula text
+- click another grid cell to insert/replace active reference token
+- drag over grid cells to write ranges (for example `A1:C4`)
+- caret-aware rewrite when editing inside an existing token/range
+- grid click suppression during reference picking to avoid accidental commit/close
+- `Ctrl+A` / `Cmd+A` scoped to editor text (not grid-wide selection)
+
+For full reference pick UX:
+- `enableSelection: true`
+- `selectionOptions.selectionType: 'mixed'` or `'cell'`
+
+Highlight behavior:
+- the active reference under the caret uses the selection model through `setSelectedRanges(...)`
+- existing cell/row selection ranges are restored when the temporary active-reference highlight is cleared
+- all formula references use one CSS overlay through `setCellCssStyles(...)`, with a distinct color matching each formula token
+
+Formula reference storage:
+- the editor displays familiar Excel A1 references such as `C1` and `D1:D3`
+- committed formulas are stored with stable column and row identities, for example `REF(COLUMN("price"),ROW("a_01"))`
+- this keeps references aligned when columns are reordered or hidden and when rows are sorted
+- `ExcelExportService` converts the stable references back to native Excel A1 formulas using the exported column and row order
+
+When a formula references a hidden source column, export it with `includeHidden: true` so the referenced column exists in the workbook. If the source column is omitted from the export, Excel cannot evaluate a formula that points to it.
+
+### Formula Drag-Fill
+
+With cell-capable selection enabled, Formula Service handles the `.slick-drag-replace-handle` automatically for formula-enabled columns.
+
+- formulas shift relative A1 references while keeping absolute reference parts fixed
+- one static source value is copied
+- multiple numeric source values continue as a linear progression
+- string or mixed source values repeat in source order
+
+This matches the common defaults documented by [AG Grid's fill handle](https://www.ag-grid.com/javascript-data-grid/cell-selection-fill-handle/). Series inference is implemented inside the optional formula-plugin package, so grids that do not register Formula Service do not include this behavior.
+
+Use `autoEdit: false` when combining formula editing and drag-fill. A single click selects the formula cell and exposes the drag handle; double-click the cell when you want to open the formula editor.
+
+Modifier-key copy/increment toggles, custom fill callbacks, range-reduction clearing, and double-click fill are not currently implemented.
+
+### Runtime API at a Glance
+Frequently used methods:
+- `setFormula(rowId, columnId, formula)`
+- `getFormula(rowId, columnId)`
+- `removeFormula(rowId, columnId)`
+- `syncFormulasFromDataset()`
+- `getEvaluatedCellValue(rowId, columnId, ...)`
+- `registerCustomFunction(name, input)`
+- `registerCustomFunctions(functionMap)`
+- `getExcelFormula(context)`
+
+For built-ins/custom functions/export compatibility, see [Formula Custom Functions](./formula-functions.md).
+
+### Evaluation and Export Summary
+Evaluation supports:
+- A1 references and ranges
+- AG-style `REF(COLUMN(),ROW())` references
+- arithmetic/comparison operators
+- built-in and custom functions
+
+Export supports:
+- conversion of formula-enabled cells to native Excel formulas
+- workbook metadata hooks for defined names and custom functions
+
+### Troubleshooting
+1. Formula cell shows raw formula string
+- Verify the column has `allowFormula: true`.
+- Verify Formula Service is registered in `externalResources`.
+- Verify formula text starts with `=`.
+
+2. Click/drag reference picking is missing
+- Verify selection prerequisites are enabled.
+- Verify the active editor is Formula Editor.
+
+3. Ctrl/Cmd+A selects the whole grid
+- Ensure focus is inside formula editor input.
+- Verify no upstream custom key handler is intercepting first.
+
+### Demo
+- Demo Page: https://ghiscoding.github.io/slickgrid-universal/#/example46
+- Demo Component: https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts
diff --git a/docs/grid-functionalities/row-selection.md b/docs/grid-functionalities/row-selection.md
index 5bee4608a..61130e4d5 100644
--- a/docs/grid-functionalities/row-selection.md
+++ b/docs/grid-functionalities/row-selection.md
@@ -306,6 +306,19 @@ this.gridOptions = {
You can also `onDragReplaceCells` event to drag and fill cell values to the extended cell selection.
+When `FormulaService` is enabled, formula cells use this same drag handle automatically. Formula references are shifted using Excel-style relative-reference rules (`A1` shifts by row and column, while `$A$1`, `A$1`, and `$A1` preserve their absolute parts), then stored in the service's stable column/row reference format. Static values in `allowFormula` columns also support the common fill-series behavior: one value copies, multiple numbers continue linearly, and string/mixed values repeat. The formula editor continues to show the resulting A1 notation.
+
+If the grid also enables formula editing, set `autoEdit: false` so a single click selects the cell and leaves the drag handle available. Double-click the cell to open the formula editor. With `autoEdit: true`, clicking a formula cell immediately opens the editor, which can conflict with starting a drag-fill operation.
+
+```ts
+const gridOptions: GridOption = {
+ autoEdit: false,
+ enableSelection: true,
+ selectionOptions: { selectionType: 'mixed' },
+ enableFormulas: true,
+};
+```
+
The Excel-style selection drag handle is visible by default. You can control its visibility with `selectionOptions.showDragHandle`:
```ts
diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md b/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md
index 512bc183b..40ee36663 100644
--- a/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md
+++ b/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md
@@ -18,6 +18,17 @@ You can Export to Excel, it will create an Excel file with the `.xlsx` default e
**NOTE:** this is an opt-in Service, you must download the necessary Service from `@slickgrid-universal/excel-export` and instantiate it in your grid options via `registerExternalResources`, see multiple examples below.
+### Compatibility Warning (Custom Workbook Functions)
+Workbook-defined custom functions are serialized with modern Excel-only LAMBDA conventions (for example `_xlfn.LAMBDA` and `_xlpm.` argument tokens).
+
+LibreOffice/OpenOffice can open the exported file structure, but they do not reliably execute workbook-defined custom functions.
+
+Built-in formulas (for example `SUM`, `IF`, arithmetic expressions) generally evaluate, while custom function formulas (for example `CUSTOMSUM(A2:C2)`) are not supported there and can return errors such as `Err:509`.
+
+If cross-suite reliability is required, treat custom functions as Excel-only and export precomputed scalar values from your app/example instead of exporting custom-function formulas.
+
+For Formula Service custom functions and a portable export workflow, see [Vanilla Formula Service doc](../../../../docs/grid-functionalities/formula-service.md) and [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts).
+
### Demo
[Demo Page](https://ghiscoding.github.io/angular-slickgrid-demos/#/example12) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/frameworks/angular-slickgrid/src/demos/examples/example12.component.ts)
diff --git a/frameworks/aurelia-slickgrid/docs/grid-functionalities/export-to-excel.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/export-to-excel.md
index 7f4fb8262..eee96dde4 100644
--- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/export-to-excel.md
+++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/export-to-excel.md
@@ -18,6 +18,17 @@ You can Export to Excel, it will create an Excel file with the `.xlsx` default e
**NOTE:** this is an opt-in Service, you must download the necessary Service from `@slickgrid-universal/excel-export` and instantiate it in your grid options via `registerExternalResources`, see multiple examples below.
+### Compatibility Warning (Custom Workbook Functions)
+Workbook-defined custom functions are serialized with modern Excel-only LAMBDA conventions (for example `_xlfn.LAMBDA` and `_xlpm.` argument tokens).
+
+LibreOffice/OpenOffice can open the exported file structure, but they do not reliably execute workbook-defined custom functions.
+
+Built-in formulas (for example `SUM`, `IF`, arithmetic expressions) generally evaluate, while custom function formulas (for example `CUSTOMSUM(A2:C2)`) are not supported there and can return errors such as `Err:509`.
+
+If cross-suite reliability is required, treat custom functions as Excel-only and export precomputed scalar values from your app/example instead of exporting custom-function formulas.
+
+For Formula Service custom functions and a portable export workflow, see [Vanilla Formula Service doc](../../../../docs/grid-functionalities/formula-service.md) and [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts).
+
### Demo
[Demo Page](https://ghiscoding.github.io/aurelia-slickgrid-demos/#/slickgrid/example12) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/aurelia/src/examples/slickgrid/example12.ts)
diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/export-to-excel.md b/frameworks/slickgrid-react/docs/grid-functionalities/export-to-excel.md
index edf1b5ada..302967a34 100644
--- a/frameworks/slickgrid-react/docs/grid-functionalities/export-to-excel.md
+++ b/frameworks/slickgrid-react/docs/grid-functionalities/export-to-excel.md
@@ -19,6 +19,17 @@ You can Export to Excel, it will create an Excel file with the `.xlsx` default e
**NOTE:** this is an opt-in Service, you must download the necessary Service from `@slickgrid-universal/excel-export` and instantiate it in your grid options via `externalResources`, see multiple examples below.
+### Compatibility Warning (Custom Workbook Functions)
+Workbook-defined custom functions are serialized with modern Excel-only LAMBDA conventions (for example `_xlfn.LAMBDA` and `_xlpm.` argument tokens).
+
+LibreOffice/OpenOffice can open the exported file structure, but they do not reliably execute workbook-defined custom functions.
+
+Built-in formulas (for example `SUM`, `IF`, arithmetic expressions) generally evaluate, while custom function formulas (for example `CUSTOMSUM(A2:C2)`) are not supported there and can return errors such as `Err:509`.
+
+If cross-suite reliability is required, treat custom functions as Excel-only and export precomputed scalar values from your app/example instead of exporting custom-function formulas.
+
+For Formula Service custom functions and a portable export workflow, see [Vanilla Formula Service doc](../../../../docs/grid-functionalities/formula-service.md) and [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts).
+
### Demo
[Demo Page](https://ghiscoding.github.io/slickgrid-react-demos/#/Example12) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/react/src/examples/slickgrid/Example12.tsx)
diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/export-to-excel.md b/frameworks/slickgrid-vue/docs/grid-functionalities/export-to-excel.md
index 22d284a54..5c0b193b1 100644
--- a/frameworks/slickgrid-vue/docs/grid-functionalities/export-to-excel.md
+++ b/frameworks/slickgrid-vue/docs/grid-functionalities/export-to-excel.md
@@ -18,6 +18,17 @@ You can Export to Excel, it will create an Excel file with the `.xlsx` default e
**NOTE:** this is an opt-in Service, you must download the necessary Service from `@slickgrid-universal/excel-export` and instantiate it in your grid options via `externalResources`, see multiple examples below.
+### Compatibility Warning (Custom Workbook Functions)
+Workbook-defined custom functions are serialized with modern Excel-only LAMBDA conventions (for example `_xlfn.LAMBDA` and `_xlpm.` argument tokens).
+
+LibreOffice/OpenOffice can open the exported file structure, but they do not reliably execute workbook-defined custom functions.
+
+Built-in formulas (for example `SUM`, `IF`, arithmetic expressions) generally evaluate, while custom function formulas (for example `CUSTOMSUM(A2:C2)`) are not supported there and can return errors such as `Err:509`.
+
+If cross-suite reliability is required, treat custom functions as Excel-only and export precomputed scalar values from your app/example instead of exporting custom-function formulas.
+
+For Formula Service custom functions and a portable export workflow, see [Vanilla Formula Service doc](../../../../docs/grid-functionalities/formula-service.md) and [Example 46](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vanilla/src/examples/example46.ts).
+
### Demo
[Demo Page](https://ghiscoding.github.io/slickgrid-vue-demos/#/Example12) / [Demo Component](https://github.com/ghiscoding/slickgrid-universal/blob/master/demos/vue/src/components/Example12.vue)
diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts
index f8b844f54..0f59b92fd 100755
--- a/packages/common/src/core/slickGrid.ts
+++ b/packages/common/src/core/slickGrid.ts
@@ -6205,7 +6205,8 @@ export class SlickGrid = Column, O e
if (removedRowHash) {
Object.keys(removedRowHash).forEach((columnId) => {
if (!addedRowHash || removedRowHash![columnId] !== addedRowHash[columnId]) {
- node = this.getCellNode(+row, this.getColumnIndex(columnId));
+ const colIdx = this.getColumnIndex(columnId);
+ node = this.getCellNode(+row, colIdx);
if (node) {
node.classList.remove(removedRowHash[columnId]);
}
@@ -6216,7 +6217,8 @@ export class SlickGrid = Column, O e
if (addedRowHash) {
Object.keys(addedRowHash).forEach((columnId) => {
if (!removedRowHash || removedRowHash[columnId] !== addedRowHash[columnId]) {
- node = this.getCellNode(+row, this.getColumnIndex(columnId));
+ const colIdx = this.getColumnIndex(columnId);
+ node = this.getCellNode(+row, colIdx);
if (node) {
node.classList.add(addedRowHash[columnId]);
}
diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts
index 8fff146e5..ebc65dabb 100644
--- a/packages/common/src/global-grid-options.ts
+++ b/packages/common/src/global-grid-options.ts
@@ -3,6 +3,7 @@ import type { Column, EmptyWarning, GridOption, RowDetailView, TreeDataOption }
export const PluginFlagMappings: Map = new Map([
['ExcelExportService', 'enableExcelExport'],
+ ['FormulaService', 'enableFormulas'],
['PdfExportService', 'enablePdfExport'],
['TextExportService', 'enableTextExport'],
['CompositeEditorComponent', 'enableCompositeEditor'],
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index f23a31d6c..6bdbe21b4 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -20,7 +20,7 @@ export * from './global-grid-options.js';
export * from './core/index.js';
export * from './enums/index.js';
-export type * from './interfaces/index.js';
+export * from './interfaces/index.js';
export * from './aggregators/aggregators.index.js';
export * from './editors/index.js';
export * from './editors/editors.index.js';
diff --git a/packages/common/src/interfaces/column.interface.ts b/packages/common/src/interfaces/column.interface.ts
index 6db85d914..1dc4cbfbf 100644
--- a/packages/common/src/interfaces/column.interface.ts
+++ b/packages/common/src/interfaces/column.interface.ts
@@ -39,6 +39,13 @@ export type Join = T
: string;
export interface Column {
+ /**
+ * Defaults to false, enable formula editing for this column when FormulaService is used.
+ * When FormulaService auto-assign is enabled (default), it injects its FormulaCellEditor automatically,
+ * so users typically only need to set this flag and do not need to define `editor.model` manually.
+ */
+ allowFormula?: boolean;
+
/** Defaults to false, should we always render the column? */
alwaysRenderColumn?: boolean;
diff --git a/packages/common/src/interfaces/formulaProvider.interface.ts b/packages/common/src/interfaces/formulaProvider.interface.ts
new file mode 100644
index 000000000..659d0f522
--- /dev/null
+++ b/packages/common/src/interfaces/formulaProvider.interface.ts
@@ -0,0 +1,50 @@
+import type { GridOption } from './gridOption.interface.js';
+
+export interface FormulaExcelExportContext {
+ columnId: number | string;
+ columnIds: Array;
+ dataRowIdx: number;
+ datasetIdPropertyName: string;
+ excelRowOffset: number;
+ gridOptions: GridOption;
+ rowId: number | string;
+ rowIds: Array;
+}
+
+export interface FormulaExcelDefinedNameExport {
+ name: string;
+ refersTo: string;
+ scope?: number | string;
+}
+
+export interface FormulaExcelCustomFunctionExport {
+ name: string;
+ args: string[];
+ body: string;
+ options?: {
+ autoPrefixXlfn?: boolean;
+ comment?: string;
+ scope?: number | string;
+ };
+}
+
+/** Optional interface that a formula external resource can implement. */
+export interface FormulaProvider {
+ /** Return whether a formula exists for the given row/column cell. */
+ hasFormula?: (rowId: number | string, columnId: number | string) => boolean;
+
+ /** Return the formula for a given row/column cell. */
+ getFormula?: (rowId: number | string, columnId: number | string) => string | undefined;
+
+ /**
+ * Return an Excel-ready formula for a given row/column cell.
+ * Formula should be returned without the leading `=`.
+ */
+ getExcelFormula?: (context: FormulaExcelExportContext) => string | undefined;
+
+ /** Return workbook-level defined names to register before writing worksheet formulas. */
+ getExcelDefinedNames?: () => FormulaExcelDefinedNameExport[];
+
+ /** Return workbook-level custom functions to register before writing worksheet formulas. */
+ getExcelCustomFunctions?: () => FormulaExcelCustomFunctionExport[];
+}
diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts
index 7895ec422..2ddefe403 100644
--- a/packages/common/src/interfaces/gridOption.interface.ts
+++ b/packages/common/src/interfaces/gridOption.interface.ts
@@ -493,6 +493,9 @@ export interface GridOption {
/** Do we want to enable the Excel Export? (if Yes, it will show up in the Grid Menu) */
enableExcelExport?: boolean;
+ /** Do we want to enable formulas handled by an optional external resource? */
+ enableFormulas?: boolean;
+
/** Do we want to enable Filters? */
enableFiltering?: boolean;
diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts
index 87133657d..f41556560 100644
--- a/packages/common/src/interfaces/index.ts
+++ b/packages/common/src/interfaces/index.ts
@@ -70,6 +70,7 @@ export type * from './formattedDataCache.interface.js';
export type * from './formatter.interface.js';
export type * from './formatterOption.interface.js';
export type * from './formatterResultObject.interface.js';
+export type * from './formulaProvider.interface.js';
export type * from './gridEvents.interface.js';
export type * from './gridMenu.interface.js';
export type * from './gridMenuCommandItemCallbackArgs.interface.js';
diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss
index 5e975b50d..831054e61 100644
--- a/packages/common/src/styles/_variables.scss
+++ b/packages/common/src/styles/_variables.scss
@@ -35,6 +35,76 @@ $slick-button-style-bg-color: #fff !default;
$slick-filter-placeholder-font-family: 'Segoe UI Symbol' !default;
$slick-focus-color: color.adjust($slick-primary-color, $lightness: 15%) !default;
+/* Formula UX Helpers */
+$slick-excel-col-prefix-bg-color: #dbeafe !default;
+$slick-excel-col-prefix-color: #1d4ed8 !default;
+$slick-excel-col-prefix-border-color: #93c5fd !default;
+$slick-excel-col-prefix-dark-bg-color: #1e3a8a !default;
+$slick-excel-col-prefix-dark-color: #bfdbfe !default;
+$slick-excel-col-prefix-dark-border-color: #3b82f6 !default;
+// light theme
+$slick-formula-token-1-color: #3269c6 !default;
+$slick-formula-token-1-background-color: rgba(50, 105, 198, 0.05) !default;
+$slick-formula-ref-cell-1-background-color: rgba(50, 105, 198, 0.5) !default;
+$slick-formula-token-2-color: #c0343f !default;
+$slick-formula-token-2-background-color: rgba(192, 52, 63, 0.05) !default;
+$slick-formula-ref-cell-2-background-color: rgba(192, 52, 63, 0.5) !default;
+$slick-formula-token-3-color: #8156b8 !default;
+$slick-formula-token-3-background-color: rgba(129, 86, 184, 0.05) !default;
+$slick-formula-ref-cell-3-background-color: rgba(129, 86, 184, 0.5) !default;
+$slick-formula-token-4-color: #007c1f !default;
+$slick-formula-token-4-background-color: rgba(0, 124, 31, 0.05) !default;
+$slick-formula-ref-cell-4-background-color: rgba(0, 124, 31, 0.5) !default;
+$slick-formula-token-5-color: #b03e85 !default;
+$slick-formula-token-5-background-color: rgba(176, 62, 133, 0.05) !default;
+$slick-formula-ref-cell-5-background-color: rgba(176, 62, 133, 0.5) !default;
+$slick-formula-token-6-color: #b74900 !default;
+$slick-formula-token-6-background-color: rgba(183, 73, 0, 0.05) !default;
+$slick-formula-ref-cell-6-background-color: rgba(183, 73, 0, 0.5) !default;
+$slick-formula-token-7-color: #247492 !default;
+$slick-formula-token-7-background-color: rgba(36, 116, 146, 0.05) !default;
+$slick-formula-ref-cell-7-background-color: rgba(36, 116, 146, 0.5) !default;
+$slick-formula-token-8-color: #c05621 !default;
+$slick-formula-token-8-background-color: rgba(192, 86, 33, 0.05) !default;
+$slick-formula-ref-cell-8-background-color: rgba(192, 86, 33, 0.5) !default;
+$slick-formula-token-9-color: #2b6cb0 !default;
+$slick-formula-token-9-background-color: rgba(43, 108, 176, 0.05) !default;
+$slick-formula-ref-cell-9-background-color: rgba(43, 108, 176, 0.5) !default;
+$slick-formula-token-10-color: #2f855a !default;
+$slick-formula-token-10-background-color: rgba(47, 133, 90, 0.05) !default;
+$slick-formula-ref-cell-10-background-color: rgba(47, 133, 90, 0.5) !default;
+// dark theme
+$slick-formula-token-1-dark-color: #8ab4ff !default;
+$slick-formula-token-1-dark-background-color: rgba(138, 180, 255, 0.2) !default;
+$slick-formula-ref-cell-1-dark-background-color: rgba(138, 180, 255, 0.8) !default;
+$slick-formula-token-2-dark-color: #ff9aa3 !default;
+$slick-formula-token-2-dark-background-color: rgba(255, 154, 163, 0.2) !default;
+$slick-formula-ref-cell-2-dark-background-color: rgba(255, 154, 163, 0.8) !default;
+$slick-formula-token-3-dark-color: #d6bcfa !default;
+$slick-formula-token-3-dark-background-color: rgba(214, 188, 250, 0.2) !default;
+$slick-formula-ref-cell-3-dark-background-color: rgba(214, 188, 250, 0.8) !default;
+$slick-formula-token-4-dark-color: #86efac !default;
+$slick-formula-token-4-dark-background-color: rgba(134, 239, 172, 0.2) !default;
+$slick-formula-ref-cell-4-dark-background-color: rgba(134, 239, 172, 0.8) !default;
+$slick-formula-token-5-dark-color: #f9a8d4 !default;
+$slick-formula-token-5-dark-background-color: rgba(249, 168, 212, 0.2) !default;
+$slick-formula-ref-cell-5-dark-background-color: rgba(249, 168, 212, 0.8) !default;
+$slick-formula-token-6-dark-color: #fdba74 !default;
+$slick-formula-token-6-dark-background-color: rgba(253, 186, 116, 0.2) !default;
+$slick-formula-ref-cell-6-dark-background-color: rgba(253, 186, 116, 0.8) !default;
+$slick-formula-token-7-dark-color: #7dd3fc !default;
+$slick-formula-token-7-dark-background-color: rgba(125, 211, 252, 0.2) !default;
+$slick-formula-ref-cell-7-dark-background-color: rgba(125, 211, 252, 0.8) !default;
+$slick-formula-token-8-dark-color: #f6ad55 !default;
+$slick-formula-token-8-dark-background-color: rgba(246, 173, 85, 0.2) !default;
+$slick-formula-ref-cell-8-dark-background-color: rgba(246, 173, 85, 0.8) !default;
+$slick-formula-token-9-dark-color: #93c5fd !default;
+$slick-formula-token-9-dark-background-color: rgba(147, 197, 253, 0.2) !default;
+$slick-formula-ref-cell-9-dark-background-color: rgba(147, 197, 253, 0.8) !default;
+$slick-formula-token-10-dark-color: #9ae6b4 !default;
+$slick-formula-token-10-dark-background-color: rgba(154, 230, 180, 0.2) !default;
+$slick-formula-ref-cell-10-dark-background-color: rgba(154, 230, 180, 0.8) !default;
+
$slick-form-control-bg-color: #fff !default;
$slick-form-control-border-color: #ccc !default;
$slick-form-control-border: 1px solid #{$slick-form-control-border-color} !default;
diff --git a/packages/common/src/styles/slick-editors.scss b/packages/common/src/styles/slick-editors.scss
index 31af71db7..ed9fd5732 100644
--- a/packages/common/src/styles/slick-editors.scss
+++ b/packages/common/src/styles/slick-editors.scss
@@ -4,7 +4,8 @@
.slick-cell {
input.dual-editor-text,
- input.editor-text {
+ input.editor-text,
+ .formula-editor-input {
border: var(--slick-text-editor-border, v.$slick-text-editor-border);
border-radius: var(--slick-text-editor-border-radius, v.$slick-text-editor-border-radius);
background: var(--slick-text-editor-background, v.$slick-text-editor-background);
@@ -17,6 +18,7 @@
margin-bottom: var(--slick-text-editor-margin-bottom, v.$slick-text-editor-margin-bottom);
margin-right: var(--slick-text-editor-margin-right, v.$slick-text-editor-margin-right);
margin-top: var(--slick-text-editor-margin-top, v.$slick-text-editor-margin-top);
+ box-sizing: border-box;
outline: 0;
height: 100%;
max-width: 100%;
@@ -42,6 +44,17 @@
}
}
+ .formula-editor-input {
+ display: flex;
+ align-items: center;
+ flex: 1 1 auto;
+ min-width: 0;
+ line-height: normal;
+ overflow: auto hidden;
+ white-space: nowrap;
+ scrollbar-width: none;
+ }
+
.slider-editor {
height: 100%;
.slider-editor-input {
diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss
index ba20ee761..e672efa55 100644
--- a/packages/common/src/styles/slick-plugins.scss
+++ b/packages/common/src/styles/slick-plugins.scss
@@ -1326,3 +1326,87 @@ li.hidden {
opacity: 1;
}
}
+
+// ----------------------------------------------
+// Formula UX Helpers
+// ----------------------------------------------
+
+.excel-col-prefix {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.35rem;
+ height: 1.2rem;
+ // margin-right: 0.35rem;
+ padding: 0 0.25rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ background: var(--slick-excel-col-prefix-bg-color, #{v.$slick-excel-col-prefix-bg-color});
+ color: var(--slick-excel-col-prefix-color, #{v.$slick-excel-col-prefix-color});
+ border: 1px solid var(--slick-excel-col-prefix-border-color, #{v.$slick-excel-col-prefix-border-color});
+ vertical-align: middle;
+}
+
+.formula-token {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ font-weight: inherit;
+ line-height: inherit;
+}
+
+.formula-token-color-1,
+.formula-token-color-7 {
+ color: #{v.$slick-formula-token-1-color};
+}
+.formula-token-color-2,
+.formula-token-color-8 {
+ color: #{v.$slick-formula-token-2-color};
+}
+.formula-token-color-3,
+.formula-token-color-9 {
+ color: #{v.$slick-formula-token-3-color};
+}
+.formula-token-color-4,
+.formula-token-color-10 {
+ color: #{v.$slick-formula-token-4-color};
+}
+.formula-token-color-5 {
+ color: #{v.$slick-formula-token-5-color};
+}
+.formula-token-color-6 {
+ color: #{v.$slick-formula-token-6-color};
+}
+
+.formula-cell-color-1,
+.formula-cell-color-7 {
+ background-color: #{v.$slick-formula-token-1-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-1-color} !important;
+}
+.formula-cell-color-2,
+.formula-cell-color-8 {
+ background-color: #{v.$slick-formula-token-2-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-2-color} !important;
+}
+.formula-cell-color-3,
+.formula-cell-color-9 {
+ background-color: #{v.$slick-formula-token-3-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-3-color} !important;
+}
+.formula-cell-color-4,
+.formula-cell-color-10 {
+ background-color: #{v.$slick-formula-token-4-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-4-color} !important;
+}
+.formula-cell-color-5 {
+ background-color: #{v.$slick-formula-token-5-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-5-color} !important;
+}
+.formula-cell-color-6 {
+ background-color: #{v.$slick-formula-token-6-background-color} !important;
+ box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-6-color} !important;
+}
diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts
index 0e634f386..864f374f8 100644
--- a/packages/excel-export/src/excelExport.service.spec.ts
+++ b/packages/excel-export/src/excelExport.service.spec.ts
@@ -15,7 +15,7 @@ import {
type SlickGrid,
} from '@slickgrid-universal/common';
import type { BasePubSubService } from '@slickgrid-universal/event-pub-sub';
-import { createExcelFileStream, downloadExcelFile, Workbook } from 'excel-builder-vanilla';
+import { createExcelFileStream, createWorkbook, downloadExcelFile, Workbook } from 'excel-builder-vanilla';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { ContainerServiceStub } from '../../../test/containerServiceStub.js';
import { TranslateServiceStub } from '../../../test/translateServiceStub.js';
@@ -25,6 +25,7 @@ import { useCellFormatByFieldType } from './excelUtils.js';
// mocked modules
vi.mock('excel-builder-vanilla', async (importOriginal) => ({
...((await importOriginal()) as any),
+ createWorkbook: vi.fn(() => new Workbook()),
downloadExcelFile: vi.fn().mockResolvedValue(true),
createExcelFileStream: vi.fn(() => {
return new ReadableStream({
@@ -2615,6 +2616,162 @@ describe('ExcelExportService', () => {
expect((service as any)._regularCellExcelFormats.title.getDataValueParser).toBe(parserSpy);
});
+ it('readRegularRowData should export formula metadata when a formula provider is registered', () => {
+ const sharedServiceStub = {
+ externalRegisteredResources: [
+ {
+ pluginName: 'FormulaService',
+ hasFormula: vi.fn().mockReturnValue(true),
+ getExcelFormula: vi.fn().mockReturnValue('B2*C2'),
+ },
+ ],
+ };
+ container.registerInstance('SharedService', sharedServiceStub);
+
+ service.init(gridStub, container);
+
+ const localColumns = [{ id: 'total', field: 'total', width: 100, type: 'number' }] as unknown as Column[];
+ (service as any)._excelExportOptions = { htmlDecode: true, autoDetectCellFormat: true };
+ (service as any)._workbook = new Workbook();
+ (service as any)._sheet = (service as any)._workbook.createWorksheet({ name: 'Sheet1' });
+ (service as any)._stylesheet = (service as any)._workbook.getStyleSheet();
+ const boldFmt = (service as any)._stylesheet.createFormat({ font: { bold: true } });
+ const strFmt = (service as any)._stylesheet.createFormat({ format: '@' });
+ const numFmt = (service as any)._stylesheet.createFormat({ format: '0' });
+ (service as any)._stylesheetFormats = { boldFormat: boldFmt, stringFormat: strFmt, numberFormat: numFmt };
+ (service as any)._formulaProvider = (sharedServiceStub.externalRegisteredResources as any[])[0];
+ (service as any)._formulaColumnIds = ['total'];
+ (service as any)._formulaRowIds = ['id_1'];
+
+ const metadataCache = (service as any).preCalculateColumnMetadata(localColumns);
+ const output = (service as any).readRegularRowData(localColumns, 0, { id: 'id_1', total: 0 }, 0, metadataCache);
+
+ expect(output[0]).toEqual(expect.objectContaining({ value: 'B2*C2', metadata: expect.objectContaining({ type: 'formula' }) }));
+ });
+
+ it('getCellFormulaForExcel should handle provider fallbacks and invalid rows', () => {
+ service.init(gridStub, container);
+ const provider = {
+ getExcelFormula: vi.fn().mockReturnValue(undefined),
+ hasFormula: vi.fn().mockReturnValue(true),
+ getFormula: vi.fn().mockReturnValue('=A1'),
+ };
+ (service as any)._formulaProvider = provider;
+
+ expect((service as any).getCellFormulaForExcel({ id: 'row-1' }, { id: 'total' }, 0)).toBe('A1');
+
+ provider.getExcelFormula.mockReturnValue(123);
+ provider.hasFormula.mockReturnValue(false);
+ expect((service as any).getCellFormulaForExcel({ id: 'row-1' }, { id: 'total' }, 0)).toBeUndefined();
+ expect((service as any).getCellFormulaForExcel({}, { id: 'total' }, 0)).toBeUndefined();
+
+ (service as any)._hasGroupedItems = true;
+ expect((service as any).getCellFormulaForExcel({ id: 'row-1' }, { id: 'total' }, 0)).toBeUndefined();
+ });
+
+ it('findFormulaProvider should return undefined when enableFormulas is false', () => {
+ const sharedServiceStub = {
+ externalRegisteredResources: [
+ {
+ pluginName: 'FormulaService',
+ hasFormula: vi.fn().mockReturnValue(true),
+ getExcelFormula: vi.fn().mockReturnValue('B2*C2'),
+ },
+ ],
+ };
+ container.registerInstance('SharedService', sharedServiceStub);
+ const previousEnableFormulas = mockGridOptions.enableFormulas;
+ mockGridOptions.enableFormulas = false;
+
+ try {
+ service.init(gridStub, container);
+ expect((service as any).findFormulaProvider()).toBeUndefined();
+ } finally {
+ mockGridOptions.enableFormulas = previousEnableFormulas;
+ }
+ });
+
+ it('findFormulaProvider should skip unrelated resources and tolerate missing registrations', () => {
+ const sharedServiceStub = { externalRegisteredResources: [{ pluginName: 'OtherService' }, { getFormula: vi.fn() }] };
+ container.registerInstance('SharedService', sharedServiceStub);
+ service.init(gridStub, container);
+ expect((service as any).findFormulaProvider()).toBe(sharedServiceStub.externalRegisteredResources[1]);
+
+ (service as any)._sharedService = { externalRegisteredResources: undefined };
+ expect((service as any).findFormulaProvider()).toBeUndefined();
+ });
+
+ it('registerFormulaProviderWorkbookArtifacts should register workbook defined names and custom functions when supported', () => {
+ service.init(gridStub, container);
+
+ const addDefinedName = vi.fn();
+ const addCustomFunction = vi.fn();
+ (service as any)._workbook = {
+ addDefinedName,
+ addCustomFunction,
+ };
+ (service as any)._formulaProvider = {
+ getExcelDefinedNames: () => [{ name: 'MY_RANGE', refersTo: 'Sheet1!$B$2:$C$100' }],
+ getExcelCustomFunctions: () => [{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }],
+ };
+
+ (service as any).registerFormulaProviderWorkbookArtifacts();
+
+ expect(addDefinedName).toHaveBeenCalledWith('MY_RANGE', 'Sheet1!$B$2:$C$100', undefined);
+ expect(addCustomFunction).toHaveBeenCalledWith('CUSTOMSUM', ['values'], 'SUM(values)', undefined);
+ });
+
+ it('registerFormulaProviderWorkbookArtifacts should ignore incomplete artifacts', () => {
+ service.init(gridStub, container);
+ const addDefinedName = vi.fn();
+ const addCustomFunction = vi.fn();
+ (service as any)._workbook = { addDefinedName, addCustomFunction };
+ (service as any)._formulaProvider = {
+ getExcelDefinedNames: () => [
+ { name: '', refersTo: 'Sheet1!A1' },
+ { name: 'VALID', refersTo: 'Sheet1!A1' },
+ ],
+ getExcelCustomFunctions: () => [
+ { name: 'MISSING_BODY', args: ['values'], body: '' },
+ { name: 'VALIDFN', args: ['values'], body: 'SUM(values)' },
+ ],
+ };
+
+ (service as any).registerFormulaProviderWorkbookArtifacts();
+
+ expect(addDefinedName).toHaveBeenCalledTimes(1);
+ expect(addCustomFunction).toHaveBeenCalledTimes(1);
+ });
+
+ it('getAllDataRowIds should retain only defined row identifiers', () => {
+ service.init(gridStub, container);
+ vi.spyOn(dataViewStub, 'getLength').mockReturnValue(3);
+ vi.spyOn(dataViewStub, 'getItem').mockImplementation((index: number) => [{ id: 'a' }, { id: null }, { id: 'c' }][index] as any);
+
+ expect((service as any).getAllDataRowIds()).toEqual(['a', 'c']);
+ });
+
+ it('registerFormulaProviderWorkbookArtifacts should not throw when workbook custom formula APIs are unavailable', () => {
+ service.init(gridStub, container);
+
+ (service as any)._workbook = {};
+ (service as any)._formulaProvider = {
+ getExcelDefinedNames: () => [{ name: 'MY_RANGE', refersTo: 'Sheet1!$B$2:$C$100' }],
+ getExcelCustomFunctions: () => [{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }],
+ };
+
+ expect(() => (service as any).registerFormulaProviderWorkbookArtifacts()).not.toThrow();
+ });
+
+ it('createWorkbookInstance should prefer createWorkbook factory for workbook compatibility features', () => {
+ service.init(gridStub, container);
+
+ const workbook = (service as any).createWorkbookInstance();
+
+ expect(createWorkbook).toHaveBeenCalledTimes(1);
+ expect(workbook).toBeDefined();
+ });
+
it('efficientYield should use scheduler.postTask when available', async () => {
const postTask = vi.fn((cb) => cb());
(globalThis as any).scheduler = { postTask };
diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts
index ce8723f59..e06529d24 100644
--- a/packages/excel-export/src/excelExport.service.ts
+++ b/packages/excel-export/src/excelExport.service.ts
@@ -6,12 +6,14 @@ import type {
ExcelGroupValueParserArgs,
ExternalResource,
FileType,
+ FormulaProvider,
GetDataValueCallback,
GetGroupTotalValueCallback,
GridOption,
KeyTitlePair,
Locale,
PubSubService,
+ SharedService,
SlickDataView,
SlickGrid,
TranslaterService,
@@ -34,6 +36,7 @@ import {
} from '@slickgrid-universal/utils';
import {
createExcelFileStream,
+ createWorkbook,
downloadExcelFile,
Workbook,
type ExcelColumnMetadata,
@@ -43,6 +46,11 @@ import {
} from 'excel-builder-vanilla';
import { getExcelFormatFromGridFormatter, getGroupTotalValue, useCellFormatByFieldType, type ExcelFormatter } from './excelUtils.js';
+interface WorkbookWithFormulas {
+ addCustomFunction?: (name: string, args: string[], body: string, options?: any) => void;
+ addDefinedName?: (name: string, refersTo: string, scope?: number | string) => void;
+}
+
interface ExcelColumnExportCache {
autoDetectCellFormat?: boolean;
exportOptions: ExcelExportOption;
@@ -73,10 +81,14 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
protected _stylesheet!: StyleSheet;
protected _stylesheetFormats: any;
protected _pubSubService: PubSubService | null = null;
+ protected _sharedService: SharedService | null = null;
protected _translaterService: TranslaterService | undefined;
protected _workbook!: Workbook;
protected _timer1?: any;
protected _timer2?: any;
+ protected _formulaProvider?: FormulaProvider;
+ protected _formulaColumnIds: Array = [];
+ protected _formulaRowIds: Array = [];
// references of each detected cell and/or group total formats
protected _regularCellExcelFormats: {
@@ -127,7 +139,11 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
this._grid = null as any;
this._dataView = null as any;
this._pubSubService = null;
+ this._sharedService = null;
this._translaterService = undefined;
+ this._formulaProvider = undefined;
+ this._formulaColumnIds = [];
+ this._formulaRowIds = [];
this._regularCellExcelFormats = Object.create(null);
this._groupTotalExcelFormats = Object.create(null);
}
@@ -141,6 +157,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
this._grid = grid;
this._dataView = grid?.getData() || {};
this._pubSubService = containerService.get('PubSubService');
+ this._sharedService = containerService.get('SharedService');
// get locales provided by user in main file or else use default English locales via the Constants
this._locales = this._gridOptions?.locales ?? Constants.locales;
@@ -183,7 +200,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
// prepare the Excel Workbook & Sheet
const worksheetOptions = { name: this._excelExportOptions.sheetName || 'Sheet1' };
- this._workbook = new Workbook();
+ this._workbook = this.createWorkbookInstance();
this._sheet = this._workbook.createWorksheet(worksheetOptions);
// add any Excel Format/Stylesheet to current Workbook
@@ -197,13 +214,13 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
this._sheet.setColumnFormats([boldFormat]);
try {
- // get all data by reading all DataView rows with yielding for responsiveness
- const dataOutput = await this.getDataOutputAsync();
-
if (this._gridOptions?.excelExportOptions?.customExcelHeader) {
this._gridOptions.excelExportOptions.customExcelHeader(this._workbook, this._sheet);
}
+ // get all data by reading all DataView rows with yielding for responsiveness
+ const dataOutput = await this.getDataOutputAsync();
+
const columns = this.getColumns();
this._sheet.setColumns(this.getColumnStyles(columns));
@@ -298,6 +315,10 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
*/
protected async getDataOutputAsync(): Promise> {
const columns = this.getColumns();
+ this._formulaProvider = this.findFormulaProvider();
+ this.registerFormulaProviderWorkbookArtifacts();
+ this._formulaColumnIds = columns.filter((col) => !col.excludeFromExport).map((col) => col.id);
+ this._formulaRowIds = this._formulaProvider ? this.getAllDataRowIds() : [];
const columnMetadataCache = this.preCalculateColumnMetadata(columns);
// pre-cache detected cell format/parser once per column to avoid repeated checks in row loop
@@ -395,6 +416,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
let colspanStartIndex = 0;
let headerOffset = 0; // increases when "Group by" is provided in the next header row
let outputGroupedHeaderTitles: Array = [];
+ const groupedHeaderRowNumber = this.getWorksheetReservedRowCount() + 1;
if (this.getGroupColumnTitle()) {
outputGroupedHeaderTitles.push({ value: '' });
@@ -417,7 +439,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
) {
const leftExcelColumnChar = this.getExcelColumnNameByIndex(colspanStartIndex + 1 + headerOffset);
const rightExcelColumnChar = this.getExcelColumnNameByIndex(cellIndex + 1 + headerOffset);
- this._sheet.mergeCells(`${leftExcelColumnChar}1`, `${rightExcelColumnChar}1`);
+ this._sheet.mergeCells(`${leftExcelColumnChar}${groupedHeaderRowNumber}`, `${rightExcelColumnChar}${groupedHeaderRowNumber}`);
// next group starts 1 column index away
colspanStartIndex = cellIndex + 1;
@@ -775,8 +797,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
// when using grid with rowspan without any colspan, we will merge some cells on single column
if (rowspan > 1 && !isNaN(prevColspan as number) && +prevColspan === 1 && columnDef.id === colspanColumnId) {
// -- Merge Data RowSpan only
- // Excel row starts at 2 or at 3 when dealing with pre-header grouping
- const excelRowNumber = row + (this._hasColumnTitlePreHeader ? 3 : 2);
+ const excelRowNumber = row + this.getExcelDataStartRowOffset();
const leftExcelColumnChar = this.getExcelColumnNameByIndex(col + 1);
const rightExcelColumnChar = this.getExcelColumnNameByIndex(col + 1);
this._sheet.mergeCells(`${leftExcelColumnChar}${excelRowNumber}`, `${rightExcelColumnChar}${excelRowNumber + rowspan - 1}`);
@@ -785,8 +806,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
// when using grid with colspan, we will merge some cells together
if ((prevColspan === '*' && col > 0) || (!isNaN(prevColspan as number) && +prevColspan > 1 && columnDef.id !== colspanColumnId)) {
// -- Merge Data, ColSpan and maybe RowSpan
- // Excel row starts at 2 or at 3 when dealing with pre-header grouping
- const excelRowNumber = row + (this._hasColumnTitlePreHeader ? 3 : 2);
+ const excelRowNumber = row + this.getExcelDataStartRowOffset();
if (typeof prevColspan === 'number' && colspan - 1 === 1) {
// partial column span
@@ -868,16 +888,21 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
}
const { excelFormatId, getDataValueParser } = this._regularCellExcelFormats[columnId];
- const parsedItemData = getDataValueParser(itemData, {
- columnDef,
- excelFormatId,
- stylesheet: this._stylesheet,
- gridOptions: this._gridOptions,
- dataRowIdx,
- dataContext: itemObj,
- }) as Date | number | string | ExcelColumnMetadata;
-
- rowOutputStrings.push(parsedItemData);
+ const formulaValue = this.getCellFormulaForExcel(itemObj, columnDef, dataRowIdx);
+ if (formulaValue !== undefined) {
+ rowOutputStrings.push({ value: formulaValue, metadata: { type: 'formula', style: excelFormatId } });
+ } else {
+ const parsedItemData = getDataValueParser(itemData, {
+ columnDef,
+ excelFormatId,
+ stylesheet: this._stylesheet,
+ gridOptions: this._gridOptions,
+ dataRowIdx,
+ dataContext: itemObj,
+ }) as Date | number | string | ExcelColumnMetadata;
+
+ rowOutputStrings.push(parsedItemData);
+ }
idx++;
}
}
@@ -885,6 +910,128 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ
return rowOutputStrings as string[];
}
+ /** Return a normalized Excel formula when a formula provider is available and the current row has an id. */
+ protected getCellFormulaForExcel(itemObj: any, columnDef: Column, dataRowIdx: number): string | undefined {
+ // grouped exports currently rely on row-level parser callbacks for deterministic row offsets.
+ if (!this._formulaProvider || this._hasGroupedItems) {
+ return undefined;
+ }
+
+ const rowId = itemObj?.[this._datasetIdPropName] as number | string | undefined;
+ if (rowId === undefined || rowId === null) {
+ return undefined;
+ }
+
+ let formula = this._formulaProvider.getExcelFormula?.({
+ columnId: columnDef.id,
+ columnIds: this._formulaColumnIds,
+ dataRowIdx,
+ datasetIdPropertyName: this._datasetIdPropName,
+ excelRowOffset: this.getExcelDataStartRowOffset(),
+ gridOptions: this._gridOptions,
+ rowId,
+ rowIds: this._formulaRowIds,
+ });
+
+ if (!formula && this._formulaProvider.hasFormula?.(rowId, columnDef.id)) {
+ formula = this._formulaProvider.getFormula?.(rowId, columnDef.id);
+ }
+
+ if (typeof formula !== 'string') {
+ return undefined;
+ }
+
+ return formula.startsWith('=') ? formula.slice(1) : formula;
+ }
+
+ /** Find first registered external resource that exposes formula provider methods. */
+ protected findFormulaProvider(): FormulaProvider | undefined {
+ if (this._gridOptions?.enableFormulas === false) {
+ return undefined;
+ }
+
+ const registeredResources = this._sharedService?.externalRegisteredResources;
+ if (!Array.isArray(registeredResources)) {
+ return undefined;
+ }
+
+ return registeredResources.find((resource) => {
+ const ref = resource as FormulaProvider;
+ return (
+ typeof ref?.getExcelFormula === 'function' ||
+ typeof ref?.getFormula === 'function' ||
+ typeof ref?.getExcelCustomFunctions === 'function' ||
+ typeof ref?.getExcelDefinedNames === 'function'
+ );
+ }) as FormulaProvider | undefined;
+ }
+
+ /** Register workbook-level defined names and custom functions exposed by FormulaProvider. */
+ protected registerFormulaProviderWorkbookArtifacts(): void {
+ if (!this._formulaProvider || !this._workbook) {
+ return;
+ }
+
+ const workbook = this._workbook as WorkbookWithFormulas;
+ const definedNames = this._formulaProvider.getExcelDefinedNames?.() ?? [];
+ const customFunctions = this._formulaProvider.getExcelCustomFunctions?.() ?? [];
+
+ if (typeof workbook.addDefinedName === 'function') {
+ for (const definedName of definedNames) {
+ if (!definedName?.name || !definedName?.refersTo) {
+ continue;
+ }
+ workbook.addDefinedName(definedName.name, definedName.refersTo, definedName.scope);
+ }
+ }
+
+ if (typeof workbook.addCustomFunction === 'function') {
+ for (const customFunction of customFunctions) {
+ if (!customFunction?.name || !Array.isArray(customFunction.args) || !customFunction?.body) {
+ continue;
+ }
+ workbook.addCustomFunction(customFunction.name, customFunction.args, customFunction.body, customFunction.options);
+ }
+ }
+ }
+
+ /** Return all row ids from DataView for formula reference translation (flat dataset use case). */
+ protected getAllDataRowIds(): Array {
+ const rowIds: Array = [];
+ const datasetIdPropertyName = this._datasetIdPropName;
+ const itemCount = this._dataView.getLength?.() ?? 0;
+
+ for (let rowIdx = 0; rowIdx < itemCount; rowIdx++) {
+ const item = this._dataView.getItem(rowIdx);
+ const rowId = item?.[datasetIdPropertyName] as number | string | undefined;
+ if (rowId !== undefined && rowId !== null) {
+ rowIds.push(rowId);
+ }
+ }
+
+ return rowIds;
+ }
+
+ /** Return number of worksheet rows already reserved before grid headers/data (includes merged-cell vertical spans). */
+ protected getWorksheetReservedRowCount(): number {
+ return Array.isArray(this._sheet?.data) ? this._sheet.data.length : 0;
+ }
+
+ /** Return default header row count generated by this service before first data row. */
+ protected getDefaultExcelHeaderRowCount(): number {
+ return this._hasColumnTitlePreHeader ? 2 : 1;
+ }
+
+ /** Return absolute Excel row offset for first exported dataset row. */
+ protected getExcelDataStartRowOffset(): number {
+ return this.getWorksheetReservedRowCount() + this.getDefaultExcelHeaderRowCount() + 1;
+ }
+
+ /** Create workbook using the factory API when available, fallback to class constructor for backward compatibility. */
+ protected createWorkbookInstance(): Workbook {
+ return typeof createWorkbook === 'function' ? (createWorkbook() as Workbook) : new Workbook();
+ }
+
/**
* Get the grouped title(s) and its group title formatter, for example if we grouped by salesRep, the returned result would be:: 'Sales Rep: John Dow (2 items)'
* @param itemObj
diff --git a/packages/excel-export/src/excelUtils.spec.ts b/packages/excel-export/src/excelUtils.spec.ts
index 3327a3f4c..ff818bb09 100644
--- a/packages/excel-export/src/excelUtils.spec.ts
+++ b/packages/excel-export/src/excelUtils.spec.ts
@@ -99,6 +99,11 @@ describe('excelUtils', () => {
});
expect(output).toEqual({ metadata: { style: 3 }, value: 1244209.33 });
});
+
+ it('should return zero when group totals or fields are missing', () => {
+ expect(getGroupTotalValue(undefined, { columnDef: { field: 'amount' } as Column, groupType: 'sum' })).toBe(0);
+ expect(getGroupTotalValue({ sum: {} }, { columnDef: { field: 'amount' } as Column, groupType: 'sum' })).toBe(0);
+ });
});
describe('decimal formatter', () => {
diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md
new file mode 100644
index 000000000..561baf7af
--- /dev/null
+++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md
@@ -0,0 +1,260 @@
+# Formula Editor Plugin Progress
+
+Last updated: 2026-08-21 (formula drag-fill and complete unit-test coverage)
+Branch context: feat/cell-formula-plugin
+
+## Latest Update: Multi-Selection Preservation and Highlight Cleanup (2026-08-26)
+- FormulaCellEditor now snapshots existing cell/row selection ranges before applying its temporary active-reference range and restores them when the temporary highlight is cleared or the editor closes.
+- FormulaCellEditor and FormulaService now share one reference-to-cell CSS hash builder and one aggregate highlight overlay key; the editor owns open-time highlighting without a demo-level pre-render hook.
+- Removed obsolete editor highlight keys, the unused selection-color lookup, and numbered `formula-ref-highlight-*` cleanup.
+- Formula colors are now cleared on every editor destroy path, including non-keyboard teardown.
+- Added regressions for restoring multiple ranges, aggregating more than ten colored references, numeric column IDs, and non-keyboard cleanup.
+
+## Latest Update: Formula Editor Cell Sizing (2026-08-22)
+- Consolidated `.formula-editor-input` cell sizing and AG Grid-style single-line editor behavior into `slick-editors.scss` alongside the native text editors.
+- Set the contenteditable formula editor to `border-box` and removed its fixed minimum height so it stays within the cell like native text editors.
+- Vertically centered formula text and colored reference tokens within the cell editor.
+- Kept the formula editor as a direct contenteditable element with standard hidden-scrollbar behavior and no ellipsis, keeping formula tokens as direct editor children.
+- Centered the direct formula text and token items within the full-height contenteditable editor.
+- Simplified the formula editor overflow declaration to the two-value shorthand.
+- Handled HOME and END explicitly so caret movement crosses colored formula-token spans.
+- Made Ctrl/Cmd+Arrow navigation move across complete colored reference-token sections.
+- Kept token navigation compatible with ES2021 by avoiding `Array.prototype.at()`.
+- Centralized repeated keyboard event suppression in a small editor helper while preserving Ctrl/Cmd+A browser selection behavior.
+
+## Maintenance Rule
+- On every formula-plugin related change, update this file in the same commit/PR.
+- Keep it short and factual: what changed, why, tests added/updated, and any new constraints.
+
+## Purpose
+This file is a handoff for future AI/dev sessions. It describes what is already implemented in the formula editor UX and what is still pending.
+
+## Implemented
+- In-grid formula reference click does not close the editor anymore.
+- Clicking another grid cell while editing a formula replaces the reference token at caret (Excel-like behavior).
+- Dragging across cells updates the active formula reference as a range.
+- Caret-aware reference detection is implemented.
+- When caret is inside a token like D1:D3, that token becomes the active editable reference range.
+- Reference range rewrite updates the token in place (no prefix/suffix corruption).
+- Endpoint drag expansion keeps opposite endpoint as anchor (e.g. D1:D3 can expand to D1:D6).
+- Grid click is suppressed during reference-pick lifecycle to prevent SlickGrid auto-commit/close.
+- Formula editor supports both selection-model highlight and CSS fallback highlight.
+- Preferred path uses grid selection model via setSelectedRanges(SlickRange[]) when available.
+- Fallback path uses setCellCssStyles when no compatible selection model exists.
+- Explicit type annotation was added for _referenceTokenRegex to satisfy isolated declarations.
+
+## Selection Model Integration
+- The formula editor now integrates with the active SelectionModel API.
+- If a compatible model exists, it drives visual range selection through setSelectedRanges.
+- This is intended to use normal Slick selection UX (including hybrid model behavior) instead of a separate custom visual system.
+
+## Required Grid Options For Full UX
+For full Excel-like range visuals/drag-resize, the grid must have cell-capable selection enabled.
+
+Recommended:
+- enableSelection: true
+- selectionOptions.selectionType: "mixed" or "cell"
+
+## Runtime Validation Added
+FormulaService now validates selection prerequisites when formula columns are detected.
+- If prerequisites are missing, it logs a one-time warning with the required options.
+- It does not auto-mutate user grid options.
+
+## FormulaService Behaviors Already Present
+- Auto-assign FormulaCellEditor to allowFormula columns (without overriding explicit non-formula custom editor models).
+- Formula store set/get/has/remove.
+- A1 and REF(COLUMN(), ROW()) support in evaluation/export flow.
+- Formula token highlighting support.
+- Excel export helpers for defined names and custom functions.
+
+## Latest Update: Excel Custom Functions Export (2026-08-06)
+- Fixed workbook creation path in Excel export service to prefer createWorkbook() (excel-builder-vanilla v5.2.0 API), with fallback to new Workbook() for backward compatibility.
+- Confirmed workbook-level defined names/custom functions registration continues to run through FormulaProvider hooks.
+- Added regression test asserting workbook factory path is used in Excel export service.
+- Updated formula demo setup to include excelCustomFunctions for CUSTOMSUM export.
+
+Why this mattered:
+- customFunctions handles in-app formula evaluation.
+- excelCustomFunctions is required for workbook-level export so Excel can resolve names/functions and avoid #NAME? (on Excel versions supporting LAMBDA).
+
+## Latest Update: Example 46 Dark Mode Editor Background (2026-08-06)
+- Fixed dark mode editor background mismatch in demo example47 by switching formula editor background to use --slick-text-editor-background.
+- Added local variable overrides in example47:
+ - light mode: --slick-text-editor-background: #fff
+ - dark mode: --slick-text-editor-background: #111827
+- Added dark-mode selected editable cell color override:
+ - --slick-cell-selected-editable-color: #333333
+- This aligns formula editor and built-in text editors with dark mode in the same grid scope.
+
+## Latest Update: Formula Token Styling (2026-08-06)
+- Updated formula token appearance to match Excel/AG Grid behavior: text color only.
+- Removed token chip styling (border/background) from shared plugin styles and example47 demo token overrides.
+- This avoids visual conflict when selecting formula text (for example Ctrl+A in editor).
+
+## Latest Update: Ctrl+A Event Scope (2026-08-06)
+- Fixed formula editor key handling so Ctrl+A / Cmd+A stays inside the editor.
+- The editor now stops propagation for select-all shortcuts without preventing default browser behavior.
+- This prevents SlickGrid from receiving the event and selecting all grid cells while formula editor is focused.
+
+## Latest Update: Formula Style Portability (2026-08-06)
+- Moved base formula editor styling from demo-level example47 stylesheet into shared plugin styles:
+ - .formula-editor-input
+ - .formula-token
+- Added shared CSS variables for formula editor border/focus/text colors with dark-mode defaults.
+- Kept only demo-specific visual overrides in example47 (for example row colors and local editor background/selected editable color vars).
+
+## Tests Added/Updated
+`src/__tests__/formula.cellEditor.spec.ts` covers:
+- Editor remains open and suppresses grid click after reference selection.
+- Caret-driven range highlight and drag-rewrite flow.
+- Endpoint drag expansion anchor behavior.
+- Fallback to cell-css highlighting when no selection model is available.
+- Initial editor load applies persistent reference colors through the shared aggregate overlay.
+- Clipboard copy/cut uses plain text from editor DOM textContent (NBSP normalized).
+- Autocomplete insertion reads live editor DOM text instead of stale cached plain value.
+- Selection highlight style is removed only when it was actually active.
+
+`src/__tests__/formula.service.spec.ts` covers:
+- Warning when formula columns exist but selection prerequisites are missing.
+- No warning when mixed selection is configured.
+
+test/cypress/e2e/example47.cy.ts covers:
+- Formula editor argument append-after-operator regression (`=SUM(C1*` then click cell => `=SUM(C1*D1`).
+- Multi-reference color persistence while typing (`=C1*SUM(D1:D3)`) with stable per-reference coloring.
+- Formula editor copy/cut plain-text clipboard behavior.
+- Incomplete reference color stability scenarios from formula-entry workflows.
+- Formula evaluation, custom functions, and baseline formula cell calculations.
+
+## Latest Update: Security & Plugin Convention Review (2026-08-06)
+- **Fixed XSS**: `FormulaCellEditor.renderTokens()` built its highlighted markup as an HTML string (only cell-reference tokens were escaped) and assigned it via `innerHTML`. Any other raw formula text (typed or loaded from dataset values) was inserted unescaped, so formulas like `=A1&""` could execute arbitrary markup/script. Rewrote to build the token spans via DOM APIs (`createTextNode`/`createElement`+`textContent`) so no formula text is ever HTML-parsed. Removed the now-unused `escapeHtml()` helper.
+- **Removed the `Function()` eval fallback** in `FormulaService.evaluateFormulaExpression()`. The custom recursive-descent parser already implements the full supported grammar and always returns a defined value/error code, so the dynamic-code fallback was unreachable in practice and only added unnecessary injection surface (regex-based guards ahead of `Function(...)` are fragile to maintain as grammar grows). The parser result is now returned directly.
+- **Added `getOptions()`/`setOptions()`** to `FormulaService` to match the `ExternalResource` plugin convention used by other plugins (e.g. `CustomTooltip`).
+- **Adopted `BindingEventService`** in `FormulaCellEditor` (added `@slickgrid-universal/binding` dependency) instead of manual `addEventListener`/`removeEventListener` bookkeeping, matching the convention used by `baseEditorClass`/`longTextEditor`/`sliderEditor`/`slickCustomTooltip`.
+- **Fixed `dispose()` asymmetry**: `autoAssignFormulaEditorToColumns()` now records each column's original `formatter`/`params`/`editorClass`/`editor` before wrapping it, and a new `restoreAutoAssignedFormulaEditorColumns()` (called from `dispose()`) restores them — mirroring the existing `enableExcelHeaderPrefix`/`disableExcelHeaderPrefix` symmetry.
+- **Fixed a corrupted `formula.service.spec.ts`**: a stray, incomplete `it(...)` block had split a test's body away from its `it(...)` declaration (leaving one dangling fragment ~90 lines later in the file), causing an OXC parse error that failed the *entire* spec file silently. This means `formula.service.spec.ts` had not actually been executable/passing prior to this fix, despite prior progress notes/verification commands claiming otherwise. Reconstructed the split test (`should shift direct A1 references by excelRowOffset during export`) and removed the orphaned fragment.
+
+### Found but NOT fixed (needs a product decision)
+After the spec file was repaired, 2 pre-existing test failures surfaced (unrelated to the changes above — same behavior existed before, just never actually ran due to the parse error):
+- `should evaluate unicode multiply with range like Excel formula shorthand` expects `=B1×C1:C3` (scalar × range) to reduce to `24` (i.e. `B1 * SUM(range)`).
+- `should return #VALUE! for scalar times range shorthand expressions` expects the structurally identical `=C1*D1:D3` (scalar * range) to return `#VALUE!`.
+
+These two expectations contradict each other for the same formula shape (only the multiply symbol differs, and `×` is normalized to `*` early in evaluation). Do not "fix" one without deciding the intended semantics for scalar-times-range shorthand (implicit SUMPRODUCT-style broadcast vs. hard error) — pick one behavior and update the other test accordingly.
+
+## Latest Update: Grouping Limitation Note (2026-08-06)
+- Grouping + FormulaService is **not fully supported yet**.
+- Grouping/Grouping Formatter scenarios can still show incorrect or unstable formula behavior.
+- `example47` includes grouping, but known grouping-related bugs remain.
+- Concrete issue: when grouping inserts extra group rows (for example group headers/totals), formula references are not remapped to account for the inserted rows, so A1 references can point to the wrong cells (row offset drift).
+- Excel export for grouped formula scenarios is also not yet fully complete.
+- Plan: keep grouping support as a follow-up task and fix it in a dedicated pass later.
+
+## Latest Update: Argument Insert After Operator Fix (2026-08-06)
+- Coverage push work is postponed for now to focus on formula UX bug fixes.
+- Fixed reference pick behavior when composing function arguments with operators.
+- Before: after `=SUM(C1*`, clicking a cell replaced `C1` (for example `=SUM(D1*`).
+- Now: after `=SUM(C1*`, clicking a cell inserts at caret as expected (for example `=SUM(C1*D1`).
+- Root cause was a single-reference fallback path that replaced the lone token even when caret context indicated a new argument expression.
+- Fix: when no token is active at caret and caret follows an argument operator/delimiter (`=`, `(`, `,`, `+`, `-`, `*`, `/`, `^`, `&`, `:`), editor now inserts at caret instead of replacing the existing reference token.
+
+## Latest Update: Stable Column/Row References (2026-08-20)
+- This risk is addressed for formulas committed through FormulaService.
+- The editor continues to display A1 references, while committed formulas use stable `REF(COLUMN("columnId"),ROW("rowId"))` references.
+- Runtime evaluation resolves stable references against the full logical column list, including hidden columns.
+- Excel export converts stable references to native A1 formulas using the exported column and row order.
+- Legacy A1 formulas are canonicalized when the service has the current grid column and row identities.
+- Exporting a formula that depends on a hidden source column requires `includeHidden: true` so the source exists in the workbook.
+- Added regressions for reorder/hide runtime behavior, reordered Excel export, and hidden-column-inclusive export.
+
+## PR #2716 Checklist Review (2026-08-20)
+Completed checklist items now include:
+- Cypress E2E coverage for formula editing, reference insertion, autocomplete, token/grid colors, clipboard behavior, and formula evaluation.
+- Stronger selected-reference styling through persistent reference colors plus SelectionModel range highlighting, with CSS fallback.
+- Stable column/row references for reorder and hide scenarios while keeping A1 syntax in the editor.
+- Excel export conversion from stable references back to native A1 formulas, including export row offsets and included hidden columns.
+- Shared reference/color parsing between FormulaCellEditor and FormulaService.
+- Formula drag-fill through the existing `.slick-drag-replace-handle` / `onDragReplaceCells` flow.
+- Drag-filled formulas shift relative A1 references by the target row/column delta while preserving absolute row/column markers during translation.
+- Drag-filled formulas are written back through stable `REF(COLUMN(),ROW())` storage, keeping reorder/hide runtime behavior and Excel export compatibility.
+
+Still open:
+- Grouping and grouped formula export.
+- Grid State/Preset persistence for formula references.
+- Whole-column drag-handle expansion and calculated-column support.
+- Browser coverage for formula-source translation, absolute-reference fill behavior, and selection-model fallback.
+- Optional strict selection-prerequisite mode.
+
+## Latest Update: Formula Color Sync, Incomplete References, and Clipboard (2026-08-10)
+- Restored color-sync separation of concerns to prevent editor/grid mismatch regressions:
+ - persistent reference coloring is applied through `buildFormulaReferenceColorCache()` -> `applyFormulaReferenceCellColors()` on user input.
+ - `renderGridSelectionHighlight()` only manages selection-model highlight and no longer re-applies persistent colors.
+- Fixed a syntax regression in `FormulaCellEditor.clearReferenceSelectionHighlight()` (malformed brace block) that caused transform/parse failure.
+- Tightened highlight cleanup logic so selection highlight CSS key is removed only if highlight was previously active.
+- Confirmed incomplete references (for example `D1:D`) keep stable token color assignment and do not collapse other reference colors.
+- Added plain-text clipboard handling for Ctrl/Cmd+C and Ctrl/Cmd+X from editor DOM text content with NBSP normalization.
+
+Why this mattered:
+- Prevented "all references same color" and "colors disappear while typing" regressions.
+- Kept formula token colors and grid reference colors aligned for multi-reference formulas.
+- Ensured clipboard output from the formula editor is plain formula text without HTML/span artifacts.
+
+## Latest Update: Saved-Formula Reopen Color Order (2026-08-15)
+- Fixed inverted grid reference colors after committing and reopening a formula such as `=C1*SUM(D1:D3)`.
+- Root cause: `FormulaService.extractExcelReferenceGroups()` collected all ranges before single-cell references, while `FormulaCellEditor` assigned colors in textual order.
+- Added `formula-reference.ts` as the shared source for reference token matching, normalization, A1 column conversion, range expansion, deduplication, color assignment, and `FormulaReferenceColorCache` state management.
+- FormulaCellEditor and FormulaService now consume the same cache abstraction; their duplicate extraction, conversion, and reference-cache lifecycle implementations were removed. The editor retains only DOM/caret and grid-style application concerns.
+- Added shared utility and FormulaService unit regressions plus Cypress coverage that commits with Enter, reopens the editor, and verifies `C1` remains color 1 while `D1:D3` remains color 2.
+- Added the reverse-order Cypress regression (`=SUM(D1:D3)*C1`) to ensure color assignment follows formula text order in both directions.
+- Moved formula-plugin unit specs into `src/__tests__` and updated their relative imports.
+- Added direct unit coverage for the shared `FormulaReferenceColorCache`, malformed references, and shared A1 conversion/range expansion helpers.
+- Expanded formula-function, FormulaCellEditor, and FormulaService edge-case coverage for invalid values, color cleanup, caret guards, circular/missing references, and literal conversion.
+- Covered empty-stat-function and SUMPRODUCT normalization branches; removed an unreachable nullish fallback after numeric normalization.
+- Added direct editor helper coverage for invalid ranges, reference-token resolution, insertion decisions, anchor selection, and cache no-op handling.
+- Added FormulaService date arithmetic and reference/literal edge-case coverage.
+- Added stable-reference regressions covering A1-to-ID canonicalization, column reorder/hide evaluation, range endpoints, reordered Excel export, and hidden-column-inclusive export.
+
+## Latest Update: Formula Drag-Fill (2026-08-21)
+
+- FormulaService now subscribes to the existing `onDragReplaceCells` event generated by `.slick-drag-replace-handle`.
+- Vertical, horizontal, and corner fill ranges reuse the same target-range semantics as the vanilla spreadsheet drag-fill example.
+- Relative A1 references are shifted by the target/source row and logical column deltas; quoted literals are left unchanged.
+- Absolute row/column markers are retained for editor reopen, subsequent fills, and Excel export.
+- Generated formulas are persisted as stable column/row references, and the drag-fill regression verifies runtime evaluation plus native Excel A1 export.
+- Refactored drag-fill range handling and A1 translation into the internal `formula.drag-fill.ts` module; FormulaService remains the lifecycle/storage adapter.
+- Added AG Grid-style common series inference inside the optional formula-plugin package only: one static value copies, multiple numeric values continue linearly, and string/mixed values repeat in source order for `allowFormula` columns.
+- Documented the interaction requirement for combined formula editing and drag-fill: with `autoEdit: false`, a single click selects the formula cell and double-click opens the editor without competing with the drag handle.
+- Added Example 47 Cypress coverage for numeric series inference, formula stability after column reorder/hide, and exported formula row offsets.
+- Expanded Vitest coverage for FormulaCellEditor keyboard, caret, focus, clipboard, and lifecycle paths; FormulaService parser, lifecycle, conversion, and export paths; and drag-fill edge cases.
+- Added FormulaService regressions for changed live-formula precedence and both FormulaValueFormatter row-resolution paths (`DataView.getItem()` and the item-array fallback).
+- The full unit suite passes with 218 files and 6,022 tests; Formula plugin coverage is 100% statements/functions/lines and 93.52% branches.
+
+### Cypress Coverage Audit (2026-08-21)
+
+The Example 47 Cypress spec now covers formula editing and persistence, reference insertion with autocomplete, token/grid colors, incomplete references, clipboard text, formula evaluation, custom-function cell behavior, numeric static-value series inference, runtime stability after column reorder/hide, and reordered Excel formulas with the dataset row offset.
+
+The Example 47 Cypress suite is passing with the prescribed `pnpm cypress:ci --spec test/cypress/e2e/example47.cy.ts` command.
+
+The following implemented paths remain covered by unit tests but do not yet have direct Cypress coverage: caret-aware reference detection and range endpoint drag expansion, CSS fallback highlighting without a compatible selection model, prerequisite warning behavior, raw `REF(COLUMN(),ROW())` entry, hidden-column-inclusive Excel export, absolute/relative formula translation through the drag handle, workbook defined-name/custom-function assertions, and grouping-specific formula behavior.
+
+## Known Constraints / Notes
+- Security audit follow-up: formula reference expansion is bounded, highlight dictionaries use null-prototype objects, and formula/drag-fill writes safely handle a `__proto__` field.
+- Without a cell-capable selection model, range visuals fall back to CSS highlighting only.
+- TreeDataService-style hard throw was intentionally not used for formula selection prerequisites; behavior is warning-only to avoid breaking existing grids.
+- Grouping and Grouping Formatter integration is currently a known limitation for FormulaService and grouped formula export.
+- Series inference currently covers AG Grid's common default path only; modifier-key toggles, custom fill callbacks, range-reduction clearing, and double-click fill remain follow-ups.
+- The FormulaService currently supports all function names and operators listed in the AG Grid Formula Reference, plus `SUMPRODUCT` and `NA`. Revisit for closer Excel/AG Grid semantic parity later, including wildcard criteria, type-coercion edge cases, and distinct `#CIRCREF!` / `#PARSE!` error codes (circular references currently return `#REF!`, and parser failures return `#ERROR!`).
+
+## Fast Verification
+
+Run:
+
+- vitest run --config test/vitest.config.mts packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts
+- vitest run --config test/vitest.config.mts packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts
+- vitest run --config test/vitest.config.mts packages/formula-plugin/src/__tests__/formula-reference.spec.ts
+- vitest run --config test/vitest.config.mts packages/formula-plugin/src/__tests__/formula.service.spec.ts
+- vitest run --config test/vitest.config.mts packages/excel-export/src/excelExport.service.spec.ts
+- pnpm test:coverage (218 files, 6,022 tests; Formula plugin: 100% statements/functions/lines, 93.52% branches)
+- cypress run --config-file test/cypress.config.ts --spec test/cypress/e2e/example47.cy.ts
+
+## Suggested Next Items
+- Add optional strict mode in FormulaService to throw (instead of warn) when full selection prerequisites are required by product requirements.
+- Add higher-level coverage for whole-column drag-handle expansion and calculated-column behavior.
diff --git a/packages/formula-plugin/README.md b/packages/formula-plugin/README.md
new file mode 100644
index 000000000..132cec4c1
--- /dev/null
+++ b/packages/formula-plugin/README.md
@@ -0,0 +1,29 @@
+# @slickgrid-universal/formula-plugin
+
+Optional Formula Service for Slickgrid-Universal.
+
+## Purpose
+
+This package provides a lightweight external resource to store formulas per cell and expose an Excel export bridge.
+
+Current scope (MVP):
+- formula storage by row id + column id
+- AG-style `REF(COLUMN("x"),ROW("id"))` to Excel A1 translation for export
+- custom function registry API (for future runtime evaluator)
+
+## Usage
+
+```ts
+import { FormulaService } from '@slickgrid-universal/formula-plugin';
+
+const formulaService = new FormulaService();
+
+gridOptions = {
+ enableFormulas: true,
+ externalResources: [formulaService],
+};
+
+formulaService.setFormula('id_1', 'total', '=REF(COLUMN("price"),ROW("id_1"))*REF(COLUMN("qty"),ROW("id_1"))');
+```
+
+When `ExcelExportService` is enabled, formulas are exported as native Excel formulas when this resource is registered.
diff --git a/packages/formula-plugin/package.json b/packages/formula-plugin/package.json
new file mode 100644
index 000000000..ae2332b48
--- /dev/null
+++ b/packages/formula-plugin/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@slickgrid-universal/formula-plugin",
+ "version": "10.9.0",
+ "description": "Optional Formula Service for Slickgrid-Universal.",
+ "type": "module",
+ "main": "./dist/index.js",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "types": "./dist/index.d.ts",
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "/dist",
+ "/src"
+ ],
+ "scripts": {
+ "build": "pnpm run clean && tsc",
+ "build:incremental": "tsc --incremental --declaration",
+ "clean": "remove dist tsconfig.tsbuildinfo",
+ "dev": "pnpm build:incremental"
+ },
+ "license": "MIT",
+ "author": "Ghislain B.",
+ "homepage": "https://github.com/ghiscoding/slickgrid-universal",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ghiscoding/slickgrid-universal.git",
+ "directory": "packages/formula-plugin"
+ },
+ "bugs": {
+ "url": "https://github.com/ghiscoding/slickgrid-universal/issues"
+ },
+ "dependencies": {
+ "@slickgrid-universal/binding": "workspace:*",
+ "@slickgrid-universal/common": "workspace:*"
+ }
+}
\ No newline at end of file
diff --git a/packages/formula-plugin/src/__tests__/formula-functions.spec.ts b/packages/formula-plugin/src/__tests__/formula-functions.spec.ts
new file mode 100644
index 000000000..e522bb93d
--- /dev/null
+++ b/packages/formula-plugin/src/__tests__/formula-functions.spec.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it, vi } from 'vitest';
+import { FORMULA_ERROR } from '../formula-errors.js';
+import { createFormulaFunctionRegistry } from '../formula-functions.js';
+
+describe('createFormulaFunctionRegistry', () => {
+ it('should include core arithmetic/stat functions', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('SUM')?.(1, 2, '3', true, null, undefined, '')).toBe(7);
+ expect(registry.get('PRODUCT')?.(2, '3', true)).toBe(6);
+ expect(registry.get('MIN')?.(6, '2', 8)).toBe(2);
+ expect(registry.get('MIN')?.()).toBe(0);
+ expect(registry.get('MAX')?.(6, '2', 8)).toBe(8);
+ expect(registry.get('MAX')?.()).toBe(0);
+ expect(registry.get('AVERAGE')?.(2, 4, '6')).toBe(4);
+ expect(registry.get('AVERAGE')?.()).toBe(0);
+ expect(registry.get('MEDIAN')?.(10, 2, 6, 8)).toBe(7);
+ expect(registry.get('MEDIAN')?.(1, 2, 3)).toBe(2);
+ expect(registry.get('MEDIAN')?.()).toBe(0);
+ expect(registry.get('POWER')?.('2', 3)).toBe(8);
+ expect(registry.get('SUM')?.({ foo: 1 } as any, 2)).toBe(2);
+ });
+
+ it('should evaluate SUMPRODUCT with scalar broadcast and array lengths', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('SUMPRODUCT')?.([1, 2, 3], 10)).toBe(60);
+ expect(registry.get('SUMPRODUCT')?.([1, 2], [3, 4])).toBe(11);
+ expect(registry.get('SUMPRODUCT')?.([2, 3, 4], [10, 20])).toBe(84);
+ expect(registry.get('SUMPRODUCT')?.([], [])).toBe(0);
+ expect(registry.get('SUMPRODUCT')?.([1, undefined], [2, 3])).toBe(2);
+ expect(registry.get('SUMPRODUCT')?.([[1], []], [2, 3])).toBe(5);
+ expect(registry.get('SUMPRODUCT')?.()).toBe(0);
+ });
+
+ it('should evaluate IF and concatenation helpers', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('IF')?.(true, 'yes', 'no')).toBe('yes');
+ expect(registry.get('IF')?.(false, 'yes', 'no')).toBe('no');
+ expect(registry.get('CONCAT')?.('a', ['b', 'c'], null, undefined, 1)).toBe('abc1');
+ });
+
+ it('should evaluate count functions with numeric and blank semantics', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('COUNT')?.(1, '2', 'x', '', null, undefined, Infinity)).toBe(2);
+ expect(registry.get('COUNTA')?.(1, '2', '', null, undefined, false)).toBe(3);
+ expect(registry.get('COUNTBLANK')?.(1, '', null, undefined, 'x')).toBe(3);
+ });
+
+ it('should evaluate COUNTIF and SUMIF criteria operators', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('COUNTIF')?.([1, 2, 3, 4], '>2')).toBe(2);
+ expect(registry.get('COUNTIF')?.(['a', 'b', 'a'], 'a')).toBe(2);
+ expect(registry.get('COUNTIF')?.([true, false, true], true)).toBe(2);
+
+ expect(registry.get('SUMIF')?.([1, 2, 3, 4], '>2')).toBe(7);
+ expect(registry.get('SUMIF')?.([1, 2, 3, 4], '<=2', [10, 20, 30, 40])).toBe(30);
+ expect(registry.get('SUMIF')?.(['x', 'y'], '=x', [4, 9])).toBe(4);
+ expect(registry.get('COUNTIF')?.([1, 2, 3, 4], '<3')).toBe(2);
+ expect(registry.get('COUNTIF')?.([1, 2, 3, 4], '>=3')).toBe(2);
+ expect(registry.get('COUNTIF')?.(['a', 'b', 'a'], '<>a')).toBe(1);
+ expect(registry.get('COUNTIF')?.(['a', null], null)).toBe(1);
+ });
+
+ it('should normalize non-finite, boolean, numeric, and invalid numeric values', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ expect(registry.get('SUM')?.(Infinity, true, false, ' 2 ', 'not numeric')).toBe(3);
+ expect(registry.get('SUM')?.([Number.NaN])).toBe(0);
+ });
+
+ it('should expose date/random/error helpers', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+
+ const now = registry.get('NOW')?.();
+ const today = registry.get('TODAY')?.();
+ const rand = registry.get('RAND')?.();
+
+ expect(now).toBeInstanceOf(Date);
+ expect(today).toBeInstanceOf(Date);
+ expect((today as Date).getHours()).toBe(0);
+ expect((today as Date).getMinutes()).toBe(0);
+ expect(typeof rand).toBe('number');
+ expect((rand as number) >= 0 && (rand as number) <= 1).toBe(true);
+ expect(registry.get('NA')?.()).toBe(FORMULA_ERROR.NA);
+ });
+
+ it('should accept valid custom functions and ignore invalid names/non-functions', () => {
+ const customSpy = vi.fn((a: number, b: number) => a + b + 1);
+ const registry = createFormulaFunctionRegistry(
+ new Map unknown>([
+ ['CUSTOM_ADD', customSpy],
+ ['sum', ((a: number, b: number) => a - b) as any],
+ ['1BAD', ((x: number) => x) as any],
+ ['ALSO_BAD', 123 as any],
+ ])
+ );
+
+ expect(registry.get('CUSTOM_ADD')?.(2, 3)).toBe(6);
+ expect(customSpy).toHaveBeenCalledTimes(1);
+ // lowercase key must not override built-ins due to name validation
+ expect(registry.get('SUM')?.(2, 3)).toBe(5);
+ expect(registry.has('1BAD')).toBe(false);
+ });
+
+ it('should allow uppercase custom names to override built-ins intentionally', () => {
+ const registry = createFormulaFunctionRegistry(new Map unknown>([['SUM', ((a: number, b: number) => a - b) as any]]));
+
+ expect(registry.get('SUM')?.(9, 4)).toBe(5);
+ });
+
+ it('should return false on unexpected criteria operator fallback', () => {
+ const registry = createFormulaFunctionRegistry(new Map());
+ const originalMatch = String.prototype.match;
+
+ const matchSpy = vi.spyOn(String.prototype as any, 'match').mockImplementation(function (this: string, ...args: any[]) {
+ const regex = args[0] as RegExp;
+ if (this === '!2') {
+ return ['!2', '!', '2'] as any;
+ }
+
+ return originalMatch!.call(this, regex);
+ });
+
+ expect(registry.get('COUNTIF')?.([1, 2, 3], '!2')).toBe(0);
+
+ matchSpy.mockRestore();
+ });
+});
diff --git a/packages/formula-plugin/src/__tests__/formula-reference.spec.ts b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts
new file mode 100644
index 000000000..b9025f1f6
--- /dev/null
+++ b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildFormulaReferenceColorInfos,
+ buildFormulaReferenceCssHash,
+ expandFormulaReferenceToGridCells,
+ FormulaReferenceColorCache,
+ getExcelColumnIndexByName,
+ getExcelColumnNameByIndex,
+ parseExcelReferenceCell,
+} from '../formula-reference.js';
+
+describe('formula reference utilities', () => {
+ it('should assign colors in textual order and reuse the color of a repeated reference', () => {
+ const references = buildFormulaReferenceColorInfos('=C1*SUM(D1:D3)+C1');
+
+ expect(references).toEqual([
+ {
+ ref: 'C1',
+ colorIdx: 0,
+ colorClass: 'formula-cell-color-1',
+ cells: [{ row: 0, cell: 2 }],
+ },
+ {
+ ref: 'D1:D3',
+ colorIdx: 1,
+ colorClass: 'formula-cell-color-2',
+ cells: [
+ { row: 0, cell: 3 },
+ { row: 1, cell: 3 },
+ { row: 2, cell: 3 },
+ ],
+ },
+ ]);
+ });
+
+ it('should normalize absolute references and retain the valid start of an incomplete range', () => {
+ const references = buildFormulaReferenceColorInfos('=$c$1 + SUM( $D$1 : D )');
+
+ expect(references.map(({ ref, colorClass, cells }) => ({ ref, colorClass, cells }))).toEqual([
+ { ref: 'C1', colorClass: 'formula-cell-color-1', cells: [{ row: 0, cell: 2 }] },
+ { ref: 'D1:D', colorClass: 'formula-cell-color-2', cells: [{ row: 0, cell: 3 }] },
+ ]);
+ });
+
+ it('should convert Excel column names and indexes through one shared implementation', () => {
+ expect(getExcelColumnNameByIndex(0)).toBe('');
+ expect(getExcelColumnNameByIndex(28)).toBe('AB');
+ expect(getExcelColumnIndexByName('AB')).toBe(27);
+ });
+
+ it('should reject malformed or non-positive cell references', () => {
+ expect(parseExcelReferenceCell('invalid')).toBeUndefined();
+ expect(parseExcelReferenceCell('A0')).toBeUndefined();
+ expect(expandFormulaReferenceToGridCells('invalid')).toEqual([]);
+ expect(expandFormulaReferenceToGridCells('D1:')).toEqual([{ row: 0, cell: 3 }]);
+ });
+
+ it('should refuse to expand an excessively large range', () => {
+ expect(expandFormulaReferenceToGridCells('A1:ZZZ1000000')).toEqual([]);
+ });
+
+ it('should share formula-change and dirty-state handling through the color cache', () => {
+ const cache = new FormulaReferenceColorCache();
+
+ expect(cache.update('=C1*SUM(D1:D3)')).toBe(true);
+ expect(cache.isDirty).toBe(true);
+ expect(cache.size).toBe(2);
+ expect(Array.from(cache.values())).toHaveLength(2);
+ expect(cache.update('=C1*SUM(D1:D3)')).toBe(false);
+
+ cache.markClean();
+ expect(cache.isDirty).toBe(false);
+ expect(cache.update('=C1*SUM(D1:D)')).toBe(true);
+ expect(cache.get('D1:D')?.cells).toEqual([{ row: 0, cell: 3 }]);
+ cache.clear();
+ expect(cache.size).toBe(0);
+ expect(cache.isDirty).toBe(false);
+ });
+
+ it('should build one CSS hash for more than ten colored references and accept numeric column IDs', () => {
+ const references = buildFormulaReferenceColorInfos('=A1+B1+C1+D1+E1+F1+G1+H1+I1+J1+K1');
+ const columns = Array.from({ length: 11 }, (_value, index) => ({ id: index }));
+ const hash = buildFormulaReferenceCssHash(references, columns, 1);
+
+ expect(Object.keys(hash)).toEqual(['0']);
+ expect(hash[0][0]).toBe('formula-cell-color-1');
+ expect(hash[0][9]).toBe('formula-cell-color-10');
+ expect(hash[0][10]).toBe('formula-cell-color-1');
+ expect(Object.keys(hash[0])).toHaveLength(11);
+ expect(buildFormulaReferenceCssHash(references, columns, 0)).toEqual({});
+ });
+});
diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts
new file mode 100644
index 000000000..ec902a170
--- /dev/null
+++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts
@@ -0,0 +1,1310 @@
+import { SlickRange, type EditorArguments } from '@slickgrid-universal/common';
+import { describe, expect, it, vi } from 'vitest';
+import { FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY } from '../formula-reference.js';
+import { FormulaCellEditor } from '../formula.cellEditor.js';
+
+describe('FormulaCellEditor', () => {
+ it('should move Home and End across token spans', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ } as any;
+
+ const editor = new FormulaCellEditor({
+ column: { field: 'total' },
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=C1*SUM(D1:D5)+C2' },
+ } as any);
+ editor.loadValue({ total: '=C1*SUM(D1:D5)+C2' });
+
+ (editor as any).restoreCaretOffset(2);
+ const homeEvent = new KeyboardEvent('keydown', { key: 'Home', cancelable: true });
+ (editor as any).handleKeydown(homeEvent);
+ expect(homeEvent.defaultPrevented).toBe(true);
+ expect(document.activeElement).toBe((editor as any)._editorElm);
+ expect((editor as any).getCaretOffset()).toBe(0);
+
+ const endEvent = new KeyboardEvent('keydown', { key: 'End', cancelable: true });
+ (editor as any).handleKeydown(endEvent);
+ expect(endEvent.defaultPrevented).toBe(true);
+ expect((editor as any).getCaretOffset()).toBe('=C1*SUM(D1:D5)+C2'.length);
+
+ (editor as any).moveCaretToOffset(0);
+ const rightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true, cancelable: true });
+ (editor as any).handleKeydown(rightEvent);
+ expect(rightEvent.defaultPrevented).toBe(true);
+ expect((editor as any).getCaretOffset()).toBe('=C1'.length);
+
+ const secondRightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true, cancelable: true });
+ (editor as any).handleKeydown(secondRightEvent);
+ expect((editor as any).getCaretOffset()).toBe('=C1*SUM(D1:D5'.length);
+
+ const leftEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft', ctrlKey: true, cancelable: true });
+ (editor as any).handleKeydown(leftEvent);
+ expect(leftEvent.defaultPrevented).toBe(true);
+ expect((editor as any).getCaretOffset()).toBe('=C1*SUM('.length);
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should display stable references as A1 while serializing the stable form', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const item = { id: 'a_01', total: '=REF(COLUMN("price"),ROW("a_01"))*REF(COLUMN("quantity"),ROW("a_01"))' };
+ const committed = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 2 }),
+ getColumns: () => [{ id: 'price' }, { id: 'quantity' }, { id: 'total' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: {
+ field: 'total',
+ editor: {
+ params: {
+ toDisplayFormula: (formula: string) =>
+ formula.replace(/REF\(COLUMN\("price"\),ROW\("a_01"\)\)/g, 'A1').replace(/REF\(COLUMN\("quantity"\),ROW\("a_01"\)\)/g, 'B1'),
+ toStoredFormula: (formula: string) =>
+ formula.replace('A1', 'REF(COLUMN("price"),ROW("a_01"))').replace('B1', 'REF(COLUMN("quantity"),ROW("a_01"))'),
+ onFormulaCommit: committed,
+ },
+ },
+ },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item,
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue(item);
+
+ expect(editor.serializeValue()).toBe(item.total);
+ expect((editor as any)._editorElm.textContent).toBe('=A1*B1');
+
+ editor.applyValue(item, editor.serializeValue());
+ expect(committed).toHaveBeenCalledWith(item.total, item);
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should assign a __proto__ field as an own data property', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({}),
+ } as any;
+ const item: Record = { id: 'row-1' };
+ const editor = new FormulaCellEditor({
+ column: { field: '__proto__', editor: { params: {} } },
+ container: hostContainer,
+ grid: gridStub,
+ item,
+ } as any);
+
+ editor.applyValue(item, '=1');
+
+ expect(Object.prototype.hasOwnProperty.call(item, '__proto__')).toBe(true);
+ expect(item.__proto__).toBe('=1');
+ expect(Object.getPrototypeOf(item)).toBe(Object.prototype);
+ editor.destroy();
+ });
+
+ it('should keep editor open and suppress grid click after selecting a reference cell', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ const gridCell = document.createElement('div');
+ gridCell.className = 'slick-cell';
+ gridContainer.appendChild(gridCell);
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 2 }),
+ getCellFromEvent: (event: MouseEvent) => (gridContainer.contains(event.target as Node) ? { row: 1, cell: 2 } : null),
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { debug: false } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=C1*D1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Place caret inside C1 so the clicked cell replaces C1.
+ (editor as any).restoreCaretOffset(2);
+
+ let wasGridClickHandled = false;
+ gridContainer.addEventListener('click', () => {
+ wasGridClickHandled = true;
+ editor.destroy();
+ });
+
+ const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ gridCell.dispatchEvent(mouseDownEvent);
+ expect(mouseDownEvent.defaultPrevented).toBe(true);
+ expect(editor.serializeValue()).toBe('=C2*D1');
+
+ const mouseUpEvent = new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 });
+ gridCell.dispatchEvent(mouseUpEvent);
+ expect(mouseUpEvent.defaultPrevented).toBe(true);
+
+ const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
+ gridCell.dispatchEvent(clickEvent);
+
+ expect(clickEvent.defaultPrevented).toBe(true);
+ expect(wasGridClickHandled).toBe(false);
+ expect((editor as any)._editorElm.isConnected).toBe(true);
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should highlight range under caret and rewrite that range through grid drag selection', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const columnIds = ['a', 'b', 'c', 'd', 'e'];
+ const cellMap = new Map();
+
+ const startCellElm = document.createElement('div');
+ startCellElm.className = 'slick-cell';
+ gridContainer.appendChild(startCellElm);
+ cellMap.set(startCellElm, { row: 0, cell: 4 });
+
+ const endCellElm = document.createElement('div');
+ endCellElm.className = 'slick-cell';
+ gridContainer.appendChild(endCellElm);
+ cellMap.set(endCellElm, { row: 2, cell: 4 });
+
+ const setCellCssStylesCalls: Array>> = [];
+ const selectionRangesCalls: Array> = [];
+ const selectionModelStub = {
+ setSelectedRanges: (ranges: Array<{ fromRow: number; fromCell: number; toRow: number; toCell: number }>) => {
+ selectionRangesCalls.push(ranges);
+ },
+ };
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 3 }),
+ getCellFromEvent: (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ return target ? (cellMap.get(target) ?? null) : null;
+ },
+ getColumns: () => columnIds.map((id) => ({ id })),
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ getSelectionModel: () => selectionModelStub,
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: (_key: string, hash: Record>) => {
+ setCellCssStylesCalls.push(hash);
+ },
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { debug: false } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=SUM(D1:D2)' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Place caret in D1:D2 token and trigger caret-sync highlight.
+ (editor as any).restoreCaretOffset(7);
+ (editor as any)._editorElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+
+ const initialSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0];
+ expect(initialSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 1, toCell: 3 });
+ expect(setCellCssStylesCalls).toHaveLength(1);
+ expect(setCellCssStylesCalls[0]).toEqual({
+ 0: { d: 'formula-cell-color-1' },
+ 1: { d: 'formula-cell-color-1' },
+ });
+
+ const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ startCellElm.dispatchEvent(mouseDownEvent);
+ expect(mouseDownEvent.defaultPrevented).toBe(true);
+
+ const mouseMoveEvent = new MouseEvent('mousemove', { bubbles: true, cancelable: true, button: 0 });
+ endCellElm.dispatchEvent(mouseMoveEvent);
+ expect(mouseMoveEvent.defaultPrevented).toBe(true);
+
+ const mouseUpEvent = new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 });
+ endCellElm.dispatchEvent(mouseUpEvent);
+ expect(mouseUpEvent.defaultPrevented).toBe(true);
+ expect(editor.serializeValue()).toBe('=SUM(E1:E3)');
+
+ const updatedSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0];
+ expect(updatedSelectionRange).toMatchObject({ fromRow: 0, fromCell: 4, toRow: 2, toCell: 4 });
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should restore pre-existing multi-ranges after its temporary formula highlight', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.append(hostContainer, gridContainer);
+ const originalRanges = [new SlickRange(1, 1), new SlickRange(3, 2, 4, 3)];
+ let selectedRanges = originalRanges;
+ const setSelectedRanges = vi.fn((ranges: SlickRange[]) => {
+ selectedRanges = ranges;
+ });
+ const selectionModelStub = {
+ getSelectedRanges: () => selectedRanges,
+ setSelectedRanges,
+ };
+ const gridStub = {
+ focus: () => undefined,
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ getSelectionModel: () => selectionModelStub,
+ removeCellCssStyles: vi.fn(),
+ setCellCssStyles: vi.fn(),
+ } as any;
+ const editor = new FormulaCellEditor({
+ column: { field: 'total' },
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1+B1' },
+ } as any);
+ editor.loadValue({ total: '=A1+B1' });
+
+ (editor as any).renderSelectionModelHighlight({ row: 0, cell: 0 }, { row: 0, cell: 0 });
+ (editor as any).renderSelectionModelHighlight({ row: 0, cell: 1 }, { row: 2, cell: 1 });
+ expect(selectedRanges).toEqual([new SlickRange(0, 1, 2, 1)]);
+
+ (editor as any).clearReferenceSelectionHighlight();
+
+ expect(selectedRanges).toEqual(originalRanges);
+ expect(selectedRanges).not.toBe(originalRanges);
+ expect(setSelectedRanges).toHaveBeenLastCalledWith(
+ [new SlickRange(1, 1), new SlickRange(3, 2, 4, 3)],
+ 'FormulaCellEditor.clearReferenceSelectionHighlight',
+ ''
+ );
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should keep existing range anchor when dragging from range endpoint to expand selection', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const cellMap = new Map();
+
+ const rangeEndCellElm = document.createElement('div');
+ rangeEndCellElm.className = 'slick-cell';
+ gridContainer.appendChild(rangeEndCellElm);
+ cellMap.set(rangeEndCellElm, { row: 2, cell: 3 }); // D3
+
+ const dragEndCellElm = document.createElement('div');
+ dragEndCellElm.className = 'slick-cell';
+ gridContainer.appendChild(dragEndCellElm);
+ cellMap.set(dragEndCellElm, { row: 5, cell: 3 }); // D6
+
+ const selectionRangesCalls: Array> = [];
+ const selectionModelStub = {
+ setSelectedRanges: (ranges: Array<{ fromRow: number; fromCell: number; toRow: number; toCell: number }>) => {
+ selectionRangesCalls.push(ranges);
+ },
+ };
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 3 }),
+ getCellFromEvent: (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ return target ? (cellMap.get(target) ?? null) : null;
+ },
+ getColumns: () => ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id })),
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ getSelectionModel: () => selectionModelStub,
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { debug: false } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=SUM(D1:D3)' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Place caret in D1:D3 token so it is selected as the editable reference range.
+ (editor as any).restoreCaretOffset(7);
+ (editor as any)._editorElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+
+ rangeEndCellElm.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+ dragEndCellElm.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, button: 0 }));
+ dragEndCellElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
+
+ expect(editor.serializeValue()).toBe('=SUM(D1:D6)');
+ expect(editor.serializeValue().startsWith('=')).toBe(true);
+
+ const updatedSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0];
+ expect(updatedSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 5, toCell: 3 });
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should keep Ctrl+A in editor and not bubble to grid keyboard handlers', () => {
+ const gridContainer = document.createElement('div');
+ const hostContainer = document.createElement('div');
+ gridContainer.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ let gridKeydownCount = 0;
+ gridContainer.addEventListener('keydown', () => {
+ gridKeydownCount++;
+ });
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { debug: false } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=C1*D1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ const keydownEvent = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'a',
+ ctrlKey: true,
+ });
+ (editor as any)._editorElm.dispatchEvent(keydownEvent);
+
+ expect(gridKeydownCount).toBe(0);
+ expect(keydownEvent.defaultPrevented).toBe(false);
+
+ editor.destroy();
+ gridContainer.remove();
+ });
+
+ it('should append a second grid reference after an operator instead of replacing the first argument', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const cellMap = new Map();
+ const c1CellElm = document.createElement('div');
+ c1CellElm.className = 'slick-cell';
+ gridContainer.appendChild(c1CellElm);
+ cellMap.set(c1CellElm, { row: 0, cell: 2 });
+
+ const d1CellElm = document.createElement('div');
+ d1CellElm.className = 'slick-cell';
+ gridContainer.appendChild(d1CellElm);
+ cellMap.set(d1CellElm, { row: 0, cell: 3 });
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ return target ? (cellMap.get(target) ?? null) : null;
+ },
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=SUM(' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+ (editor as any).restoreCaretOffset(5);
+ (editor as any)._referenceEditRange = undefined;
+ expect((editor as any).resolveReferenceEditRangeForGridSelection()).toEqual({ start: 5, end: 5 });
+ vi.spyOn(editor as any, 'resolveReferenceEditRangeForGridSelection').mockReturnValue(undefined);
+
+ c1CellElm.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+ c1CellElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
+ c1CellElm.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
+ expect(editor.serializeValue()).toBe('=SUM(C1');
+
+ (editor as any)._editorElm.textContent = '=SUM(C1*';
+ (editor as any).restoreCaretOffset(8);
+ (editor as any)._editorElm.dispatchEvent(new Event('input', { bubbles: true }));
+ expect(editor.serializeValue()).toBe('=SUM(C1*');
+
+ d1CellElm.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+ d1CellElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
+ d1CellElm.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
+
+ expect(editor.serializeValue()).toBe('=SUM(C1*D1');
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should provide autocomplete suggestions and insert selected function on Enter', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM', 'SUMIF'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ (editor as any)._editorElm.textContent = '=su';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).handleInput();
+
+ expect((editor as any)._autocompleteItems).toEqual(['SUM', 'SUMIF']);
+ expect((editor as any)._autocompleteElm?.style.display).toBe('block');
+
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+
+ expect(editor.serializeValue()).toBe('=SUMIF(');
+ expect((editor as any)._autocompleteItems).toHaveLength(0);
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should fallback to cell-css highlighting when no selection model is available', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const setCellCssStylesSpy = vi.fn();
+ const removeCellCssStylesSpy = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: removeCellCssStylesSpy,
+ setCellCssStyles: setCellCssStylesSpy,
+ getSelectionModel: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=SUM(B1:C2)' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+ (editor as any).restoreCaretOffset(8);
+ // Call handleInput directly to simulate user typing, which applies colors
+ (editor as any).handleInput();
+
+ expect(setCellCssStylesSpy).toHaveBeenCalledTimes(1);
+ const cssHash = setCellCssStylesSpy.mock.calls[0][1] as Record>;
+ expect(cssHash[0].b).toBe('formula-cell-color-1');
+ expect(cssHash[0].c).toBe('formula-cell-color-1');
+ expect(cssHash[1].b).toBe('formula-cell-color-1');
+ expect(cssHash[1].c).toBe('formula-cell-color-1');
+ expect(removeCellCssStylesSpy).not.toHaveBeenCalled();
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should handle autocomplete selection edge cases safely', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // guard branch when menu element is absent
+ (editor as any)._autocompleteElm = undefined;
+ (editor as any)._autocompleteItems = ['SUM'];
+ (editor as any).renderAutocompleteItems();
+
+ (editor as any)._editorElm.textContent = '=A1+1';
+ (editor as any).restoreCaretOffset(5);
+ const beforeInvalid = editor.serializeValue();
+ (editor as any).selectAutocompleteItem();
+ (editor as any).selectAutocompleteItem('SUM');
+ expect(editor.serializeValue()).toBe(beforeInvalid);
+
+ (editor as any)._editorElm.textContent = '=zz';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).handleInput();
+ expect((editor as any)._autocompleteItems).toHaveLength(0);
+
+ (editor as any).ensureAutocompleteElement();
+ const existingAutocompleteElm = (editor as any)._autocompleteElm;
+ (editor as any).ensureAutocompleteElement();
+ expect((editor as any)._autocompleteElm).toBe(existingAutocompleteElm);
+
+ (editor as any)._autocompleteElm = undefined;
+ (editor as any).positionAutocomplete();
+
+ (editor as any)._editorElm.textContent = '=su (A1)';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).handleInput();
+ const firstOption = (editor as any)._autocompleteElm?.querySelector('div') as HTMLDivElement;
+ firstOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ expect(editor.serializeValue()).toBe('=SUM (A1)');
+
+ (editor as any)._editorElm.textContent = '=su (A1)';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).selectAutocompleteItem('SUM');
+ expect(editor.serializeValue()).toBe('=SUM (A1)');
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should apply persistent cell colors on initial load', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const setCellCssStylesSpy = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: setCellCssStylesSpy,
+ getSelectionModel: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=SUM(B1:C2)' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ expect(setCellCssStylesSpy).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, {
+ 0: { b: 'formula-cell-color-1', c: 'formula-cell-color-1' },
+ 1: { b: 'formula-cell-color-1', c: 'formula-cell-color-1' },
+ });
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should clear formula colors when destroyed without a keyboard exit', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.append(hostContainer, gridContainer);
+ const removeCellCssStyles = vi.fn();
+ const setCellCssStyles = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles,
+ setCellCssStyles,
+ } as any;
+ const editor = new FormulaCellEditor({
+ column: { field: 'total' },
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ } as any);
+ editor.loadValue({ total: '=A1' });
+
+ (editor as any)._editorElm.textContent = '=A1+1';
+ (editor as any).handleInput();
+ expect(setCellCssStyles).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, { 0: { a: 'formula-cell-color-1' } });
+
+ editor.destroy();
+
+ expect((editor as any)._isExitingEditor).toBe(false);
+ expect(removeCellCssStyles).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should copy and cut plain text from editor DOM on Ctrl+C/Ctrl+X', async () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const writeTextSpy = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: writeTextSpy },
+ configurable: true,
+ });
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ getSelectionModel: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ (editor as any)._editorElm.textContent = '=SUM(A1\u00a0+\u00a0B1)';
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }));
+
+ expect(writeTextSpy).toHaveBeenNthCalledWith(1, '=SUM(A1 + B1)');
+ expect((editor as any)._editorElm.textContent).toBe('=SUM(A1\u00a0+\u00a0B1)');
+
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true }));
+
+ expect(writeTextSpy).toHaveBeenNthCalledWith(2, '=SUM(A1 + B1)');
+ expect(editor.serializeValue()).toBe('');
+
+ writeTextSpy.mockRejectedValueOnce(new Error('clipboard unavailable'));
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }));
+ writeTextSpy.mockRejectedValueOnce(new Error('clipboard unavailable'));
+ (editor as any)._editorElm.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true }));
+
+ await Promise.resolve();
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should use live editor DOM text when selecting autocomplete item', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Keep stale internal value to ensure selection logic reads from DOM textContent.
+ (editor as any)._plainTextValue = '=A1';
+ (editor as any)._editorElm.textContent = '=su';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).selectAutocompleteItem('SUM');
+
+ expect(editor.serializeValue()).toBe('=SUM(');
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should replace typed function name at caret in middle of formula and preserve surrounding text', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: () => undefined,
+ setCellCssStyles: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM', 'SUMIF'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1+B1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Keep stale internal value to ensure replacement is driven by current DOM text.
+ (editor as any)._plainTextValue = '=A1+OLD(B1)+C1';
+ (editor as any)._editorElm.textContent = '=A1+su(B1)+C1';
+ (editor as any).restoreCaretOffset(6); // right after "su"
+ (editor as any).selectAutocompleteItem('SUM');
+
+ expect(editor.serializeValue()).toBe('=A1+SUM(B1)+C1');
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should not mutate grid styles when no selection highlight is active', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.appendChild(hostContainer);
+ document.body.appendChild(gridContainer);
+
+ const removeCellCssStylesSpy = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: removeCellCssStylesSpy,
+ setCellCssStyles: () => undefined,
+ getSelectionModel: () => undefined,
+ } as any;
+
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ (editor as any)._isSelectionModelHighlightActive = false;
+ (editor as any).clearReferenceSelectionHighlight();
+
+ expect(removeCellCssStylesSpy).not.toHaveBeenCalled();
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should cover persistent color cleanup and caret guards', () => {
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.append(hostContainer, gridContainer);
+ const removeCellCssStylesSpy = vi.fn();
+ const setCellCssStylesSpy = vi.fn();
+ const gridStub = {
+ focus: () => undefined,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => true }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ removeCellCssStyles: removeCellCssStylesSpy,
+ setCellCssStyles: setCellCssStylesSpy,
+ getSelectionModel: () => undefined,
+ } as any;
+ const args = {
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: () => undefined,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: () => undefined,
+ } as unknown as EditorArguments;
+ const editor = new FormulaCellEditor(args);
+ editor.loadValue((args as any).item);
+
+ // Reapply colors after an existing cache so both cleanup paths are exercised.
+ (editor as any)._editorElm.textContent = '=B1';
+ (editor as any).handleInput();
+ (editor as any)._editorElm.textContent = '=1';
+ (editor as any).handleInput();
+ (editor as any)._editorElm.textContent = '=A1';
+ (editor as any).handleInput();
+
+ (editor as any)._editorElm.textContent = '=Z1';
+ (editor as any).handleInput();
+
+ expect((editor as any).parseExcelReferenceCellRange('')).toBeUndefined();
+ expect((editor as any).parseExcelReferenceCellRange('A0')).toBeUndefined();
+ expect((editor as any).parseExcelReferenceCellRange('A1:B')).toBeUndefined();
+ (editor as any)._plainTextValue = '=A1';
+ (editor as any)._editorElm.textContent = '=A1';
+ (editor as any).restoreCaretOffset(3);
+ expect((editor as any).getReferenceTokenRangeAtCaret()).toEqual({ start: 1, end: 3 });
+ expect((editor as any).resolveReferenceEditRangeForGridSelection()).toEqual({ start: 1, end: 3 });
+ (editor as any)._referenceEditRange = { start: 0, end: 0 };
+ expect((editor as any).resolveReferenceEditRangeForGridSelection()).toEqual({ start: 1, end: 3 });
+ (editor as any)._plainTextValue = 'plain';
+ (editor as any)._editorElm.textContent = 'plain';
+ (editor as any).restoreCaretOffset(5);
+ expect((editor as any).shouldInsertReferenceAtCaret()).toBe(false);
+ expect((editor as any).getSingleReferenceTokenRangeOrUndefined()).toBeUndefined();
+ (editor as any)._plainTextValue = '=A1+B1';
+ (editor as any)._editorElm.textContent = '=A1+B1';
+ expect((editor as any).getSingleReferenceTokenRangeOrUndefined()).toBeUndefined();
+ (editor as any)._plainTextValue = '=A1';
+ (editor as any)._editorElm.textContent = '=A1';
+ (editor as any).restoreCaretOffset(1);
+ expect((editor as any).shouldInsertReferenceAtCaret()).toBe(true);
+ expect((editor as any).getSingleReferenceTokenRangeOrUndefined()).toEqual({ start: 1, end: 3 });
+ (editor as any)._plainTextValue = '= ';
+ (editor as any)._editorElm.textContent = '= ';
+ (editor as any).restoreCaretOffset(4);
+ expect((editor as any).shouldInsertReferenceAtCaret()).toBe(true);
+ expect((editor as any).resolveReferenceSelectionAnchorCell({ row: 0, cell: 0 }, { startCell: { row: 0, cell: 0 }, endCell: { row: 1, cell: 1 } })).toEqual({
+ row: 1,
+ cell: 1,
+ });
+ (editor as any)._formulaRefColorCache.markClean();
+ (editor as any).applyFormulaReferenceCellColors();
+
+ (editor as any)._isExitingEditor = true;
+ (editor as any).clearReferenceSelectionHighlight();
+ expect(removeCellCssStylesSpy).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+
+ (editor as any)._isExitingEditor = false;
+ (editor as any).restoreCaretOffset(999);
+ (editor as any)._isDestroyed = true;
+ (editor as any).restoreCaretOffset(0);
+ (editor as any)._isDestroyed = false;
+ const selectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue(null);
+ (editor as any).restoreCaretOffset(0);
+ selectionSpy.mockRestore();
+ (editor as any)._editorElm.textContent = 'plain text';
+ (editor as any).updateAutocomplete();
+
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ });
+
+ it('should cover paste, keyboard navigation, and editor lifecycle guards', () => {
+ vi.useFakeTimers();
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ document.body.append(hostContainer, gridContainer);
+ const focusSpy = vi.fn();
+ const commitCurrentEdit = vi.fn(() => true);
+ const navigateNext = vi.fn();
+ const navigatePrev = vi.fn();
+ const cancelChanges = vi.fn();
+ const gridStub = {
+ focus: focusSpy,
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => null,
+ getColumns: () => [{ id: 'a' }, { id: 'b' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ navigateNext,
+ navigatePrev,
+ removeCellCssStyles: vi.fn(),
+ setCellCssStyles: vi.fn(),
+ } as any;
+
+ const editor = new FormulaCellEditor({
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges: vi.fn(),
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges,
+ } as unknown as EditorArguments);
+ editor.loadValue({ total: '=A1' });
+
+ editor.focus();
+ expect(editor.validate()).toEqual({ valid: true, msg: '' });
+ expect(editor.isValueChanged()).toBe(false);
+
+ const execCommandSpy = vi.fn().mockReturnValue(true);
+ Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommandSpy });
+ (editor as any).handlePaste({
+ preventDefault: vi.fn(),
+ clipboardData: { getData: () => '+B1' },
+ });
+ expect(execCommandSpy).toHaveBeenCalledWith('insertText', false, '+B1');
+
+ (editor as any)._editorElm.textContent = '=su';
+ (editor as any).restoreCaretOffset(3);
+ (editor as any).handleInput();
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true }));
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
+
+ (editor as any)._autocompleteItems = [];
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true }));
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ expect(commitCurrentEdit).toHaveBeenCalledTimes(1);
+ editor.destroy();
+
+ const invalidRangeEditor = new FormulaCellEditor({
+ column: { field: 'total' },
+ commitChanges: vi.fn(),
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1:B' },
+ cancelChanges: vi.fn(),
+ } as unknown as EditorArguments);
+ invalidRangeEditor.loadValue({ total: '=A1:B' });
+ (invalidRangeEditor as any).restoreCaretOffset(5);
+ (invalidRangeEditor as any).handleInput();
+ (invalidRangeEditor as any)._referenceEditRange = undefined;
+ (invalidRangeEditor as any)._plainTextValue = '=1+A1';
+ (invalidRangeEditor as any)._editorElm.textContent = '=1+A1';
+ (invalidRangeEditor as any).restoreCaretOffset(2);
+ expect((invalidRangeEditor as any).resolveReferenceEditRangeForGridSelection()).toEqual({ start: 3, end: 5 });
+ invalidRangeEditor.destroy();
+
+ // A newly opened editor from Tab ignores the initial untouched Tab blur.
+ const tabEditor = new FormulaCellEditor({
+ event: new KeyboardEvent('keydown', { key: 'Tab' }),
+ column: { field: 'total' },
+ commitChanges: vi.fn(),
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: vi.fn(),
+ } as unknown as EditorArguments);
+ tabEditor.loadValue({ total: '=A1' });
+ (tabEditor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }));
+ expect(navigateNext).not.toHaveBeenCalled();
+ tabEditor.destroy();
+
+ // A changed editor commits and navigates in both directions after the timer.
+ const navigateEditor = new FormulaCellEditor({
+ column: { field: 'total' },
+ commitChanges: vi.fn(),
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges,
+ } as unknown as EditorArguments);
+ navigateEditor.loadValue({ total: '=A1' });
+ (navigateEditor as any)._isValueTouched = true;
+ (navigateEditor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true }));
+ vi.runAllTimers();
+ expect(navigatePrev).toHaveBeenCalled();
+ navigateEditor.destroy();
+
+ const escapeEditor = new FormulaCellEditor({
+ column: { field: 'total' },
+ commitChanges: vi.fn(),
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges,
+ } as unknown as EditorArguments);
+ escapeEditor.loadValue({ total: '=A1' });
+ (escapeEditor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
+ expect(cancelChanges).toHaveBeenCalled();
+ escapeEditor.destroy();
+
+ delete (document as any).execCommand;
+ hostContainer.remove();
+ gridContainer.remove();
+ vi.useRealTimers();
+ });
+
+ it('should cover focus, commit fallback, pointer guards, and reference-sync cleanup', () => {
+ vi.useFakeTimers();
+ const hostContainer = document.createElement('div');
+ const gridContainer = document.createElement('div');
+ const gridCell = document.createElement('div');
+ gridContainer.appendChild(gridCell);
+ document.body.append(hostContainer, gridContainer);
+ let commitResult = false;
+ let eventCell: { row: number; cell: number } | null = null;
+ const commitChanges = vi.fn();
+ const navigateNext = vi.fn();
+ const gridStub = {
+ focus: vi.fn(),
+ getActiveCell: () => ({ row: 0, cell: 0 }),
+ getCellFromEvent: () => eventCell,
+ getColumns: () => [{ id: 'a' }],
+ getContainerNode: () => gridContainer,
+ getEditorLock: () => ({ commitCurrentEdit: () => commitResult }),
+ getOptions: () => ({ editorNavigateOnArrows: false }),
+ navigateNext,
+ navigatePrev: vi.fn(),
+ removeCellCssStyles: vi.fn(),
+ setCellCssStyles: vi.fn(),
+ } as any;
+ const editor = new FormulaCellEditor({
+ column: { field: 'total', editor: { params: { formulaFunctionList: ['SUM'] } } },
+ commitChanges,
+ container: hostContainer,
+ grid: gridStub,
+ item: { total: '=A1' },
+ cancelChanges: vi.fn(),
+ } as unknown as EditorArguments);
+ editor.loadValue({ total: '=A1' });
+
+ (editor as any).handleFocusIn();
+ (editor as any)._initialLoadComplete = true;
+ (editor as any).handleFocusIn();
+ (editor as any).handleEditorKeyUp();
+ (editor as any).handleEditorMouseUp();
+ (editor as any).handleFocusOut(new FocusEvent('focusout', { relatedTarget: null }));
+ vi.runAllTimers();
+ (editor as any)._isExitingEditor = true;
+ (editor as any).handleFocusOut(new FocusEvent('focusout', { relatedTarget: null }));
+ (editor as any)._isExitingEditor = false;
+ (editor as any)._suppressInitialTabBlur = false;
+ (editor as any).ensureAutocompleteElement();
+ (editor as any).handleFocusOut(new FocusEvent('focusout', { relatedTarget: null }));
+ (editor as any)._suppressInitialTabBlur = true;
+ (editor as any)._isValueTouched = false;
+ (editor as any).handleFocusOut(new FocusEvent('focusout', { relatedTarget: null }));
+ (editor as any)._isDestroyed = true;
+ vi.runAllTimers();
+ (editor as any)._isDestroyed = false;
+
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }));
+ expect(commitChanges).toHaveBeenCalled();
+ (editor as any)._isExitingEditor = false;
+ (editor as any)._isValueTouched = true;
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }));
+ vi.runAllTimers();
+
+ commitResult = true;
+ (editor as any)._isExitingEditor = false;
+ (editor as any)._isValueTouched = true;
+ (editor as any).handleKeydown(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }));
+ vi.runAllTimers();
+ expect(navigateNext).toHaveBeenCalled();
+
+ (editor as any)._plainTextValue = 'plain';
+ (editor as any)._editorElm.textContent = 'plain';
+ (editor as any).syncReferenceSelectionFromCaret();
+ expect((editor as any).getReferenceTokenRangeAtCaret()).toEqual({ start: 0, end: 0 });
+
+ const gridTargetEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ Object.defineProperty(gridTargetEvent, 'target', { configurable: true, value: gridCell });
+ (editor as any)._isExitingEditor = false;
+ (editor as any)._plainTextValue = '=A1';
+ eventCell = { row: -1, cell: -1 };
+ (editor as any).handleWindowMouseDown(gridTargetEvent);
+ eventCell = null;
+ (editor as any)._plainTextValue = 'plain';
+ (editor as any).handleWindowMouseDown(gridTargetEvent);
+ (editor as any)._plainTextValue = '=A1';
+ (editor as any)._gridContainerElm = (editor as any)._editorElm;
+ const editorTarget = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ Object.defineProperty(editorTarget, 'target', { configurable: true, value: (editor as any)._editorElm });
+ (editor as any).handleWindowMouseDown(editorTarget);
+ (editor as any)._gridContainerElm = gridContainer;
+ gridContainer.appendChild((editor as any)._autocompleteElm);
+ const autocompleteTarget = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ Object.defineProperty(autocompleteTarget, 'target', { configurable: true, value: (editor as any)._autocompleteElm });
+ (editor as any).handleWindowMouseDown(autocompleteTarget);
+
+ const selectionSpy = vi.spyOn(window, 'getSelection').mockReturnValue(null);
+ (editor as any).setCursorAtEnd();
+ selectionSpy.mockRestore();
+
+ (editor as any)._editorElm.remove();
+ expect((editor as any).shouldCaptureGridReferenceSelection(gridTargetEvent)).toBe(false);
+
+ (editor as any)._plainTextValue = '=A1';
+ (editor as any)._editorElm.textContent = '=A1';
+ (editor as any).restoreCaretOffset(1);
+ const editorTargetEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 });
+ (editor as any).handleWindowMouseDown(editorTargetEvent);
+ (editor as any)._autocompleteElm = document.createElement('div');
+ (editor as any).handleWindowMouseDown(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+
+ gridCell.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
+ (editor as any)._isDraggingGridRefSelection = true;
+ (editor as any)._referenceRangeAnchorCell = { row: 0, cell: 0 };
+ gridCell.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, button: 0 }));
+ gridCell.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
+ vi.runAllTimers();
+
+ (editor as any).setCursorAtEnd();
+ editor.destroy();
+ hostContainer.remove();
+ gridContainer.remove();
+ vi.useRealTimers();
+ });
+});
diff --git a/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts b/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts
new file mode 100644
index 000000000..45d695cb4
--- /dev/null
+++ b/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts
@@ -0,0 +1,287 @@
+import { SlickRange } from '@slickgrid-universal/common';
+import type { Column } from '@slickgrid-universal/common';
+import { describe, expect, it, vi } from 'vitest';
+import { getFillSeriesValue, handleFormulaDragFill, type FormulaDragFillContext } from '../formula.drag-fill.js';
+
+describe('formula drag-fill', () => {
+ it('should copy one value, continue numeric ranges, and repeat mixed ranges', () => {
+ expect(getFillSeriesValue([4], 3)).toBe(4);
+ expect(getFillSeriesValue([1, 3], 4)).toBe(9);
+ expect(getFillSeriesValue([5, 7], -2)).toBe(1);
+ expect(getFillSeriesValue(['10', '20'], 2)).toBe(30);
+ expect(getFillSeriesValue(['A', 'B'], 4)).toBe('A');
+ expect(getFillSeriesValue([1, 'x'], 3)).toBe('x');
+ expect(getFillSeriesValue([], 0)).toBeUndefined();
+ });
+
+ it('should assign drag-filled values to a __proto__ field without changing the row prototype', () => {
+ const columns: Column[] = [{ id: '__proto__', field: '__proto__', allowFormula: true }];
+ const sourceValue = { copied: true };
+ const sourceItem: Record = { id: 'r1' };
+ Object.defineProperty(sourceItem, '__proto__', { configurable: true, enumerable: true, value: sourceValue, writable: true });
+ const targetItem: Record = { id: 'r2' };
+ const items = [sourceItem, targetItem];
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({}),
+ } as any;
+
+ handleFormulaDragFill({ grid, prevSelectedRange: new SlickRange(0, 0), selectedRange: new SlickRange(0, 0, 1, 0) } as any, {
+ grid,
+ dataView: { updateItems: vi.fn() } as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ });
+
+ expect(Object.prototype.hasOwnProperty.call(targetItem, '__proto__')).toBe(true);
+ expect(targetItem.__proto__).toBe(sourceValue);
+ expect(Object.getPrototypeOf(targetItem)).toBe(Object.prototype);
+ });
+
+ it('should infer a vertical numeric series only in formula-enabled columns', () => {
+ const columns: Column[] = [
+ { id: 'series', field: 'series', allowFormula: true },
+ { id: 'ordinary', field: 'ordinary' },
+ ];
+ const items = [
+ { id: 'r1', series: 1, ordinary: 10 },
+ { id: 'r2', series: 3, ordinary: 20 },
+ { id: 'r3', series: 0, ordinary: 30 },
+ { id: 'r4', series: 0, ordinary: 40 },
+ ];
+ const updateItems = vi.fn();
+ const setFormula = vi.fn();
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+ const context: FormulaDragFillContext = {
+ grid,
+ dataView: { updateItems } as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula,
+ toStoredFormula: (formula) => formula,
+ toDisplayFormulaForCell: (formula) => formula,
+ };
+
+ handleFormulaDragFill(
+ {
+ grid,
+ prevSelectedRange: new SlickRange(0, 0, 1, 1),
+ selectedRange: new SlickRange(0, 0, 3, 1),
+ } as any,
+ context
+ );
+
+ expect(items.map((item) => item.series)).toEqual([1, 3, 5, 7]);
+ expect(items.map((item) => item.ordinary)).toEqual([10, 20, 30, 40]);
+ expect(setFormula).toHaveBeenCalledWith('r3', 'series', null);
+ expect(setFormula).toHaveBeenCalledWith('r4', 'series', null);
+ expect(updateItems).toHaveBeenCalledOnce();
+ });
+
+ it('should infer horizontal numeric series and repeat string values', () => {
+ const columns: Column[] = [
+ { id: 'a', field: 'a', allowFormula: true },
+ { id: 'b', field: 'b', allowFormula: true },
+ { id: 'c', field: 'c', allowFormula: true },
+ { id: 'd', field: 'd', allowFormula: true },
+ ];
+ const numericItems = [{ id: 1, a: 10, b: 7, c: 0, d: 0 }];
+ const stringItems = [{ id: 1, a: 'A', b: 'B', c: '', d: '' }];
+
+ const fill = (items: any[]) => {
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({}),
+ } as any;
+ handleFormulaDragFill(
+ {
+ grid,
+ prevSelectedRange: new SlickRange(0, 0, 0, 1),
+ selectedRange: new SlickRange(0, 0, 0, 3),
+ } as any,
+ {
+ grid,
+ dataView: {} as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ }
+ );
+ };
+
+ fill(numericItems);
+ fill(stringItems);
+
+ expect(numericItems[0]).toEqual({ id: 1, a: 10, b: 7, c: 4, d: 1 });
+ expect(stringItems[0]).toEqual({ id: 1, a: 'A', b: 'B', c: 'A', d: 'B' });
+ });
+
+ it('should not drag a formula into a column that does not allow formulas', () => {
+ const columns: Column[] = [
+ { id: 'formula', field: 'formula', allowFormula: true },
+ { id: 'ordinary', field: 'ordinary' },
+ ];
+ const items = [{ id: 1, formula: '=A1', ordinary: 'unchanged' }];
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({ dataItemColumnValueExtractor: (item: any, column: Column) => item[column.field as string] }),
+ } as any;
+
+ handleFormulaDragFill(
+ {
+ grid,
+ prevSelectedRange: new SlickRange(0, 0),
+ selectedRange: new SlickRange(0, 0, 0, 1),
+ } as any,
+ {
+ grid,
+ dataView: {} as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: (_rowId, columnId) => (columnId === 'formula' ? '=A1' : undefined),
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ }
+ );
+
+ expect(items[0].ordinary).toBe('unchanged');
+ });
+
+ it('should ignore incomplete drag ranges and rows without dataset ids', () => {
+ const noVisibleColumnsContext = {
+ grid: {} as any,
+ dataView: {} as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ } as FormulaDragFillContext;
+
+ expect(() => handleFormulaDragFill({} as any, noVisibleColumnsContext)).not.toThrow();
+
+ const columns: Column[] = [{ id: 'value', field: 'value', allowFormula: true }];
+ const setFormula = vi.fn();
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: () => ({ value: 1 }),
+ getOptions: () => ({}),
+ } as any;
+ const context = { ...noVisibleColumnsContext, grid, setFormula };
+
+ handleFormulaDragFill({ grid, prevSelectedRange: new SlickRange(0, 0), selectedRange: new SlickRange(0, 0) } as any, context);
+
+ handleFormulaDragFill({ grid, prevSelectedRange: new SlickRange(0, 0), selectedRange: new SlickRange(0, 0, 1, 0) } as any, context);
+
+ expect(setFormula).not.toHaveBeenCalled();
+ });
+
+ it('should use the updateItem fallback and data extractors while skipping hidden source values', () => {
+ const columns: Column[] = [
+ { id: 'source', field: 'source', hidden: true, allowFormula: true },
+ { id: 'target', field: 'target', allowFormula: true },
+ ];
+ const items = [
+ { id: 'r1', source: 2, target: 0 },
+ { id: 'r2', source: 4, target: 0 },
+ ];
+ const updateItem = vi.fn();
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({ dataItemColumnValueExtractor: (item: any, column: Column) => item[column.field as string] }),
+ } as any;
+
+ handleFormulaDragFill(
+ {
+ grid,
+ prevSelectedRange: new SlickRange(0, 0),
+ selectedRange: new SlickRange(0, 0, 1, 0),
+ } as any,
+ {
+ grid,
+ dataView: { updateItem } as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ }
+ );
+
+ expect(updateItem).toHaveBeenCalledWith('r2', items[1]);
+ expect(items[1].source).toBeUndefined();
+ });
+
+ it('should skip formula fills when visible columns are not present in the full column list', () => {
+ const columns: Column[] = [{ id: 'formula', field: 'formula', allowFormula: true }];
+ const items = [
+ { id: 'r1', formula: '=A1' },
+ { id: 'r2', formula: '' },
+ ];
+ const setFormula = vi.fn();
+ const grid = {
+ getColumns: () => [],
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({ dataItemColumnValueExtractor: (item: any, column: Column) => item[column.field as string] }),
+ } as any;
+
+ handleFormulaDragFill({ grid, prevSelectedRange: new SlickRange(0, 0), selectedRange: new SlickRange(0, 0, 1, 0) } as any, {
+ grid,
+ dataView: {} as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => '=A1',
+ setFormula,
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ });
+
+ expect(setFormula).not.toHaveBeenCalled();
+ });
+
+ it('should handle corner fills and non-numeric seed values', () => {
+ const columns: Column[] = [
+ { id: 'a', field: 'a', allowFormula: true },
+ { id: 'b', field: 'b', allowFormula: true },
+ ];
+ const items = Array.from({ length: 3 }, (_unused, id) => ({ id, a: id + 1, b: id + 10 }));
+ const grid = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({}),
+ } as any;
+
+ handleFormulaDragFill({ grid, prevSelectedRange: new SlickRange(1, 1), selectedRange: new SlickRange(0, 0, 2, 1) } as any, {
+ grid,
+ dataView: {} as any,
+ getDatasetIdPropertyName: () => 'id',
+ getFormula: () => undefined,
+ setFormula: vi.fn(),
+ toStoredFormula: (formula: string) => formula,
+ toDisplayFormulaForCell: (formula: string) => formula,
+ });
+
+ expect(getFillSeriesValue([Number.NaN, 2], 1)).toBe(2);
+ expect(getFillSeriesValue(['', 2], 1)).toBe(2);
+ });
+});
diff --git a/packages/formula-plugin/src/__tests__/formula.service.spec.ts b/packages/formula-plugin/src/__tests__/formula.service.spec.ts
new file mode 100644
index 000000000..2babf6c92
--- /dev/null
+++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts
@@ -0,0 +1,1562 @@
+import { Formatters, SlickEvent, SlickRange } from '@slickgrid-universal/common';
+import type { Column, FormulaExcelExportContext } from '@slickgrid-universal/common';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FORMULA_ERROR } from '../formula-errors.js';
+import { FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY } from '../formula-reference.js';
+import { FormulaCellEditor } from '../formula.cellEditor.js';
+import { translateFormulaReferences } from '../formula.drag-fill.js';
+import { FormulaService } from '../formula.service.js';
+
+describe('FormulaService', () => {
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('should expose and merge service options', () => {
+ const service = new FormulaService({ autoAssignEditor: false });
+
+ expect(service.getOptions()).toEqual({ autoAssignEditor: false });
+ service.setOptions({ enableExcelHeaderPrefix: false });
+
+ expect(service.getOptions()).toEqual({ autoAssignEditor: false, enableExcelHeaderPrefix: false });
+ });
+
+ it('should cover service lifecycle and conversion guard paths', () => {
+ const columns: Column[] = [{ id: 'value', field: 'value', allowFormula: true }];
+ const items = [{ id: 'r1', value: '=A1' }];
+ const onDragReplaceCells = new SlickEvent();
+ const gridStub = {
+ onDragReplaceCells,
+ getColumns: () => columns,
+ setColumns: (nextColumns: Column[]) => columns.splice(0, columns.length, ...nextColumns),
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id', enableSelection: true, selectionOptions: { selectionType: 'mixed' } }),
+ setCellCssStyles: vi.fn(),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ service.clearFormulaReferenceHighlights();
+ service.renderFormulaReferenceHighlights();
+ service.renderFormulaReferenceHighlights('=Z1');
+ expect(service.extractExcelReferences('=A1')).toEqual([{ col: 'A', row: 1 }]);
+ service.clearFormulas();
+ expect(service.hasFormula('r1', 'value')).toBe(false);
+ service.setFormula('r1', 'value', '=A1');
+ expect(service.removeFormula('r1', 'value')).toBe(true);
+ expect(service.unregisterCustomFunction('missing')).toBe(false);
+ expect(service.getExcelFormula({ rowId: 'r1', columnId: 'value', columnIds: ['value'], rowIds: ['r1'], excelRowOffset: 1 } as any)).toBeUndefined();
+ expect(service.getExcelDefinedNames()).toEqual([]);
+ expect(service.getExcelCustomFunctions()).toEqual([]);
+ expect((service as any).shiftDirectExcelReferences('', [], [], 0)).toBe('');
+
+ (service as any)._dataView = { getItems: () => items };
+ expect((service as any).getDatasetLength()).toBe(1);
+ (service as any)._dataView = {};
+ expect((service as any).getDatasetLength()).toBe(0);
+
+ const fallbackService = new FormulaService();
+ expect(fallbackService.getEvaluatedCellValue('missing', 'value', 42)).toBe(42);
+ const noColumnsService = new FormulaService();
+ noColumnsService.init({ getColumns: () => [], getData: () => ({ getItems: () => [], getLength: () => 0 }), getOptions: () => ({}) } as any);
+ noColumnsService.syncFormulasFromDataset();
+
+ const missingIdService = new FormulaService();
+ missingIdService.init({
+ getColumns: () => [{ id: 'value', field: 'value', allowFormula: true }],
+ getData: () => ({ getItems: () => [{ value: '=A1' }], getLength: () => 1 }),
+ getOptions: () => ({}),
+ } as any);
+
+ const flagService = new FormulaService();
+ (flagService as any)._formulaReferenceAbsoluteFlagsByKey.set('r::value', [{ column: true, row: false }]);
+ expect((flagService as any).applyFormulaReferenceAbsoluteFlags('r::value', '=A1+B1')).toBe('=$A1+B1');
+ (flagService as any)._grid = { getColumns: () => [{ id: 'value', field: 'value' }], getOptions: () => ({}) };
+ (flagService as any)._dataView = { getItems: () => [{ id: 'r', value: '=A1' }] };
+ (flagService as any)._formulaStore.set('orphan::value', '=A1');
+ (flagService as any)._formulaCoordinatesByKey.set('r::value', { rowId: 'r', columnId: 'value' });
+ (flagService as any)._formulaStore.set('r::value', '=A1');
+ (flagService as any).canonicalizeStoredFormulas();
+ expect((flagService as any).getFormula('r', 'value')).toContain('REF(COLUMN("value")');
+
+ const pipelineService = new FormulaService();
+ const formulaFormatter = () => 'formula';
+ (formulaFormatter as any).__formulaEvalFormatter = true;
+ const multiple = (Formatters as any).multiple;
+ const withExisting = (pipelineService as any).withFormulaFormatterPipeline(
+ { params: { formatters: [formulaFormatter] }, formatter: multiple },
+ formulaFormatter
+ );
+ expect(withExisting.params.formatters).toEqual([formulaFormatter]);
+ const withMissingFormula = (pipelineService as any).withFormulaFormatterPipeline({ params: { formatters: [] }, formatter: multiple }, () => 'formula');
+ expect(withMissingFormula.params.formatters).toHaveLength(1);
+ const wrappedFormatter = () => 'base';
+ (wrappedFormatter as any).__formulaAutoEditableWrapped = true;
+ (wrappedFormatter as any).__formulaAutoEditableBaseFormatter = () => 'original';
+ expect((pipelineService as any).unwrapAutoEditableFormatter(wrappedFormatter)()).toBe('original');
+ expect((pipelineService as any).normalizeFormulaSyntax('')).toBe('');
+
+ expect((flagService as any).convertA1ReferencesToStableRefs('=A0:B1', ['value'], ['r'])).toBe('=A0:B1');
+ expect((flagService as any).convertA1ReferencesToStableRefs('=A1:B1', ['value'], ['r'])).toBe('=A1:B1');
+ expect((flagService as any).replaceRefFunctionsWithA1Refs('', ['value'], ['r'])).toBe('');
+ expect((flagService as any).replaceRefFunctionsWithA1Refs('=REF(COLUMN("value"),ROW("missing"))', ['value'], ['r'])).toBe('=');
+
+ (flagService as any)._dataView = { getItems: () => [{ id: 'r', value: '' }] };
+ flagService.setFormula('r', 'value', '=1');
+ vi.spyOn(flagService as any, 'evaluateFormulaExpression')
+ .mockReturnValueOnce(Number.POSITIVE_INFINITY)
+ .mockReturnValueOnce(Number.NaN);
+ expect(flagService.getEvaluatedCellValue('r', 'value', '=1', 0)).toBe(FORMULA_ERROR.DIV0);
+ flagService.registerCustomFunction('NAN', () => Number.NaN);
+ flagService.setFormula('r', 'value', '=2');
+ expect(flagService.getEvaluatedCellValue('r', 'value', '=2', 0)).toBe(FORMULA_ERROR.VALUE);
+ flagService.setFormula('r', 'value', '=Z1');
+ expect(flagService.getEvaluatedCellValue('r', 'value', '=Z1', 0)).toBe(FORMULA_ERROR.REF);
+ expect((flagService as any).evaluateExpressionWithParser('"x"^2', new Map())).toBe(FORMULA_ERROR.NUM);
+ });
+
+ it('should keep special column IDs as own highlight hash keys', () => {
+ const columns: Column[] = [{ id: '__proto__', field: '__proto__', allowFormula: true }];
+ const setCellCssStyles = vi.fn();
+ const service = new FormulaService({ autoAssignEditor: false });
+ service.init({
+ getColumns: () => columns,
+ getData: () => ({ getItems: () => [{ id: 'r1', value: 1 }], getLength: () => 1 }),
+ getOptions: () => ({ datasetIdPropertyName: 'id', enableFormulas: true }),
+ setCellCssStyles,
+ } as any);
+
+ service.renderFormulaReferenceHighlights('=A1');
+
+ const hash = setCellCssStyles.mock.calls[0]?.[1] as Record>;
+ expect(Object.prototype.hasOwnProperty.call(hash[0], '__proto__')).toBe(true);
+ expect(hash[0].__proto__).toBe('formula-cell-color-1');
+ });
+
+ it('should set/get/has formula by row and column ids', () => {
+ const service = new FormulaService();
+
+ service.setFormula('id_1', 'total', '=REF(COLUMN("price"),ROW("id_1"))*2');
+
+ expect(service.hasFormula('id_1', 'total')).toBeTruthy();
+ expect(service.getFormula('id_1', 'total')).toBe('=REF(COLUMN("price"),ROW("id_1"))*2');
+ });
+
+ it('should remove formula when setFormula receives empty value', () => {
+ const service = new FormulaService();
+
+ service.setFormula('id_1', 'total', 'A1+B1');
+ service.setFormula('id_1', 'total', '');
+
+ expect(service.hasFormula('id_1', 'total')).toBeFalsy();
+ expect(service.getFormula('id_1', 'total')).toBeUndefined();
+ });
+
+ it('should translate relative and absolute A1 references for drag-fill without changing quoted literals', () => {
+ expect(translateFormulaReferences('=A1+$B1+C$1+$D$1+"A1"', 2, 1)).toBe('=B3+$B3+D$1+$D$1+"A1"');
+ });
+
+ it('should drag-fill a formula into target rows and keep the stored formula stable', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'quantity', field: 'quantity' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 'r1', price: 2, quantity: 3, total: '=A1*2' },
+ { id: 'r2', price: 4, quantity: 5, total: '' },
+ { id: 'r3', price: 6, quantity: 7, total: '' },
+ ];
+ const gridStub = {
+ getColumns: () => columns,
+ getVisibleColumns: () => columns,
+ setColumns: (newColumns: Column[]) => columns.splice(0, columns.length, ...newColumns),
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ updateItems: vi.fn(),
+ }),
+ getDataItem: (row: number) => items[row],
+ getOptions: () => ({ datasetIdPropertyName: 'id', enableFormulas: true }),
+ } as any;
+
+ service.init(gridStub);
+ (service as any).handleDragReplaceCells(
+ {},
+ {
+ prevSelectedRange: new SlickRange(0, 2),
+ selectedRange: new SlickRange(0, 2, 2, 2),
+ grid: gridStub,
+ }
+ );
+
+ expect(service.getFormula('r2', 'total')).toBe('=REF(COLUMN("price"),ROW("r2"))*2');
+ expect(service.getFormula('r3', 'total')).toBe('=REF(COLUMN("price"),ROW("r3"))*2');
+ expect(service.getEvaluatedCellValue('r2', 'total')).toBe(8);
+ expect(service.getEvaluatedCellValue('r3', 'total')).toBe(12);
+ expect(
+ service.getExcelFormula({
+ columnId: 'total',
+ columnIds: ['price', 'quantity', 'total'],
+ dataRowIdx: 1,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 1,
+ gridOptions: {},
+ rowId: 'r2',
+ rowIds: ['r1', 'r2', 'r3'],
+ })
+ ).toBe('A2*2');
+
+ service.setFormula('r1', 'total', '=$A$1+$B1');
+ (service as any).handleDragReplaceCells(
+ {},
+ {
+ prevSelectedRange: new SlickRange(0, 2),
+ selectedRange: new SlickRange(0, 2, 1, 2),
+ grid: gridStub,
+ }
+ );
+
+ expect((service as any).toDisplayFormulaForCell(service.getFormula('r2', 'total'), 'r2', 'total')).toBe('=$A$1+$B2');
+ expect(service.getEvaluatedCellValue('r2', 'total')).toBe(7);
+ });
+
+ it('should translate REF() formula syntax into Excel references', () => {
+ const service = new FormulaService();
+ service.setFormula('id_2', 'total', '=REF(COLUMN("price"),ROW("id_2"))*REF(COLUMN("qty"),ROW("id_2"))');
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'total',
+ columnIds: ['product', 'price', 'qty', 'total'],
+ dataRowIdx: 1,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 2,
+ gridOptions: {},
+ rowId: 'id_2',
+ rowIds: ['id_1', 'id_2', 'id_3'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('B3*C3');
+ });
+
+ it('should support numeric ROW() references', () => {
+ const service = new FormulaService();
+ service.setFormula('id_1', 'tax', 'REF(COLUMN("total"),ROW(2))*0.1');
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'tax',
+ columnIds: ['product', 'price', 'qty', 'total', 'tax'],
+ dataRowIdx: 0,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 2,
+ gridOptions: {},
+ rowId: 'id_1',
+ rowIds: ['id_1', 'id_2', 'id_3'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('D3*0.1');
+ });
+
+ it('should expose workbook export metadata for defined names and custom functions', () => {
+ const service = new FormulaService({
+ excelDefinedNames: [{ name: 'MY_RANGE', refersTo: 'Sheet1!$B$2:$C$100' }],
+ excelCustomFunctions: [{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }],
+ });
+
+ const definedNames = service.getExcelDefinedNames();
+ const customFunctions = service.getExcelCustomFunctions();
+
+ expect(definedNames).toEqual([{ name: 'MY_RANGE', refersTo: 'Sheet1!$B$2:$C$100' }]);
+ expect(customFunctions).toEqual([{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }]);
+ expect(definedNames).not.toBe((service as any)._options.excelDefinedNames);
+ expect(customFunctions).not.toBe((service as any)._options.excelCustomFunctions);
+ });
+
+ it('should auto-assign FormulaCellEditor on allowFormula columns without explicit model', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'name', field: 'name' },
+ { id: 'total', field: 'total', allowFormula: true },
+ { id: 'taxes', field: 'taxes', allowFormula: true, editor: { params: { debug: true } } },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({}),
+ } as any;
+
+ service.init(gridStub);
+
+ const totalCol = columns.find((col) => col.id === 'total');
+ const taxesCol = columns.find((col) => col.id === 'taxes');
+
+ expect(totalCol?.editor?.model).toBe(FormulaCellEditor);
+ expect(taxesCol?.editor?.model).toBe(FormulaCellEditor);
+ expect(taxesCol?.editor?.params?.debug).toBe(true);
+ });
+
+ it('should not override non-formula custom editors', () => {
+ const service = new FormulaService();
+ const customEditor = (() => undefined) as any;
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true, editor: { model: customEditor } }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({}),
+ } as any;
+
+ service.init(gridStub);
+
+ expect(columns[0].editor?.model).toBe(customEditor);
+ });
+
+ it('should stay inert when enableFormulas is explicitly disabled in grid options', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const setColumnsSpy = vi.fn();
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: setColumnsSpy,
+ getData: () => ({
+ getItems: () => [{ id: 1, total: '=A1' }],
+ getLength: () => 1,
+ }),
+ getOptions: () => ({ enableFormulas: false, datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+
+ expect(setColumnsSpy).not.toHaveBeenCalled();
+ expect(service.hasFormula(1, 'total')).toBe(false);
+ });
+
+ it('should warn when formula columns exist without cell-capable selection model options', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({}),
+ getOptions: () => ({ enableSelection: false }),
+ } as any;
+
+ service.init(gridStub);
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0]?.[0]).toContain('enableSelection: true');
+ });
+
+ it('should not warn when mixed selection model is enabled for formula columns', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({}),
+ getOptions: () => ({
+ enableSelection: true,
+ selectionOptions: { selectionType: 'mixed' },
+ }),
+ } as any;
+
+ service.init(gridStub);
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('should add and remove Excel column prefixes idempotently', () => {
+ const columns: Column[] = [
+ { id: 'name', field: 'name', name: 'Name' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const setColumns = vi.fn((nextColumns: Column[]) => columns.splice(0, columns.length, ...nextColumns));
+ const service = new FormulaService();
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns,
+ getData: () => ({ getItems: () => [], getLength: () => 0 }),
+ getOptions: () => ({ enableSelection: true, selectionOptions: { selectionType: 'mixed' } }),
+ } as any;
+
+ service.init(gridStub);
+ service.enableExcelHeaderPrefix();
+ expect(columns[0].name).toContain('A Name');
+ expect(columns[1].name).toContain('B total');
+
+ const callsAfterEnable = setColumns.mock.calls.length;
+ service.enableExcelHeaderPrefix();
+ expect(setColumns).toHaveBeenCalledTimes(callsAfterEnable);
+
+ service.disableExcelHeaderPrefix();
+ expect(columns[0].name).toBe('Name');
+ expect(columns[1].name).toContain('B total');
+ service.disableExcelHeaderPrefix();
+ expect(setColumns).toHaveBeenCalledTimes(callsAfterEnable + 1);
+ });
+
+ it('should evaluate SUM() with A1 references', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 10, qty: 3, total: '=SUM(A1,B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=SUM(A1,B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(13);
+ });
+
+ it('should prefer a changed live formula when the stored formula is not stable', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const items = [{ id: 1, total: '=1' }];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=1');
+
+ expect(service.getEvaluatedCellValue(1, 'total', '=2', 0)).toBe(2);
+ });
+
+ it('should evaluate both direct A1 and REF(COLUMN(),ROW()) formula styles', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'totalA1', field: 'totalA1', allowFormula: true },
+ { id: 'totalRef', field: 'totalRef', allowFormula: true },
+ ];
+ const items = [
+ {
+ id: 1,
+ price: 10,
+ qty: 4,
+ totalA1: '=A1*B1',
+ totalRef: '=REF(COLUMN("price"),ROW(1))*REF(COLUMN("qty"),ROW(1))',
+ },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'totalA1', '=A1*B1');
+ service.setFormula(1, 'totalRef', '=REF(COLUMN("price"),ROW(1))*REF(COLUMN("qty"),ROW(1))');
+
+ expect(service.getEvaluatedCellValue(1, 'totalA1', items[0].totalA1, 0)).toBe(40);
+ expect(service.getEvaluatedCellValue(1, 'totalRef', items[0].totalRef, 0)).toBe(40);
+ });
+
+ it('should canonicalize editor A1 references and remain stable after column reorder or hide', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'product', field: 'product' },
+ { id: 'price', field: 'price' },
+ { id: 'quantity', field: 'quantity' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 'a_01', product: 'Apples', price: 1.2, quantity: 5, total: '=B1*C1' }];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => columns.splice(0, columns.length, ...newCols),
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula('a_01', 'total', '=B1*C1');
+
+ expect(service.getFormula('a_01', 'total')).toBe('=REF(COLUMN("price"),ROW("a_01"))*REF(COLUMN("quantity"),ROW("a_01"))');
+ expect(service.getEvaluatedCellValue('a_01', 'total', items[0].total, 0)).toBe(6);
+
+ columns.splice(0, columns.length, columns[2], columns[0], columns[3], columns[1]);
+ expect(service.getEvaluatedCellValue('a_01', 'total', items[0].total, 0)).toBe(6);
+
+ columns.find((column) => column.id === 'product')!.hidden = true;
+ expect(service.getEvaluatedCellValue('a_01', 'total', items[0].total, 0)).toBe(6);
+ });
+
+ it('should canonicalize and evaluate A1 ranges with stable endpoint references', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'product', field: 'product' },
+ { id: 'price', field: 'price' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 'a_01', product: 'Apples', price: 1.2, total: '=SUM(B1:B3)' },
+ { id: 'o_02', product: 'Oranges', price: 0.8, total: 0 },
+ { id: 'b_03', product: 'Bananas', price: 1.6, total: 0 },
+ ];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => columns.splice(0, columns.length, ...newCols),
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula('a_01', 'total', '=SUM(B1:B3)');
+
+ expect(service.getFormula('a_01', 'total')).toBe('=SUM(REF(COLUMN("price"),ROW("a_01")):REF(COLUMN("price"),ROW("b_03")))');
+ expect(service.getEvaluatedCellValue('a_01', 'total', items[0].total, 0)).toBeCloseTo(3.6, 10);
+ });
+
+ it('should export stable references as native Excel A1 formulas using the export order', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'quantity', field: 'quantity' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 'a_01', price: 1.2, quantity: 5, total: '=A1*B1' }];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula('a_01', 'total', '=A1*B1');
+ columns.splice(0, columns.length, columns[1], columns[0], columns[2]);
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'total',
+ columnIds: ['quantity', 'price', 'total'],
+ dataRowIdx: 0,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 2,
+ gridOptions: {},
+ rowId: 'a_01',
+ rowIds: ['a_01'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('B2*A2');
+ });
+
+ it('should export stable references to hidden columns when hidden columns are included', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'product', field: 'product', hidden: true },
+ { id: 'price', field: 'price' },
+ { id: 'quantity', field: 'quantity' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 'a_01', product: 'Apples', price: 1.2, quantity: 5, total: '=B1*C1' }];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula('a_01', 'total', '=B1*C1');
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'total',
+ columnIds: ['product', 'price', 'quantity', 'total'],
+ dataRowIdx: 0,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 2,
+ gridOptions: {},
+ rowId: 'a_01',
+ rowIds: ['a_01'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('B2*C2');
+ });
+
+ it('should shift direct A1 references by excelRowOffset during export', () => {
+ const gridStub = {
+ getData: vi.fn().mockReturnValue({}),
+ getColumns: vi.fn().mockReturnValue([{ id: 'price' }, { id: 'qty' }, { id: 'total' }]),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ service.setFormula('id_1', 'total', '=SUM(C1,D1)');
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'total',
+ columnIds: ['price', 'qty', 'total'],
+ dataRowIdx: 0,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 3,
+ gridOptions: {},
+ rowId: 'id_1',
+ rowIds: ['id_1'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('SUM(C3,D3)');
+ });
+
+ it('should return #VALUE! for scalar times range shorthand expressions', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'd', field: 'd' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 1, a: 0, b: 0, c: 2, d: 3, total: '=C1*D1:D3' },
+ { id: 2, a: 0, b: 0, c: 9, d: 4, total: 0 },
+ { id: 3, a: 0, b: 0, c: 9, d: 5, total: 0 },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=C1*D1:D3');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(FORMULA_ERROR.VALUE);
+ });
+
+ it('should return #VALUE! for scalar times range shorthand with Unicode multiply symbol', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'd', field: 'd' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 1, a: 0, b: 0, c: 2, d: 3, total: '=C1×D1:D3' },
+ { id: 2, a: 0, b: 0, c: 9, d: 4, total: 0 },
+ { id: 3, a: 0, b: 0, c: 9, d: 5, total: 0 },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=C1×D1:D3');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(FORMULA_ERROR.VALUE);
+ });
+
+ it('should highlight a full range token with a single color class', () => {
+ const setCellCssStyles = vi.fn();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'd', field: 'd' },
+ ];
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ setCellCssStyles,
+ removeCellCssStyles: vi.fn(),
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ service.renderFormulaReferenceHighlights('=C1:C3');
+
+ expect(setCellCssStyles).toHaveBeenCalledTimes(1);
+ const cssHash = setCellCssStyles.mock.calls[0][1] as Record>;
+ expect(cssHash[0].c).toBe('formula-cell-color-1');
+ expect(cssHash[1].c).toBe('formula-cell-color-1');
+ expect(cssHash[2].c).toBe('formula-cell-color-1');
+ });
+
+ it('should preserve formula reference order when assigning highlight colors', () => {
+ const setCellCssStyles = vi.fn();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'd', field: 'd' },
+ ];
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ setCellCssStyles,
+ removeCellCssStyles: vi.fn(),
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ service.renderFormulaReferenceHighlights('=C1*SUM(D1:D3)');
+
+ expect(setCellCssStyles).toHaveBeenCalledTimes(1);
+ expect(setCellCssStyles.mock.calls[0][0]).toBe(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ expect(setCellCssStyles.mock.calls[0][1]).toEqual({
+ 0: { c: 'formula-cell-color-1', d: 'formula-cell-color-2' },
+ 1: { d: 'formula-cell-color-2' },
+ 2: { d: 'formula-cell-color-2' },
+ });
+
+ // The same mapping must work when the range is the first reference in the formula.
+ setCellCssStyles.mockClear();
+ service.renderFormulaReferenceHighlights('=SUM(D1:D3)*C1');
+
+ expect(setCellCssStyles).toHaveBeenCalledTimes(1);
+ expect(setCellCssStyles.mock.calls[0][1]).toEqual({
+ 0: { c: 'formula-cell-color-2', d: 'formula-cell-color-1' },
+ 1: { d: 'formula-cell-color-1' },
+ 2: { d: 'formula-cell-color-1' },
+ });
+ });
+
+ it('should use and clear one aggregate highlight key for more than ten references', () => {
+ const setCellCssStyles = vi.fn();
+ const removeCellCssStyles = vi.fn();
+ const columns = Array.from({ length: 11 }, (_value, index) => ({
+ id: String.fromCharCode(97 + index),
+ field: String.fromCharCode(97 + index),
+ })) as Column[];
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ setCellCssStyles,
+ removeCellCssStyles,
+ getData: () => ({ getItems: () => [{ id: 1 }], getLength: () => 1 }),
+ } as any;
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ service.renderFormulaReferenceHighlights('=A1+B1+C1+D1+E1+F1+G1+H1+I1+J1+K1');
+
+ expect(setCellCssStyles).toHaveBeenCalledTimes(1);
+ expect(setCellCssStyles.mock.calls[0][0]).toBe(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ expect(Object.keys(setCellCssStyles.mock.calls[0][1][0])).toHaveLength(11);
+
+ removeCellCssStyles.mockClear();
+ service.clearFormulaReferenceHighlights();
+ expect(removeCellCssStyles).toHaveBeenCalledOnce();
+ expect(removeCellCssStyles).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ });
+
+ it('should remap direct A1 references when hidden columns are excluded from export', () => {
+ const gridStub = {
+ getData: vi.fn().mockReturnValue({}),
+ getColumns: vi.fn().mockReturnValue([{ id: 'hiddenId' }, { id: 'price' }, { id: 'qty' }, { id: 'total' }]),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ service.setFormula('id_1', 'total', '=SUM(C1,D1)');
+
+ const context: FormulaExcelExportContext = {
+ columnId: 'total',
+ columnIds: ['price', 'qty', 'total'],
+ dataRowIdx: 0,
+ datasetIdPropertyName: 'id',
+ excelRowOffset: 3,
+ gridOptions: {},
+ rowId: 'id_1',
+ rowIds: ['id_1'],
+ };
+
+ expect(service.getExcelFormula(context)).toBe('SUM(B3,C3)');
+ });
+
+ it('should evaluate SUM() with ranges', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 1, price: 10, qty: 3, total: '=SUM(A1:B1)' },
+ { id: 2, price: 7, qty: 5, total: 0 },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=SUM(A1:B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(13);
+ });
+
+ it('should memoize nested referenced formulas across sibling evaluations in the same tick', () => {
+ const trackSpy = vi.fn((value: number) => value);
+ const service = new FormulaService({
+ customFunctions: {
+ TRACK: trackSpy,
+ },
+ });
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'subTotal', field: 'subTotal', allowFormula: true },
+ { id: 'taxes', field: 'taxes', allowFormula: true },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 10, qty: 3, subTotal: '=TRACK(A1*B1)', taxes: '=C1*0.1', total: '=C1+1' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'subTotal', '=TRACK(A1*B1)');
+ service.setFormula(1, 'taxes', '=C1*0.1');
+ service.setFormula(1, 'total', '=C1+1');
+
+ expect(service.getEvaluatedCellValue(1, 'taxes', items[0].taxes, 0)).toBe(3);
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(31);
+ expect(trackSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return #VALUE! for unicode multiply with scalar-times-range shorthand', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 1, a: 0, b: 2, c: 3, total: '=B1×C1:C3' },
+ { id: 2, a: 0, b: 0, c: 4, total: 0 },
+ { id: 3, a: 0, b: 0, c: 5, total: 0 },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=B1×C1:C3');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(FORMULA_ERROR.VALUE);
+ });
+
+ it('should evaluate SUMPRODUCT with scalar and range values', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'a', field: 'a' },
+ { id: 'b', field: 'b' },
+ { id: 'c', field: 'c' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [
+ { id: 1, a: 0, b: 2, c: 3, total: '=SUMPRODUCT(B1,C1:C3)' },
+ { id: 2, a: 0, b: 0, c: 4, total: 0 },
+ { id: 3, a: 0, b: 0, c: 5, total: 0 },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=SUMPRODUCT(B1,C1:C3)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(24);
+ });
+
+ it('should evaluate custom functions registered through options', () => {
+ const service = new FormulaService({
+ customFunctions: {
+ NET: (amount: number, taxes: number) => amount - taxes,
+ },
+ });
+ const columns: Column[] = [
+ { id: 'gross', field: 'gross' },
+ { id: 'taxes', field: 'taxes' },
+ { id: 'net', field: 'net', allowFormula: true },
+ ];
+ const items = [{ id: 1, gross: 125, taxes: 20, net: '=NET(A1,B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'net', '=NET(A1,B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'net', items[0].net, 0)).toBe(105);
+ });
+
+ it('should evaluate AG-Grid style custom function definitions through options', () => {
+ const service = new FormulaService({
+ customFunctions: {
+ CUSTOMSUM: {
+ func: ({ values }: { values: unknown[] }) => values.reduce((total, value) => total + Number(value ?? 0), 0),
+ },
+ },
+ });
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 10, qty: 3, total: '=CUSTOMSUM(A1,B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=CUSTOMSUM(A1,B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(13);
+ });
+
+ it('should flatten range arguments for AG-Grid style custom functions', () => {
+ const service = new FormulaService({
+ customFunctions: {
+ CUSTOMSUM: {
+ func: ({ values }: { values: unknown[] }) => values.reduce((total, value) => total + Number(value ?? 0), 0),
+ },
+ },
+ });
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 2.22, qty: 4, total: '=CUSTOMSUM(A1:B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=CUSTOMSUM(A1:B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBeCloseTo(6.22, 12);
+ });
+
+ it('should register custom functions at runtime with bulk API', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 10, qty: 3, total: '=CUSTOMSUM(A1,B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.registerCustomFunctions({
+ CUSTOMSUM: {
+ func: ({ values }: { values: unknown[] }) => values.reduce((total, value) => total + Number(value ?? 0), 0),
+ },
+ });
+ service.setFormula(1, 'total', '=CUSTOMSUM(A1,B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(13);
+ });
+
+ it('should skip auto-assignment when autoAssignEditor is disabled', () => {
+ const invalidateSpy = vi.fn();
+ const renderSpy = vi.fn();
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: vi.fn(),
+ getData: () => ({}),
+ getOptions: () => ({ editable: true }),
+ invalidate: invalidateSpy,
+ render: renderSpy,
+ } as any;
+
+ const service = new FormulaService({ autoAssignEditor: false });
+ service.init(gridStub);
+
+ expect(gridStub.setColumns).not.toHaveBeenCalled();
+ expect(invalidateSpy).not.toHaveBeenCalled();
+ expect(renderSpy).not.toHaveBeenCalled();
+ expect(columns[0].editor?.model).toBeUndefined();
+ });
+
+ it('should restore original formatter/editor config on dispose after auto-assign', () => {
+ const invalidateSpy = vi.fn();
+ const renderSpy = vi.fn();
+ const originalFormatter = vi.fn((_r, _c, value) => `orig:${value}`);
+ const columns: Column[] = [
+ {
+ id: 'total',
+ field: 'total',
+ allowFormula: true,
+ formatter: originalFormatter,
+ params: { maxDecimal: 2 },
+ },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({ getItems: () => [], getLength: () => 0 }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: invalidateSpy,
+ render: renderSpy,
+ removeCellCssStyles: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ expect(columns[0].editor?.model).toBe(FormulaCellEditor);
+
+ service.dispose();
+
+ expect(columns[0].formatter).toBe(originalFormatter);
+ expect(columns[0].editor).toBeUndefined();
+ expect(columns[0].params).toEqual({ maxDecimal: 2 });
+ });
+
+ it('should fallback to local editable marker formatter when autoAddCustomEditorFormatter is unavailable', () => {
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const items = [{ id: 1, total: '=SUM(1,2)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ const formatted = columns[0].formatter?.(0, 0, items[0].total, columns[0], items[0], gridStub);
+ expect(formatted).toBeInstanceOf(HTMLElement);
+ expect((formatted as HTMLElement).className).toContain('editing-field');
+ expect((formatted as HTMLElement).textContent).toBe('3');
+ });
+
+ it('should keep formatter output untouched when grid is not editable', () => {
+ const baseElm = document.createElement('span');
+ baseElm.textContent = 'already-formatted';
+ const baseFormatter = vi.fn(() => baseElm);
+
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true, formatter: baseFormatter }];
+ const items = [{ id: 1, total: '=SUM(1,2)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ editable: false, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ const formatted = columns[0].formatter?.(0, 0, items[0].total, columns[0], items[0], gridStub);
+ expect(formatted).toBe(baseElm);
+ });
+
+ it('should delegate final display to autoAddCustomEditorFormatter when available', () => {
+ const autoEditableSpy = vi.fn((_row, _cell, value) => `wrapped:${String(value)}`);
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const items = [{ id: 1, total: '=SUM(1,2)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id', autoAddCustomEditorFormatter: autoEditableSpy }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ const formatted = columns[0].formatter?.(0, 0, items[0].total, columns[0], items[0], gridStub);
+ expect(formatted).toBe('wrapped:3');
+ expect(autoEditableSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should wrap HTMLElement formatter output inside editable marker container', () => {
+ const baseElm = document.createElement('span');
+ baseElm.textContent = 'already-formatted';
+ const baseFormatter = vi.fn(() => baseElm);
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true, formatter: baseFormatter }];
+ const items = [{ id: 1, total: '=SUM(1,2)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ const formatted = columns[0].formatter?.(0, 0, items[0].total, columns[0], items[0], gridStub) as HTMLElement;
+ expect(formatted).toBeInstanceOf(HTMLElement);
+ expect(formatted.className).toContain('editing-field');
+ expect(formatted.firstElementChild).toBe(baseElm);
+ });
+
+ it('should resolve formatter rows through DataView getItem or the item-array fallback', () => {
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const items = [{ id: 1, total: '=SUM(1,2)' }];
+ const getItem = vi.fn((row: number) => items[row]);
+ const dataView = { getItems: () => items, getLength: () => items.length, getItem };
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => dataView,
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+ const service = new FormulaService();
+ service.init(gridStub);
+
+ const formatter = (service as any).buildFormulaValueFormatter(columns[0]);
+ expect(formatter(0, 0, items[0].total, columns[0])).toBe(3);
+ expect(getItem).toHaveBeenCalledWith(0);
+
+ const fallbackService = new FormulaService();
+ const fallbackDataView = { getItems: () => items, getLength: () => items.length };
+ const fallbackGridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => fallbackDataView,
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+ fallbackService.init(fallbackGridStub);
+
+ const fallbackFormatter = (fallbackService as any).buildFormulaValueFormatter(columns[0]);
+ expect(fallbackFormatter(0, 0, items[0].total, columns[0])).toBe(3);
+ });
+
+ it('should reuse memoized value when evaluating same formula cell repeatedly in one tick', () => {
+ const trackSpy = vi.fn((value: number) => value);
+ const service = new FormulaService({
+ customFunctions: {
+ TRACK: trackSpy,
+ },
+ });
+ const columns: Column[] = [
+ { id: 'price', field: 'price' },
+ { id: 'qty', field: 'qty' },
+ { id: 'total', field: 'total', allowFormula: true },
+ ];
+ const items = [{ id: 1, price: 2, qty: 3, total: '=TRACK(A1*B1)' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({
+ getItems: () => items,
+ getLength: () => items.length,
+ }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'total', '=TRACK(A1*B1)');
+
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(6);
+ expect(service.getEvaluatedCellValue(1, 'total', items[0].total, 0)).toBe(6);
+ expect(trackSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return false when removing a non-existing formula', () => {
+ const service = new FormulaService();
+
+ expect(service.removeFormula('missing-row', 'missing-col')).toBe(false);
+ });
+
+ it('should restore only tracked formula columns and keep other columns as-is on dispose', () => {
+ const formulaFormatter = vi.fn((_r, _c, value) => `f:${value}`);
+ const staticFormatter = vi.fn((_r, _c, value) => `s:${value}`);
+ const columns: Column[] = [
+ { id: 'name', field: 'name', formatter: staticFormatter },
+ { id: 'total', field: 'total', allowFormula: true, formatter: formulaFormatter, params: { precision: 2 } },
+ ];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({ getItems: () => [], getLength: () => 0 }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ removeCellCssStyles: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ const beforeDisposeNameFormatter = columns[0].formatter;
+
+ service.dispose();
+
+ expect(columns[0].formatter).toBe(beforeDisposeNameFormatter);
+ expect(columns[1].formatter).toBe(formulaFormatter);
+ expect(columns[1].params).toEqual({ precision: 2 });
+ });
+
+ it('should no-op dispose restore when no formula columns were auto-assigned', () => {
+ const columns: Column[] = [{ id: 'name', field: 'name' }];
+ const setColumnsSpy = vi.fn((newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ });
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: setColumnsSpy,
+ getData: () => ({ getItems: () => [], getLength: () => 0 }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ removeCellCssStyles: vi.fn(),
+ } as any;
+
+ const service = new FormulaService();
+ service.init(gridStub);
+ setColumnsSpy.mockClear();
+
+ service.dispose();
+
+ expect(setColumnsSpy).not.toHaveBeenCalled();
+ });
+
+ it('should evaluate object cell references as string literals via expression conversion fallback', () => {
+ const service = new FormulaService();
+ const columns: Column[] = [
+ { id: 'payload', field: 'payload' },
+ { id: 'out', field: 'out', allowFormula: true },
+ ];
+ const items = [{ id: 1, payload: { foo: 'bar' }, out: '=A1' }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (_newCols: Column[]) => undefined,
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+
+ service.init(gridStub);
+ service.setFormula(1, 'out', '=A1');
+
+ expect(service.getEvaluatedCellValue(1, 'out', items[0].out, '')).toBe('[object Object]');
+ });
+
+ it('should handle invalid references, circular references, missing cells, and literal conversion', () => {
+ const service = new FormulaService();
+ const items = [{ id: 1, value: '=A1' }];
+ const columns: Column[] = [{ id: 'value', field: 'value' }];
+ const gridStub = {
+ getColumns: () => columns,
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ datasetIdPropertyName: 'id' }),
+ } as any;
+ service.init(gridStub);
+
+ const context = { visited: new Set(), memo: new Map() };
+ expect((service as any).resolveExcelReferenceValue('?', 1, context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).resolveExcelReferenceValue('A', 0, context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).resolveExcelReferenceValue('B', 1, context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).resolveExcelReferenceValue('A', 2, context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).resolveExcelRangeValues('?', 1, 'A', 1, context)).toEqual([]);
+
+ context.visited.add('1::value');
+ expect((service as any).resolveExcelReferenceValue('A', 1, context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).getCellRawValue('missing', 'value')).toBeUndefined();
+ expect((service as any).getCellRawValue(1, 'missing')).toBeUndefined();
+
+ expect((service as any).toExpressionLiteral(null)).toBe('0');
+ expect((service as any).toExpressionLiteral(true)).toBe('true');
+ expect((service as any).toExpressionLiteral(false)).toBe('false');
+ expect((service as any).toExpressionLiteral(' 12.5 ')).toBe('12.5');
+ expect((service as any).toExpressionLiteral('hello')).toBe('"hello"');
+ expect((service as any).replaceRefFunctionsWithA1Refs('=REF(COLUMN("missing"),ROW(1))', ['value'], ['1'], 1)).toBe('=');
+ service.registerCustomFunction('INVALID', {} as any);
+ expect(service.getCustomFunction('INVALID')).toBeUndefined();
+
+ const baseDate = new Date('2024-01-10T00:00:00.000Z');
+ expect((FormulaService as any).addFormulaValues(baseDate, 2)).toEqual(new Date('2024-01-12T00:00:00.000Z'));
+ expect((FormulaService as any).addFormulaValues(2, baseDate)).toEqual(new Date('2024-01-12T00:00:00.000Z'));
+ expect((FormulaService as any).addFormulaValues(2, 3)).toBe(5);
+ expect((FormulaService as any).subtractFormulaValues(baseDate, 2)).toEqual(new Date('2024-01-08T00:00:00.000Z'));
+ expect((FormulaService as any).subtractFormulaValues(baseDate, new Date('2024-01-08T00:00:00.000Z'))).toBe(2);
+ expect((FormulaService as any).subtractFormulaValues(5, 2)).toBe(3);
+ expect((FormulaService as any).addDays(baseDate, 1)).toEqual(new Date('2024-01-11T00:00:00.000Z'));
+ });
+
+ it('should cover the recursive-descent parser operators, literals, collections, and syntax errors', () => {
+ const service = new FormulaService();
+ const functions = new Map unknown>([['FN', (...args) => args.length]]);
+ const evaluate = (expression: string) => (service as any).evaluateExpressionWithParser(expression, functions);
+
+ expect(evaluate(' 1 + 2 ')).toBe(3);
+ expect(evaluate('"a\\"b"')).toBe('a"b');
+ expect(evaluate('1 == 1')).toBe(true);
+ expect(evaluate('1 != 2')).toBe(true);
+ expect(evaluate('1 < 2')).toBe(true);
+ expect(evaluate('2 > 1')).toBe(true);
+ expect(evaluate('1 <= 1')).toBe(true);
+ expect(evaluate('1 >= 1')).toBe(true);
+ expect(evaluate('"a" & "b"')).toBe('ab');
+ expect(evaluate('4 - 2')).toBe(2);
+ expect(evaluate('2 * 3')).toBe(6);
+ expect(evaluate('6 / 2')).toBe(3);
+ expect(evaluate('2 ^ 3')).toBe(8);
+ expect(evaluate('50%')).toBe(0.5);
+ expect(evaluate('+2')).toBe(2);
+ expect(evaluate('-2')).toBe(-2);
+ expect(evaluate('FN(1, 2)')).toBe(2);
+ expect(evaluate('TRUE')).toBe(true);
+ expect(evaluate('FALSE')).toBe(false);
+ expect(evaluate('NULL')).toBe(null);
+ expect(evaluate('(1)')).toBe(1);
+ expect(evaluate('[]')).toEqual([]);
+ expect(evaluate('[1, 2]')).toEqual([1, 2]);
+
+ expect(evaluate('1..2')).toBe(FORMULA_ERROR.NUM);
+ expect(evaluate('@')).toBe(FORMULA_ERROR.ERROR);
+ expect(evaluate('UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('UNKNOWN()')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('FN(')).toBe(FORMULA_ERROR.ERROR);
+ expect(evaluate('(1')).toBe(FORMULA_ERROR.ERROR);
+ expect(evaluate('[1')).toBe(FORMULA_ERROR.ERROR);
+ expect(evaluate('1 2')).toBe(FORMULA_ERROR.ERROR);
+ expect(evaluate('1 / 0')).toBe(FORMULA_ERROR.DIV0);
+ expect(evaluate('1 + UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('1 + "x"')).toBe('1x');
+ expect(evaluate('1 * "x"')).toBe(FORMULA_ERROR.VALUE);
+ expect(evaluate('1 < UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('"a" & UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('1 - "x"')).toBe(FORMULA_ERROR.VALUE);
+ expect(evaluate('1 * UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('1 ^ UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('"x"%')).toBe(FORMULA_ERROR.VALUE);
+ expect(evaluate('+UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('-UNKNOWN')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('(UNKNOWN)')).toBe(FORMULA_ERROR.NAME);
+ expect(evaluate('[UNKNOWN]')).toBe(FORMULA_ERROR.NAME);
+
+ const context = { visited: new Set(), memo: new Map() };
+ expect((service as any).evaluateFormulaExpression('', context)).toBe(FORMULA_ERROR.NULL);
+ expect((service as any).evaluateFormulaExpression('=1;2', context)).toBe(FORMULA_ERROR.ERROR);
+ expect((service as any).evaluateFormulaExpression('=FOO', context)).toBe(FORMULA_ERROR.NAME);
+ expect((service as any).evaluateFormulaExpression('=A1:B1', context)).toBe(FORMULA_ERROR.REF);
+ expect((service as any).evaluateFormulaExpression('=SUM(A1:ZZZ1000000)', context)).toBe(FORMULA_ERROR.REF);
+
+ for (const error of [ReferenceError, TypeError, SyntaxError, Error]) {
+ const throwingService = new FormulaService({});
+ throwingService.registerCustomFunction('THROW', () => {
+ throw new error();
+ });
+ expect((throwingService as any).evaluateFormulaExpression('=THROW()', { visited: new Set(), memo: new Map() })).toBe(
+ error === ReferenceError ? FORMULA_ERROR.NAME : error === TypeError ? FORMULA_ERROR.VALUE : FORMULA_ERROR.ERROR
+ );
+ }
+ });
+
+ it('should wrap onFormulaInputChange and invoke user callback without forcing highlight refresh', () => {
+ const userCallback = vi.fn();
+ const service = new FormulaService();
+ const highlightSpy = vi.spyOn(service as any, 'renderFormulaReferenceHighlights');
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true, editor: { params: { onFormulaInputChange: userCallback } } }];
+
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newCols: Column[]) => {
+ columns.splice(0, columns.length, ...newCols);
+ },
+ getData: () => ({ getItems: () => [], getLength: () => 0 }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ service.init(gridStub);
+ const wrapped = columns[0].editor?.params?.onFormulaInputChange as ((formula: string) => void) | undefined;
+ wrapped?.('=A1');
+
+ expect(highlightSpy).not.toHaveBeenCalled();
+ expect(userCallback).toHaveBeenCalledWith('=A1');
+ });
+
+ it('should wrap formula editor conversion and commit callbacks with and without row items', () => {
+ const columns: Column[] = [{ id: 'total', field: 'total', allowFormula: true }];
+ const items = [{ id: 'r1', total: '=A1' }];
+ const service = new FormulaService();
+ const gridStub = {
+ getColumns: () => columns,
+ setColumns: (newColumns: Column[]) => columns.splice(0, columns.length, ...newColumns),
+ getData: () => ({ getItems: () => items, getLength: () => items.length }),
+ getOptions: () => ({ editable: true, datasetIdPropertyName: 'id' }),
+ invalidate: vi.fn(),
+ render: vi.fn(),
+ } as any;
+
+ service.init(gridStub);
+ const params = columns[0].editor?.params as any;
+
+ expect(params.toDisplayFormula('=A1')).toBe('=A1');
+ expect(params.toDisplayFormula('=A1', { id: 'r1' })).toBe('=A1');
+ expect(params.toStoredFormula('=A1')).toContain('REF(COLUMN("total"),ROW("r1"))');
+ expect(params.toStoredFormula('=A1', { id: 'r1' })).toContain('REF(COLUMN("total"),ROW("r1"))');
+
+ params.onFormulaCommit('=A1');
+ expect(service.getFormula('r1', 'total')).toBe('=REF(COLUMN("total"),ROW("r1"))');
+ params.onFormulaCommit('=A1', { id: 'r1' });
+ expect(service.getFormula('r1', 'total')).toBe('=REF(COLUMN("total"),ROW("r1"))');
+ });
+});
diff --git a/packages/formula-plugin/src/formula-errors.ts b/packages/formula-plugin/src/formula-errors.ts
new file mode 100644
index 000000000..8c3c7595f
--- /dev/null
+++ b/packages/formula-plugin/src/formula-errors.ts
@@ -0,0 +1,18 @@
+export const FORMULA_ERROR = {
+ DIV0: '#DIV/0!',
+ ERROR: '#ERROR!',
+ NA: '#N/A',
+ NAME: '#NAME?',
+ NULL: '#NULL!',
+ NUM: '#NUM!',
+ REF: '#REF!',
+ VALUE: '#VALUE!',
+} as const;
+
+export type FormulaErrorCode = (typeof FORMULA_ERROR)[keyof typeof FORMULA_ERROR];
+
+const FORMULA_ERROR_VALUES = new Set(Object.values(FORMULA_ERROR));
+
+export function isFormulaErrorCode(value: unknown): value is FormulaErrorCode {
+ return typeof value === 'string' && FORMULA_ERROR_VALUES.has(value);
+}
diff --git a/packages/formula-plugin/src/formula-functions.ts b/packages/formula-plugin/src/formula-functions.ts
new file mode 100644
index 000000000..8685bef00
--- /dev/null
+++ b/packages/formula-plugin/src/formula-functions.ts
@@ -0,0 +1,227 @@
+import { FORMULA_ERROR } from './formula-errors.js';
+
+export type FormulaCallback = (...args: any[]) => unknown;
+
+export function createFormulaFunctionRegistry(customFunctions: ReadonlyMap): Map {
+ const registry = createBuiltInFormulaFunctions();
+
+ // Custom functions can extend or override built-ins by name.
+ for (const [functionName, callback] of customFunctions.entries()) {
+ if (/^[A-Z_][A-Z0-9_]*$/.test(functionName) && typeof callback === 'function') {
+ registry.set(functionName, callback);
+ }
+ }
+
+ return registry;
+}
+
+function createBuiltInFormulaFunctions(): Map {
+ const registry = new Map();
+
+ const IF = (condition: unknown, yesValue: unknown, noValue: unknown) => (condition ? yesValue : noValue);
+ const SUM = (...args: unknown[]) =>
+ flattenFormulaFunctionArgs(args)
+ .map((value) => toNumericFormulaValue(value))
+ .reduce((acc, value) => acc + value, 0);
+ const PRODUCT = (...args: unknown[]) =>
+ flattenFormulaFunctionArgs(args)
+ .map((value) => toNumericFormulaValue(value))
+ .reduce((acc, value) => acc * value, 1);
+ const SUMPRODUCT = (...args: unknown[]) => {
+ if (!args.length) {
+ return 0;
+ }
+
+ const arrays = args.map((arg) => toFormulaArray(arg).map((value) => toNumericFormulaValue(value)));
+ const maxLen = Math.max(...arrays.map((arr) => arr.length));
+ if (!maxLen || !Number.isFinite(maxLen)) {
+ return 0;
+ }
+
+ // Broadcast scalar args across array lengths to emulate Excel SUMPRODUCT behavior.
+ const normalizedArrays = arrays.map((arr) => {
+ if (arr.length === maxLen) {
+ return arr;
+ }
+ if (arr.length === 1) {
+ return Array.from({ length: maxLen }, () => arr[0]);
+ }
+ return arr;
+ });
+
+ let sum = 0;
+ for (let i = 0; i < maxLen; i++) {
+ let product = 1;
+ for (const arr of normalizedArrays) {
+ if (i >= arr.length) {
+ continue;
+ }
+ // Values are normalized through toNumericFormulaValue() above, so each present entry is numeric.
+ product *= arr[i];
+ }
+ sum += product;
+ }
+
+ return sum;
+ };
+ const MIN = (...args: unknown[]) => {
+ const values = flattenFormulaFunctionArgs(args).map((value) => toNumericFormulaValue(value));
+ return values.length ? Math.min(...values) : 0;
+ };
+ const MAX = (...args: unknown[]) => {
+ const values = flattenFormulaFunctionArgs(args).map((value) => toNumericFormulaValue(value));
+ return values.length ? Math.max(...values) : 0;
+ };
+ const AVERAGE = (...args: unknown[]) => {
+ const values = flattenFormulaFunctionArgs(args).map((value) => toNumericFormulaValue(value));
+ return values.length ? values.reduce((acc, value) => acc + value, 0) / values.length : 0;
+ };
+ const MEDIAN = (...args: unknown[]) => {
+ const values = flattenFormulaFunctionArgs(args)
+ .map((value) => toNumericFormulaValue(value))
+ .sort((a, b) => a - b);
+ if (!values.length) {
+ return 0;
+ }
+ const mid = Math.floor(values.length / 2);
+ return values.length % 2 === 0 ? (values[mid - 1] + values[mid]) / 2 : values[mid];
+ };
+ const POWER = (arg1: unknown, arg2: unknown) => Math.pow(toNumericFormulaValue(arg1), toNumericFormulaValue(arg2));
+ const RAND = () => Math.random();
+ const NOW = () => new Date();
+ const TODAY = () => {
+ const now = new Date();
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ };
+ const CONCAT = (...args: unknown[]) =>
+ flattenFormulaFunctionArgs(args)
+ .map((arg) => String(arg ?? ''))
+ .join('');
+ const COUNT = (...args: unknown[]) => flattenFormulaFunctionArgs(args).filter((value) => isNumericFormulaValue(value)).length;
+ const COUNTA = (...args: unknown[]) =>
+ flattenFormulaFunctionArgs(args).filter((value) => value !== null && value !== undefined && value !== '').length;
+ const COUNTBLANK = (...args: unknown[]) =>
+ flattenFormulaFunctionArgs(args).filter((value) => value === null || value === undefined || value === '').length;
+ const COUNTIF = (range: unknown, criteria: unknown) => {
+ const values = toFormulaArray(range);
+ return values.filter((value) => matchesFormulaCriteria(value, criteria)).length;
+ };
+ const SUMIF = (range: unknown, criteria: unknown, sumRange?: unknown) => {
+ const criteriaValues = toFormulaArray(range);
+ const sumValues = sumRange === undefined ? criteriaValues : toFormulaArray(sumRange);
+ const length = Math.min(criteriaValues.length, sumValues.length);
+ let sum = 0;
+ for (let i = 0; i < length; i++) {
+ if (matchesFormulaCriteria(criteriaValues[i], criteria)) {
+ sum += toNumericFormulaValue(sumValues[i]);
+ }
+ }
+ return sum;
+ };
+ const NA = () => FORMULA_ERROR.NA;
+
+ registry.set('IF', IF);
+ registry.set('SUM', SUM);
+ registry.set('SUMPRODUCT', SUMPRODUCT);
+ registry.set('SUMIF', SUMIF);
+ registry.set('PRODUCT', PRODUCT);
+ registry.set('MIN', MIN);
+ registry.set('MAX', MAX);
+ registry.set('AVERAGE', AVERAGE);
+ registry.set('MEDIAN', MEDIAN);
+ registry.set('POWER', POWER);
+ registry.set('RAND', RAND);
+ registry.set('NOW', NOW);
+ registry.set('TODAY', TODAY);
+ registry.set('CONCAT', CONCAT);
+ registry.set('COUNT', COUNT);
+ registry.set('COUNTA', COUNTA);
+ registry.set('COUNTBLANK', COUNTBLANK);
+ registry.set('COUNTIF', COUNTIF);
+ registry.set('NA', NA);
+
+ return registry;
+}
+
+function flattenFormulaFunctionArgs(args: unknown[]): unknown[] {
+ const flat: unknown[] = [];
+ for (const arg of args) {
+ if (Array.isArray(arg)) {
+ flat.push(...flattenFormulaFunctionArgs(arg));
+ } else {
+ flat.push(arg);
+ }
+ }
+ return flat;
+}
+
+function toNumericFormulaValue(value: unknown): number {
+ if (value === null || value === undefined || value === '') {
+ return 0;
+ }
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : 0;
+ }
+ if (typeof value === 'boolean') {
+ return value ? 1 : 0;
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ const numeric = Number(trimmed);
+ return Number.isFinite(numeric) ? numeric : 0;
+ }
+ return 0;
+}
+
+function isNumericFormulaValue(value: unknown): boolean {
+ if (typeof value === 'number') {
+ return Number.isFinite(value);
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return false;
+ }
+ const numeric = Number(trimmed);
+ return Number.isFinite(numeric);
+ }
+ return false;
+}
+
+function toFormulaArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? flattenFormulaFunctionArgs(value) : [value];
+}
+
+function matchesFormulaCriteria(value: unknown, criteria: unknown): boolean {
+ if (typeof criteria === 'number' || typeof criteria === 'boolean') {
+ return value === criteria;
+ }
+
+ const criteriaText = String(criteria ?? '').trim();
+ const operatorMatch = criteriaText.match(/^(<=|>=|<>|=|<|>)(.*)$/);
+ const operator = operatorMatch?.[1] ?? '=';
+ const operandText = (operatorMatch?.[2] ?? criteriaText).trim();
+
+ const leftNumber = isNumericFormulaValue(value) ? Number(String(value).trim()) : undefined;
+ const rightNumber = isNumericFormulaValue(operandText) ? Number(operandText) : undefined;
+
+ const left = leftNumber ?? String(value ?? '');
+ const right = rightNumber ?? operandText;
+
+ switch (operator) {
+ case '=':
+ return left === right;
+ case '<>':
+ return left !== right;
+ case '<':
+ return (left as any) < (right as any);
+ case '>':
+ return (left as any) > (right as any);
+ case '<=':
+ return (left as any) <= (right as any);
+ case '>=':
+ return (left as any) >= (right as any);
+ default:
+ return false;
+ }
+}
diff --git a/packages/formula-plugin/src/formula-reference.ts b/packages/formula-plugin/src/formula-reference.ts
new file mode 100644
index 000000000..177426504
--- /dev/null
+++ b/packages/formula-plugin/src/formula-reference.ts
@@ -0,0 +1,211 @@
+const FORMULA_TOKEN_COLOR_COUNT = 10;
+/** Maximum number of cells expanded for one formula reference range. */
+export const FORMULA_MAX_REFERENCE_CELLS = 100_000;
+/** Shared grid CSS overlay key used while formula references are highlighted. */
+export const FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY = 'formula-reference-highlights';
+
+export interface FormulaGridCell {
+ row: number;
+ cell: number;
+}
+
+export interface FormulaReferenceColorInfo {
+ ref: string;
+ colorIdx: number;
+ colorClass: string;
+ cells: FormulaGridCell[];
+}
+
+export type FormulaReferenceCssHash = Record>;
+
+/** Shared reference/color state used by FormulaCellEditor and FormulaService. */
+export class FormulaReferenceColorCache {
+ protected _formula = '';
+ protected _references: Map = new Map();
+ protected _isDirty = false;
+
+ update(formula: string): boolean {
+ if (formula === this._formula) {
+ return false;
+ }
+
+ this._formula = formula;
+ this._references.clear();
+ for (const info of buildFormulaReferenceColorInfos(formula)) {
+ this._references.set(info.ref, info);
+ }
+ this._isDirty = true;
+ return true;
+ }
+
+ clear(): void {
+ this._formula = '';
+ this._references.clear();
+ this._isDirty = false;
+ }
+
+ get isDirty(): boolean {
+ return this._isDirty;
+ }
+
+ get size(): number {
+ return this._references.size;
+ }
+
+ get(ref: string): FormulaReferenceColorInfo | undefined {
+ return this._references.get(normalizeFormulaReferenceToken(ref));
+ }
+
+ values(): IterableIterator {
+ return this._references.values();
+ }
+
+ markClean(): void {
+ this._isDirty = false;
+ }
+}
+
+// Match complete or incomplete ranges (D1:D4, D1:D, D1:) before single cells.
+const FORMULA_REFERENCE_TOKEN_PATTERN = String.raw`\$?[A-Z]{1,3}\$?\d+\s*:\s*(?:\$?[A-Z]{1,3}\$?\d*)?|\$?[A-Z]{1,3}\$?\d+`;
+
+export function createFormulaReferenceTokenRegex(): RegExp {
+ return new RegExp(FORMULA_REFERENCE_TOKEN_PATTERN, 'gi');
+}
+
+export function normalizeFormulaReferenceToken(token: string): string {
+ return token.replace(/\$/g, '').replace(/\s+/g, '').toUpperCase();
+}
+
+export function getExcelColumnNameByIndex(columnIndex: number): string {
+ let dividend = columnIndex;
+ let columnName = '';
+
+ while (dividend > 0) {
+ const modulo = (dividend - 1) % 26;
+ columnName = String.fromCharCode(65 + modulo) + columnName;
+ dividend = Math.floor((dividend - modulo) / 26);
+ }
+
+ return columnName;
+}
+
+export function getExcelColumnIndexByName(columnName: string): number {
+ let columnIndex = 0;
+ for (let i = 0; i < columnName.length; i++) {
+ columnIndex = columnIndex * 26 + (columnName.charCodeAt(i) - 64);
+ }
+ return columnIndex - 1;
+}
+
+export function parseExcelReferenceCell(token: string): FormulaGridCell | undefined {
+ const match = normalizeFormulaReferenceToken(token).match(/^([A-Z]{1,3})(\d+)$/);
+ if (!match) {
+ return undefined;
+ }
+
+ const row = Number.parseInt(match[2], 10) - 1;
+ const cell = getExcelColumnIndexByName(match[1]);
+ if (!Number.isFinite(row) || row < 0) {
+ return undefined;
+ }
+
+ return { row, cell };
+}
+
+// fallow-ignore-next-line unused-export
+export function expandFormulaReferenceToGridCells(reference: string): FormulaGridCell[] {
+ const normalizedRef = normalizeFormulaReferenceToken(reference);
+ const [startToken, endToken] = normalizedRef.includes(':') ? normalizedRef.split(':', 2) : [normalizedRef, normalizedRef];
+ const startCell = parseExcelReferenceCell(startToken);
+ const endCell = parseExcelReferenceCell(endToken || startToken);
+
+ if (!startCell) {
+ return [];
+ }
+ if (!endCell) {
+ return [startCell];
+ }
+
+ const cells: FormulaGridCell[] = [];
+ const minRow = Math.min(startCell.row, endCell.row);
+ const maxRow = Math.max(startCell.row, endCell.row);
+ const minCell = Math.min(startCell.cell, endCell.cell);
+ const maxCell = Math.max(startCell.cell, endCell.cell);
+ const rowCount = maxRow - minRow + 1;
+ const cellCount = maxCell - minCell + 1;
+ if (!Number.isSafeInteger(rowCount) || !Number.isSafeInteger(cellCount) || rowCount * cellCount > FORMULA_MAX_REFERENCE_CELLS) {
+ return [];
+ }
+
+ for (let row = minRow; row <= maxRow; row++) {
+ for (let cell = minCell; cell <= maxCell; cell++) {
+ cells.push({ row, cell });
+ }
+ }
+ return cells;
+}
+
+/** Build the shared left-to-right reference/color mapping used by both the editor and service. */
+// fallow-ignore-next-line unused-export
+export function buildFormulaReferenceColorInfos(formula: string): FormulaReferenceColorInfo[] {
+ const references: FormulaReferenceColorInfo[] = [];
+ const seen = new Set();
+ const referenceRegex = createFormulaReferenceTokenRegex();
+ let match: RegExpExecArray | null;
+
+ while ((match = referenceRegex.exec(formula)) !== null) {
+ const ref = normalizeFormulaReferenceToken(match[0]);
+ if (seen.has(ref)) {
+ continue;
+ }
+
+ seen.add(ref);
+ const colorIdx = references.length % FORMULA_TOKEN_COLOR_COUNT;
+ references.push({
+ ref,
+ colorIdx,
+ colorClass: `formula-cell-color-${colorIdx + 1}`,
+ cells: expandFormulaReferenceToGridCells(ref),
+ });
+ }
+
+ return references;
+}
+
+/** Convert colored formula references to the keyed cell-class hash expected by SlickGrid. */
+export function buildFormulaReferenceCssHash(
+ references: Iterable,
+ columns: Array<{ id?: number | string }>,
+ rowCount?: number
+): FormulaReferenceCssHash {
+ const hash: FormulaReferenceCssHash = Object.create(null);
+
+ for (const reference of references) {
+ for (const cell of reference.cells) {
+ const columnId = columns[cell.cell]?.id;
+ if (columnId === undefined || columnId === null || cell.row < 0 || (rowCount !== undefined && cell.row >= rowCount)) {
+ continue;
+ }
+
+ const rowClasses = (hash[cell.row] ??= Object.create(null));
+ rowClasses[columnId] = reference.colorClass;
+ }
+ }
+
+ return hash;
+}
+
+/** Assign a data-cell value without invoking the legacy Object.prototype.__proto__ setter. */
+export function setFormulaObjectProperty(target: Record, propertyName: string, value: unknown): void {
+ if (propertyName === '__proto__') {
+ Object.defineProperty(target, propertyName, {
+ configurable: true,
+ enumerable: true,
+ value,
+ writable: true,
+ });
+ return;
+ }
+
+ target[propertyName] = value;
+}
diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts
new file mode 100644
index 000000000..7f54bcde1
--- /dev/null
+++ b/packages/formula-plugin/src/formula.cellEditor.ts
@@ -0,0 +1,1030 @@
+import { BindingEventService } from '@slickgrid-universal/binding';
+import type { Editor, EditorArguments, EditorValidationResult, SelectionModel } from '@slickgrid-universal/common';
+import { createDomElement, SlickRange } from '@slickgrid-universal/common';
+import {
+ buildFormulaReferenceCssHash,
+ createFormulaReferenceTokenRegex,
+ FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY,
+ FormulaReferenceColorCache,
+ getExcelColumnNameByIndex,
+ normalizeFormulaReferenceToken,
+ parseExcelReferenceCell,
+ setFormulaObjectProperty,
+} from './formula-reference.js';
+
+export interface FormulaEditorParams {
+ debug?: boolean;
+ formulaFunctionList?: string[];
+ onFormulaInputChange?: (formula: string) => void;
+ /** Convert the persisted formula to the user-facing A1 form when the editor opens. */
+ toDisplayFormula?: (formula: string, item?: any) => string;
+ /** Convert the user-facing A1 form to the persisted formula form on commit. */
+ toStoredFormula?: (formula: string, item?: any) => string;
+ /** Notify the formula service after a formula has been committed. */
+ onFormulaCommit?: (formula: string, item?: any) => void;
+}
+
+export class FormulaCellEditor implements Editor {
+ protected _autocompleteElm?: HTMLDivElement;
+ protected _autocompleteItems: string[] = [];
+ protected _autocompleteSelectedIdx = 0;
+ protected _editorElm!: HTMLDivElement;
+ protected _gridContainerElm?: HTMLElement;
+ protected _blurRestoreTimer?: ReturnType;
+ protected _isDraggingGridRefSelection = false;
+ protected _isOpenedByTabKey = false;
+ protected _isDestroyed = false;
+ protected _isExitingEditor = false;
+ protected _isValueTouched = false;
+ protected _originalValue = '';
+ protected _referenceEditRange?: { start: number; end: number };
+ protected _referenceRangeAnchorCell?: { row: number; cell: number };
+ protected _selectionRangesBeforeFormulaHighlight?: SlickRange[];
+ protected _suppressNextGridClick = false;
+ protected _suppressGridClickResetTimer?: ReturnType;
+ protected _suppressInitialTabBlur = false;
+ protected _tabNavigateTimer?: ReturnType;
+ protected _isSyncingReferenceFromCaret = false;
+ protected _isSelectionModelHighlightActive = false;
+ protected _plainTextValue = ''; // Keep plain text in sync with DOM for reliable copy/paste
+ protected _formulaRefColorCache: FormulaReferenceColorCache = new FormulaReferenceColorCache();
+ protected _bindEventService: BindingEventService = new BindingEventService();
+ protected _debug = false;
+
+ protected _initialLoadComplete = false; // Skip sync on first focusin during editor load
+
+ constructor(protected readonly args: EditorArguments) {
+ this._isOpenedByTabKey = (this.args.event as KeyboardEvent | undefined)?.key === 'Tab';
+ // Some grid focus transitions trigger an immediate blur right after editor activation.
+ // Suppress the first external blur by default to keep keyboard focus inside the grid.
+ this._suppressInitialTabBlur = true;
+ this.init();
+ }
+
+ init(): void {
+ // Extract debug flag from editor params
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ this._debug = editorParams?.debug ?? false;
+
+ this._editorElm = createDomElement('div', { className: 'formula-editor-input' });
+ this._editorElm.setAttribute('contenteditable', 'plaintext-only');
+ this._editorElm.setAttribute('role', 'textbox');
+ this._editorElm.setAttribute('spellcheck', 'false');
+ this.args.container.appendChild(this._editorElm);
+
+ this._bindEventService.bind(this._editorElm, 'input', this.handleInput.bind(this));
+ this._bindEventService.bind(this._editorElm, 'paste', this.handlePaste.bind(this) as EventListener);
+ this._bindEventService.bind(this._editorElm, 'keydown', this.handleKeydown.bind(this) as EventListener);
+ this._bindEventService.bind(this._editorElm, 'keyup', this.handleEditorKeyUp.bind(this));
+ this._bindEventService.bind(this._editorElm, 'focusin', this.handleFocusIn.bind(this));
+ this._bindEventService.bind(this._editorElm, 'focusout', this.handleFocusOut.bind(this) as EventListener);
+ this._bindEventService.bind(this._editorElm, 'mouseup', this.handleEditorMouseUp.bind(this));
+
+ // Capture grid pointer interactions while formula typing is active to support click/drag reference picking.
+ // Use window capture phase so we run before SlickGrid's normal click lifecycle.
+ this._gridContainerElm = this.args.grid.getContainerNode?.();
+ this._bindEventService.bind(window, 'mousedown', this.handleWindowMouseDown as EventListener, true);
+ this._bindEventService.bind(window, 'click', this.handleWindowClick as EventListener, true);
+ this._bindEventService.bind(window, 'mousemove', this.handleWindowMouseMove as EventListener, true);
+ this._bindEventService.bind(window, 'mouseup', this.handleWindowMouseUp as EventListener, true);
+ }
+
+ destroy(): void {
+ this._isDestroyed = true;
+ clearTimeout(this._blurRestoreTimer);
+ clearTimeout(this._suppressGridClickResetTimer);
+ clearTimeout(this._tabNavigateTimer);
+ this.hideAutocomplete();
+ this.clearReferenceSelectionHighlight();
+ this.clearFormulaReferenceColors();
+ this._bindEventService.unbindAll();
+ this._autocompleteElm?.remove();
+ this._editorElm?.remove();
+ }
+
+ focus(): void {
+ this.args.grid.focus('internal');
+ this._editorElm.focus();
+ this.setCursorAtEnd();
+ }
+
+ loadValue(item: any): void {
+ const field = this.args.column.field as string;
+ const value = item?.[field] ?? '';
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ const displayValue = editorParams?.toDisplayFormula?.(String(value), item) ?? String(value);
+ this._originalValue = displayValue;
+ this._plainTextValue = this._originalValue; // Keep in sync
+ this._editorElm.textContent = this._originalValue;
+
+ // Build cache first so colors are assigned correctly (this also applies colors to grid)
+ this.buildFormulaReferenceColorCache();
+
+ this.renderTokens();
+ this._initialLoadComplete = true; // Allow sync on subsequent focusin events
+ }
+
+ serializeValue(): string {
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ return editorParams?.toStoredFormula?.(this.getPlainTextValue(), this.args.item) ?? this.getPlainTextValue();
+ }
+
+ applyValue(item: any, state: any): void {
+ const field = this.args.column.field as string;
+ setFormulaObjectProperty(item, field, state);
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ editorParams?.onFormulaCommit?.(String(state ?? ''), item);
+ }
+
+ isValueChanged(): boolean {
+ return this.getPlainTextValue() !== this._originalValue;
+ }
+
+ validate(): EditorValidationResult {
+ return { valid: true, msg: '' };
+ }
+
+ protected handleInput(): void {
+ this._isValueTouched = true;
+
+ // Extract plain text from DOM (may contain styled spans after renderTokens)
+ this._plainTextValue = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+
+ this.clearReferenceSelectionHighlight();
+
+ this.buildFormulaReferenceColorCache();
+ this.renderTokens();
+ this.syncReferenceSelectionFromCaret();
+ this.updateAutocomplete();
+ this.publishFormulaInput();
+ }
+
+ protected handlePaste(event: ClipboardEvent): void {
+ event.preventDefault();
+ const text = event.clipboardData?.getData('text/plain') || '';
+ document.execCommand('insertText', false, text);
+ this._plainTextValue = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+ this.buildFormulaReferenceColorCache();
+ this.renderTokens();
+ this.syncReferenceSelectionFromCaret();
+ this.updateAutocomplete();
+ this.publishFormulaInput();
+ }
+
+ protected handleFocusIn(): void {
+ // Skip sync on initial focusin during editor load to preserve reference colors
+ // Only sync on subsequent focus events when user is actively interacting
+ if (this._initialLoadComplete) {
+ this.syncReferenceSelectionFromCaret();
+ }
+ }
+
+ protected handleEditorKeyUp(): void {
+ this.syncReferenceSelectionFromCaret();
+ }
+
+ protected handleEditorMouseUp(): void {
+ this.syncReferenceSelectionFromCaret();
+ }
+
+ protected handleFocusOut(event: FocusEvent): void {
+ if (this._isExitingEditor) {
+ return;
+ }
+
+ const nextTarget = event.relatedTarget as Node | null;
+ const gridContainer = this.args.grid.getContainerNode?.();
+ const isFocusStillInGrid = !!(nextTarget && gridContainer?.contains(nextTarget));
+
+ if (this._suppressInitialTabBlur && !this._isValueTouched && !isFocusStillInGrid) {
+ this._suppressInitialTabBlur = false;
+ this._blurRestoreTimer = setTimeout(() => {
+ if (this._isDestroyed || !this._editorElm?.isConnected) {
+ return;
+ }
+ this.args.grid.focus('internal');
+ this._editorElm.focus();
+ this.setCursorAtEnd();
+ }, 0);
+ return;
+ }
+
+ this._suppressInitialTabBlur = false;
+ this.hideAutocomplete();
+ }
+
+ protected handleKeydown(event: KeyboardEvent): void {
+ // Keep Select-All scoped to the formula editor.
+ // Let browser default behavior select editor content, but stop SlickGrid from handling Ctrl/Cmd+A.
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'a') {
+ this.stopKeyboardEvent(event, false);
+ return;
+ }
+
+ // Handle copy/cut to ensure we copy plain text only, not HTML spans
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'c') {
+ this.stopKeyboardEvent(event);
+ const plainText = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+ navigator.clipboard.writeText(plainText).catch(() => {
+ // Fallback for older browsers
+ });
+ return;
+ }
+
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'x') {
+ this.stopKeyboardEvent(event);
+ const plainText = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+ navigator.clipboard.writeText(plainText).catch(() => {
+ // Fallback for older browsers
+ });
+ // Clear the editor after cut
+ this._plainTextValue = '';
+ this._editorElm.textContent = '';
+ this._isValueTouched = true;
+ this.buildFormulaReferenceColorCache();
+ this.renderTokens();
+ this.publishFormulaInput();
+ return;
+ }
+
+ if (this._autocompleteItems.length > 0) {
+ if (event.key === 'ArrowDown') {
+ this.stopKeyboardEvent(event);
+ this._autocompleteSelectedIdx = (this._autocompleteSelectedIdx + 1) % this._autocompleteItems.length;
+ this.renderAutocompleteItems();
+ return;
+ }
+
+ if (event.key === 'ArrowUp') {
+ this.stopKeyboardEvent(event);
+ this._autocompleteSelectedIdx =
+ (this._autocompleteSelectedIdx - 1 + this._autocompleteItems.length) % this._autocompleteItems.length;
+ this.renderAutocompleteItems();
+ return;
+ }
+
+ if (event.key === 'Enter' || event.key === 'Tab') {
+ this.stopKeyboardEvent(event);
+ this.selectAutocompleteItem(this._autocompleteItems[this._autocompleteSelectedIdx]);
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ this.hideAutocomplete();
+ }
+ }
+
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const text = this.getPlainTextValue();
+ const tokenRanges = this.getFormulaReferenceTokenRanges(text);
+ if (tokenRanges.length > 0) {
+ this.stopKeyboardEvent(event);
+
+ const caretOffset = this.getCaretOffset();
+ const previousToken = tokenRanges.filter((range) => range.start < caretOffset).pop();
+ const targetOffset =
+ event.key === 'ArrowRight'
+ ? (tokenRanges.find((range) => range.end > caretOffset)?.end ?? text.length)
+ : (previousToken?.start ?? 0);
+
+ this.moveCaretToOffset(targetOffset);
+ return;
+ }
+ }
+
+ if (event.key === 'Home' || event.key === 'End') {
+ this.stopKeyboardEvent(event);
+ this.moveCaretToOffset(event.key === 'Home' ? 0 : this.getPlainTextValue().length);
+ return;
+ }
+
+ if (!this.args.grid.getOptions().editorNavigateOnArrows && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ if (event.key === 'Enter') {
+ this.stopKeyboardEvent(event);
+ this._isExitingEditor = true;
+ this.clearReferenceSelectionHighlight();
+ const didCommit = this.args.grid.getEditorLock?.()?.commitCurrentEdit?.();
+ if (didCommit === false) {
+ this.args.commitChanges();
+ }
+ } else if (event.key === 'Tab') {
+ const grid = this.args.grid;
+ const isShiftTab = event.shiftKey;
+
+ this.stopKeyboardEvent(event);
+
+ if (this._isOpenedByTabKey && !this._isValueTouched) {
+ this._isOpenedByTabKey = false;
+ return;
+ }
+
+ this._isOpenedByTabKey = false;
+ this._suppressInitialTabBlur = false;
+ this._isExitingEditor = true;
+ this.clearReferenceSelectionHighlight();
+ const didCommit = this.args.grid.getEditorLock?.()?.commitCurrentEdit?.();
+ if (didCommit === false) {
+ this.args.commitChanges();
+ }
+
+ this._tabNavigateTimer = setTimeout(() => {
+ if (didCommit === false) {
+ return;
+ }
+ grid.focus('internal');
+ if (isShiftTab) {
+ grid.navigatePrev();
+ } else {
+ grid.navigateNext();
+ }
+ grid.focus('internal');
+ }, 0);
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ this._isExitingEditor = true;
+ this.clearReferenceSelectionHighlight();
+ this.args.cancelChanges();
+ }
+ }
+
+ protected stopKeyboardEvent(event: KeyboardEvent, preventDefault = true): void {
+ if (preventDefault) {
+ event.preventDefault();
+ }
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ }
+
+ protected handleWindowMouseDown = (event: MouseEvent): void => {
+ if (!this.shouldCaptureGridReferenceSelection(event)) {
+ return;
+ }
+
+ const cell = this.args.grid.getCellFromEvent(event);
+ if (!cell || cell.row < 0 || cell.cell < 0) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ this._isDraggingGridRefSelection = true;
+ this._suppressNextGridClick = true;
+ const referenceEditRange = this.resolveReferenceEditRangeForGridSelection();
+ this._referenceEditRange = referenceEditRange ?? this._referenceEditRange;
+
+ const existingReferenceCellRange = this._referenceEditRange
+ ? this.parseExcelReferenceCellRange(this.getPlainTextValue().slice(this._referenceEditRange.start, this._referenceEditRange.end))
+ : undefined;
+
+ this._referenceRangeAnchorCell = this.resolveReferenceSelectionAnchorCell(
+ { row: cell.row, cell: cell.cell },
+ existingReferenceCellRange
+ );
+
+ this.replaceReferenceRangeFromGridSelection(this._referenceRangeAnchorCell, cell);
+ };
+
+ protected handleWindowClick = (event: MouseEvent): void => {
+ if (!this._suppressNextGridClick || !this.isEventInsideGrid(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+ clearTimeout(this._suppressGridClickResetTimer);
+ this._suppressNextGridClick = false;
+ };
+
+ protected handleWindowMouseMove = (event: MouseEvent): void => {
+ if (!this._isDraggingGridRefSelection || !this._referenceRangeAnchorCell) {
+ return;
+ }
+
+ const cell = this.args.grid.getCellFromEvent(event);
+ if (!cell || cell.row < 0 || cell.cell < 0) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ this.replaceReferenceRangeFromGridSelection(this._referenceRangeAnchorCell, cell);
+ };
+
+ protected handleWindowMouseUp = (event: MouseEvent): void => {
+ if (!this._isDraggingGridRefSelection) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ event.stopImmediatePropagation();
+
+ this._isDraggingGridRefSelection = false;
+ // Keep click suppression active through the click phase fired right after mouseup.
+ // SlickGrid handles click to navigate/commit editor; suppressing that click keeps formula edit alive.
+ clearTimeout(this._suppressGridClickResetTimer);
+ this._suppressGridClickResetTimer = setTimeout(() => {
+ this._suppressNextGridClick = false;
+ }, 0);
+ this._referenceRangeAnchorCell = undefined;
+ this.syncReferenceSelectionFromCaret();
+ };
+
+ protected getPlainTextValue(): string {
+ return this._plainTextValue;
+ }
+
+ protected publishFormulaInput(): void {
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ editorParams?.onFormulaInputChange?.(this.getPlainTextValue());
+ }
+
+ protected setCursorAtEnd(): void {
+ if (this._isDestroyed || !this._editorElm?.isConnected) {
+ return;
+ }
+
+ const selection = window.getSelection();
+ if (!selection) {
+ return;
+ }
+ const range = document.createRange();
+ range.selectNodeContents(this._editorElm);
+ range.collapse(false);
+ try {
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } catch {
+ // Editor might already be detached from DOM during async focus transitions.
+ }
+ }
+
+ protected shouldCaptureGridReferenceSelection(event: MouseEvent): boolean {
+ if (this._isDestroyed || this._isExitingEditor || event.button !== 0) {
+ return false;
+ }
+
+ if (!this._editorElm?.isConnected) {
+ return false;
+ }
+
+ const plainText = this.getPlainTextValue().trimStart();
+ if (!plainText.startsWith('=')) {
+ return false;
+ }
+
+ if (!this.isEventInsideGrid(event)) {
+ return false;
+ }
+
+ const eventTarget = event.target as Node | null;
+ if (eventTarget && this._editorElm.contains(eventTarget)) {
+ return false;
+ }
+ if (eventTarget && this._autocompleteElm?.contains(eventTarget)) {
+ return false;
+ }
+
+ return !!this.args.grid.getCellFromEvent(event);
+ }
+
+ protected isEventInsideGrid(event: MouseEvent): boolean {
+ const eventTarget = event.target as Node | null;
+ return !!(eventTarget && this._gridContainerElm?.contains(eventTarget));
+ }
+
+ protected getReferenceTokenRangeAtCaret(): { start: number; end: number } {
+ const rangeAtCaret = this.getReferenceTokenRangeAtCaretOrUndefined();
+ if (rangeAtCaret) {
+ return rangeAtCaret;
+ }
+
+ const caretOffset = this.getCaretOffset();
+ return { start: caretOffset, end: caretOffset };
+ }
+
+ protected getReferenceTokenRangeAtCaretOrUndefined(): { start: number; end: number } | undefined {
+ const text = this.getPlainTextValue();
+ const caretOffset = this.getCaretOffset();
+ const regex = createFormulaReferenceTokenRegex();
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(text)) !== null) {
+ const start = match.index;
+ const end = start + match[0].length;
+ if (caretOffset >= start && caretOffset <= end) {
+ return { start, end };
+ }
+ }
+
+ return undefined;
+ }
+
+ protected syncReferenceSelectionFromCaret(): void {
+ if (this._isSyncingReferenceFromCaret || this._isDraggingGridRefSelection || this._isDestroyed || !this._editorElm?.isConnected) {
+ return;
+ }
+
+ const rawFormulaText = this.getPlainTextValue().trimStart();
+ if (!rawFormulaText.startsWith('=')) {
+ this._referenceEditRange = undefined;
+ this.clearReferenceSelectionHighlight();
+ return;
+ }
+
+ const activeReferenceRange = this.getReferenceTokenRangeAtCaretOrUndefined();
+ if (!activeReferenceRange) {
+ this._referenceEditRange = undefined;
+ this.clearReferenceSelectionHighlight();
+ return;
+ }
+
+ const referenceToken = this.getPlainTextValue().slice(activeReferenceRange.start, activeReferenceRange.end);
+ const parsedRange = this.parseExcelReferenceCellRange(referenceToken);
+ this._referenceEditRange = activeReferenceRange;
+
+ if (!parsedRange) {
+ this.clearReferenceSelectionHighlight();
+ return;
+ }
+
+ this._isSyncingReferenceFromCaret = true;
+ try {
+ this.renderGridSelectionHighlight(parsedRange.startCell, parsedRange.endCell);
+ } finally {
+ this._isSyncingReferenceFromCaret = false;
+ }
+ }
+
+ protected parseExcelReferenceCellRange(
+ referenceToken: string
+ ): { startCell: { row: number; cell: number }; endCell: { row: number; cell: number } } | undefined {
+ const normalizedReferenceToken = normalizeFormulaReferenceToken(referenceToken);
+ if (!normalizedReferenceToken) {
+ return undefined;
+ }
+
+ const [startToken, endToken] = normalizedReferenceToken.includes(':')
+ ? normalizedReferenceToken.split(':', 2)
+ : [normalizedReferenceToken, normalizedReferenceToken];
+
+ const startCell = parseExcelReferenceCell(startToken);
+ const endCell = parseExcelReferenceCell(endToken);
+ if (!startCell || !endCell) {
+ return undefined;
+ }
+
+ return { startCell, endCell };
+ }
+
+ protected replaceReferenceRangeFromGridSelection(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): void {
+ const nextReference = this.buildExcelReferenceFromCellRange(startCell, endCell);
+ const text = this.getPlainTextValue();
+ const replaceRange = this._referenceEditRange ?? this.getReferenceTokenRangeAtCaret();
+ const safeStart = Math.max(0, Math.min(replaceRange.start, text.length));
+ const safeEnd = Math.max(safeStart, Math.min(replaceRange.end, text.length));
+
+ const nextText = `${text.slice(0, safeStart)}${nextReference}${text.slice(safeEnd)}`;
+ this._referenceEditRange = { start: safeStart, end: safeStart + nextReference.length };
+
+ this._plainTextValue = nextText; // Keep in sync
+ this._editorElm.textContent = nextText;
+ this.buildFormulaReferenceColorCache();
+ this.renderTokens();
+ this.args.grid.focus('internal');
+ this._editorElm.focus();
+ this.restoreCaretOffset(this._referenceEditRange.end);
+ this._isValueTouched = true;
+ this.publishFormulaInput();
+ this.renderGridSelectionHighlight(startCell, endCell);
+ }
+
+ protected resolveReferenceEditRangeForGridSelection(): { start: number; end: number } | undefined {
+ if (this._referenceEditRange) {
+ const text = this.getPlainTextValue();
+ const safeStart = Math.max(0, Math.min(this._referenceEditRange.start, text.length));
+ const safeEnd = Math.max(safeStart, Math.min(this._referenceEditRange.end, text.length));
+ if (safeEnd > safeStart) {
+ return { start: safeStart, end: safeEnd };
+ }
+ }
+
+ const rangeAtCaret = this.getReferenceTokenRangeAtCaretOrUndefined();
+ if (rangeAtCaret) {
+ return rangeAtCaret;
+ }
+
+ if (this.shouldInsertReferenceAtCaret()) {
+ const caretOffset = this.getCaretOffset();
+ return { start: caretOffset, end: caretOffset };
+ }
+
+ return this.getSingleReferenceTokenRangeOrUndefined();
+ }
+
+ protected shouldInsertReferenceAtCaret(): boolean {
+ const text = this.getPlainTextValue();
+ const caretOffset = this.getCaretOffset();
+ const textBeforeCaret = text.slice(0, caretOffset);
+ if (!textBeforeCaret.trimStart().startsWith('=')) {
+ return false;
+ }
+
+ const textBeforeCaretTrimEnd = textBeforeCaret.replace(/\s+$/, '');
+ const lastChar = textBeforeCaretTrimEnd[textBeforeCaretTrimEnd.length - 1];
+ return /[=,(+\-*/^&:]/.test(lastChar);
+ }
+
+ protected getSingleReferenceTokenRangeOrUndefined(): { start: number; end: number } | undefined {
+ const text = this.getPlainTextValue();
+ const regex = createFormulaReferenceTokenRegex();
+ const firstMatch = regex.exec(text);
+ if (!firstMatch) {
+ return undefined;
+ }
+
+ const secondMatch = regex.exec(text);
+ if (secondMatch) {
+ return undefined;
+ }
+
+ return { start: firstMatch.index, end: firstMatch.index + firstMatch[0].length };
+ }
+
+ protected resolveReferenceSelectionAnchorCell(
+ selectedCell: { row: number; cell: number },
+ existingReferenceCellRange?: { startCell: { row: number; cell: number }; endCell: { row: number; cell: number } }
+ ): { row: number; cell: number } {
+ if (!existingReferenceCellRange) {
+ return selectedCell;
+ }
+
+ const { startCell, endCell } = existingReferenceCellRange;
+ if (this.cellsAreEqual(selectedCell, startCell)) {
+ return endCell;
+ }
+ if (this.cellsAreEqual(selectedCell, endCell)) {
+ return startCell;
+ }
+
+ return selectedCell;
+ }
+
+ protected cellsAreEqual(cellA: { row: number; cell: number }, cellB: { row: number; cell: number }): boolean {
+ return cellA.row === cellB.row && cellA.cell === cellB.cell;
+ }
+
+ protected buildExcelReferenceFromCellRange(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): string {
+ const startColIdx = Math.min(startCell.cell, endCell.cell);
+ const endColIdx = Math.max(startCell.cell, endCell.cell);
+ const startRowIdx = Math.min(startCell.row, endCell.row);
+ const endRowIdx = Math.max(startCell.row, endCell.row);
+
+ // getExcelColumnNameByIndex expects a 1-based column number, grid cell index is 0-based
+ const startRef = `${getExcelColumnNameByIndex(startColIdx + 1)}${startRowIdx + 1}`;
+ const endRef = `${getExcelColumnNameByIndex(endColIdx + 1)}${endRowIdx + 1}`;
+ return startRef === endRef ? startRef : `${startRef}:${endRef}`;
+ }
+
+ protected renderGridSelectionHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): void {
+ // The grid already has persistent formula reference colors applied from applyFormulaReferenceCellColors()
+ // This method just manages the selection model highlighting, not the colors
+ // Use the selection model to show a blue highlight box around the reference
+ // The persistent colors are already applied by applyFormulaReferenceCellColors()
+ this.renderSelectionModelHighlight(startCell, endCell);
+ }
+
+ /**
+ * Refresh the shared formula → reference → color → cells cache.
+ * Must be called before any rendering or grid cell coloring operations.
+ */
+ protected buildFormulaReferenceColorCache(): void {
+ this._formulaRefColorCache.update(this.getPlainTextValue());
+ this.applyFormulaReferenceCellColors();
+ }
+
+ /**
+ * Apply all cached formula reference colors to their corresponding grid cells.
+ * This paints the entire grid to show all formula references in their colors.
+ */
+ protected applyFormulaReferenceCellColors(): void {
+ if (!this._formulaRefColorCache.isDirty) {
+ return; // No colors to apply
+ }
+
+ const hash = buildFormulaReferenceCssHash(this._formulaRefColorCache.values(), this.args.grid.getColumns?.() || []);
+
+ if (Object.keys(hash).length > 0) {
+ this.args.grid.setCellCssStyles?.(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, hash as any);
+ } else {
+ this.clearFormulaReferenceColors();
+ }
+
+ this._formulaRefColorCache.markClean();
+ }
+
+ protected clearFormulaReferenceColors(): void {
+ this.args.grid.removeCellCssStyles?.(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ }
+
+ protected clearReferenceSelectionHighlight(): void {
+ const selectionModel = this.getGridSelectionModel();
+ const hadSelectionHighlight = this._isSelectionModelHighlightActive;
+
+ if (hadSelectionHighlight) {
+ selectionModel?.setSelectedRanges(
+ this._selectionRangesBeforeFormulaHighlight ?? [],
+ 'FormulaCellEditor.clearReferenceSelectionHighlight',
+ ''
+ );
+ this._isSelectionModelHighlightActive = false;
+ }
+ this._selectionRangesBeforeFormulaHighlight = undefined;
+
+ // When exiting the editor, also clear persistent formula colors
+ // Otherwise they linger after ENTER/Escape even though the editor is closed
+ if (this._isExitingEditor) {
+ this.clearFormulaReferenceColors();
+ }
+ }
+
+ protected renderSelectionModelHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): boolean {
+ const selectionModel = this.getGridSelectionModel();
+ if (!selectionModel) {
+ return false;
+ }
+
+ if (!this._isSelectionModelHighlightActive) {
+ const selectedRanges =
+ typeof selectionModel.getSelectedRanges === 'function' ? selectionModel.getSelectedRanges() : ([] as SlickRange[]);
+ this._selectionRangesBeforeFormulaHighlight = selectedRanges.map(
+ (range) => new SlickRange(range.fromRow, range.fromCell, range.toRow, range.toCell)
+ );
+ }
+
+ selectionModel.setSelectedRanges(
+ [new SlickRange(startCell.row, startCell.cell, endCell.row, endCell.cell)],
+ 'FormulaCellEditor.renderSelectionModelHighlight',
+ ''
+ );
+ this._isSelectionModelHighlightActive = true;
+ return true;
+ }
+
+ protected getGridSelectionModel(): SelectionModel | undefined {
+ const selectionModel = this.args.grid.getSelectionModel?.() as SelectionModel | undefined;
+ if (!selectionModel || typeof selectionModel.setSelectedRanges !== 'function') {
+ return undefined;
+ }
+ return selectionModel;
+ }
+
+ protected getCaretOffset(): number {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return this.getPlainTextValue().length;
+ }
+
+ const range = selection.getRangeAt(0);
+ const preRange = range.cloneRange();
+ preRange.selectNodeContents(this._editorElm);
+ preRange.setEnd(range.endContainer, range.endOffset);
+ return preRange.toString().length;
+ }
+
+ protected moveCaretToOffset(offset: number): void {
+ this._editorElm.focus({ preventScroll: true });
+ this.restoreCaretOffset(offset);
+ this._editorElm.scrollLeft = offset === 0 ? 0 : this._editorElm.scrollWidth;
+ }
+
+ protected getFormulaReferenceTokenRanges(text: string): Array<{ start: number; end: number }> {
+ return Array.from(text.matchAll(createFormulaReferenceTokenRegex()), (match) => ({
+ start: match.index,
+ end: match.index + match[0].length,
+ }));
+ }
+
+ protected restoreCaretOffset(offset: number): void {
+ if (this._isDestroyed || !this._editorElm?.isConnected) {
+ return;
+ }
+
+ const selection = window.getSelection();
+ if (!selection) {
+ return;
+ }
+
+ const walker = document.createTreeWalker(this._editorElm, NodeFilter.SHOW_TEXT);
+ let currentOffset = 0;
+ let node: Node | null = walker.nextNode();
+
+ while (node) {
+ const textLength = (node.textContent || '').length;
+ if (currentOffset + textLength >= offset) {
+ const range = document.createRange();
+ range.setStart(node, Math.max(0, offset - currentOffset));
+ range.collapse(true);
+ try {
+ selection.removeAllRanges();
+ selection.addRange(range);
+ } catch {
+ // Editor might already be detached from DOM during async focus transitions.
+ }
+ return;
+ }
+ currentOffset += textLength;
+ node = walker.nextNode();
+ }
+
+ this.setCursorAtEnd();
+ }
+
+ protected renderTokens(): void {
+ const raw = this.getPlainTextValue();
+ if (!raw.startsWith('=')) {
+ this._editorElm.textContent = raw;
+ return;
+ }
+
+ const caret = this.getCaretOffset();
+ // Read from the shared cache; callers are responsible for calling buildFormulaReferenceColorCache() first
+ const refColorCache = this._formulaRefColorCache;
+
+ const referenceTokenRegex = createFormulaReferenceTokenRegex();
+ // Build nodes via the DOM API (instead of innerHTML+string concat) so untrusted formula
+ // text (e.g. `=A1&""`) can never be parsed as markup.
+ const fragment = document.createDocumentFragment();
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = referenceTokenRegex.exec(raw)) !== null) {
+ if (match.index > lastIndex) {
+ fragment.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
+ }
+
+ const normalizedRef = normalizeFormulaReferenceToken(match[0]);
+ const colorIdx = refColorCache.get(normalizedRef)?.colorIdx ?? 0;
+ const colorClass = `formula-token-color-${colorIdx + 1}`;
+ const span = createDomElement('span', { className: `formula-token ${colorClass}` });
+ span.textContent = match[0];
+ fragment.appendChild(span);
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ if (lastIndex < raw.length) {
+ fragment.appendChild(document.createTextNode(raw.slice(lastIndex)));
+ }
+
+ this._editorElm.innerHTML = '';
+ this._editorElm.appendChild(fragment);
+ this.restoreCaretOffset(caret);
+ }
+
+ protected getFormulaFunctionList(): string[] {
+ const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined;
+ const list = editorParams?.formulaFunctionList;
+ return Array.isArray(list) ? list : [];
+ }
+
+ protected updateAutocomplete(): void {
+ const text = this.getPlainTextValue();
+ const caretOffset = this.getCaretOffset();
+ const textBeforeCaret = text.slice(0, caretOffset);
+ const allFunctions = this.getFormulaFunctionList();
+ if (!textBeforeCaret.startsWith('=') || allFunctions.length === 0) {
+ this.hideAutocomplete();
+ return;
+ }
+
+ const match = textBeforeCaret.match(/(?:^|[^A-Za-z0-9_]\s*)([A-Za-z_][A-Za-z0-9_]*)?$/);
+ const prefix = (match?.[1] || '').toUpperCase();
+ if (!prefix) {
+ this.hideAutocomplete();
+ return;
+ }
+
+ const suggestions = allFunctions
+ .filter((name) => name.toUpperCase().startsWith(prefix))
+ .sort((a, b) => a.localeCompare(b))
+ .slice(0, 12);
+
+ if (!suggestions.length) {
+ this.hideAutocomplete();
+ return;
+ }
+
+ this._autocompleteItems = suggestions;
+ this._autocompleteSelectedIdx = 0;
+ this.ensureAutocompleteElement();
+ this.renderAutocompleteItems();
+ this.positionAutocomplete();
+ this._autocompleteElm!.style.display = 'block';
+ }
+
+ protected ensureAutocompleteElement(): void {
+ if (this._autocompleteElm) {
+ return;
+ }
+
+ const elm = createDomElement('div', {
+ className: 'slick-autocomplete formula-autocomplete',
+ style: {
+ position: 'fixed',
+ zIndex: '1000',
+ display: 'none',
+ },
+ });
+ document.body.appendChild(elm);
+ this._autocompleteElm = elm;
+ }
+
+ protected positionAutocomplete(): void {
+ if (!this._autocompleteElm || !this._editorElm?.isConnected) {
+ return;
+ }
+
+ const rect = this._editorElm.getBoundingClientRect();
+ this._autocompleteElm.style.left = `${Math.round(rect.left)}px`;
+ this._autocompleteElm.style.top = `${Math.round(rect.bottom + 2)}px`;
+ this._autocompleteElm.style.minWidth = `${Math.max(140, Math.round(rect.width))}px`;
+ }
+
+ protected renderAutocompleteItems(): void {
+ if (!this._autocompleteElm) {
+ return;
+ }
+
+ this._autocompleteElm.innerHTML = '';
+ for (let i = 0; i < this._autocompleteItems.length; i++) {
+ const suggestion = this._autocompleteItems[i];
+ const itemElm = createDomElement('div', {
+ className: i === this._autocompleteSelectedIdx ? 'selected' : '',
+ });
+ itemElm.textContent = suggestion;
+ itemElm.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ this.selectAutocompleteItem(suggestion);
+ });
+ this._autocompleteElm.appendChild(itemElm);
+ }
+ }
+
+ protected hideAutocomplete(): void {
+ this._autocompleteItems = [];
+ this._autocompleteSelectedIdx = 0;
+ if (this._autocompleteElm) {
+ this._autocompleteElm.style.display = 'none';
+ this._autocompleteElm.innerHTML = '';
+ }
+ }
+
+ protected selectAutocompleteItem(functionName?: string): void {
+ if (!functionName) {
+ return;
+ }
+
+ // Read directly from DOM to handle cases where textContent was set externally (e.g., in tests)
+ const text = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+ const caretOffset = this.getCaretOffset();
+ const textBeforeCaret = text.slice(0, caretOffset);
+ const textAfterCaret = text.slice(caretOffset);
+ const match = textBeforeCaret.match(/(?:^|[^A-Za-z0-9_]\s*)([A-Za-z_][A-Za-z0-9_]*)?$/);
+ if (!match) {
+ return;
+ }
+
+ const typedPrefix = match[1] || '';
+ const replaceStart = caretOffset - typedPrefix.length;
+ const afterTrimStart = textAfterCaret.trimStart();
+ const whitespacePrefixLength = textAfterCaret.length - afterTrimStart.length;
+ const hasOpeningParenAlready = afterTrimStart.startsWith('(');
+ const openingParenSuffix = hasOpeningParenAlready ? '' : '(';
+
+ const nextText = `${text.slice(0, replaceStart)}${functionName}${openingParenSuffix}${textAfterCaret}`;
+ const nextCaret = hasOpeningParenAlready
+ ? replaceStart + functionName.length + whitespacePrefixLength + 1
+ : replaceStart + functionName.length + 1;
+
+ this._plainTextValue = nextText; // Keep in sync
+ this._editorElm.textContent = nextText;
+ // Manually update _plainTextValue from DOM after setting textContent to ensure sync
+ this._plainTextValue = (this._editorElm.textContent || '').replace(/\u00a0/g, ' ');
+ this.buildFormulaReferenceColorCache();
+ this.renderTokens();
+ this.restoreCaretOffset(nextCaret);
+ this._isValueTouched = true;
+ this.hideAutocomplete();
+ this.publishFormulaInput();
+ }
+}
diff --git a/packages/formula-plugin/src/formula.drag-fill.ts b/packages/formula-plugin/src/formula.drag-fill.ts
new file mode 100644
index 000000000..5e3b31786
--- /dev/null
+++ b/packages/formula-plugin/src/formula.drag-fill.ts
@@ -0,0 +1,268 @@
+import type { Column, OnDragReplaceCellsEventArgs, SlickDataView, SlickGrid, SlickRange } from '@slickgrid-universal/common';
+import { SlickSelectionUtils } from '@slickgrid-universal/common';
+import { getExcelColumnIndexByName, getExcelColumnNameByIndex, setFormulaObjectProperty } from './formula-reference.js';
+
+/** Internal callbacks used by FormulaService to keep storage and display concerns in the service. */
+export interface FormulaDragFillContext {
+ grid: SlickGrid;
+ dataView: SlickDataView;
+ getDatasetIdPropertyName: () => string;
+ getFormula: (rowId: number | string, columnId: number | string) => string | undefined;
+ setFormula: (rowId: number | string, columnId: number | string, formula?: string | null) => void;
+ toStoredFormula: (formula: string) => string;
+ toDisplayFormulaForCell: (formula: string, rowId: number | string, columnId: number | string) => string;
+}
+
+type FormulaFillDirection = 'horizontal' | 'vertical';
+
+interface FormulaFillTarget {
+ direction: FormulaFillDirection;
+ range: SlickRange;
+}
+
+/** Fill formula cells through the same target-range semantics as the spreadsheet drag-fill example. */
+export function handleFormulaDragFill(args: OnDragReplaceCellsEventArgs, context: FormulaDragFillContext): void {
+ const baseRange = args?.prevSelectedRange;
+ const selectedRange = args?.selectedRange;
+ if (!baseRange || !selectedRange || !context.grid?.getVisibleColumns) {
+ return;
+ }
+
+ const verticalTargetRange = SlickSelectionUtils.verticalTargetRange(baseRange, selectedRange);
+ const horizontalTargetRange = SlickSelectionUtils.horizontalTargetRange(baseRange, selectedRange);
+ const cornerTargetRange = SlickSelectionUtils.cornerTargetRange(baseRange, selectedRange);
+ const addedRowCount = Math.max(0, baseRange.fromRow - selectedRange.fromRow) + Math.max(0, selectedRange.toRow - baseRange.toRow);
+ const addedCellCount = Math.max(0, baseRange.fromCell - selectedRange.fromCell) + Math.max(0, selectedRange.toCell - baseRange.toCell);
+ const cornerDirection: FormulaFillDirection = addedRowCount >= addedCellCount ? 'vertical' : 'horizontal';
+ const fillTargets: FormulaFillTarget[] = [];
+ if (verticalTargetRange) {
+ fillTargets.push({ direction: 'vertical', range: verticalTargetRange });
+ }
+ if (horizontalTargetRange) {
+ fillTargets.push({ direction: 'horizontal', range: horizontalTargetRange });
+ }
+ if (cornerTargetRange) {
+ fillTargets.push({ direction: cornerDirection, range: cornerTargetRange });
+ }
+ if (fillTargets.length === 0) {
+ return;
+ }
+
+ const visibleColumns = context.grid.getVisibleColumns() as Column[];
+ const allColumns = (context.grid.getColumns?.() || []) as Column[];
+ const updatedItems = new Map();
+ const valueSeriesCache = new Map();
+ const rowIdProperty = context.getDatasetIdPropertyName();
+
+ for (const { direction, range: targetRange } of fillTargets) {
+ for (let rowOffset = 0; rowOffset < targetRange.rowCount(); rowOffset++) {
+ const targetRow = targetRange.fromRow + rowOffset;
+ const sourceRow = baseRange.fromRow + (rowOffset % baseRange.rowCount());
+ const targetItem = context.grid.getDataItem(targetRow);
+ const sourceItem = context.grid.getDataItem(sourceRow);
+ const targetRowId = targetItem?.[rowIdProperty] as number | string | undefined;
+ const sourceRowId = sourceItem?.[rowIdProperty] as number | string | undefined;
+ if (targetRowId === undefined || targetRowId === null || sourceRowId === undefined || sourceRowId === null) {
+ continue;
+ }
+
+ for (let cellOffset = 0; cellOffset < targetRange.cellCount(); cellOffset++) {
+ const targetVisibleCell = targetRange.fromCell + cellOffset;
+ const sourceVisibleCell = baseRange.fromCell + (cellOffset % baseRange.cellCount());
+ const targetColumn = visibleColumns[targetVisibleCell];
+ const sourceColumn = visibleColumns[sourceVisibleCell];
+ if (!targetColumn?.allowFormula || !sourceColumn) {
+ continue;
+ }
+
+ const targetField = String(targetColumn.field ?? targetColumn.id);
+ const sourceFormula = getFormulaOrRawValue(sourceItem, sourceRowId, sourceColumn, context.getFormula);
+ if (sourceFormula) {
+ const sourceColumnIndex = allColumns.findIndex((column) => String(column.id) === String(sourceColumn.id));
+ const targetColumnIndex = allColumns.findIndex((column) => String(column.id) === String(targetColumn.id));
+ if (sourceColumnIndex < 0 || targetColumnIndex < 0) {
+ continue;
+ }
+
+ const displayFormula = context.toDisplayFormulaForCell(sourceFormula, sourceRowId, sourceColumn.id);
+ const translatedFormula = translateFormulaReferences(
+ displayFormula,
+ targetRow - sourceRow,
+ targetColumnIndex - sourceColumnIndex
+ );
+ setFormulaObjectProperty(targetItem, targetField, context.toStoredFormula(translatedFormula));
+ context.setFormula(targetRowId, targetColumn.id, translatedFormula);
+ } else {
+ const { seriesIndex, sourceValues } = getSourceValueSeries(
+ baseRange,
+ direction,
+ targetRow,
+ targetVisibleCell,
+ visibleColumns,
+ context.grid,
+ valueSeriesCache
+ );
+ setFormulaObjectProperty(targetItem, targetField, getFillSeriesValue(sourceValues, seriesIndex));
+ context.setFormula(targetRowId, targetColumn.id, null);
+ }
+ updatedItems.set(String(targetRowId), { id: targetRowId, item: targetItem });
+ }
+ }
+ }
+
+ if (updatedItems.size > 0) {
+ const updates = Array.from(updatedItems.values());
+ if (typeof context.dataView?.updateItems === 'function') {
+ context.dataView.updateItems(
+ updates.map(({ id }) => id),
+ updates.map(({ item }) => item)
+ );
+ } else if (typeof context.dataView?.updateItem === 'function') {
+ updates.forEach(({ id, item }) => context.dataView.updateItem(id, item));
+ }
+ }
+}
+
+function getSourceValueSeries(
+ baseRange: SlickRange,
+ direction: FormulaFillDirection,
+ targetRow: number,
+ targetCell: number,
+ columns: Column[],
+ grid: SlickGrid,
+ cache: Map
+): { seriesIndex: number; sourceValues: unknown[] } {
+ const options = grid.getOptions();
+ const getSourceValue = (row: number, cell: number): unknown => {
+ const column = columns[cell];
+ const item = grid.getDataItem(row);
+ if (!column || column.hidden || !item) {
+ return undefined;
+ }
+ return options.dataItemColumnValueExtractor ? options.dataItemColumnValueExtractor(item, column) : item[column.field];
+ };
+
+ if (direction === 'vertical') {
+ const sourceCell = baseRange.fromCell + positiveModulo(targetCell - baseRange.fromCell, baseRange.cellCount());
+ const cacheKey = `v${sourceCell}`;
+ let sourceValues = cache.get(cacheKey);
+ if (!sourceValues) {
+ sourceValues = [];
+ for (let sourceRow = baseRange.fromRow; sourceRow <= baseRange.toRow; sourceRow++) {
+ sourceValues.push(getSourceValue(sourceRow, sourceCell));
+ }
+ cache.set(cacheKey, sourceValues);
+ }
+ return { seriesIndex: targetRow - baseRange.fromRow, sourceValues };
+ }
+
+ const sourceRow = baseRange.fromRow + positiveModulo(targetRow - baseRange.fromRow, baseRange.rowCount());
+ const cacheKey = `h${sourceRow}`;
+ let sourceValues = cache.get(cacheKey);
+ if (!sourceValues) {
+ sourceValues = [];
+ for (let sourceCell = baseRange.fromCell; sourceCell <= baseRange.toCell; sourceCell++) {
+ sourceValues.push(getSourceValue(sourceRow, sourceCell));
+ }
+ cache.set(cacheKey, sourceValues);
+ }
+ return { seriesIndex: targetCell - baseRange.fromCell, sourceValues };
+}
+
+/** AG Grid-style default: copy one value, continue numeric ranges, and repeat mixed ranges. */
+// fallow-ignore-next-line unused-export
+export function getFillSeriesValue(sourceValues: unknown[], seriesIndex: number): unknown {
+ if (sourceValues.length === 0) {
+ return undefined;
+ }
+
+ const numericValues = sourceValues.map((value) => {
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : undefined;
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ const numericValue = Number(value);
+ return Number.isFinite(numericValue) ? numericValue : undefined;
+ }
+ return undefined;
+ });
+
+ if (sourceValues.length > 1 && numericValues.every((value): value is number => value !== undefined)) {
+ const firstValue = numericValues[0];
+ const lastValue = numericValues[numericValues.length - 1];
+ const step = (lastValue - firstValue) / (sourceValues.length - 1);
+ return firstValue + step * seriesIndex;
+ }
+
+ return sourceValues[positiveModulo(seriesIndex, sourceValues.length)];
+}
+
+function positiveModulo(value: number, divisor: number): number {
+ return ((value % divisor) + divisor) % divisor;
+}
+
+function getFormulaOrRawValue(
+ item: any,
+ rowId: number | string,
+ column: Column,
+ getFormula: FormulaDragFillContext['getFormula']
+): string | undefined {
+ const storedFormula = getFormula(rowId, column.id);
+ if (storedFormula?.trim().startsWith('=')) {
+ return storedFormula.trim();
+ }
+
+ const field = String(column.field ?? column.id);
+ const rawValue = item?.[field];
+ return typeof rawValue === 'string' && rawValue.trim().startsWith('=') ? rawValue.trim() : undefined;
+}
+
+/** Shift relative A1 references while leaving quoted literals untouched. */
+// fallow-ignore-next-line unused-export
+export function translateFormulaReferences(formula: string, rowDelta: number, columnDelta: number): string {
+ const referenceRegex = /(?
+ segment.replace(referenceRegex, (reference) =>
+ reference
+ .split(':')
+ .map((endpoint) => translateFormulaReferenceEndpoint(endpoint.trim(), rowDelta, columnDelta))
+ .join(':')
+ )
+ );
+}
+
+function translateFormulaReferenceEndpoint(reference: string, rowDelta: number, columnDelta: number): string {
+ const match = reference.match(/^(\$?)([A-Z]{1,3})(\$?)(\d+)$/i);
+ /* v8 ignore if - callers only pass endpoints matched by the validating reference regex */
+ if (!match) {
+ return reference;
+ }
+
+ const columnIsAbsolute = match[1] === '$';
+ const rowIsAbsolute = match[3] === '$';
+ const columnIndex = getExcelColumnIndexByName(match[2].toUpperCase());
+ const rowIndex = Number.parseInt(match[4], 10) - 1;
+ /* v8 ignore if - the endpoint regex guarantees a valid Excel column and row */
+ if (columnIndex < 0 || !Number.isFinite(rowIndex)) {
+ return reference;
+ }
+
+ const shiftedColumnIndex = Math.max(0, columnIndex + (columnIsAbsolute ? 0 : columnDelta));
+ const shiftedRowIndex = Math.max(0, rowIndex + (rowIsAbsolute ? 0 : rowDelta));
+ return `${columnIsAbsolute ? '$' : ''}${getExcelColumnNameByIndex(shiftedColumnIndex + 1)}${rowIsAbsolute ? '$' : ''}${shiftedRowIndex + 1}`;
+}
+
+function transformFormulaOutsideQuotedStrings(formula: string, transform: (segment: string) => string): string {
+ const quotedTextRegex = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
+ let result = '';
+ let previousEnd = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = quotedTextRegex.exec(formula)) !== null) {
+ result += transform(formula.slice(previousEnd, match.index));
+ result += match[0];
+ previousEnd = match.index + match[0].length;
+ }
+
+ return result + transform(formula.slice(previousEnd));
+}
diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts
new file mode 100644
index 000000000..2a5f967eb
--- /dev/null
+++ b/packages/formula-plugin/src/formula.service.ts
@@ -0,0 +1,1642 @@
+import type {
+ Column,
+ ColumnEditor,
+ ContainerService,
+ ExternalResource,
+ Formatter,
+ FormulaExcelCustomFunctionExport,
+ FormulaExcelDefinedNameExport,
+ FormulaExcelExportContext,
+ FormulaProvider,
+ OnDragReplaceCellsEventArgs,
+ SlickDataView,
+ SlickGrid,
+} from '@slickgrid-universal/common';
+import { createDomElement, Formatters, SlickEventHandler } from '@slickgrid-universal/common';
+import { FORMULA_ERROR, isFormulaErrorCode, type FormulaErrorCode } from './formula-errors.js';
+import { createFormulaFunctionRegistry, type FormulaCallback } from './formula-functions.js';
+import {
+ buildFormulaReferenceCssHash,
+ FORMULA_MAX_REFERENCE_CELLS,
+ FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY,
+ FormulaReferenceColorCache,
+ getExcelColumnIndexByName,
+ getExcelColumnNameByIndex,
+ parseExcelReferenceCell,
+} from './formula-reference.js';
+import { FormulaCellEditor, type FormulaEditorParams } from './formula.cellEditor.js';
+import { handleFormulaDragFill } from './formula.drag-fill.js';
+
+export type { FormulaCallback } from './formula-functions.js';
+
+export interface FormulaCustomFunctionParams {
+ values: unknown[];
+}
+
+export interface FormulaCustomFunctionDefinition {
+ func: FormulaCallback | ((params: FormulaCustomFunctionParams) => unknown);
+}
+
+export type FormulaCustomFunctionInput = FormulaCallback | FormulaCustomFunctionDefinition;
+
+export interface FormulaServiceOption {
+ /** Defaults to true, auto-attach FormulaCellEditor on columns having allowFormula=true. */
+ autoAssignEditor?: boolean;
+
+ /** Optional default editor params merged with per-column editor params. */
+ editorParams?: FormulaEditorParams;
+
+ /** Defaults to true, prepend Excel-like column letters in header while editing formulas. */
+ enableExcelHeaderPrefix?: boolean;
+
+ /** Optional function callbacks available during formula evaluation (e.g. MYFUNC(A1, B1)). */
+ customFunctions?: Record;
+
+ /** Optional Excel workbook-level names to register at export time. */
+ excelDefinedNames?: FormulaExcelDefinedNameExport[];
+
+ /** Optional Excel workbook-level custom function definitions for export. */
+ excelCustomFunctions?: FormulaExcelCustomFunctionExport[];
+
+ /** Defaults to true, sync initial formulas from dataset rows into internal formula store. */
+ autoSyncFormulasFromDataset?: boolean;
+}
+
+interface FormulaEvaluationContext {
+ visited: Set;
+ memo: Map;
+}
+
+interface FormulaReferenceAbsoluteFlags {
+ column: boolean;
+ row: boolean;
+}
+
+/**
+ * Optional formula service storing formulas by row/column and exposing export helpers.
+ * This MVP focuses on formula storage and Excel conversion support.
+ */
+export class FormulaService implements ExternalResource, FormulaProvider {
+ readonly pluginName = 'FormulaService';
+
+ protected _grid!: SlickGrid;
+ protected _dataView!: SlickDataView;
+ protected _customFunctions: Map = new Map();
+ protected _formulaStore: Map = new Map();
+ protected _formulaCoordinatesByKey: Map = new Map();
+ protected _formulaReferenceAbsoluteFlagsByKey: Map = new Map();
+ protected _formulaRefColorCache: FormulaReferenceColorCache = new FormulaReferenceColorCache();
+ protected _originalColumnNamesById: Map = new Map();
+ protected _isExcelHeaderPrefixEnabled = false;
+ protected _hasWarnedSelectionPrerequisite = false;
+ protected _hasAutoAssignedFormulaEditor = false;
+ protected _originalColumnDefsById: Map> = new Map();
+ protected _evaluationMemo: Map = new Map();
+ protected _isEvaluationMemoFlushScheduled = false;
+ protected _eventHandler: SlickEventHandler = new SlickEventHandler();
+
+ protected static readonly FORMULA_EVAL_FORMATTER_FLAG = '__formulaEvalFormatter';
+
+ constructor(protected _options: FormulaServiceOption = {}) {}
+
+ getOptions(): FormulaServiceOption {
+ return this._options;
+ }
+
+ setOptions(newOptions: FormulaServiceOption): void {
+ this._options = { ...this._options, ...newOptions };
+ }
+
+ init(grid: SlickGrid, _containerService?: ContainerService): void {
+ this._grid = grid;
+ this._dataView = grid?.getData() || {};
+
+ // Respect explicit grid opt-out; when disabled, the service stays inert.
+ if (grid?.getOptions?.().enableFormulas === false) {
+ return;
+ }
+
+ if (this._grid.onDragReplaceCells) {
+ this._eventHandler.subscribe(this._grid.onDragReplaceCells, this.handleDragReplaceCells.bind(this));
+ }
+
+ if (this._options.customFunctions) {
+ this.registerCustomFunctions(this._options.customFunctions);
+ }
+
+ if (this._options.autoSyncFormulasFromDataset !== false) {
+ this.syncFormulasFromDataset();
+ }
+ this.canonicalizeStoredFormulas();
+
+ this.autoAssignFormulaEditorToColumns();
+ this.validateSelectionModelPrerequisites();
+ }
+
+ dispose(): void {
+ this._eventHandler.unsubscribeAll();
+ this.clearFormulaReferenceHighlights();
+ this.disableExcelHeaderPrefix();
+ this.restoreAutoAssignedFormulaEditorColumns();
+ this._formulaStore.clear();
+ this._formulaCoordinatesByKey.clear();
+ this._formulaReferenceAbsoluteFlagsByKey.clear();
+ this._formulaRefColorCache.clear();
+ this.resetEvaluationMemo();
+ this._customFunctions.clear();
+ this._originalColumnNamesById.clear();
+ }
+
+ clearFormulaReferenceHighlights(): void {
+ if (!this._grid?.removeCellCssStyles) {
+ return;
+ }
+
+ this._grid.removeCellCssStyles(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY);
+ }
+
+ protected validateSelectionModelPrerequisites(): void {
+ if (this._hasWarnedSelectionPrerequisite || !this._grid?.getColumns || !this._grid?.getOptions) {
+ return;
+ }
+
+ const columns = this._grid.getColumns() as Column[];
+ const hasFormulaColumns = columns.some((column) => column.allowFormula === true || column.editor?.model === FormulaCellEditor);
+ if (!hasFormulaColumns) {
+ return;
+ }
+
+ const gridOptions = this._grid.getOptions();
+ const hasSelectionEnabled = gridOptions.enableSelection === true;
+ const selectionType = gridOptions.selectionOptions?.selectionType;
+ const supportsCellRangeSelection = hasSelectionEnabled && selectionType !== 'row';
+
+ if (!supportsCellRangeSelection) {
+ this._hasWarnedSelectionPrerequisite = true;
+ console.warn(
+ '[Slickgrid-Universal][FormulaService] Formula range visuals and drag-resize rely on an active cell-capable SelectionModel. Enable `enableSelection: true` and `selectionOptions.selectionType: "mixed"` (or `"cell"`) for full Excel-like range UX.'
+ );
+ }
+ }
+
+ enableExcelHeaderPrefix(): void {
+ if (
+ this._options.enableExcelHeaderPrefix === false ||
+ this._isExcelHeaderPrefixEnabled ||
+ !this._grid?.getColumns ||
+ !this._grid?.setColumns
+ ) {
+ return;
+ }
+
+ const columns = this._grid.getColumns() as Column[];
+ const nextColumns = columns.map((column, index) => {
+ if (!this._originalColumnNamesById.has(column.id)) {
+ this._originalColumnNamesById.set(column.id, column.name);
+ }
+
+ const originalName = this._originalColumnNamesById.get(column.id);
+ const nameText = typeof originalName === 'string' ? originalName : String(column.id);
+ const excelLabel = getExcelColumnNameByIndex(index + 1);
+
+ return {
+ ...column,
+ name: `${excelLabel} ${nameText}`,
+ };
+ });
+
+ this._grid.setColumns(nextColumns as Column[]);
+ this._isExcelHeaderPrefixEnabled = true;
+ }
+
+ disableExcelHeaderPrefix(): void {
+ if (!this._isExcelHeaderPrefixEnabled || !this._grid?.getColumns || !this._grid?.setColumns) {
+ return;
+ }
+
+ const columns = this._grid.getColumns() as Column[];
+ const restoredColumns = columns.map((column) => {
+ const originalName = this._originalColumnNamesById.get(column.id);
+ return {
+ ...column,
+ name: originalName ?? column.name,
+ };
+ });
+
+ this._grid.setColumns(restoredColumns as Column[]);
+ this._isExcelHeaderPrefixEnabled = false;
+ }
+
+ renderFormulaReferenceHighlights(formula?: string): void {
+ this.clearFormulaReferenceHighlights();
+ if (!this._grid?.getColumns || !this._grid?.setCellCssStyles || !formula || !formula.startsWith('=')) {
+ return;
+ }
+
+ const normalizedFormula = formula.startsWith('=') ? formula.slice(1) : formula;
+ const normalizedFormulaWithRefs = `=${this.replaceRefFunctionsWithA1Refs(
+ normalizedFormula,
+ ((this._grid?.getColumns?.() as Column[] | undefined) || []).map((col) => String(col.id)),
+ this.getDataItems().map((item) => String(item?.[this.getDatasetIdPropertyName()] ?? '')),
+ 1
+ )}`;
+ this._formulaRefColorCache.update(normalizedFormulaWithRefs);
+ const columns = this._grid.getColumns() as Column[];
+ const hash = buildFormulaReferenceCssHash(this._formulaRefColorCache.values(), columns, this.getDatasetLength());
+
+ if (Object.keys(hash).length > 0) {
+ this._grid.setCellCssStyles(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, hash as any);
+ }
+ this._formulaRefColorCache.markClean();
+ }
+
+ extractExcelReferences(formula: string): Array<{ col: string; row: number }> {
+ const displayFormula = this.toDisplayFormula(formula);
+ this._formulaRefColorCache.update(displayFormula);
+ return Array.from(this._formulaRefColorCache.values()).flatMap((reference) =>
+ reference.cells.map((cell) => ({ col: getExcelColumnNameByIndex(cell.cell + 1), row: cell.row + 1 }))
+ );
+ }
+
+ clearFormulas(): void {
+ this._formulaStore.clear();
+ this._formulaCoordinatesByKey.clear();
+ this._formulaReferenceAbsoluteFlagsByKey.clear();
+ this.resetEvaluationMemo();
+ }
+
+ /** Sync formula strings found in dataset rows to the internal formula store. */
+ syncFormulasFromDataset(): void {
+ const columns = (this._grid?.getColumns?.() || []) as Column[];
+ if (!columns.length) {
+ return;
+ }
+
+ const formulaColumns = columns.filter((col) => !!col.allowFormula);
+ if (!formulaColumns.length) {
+ return;
+ }
+
+ const items = this.getDataItems();
+ const datasetIdPropertyName = this.getDatasetIdPropertyName();
+
+ for (const item of items) {
+ const rowId = item?.[datasetIdPropertyName] as number | string | undefined;
+ if (rowId === undefined || rowId === null) {
+ continue;
+ }
+
+ for (const column of formulaColumns) {
+ const columnId = column.id;
+ const fieldName = String(column.field ?? column.id);
+ const rawValue = item?.[fieldName as keyof typeof item] ?? item?.[String(columnId) as keyof typeof item];
+
+ if (typeof rawValue === 'string' && rawValue.trim().startsWith('=')) {
+ this.setFormula(rowId, columnId, rawValue.trim());
+ }
+ }
+ }
+ }
+
+ /**
+ * Evaluate a formula for a specific cell.
+ * - 3 args: (rowId, columnId, fallbackValue)
+ * - 4 args: (rowId, columnId, currentCellValue, fallbackValue)
+ */
+ getEvaluatedCellValue(
+ rowId: number | string,
+ columnId: number | string,
+ currentCellValueOrFallbackValue?: unknown,
+ fallbackValue?: T
+ ): unknown {
+ const hasCurrentCellValue = arguments.length >= 4;
+ const liveValue = hasCurrentCellValue ? currentCellValueOrFallbackValue : this.getCellRawValue(rowId, columnId);
+ const safeFallbackValue = (hasCurrentCellValue ? fallbackValue : (currentCellValueOrFallbackValue as T | undefined)) as T | undefined;
+ const rowStoredValue = this.getCellRawValue(rowId, columnId);
+ const storedFormula = this.getFormula(rowId, columnId);
+
+ const normalizedStoredFormula =
+ typeof storedFormula === 'string' && storedFormula.trim().startsWith('=') ? storedFormula.trim() : undefined;
+ const normalizedLiveFormula = typeof liveValue === 'string' && liveValue.trim().startsWith('=') ? liveValue.trim() : undefined;
+ const normalizedRowFormula =
+ typeof rowStoredValue === 'string' && rowStoredValue.trim().startsWith('=') ? rowStoredValue.trim() : undefined;
+
+ // Once a formula has a stable representation, it is authoritative even if the
+ // dataset still contains a legacy A1 value. This prevents a reorder from making
+ // the live cell value override the ID-based formula in the store.
+ const storedFormulaIsStable = !!normalizedStoredFormula && /\bREF\(\s*COLUMN\(/i.test(normalizedStoredFormula);
+ const formula = storedFormulaIsStable
+ ? normalizedStoredFormula
+ : normalizedLiveFormula && normalizedLiveFormula !== normalizedStoredFormula
+ ? normalizedLiveFormula
+ : (normalizedStoredFormula ?? normalizedLiveFormula ?? normalizedRowFormula);
+
+ if (!formula || !formula.trim().startsWith('=')) {
+ return safeFallbackValue;
+ }
+
+ const storeKey = this.buildStoreKey(rowId, columnId);
+ const evalMemo = this.getOrCreateEvaluationMemo();
+ const memoKey = this.buildEvaluationMemoKey(rowId, columnId, formula);
+
+ if (evalMemo.has(memoKey)) {
+ return evalMemo.get(memoKey);
+ }
+
+ const evaluated = this.evaluateFormulaExpression(formula, {
+ visited: new Set([storeKey]),
+ memo: evalMemo,
+ });
+
+ if (isFormulaErrorCode(evaluated)) {
+ evalMemo.set(memoKey, evaluated);
+ return evaluated;
+ }
+
+ if (evaluated === undefined || (typeof evaluated === 'number' && Number.isNaN(evaluated))) {
+ const errorValue = FORMULA_ERROR.VALUE;
+ evalMemo.set(memoKey, errorValue);
+ return errorValue;
+ }
+
+ if (typeof evaluated === 'number' && !Number.isFinite(evaluated)) {
+ const errorValue = FORMULA_ERROR.DIV0;
+ evalMemo.set(memoKey, errorValue);
+ return errorValue;
+ }
+
+ evalMemo.set(memoKey, evaluated);
+ return evaluated;
+ }
+
+ getFormula(rowId: number | string, columnId: number | string): string | undefined {
+ return this._formulaStore.get(this.buildStoreKey(rowId, columnId));
+ }
+
+ hasFormula(rowId: number | string, columnId: number | string): boolean {
+ return this._formulaStore.has(this.buildStoreKey(rowId, columnId));
+ }
+
+ removeFormula(rowId: number | string, columnId: number | string): boolean {
+ const key = this.buildStoreKey(rowId, columnId);
+ const wasDeleted = this._formulaStore.delete(key);
+ if (wasDeleted) {
+ this._formulaCoordinatesByKey.delete(key);
+ this.resetEvaluationMemo();
+ }
+ return wasDeleted;
+ }
+
+ setFormula(rowId: number | string, columnId: number | string, formula?: string | null): void {
+ const key = this.buildStoreKey(rowId, columnId);
+ if (formula == null || formula === '') {
+ this._formulaStore.delete(key);
+ this._formulaCoordinatesByKey.delete(key);
+ this._formulaReferenceAbsoluteFlagsByKey.delete(key);
+ this.resetEvaluationMemo();
+ return;
+ }
+
+ if (this.containsDirectExcelReference(formula)) {
+ this.captureFormulaReferenceAbsoluteFlags(key, formula);
+ }
+ this._formulaCoordinatesByKey.set(key, { rowId, columnId });
+ this._formulaStore.set(key, this.toStoredFormula(formula));
+ this.resetEvaluationMemo();
+ }
+
+ /**
+ * Fill formulas through the same drag-handle event used by the spreadsheet examples.
+ * The source formula is converted to the editor's A1 form, shifted relative to the
+ * source cell, and then stored again in stable column/row-reference form.
+ */
+ protected handleDragReplaceCells(_event: unknown, args: OnDragReplaceCellsEventArgs): void {
+ handleFormulaDragFill(args, {
+ grid: this._grid,
+ dataView: this._dataView,
+ getDatasetIdPropertyName: this.getDatasetIdPropertyName.bind(this),
+ getFormula: this.getFormula.bind(this),
+ setFormula: this.setFormula.bind(this),
+ toStoredFormula: this.toStoredFormula.bind(this),
+ toDisplayFormulaForCell: this.toDisplayFormulaForCell.bind(this),
+ });
+ }
+
+ protected containsDirectExcelReference(formula: string): boolean {
+ const referenceRegex = /(? {
+ found ||= referenceRegex.test(segment);
+ referenceRegex.lastIndex = 0;
+ return segment;
+ });
+ return found;
+ }
+
+ protected captureFormulaReferenceAbsoluteFlags(key: string, formula: string): void {
+ const referenceRegex = /(? {
+ segment.replace(referenceRegex, (reference) => {
+ reference.split(':').forEach((endpoint) => {
+ const match = endpoint.trim().match(/^(\$?)[A-Z]{1,3}(\$?)(\d+)$/i);
+ if (match) {
+ flags.push({ column: match[1] === '$', row: match[2] === '$' });
+ }
+ });
+ return reference;
+ });
+ return segment;
+ });
+ this._formulaReferenceAbsoluteFlagsByKey.set(key, flags);
+ }
+
+ protected toDisplayFormulaForCell(formula: string, rowId: number | string, columnId: number | string): string {
+ const displayFormula = this.toDisplayFormula(formula);
+ return this.applyFormulaReferenceAbsoluteFlags(this.buildStoreKey(rowId, columnId), displayFormula);
+ }
+
+ protected applyFormulaReferenceAbsoluteFlags(key: string, formula: string): string {
+ const flags = this._formulaReferenceAbsoluteFlagsByKey.get(key);
+ if (!flags?.length) {
+ return formula;
+ }
+
+ const referenceRegex = /(?
+ segment.replace(referenceRegex, (reference) =>
+ reference
+ .split(':')
+ .map((endpoint) => {
+ const flag = flags[flagIndex++];
+ if (!flag) {
+ return endpoint;
+ }
+ const parsed = endpoint.trim().match(/^(\$?)([A-Z]{1,3})(\$?)(\d+)$/i);
+ /* v8 ignore if - the surrounding reference regex guarantees a valid endpoint */
+ if (!parsed) {
+ return endpoint;
+ }
+ return `${flag.column ? '$' : ''}${parsed[2].toUpperCase()}${flag.row ? '$' : ''}${parsed[4]}`;
+ })
+ .join(':')
+ )
+ );
+ }
+
+ /** Canonicalize formulas supplied before the grid was initialized. */
+ protected canonicalizeStoredFormulas(): void {
+ for (const [key, formula] of this._formulaStore.entries()) {
+ const coordinates = this._formulaCoordinatesByKey.get(key);
+ if (!coordinates) {
+ continue;
+ }
+
+ const canonicalFormula = this.toStoredFormula(formula);
+ if (canonicalFormula !== formula) {
+ this._formulaStore.set(key, canonicalFormula);
+ }
+ }
+ }
+
+ registerCustomFunction(functionName: string, functionInput: FormulaCustomFunctionInput): void {
+ const normalizedCallback = this.normalizeCustomFunctionInput(functionInput);
+ if (!normalizedCallback) {
+ return;
+ }
+ this._customFunctions.set(functionName.toUpperCase(), normalizedCallback);
+ }
+
+ registerCustomFunctions(customFunctions: Record): void {
+ for (const [functionName, functionInput] of Object.entries(customFunctions || {})) {
+ this.registerCustomFunction(functionName, functionInput);
+ }
+ }
+
+ unregisterCustomFunction(functionName: string): boolean {
+ return this._customFunctions.delete(functionName.toUpperCase());
+ }
+
+ getCustomFunction(functionName: string): FormulaCallback | undefined {
+ return this._customFunctions.get(functionName.toUpperCase());
+ }
+
+ getExcelDefinedNames(): FormulaExcelDefinedNameExport[] {
+ const definedNames = this._options.excelDefinedNames;
+ if (!Array.isArray(definedNames)) {
+ return [];
+ }
+
+ return definedNames.filter((item) => !!item?.name && !!item?.refersTo).map((item) => ({ ...item }));
+ }
+
+ getExcelCustomFunctions(): FormulaExcelCustomFunctionExport[] {
+ const customFunctions = this._options.excelCustomFunctions;
+ if (!Array.isArray(customFunctions)) {
+ return [];
+ }
+
+ return customFunctions
+ .filter((item) => !!item?.name && Array.isArray(item.args) && !!item?.body)
+ .map((item) => ({
+ ...item,
+ args: [...item.args],
+ }));
+ }
+
+ /**
+ * Translate AG-style long references into Excel A1 references.
+ * Example: REF(COLUMN("price"),ROW("id_1")) -> C2
+ */
+ getExcelFormula(context: FormulaExcelExportContext): string | undefined {
+ const originalFormula = this.getFormula(context.rowId, context.columnId);
+ if (!originalFormula) {
+ return undefined;
+ }
+
+ const normalizedFormula = originalFormula.startsWith('=') ? originalFormula.slice(1) : originalFormula;
+ const allGridColumnIds = (this._grid?.getColumns?.() as Column[] | undefined)?.map((col) => String(col.id)) ?? [];
+ const exportedColumnIds = context.columnIds.map((colId) => String(colId));
+ const normalizedColumnIds = exportedColumnIds;
+ const normalizedRowIds = context.rowIds.map((rowId) => String(rowId));
+ // Canonicalize legacy A1 formulas first, then resolve every stable reference against
+ // the actual exported column/row order. This keeps export independent from grid reordering.
+ const stableFormula = this.convertA1ReferencesToStableRefs(normalizedFormula, allGridColumnIds, normalizedRowIds);
+ const shiftedLegacyFormula = this.shiftDirectExcelReferences(
+ stableFormula,
+ allGridColumnIds,
+ normalizedColumnIds,
+ Math.max(0, context.excelRowOffset - 1)
+ );
+
+ const withNumericRowRefs = this.replaceRefFunctionsWithA1Refs(
+ shiftedLegacyFormula,
+ normalizedColumnIds,
+ normalizedRowIds,
+ context.excelRowOffset
+ );
+
+ return this.normalizeFormulaSyntax(
+ this.applyFormulaReferenceAbsoluteFlags(this.buildStoreKey(context.rowId, context.columnId), withNumericRowRefs)
+ );
+ }
+
+ /** Shift only direct A1 references left after stable references have been canonicalized. */
+ protected shiftDirectExcelReferences(formula: string, gridColumnIds: string[], exportedColumnIds: string[], rowDelta: number): string {
+ if (!formula || (rowDelta === 0 && gridColumnIds.length === 0)) {
+ return formula;
+ }
+
+ const a1ReferenceRegex =
+ /(? {
+ const parsed = parseExcelReferenceCell(token);
+ /* v8 ignore if - the A1 token regex guarantees that parsing succeeds */
+ if (!parsed) {
+ return token;
+ }
+
+ const sourceColumnId = gridColumnIds[parsed.cell];
+ const exportedColumnIndex = sourceColumnId === undefined ? -1 : exportedColumnIds.indexOf(sourceColumnId);
+ const sourceColumnName = token.replace(/\$/g, '').replace(/\d+$/, '').toUpperCase();
+ const columnName = exportedColumnIndex >= 0 ? getExcelColumnNameByIndex(exportedColumnIndex + 1) : sourceColumnName;
+ const rowMatch = token.match(/(\d+)$/);
+ const rowNumber = rowMatch ? Number(rowMatch[1]) + rowDelta : parsed.row + 1 + rowDelta;
+ const hasLeadingDollar = token.startsWith('$');
+ const columnDollar = token.match(/^\$/) ? '$' : '';
+ const rowDollar = /\$\d+$/.test(token) ? '$' : '';
+ return `${hasLeadingDollar || columnDollar ? '$' : ''}${columnName}${rowDollar}${rowNumber}`;
+ };
+
+ return this.transformFormulaOutsideQuotedStrings(formula, (segment) =>
+ segment.replace(a1ReferenceRegex, (reference) => {
+ const [startToken, endToken] = reference.split(':', 2);
+ const shiftedStart = remapEndpoint(startToken);
+ return endToken ? `${shiftedStart}:${remapEndpoint(endToken)}` : shiftedStart;
+ })
+ );
+ }
+
+ protected buildStoreKey(rowId: number | string, columnId: number | string): string {
+ return `${String(rowId)}::${String(columnId)}`;
+ }
+
+ protected buildEvaluationMemoKey(rowId: number | string, columnId: number | string, formula: string): string {
+ return `${this.buildStoreKey(rowId, columnId)}::${formula.trim()}`;
+ }
+
+ protected getOrCreateEvaluationMemo(): Map {
+ if (!this._isEvaluationMemoFlushScheduled) {
+ this._isEvaluationMemoFlushScheduled = true;
+ Promise.resolve().then(() => {
+ this._evaluationMemo.clear();
+ this._isEvaluationMemoFlushScheduled = false;
+ });
+ }
+
+ return this._evaluationMemo;
+ }
+
+ protected resetEvaluationMemo(): void {
+ this._evaluationMemo.clear();
+ this._isEvaluationMemoFlushScheduled = false;
+ }
+
+ protected getDatasetLength(): number {
+ const dataViewAny = this._dataView as any;
+ if (dataViewAny?.getLength && typeof dataViewAny.getLength === 'function') {
+ return dataViewAny.getLength();
+ }
+ const items = dataViewAny?.getItems && typeof dataViewAny.getItems === 'function' ? dataViewAny.getItems() : [];
+ return Array.isArray(items) ? items.length : 0;
+ }
+
+ protected getDataItems(): any[] {
+ const dataViewAny = this._dataView as any;
+ const items = dataViewAny?.getItems && typeof dataViewAny.getItems === 'function' ? dataViewAny.getItems() : [];
+ return Array.isArray(items) ? items : [];
+ }
+
+ protected getDatasetIdPropertyName(): string {
+ return this._grid?.getOptions?.().datasetIdPropertyName ?? 'id';
+ }
+
+ protected evaluateFormulaExpression(formula: string, context: FormulaEvaluationContext): unknown {
+ const normalized = formula.trim().startsWith('=') ? formula.trim().slice(1) : formula.trim();
+ if (!normalized) {
+ return FORMULA_ERROR.NULL;
+ }
+
+ const normalizedSyntax = this.normalizeFormulaSyntax(
+ this.replaceRefFunctionsWithA1Refs(
+ normalized,
+ ((this._grid?.getColumns?.() as Column[] | undefined) || []).map((col) => String(col.id)),
+ this.getDataItems().map((item) => String(item?.[this.getDatasetIdPropertyName()] ?? '')),
+ 1
+ )
+ );
+
+ let firstErrorCode: FormulaErrorCode | undefined;
+
+ const expressionWithRanges = normalizedSyntax.replace(
+ /\$?([A-Z]{1,3})\$?(\d+)\s*:\s*\$?([A-Z]{1,3})\$?(\d+)/gi,
+ (_match, startCol: string, startRow: string, endCol: string, endRow: string) => {
+ const rangeValues = this.resolveExcelRangeValues(startCol, Number(startRow), endCol, Number(endRow), context);
+ const errorInRange = rangeValues.find((value) => isFormulaErrorCode(value));
+ if (isFormulaErrorCode(errorInRange) && !firstErrorCode) {
+ firstErrorCode = errorInRange;
+ }
+ return this.toExpressionArrayLiteral(rangeValues);
+ }
+ );
+
+ const expressionWithValues = expressionWithRanges.replace(/\$?([A-Z]{1,3})\$?(\d+)/gi, (_match, colName: string, rowNumber: string) => {
+ const resolved = this.resolveExcelReferenceValue(colName, Number(rowNumber), context);
+ if (isFormulaErrorCode(resolved) && !firstErrorCode) {
+ firstErrorCode = resolved;
+ }
+ return this.toExpressionLiteral(resolved);
+ });
+
+ if (firstErrorCode) {
+ return firstErrorCode;
+ }
+
+ const jsExpression = expressionWithValues
+ .replace(/<>/g, '!=')
+ .replace(/\bTRUE\b/gi, 'true')
+ .replace(/\bFALSE\b/gi, 'false')
+ .replace(/(^|[^<>=!])=([^=])/g, '$1==$2');
+
+ if (/[;{}\\`]/.test(jsExpression)) {
+ return FORMULA_ERROR.ERROR;
+ }
+
+ const formulaFunctions = this.getFormulaFunctionRegistry();
+ const expressionWithoutStrings = jsExpression.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '');
+ const identifiers = expressionWithoutStrings.match(/[A-Za-z_][A-Za-z0-9_]*/g) || [];
+ const allowedIdentifiers = new Set(['TRUE', 'FALSE', 'NULL', ...Array.from(formulaFunctions.keys())]);
+ if (identifiers.some((id) => !allowedIdentifiers.has(id.toUpperCase()))) {
+ return FORMULA_ERROR.NAME;
+ }
+
+ try {
+ // The recursive-descent parser below implements the full supported grammar (operators, ranges,
+ // whitelisted functions); it is used exclusively so no formula text is ever passed to a dynamic
+ // code evaluator (e.g. `Function`/`eval`), which would otherwise be an unnecessary injection surface.
+ return this.evaluateExpressionWithParser(jsExpression, formulaFunctions);
+ } catch (error) {
+ if (error instanceof ReferenceError) {
+ return FORMULA_ERROR.NAME;
+ }
+ if (error instanceof TypeError) {
+ return FORMULA_ERROR.VALUE;
+ }
+ if (error instanceof SyntaxError) {
+ return FORMULA_ERROR.ERROR;
+ }
+ return FORMULA_ERROR.ERROR;
+ }
+ }
+
+ protected evaluateExpressionWithParser(expression: string, formulaFunctions: Map): unknown {
+ type TokenType = 'number' | 'string' | 'identifier' | 'operator' | 'paren' | 'bracket' | 'comma' | 'eof';
+ interface Token {
+ type: TokenType;
+ value: string;
+ }
+
+ const tokens: Token[] = [];
+ const src = expression;
+ let i = 0;
+
+ const pushToken = (type: TokenType, value: string) => tokens.push({ type, value });
+
+ while (i < src.length) {
+ const ch = src[i];
+
+ if (/\s/.test(ch)) {
+ i++;
+ continue;
+ }
+
+ if (ch === '"' || ch === "'") {
+ const quote = ch;
+ i++;
+ let value = '';
+ while (i < src.length) {
+ const c = src[i];
+ if (c === '\\' && i + 1 < src.length) {
+ value += src[i + 1];
+ i += 2;
+ continue;
+ }
+ if (c === quote) {
+ i++;
+ break;
+ }
+ value += c;
+ i++;
+ }
+ pushToken('string', value);
+ continue;
+ }
+
+ if (/\d|\./.test(ch)) {
+ let numberValue = ch;
+ i++;
+ while (i < src.length && /[\d.]/.test(src[i])) {
+ numberValue += src[i];
+ i++;
+ }
+ if (!/^\d*\.?\d+$/.test(numberValue)) {
+ return FORMULA_ERROR.NUM;
+ }
+ pushToken('number', numberValue);
+ continue;
+ }
+
+ if (/[A-Za-z_]/.test(ch)) {
+ let ident = ch;
+ i++;
+ while (i < src.length && /[A-Za-z0-9_]/.test(src[i])) {
+ ident += src[i];
+ i++;
+ }
+ pushToken('identifier', ident);
+ continue;
+ }
+
+ const twoCharOp = src.slice(i, i + 2);
+ if (['==', '!=', '<=', '>='].includes(twoCharOp)) {
+ pushToken('operator', twoCharOp);
+ i += 2;
+ continue;
+ }
+
+ if (['+', '-', '*', '/', '<', '>', '^', '&', '%'].includes(ch)) {
+ pushToken('operator', ch);
+ i++;
+ continue;
+ }
+
+ if (ch === '(' || ch === ')') {
+ pushToken('paren', ch);
+ i++;
+ continue;
+ }
+
+ if (ch === '[' || ch === ']') {
+ pushToken('bracket', ch);
+ i++;
+ continue;
+ }
+
+ if (ch === ',') {
+ pushToken('comma', ch);
+ i++;
+ continue;
+ }
+
+ return FORMULA_ERROR.ERROR;
+ }
+
+ pushToken('eof', '');
+
+ let cursor = 0;
+ const peek = () => tokens[cursor];
+ const consume = () => tokens[cursor++];
+ const matchOperator = (...ops: string[]) => peek().type === 'operator' && ops.includes(peek().value);
+ const matchParen = (p: '(' | ')') => peek().type === 'paren' && peek().value === p;
+ const matchBracket = (b: '[' | ']') => peek().type === 'bracket' && peek().value === b;
+
+ const parseExpression = (): unknown => parseComparison();
+
+ const parseComparison = (): unknown => {
+ let left = parseConcatenation();
+ if (isFormulaErrorCode(left)) {
+ return left;
+ }
+ while (matchOperator('==', '!=', '<', '>', '<=', '>=')) {
+ const op = consume().value;
+ const right = parseConcatenation();
+ if (isFormulaErrorCode(right)) {
+ return right;
+ }
+ switch (op) {
+ case '==':
+ left = (left as any) == (right as any);
+ break;
+ case '!=':
+ left = (left as any) != (right as any);
+ break;
+ case '<':
+ left = (left as any) < (right as any);
+ break;
+ case '>':
+ left = (left as any) > (right as any);
+ break;
+ case '<=':
+ left = (left as any) <= (right as any);
+ break;
+ case '>=':
+ left = (left as any) >= (right as any);
+ break;
+ }
+ }
+ return left;
+ };
+
+ const parseConcatenation = (): unknown => {
+ let left = parseAdditive();
+ if (isFormulaErrorCode(left)) {
+ return left;
+ }
+ while (matchOperator('&')) {
+ consume();
+ const right = parseAdditive();
+ if (isFormulaErrorCode(right)) {
+ return right;
+ }
+ left = `${left ?? ''}${right ?? ''}`;
+ }
+ return left;
+ };
+
+ const parseAdditive = (): unknown => {
+ let left = parseMultiplicative();
+ if (isFormulaErrorCode(left)) {
+ return left;
+ }
+ while (matchOperator('+', '-')) {
+ const op = consume().value;
+ const right = parseMultiplicative();
+ if (isFormulaErrorCode(right)) {
+ return right;
+ }
+ left = op === '+' ? FormulaService.addFormulaValues(left, right) : FormulaService.subtractFormulaValues(left, right);
+ if (typeof left === 'number' && Number.isNaN(left)) {
+ return FORMULA_ERROR.VALUE;
+ }
+ }
+ return left;
+ };
+
+ const parseMultiplicative = (): unknown => {
+ let left = parseUnary();
+ if (isFormulaErrorCode(left)) {
+ return left;
+ }
+ while (matchOperator('*', '/')) {
+ const op = consume().value;
+ const right = parseUnary();
+ if (isFormulaErrorCode(right)) {
+ return right;
+ }
+ if (op === '/' && Number(right) === 0) {
+ return FORMULA_ERROR.DIV0;
+ }
+ left = op === '*' ? (left as any) * (right as any) : (left as any) / (right as any);
+ if (typeof left === 'number' && Number.isNaN(left)) {
+ return FORMULA_ERROR.VALUE;
+ }
+ }
+ return left;
+ };
+
+ const parsePower = (): unknown => {
+ let left = parsePostfix();
+ if (isFormulaErrorCode(left)) {
+ return left;
+ }
+ while (matchOperator('^')) {
+ consume();
+ const right = parseUnary();
+ if (isFormulaErrorCode(right)) {
+ return right;
+ }
+ left = Math.pow(Number(left), Number(right));
+ if (typeof left === 'number' && Number.isNaN(left)) {
+ return FORMULA_ERROR.NUM;
+ }
+ }
+ return left;
+ };
+
+ const parsePostfix = (): unknown => {
+ let value = parsePrimary();
+ if (isFormulaErrorCode(value)) {
+ return value;
+ }
+ while (matchOperator('%')) {
+ consume();
+ value = Number(value) / 100;
+ if (typeof value === 'number' && Number.isNaN(value)) {
+ return FORMULA_ERROR.VALUE;
+ }
+ }
+ return value;
+ };
+
+ const parseUnary = (): unknown => {
+ if (matchOperator('+')) {
+ consume();
+ const unary = parseUnary();
+ if (isFormulaErrorCode(unary)) {
+ return unary;
+ }
+ const numeric = Number(unary);
+ return Number.isNaN(numeric) ? FORMULA_ERROR.VALUE : numeric;
+ }
+ if (matchOperator('-')) {
+ consume();
+ const unary = parseUnary();
+ if (isFormulaErrorCode(unary)) {
+ return unary;
+ }
+ const numeric = Number(unary);
+ return Number.isNaN(numeric) ? FORMULA_ERROR.VALUE : -numeric;
+ }
+ return parsePower();
+ };
+
+ const parsePrimary = (): unknown => {
+ const tk = peek();
+
+ if (tk.type === 'number') {
+ consume();
+ return Number(tk.value);
+ }
+
+ if (tk.type === 'string') {
+ consume();
+ return tk.value;
+ }
+
+ if (tk.type === 'identifier') {
+ const ident = consume().value;
+ const upperIdent = ident.toUpperCase();
+ if (matchParen('(')) {
+ consume();
+ const args: unknown[] = [];
+ if (!matchParen(')')) {
+ while (true) {
+ args.push(parseExpression());
+ if (peek().type === 'comma') {
+ consume();
+ continue;
+ }
+ break;
+ }
+ }
+
+ if (!matchParen(')')) {
+ return FORMULA_ERROR.ERROR;
+ }
+ consume();
+
+ const fn = formulaFunctions.get(upperIdent);
+ if (typeof fn !== 'function') {
+ return FORMULA_ERROR.NAME;
+ }
+ const fnResult = fn(...args);
+ return isFormulaErrorCode(fnResult) ? fnResult : fnResult;
+ }
+
+ if (upperIdent === 'TRUE') {
+ return true;
+ }
+ if (upperIdent === 'FALSE') {
+ return false;
+ }
+ if (upperIdent === 'NULL') {
+ return null;
+ }
+ return FORMULA_ERROR.NAME;
+ }
+
+ if (matchParen('(')) {
+ consume();
+ const value = parseExpression();
+ if (isFormulaErrorCode(value)) {
+ return value;
+ }
+ if (!matchParen(')')) {
+ return FORMULA_ERROR.ERROR;
+ }
+ consume();
+ return value;
+ }
+
+ if (matchBracket('[')) {
+ consume();
+ const values: unknown[] = [];
+
+ if (!matchBracket(']')) {
+ while (true) {
+ const value = parseExpression();
+ if (isFormulaErrorCode(value)) {
+ return value;
+ }
+ values.push(value);
+
+ if (peek().type === 'comma') {
+ consume();
+ continue;
+ }
+ break;
+ }
+ }
+
+ if (!matchBracket(']')) {
+ return FORMULA_ERROR.ERROR;
+ }
+ consume();
+ return values;
+ }
+
+ return FORMULA_ERROR.ERROR;
+ };
+
+ const output = parseExpression();
+ if (isFormulaErrorCode(output)) {
+ return output;
+ }
+ if (peek().type !== 'eof') {
+ return FORMULA_ERROR.ERROR;
+ }
+ return output;
+ }
+
+ protected buildFormulaValueFormatter(column: Column): Formatter {
+ const formulaValueFormatter: Formatter = (row, _cell, value, columnDef, dataContext) => {
+ const currentRowItem =
+ dataContext ??
+ ((this._dataView as any)?.getItem && typeof (this._dataView as any).getItem === 'function'
+ ? (this._dataView as any).getItem(row)
+ : this.getDataItems()[row]);
+
+ const rowIdProp = this.getDatasetIdPropertyName();
+ const rowId = currentRowItem?.[rowIdProp] as number | string | undefined;
+ const columnId = (columnDef?.id ?? column.id) as number | string;
+ const field = (columnDef?.field ?? column.field ?? columnDef?.id ?? column.id) as string;
+ const rawCellValue = currentRowItem?.[field as keyof typeof currentRowItem] ?? value;
+ const fallbackValue = typeof rawCellValue === 'string' && rawCellValue.trim().startsWith('=') ? undefined : rawCellValue;
+
+ const evaluatedValue = rowId !== undefined ? this.getEvaluatedCellValue(rowId, columnId, rawCellValue, fallbackValue) : rawCellValue;
+
+ return evaluatedValue;
+ };
+
+ (formulaValueFormatter as any)[FormulaService.FORMULA_EVAL_FORMATTER_FLAG] = true;
+ return formulaValueFormatter;
+ }
+
+ protected withFormulaFormatterPipeline(column: Column, formulaValueFormatter: Formatter): Pick {
+ const existingFormatter = this.unwrapAutoEditableFormatter(column.formatter as Formatter | undefined);
+ const existingParams = (column.params || {}) as Record;
+
+ if (!existingFormatter) {
+ return {
+ formatter: formulaValueFormatter,
+ params: existingParams,
+ };
+ }
+
+ if (existingFormatter === Formatters.multiple) {
+ const formatters = Array.isArray(existingParams.formatters) ? [...existingParams.formatters] : [];
+ const hasFormulaFormatter = formatters.some(
+ (formatter: Formatter) => !!(formatter as any)?.[FormulaService.FORMULA_EVAL_FORMATTER_FLAG]
+ );
+ if (!hasFormulaFormatter) {
+ formatters.unshift(formulaValueFormatter);
+ }
+
+ return {
+ formatter: existingFormatter,
+ params: {
+ ...existingParams,
+ formatters,
+ },
+ };
+ }
+
+ return {
+ formatter: Formatters.multiple,
+ params: {
+ ...existingParams,
+ formatters: [formulaValueFormatter, existingFormatter],
+ },
+ };
+ }
+
+ protected unwrapAutoEditableFormatter(formatter?: Formatter): Formatter | undefined {
+ let currentFormatter = formatter as any;
+
+ // Defensively unwrap previously auto-wrapped formatters to avoid recursive wrapping.
+ while (currentFormatter?.__formulaAutoEditableWrapped && typeof currentFormatter?.__formulaAutoEditableBaseFormatter === 'function') {
+ currentFormatter = currentFormatter.__formulaAutoEditableBaseFormatter;
+ }
+
+ return currentFormatter as Formatter | undefined;
+ }
+
+ /** Normalize common Excel-like operators into parser-friendly syntax. */
+ protected normalizeFormulaSyntax(expression: string): string {
+ if (!expression) {
+ return expression;
+ }
+
+ return expression.replace(/×/g, '*').replace(/÷/g, '/').replace(/[−–—]/g, '-');
+ }
+
+ /** Return the complete logical column list, including hidden columns. */
+ protected getFormulaColumnIds(): string[] {
+ return ((this._grid?.getColumns?.() || []) as Column[]).map((column) => String(column.id));
+ }
+
+ /** Return the current DataView row identity list in display/evaluation order. */
+ protected getFormulaRowIds(): string[] {
+ return this.getDataItems()
+ .map((item) => item?.[this.getDatasetIdPropertyName()])
+ .filter((rowId) => rowId !== undefined && rowId !== null)
+ .map((rowId) => String(rowId));
+ }
+
+ /**
+ * Convert user-facing A1 references to stable AG-style references.
+ * Quoted formula strings are intentionally ignored so values such as "A1" remain literals.
+ */
+ protected convertA1ReferencesToStableRefs(
+ formula: string,
+ columnIds: string[] = this.getFormulaColumnIds(),
+ rowIds: string[] = this.getFormulaRowIds()
+ ): string {
+ if (!formula || columnIds.length === 0 || rowIds.length === 0) {
+ return formula;
+ }
+
+ const a1ReferenceRegex =
+ /(?
+ segment.replace(a1ReferenceRegex, (reference) => {
+ const [startToken, endToken] = reference.split(':', 2);
+ const startCell = parseExcelReferenceCell(startToken);
+ const endCell = endToken ? parseExcelReferenceCell(endToken) : undefined;
+ if (!startCell || (endToken && !endCell)) {
+ return reference;
+ }
+
+ const startColumnId = columnIds[startCell.cell];
+ const startRowId = rowIds[startCell.row];
+ if (startColumnId === undefined || startRowId === undefined) {
+ return reference;
+ }
+
+ const startRef = `REF(COLUMN(${JSON.stringify(startColumnId)}),ROW(${JSON.stringify(startRowId)}))`;
+ if (!endCell) {
+ return startRef;
+ }
+
+ const endColumnId = columnIds[endCell.cell];
+ const endRowId = rowIds[endCell.row];
+ if (endColumnId === undefined || endRowId === undefined) {
+ return reference;
+ }
+
+ return `${startRef}:REF(COLUMN(${JSON.stringify(endColumnId)}),ROW(${JSON.stringify(endRowId)}))`;
+ })
+ );
+ }
+
+ /** Convert the persisted stable syntax to the A1 syntax shown in the editor. */
+ protected toDisplayFormula(formula: string): string {
+ return this.replaceRefFunctionsWithA1Refs(formula, this.getFormulaColumnIds(), this.getFormulaRowIds(), 1);
+ }
+
+ /** Convert editor A1 syntax to the stable syntax used by runtime storage and export. */
+ protected toStoredFormula(formula: string): string {
+ return this.convertA1ReferencesToStableRefs(formula);
+ }
+
+ /** Transform only formula text outside quoted string literals. */
+ protected transformFormulaOutsideQuotedStrings(formula: string, transform: (segment: string) => string): string {
+ const quotedTextRegex = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
+ let result = '';
+ let previousEnd = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = quotedTextRegex.exec(formula)) !== null) {
+ result += transform(formula.slice(previousEnd, match.index));
+ result += match[0];
+ previousEnd = match.index + match[0].length;
+ }
+
+ return result + transform(formula.slice(previousEnd));
+ }
+
+ /** Replace REF(COLUMN("x"),ROW(...)) expressions by concrete A1 references. */
+ protected replaceRefFunctionsWithA1Refs(expression: string, columnIds: string[], rowIds: string[], excelRowOffset = 1): string {
+ if (!expression) {
+ return expression;
+ }
+
+ const withNamedRowRefs = expression.replace(
+ /REF\(\s*COLUMN\("([^"]+)"\)\s*,\s*ROW\("([^"]+)"\)\s*\)/gi,
+ (_match, columnId: string, rowId: string) => {
+ const columnIdx = columnIds.indexOf(String(columnId));
+ const rowIdx = rowIds.indexOf(String(rowId));
+ if (columnIdx < 0 || rowIdx < 0) {
+ return '';
+ }
+
+ const excelColName = getExcelColumnNameByIndex(columnIdx + 1);
+ const excelRowNumber = rowIdx + excelRowOffset;
+ return `${excelColName}${excelRowNumber}`;
+ }
+ );
+
+ return withNamedRowRefs.replace(
+ /REF\(\s*COLUMN\("([^"]+)"\)\s*,\s*ROW\((\d+)\)\s*\)/gi,
+ (_match, columnId: string, rowNumber: string) => {
+ const columnIdx = columnIds.indexOf(String(columnId));
+ const rowIdx = Number(rowNumber);
+ if (columnIdx < 0 || Number.isNaN(rowIdx)) {
+ return '';
+ }
+
+ const excelColName = getExcelColumnNameByIndex(columnIdx + 1);
+ const excelRowNumber = rowIdx + excelRowOffset - 1;
+ return `${excelColName}${excelRowNumber}`;
+ }
+ );
+ }
+
+ protected normalizeCustomFunctionInput(functionInput: FormulaCustomFunctionInput): FormulaCallback | undefined {
+ if (typeof functionInput === 'function') {
+ return functionInput;
+ }
+
+ const definition = functionInput as FormulaCustomFunctionDefinition | undefined;
+ if (!definition || typeof definition.func !== 'function') {
+ return undefined;
+ }
+
+ return (...args: unknown[]) => {
+ const flatValues: unknown[] = [];
+ const flatten = (value: unknown): void => {
+ if (Array.isArray(value)) {
+ for (const nestedValue of value) {
+ flatten(nestedValue);
+ }
+ return;
+ }
+ flatValues.push(value);
+ };
+
+ for (const arg of args) {
+ flatten(arg);
+ }
+
+ return definition.func({ values: flatValues });
+ };
+ }
+
+ protected resolveExcelRangeValues(
+ startColName: string,
+ startRowNumber: number,
+ endColName: string,
+ endRowNumber: number,
+ context: FormulaEvaluationContext
+ ): unknown[] {
+ const startColIdx = getExcelColumnIndexByName(startColName.toUpperCase());
+ const endColIdx = getExcelColumnIndexByName(endColName.toUpperCase());
+ if (startColIdx < 0 || endColIdx < 0) {
+ return [];
+ }
+
+ const minColIdx = Math.min(startColIdx, endColIdx);
+ const maxColIdx = Math.max(startColIdx, endColIdx);
+ const minRowNumber = Math.max(1, Math.min(startRowNumber, endRowNumber));
+ const maxRowNumber = Math.max(startRowNumber, endRowNumber);
+ const rowCount = maxRowNumber - minRowNumber + 1;
+ const cellCount = maxColIdx - minColIdx + 1;
+ if (!Number.isSafeInteger(rowCount) || !Number.isSafeInteger(cellCount) || rowCount * cellCount > FORMULA_MAX_REFERENCE_CELLS) {
+ return [FORMULA_ERROR.REF];
+ }
+ const rangeValues: unknown[] = [];
+
+ for (let rowNumber = minRowNumber; rowNumber <= maxRowNumber; rowNumber++) {
+ for (let colIdx = minColIdx; colIdx <= maxColIdx; colIdx++) {
+ const colName = getExcelColumnNameByIndex(colIdx + 1);
+ rangeValues.push(this.resolveExcelReferenceValue(colName, rowNumber, context));
+ }
+ }
+
+ return rangeValues;
+ }
+
+ protected getFormulaFunctionRegistry(): Map {
+ return createFormulaFunctionRegistry(this._customFunctions);
+ }
+
+ protected toExpressionArrayLiteral(values: unknown[]): string {
+ return `[${values.map((value) => this.toExpressionLiteral(value)).join(',')}]`;
+ }
+
+ protected static addFormulaValues(left: unknown, right: unknown): unknown {
+ if (left instanceof Date && typeof right === 'number') {
+ return FormulaService.addDays(left, right);
+ }
+ if (right instanceof Date && typeof left === 'number') {
+ return FormulaService.addDays(right, left);
+ }
+ return (left as any) + (right as any);
+ }
+
+ protected static subtractFormulaValues(left: unknown, right: unknown): unknown {
+ if (left instanceof Date && typeof right === 'number') {
+ return FormulaService.addDays(left, -right);
+ }
+ if (left instanceof Date && right instanceof Date) {
+ return (left.getTime() - right.getTime()) / (1000 * 60 * 60 * 24);
+ }
+ return (left as any) - (right as any);
+ }
+
+ protected static addDays(date: Date, days: number): Date {
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
+ }
+
+ protected resolveExcelReferenceValue(colName: string, rowNumber: number, context: FormulaEvaluationContext): unknown {
+ const colIdx = getExcelColumnIndexByName(colName.toUpperCase());
+ if (colIdx < 0 || Number.isNaN(rowNumber) || rowNumber < 1) {
+ return FORMULA_ERROR.REF;
+ }
+
+ const columns = (this._grid?.getColumns?.() || []) as Column[];
+ const column = columns[colIdx];
+ const item = this.getDataItems()[rowNumber - 1];
+ if (!column || !item) {
+ return FORMULA_ERROR.REF;
+ }
+
+ const rowIdProp = this.getDatasetIdPropertyName();
+ const rowId = item[rowIdProp] as number | string;
+ const columnId = column.id as number | string;
+ const field = (column.field ?? column.id) as string;
+ const rawValue = item[field as keyof typeof item];
+
+ if (typeof rawValue === 'string' && rawValue.trim().startsWith('=')) {
+ const key = this.buildStoreKey(rowId, columnId);
+ if (context.visited.has(key)) {
+ return FORMULA_ERROR.REF;
+ }
+
+ const nestedFormula = this.getFormula(rowId, columnId) ?? rawValue;
+ const nestedMemoKey = this.buildEvaluationMemoKey(rowId, columnId, nestedFormula);
+ if (context.memo.has(nestedMemoKey)) {
+ return context.memo.get(nestedMemoKey);
+ }
+
+ context.visited.add(key);
+ const nested = this.evaluateFormulaExpression(nestedFormula, context);
+ context.visited.delete(key);
+ context.memo.set(nestedMemoKey, nested);
+ return nested;
+ }
+
+ return rawValue;
+ }
+
+ protected getCellRawValue(rowId: number | string, columnId: number | string): unknown {
+ const rowIdProp = this.getDatasetIdPropertyName();
+ const item = this.getDataItems().find((it) => String(it?.[rowIdProp]) === String(rowId));
+ if (!item) {
+ return undefined;
+ }
+
+ const column = ((this._grid?.getColumns?.() || []) as Column[]).find((col) => String(col.id) === String(columnId));
+ if (!column) {
+ return undefined;
+ }
+
+ const field = (column.field ?? column.id) as string;
+ return item[field as keyof typeof item];
+ }
+
+ protected toExpressionLiteral(value: unknown): string {
+ if (value === null || value === undefined || value === '') {
+ return '0';
+ }
+
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? String(value) : '0';
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false';
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
+ return trimmed;
+ }
+ return JSON.stringify(trimmed);
+ }
+
+ return JSON.stringify(String(value));
+ }
+
+ protected autoAssignFormulaEditorToColumns(): void {
+ if (this._options.autoAssignEditor === false || !this._grid?.getColumns || !this._grid?.setColumns) {
+ return;
+ }
+
+ const autoEditableFormatter = this._grid.getOptions?.().autoAddCustomEditorFormatter as Formatter | undefined;
+ const columns = (this._grid.getColumns?.() || []) as Column[];
+ const formulaFunctionNames = Array.from(this.getFormulaFunctionRegistry().keys()).sort((a, b) => a.localeCompare(b));
+ let hasChanges = false;
+
+ const updatedColumns = columns.map((column) => {
+ if (!column?.allowFormula) {
+ return column;
+ }
+
+ const columnEditor = (column.editor || {}) as ColumnEditor;
+ const hasEditorModel = !!columnEditor.model;
+
+ if (hasEditorModel && columnEditor.model !== FormulaCellEditor) {
+ return column;
+ }
+
+ const mergedParams = {
+ ...(this._options.editorParams || {}),
+ ...(columnEditor.params || {}),
+ } as FormulaEditorParams;
+
+ if (!Array.isArray(mergedParams.formulaFunctionList) || mergedParams.formulaFunctionList.length === 0) {
+ mergedParams.formulaFunctionList = formulaFunctionNames;
+ }
+
+ const userOnFormulaInputChange = mergedParams.onFormulaInputChange;
+ mergedParams.onFormulaInputChange = (formula: string) => {
+ userOnFormulaInputChange?.(formula);
+ };
+ mergedParams.toDisplayFormula = (formula: string, item?: any) => {
+ const rowId = item?.[this.getDatasetIdPropertyName()] as number | string | undefined;
+ return rowId === undefined || rowId === null
+ ? this.toDisplayFormula(formula)
+ : this.toDisplayFormulaForCell(formula, rowId, column.id);
+ };
+ mergedParams.toStoredFormula = (formula: string, item?: any) => {
+ const rowId = item?.[this.getDatasetIdPropertyName()] as number | string | undefined;
+ const storedFormula = this.toStoredFormula(formula);
+ if (rowId !== undefined && rowId !== null && this.containsDirectExcelReference(formula)) {
+ this.captureFormulaReferenceAbsoluteFlags(this.buildStoreKey(rowId, column.id), formula);
+ }
+ return storedFormula;
+ };
+ mergedParams.onFormulaCommit = (formula: string, item?: any) => {
+ const rowId = item?.[this.getDatasetIdPropertyName()] as number | string | undefined;
+ if (rowId !== undefined && rowId !== null) {
+ this.setFormula(rowId, column.id, formula);
+ }
+ };
+
+ const formulaValueFormatter = this.buildFormulaValueFormatter(column);
+ const { formatter: pipelineFormatter, params: pipelineParams } = this.withFormulaFormatterPipeline(column, formulaValueFormatter);
+
+ let nextFormatter = pipelineFormatter;
+ const alreadyWrapped = !!(nextFormatter as any)?.__formulaAutoEditableWrapped;
+
+ if (!alreadyWrapped) {
+ const basePipelineFormatter = pipelineFormatter;
+ const wrappedFormatter: Formatter = (row, cell, value, columnDef, dataContext, grid) => {
+ const formattedValue = basePipelineFormatter ? basePipelineFormatter(row, cell, value, columnDef, dataContext, grid) : value;
+ const baseValue = formattedValue === undefined ? value : formattedValue;
+
+ if (typeof autoEditableFormatter === 'function') {
+ return autoEditableFormatter(row, cell, baseValue, columnDef, dataContext, grid);
+ }
+
+ // Fallback behavior: still show editable UI marker when formula feature is enabled.
+ const isGridEditable = !!grid?.getOptions?.().editable;
+ const isFormulaCell = !!columnDef?.allowFormula;
+ if (!isGridEditable || !isFormulaCell) {
+ return baseValue;
+ }
+
+ const divElm = createDomElement('div', { className: 'editing-field' });
+ if (baseValue instanceof HTMLElement) {
+ divElm.appendChild(baseValue);
+ } else {
+ divElm.textContent = baseValue === null || baseValue === undefined ? '' : String(baseValue);
+ }
+ return divElm;
+ };
+ (wrappedFormatter as any).__formulaAutoEditableWrapped = true;
+ (wrappedFormatter as any).__formulaAutoEditableBaseFormatter = basePipelineFormatter;
+ nextFormatter = wrappedFormatter;
+ }
+
+ if (!this._originalColumnDefsById.has(column.id)) {
+ this._originalColumnDefsById.set(column.id, {
+ formatter: column.formatter,
+ params: column.params,
+ editorClass: column.editorClass,
+ editor: column.editor,
+ });
+ }
+
+ hasChanges = true;
+ return {
+ ...column,
+ formatter: nextFormatter,
+ params: pipelineParams,
+ editorClass: FormulaCellEditor,
+ editor: {
+ ...columnEditor,
+ model: FormulaCellEditor,
+ params: mergedParams,
+ },
+ };
+ });
+
+ if (hasChanges) {
+ this._hasAutoAssignedFormulaEditor = true;
+ this._grid.setColumns(updatedColumns as Column[]);
+ this._grid.invalidate?.();
+ this._grid.render?.();
+ }
+ }
+
+ /** Restore columns to their pre-plugin formatter/editor definitions (mirrors {@link disableExcelHeaderPrefix}). */
+ protected restoreAutoAssignedFormulaEditorColumns(): void {
+ if (
+ !this._hasAutoAssignedFormulaEditor ||
+ !this._grid?.getColumns ||
+ !this._grid?.setColumns ||
+ this._originalColumnDefsById.size === 0
+ ) {
+ return;
+ }
+
+ const columns = (this._grid.getColumns() || []) as Column[];
+ const restoredColumns = columns.map((column) => {
+ const original = this._originalColumnDefsById.get(column.id);
+ if (!original) {
+ return column;
+ }
+ return { ...column, ...original };
+ });
+
+ this._grid.setColumns(restoredColumns as Column[]);
+ this._grid.invalidate?.();
+ this._grid.render?.();
+ this._originalColumnDefsById.clear();
+ this._hasAutoAssignedFormulaEditor = false;
+ }
+}
diff --git a/packages/formula-plugin/src/index.ts b/packages/formula-plugin/src/index.ts
new file mode 100644
index 000000000..d8df71c56
--- /dev/null
+++ b/packages/formula-plugin/src/index.ts
@@ -0,0 +1,4 @@
+export * from './formula.service.js';
+export * from './formula.cellEditor.js';
+export * from './formula-errors.js';
+export * from './formula-functions.js';
diff --git a/packages/formula-plugin/tsconfig.json b/packages/formula-plugin/tsconfig.json
new file mode 100644
index 000000000..5f10effa5
--- /dev/null
+++ b/packages/formula-plugin/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compileOnSave": false,
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "typeRoots": ["./node_modules/@types", "../../node_modules/@types"]
+ },
+ "exclude": ["dist", "node_modules", "**/*.spec.ts"],
+ "filesGlob": ["./src/**/*.ts"],
+ "include": ["src/**/*.ts", "types/**/*.ts"],
+ "references": [
+ {
+ "path": "../common"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f306949e7..00fae0eff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -624,6 +624,9 @@ importers:
'@slickgrid-universal/excel-export':
specifier: workspace:*
version: link:../../packages/excel-export
+ '@slickgrid-universal/formula-plugin':
+ specifier: workspace:*
+ version: link:../../packages/formula-plugin
'@slickgrid-universal/graphql':
specifier: workspace:*
version: link:../../packages/graphql
@@ -1443,6 +1446,15 @@ importers:
specifier: workspace:*
version: link:../event-pub-sub
+ packages/formula-plugin:
+ dependencies:
+ '@slickgrid-universal/binding':
+ specifier: workspace:*
+ version: link:../binding
+ '@slickgrid-universal/common':
+ specifier: workspace:*
+ version: link:../common
+
packages/graphql:
dependencies:
'@slickgrid-universal/common':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index a5e8205bd..bc1659786 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -77,5 +77,5 @@ overrides:
picomatch: ^4.0.7
postcss: ^8.5.26
qs: ^6.15.3
- undici: ^7.29.0
+ undici: '^7.29.0'
vite: 'catalog:'
diff --git a/test/cypress.config.ts b/test/cypress.config.ts
index d2688c482..dfa839891 100644
--- a/test/cypress.config.ts
+++ b/test/cypress.config.ts
@@ -17,6 +17,7 @@ interface ParsedXlsxExport {
header: string[];
firstDataRow: string[];
dataRows: string[][];
+ formulaRows: string[][];
}
function isExcelExportFile(fileName: string): boolean {
@@ -150,6 +151,32 @@ function parseSheetRowValues(sheetXml: string, sharedStrings: string[], rowNumbe
return values;
}
+function parseSheetRowFormulas(sheetXml: string, rowNumber: number): string[] {
+ const rowRegex = new RegExp(`]*r="${rowNumber}"[^>]*>([\\s\\S]*?)<\\/row>`);
+ const rowMatch = sheetXml.match(rowRegex);
+ if (!rowMatch) {
+ return [];
+ }
+
+ const formulas: string[] = [];
+ const cellRegex = /]*)>([\s\S]*?)<\/c>/g;
+ let cellMatch: RegExpExecArray | null;
+
+ while ((cellMatch = cellRegex.exec(rowMatch[1])) !== null) {
+ const cellReference = cellMatch[1].match(/\sr="([A-Z]+)\d+"/);
+ const formulaMatch = cellMatch[2].match(/]*)?>([\s\S]*?)<\/f>/);
+ if (!cellReference || !formulaMatch) {
+ continue;
+ }
+
+ const columnName = cellReference[1];
+ const columnIndex = [...columnName].reduce((index, letter) => index * 26 + letter.charCodeAt(0) - 64, 0) - 1;
+ formulas[columnIndex] = decodeXmlEntities(formulaMatch[1]);
+ }
+
+ return formulas.map((formula) => formula || '');
+}
+
function parseXlsxExport(filePath: string, maxDataRows = 10): ParsedXlsxExport {
const zipBuffer = fs.readFileSync(filePath);
const entries = extractZipEntries(zipBuffer);
@@ -175,6 +202,7 @@ function parseXlsxExport(filePath: string, maxDataRows = 10): ParsedXlsxExport {
const dataRows = Array.from({ length: Math.max(0, maxDataRows) }, (_unused, index) =>
parseSheetRowValues(firstSheetXml, sharedStrings, index + 2)
);
+ const formulaRows = Array.from({ length: Math.max(0, maxDataRows) }, (_unused, index) => parseSheetRowFormulas(firstSheetXml, index + 2));
return {
fileName: path.basename(filePath),
@@ -183,6 +211,7 @@ function parseXlsxExport(filePath: string, maxDataRows = 10): ParsedXlsxExport {
header,
firstDataRow,
dataRows,
+ formulaRows,
};
}
diff --git a/test/cypress/e2e/example47.cy.ts b/test/cypress/e2e/example47.cy.ts
new file mode 100644
index 000000000..3259e0f75
--- /dev/null
+++ b/test/cypress/e2e/example47.cy.ts
@@ -0,0 +1,390 @@
+describe('Example 47 - Formula Service (MVP)', () => {
+ const GRID_ROW_HEIGHT = 38;
+ const fullTitles = ['#', 'Name', 'Price', 'Quantity', 'Sub-Total', 'Taxable', 'Taxes', 'Total', 'Custom Sum'];
+
+ const rowSelector = (rowIdx: number) => `.grid47 [style="transform: translateY(${GRID_ROW_HEIGHT * rowIdx}px);"]`;
+ const cell = (rowIdx: number, cellIdx: number) => `${rowSelector(rowIdx)} > .slick-cell:nth(${cellIdx})`;
+
+ it('should display Example title', () => {
+ cy.visit(`${Cypress.config('baseUrl')}/example47`);
+ cy.get('h3').should('contain', 'Example 47 - Formula Service (MVP)');
+ });
+
+ it('should have exact column titles on grid', () => {
+ cy.get('.grid47')
+ .find('.slick-header-columns')
+ .children()
+ .each(($child, index) => expect($child.text()).to.eq(fullTitles[index]));
+ });
+
+ it('should check first 3 rows with calculated values (including Custom Sum)', () => {
+ // 1st row
+ cy.get(cell(0, 0)).contains('1');
+ cy.get(cell(0, 1)).contains('Oranges');
+ cy.get(cell(0, 2)).contains('$2.22');
+ cy.get(cell(0, 3)).contains('4');
+ cy.get(cell(0, 4)).contains('$8.88');
+ cy.get(cell(0, 5)).should('have.text', '');
+ cy.get(cell(0, 6)).contains('$0.00');
+ cy.get(cell(0, 7)).contains('$8.88');
+ cy.get(cell(0, 8)).contains('$6.22');
+
+ // 2nd row
+ cy.get(cell(1, 0)).contains('2');
+ cy.get(cell(1, 1)).contains('Apples');
+ cy.get(cell(1, 2)).contains('$1.55');
+ cy.get(cell(1, 3)).contains('3');
+ cy.get(cell(1, 4)).contains('$4.65');
+ cy.get(cell(1, 5)).should('have.text', '');
+ cy.get(cell(1, 6)).contains('$0.00');
+ cy.get(cell(1, 7)).contains('$4.65');
+ cy.get(cell(1, 8)).contains('$4.55');
+
+ // 3rd row
+ cy.get(cell(2, 0)).contains('3');
+ cy.get(cell(2, 1)).contains('Honeycomb Cereals');
+ cy.get(cell(2, 2)).contains('$4.55');
+ cy.get(cell(2, 3)).contains('2');
+ cy.get(cell(2, 4)).contains('$9.10');
+ cy.get(cell(2, 5)).find('.mdi-check');
+ cy.get(cell(2, 6)).contains('$0.68');
+ cy.get(cell(2, 7)).contains('$9.78');
+ cy.get(cell(2, 8)).contains('$6.55');
+ });
+
+ it('should edit a formula cell in Formula Editor and persist the updated formula result', () => {
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=C1*D1*2{enter}', { force: true });
+
+ cy.get(cell(0, 4)).contains('$17.76');
+ cy.get(cell(0, 7)).contains('$17.76');
+
+ // Re-open editor and verify the entered formula text persisted in store.
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input')
+ .should('be.visible')
+ .invoke('text')
+ .then((text) => text.replace(/\s+/g, ''))
+ .should('contain', '=C1*D1*2');
+ cy.get('.formula-editor-input').type('{enter}', { force: true });
+
+ // restore baseline formulas for subsequent test steps in this serial run
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ // In this demo, reloaded formula text can require one editor commit to refresh displayed calculated value.
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').type('{enter}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+ cy.get(cell(0, 7)).contains('$8.88');
+ });
+
+ it('should keep first argument and append second reference after operator in function expression', () => {
+ // Start formula entry from the Sub-Total formula cell.
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=s', { force: true });
+
+ // Pick SUM from autocomplete.
+ cy.get('.formula-autocomplete').should('be.visible');
+ cy.contains('.formula-autocomplete div', /^SUM$/).click({ force: true });
+
+ // Pick first cell reference, then multiply operator, then second reference.
+ cy.get(cell(0, 2)).click();
+ cy.get('.formula-editor-input').should('be.visible').type('*', { force: true });
+ cy.get(cell(0, 3)).click();
+
+ // Regression assertion: second click must append at caret, not replace C1.
+ cy.get('.formula-editor-input')
+ .invoke('text')
+ .then((text) => text.replace(/\s+/g, ''))
+ .should('eq', '=SUM(C1*D1');
+
+ // This test validates editor UX string composition (not formula execution semantics).
+ // Cancel edit to avoid committing a partially composed function expression in this serial flow.
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+
+ // Restore canonical formula text for subsequent serial test steps.
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').type('{enter}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+ });
+
+ it('should keep multi-reference cell colors while typing formula text', () => {
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=C1*SUM(D1:D3)', { force: true });
+
+ // C1 should keep the first reference color.
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-1');
+
+ // D1:D3 should keep the second reference color across the full range.
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(1, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(2, 3)).should('have.class', 'formula-cell-color-2');
+
+ // Cancel this transient edit to avoid affecting subsequent tests.
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+ });
+
+ it('should keep formula-token colors aligned with matching grid cell colors', () => {
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=C1*SUM(D1:D3)', { force: true });
+
+ // Editor token colors.
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-1', /^C1$/).should('exist');
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-2', /^D1:D3$/).should('exist');
+
+ // Grid cell colors must match token palette assignment.
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(1, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(2, 3)).should('have.class', 'formula-cell-color-2');
+
+ // Commit and reopen: FormulaCellEditor rebuilds grid highlights from the saved formula,
+ // and its reference order must stay aligned with the editor token order.
+ cy.get('.formula-editor-input').type('{enter}', { force: true });
+ cy.get(cell(0, 4)).dblclick();
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-1', /^C1$/).should('exist');
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-2', /^D1:D3$/).should('exist');
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(1, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(2, 3)).should('have.class', 'formula-cell-color-2');
+
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ });
+
+ it('should preserve reference colors when a range precedes a single-cell reference', () => {
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=SUM(D1:D3)*C1', { force: true });
+
+ // The shared cache must assign colors by textual order, regardless of reference shape.
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-1', /^D1:D3$/).should('exist');
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-2', /^C1$/).should('exist');
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(1, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(2, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-2');
+
+ // Commit and reopen so FormulaCellEditor initial-load highlighting is exercised.
+ cy.get('.formula-editor-input').type('{enter}', { force: true });
+ cy.get(cell(0, 4)).dblclick();
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-1', /^D1:D3$/).should('exist');
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-2', /^C1$/).should('exist');
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(1, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(2, 3)).should('have.class', 'formula-cell-color-1');
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-2');
+
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ });
+
+ it('should keep stable coloring when formula contains an incomplete range reference', () => {
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=C1*SUM(D1:D)', { force: true });
+
+ // Complete reference keeps color #1.
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-1', /^C1$/).should('exist');
+ cy.get(cell(0, 2)).should('have.class', 'formula-cell-color-1');
+
+ // Incomplete range keeps its own color #2 and should only color the valid start cell D1.
+ cy.contains('.formula-editor-input .formula-token.formula-token-color-2', /^D1:D$/).should('exist');
+ cy.get(cell(0, 3)).should('have.class', 'formula-cell-color-2');
+ cy.get(cell(1, 3)).should('not.have.class', 'formula-cell-color-2');
+ cy.get(cell(2, 3)).should('not.have.class', 'formula-cell-color-2');
+
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+ });
+
+ it('should copy and cut plain text without nbsp/html artifacts from formula editor', () => {
+ cy.window().then((win) => {
+ const writeTextStub = cy.stub().resolves();
+ Object.defineProperty(win.navigator, 'clipboard', {
+ value: { writeText: writeTextStub },
+ configurable: true,
+ });
+ cy.wrap(writeTextStub).as('writeTextStub');
+ });
+
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input')
+ .should('be.visible')
+ .invoke('text', '=SUM(C1\u00a0+\u00a0D1)')
+ .trigger('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true });
+
+ cy.get('@writeTextStub').should('have.been.calledWithExactly', '=SUM(C1 + D1)');
+
+ cy.get('.formula-editor-input').trigger('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true });
+ cy.get('@writeTextStub').should('have.been.calledWithExactly', '=SUM(C1 + D1)');
+ cy.get('.formula-editor-input').invoke('text').should('eq', '');
+
+ // Exit transient edit and restore baseline formulas for later serial tests.
+ cy.get('.formula-editor-input').type('{esc}', { force: true });
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').type('{enter}', { force: true });
+ cy.get(cell(0, 4)).contains('$8.88');
+ });
+
+ it('should evaluate IF formula correctly for non-taxable and taxable rows', () => {
+ // non-taxable row: IF condition should return 0 taxes
+ cy.get(cell(0, 6)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=IF(F1=TRUE,E1*0.2,0){enter}', { force: true });
+ cy.get(cell(0, 6)).contains('$0.00');
+ cy.get(cell(0, 7)).contains('$8.88');
+
+ // taxable row: IF condition should calculate taxes from sub-total
+ cy.get(cell(2, 6)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=IF(F3=TRUE,E3*0.2,0){enter}', { force: true });
+ cy.get(cell(2, 6)).contains('$1.82');
+ cy.get(cell(2, 7)).contains('$10.92');
+
+ // restore baseline formulas for subsequent serial tests
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').type('{enter}', { force: true });
+ cy.get(cell(2, 6)).contains('$0.68');
+ cy.get(cell(2, 7)).contains('$9.78');
+ });
+
+ it('should support SUM and other built-in functions and keep custom function column editable', () => {
+ // verify default custom function exists in editor text for row 1
+ cy.get(cell(0, 8)).dblclick();
+ cy.get('.formula-editor-input')
+ .should('be.visible')
+ .invoke('text')
+ .then((text) => text.replace(/\s+/g, ''))
+ .should('contain', '=CUSTOMSUM(C1:D1)');
+
+ // SUM on row 1 (same expected result)
+ cy.get('.formula-editor-input').click().type('{selectall}=SUM(C1:D1){enter}', { force: true });
+ cy.get(cell(0, 8)).contains('$6.22');
+
+ // PRODUCT on row 2
+ cy.get(cell(1, 8)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=PRODUCT(C2,D2){enter}', { force: true });
+ cy.get(cell(1, 8)).contains('$4.65');
+
+ // MAX on row 3
+ cy.get(cell(2, 8)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=MAX(C3,D3){enter}', { force: true });
+ cy.get(cell(2, 8)).contains('$4.55');
+
+ // restore baseline formulas for subsequent serial tests
+ cy.get('[data-test="reload-formulas-btn"]').click();
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').type('{enter}', { force: true });
+ cy.get(cell(0, 8)).contains('$6.22');
+ cy.get(cell(1, 8)).contains('$4.55');
+ cy.get(cell(2, 8)).contains('$6.55');
+ });
+
+ it('should update tax rate and then recalculate formula-driven values after editing price/qty', () => {
+ cy.get('[data-test="taxrate"]').clear().type('6.25');
+ cy.get('[data-test="update-btn"]').click();
+
+ // 3rd row taxes/total should reflect new tax rate
+ cy.get(cell(2, 6)).contains('$0.57');
+ cy.get(cell(2, 7)).contains('$9.67');
+
+ // edit price + qty in row 3 and validate formula recalculation
+ cy.get(cell(2, 2)).dblclick();
+ cy.get(`${cell(2, 2)} input`)
+ .clear()
+ .type('4.23{enter}');
+ cy.get(cell(2, 3)).dblclick();
+ cy.get(`${cell(2, 3)} input`)
+ .clear()
+ .type('3{enter}');
+
+ cy.get(cell(2, 4)).contains('$12.69');
+ cy.get(cell(2, 6)).contains('$0.79');
+ cy.get(cell(2, 7)).contains('$13.48');
+ cy.get(cell(2, 8)).contains('$7.23');
+ });
+
+ it('should group by Taxable and allow returning back to ungrouped view', () => {
+ cy.get('[data-test="group-by-btn"]').click();
+
+ cy.get('.grid47 .slick-group').should('have.length.at.least', 2);
+ cy.get('.grid47 .slick-group').first().should('contain', 'Taxable:');
+ cy.get('.grid47 .slick-group-totals').should('have.length.at.least', 1);
+
+ cy.get('[data-test="clear-grouping-btn"]').click();
+
+ cy.get(cell(0, 1)).contains('Oranges');
+ cy.get(cell(1, 1)).contains('Apples');
+ });
+
+ it('should infer a numeric series when drag-filling static values in a formula column', () => {
+ cy.reload();
+
+ // Replace the first two Sub-Total formulas with the numeric seed values 10 and 20.
+ cy.get(cell(0, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}10{enter}', { force: true });
+ cy.get(cell(1, 4)).dblclick();
+ cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}20{enter}', { force: true });
+
+ // Select the two seed cells with the cell-range selector, then drag the fill handle down through row 4.
+ cy.get(cell(0, 4)).click({ force: true });
+ cy.get(cell(0, 4)).trigger('mousedown', { which: 1, force: true });
+ cy.get(cell(1, 4)).trigger('mousemove', 'bottomRight').trigger('mouseup', 'bottomRight', { which: 1, force: true });
+ cy.get('.grid47 .slick-cell.selected').should('have.length', 2);
+ cy.get(cell(1, 4)).find('.slick-drag-replace-handle').trigger('mousedown', { which: 1, force: true });
+ cy.get(cell(3, 4)).trigger('mousemove', 'bottomRight').trigger('mouseup', 'bottomRight', { which: 1, force: true });
+
+ cy.get(cell(2, 4)).should('contain', '$30.00');
+ cy.get(cell(3, 4)).should('contain', '$40.00');
+ });
+
+ it('should preserve formula results after column reorder and hiding a referenced source column', () => {
+ cy.reload();
+
+ const reorderedTitles = ['#', 'Name', 'Quantity', 'Sub-Total', 'Price', 'Taxable', 'Taxes', 'Total', 'Custom Sum'];
+ cy.get('.grid47 .slick-header-columns .slick-header-column:nth(2)')
+ .contains('Price')
+ .drag('.grid47 .slick-header-columns .slick-header-column:nth(4)');
+ cy.get('.grid47 .slick-header-columns')
+ .children()
+ .each(($child, index) => expect($child.text()).to.eq(reorderedTitles[index]));
+
+ // Price and Quantity moved, but the stable formula still calculates the same Sub-Total.
+ cy.get(cell(0, 2)).should('contain', '4');
+ cy.get(cell(0, 3)).should('contain', '$8.88');
+ cy.get(cell(0, 4)).should('contain', '$2.22');
+
+ // Hide Price through the column picker and verify formulas can still evaluate at runtime.
+ cy.get('.grid47 .slick-header-column').contains('Price').trigger('mouseover').trigger('contextmenu').invoke('show');
+ cy.get('.slick-column-picker:visible input[data-columnid="price"]').parent('.icon-checkbox-container').click({ force: true });
+ cy.get('.slick-column-picker:visible .close').click({ force: true });
+
+ cy.get('.grid47 .slick-header-columns .slick-header-column').should('have.length', 8);
+ cy.get(cell(0, 3)).should('contain', '$8.88');
+ cy.get(cell(0, 6)).should('contain', '$8.88');
+ });
+
+ it('should export reordered formulas with the correct Excel row offset', () => {
+ cy.reload();
+ const downloadsFolder = Cypress.config('downloadsFolder');
+
+ cy.get('.grid47 .slick-header-columns .slick-header-column:nth(2)')
+ .contains('Price')
+ .drag('.grid47 .slick-header-columns .slick-header-column:nth(4)');
+ cy.task('clearXlsxDownloads', { downloadsFolder });
+ cy.get('[data-test="export-excel-btn"]').click();
+
+ cy.task('readLatestXlsxExport', { downloadsFolder, timeoutMs: 15000, maxDataRows: 2 }).then((xlsx: any) => {
+ // formulaRows[0] is the header row; the custom title row plus the header place
+ // the first dataset row on Excel row 3, which is formulaRows[1].
+ // The # column is excluded from Excel, and Price is dropped after
+ // Sub-Total by the drag operation. The exported columns are Name (A),
+ // Quantity (B), Sub-Total (C), Price (D), Taxable (E), Taxes (F),
+ // Total (G), and Custom Sum (H).
+ expect(xlsx.formulaRows[1][2]).to.equal('D3*B3');
+ expect(xlsx.formulaRows[1][6]).to.equal('C3+F3');
+ });
+ });
+});
diff --git a/tsconfig.packages.json b/tsconfig.packages.json
index 0f84a154c..d38ffac67 100644
--- a/tsconfig.packages.json
+++ b/tsconfig.packages.json
@@ -9,6 +9,7 @@
{ "path": "./packages/empty-warning-component" },
{ "path": "./packages/event-pub-sub" },
{ "path": "./packages/excel-export" },
+ { "path": "./packages/formula-plugin" },
{ "path": "./packages/graphql" },
{ "path": "./packages/odata" },
{ "path": "./packages/pagination-component" },