diff --git a/AGENTS.md b/AGENTS.md index 0c9f01d06..2ac199bfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Changes in `packages/` can affect every framework. Preserve backward compatibili - Unit tests use Vitest with `test/vitest.config.mts`. - E2E tests use Cypress with `test/cypress.config.ts`. - Cypress tests use `testIsolation: false`; preserve their execution order and inherited state. +- The Vanilla demo is the default Cypress target. Start its Vite watch server first with `pnpm serve:vite` (or use an already-running Vanilla server), then run `pnpm cypress:ci --spec test/cypress/e2e/.cy.ts` for a focused spec. - Framework demos provide headless Cypress CI scripts. Start the matching demo server first (`pnpm angular:serve`, `pnpm aurelia:serve`, `pnpm react:serve`, or `pnpm vue:serve`). - Run the corresponding root CI command: `pnpm angular:cypress:ci`, `pnpm aurelia:cypress:ci`, `pnpm react:cypress:ci`, or `pnpm vue:cypress:ci` (for example, `pnpm aurelia:cypress:ci`). These commands use each framework's Cypress config and are preferred for validating framework-specific E2E suites. - Add or update tests for behavior changes, especially in core packages. diff --git a/demos/vanilla/package.json b/demos/vanilla/package.json index 98024a552..c07d00419 100644 --- a/demos/vanilla/package.json +++ b/demos/vanilla/package.json @@ -19,6 +19,7 @@ "@slickgrid-universal/composite-editor-component": "workspace:*", "@slickgrid-universal/custom-tooltip-plugin": "workspace:*", "@slickgrid-universal/excel-export": "workspace:*", + "@slickgrid-universal/formula-plugin": "workspace:*", "@slickgrid-universal/graphql": "workspace:*", "@slickgrid-universal/odata": "workspace:*", "@slickgrid-universal/pdf-export": "workspace:*", @@ -42,4 +43,4 @@ "typescript": "catalog:", "vite": "catalog:" } -} +} \ No newline at end of file diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index 1976666f5..e62c7a932 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -44,6 +44,7 @@ import Example43 from './examples/example43.js'; import Example44 from './examples/example44.js'; import Example45 from './examples/example45.js'; import Example46 from './examples/example46.js'; +import Example47 from './examples/example47.js'; import Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -98,6 +99,7 @@ export class AppRouting { { route: 'example44', name: 'example44', view: './examples/example44.html', viewModel: Example44, title: 'Example44' }, { route: 'example45', name: 'example45', view: './examples/example45.html', viewModel: Example45, title: 'Example45' }, { route: 'example46', name: 'example46', view: './examples/example46.html', viewModel: Example46, title: 'Example46' }, + { route: 'example47', name: 'example47', view: './examples/example47.html', viewModel: Example47, title: 'Example47' }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 8756cc7cb..9e7fb7b58 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -87,6 +87,7 @@

Slickgrid-Universal

Example44 - Variable Row Height (provider) Example45 - Variable Row Height (metadata) Example46 - RTL (Right-to-Left) + Example47 - Formula Service diff --git a/demos/vanilla/src/examples/example47.html b/demos/vanilla/src/examples/example47.html new file mode 100644 index 000000000..3d45ce4e4 --- /dev/null +++ b/demos/vanilla/src/examples/example47.html @@ -0,0 +1,92 @@ +

+ Example 47 - Formula Service (MVP) + + + + + +

+ +
+ 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 92bd1f34b..e17b22eff 100644 --- a/docs/grid-functionalities/row-selection.md +++ b/docs/grid-functionalities/row-selection.md @@ -305,6 +305,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 ccd61a130..e9b8926ce 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -6175,7 +6175,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]); } @@ -6186,7 +6187,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 c65c9ef4c..a2b2eccea 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -487,6 +487,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 f36316aae..3a69726b3 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..ff36b4ed2 --- /dev/null +++ b/packages/formula-plugin/src/formula-reference.ts @@ -0,0 +1,209 @@ +export 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 }; +} + +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. */ +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..9bd712e37 --- /dev/null +++ b/packages/formula-plugin/src/formula.drag-fill.ts @@ -0,0 +1,266 @@ +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. */ +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. */ +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 f2aaca732..0d8c9d79c 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 66277a6be..13332fca9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -77,5 +77,5 @@ overrides: picomatch: ^4.0.5 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" },