From 1fc606527f4c791c2eb71f41feb0f3297a25f37f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 6 Aug 2026 16:57:32 -0400 Subject: [PATCH 01/57] feat(formulas): create new Formula Editor plugin --- demos/vanilla/package.json | 3 +- demos/vanilla/src/app-routing.ts | 2 + demos/vanilla/src/app.html | 3 +- demos/vanilla/src/examples/example46.html | 92 + demos/vanilla/src/examples/example46.scss | 41 + demos/vanilla/src/examples/example46.ts | 672 ++++++++ docs/TOC.md | 2 + docs/grid-functionalities/export-to-excel.md | 11 + .../grid-functionalities/formula-functions.md | 158 ++ docs/grid-functionalities/formula-service.md | 135 ++ .../grid-functionalities/export-to-excel.md | 11 + .../grid-functionalities/export-to-excel.md | 11 + .../grid-functionalities/export-to-excel.md | 11 + .../grid-functionalities/export-to-excel.md | 11 + packages/common/package.json | 4 +- packages/common/src/global-grid-options.ts | 1 + packages/common/src/index.ts | 2 +- .../common/src/interfaces/column.interface.ts | 7 + .../interfaces/formulaProvider.interface.ts | 50 + .../src/interfaces/gridOption.interface.ts | 3 + packages/common/src/interfaces/index.ts | 1 + packages/common/src/styles/_variables.scss | 70 + packages/common/src/styles/slick-plugins.scss | 103 ++ packages/excel-export/package.json | 4 +- .../src/excelExport.service.spec.ts | 99 +- .../excel-export/src/excelExport.service.ts | 185 +- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 149 ++ packages/formula-plugin/README.md | 29 + packages/formula-plugin/package.json | 43 + packages/formula-plugin/src/formula-errors.ts | 18 + .../src/formula-functions.spec.ts | 115 ++ .../formula-plugin/src/formula-functions.ts | 226 +++ .../src/formula.cellEditor.spec.ts | 510 ++++++ .../formula-plugin/src/formula.cellEditor.ts | 943 +++++++++++ .../src/formula.service.spec.ts | 953 +++++++++++ .../formula-plugin/src/formula.service.ts | 1481 +++++++++++++++++ packages/formula-plugin/src/index.ts | 4 + packages/formula-plugin/tsconfig.json | 17 + pnpm-lock.yaml | 32 +- test/cypress/e2e/example46.cy.ts | 201 +++ tsconfig.packages.json | 1 + 41 files changed, 6377 insertions(+), 37 deletions(-) create mode 100644 demos/vanilla/src/examples/example46.html create mode 100644 demos/vanilla/src/examples/example46.scss create mode 100644 demos/vanilla/src/examples/example46.ts create mode 100644 docs/grid-functionalities/formula-functions.md create mode 100644 docs/grid-functionalities/formula-service.md create mode 100644 packages/common/src/interfaces/formulaProvider.interface.ts create mode 100644 packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md create mode 100644 packages/formula-plugin/README.md create mode 100644 packages/formula-plugin/package.json create mode 100644 packages/formula-plugin/src/formula-errors.ts create mode 100644 packages/formula-plugin/src/formula-functions.spec.ts create mode 100644 packages/formula-plugin/src/formula-functions.ts create mode 100644 packages/formula-plugin/src/formula.cellEditor.spec.ts create mode 100644 packages/formula-plugin/src/formula.cellEditor.ts create mode 100644 packages/formula-plugin/src/formula.service.spec.ts create mode 100644 packages/formula-plugin/src/formula.service.ts create mode 100644 packages/formula-plugin/src/index.ts create mode 100644 packages/formula-plugin/tsconfig.json create mode 100644 test/cypress/e2e/example46.cy.ts diff --git a/demos/vanilla/package.json b/demos/vanilla/package.json index 98024a5527..c07d004196 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 c705c59a91..1976666f59 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -43,6 +43,7 @@ import Example42 from './examples/example42.js'; 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 Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -96,6 +97,7 @@ export class AppRouting { { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { 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: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 4482f40cfc..2d6d225cb6 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -71,7 +71,7 @@

Slickgrid-Universal

diff --git a/demos/vanilla/src/examples/example46.html b/demos/vanilla/src/examples/example46.html new file mode 100644 index 0000000000..5c6bcffcc8 --- /dev/null +++ b/demos/vanilla/src/examples/example46.html @@ -0,0 +1,92 @@ +

+ Example 46 - 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/example46.scss b/demos/vanilla/src/examples/example46.scss new file mode 100644 index 0000000000..20b70e61c0 --- /dev/null +++ b/demos/vanilla/src/examples/example46.scss @@ -0,0 +1,41 @@ +.grid46 { + --example46-row-index-bg: #ececec; + --example46-row-index-color: inherit; + --example46-sub-total-color: rgb(33, 80, 115); + --example46-taxes-color: rgb(198, 89, 17); + --example46-total-color: rgb(0, 90, 158); + --slick-text-editor-background: #fff; + + .slick-row:not(.slick-group) > .cell-unselectable { + background: var(--example46-row-index-bg) !important; + color: var(--example46-row-index-color); + font-weight: bold; + } + + .text-sub-total { + font-style: italic; + color: var(--example46-sub-total-color); + } + + .text-taxes { + font-style: italic; + color: var(--example46-taxes-color); + } + + .text-total { + font-weight: bold; + color: var(--example46-total-color); + } +} + +body[data-theme='dark'] .grid46, +.dark-mode .grid46, +.slick-dark-mode .grid46 { + --example46-row-index-bg: #334155; + --example46-row-index-color: #e2e8f0; + --example46-sub-total-color: #93c5fd; + --example46-taxes-color: #fdba74; + --example46-total-color: #60a5fa; + --slick-text-editor-background: #111827; + --slick-cell-selected-editable-color: #333333; +} diff --git a/demos/vanilla/src/examples/example46.ts b/demos/vanilla/src/examples/example46.ts new file mode 100644 index 0000000000..8e68659eec --- /dev/null +++ b/demos/vanilla/src/examples/example46.ts @@ -0,0 +1,672 @@ +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 './example46.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 Example46 { + 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({ + editorParams: { debug: true }, + 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('.grid46') 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: 90, + 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: 460, + gridWidth: 830, + enableCellNavigation: true, + autoEdit: true, + autoCommitEdit: true, + editable: true, + rowHeight: 38, + formatterOptions: { + maxDecimal: 2, + minDecimal: 2, + }, + + // column reorder and visibility will probably fail, let's disable for now + enableColumnReorder: false, + enableColumnPicker: false, + enableGridMenu: false, + enableHeaderMenu: false, + + 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; + const item = args?.item as GroceryItem | undefined; + + if (columnDef?.allowFormula) { + this.formulaService.enableExcelHeaderPrefix(); + const value = item?.[String(columnDef.id) as keyof GroceryItem]; + const formula = typeof value === 'string' ? value : this.formulaService.getFormula(item?.id as number, String(columnDef.id)); + this.formulaService.renderFormulaReferenceHighlights(formula); + 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/docs/TOC.md b/docs/TOC.md index 9600401fb3..6fc21640cd 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -58,6 +58,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 f78d7e1a48..be9de8ed3d 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 0000000000..5891f051ba --- /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 0000000000..ace3998a29 --- /dev/null +++ b/docs/grid-functionalities/formula-service.md @@ -0,0 +1,135 @@ +#### 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) +- [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: +- preferred: selection-model highlights through `setSelectedRanges(...)` +- fallback: CSS highlights through `setCellCssStyles(...)` + +### 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/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md b/frameworks/angular-slickgrid/docs/grid-functionalities/export-to-excel.md index c7f7a1774f..b583e4c093 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 a679335df2..6cf526a954 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 709d0f0e4c..5459585dbe 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 f4e685c65f..8abfa58d55 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/package.json b/packages/common/package.json index 7178f58f60..84f5f5cbde 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -61,7 +61,7 @@ "baseline widely available" ], "dependencies": { - "@excel-builder-vanilla/types": "^5.1.0", + "@excel-builder-vanilla/types": "^5.2.0", "@formkit/tempo": "catalog:", "@slickgrid-universal/binding": "workspace:*", "@slickgrid-universal/event-pub-sub": "workspace:*", @@ -90,4 +90,4 @@ "type": "ko_fi", "url": "https://ko-fi.com/ghiscoding" } -} +} \ No newline at end of file diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index 8fff146e5c..ebc65dabbd 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 4d5e3d95ff..298383f2a8 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 6db85d9143..1dc4cbfbf7 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 0000000000..659d0f5226 --- /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 8db2cbd7a7..1c997df7b9 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 87133657d4..f41556560e 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 dd5e72a377..76a68d464f 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.1) !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.1) !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.1) !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.1) !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.1) !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.1) !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.1) !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.1) !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.1) !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.1) !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-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 071b4303d1..4962c1b8db 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -1296,3 +1296,106 @@ li.hidden { bottom: 0; right: 0; } + +// ---------------------------------------------- +// 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-editor-input { + width: 100%; + min-height: 26px; + padding: var(--slick-text-editor-padding, 2px 4px); + border: var(--slick-text-editor-border, 1px solid #9ca3af); + border-radius: var(--slick-text-editor-border-radius, 3px); + background: var(--slick-text-editor-background, #fff); + color: var(--slick-text-editor-color, inherit); + line-height: 1.3; + white-space: pre-wrap; + word-break: break-word; + outline: none; + + &:focus { + border-color: var(--slick-form-control-focus-border-color, #2563eb); + box-shadow: var(--slick-form-control-focus-box-shadow, 0 0 0 1px #2563eb); + } +} + +.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-ref-cell-color-1, +.formula-ref-cell-color-7 { + background: #{v.$slick-formula-ref-cell-1-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-1-color}; +} +.formula-ref-cell-color-2, +.formula-ref-cell-color-8 { + background: #{v.$slick-formula-ref-cell-2-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-2-color}; +} +.formula-ref-cell-color-3, +.formula-ref-cell-color-9 { + background: #{v.$slick-formula-ref-cell-3-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-3-color}; +} +.formula-ref-cell-color-4, +.formula-ref-cell-color-10 { + background: #{v.$slick-formula-ref-cell-4-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-4-color}; +} +.formula-ref-cell-color-5 { + background: #{v.$slick-formula-ref-cell-5-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-5-color}; +} +.formula-ref-cell-color-6 { + background: #{v.$slick-formula-ref-cell-6-background-color} !important; + box-shadow: inset 0 0 0 2px #{v.$slick-formula-token-6-color}; +} diff --git a/packages/excel-export/package.json b/packages/excel-export/package.json index 02fbc0f40d..9a65d382c9 100644 --- a/packages/excel-export/package.json +++ b/packages/excel-export/package.json @@ -39,7 +39,7 @@ "dependencies": { "@slickgrid-universal/common": "workspace:*", "@slickgrid-universal/utils": "workspace:*", - "excel-builder-vanilla": "^5.1.0" + "excel-builder-vanilla": "^5.2.0" }, "devDependencies": { "@slickgrid-universal/event-pub-sub": "workspace:*" @@ -48,4 +48,4 @@ "type": "ko_fi", "url": "https://ko-fi.com/ghiscoding" } -} +} \ No newline at end of file diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index 7a586a6d70..1c4763610c 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 { getExcelSameInputDataCallback, useCellFormatByFieldType } from './excel // 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({ @@ -2585,6 +2586,102 @@ 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('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('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 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 964ea0636d..a66fb5badc 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 = {}; this._groupTotalExcelFormats = {}; } @@ -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 @@ -368,6 +389,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: '' }); @@ -390,7 +412,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; @@ -749,8 +771,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}`); @@ -759,8 +780,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 @@ -842,16 +862,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++; } } @@ -859,6 +884,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/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md new file mode 100644 index 0000000000..133c9df171 --- /dev/null +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -0,0 +1,149 @@ +# Formula Editor Plugin Progress + +Last updated: 2026-08-06 (XSS fix in token rendering + removed Function() eval fallback + plugin API conventions + spec file corruption fix + grouping limitation note) +Branch context: feat/cell-formula-plugin + +## 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 example46 by switching formula editor background to use --slick-text-editor-background. +- Added local variable overrides in example46: + - 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 example46 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 example46 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 example46 (for example row colors and local editor background/selected editable color vars). + +## Tests Added/Updated +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. + +formula.service.spec.ts covers: +- Warning when formula columns exist but selection prerequisites are missing. +- No warning when mixed selection is configured. + +## 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. +- `example46` 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: Column Reorder/Hide Offset Risk Note (2026-08-06) +- Added a forward-looking risk note for formula stability with column visibility/order changes. +- Most probable issue: if a column is hidden or moved (for example via Column Picker or Grid Menu), formulas that rely on A1-style column letters can become offset/misaligned from intended source columns. +- Current status: not fully validated/fixed yet. +- Plan: revisit in a dedicated pass with explicit handling/tests for column hide/show and column reorder scenarios. +- Modified grid option to include: `{ enableColumnReorder: false, enableColumnPicker: false, enableGridMenu: false, enableHeaderMenu: false }` in example46 + +## Known Constraints / Notes +- 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. + +## Fast Verification +Run: +- vitest run --config test/vitest.config.mts packages/formula-plugin/src/formula.cellEditor.spec.ts +- vitest run --config test/vitest.config.mts packages/formula-plugin/src/formula.service.spec.ts +- vitest run --config test/vitest.config.mts packages/excel-export/src/excelExport.service.spec.ts + +## Suggested Next Items +- Add optional strict mode in FormulaService to throw (instead of warn) when full selection prerequisites are required by product requirements. +- Validate behavior with drag handle interactions from SlickHybridSelectionModel in a higher-level integration test. +- Add docs snippet in user-facing formula plugin docs showing required selection options for range UX. diff --git a/packages/formula-plugin/README.md b/packages/formula-plugin/README.md new file mode 100644 index 0000000000..132cec4c11 --- /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 0000000000..ae2332b48c --- /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/formula-errors.ts b/packages/formula-plugin/src/formula-errors.ts new file mode 100644 index 0000000000..8c3c7595fb --- /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.spec.ts b/packages/formula-plugin/src/formula-functions.spec.ts new file mode 100644 index 0000000000..bc46231adb --- /dev/null +++ b/packages/formula-plugin/src/formula-functions.spec.ts @@ -0,0 +1,115 @@ +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('MAX')?.(6, '2', 8)).toBe(8); + expect(registry.get('AVERAGE')?.(2, 4, '6')).toBe(4); + expect(registry.get('MEDIAN')?.(10, 2, 6, 8)).toBe(7); + 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')?.()).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); + }); + + 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, _regex: 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/formula-functions.ts b/packages/formula-plugin/src/formula-functions.ts new file mode 100644 index 0000000000..475f0db3ee --- /dev/null +++ b/packages/formula-plugin/src/formula-functions.ts @@ -0,0 +1,226 @@ +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; + } + product *= arr[i] ?? 0; + } + 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.cellEditor.spec.ts b/packages/formula-plugin/src/formula.cellEditor.spec.ts new file mode 100644 index 0000000000..afc7104924 --- /dev/null +++ b/packages/formula-plugin/src/formula.cellEditor.spec.ts @@ -0,0 +1,510 @@ +import type { EditorArguments } from '@slickgrid-universal/common'; +import { describe, expect, it, vi } from 'vitest'; +import { FormulaCellEditor } from './formula.cellEditor.js'; + +describe('FormulaCellEditor', () => { + 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.at(-1)?.[0]; + expect(initialSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 1, toCell: 3 }); + expect(setCellCssStylesCalls).toHaveLength(0); + + 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.at(-1)?.[0]; + expect(updatedSelectionRange).toMatchObject({ fromRow: 0, fromCell: 4, toRow: 2, toCell: 4 }); + + 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.at(-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); + + 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); + (editor as any)._editorElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + expect(setCellCssStylesSpy).toHaveBeenCalledTimes(1); + const cssHash = setCellCssStylesSpy.mock.calls[0][1] as Record>; + expect(cssHash[0].b).toBe('formula-ref-cell-color-1'); + expect(cssHash[0].c).toBe('formula-ref-cell-color-1'); + expect(cssHash[1].b).toBe('formula-ref-cell-color-1'); + expect(cssHash[1].c).toBe('formula-ref-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(); + }); +}); diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts new file mode 100644 index 0000000000..445862fd6c --- /dev/null +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -0,0 +1,943 @@ +import { BindingEventService } from '@slickgrid-universal/binding'; +import type { Editor, EditorArguments, EditorValidationResult, SelectionModel } from '@slickgrid-universal/common'; +import { createDomElement, SlickRange } from '@slickgrid-universal/common'; + +const FORMULA_TOKEN_COLOR_COUNT = 10; + +export interface FormulaEditorParams { + debug?: boolean; + formulaFunctionList?: string[]; + onFormulaInputChange?: (formula: string) => void; +} + +function extractExcelReferencesFromFormula(formula: string): string[] { + const refs: string[] = []; + const seen = new Set(); + const regex = /\$?[A-Z]{1,3}\$?\d+\s*:\s*\$?[A-Z]{1,3}\$?\d+|\$?[A-Z]{1,3}\$?\d+/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(formula)) !== null) { + const ref = normalizeFormulaReferenceToken(match[0]); + if (!seen.has(ref)) { + seen.add(ref); + refs.push(ref); + } + } + return refs; +} + +function normalizeFormulaReferenceToken(token: string): string { + return token.replace(/\$/g, '').replace(/\s+/g, '').toUpperCase(); +} + +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 _referenceSelectionStyleKey = 'formula-editor-grid-ref-selection'; + protected _suppressNextGridClick = false; + protected _suppressGridClickResetTimer?: ReturnType; + protected _suppressInitialTabBlur = false; + protected _tabNavigateTimer?: ReturnType; + protected _isSyncingReferenceFromCaret = false; + protected _isSelectionModelHighlightActive = false; + protected _bindEventService: BindingEventService = new BindingEventService(); + + protected readonly _referenceTokenRegex: RegExp = /\$?[A-Z]{1,3}\$?\d+\s*:\s*\$?[A-Z]{1,3}\$?\d+|\$?[A-Z]{1,3}\$?\d+/g; + + 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 { + 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._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] ?? ''; + this._originalValue = String(value); + this._editorElm.textContent = this._originalValue; + this.renderTokens(); + } + + serializeValue(): string { + return this.getPlainTextValue(); + } + + applyValue(item: any, state: any): void { + const field = this.args.column.field as string; + item[field] = state; + } + + isValueChanged(): boolean { + return this.getPlainTextValue() !== this._originalValue; + } + + validate(): EditorValidationResult { + return { valid: true, msg: '' }; + } + + protected handleInput(): void { + this._isValueTouched = true; + this.clearReferenceSelectionHighlight(); + 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); + } + + protected handleFocusIn(): void { + 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') { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + + if (this._autocompleteItems.length > 0) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + this._autocompleteSelectedIdx = (this._autocompleteSelectedIdx + 1) % this._autocompleteItems.length; + this.renderAutocompleteItems(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + this._autocompleteSelectedIdx = + (this._autocompleteSelectedIdx - 1 + this._autocompleteItems.length) % this._autocompleteItems.length; + this.renderAutocompleteItems(); + return; + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + this.selectAutocompleteItem(this._autocompleteItems[this._autocompleteSelectedIdx]); + return; + } + + if (event.key === 'Escape') { + this.hideAutocomplete(); + } + } + + if ( + !this.args.grid.getOptions().editorNavigateOnArrows && + (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'Home' || event.key === 'End') + ) { + event.stopImmediatePropagation(); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + 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; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + 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 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._editorElm.textContent || '').replace(/\u00a0/g, ' '); + } + + 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 = new RegExp(this._referenceTokenRegex.source, 'g'); + 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 = this.parseExcelReferenceCell(startToken); + const endCell = this.parseExcelReferenceCell(endToken); + if (!startCell || !endCell) { + return undefined; + } + + return { startCell, endCell }; + } + + protected parseExcelReferenceCell(token: string): { row: number; cell: number } | undefined { + const match = token.match(/^([A-Z]{1,3})(\d+)$/); + if (!match) { + return undefined; + } + + const columnName = match[1]; + const rowIndex = Number.parseInt(match[2], 10) - 1; + if (!Number.isFinite(rowIndex) || rowIndex < 0) { + return undefined; + } + + let columnIndex = 0; + for (let i = 0; i < columnName.length; i++) { + columnIndex = columnIndex * 26 + (columnName.charCodeAt(i) - 64); + } + + return { row: rowIndex, cell: columnIndex - 1 }; + } + + 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._editorElm.textContent = nextText; + 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+$/, ''); + if (!textBeforeCaretTrimEnd.length) { + return false; + } + + const lastChar = textBeforeCaretTrimEnd[textBeforeCaretTrimEnd.length - 1]; + return /[=,(+\-*/^&:]/.test(lastChar); + } + + protected getSingleReferenceTokenRangeOrUndefined(): { start: number; end: number } | undefined { + const text = this.getPlainTextValue(); + const regex = new RegExp(this._referenceTokenRegex.source, 'g'); + 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); + + const startRef = `${this.getExcelColumnNameByIndex(startColIdx + 1)}${startRowIdx + 1}`; + const endRef = `${this.getExcelColumnNameByIndex(endColIdx + 1)}${endRowIdx + 1}`; + return startRef === endRef ? startRef : `${startRef}:${endRef}`; + } + + protected 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; + } + + protected renderGridSelectionHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): void { + if (this.renderSelectionModelHighlight(startCell, endCell)) { + this.args.grid.removeCellCssStyles?.(this._referenceSelectionStyleKey); + return; + } + + 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 columns = this.args.grid.getColumns?.() || []; + const hash: Record> = {}; + + for (let row = minRow; row <= maxRow; row++) { + const rowStyles: Record = {}; + for (let cell = minCell; cell <= maxCell; cell++) { + const column = columns[cell]; + if (column?.id !== undefined && column?.id !== null) { + rowStyles[column.id] = 'formula-ref-cell-color-1'; + } + } + if (Object.keys(rowStyles).length > 0) { + hash[row] = rowStyles; + } + } + + if (Object.keys(hash).length > 0) { + this.args.grid.setCellCssStyles?.(this._referenceSelectionStyleKey, hash as any); + } + } + + protected clearReferenceSelectionHighlight(): void { + const selectionModel = this.getGridSelectionModel(); + if (selectionModel && this._isSelectionModelHighlightActive) { + selectionModel.setSelectedRanges([], 'FormulaCellEditor.clearReferenceSelectionHighlight', ''); + this._isSelectionModelHighlightActive = false; + } + + this.args.grid.removeCellCssStyles?.(this._referenceSelectionStyleKey); + } + + protected renderSelectionModelHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): boolean { + const selectionModel = this.getGridSelectionModel(); + if (!selectionModel) { + return false; + } + + 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 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(); + const refs = extractExcelReferencesFromFormula(raw); + const refColorIndex = new Map(); + refs.forEach((ref, idx) => refColorIndex.set(ref, idx % FORMULA_TOKEN_COLOR_COUNT)); + + const referenceTokenRegex = new RegExp(this._referenceTokenRegex.source, 'g'); + // 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 = refColorIndex.get(normalizedRef) ?? 0; + const span = createDomElement('span', { className: `formula-token formula-token-color-${colorIdx + 1}` }); + 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(/(?:^|[=(,]\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; + } + + const text = this.getPlainTextValue(); + const caretOffset = this.getCaretOffset(); + const textBeforeCaret = text.slice(0, caretOffset); + const textAfterCaret = text.slice(caretOffset); + const match = textBeforeCaret.match(/(?:^|[=(,]\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._editorElm.textContent = nextText; + this.renderTokens(); + this.restoreCaretOffset(nextCaret); + this._isValueTouched = true; + this.hideAutocomplete(); + this.publishFormulaInput(); + } +} diff --git a/packages/formula-plugin/src/formula.service.spec.ts b/packages/formula-plugin/src/formula.service.spec.ts new file mode 100644 index 0000000000..e58f77462f --- /dev/null +++ b/packages/formula-plugin/src/formula.service.spec.ts @@ -0,0 +1,953 @@ +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 { FormulaCellEditor } from './formula.cellEditor.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 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 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 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 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 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-ref-cell-color-1'); + expect(cssHash[1].c).toBe('formula-ref-cell-color-1'); + expect(cssHash[2].c).toBe('formula-ref-cell-color-1'); + }); + + 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 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 wrap onFormulaInputChange to refresh highlights and invoke user callback', () => { + 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).toHaveBeenCalledWith('=A1'); + expect(userCallback).toHaveBeenCalledWith('=A1'); + }); +}); diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts new file mode 100644 index 0000000000..9b08ae3c51 --- /dev/null +++ b/packages/formula-plugin/src/formula.service.ts @@ -0,0 +1,1481 @@ +import type { + Column, + ColumnEditor, + ContainerService, + ExternalResource, + Formatter, + FormulaExcelCustomFunctionExport, + FormulaExcelDefinedNameExport, + FormulaExcelExportContext, + FormulaProvider, + SlickDataView, + SlickGrid, +} from '@slickgrid-universal/common'; +import { createDomElement, Formatters } from '@slickgrid-universal/common'; +import { FORMULA_ERROR, isFormulaErrorCode, type FormulaErrorCode } from './formula-errors.js'; +import { createFormulaFunctionRegistry, type FormulaCallback } from './formula-functions.js'; +import { FormulaCellEditor, type FormulaEditorParams } from './formula.cellEditor.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; +} + +/** + * 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 static readonly FORMULA_TOKEN_COLOR_COUNT = 10; + + protected _grid!: SlickGrid; + protected _dataView!: SlickDataView; + protected _customFunctions: Map = new Map(); + protected _formulaStore: Map = new Map(); + protected _originalColumnNamesById: Map = new Map(); + protected _formulaRefStyleKeys: string[] = []; + protected _isExcelHeaderPrefixEnabled = false; + protected _hasWarnedSelectionPrerequisite = false; + protected _hasAutoAssignedFormulaEditor = false; + protected _originalColumnDefsById: Map> = new Map(); + protected _evaluationMemo: Map = new Map(); + protected _isEvaluationMemoFlushScheduled = false; + + 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._options.customFunctions) { + this.registerCustomFunctions(this._options.customFunctions); + } + + if (this._options.autoSyncFormulasFromDataset !== false) { + this.syncFormulasFromDataset(); + } + + this.autoAssignFormulaEditorToColumns(); + this.validateSelectionModelPrerequisites(); + } + + dispose(): void { + this.clearFormulaReferenceHighlights(); + this.disableExcelHeaderPrefix(); + this.restoreAutoAssignedFormulaEditorColumns(); + this._formulaStore.clear(); + this.resetEvaluationMemo(); + this._customFunctions.clear(); + this._originalColumnNamesById.clear(); + } + + clearFormulaReferenceHighlights(): void { + if (!this._grid?.removeCellCssStyles) { + return; + } + + for (const styleKey of this._formulaRefStyleKeys) { + this._grid.removeCellCssStyles(styleKey); + } + this._formulaRefStyleKeys = []; + } + + 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 = this.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 referenceGroups = this.extractExcelReferenceGroups( + `=${this.replaceRefFunctionsWithA1Refs( + normalizedFormula, + ((this._grid?.getColumns?.() as Column[] | undefined) || []).map((col) => String(col.id)), + this.getDataItems().map((item) => String(item?.[this.getDatasetIdPropertyName()] ?? '')), + 1 + )}` + ); + const columns = this._grid.getColumns() as Column[]; + const datasetLength = this.getDatasetLength(); + + referenceGroups.forEach((refs, idx) => { + const cssColorClass = `formula-ref-cell-color-${(idx % FormulaService.FORMULA_TOKEN_COLOR_COUNT) + 1}`; + const styleKey = `formula-ref-highlight-${idx}`; + const hash: Record> = {}; + + for (const ref of refs) { + const colIdx = this.getExcelColumnIndexByName(ref.col); + const rowIdx = ref.row - 1; + const column = columns[colIdx]; + + if (!column || rowIdx < 0 || rowIdx >= datasetLength) { + continue; + } + + if (!hash[rowIdx]) { + hash[rowIdx] = {}; + } + hash[rowIdx][column.id as number | string] = cssColorClass; + } + + if (Object.keys(hash).length > 0) { + this._grid.setCellCssStyles(styleKey, hash as any); + this._formulaRefStyleKeys.push(styleKey); + } + }); + } + + extractExcelReferences(formula: string): Array<{ col: string; row: number }> { + return this.extractExcelReferenceGroups(formula).flat(); + } + + protected extractExcelReferenceGroups(formula: string): Array> { + const groups: Array> = []; + const rangeRegex = /\$?([A-Z]{1,3})\$?(\d+)\s*:\s*\$?([A-Z]{1,3})\$?(\d+)/g; + let rangeMatch: RegExpExecArray | null; + + while ((rangeMatch = rangeRegex.exec(formula)) !== null) { + const startColName = rangeMatch[1].toUpperCase(); + const startRowNumber = Number(rangeMatch[2]); + const endColName = rangeMatch[3].toUpperCase(); + const endRowNumber = Number(rangeMatch[4]); + + const startColIdx = this.getExcelColumnIndexByName(startColName); + const endColIdx = this.getExcelColumnIndexByName(endColName); + if (startColIdx < 0 || endColIdx < 0 || Number.isNaN(startRowNumber) || Number.isNaN(endRowNumber)) { + continue; + } + + 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 groupRefs: Array<{ col: string; row: number }> = []; + + for (let rowNumber = minRowNumber; rowNumber <= maxRowNumber; rowNumber++) { + for (let colIdx = minColIdx; colIdx <= maxColIdx; colIdx++) { + groupRefs.push({ col: this.getExcelColumnNameByIndex(colIdx + 1), row: rowNumber }); + } + } + + if (groupRefs.length > 0) { + groups.push(groupRefs); + } + } + + const formulaWithoutRanges = formula.replace(rangeRegex, ' '); + const singleRefRegex = /\$?([A-Z]{1,3})\$?(\d+)/g; + const seenSingleRefs = new Set(); + let singleMatch: RegExpExecArray | null; + + while ((singleMatch = singleRefRegex.exec(formulaWithoutRanges)) !== null) { + const col = singleMatch[1].toUpperCase(); + const row = Number(singleMatch[2]); + const key = `${col}${row}`; + if (Number.isNaN(row) || seenSingleRefs.has(key)) { + continue; + } + seenSingleRefs.add(key); + groups.push([{ col, row }]); + } + + return groups; + } + + clearFormulas(): void { + this._formulaStore.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; + + const formula = + 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 wasDeleted = this._formulaStore.delete(this.buildStoreKey(rowId, columnId)); + if (wasDeleted) { + 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.resetEvaluationMemo(); + return; + } + + this._formulaStore.set(key, formula); + this.resetEvaluationMemo(); + } + + 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 excelRowDelta = Math.max(0, context.excelRowOffset - 1); + const allGridColumnIds = (this._grid?.getColumns?.() as Column[] | undefined)?.map((col) => String(col.id)) ?? []; + const exportedColumnIds = context.columnIds.map((colId) => String(colId)); + const shiftedFormula = + excelRowDelta > 0 + ? normalizedFormula.replace(/(\$?[A-Z]{1,3}\$?)(\d+)/g, (_match, columnRef: string, rowNumber: string) => { + const remappedColumnRef = this.remapDirectExcelColumnRef(columnRef, allGridColumnIds, exportedColumnIds); + const row = Number(rowNumber); + if (!Number.isFinite(row)) { + return `${remappedColumnRef}${rowNumber}`; + } + return `${remappedColumnRef}${row + excelRowDelta}`; + }) + : normalizedFormula; + const normalizedColumnIds = exportedColumnIds; + const normalizedRowIds = context.rowIds.map((rowId) => String(rowId)); + + const withNumericRowRefs = this.replaceRefFunctionsWithA1Refs( + shiftedFormula, + normalizedColumnIds, + normalizedRowIds, + context.excelRowOffset + ); + + return this.normalizeFormulaSyntax(withNumericRowRefs); + } + + /** Remap direct A1 column letters from grid coordinates to exported sheet coordinates. */ + protected remapDirectExcelColumnRef(columnRef: string, gridColumnIds: string[], exportedColumnIds: string[]): string { + const hasLeadingDollar = columnRef.startsWith('$'); + const hasTrailingDollar = columnRef.endsWith('$'); + const rawColumnName = columnRef.replace(/\$/g, '').toUpperCase(); + const sourceColumnIndex = this.getExcelColumnIndexByName(rawColumnName); + + if (sourceColumnIndex < 0) { + return columnRef; + } + + const sourceColumnId = gridColumnIds[sourceColumnIndex]; + if (!sourceColumnId) { + return columnRef; + } + + const targetColumnIndex = exportedColumnIds.indexOf(sourceColumnId); + if (targetColumnIndex < 0) { + return columnRef; + } + + const targetColumnName = this.getExcelColumnNameByIndex(targetColumnIndex + 1); + return `${hasLeadingDollar ? '$' : ''}${targetColumnName}${hasTrailingDollar ? '$' : ''}`; + } + + 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 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; + } + + protected getExcelColumnIndexByName(colName: string): number { + let colIdx = 0; + for (let i = 0; i < colName.length; i++) { + colIdx = colIdx * 26 + (colName.charCodeAt(i) - 64); + } + return colIdx - 1; + } + + 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, '-'); + } + + /** 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 = this.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 = this.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 = this.getExcelColumnIndexByName(startColName.toUpperCase()); + const endColIdx = this.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 rangeValues: unknown[] = []; + + for (let rowNumber = minRowNumber; rowNumber <= maxRowNumber; rowNumber++) { + for (let colIdx = minColIdx; colIdx <= maxColIdx; colIdx++) { + const colName = this.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 = this.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) => { + this.renderFormulaReferenceHighlights(formula); + userOnFormulaInputChange?.(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 0000000000..d8df71c566 --- /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 0000000000..5f10effa56 --- /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 a87a3ccdf9..6f7efc0ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -630,6 +630,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 @@ -1323,8 +1326,8 @@ importers: packages/common: dependencies: '@excel-builder-vanilla/types': - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^5.2.0 + version: 5.2.0 '@formkit/tempo': specifier: 'catalog:' version: 1.1.0 @@ -1442,13 +1445,22 @@ importers: specifier: workspace:* version: link:../utils excel-builder-vanilla: - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^5.2.0 + version: 5.2.0 devDependencies: '@slickgrid-universal/event-pub-sub': 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': @@ -2356,8 +2368,8 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@excel-builder-vanilla/types@5.1.0': - resolution: {integrity: sha512-R2FPgeuArFQaeGrqVTvXlRs6OsTrUGf7GtN4eQT4sf2AD9FhjzTYfkisVWzA3tvr5+snYmleQKBK3QixlgNrgQ==} + '@excel-builder-vanilla/types@5.2.0': + resolution: {integrity: sha512-/gxZoYJN1f1Kom5zkcx4s7HklCeSK3v+W7XmW65OVIcK1J4sowu2NNjkTc/OgGWH/82y2p+TZTPMAFpJ3T/zCw==} '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} @@ -5569,8 +5581,8 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - excel-builder-vanilla@5.1.0: - resolution: {integrity: sha512-Dy8EBJGYcq0yGCof5vw+NyhFfgvJkRq0wVcStcpbH7aO3gSVwUX9Vt0d7DhBP3XxPhBy/GrJQ/ldRNRb3aL0jw==} + excel-builder-vanilla@5.2.0: + resolution: {integrity: sha512-kWvhUe4HdVmj7O6AYfjTURkNf/1ZGZsrgQ8ESCB3nzVE+BvpedZbJaLURbGUP2BBIaO+6N8lyMYoRhIHpavMew==} execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} @@ -9020,7 +9032,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@excel-builder-vanilla/types@5.1.0': {} + '@excel-builder-vanilla/types@5.2.0': {} '@exodus/bytes@1.15.1': {} @@ -12823,7 +12835,7 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - excel-builder-vanilla@5.1.0: + excel-builder-vanilla@5.2.0: dependencies: fflate: 0.8.3 diff --git a/test/cypress/e2e/example46.cy.ts b/test/cypress/e2e/example46.cy.ts new file mode 100644 index 0000000000..b872658a3c --- /dev/null +++ b/test/cypress/e2e/example46.cy.ts @@ -0,0 +1,201 @@ +describe('Example 46 - Formula Service (MVP)', () => { + const GRID_ROW_HEIGHT = 38; + const fullTitles = ['#', 'Name', 'Price', 'Quantity', 'Sub-Total', 'Taxable', 'Taxes', 'Total', 'Custom Sum']; + + const rowSelector = (rowIdx: number) => `.grid46 [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')}/example46`); + cy.get('h3').should('contain', 'Example 46 - Formula Service (MVP)'); + }); + + it('should have exact column titles on grid', () => { + cy.get('.grid46') + .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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + 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)).click(); + cy.get(`${cell(2, 2)} input`) + .clear() + .type('4.23{enter}'); + cy.get(cell(2, 3)).click(); + 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('.grid46 .slick-group').should('have.length.at.least', 2); + cy.get('.grid46 .slick-group').first().should('contain', 'Taxable:'); + cy.get('.grid46 .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'); + }); +}); diff --git a/tsconfig.packages.json b/tsconfig.packages.json index 0f84a154cc..d38ffac672 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" }, From e366fb122e2151c9d67511a7d81e20cc334d7e51 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 7 Aug 2026 10:21:25 -0400 Subject: [PATCH 02/57] chore(deps): update excel-builder-vanilla with latest fixes --- packages/common/package.json | 2 +- packages/excel-export/package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/common/package.json b/packages/common/package.json index 84f5f5cbde..a9e6c6225a 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -61,7 +61,7 @@ "baseline widely available" ], "dependencies": { - "@excel-builder-vanilla/types": "^5.2.0", + "@excel-builder-vanilla/types": "^5.2.1", "@formkit/tempo": "catalog:", "@slickgrid-universal/binding": "workspace:*", "@slickgrid-universal/event-pub-sub": "workspace:*", diff --git a/packages/excel-export/package.json b/packages/excel-export/package.json index 9a65d382c9..1b984b1669 100644 --- a/packages/excel-export/package.json +++ b/packages/excel-export/package.json @@ -39,7 +39,7 @@ "dependencies": { "@slickgrid-universal/common": "workspace:*", "@slickgrid-universal/utils": "workspace:*", - "excel-builder-vanilla": "^5.2.0" + "excel-builder-vanilla": "^5.2.2" }, "devDependencies": { "@slickgrid-universal/event-pub-sub": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f7efc0ea4..949f6744b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1326,8 +1326,8 @@ importers: packages/common: dependencies: '@excel-builder-vanilla/types': - specifier: ^5.2.0 - version: 5.2.0 + specifier: ^5.2.1 + version: 5.2.1 '@formkit/tempo': specifier: 'catalog:' version: 1.1.0 @@ -1445,8 +1445,8 @@ importers: specifier: workspace:* version: link:../utils excel-builder-vanilla: - specifier: ^5.2.0 - version: 5.2.0 + specifier: ^5.2.2 + version: 5.2.2 devDependencies: '@slickgrid-universal/event-pub-sub': specifier: workspace:* @@ -2368,8 +2368,8 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@excel-builder-vanilla/types@5.2.0': - resolution: {integrity: sha512-/gxZoYJN1f1Kom5zkcx4s7HklCeSK3v+W7XmW65OVIcK1J4sowu2NNjkTc/OgGWH/82y2p+TZTPMAFpJ3T/zCw==} + '@excel-builder-vanilla/types@5.2.1': + resolution: {integrity: sha512-JHwykCztVTsJiR0+o/5Du7cei4L8gvJIbR9DwdpWJDFttCGh8hCyfCHB6dEyJrN6LZQwZvcwhHEM8oJ+4PDtmQ==} '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} @@ -5581,8 +5581,8 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - excel-builder-vanilla@5.2.0: - resolution: {integrity: sha512-kWvhUe4HdVmj7O6AYfjTURkNf/1ZGZsrgQ8ESCB3nzVE+BvpedZbJaLURbGUP2BBIaO+6N8lyMYoRhIHpavMew==} + excel-builder-vanilla@5.2.2: + resolution: {integrity: sha512-uDTc8MyINnc+IxMMPNg4xWAMqboFnckbnCvi/819VgwZzGh/ZqHzA5NHxxq80B8EAlJ7gAzeOa/gKB+MDfERJw==} execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} @@ -9032,7 +9032,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@excel-builder-vanilla/types@5.2.0': {} + '@excel-builder-vanilla/types@5.2.1': {} '@exodus/bytes@1.15.1': {} @@ -12835,7 +12835,7 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - excel-builder-vanilla@5.2.0: + excel-builder-vanilla@5.2.2: dependencies: fflate: 0.8.3 From a9b454bbd0bf193cc8bab0713ee50b0095df8044 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Fri, 7 Aug 2026 21:07:47 +0100 Subject: [PATCH 03/57] fix(deps): update all non-major dependencies to ^5.2.3 (#2713) --- packages/common/package.json | 2 +- packages/excel-export/package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/common/package.json b/packages/common/package.json index e578ca5abf..08786d51c7 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -61,7 +61,7 @@ "baseline widely available" ], "dependencies": { - "@excel-builder-vanilla/types": "^5.1.0", + "@excel-builder-vanilla/types": "^5.2.3", "@formkit/tempo": "catalog:", "@slickgrid-universal/binding": "workspace:*", "@slickgrid-universal/event-pub-sub": "workspace:*", diff --git a/packages/excel-export/package.json b/packages/excel-export/package.json index 02fbc0f40d..eb03bd3ea2 100644 --- a/packages/excel-export/package.json +++ b/packages/excel-export/package.json @@ -39,7 +39,7 @@ "dependencies": { "@slickgrid-universal/common": "workspace:*", "@slickgrid-universal/utils": "workspace:*", - "excel-builder-vanilla": "^5.1.0" + "excel-builder-vanilla": "^5.2.3" }, "devDependencies": { "@slickgrid-universal/event-pub-sub": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce15587711..2ca7e0d9e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1317,8 +1317,8 @@ importers: packages/common: dependencies: '@excel-builder-vanilla/types': - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^5.2.3 + version: 5.2.3 '@formkit/tempo': specifier: 'catalog:' version: 1.1.0 @@ -1436,8 +1436,8 @@ importers: specifier: workspace:* version: link:../utils excel-builder-vanilla: - specifier: ^5.1.0 - version: 5.1.0 + specifier: ^5.2.3 + version: 5.2.3 devDependencies: '@slickgrid-universal/event-pub-sub': specifier: workspace:* @@ -2308,8 +2308,8 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@excel-builder-vanilla/types@5.1.0': - resolution: {integrity: sha512-R2FPgeuArFQaeGrqVTvXlRs6OsTrUGf7GtN4eQT4sf2AD9FhjzTYfkisVWzA3tvr5+snYmleQKBK3QixlgNrgQ==} + '@excel-builder-vanilla/types@5.2.3': + resolution: {integrity: sha512-MY4u5e+d+80/z9LS4tZafayVs2XccwF/QNIelb4VGgjekhEsEv+n3fh916JgRuhQhYm2C3YnR1Oy+nsP7ozMkw==} '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} @@ -5543,8 +5543,8 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} - excel-builder-vanilla@5.1.0: - resolution: {integrity: sha512-Dy8EBJGYcq0yGCof5vw+NyhFfgvJkRq0wVcStcpbH7aO3gSVwUX9Vt0d7DhBP3XxPhBy/GrJQ/ldRNRb3aL0jw==} + excel-builder-vanilla@5.2.3: + resolution: {integrity: sha512-bz40csisehNfD8l7Zntr8QWhRnvmwp/9Qc0kW98rJiRZwOhEwFmuVbLzzXnJ/i2UQD8UEiHH8YVWojmcFENshA==} execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} @@ -8932,7 +8932,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@excel-builder-vanilla/types@5.1.0': {} + '@excel-builder-vanilla/types@5.2.3': {} '@exodus/bytes@1.15.1': {} @@ -12773,7 +12773,7 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - excel-builder-vanilla@5.1.0: + excel-builder-vanilla@5.2.3: dependencies: fflate: 0.8.3 From 3a973723d985c01bfc3ed162cb3a184802d84541 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 7 Aug 2026 16:29:54 -0400 Subject: [PATCH 04/57] docs: add more generic comment about min age exclusions --- .github/renovate.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index be4f49a32b..57e274f3ca 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -45,7 +45,7 @@ allowedVersions: '< 22.0.0', }, { - // Exclude @lerna-lite/* from minimumReleaseAge since I'm the maintainer of Lerna-Lite + // Exclude all the packages that I personally maintain from minimumReleaseAge matchPackagePatterns: ['^@lerna-lite/', '^@excel-builder-vanilla/types$', '^excel-builder-vanilla$', '^multiple-select-vanilla$'], minimumReleaseAge: '0', }, From 75842a37b5d498b831cce3b097ed38dec8aa370c Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Sat, 8 Aug 2026 17:29:42 +0100 Subject: [PATCH 05/57] chore(deps): update dependency eslint-plugin-cypress to v7 (#2715) --- package.json | 2 +- pnpm-lock.yaml | 20 +++++++------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 6bd44270fc..7b7d1a276b 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "cross-env": "catalog:", "cypress": "catalog:", "cypress-real-events": "catalog:", - "eslint-plugin-cypress": "^6.4.4", + "eslint-plugin-cypress": "^7.0.0", "eslint-plugin-local-import-ext": "0.2.0", "globals": "catalog:", "jsdom": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca7e0d9e0..08bb9b9001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,8 +199,8 @@ importers: specifier: 'catalog:' version: 1.15.0(cypress@15.20.0) eslint-plugin-cypress: - specifier: ^6.4.4 - version: 6.4.4(eslint@10.8.0(supports-color@8.1.1)) + specifier: ^7.0.0 + version: 7.0.0(eslint@10.8.0(supports-color@8.1.1)) eslint-plugin-local-import-ext: specifier: 0.2.0 version: 0.2.0 @@ -5459,11 +5459,11 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-plugin-cypress@6.4.4: - resolution: {integrity: sha512-ez6i14V0xYrq0DMKQmKPeWpi9XP8RI8GK6YT657yOnVlg72PUyR2psgVmC3jXjpTAXVdyB5VDoQPN/ytaw9+Pg==} + eslint-plugin-cypress@7.0.0: + resolution: {integrity: sha512-gpvL3S1w45c0DORI/P5v3k1UUW2NKje3JiFz+oTHGF6dZa6ZPBokItJS7jP0BXGPEgI3RpHyAMNMAJ9K5P0bow==} peerDependencies: '@typescript-eslint/parser': '>=8' - eslint: '>=9' + eslint: '>=10' peerDependenciesMeta: '@typescript-eslint/parser': optional: true @@ -5764,10 +5764,6 @@ packages: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} engines: {node: '>=10'} - globals@17.8.0: - resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} - engines: {node: '>=18'} - globals@17.9.0: resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} engines: {node: '>=18'} @@ -12682,10 +12678,10 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-plugin-cypress@6.4.4(eslint@10.8.0(supports-color@8.1.1)): + eslint-plugin-cypress@7.0.0(eslint@10.8.0(supports-color@8.1.1)): dependencies: eslint: 10.8.0(supports-color@8.1.1) - globals: 17.8.0 + globals: 17.9.0 eslint-plugin-local-import-ext@0.2.0: {} @@ -13051,8 +13047,6 @@ snapshots: dependencies: ini: 2.0.0 - globals@17.8.0: {} - globals@17.9.0: {} gonzales-pe@4.3.0: From 1edcd29b38493d2006801731565df6df534641b0 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 8 Aug 2026 16:47:30 -0400 Subject: [PATCH 06/57] chore: fix few styling issues & rename to Example47 --- demos/vanilla/src/app-routing.ts | 4 +- demos/vanilla/src/app.html | 17 +++---- demos/vanilla/src/examples/example46.scss | 41 ----------------- .../{example46.html => example47.html} | 12 ++--- demos/vanilla/src/examples/example47.scss | 43 +++++++++++++++++ .../examples/{example46.ts => example47.ts} | 8 ++-- packages/common/src/styles/_variables.scss | 20 ++++---- packages/common/src/styles/slick-plugins.scss | 46 +++++++++---------- .../src/formula.cellEditor.spec.ts | 8 ++-- .../formula-plugin/src/formula.cellEditor.ts | 2 +- .../src/formula.service.spec.ts | 6 +-- .../formula-plugin/src/formula.service.ts | 2 +- 12 files changed, 104 insertions(+), 105 deletions(-) delete mode 100644 demos/vanilla/src/examples/example46.scss rename demos/vanilla/src/examples/{example46.html => example47.html} (93%) create mode 100644 demos/vanilla/src/examples/example47.scss rename demos/vanilla/src/examples/{example46.ts => example47.ts} (99%) diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index 1976666f59..78bfcf4b02 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -43,7 +43,7 @@ import Example42 from './examples/example42.js'; 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'; @@ -97,7 +97,7 @@ export class AppRouting { { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { 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 2d6d225cb6..b5ffc6012a 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -29,8 +29,7 @@

Slickgrid-Universal

Documentation SlickGrid Icons diff --git a/demos/vanilla/src/examples/example46.scss b/demos/vanilla/src/examples/example46.scss deleted file mode 100644 index 20b70e61c0..0000000000 --- a/demos/vanilla/src/examples/example46.scss +++ /dev/null @@ -1,41 +0,0 @@ -.grid46 { - --example46-row-index-bg: #ececec; - --example46-row-index-color: inherit; - --example46-sub-total-color: rgb(33, 80, 115); - --example46-taxes-color: rgb(198, 89, 17); - --example46-total-color: rgb(0, 90, 158); - --slick-text-editor-background: #fff; - - .slick-row:not(.slick-group) > .cell-unselectable { - background: var(--example46-row-index-bg) !important; - color: var(--example46-row-index-color); - font-weight: bold; - } - - .text-sub-total { - font-style: italic; - color: var(--example46-sub-total-color); - } - - .text-taxes { - font-style: italic; - color: var(--example46-taxes-color); - } - - .text-total { - font-weight: bold; - color: var(--example46-total-color); - } -} - -body[data-theme='dark'] .grid46, -.dark-mode .grid46, -.slick-dark-mode .grid46 { - --example46-row-index-bg: #334155; - --example46-row-index-color: #e2e8f0; - --example46-sub-total-color: #93c5fd; - --example46-taxes-color: #fdba74; - --example46-total-color: #60a5fa; - --slick-text-editor-background: #111827; - --slick-cell-selected-editable-color: #333333; -} diff --git a/demos/vanilla/src/examples/example46.html b/demos/vanilla/src/examples/example47.html similarity index 93% rename from demos/vanilla/src/examples/example46.html rename to demos/vanilla/src/examples/example47.html index 5c6bcffcc8..3d45ce4e4b 100644 --- a/demos/vanilla/src/examples/example46.html +++ b/demos/vanilla/src/examples/example47.html @@ -1,5 +1,5 @@

- Example 46 - Formula Service (MVP) + Example 47 - Formula Service (MVP) +
diff --git a/demos/vanilla/src/examples/example37.ts b/demos/vanilla/src/examples/example37.ts index 53f8352e98..9774935074 100644 --- a/demos/vanilla/src/examples/example37.ts +++ b/demos/vanilla/src/examples/example37.ts @@ -4,7 +4,7 @@ import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanil import { ExampleGridOptions } from './example-grid-options.js'; import './example37.scss'; -const NB_ITEMS = 1000; +const NB_ITEMS = 400; export default class Example37 { protected _eventHandler: SlickEventHandler; @@ -17,6 +17,7 @@ export default class Example37 { dataset2!: any[]; sgb1!: SlickVanillaGridBundle; sgb2!: SlickVanillaGridBundle; + excelExportService = new ExcelExportService(); gridFocus() { this.sgb1.slickGrid?.focus(); @@ -149,7 +150,7 @@ export default class Example37 { excelExportOptions: { exportWithFormatter: true, }, - externalResources: [new ExcelExportService()], + externalResources: [this.excelExportService], // enable new hybrid selection model (rows & cells) enableSelection: true, @@ -223,4 +224,8 @@ export default class Example37 { } return data; } + + exportGrid1ToExcel() { + this.excelExportService.exportToExcel({ filename: 'export', format: 'xlsx' }); + } } diff --git a/test/cypress.config.ts b/test/cypress.config.ts index 493e3d6647..d2688c4826 100644 --- a/test/cypress.config.ts +++ b/test/cypress.config.ts @@ -1,5 +1,212 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; import { defineConfig } from 'cypress'; +interface ReadLatestXlsxTaskOptions { + downloadsFolder: string; + minModifiedTime?: number; + timeoutMs?: number; + maxDataRows?: number; +} + +interface ParsedXlsxExport { + fileName: string; + sheetName: string; + rowCount: number; + header: string[]; + firstDataRow: string[]; + dataRows: string[][]; +} + +function isExcelExportFile(fileName: string): boolean { + const lowerFileName = fileName.toLowerCase(); + return lowerFileName.endsWith('.xlsx') || lowerFileName.endsWith('.xlxs'); +} + +function decodeXmlEntities(input: string): string { + return input.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll(''', "'"); +} + +function extractZipEntries(buffer: Buffer): Map { + const entries = new Map(); + const eocdSignature = 0x06054b50; + const centralSignature = 0x02014b50; + const localSignature = 0x04034b50; + + let eocdOffset = -1; + for (let i = buffer.length - 22; i >= 0; i--) { + if (buffer.readUInt32LE(i) === eocdSignature) { + eocdOffset = i; + break; + } + } + + if (eocdOffset < 0) { + throw new Error('Invalid XLSX file: end of central directory not found'); + } + + const totalEntries = buffer.readUInt16LE(eocdOffset + 10); + const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16); + let cursor = centralDirectoryOffset; + + for (let i = 0; i < totalEntries; i++) { + if (buffer.readUInt32LE(cursor) !== centralSignature) { + throw new Error('Invalid XLSX file: central directory entry signature mismatch'); + } + + const compressionMethod = buffer.readUInt16LE(cursor + 10); + const compressedSize = buffer.readUInt32LE(cursor + 20); + const fileNameLength = buffer.readUInt16LE(cursor + 28); + const extraLength = buffer.readUInt16LE(cursor + 30); + const commentLength = buffer.readUInt16LE(cursor + 32); + const localHeaderOffset = buffer.readUInt32LE(cursor + 42); + const fileName = buffer.toString('utf8', cursor + 46, cursor + 46 + fileNameLength); + + if (buffer.readUInt32LE(localHeaderOffset) !== localSignature) { + throw new Error(`Invalid XLSX file: local header signature mismatch for "${fileName}"`); + } + + const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26); + const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28); + const dataStart = localHeaderOffset + 30 + localFileNameLength + localExtraLength; + const compressedData = buffer.subarray(dataStart, dataStart + compressedSize); + + let fileData: Buffer; + if (compressionMethod === 0) { + fileData = Buffer.from(compressedData); + } else if (compressionMethod === 8) { + fileData = zlib.inflateRawSync(compressedData); + } else { + throw new Error(`Unsupported XLSX compression method "${compressionMethod}" in "${fileName}"`); + } + + entries.set(fileName, fileData); + cursor += 46 + fileNameLength + extraLength + commentLength; + } + + return entries; +} + +function parseSharedStrings(sharedStringsXml: string): string[] { + const output: string[] = []; + const regex = //g; + const textRegex = /]*)?>([\s\S]*?)<\/t>/g; + const stringItems = sharedStringsXml.match(regex) || []; + + for (const item of stringItems) { + const textParts: string[] = []; + let match: RegExpExecArray | null; + while ((match = textRegex.exec(item)) !== null) { + textParts.push(decodeXmlEntities(match[1])); + } + output.push(textParts.join('')); + } + + return output; +} + +function parseSheetRowValues(sheetXml: string, sharedStrings: string[], rowNumber: number): string[] { + const rowRegex = new RegExp(`]*r="${rowNumber}"[^>]*>([\\s\\S]*?)<\\/row>`); + const rowMatch = sheetXml.match(rowRegex); + if (!rowMatch) { + return []; + } + + const rowBody = rowMatch[1]; + const cellRegex = /]*)>([\s\S]*?)<\/c>/g; + const values: string[] = []; + let cellMatch: RegExpExecArray | null; + + while ((cellMatch = cellRegex.exec(rowBody)) !== null) { + const attrs = cellMatch[1] || ''; + const body = cellMatch[2] || ''; + const cellTypeMatch = attrs.match(/\st="([^"]+)"/); + const cellType = cellTypeMatch?.[1] || ''; + + if (cellType === 's') { + const valueMatch = body.match(/(\d+)<\/v>/); + const stringIndex = valueMatch ? Number(valueMatch[1]) : NaN; + values.push(Number.isFinite(stringIndex) ? sharedStrings[stringIndex] || '' : ''); + continue; + } + + if (cellType === 'inlineStr') { + const inlineMatch = body.match(/]*)?>([\s\S]*?)<\/t>/); + values.push(inlineMatch ? decodeXmlEntities(inlineMatch[1]) : ''); + continue; + } + + if (cellType === 'b') { + const boolValueMatch = body.match(/([01])<\/v>/); + values.push(boolValueMatch?.[1] === '1' ? 'TRUE' : 'FALSE'); + continue; + } + + const rawValueMatch = body.match(/([\s\S]*?)<\/v>/); + values.push(rawValueMatch ? decodeXmlEntities(rawValueMatch[1]) : ''); + } + + return values; +} + +function parseXlsxExport(filePath: string, maxDataRows = 10): ParsedXlsxExport { + const zipBuffer = fs.readFileSync(filePath); + const entries = extractZipEntries(zipBuffer); + + const workbookXml = entries.get('xl/workbook.xml')?.toString('utf8') || ''; + const sheetNameMatch = workbookXml.match(/]*name="([^"]+)"/); + const sheetName = decodeXmlEntities(sheetNameMatch?.[1] || ''); + if (!sheetName) { + throw new Error(`No worksheet found in exported file "${path.basename(filePath)}"`); + } + + const firstSheetXml = entries.get('xl/worksheets/sheet1.xml')?.toString('utf8') || ''; + if (!firstSheetXml) { + throw new Error(`Missing worksheet payload "xl/worksheets/sheet1.xml" in "${path.basename(filePath)}"`); + } + + const sharedStringsXml = entries.get('xl/sharedStrings.xml')?.toString('utf8') || ''; + const sharedStrings = sharedStringsXml ? parseSharedStrings(sharedStringsXml) : []; + + const header = parseSheetRowValues(firstSheetXml, sharedStrings, 1); + const firstDataRow = parseSheetRowValues(firstSheetXml, sharedStrings, 2); + const rowCountMatches = [...firstSheetXml.matchAll(/ + parseSheetRowValues(firstSheetXml, sharedStrings, index + 2) + ); + + return { + fileName: path.basename(filePath), + sheetName, + rowCount: rowCountMatches.length, + header, + firstDataRow, + dataRows, + }; +} + +function getLatestXlsxFile(downloadsFolder: string, minModifiedTime = 0): string | undefined { + if (!fs.existsSync(downloadsFolder)) { + return undefined; + } + + const xlsxEntries = fs + .readdirSync(downloadsFolder, { withFileTypes: true }) + .filter((entry) => entry.isFile() && isExcelExportFile(entry.name)) + .map((entry) => { + const filePath = path.join(downloadsFolder, entry.name); + return { + filePath, + mtimeMs: fs.statSync(filePath).mtimeMs, + }; + }) + .filter((entry) => entry.mtimeMs >= minModifiedTime) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + return xlsxEntries[0]?.filePath; +} + export default defineConfig({ allowCypressEnv: false, video: false, @@ -35,6 +242,55 @@ export default defineConfig({ specPattern: 'test/cypress/e2e/**/*.cy.ts', testIsolation: false, setupNodeEvents(on) { + on('task', { + clearXlsxDownloads({ downloadsFolder }: { downloadsFolder: string }) { + if (!fs.existsSync(downloadsFolder)) { + return 0; + } + + let removedCount = 0; + const files = fs.readdirSync(downloadsFolder, { withFileTypes: true }); + for (const file of files) { + if (file.isFile() && isExcelExportFile(file.name)) { + try { + fs.unlinkSync(path.join(downloadsFolder, file.name)); + removedCount += 1; + } catch (error) { + const errorCode = (error as NodeJS.ErrnoException)?.code; + if (errorCode !== 'EBUSY' && errorCode !== 'EPERM') { + throw error; + } + } + } + } + + return removedCount; + }, + + async readLatestXlsxExport({ + downloadsFolder, + minModifiedTime = 0, + timeoutMs = 10000, + maxDataRows = 10, + }: ReadLatestXlsxTaskOptions): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime <= timeoutMs) { + const latestFilePath = + getLatestXlsxFile(downloadsFolder, minModifiedTime) || + // Fallback for Windows/CI timestamp precision or delayed metadata updates. + getLatestXlsxFile(downloadsFolder, 0); + if (latestFilePath) { + return parseXlsxExport(latestFilePath, maxDataRows); + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error(`No .xlsx export found in "${downloadsFolder}" within ${timeoutMs}ms`); + }, + }); + on('before:browser:launch', (browser, launchOptions) => { if (['chrome', 'edge'].includes(browser.name)) { if (browser.isHeadless) { diff --git a/test/cypress/e2e/example07.cy.ts b/test/cypress/e2e/example07.cy.ts index 5b33ac4a8e..50adf1e9f3 100644 --- a/test/cypress/e2e/example07.cy.ts +++ b/test/cypress/e2e/example07.cy.ts @@ -860,6 +860,34 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { cy.get('.slick-context-menu .slick-menu-command-list').should('not.exist'); }); + it('should export to Excel and contain expected worksheet content', () => { + const downloadsFolder = Cypress.config('downloadsFolder'); + + cy.get('.grid7').find('.slick-row .slick-cell:nth(2)').rightclick({ force: true }); + cy.get('.slick-context-menu.dropright .slick-menu-command-list') + .find('.slick-menu-item:nth(1)') + .find('.slick-menu-content') + .contains('Export to Excel') + .click(); + + cy.task('readLatestXlsxExport', { downloadsFolder, timeoutMs: 15000 }).then((xlsx: any) => { + expect(xlsx.fileName).to.match(/\.xlsx$/i); + expect(xlsx.sheetName).to.be.a('string').and.not.be.empty; + expect(xlsx.rowCount).to.be.gte(3); + + const normalizedHeaders = xlsx.header.map((header: string) => `${header}`.replace(/\s+/g, ' ').trim()); + expect(normalizedHeaders).to.deep.equal(['Title', '% Complete', 'Duration', 'Completed', 'Start', 'Prerequisites', 'Title']); + + expect(xlsx.firstDataRow[0]).to.equal('Task 4'); + expect(Number(xlsx.firstDataRow[1])).to.be.a('number'); + expect(xlsx.firstDataRow[2]).to.equal('0'); + expect(`${xlsx.firstDataRow[3]}`.toUpperCase()).to.equal('FALSE'); + expect(xlsx.firstDataRow[4]).to.equal('2009-01-01'); + expect(xlsx.firstDataRow[5]).to.contain('Task 4'); + expect(xlsx.firstDataRow[6]).to.equal('Task 4'); + }); + }); + it('should switch language', () => { cy.get('[data-test="language-button"]').click(); diff --git a/test/cypress/e2e/example37.cy.ts b/test/cypress/e2e/example37.cy.ts index c1d0ede613..48be92a208 100644 --- a/test/cypress/e2e/example37.cy.ts +++ b/test/cypress/e2e/example37.cy.ts @@ -38,6 +38,42 @@ describe('Example 37 - Hybrid Selection Model', () => { }); }); + it('should export Grid 1 to Excel without hidden columns and keep expected data shapes', () => { + const downloadsFolder = Cypress.config('downloadsFolder'); + + cy.get('[data-test="export-excel-btn"]').click(); + + cy.task('readLatestXlsxExport', { downloadsFolder, timeoutMs: 15000, maxDataRows: 11 }).then((xlsx: any) => { + expect(xlsx.fileName).to.match(/\.xlsx$/i); + + const normalizedHeader = xlsx.header.map((columnName: string) => + `${columnName}` + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() + ); + + expect(normalizedHeader).to.deep.equal(['#', 'title', '% complete', 'start', 'finish', 'priority', 'effort driven']); + expect(normalizedHeader).to.have.length(7); + expect(normalizedHeader).to.not.include('last column / hidden column'); + expect(xlsx.rowCount - 1).to.equal(400); + + expect(xlsx.dataRows).to.be.an('array'); + expect(xlsx.dataRows.length).to.equal(11); + + xlsx.dataRows.forEach((row: string[], index: number) => { + expect(row[0]).to.equal(`${index}`); + expect(row[1]).to.equal(`Task ${index}`); + expect(Number(row[2])).to.be.within(0, 99); + expect(row[3]).to.match(/^\d{2}\/\d{2}\/\d{4}$/); + expect(row[4]).to.match(/^\d{2}\/\d{2}\/\d{4}$/); + expect(row[5]).to.match(/^(Low|Medium|High)$/); + expect(row[6]).to.match(/^(Yes|No)$/); + }); + }); + }); + it('should click on Task 1 and be able to drag from bottom right corner to expand the cell selections to include 4 cells', () => { cy.get('.grid37-1 .slick-row[data-row="1"] .slick-cell.l1.r1').as('task1'); cy.get('@task1').should('contain', 'Task 1'); From 5cd515b07d71a583627ddeb8780361d7ab0fc5ca Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Mon, 10 Aug 2026 17:25:05 -0400 Subject: [PATCH 13/57] feat: add core RTL support for headers and column resizing (#2714) * feat: add core RTL support for headers and column resizing --- .../src/examples/slickgrid/example57.html | 24 ++ .../src/examples/slickgrid/example57.ts | 78 ++++++ demos/aurelia/src/my-app.ts | 1 + .../aurelia/test/cypress/e2e/example57.cy.ts | 81 ++++++ demos/react/src/examples/slickgrid/App.tsx | 1 + .../src/examples/slickgrid/Example57.tsx | 104 +++++++ demos/react/test/cypress/e2e/example57.cy.ts | 81 ++++++ demos/vanilla/src/app-routing.ts | 2 + demos/vanilla/src/app.html | 16 +- demos/vanilla/src/examples/example46.html | 22 ++ demos/vanilla/src/examples/example46.scss | 8 + demos/vanilla/src/examples/example46.ts | 88 ++++++ demos/vue/src/components/Example57.vue | 101 +++++++ demos/vue/src/router/index.ts | 1 + demos/vue/test/cypress/e2e/example57.cy.ts | 81 ++++++ .../src/demos/app-routing.module.ts | 1 + .../src/demos/app.component.html | 3 + .../demos/examples/example57.component.html | 19 ++ .../demos/examples/example57.component.scss | 3 + .../src/demos/examples/example57.component.ts | 89 ++++++ .../test/cypress/e2e/example57.cy.ts | 81 ++++++ .../src/core/__tests__/slickGrid-rtl.spec.ts | 255 ++++++++++++++++++ packages/common/src/core/slickGrid.ts | 96 +++++-- .../__tests__/slickGridMenu.spec.ts | 11 + .../common/src/extensions/slickGridMenu.ts | 6 + .../src/interfaces/gridOption.interface.ts | 3 + packages/common/src/styles/_variables.scss | 4 +- packages/common/src/styles/slick-grid.scss | 29 +- packages/common/src/styles/slick-plugins.scss | 25 +- test/cypress/e2e/example46.cy.ts | 99 +++++++ 30 files changed, 1370 insertions(+), 43 deletions(-) create mode 100644 demos/aurelia/src/examples/slickgrid/example57.html create mode 100644 demos/aurelia/src/examples/slickgrid/example57.ts create mode 100644 demos/aurelia/test/cypress/e2e/example57.cy.ts create mode 100644 demos/react/src/examples/slickgrid/Example57.tsx create mode 100644 demos/react/test/cypress/e2e/example57.cy.ts create mode 100644 demos/vanilla/src/examples/example46.html create mode 100644 demos/vanilla/src/examples/example46.scss create mode 100644 demos/vanilla/src/examples/example46.ts create mode 100644 demos/vue/src/components/Example57.vue create mode 100644 demos/vue/test/cypress/e2e/example57.cy.ts create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.html create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.scss create mode 100644 frameworks/angular-slickgrid/src/demos/examples/example57.component.ts create mode 100644 frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts create mode 100644 packages/common/src/core/__tests__/slickGrid-rtl.spec.ts create mode 100644 test/cypress/e2e/example46.cy.ts diff --git a/demos/aurelia/src/examples/slickgrid/example57.html b/demos/aurelia/src/examples/slickgrid/example57.html new file mode 100644 index 0000000000..da89bf44f7 --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.html @@ -0,0 +1,24 @@ +

+ Example 57: RTL (Right-to-Left) + + + code + + +

+ +
Basic grid with RTL (Right-to-Left) enabled for RTL languages
+ +
+ +
diff --git a/demos/aurelia/src/examples/slickgrid/example57.ts b/demos/aurelia/src/examples/slickgrid/example57.ts new file mode 100644 index 0000000000..867aa840bd --- /dev/null +++ b/demos/aurelia/src/examples/slickgrid/example57.ts @@ -0,0 +1,78 @@ +import { Formatters, type Column, type GridOption } from 'aurelia-slickgrid'; + +const NB_ITEMS = 100; + +export class Example57 { + gridOptions!: GridOption; + columns: Column[] = []; + dataset: any[] = []; + previousBodyDir: string | null = null; + + constructor() { + this.defineGrid(); + } + + attached() { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + this.dataset = this.mockData(NB_ITEMS); + } + + detached() { + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + defineGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + mockData(count: number) { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } +} diff --git a/demos/aurelia/src/my-app.ts b/demos/aurelia/src/my-app.ts index 3ce7d1a920..562a2e1299 100644 --- a/demos/aurelia/src/my-app.ts +++ b/demos/aurelia/src/my-app.ts @@ -62,6 +62,7 @@ const myRoutes: Routeable[] = [ { path: 'example54', component: () => import('./examples/slickgrid/example54.js'), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', component: () => import('./examples/slickgrid/example55.js'), title: '55- Variable Row Height (provider)' }, { path: 'example56', component: () => import('./examples/slickgrid/example56.js'), title: '56- Variable Row Height (metadata)' }, + { path: 'example57', component: () => import('./examples/slickgrid/example57.js'), title: '57- RTL (Right-to-Left)' }, { path: 'home', component: () => import('./home-page.js'), title: 'Home' }, ]; @route({ diff --git a/demos/aurelia/test/cypress/e2e/example57.cy.ts b/demos/aurelia/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..df73fe98f8 --- /dev/null +++ b/demos/aurelia/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/demos/react/src/examples/slickgrid/App.tsx b/demos/react/src/examples/slickgrid/App.tsx index 7fe024808c..f44e0121fa 100644 --- a/demos/react/src/examples/slickgrid/App.tsx +++ b/demos/react/src/examples/slickgrid/App.tsx @@ -58,6 +58,7 @@ const routes = [ { path: 'example54', route: '/example54', element: lazy(() => import('./Example54.js')), title: '54- AI / Web MCP Toolkit' }, { path: 'example55', route: '/example55', element: lazy(() => import('./Example55.js')), title: '55- Variable Row Height (provider)' }, { path: 'example56', route: '/example56', element: lazy(() => import('./Example56.js')), title: '56- Variable Row Height (metadata)' }, + { path: 'example57', route: '/example57', element: lazy(() => import('./Example57.js')), title: '57- RTL (Right-to-Left)' }, ]; export default function Routes() { diff --git a/demos/react/src/examples/slickgrid/Example57.tsx b/demos/react/src/examples/slickgrid/Example57.tsx new file mode 100644 index 0000000000..1ec7ff4312 --- /dev/null +++ b/demos/react/src/examples/slickgrid/Example57.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { Formatters, SlickgridReact, type Column, type GridOption } from 'slickgrid-react'; + +const NB_ITEMS = 100; + +const Example57: React.FC = () => { + const [gridOptions, setGridOptions] = useState(undefined); + const [columns, setColumns] = useState([]); + const [dataset, setDataset] = useState([]); + + useEffect(() => { + const previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + defineGrid(); + const mockData = mockDataset(); + setDataset(mockData); + + return () => { + if (previousBodyDir) { + document.body.setAttribute('dir', previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + }; + }, []); + + const defineGrid = () => { + const cols: Column[] = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + setColumns(cols); + + const opts: GridOption = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + setGridOptions(opts); + }; + + const mockDataset = () => { + const data = []; + for (let i = 0; i < NB_ITEMS; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + }; + + return !gridOptions ? null : ( +
+

+ Example 57: RTL (Right-to-Left) + + see  + + code + + +

+ +
Basic grid with RTL (Right-to-Left) enabled for RTL languages.
+ +
+ +
+
+ ); +}; + +export default Example57; diff --git a/demos/react/test/cypress/e2e/example57.cy.ts b/demos/react/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..df73fe98f8 --- /dev/null +++ b/demos/react/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/demos/vanilla/src/app-routing.ts b/demos/vanilla/src/app-routing.ts index c705c59a91..1976666f59 100644 --- a/demos/vanilla/src/app-routing.ts +++ b/demos/vanilla/src/app-routing.ts @@ -43,6 +43,7 @@ import Example42 from './examples/example42.js'; 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 Icons from './examples/icons.js'; import type { RouterConfig } from './interfaces.js'; @@ -96,6 +97,7 @@ export class AppRouting { { route: 'example43', name: 'example43', view: './examples/example43.html', viewModel: Example43, title: 'Example43' }, { 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: '', redirect: 'example01' }, { route: '**', redirect: 'example01' }, ]; diff --git a/demos/vanilla/src/app.html b/demos/vanilla/src/app.html index 4482f40cfc..8756cc7cb8 100644 --- a/demos/vanilla/src/app.html +++ b/demos/vanilla/src/app.html @@ -29,8 +29,7 @@

Slickgrid-Universal

Documentation SlickGrid Icons diff --git a/demos/vanilla/src/examples/example46.html b/demos/vanilla/src/examples/example46.html new file mode 100644 index 0000000000..4bf26370b5 --- /dev/null +++ b/demos/vanilla/src/examples/example46.html @@ -0,0 +1,22 @@ +
+

+ Example 46 - RTL (Right-to-Left) + with column resizing + +

+ +
Basic grid with RTL (Right-to-Left) enabled for RTL languages
+ +
+
+
+
diff --git a/demos/vanilla/src/examples/example46.scss b/demos/vanilla/src/examples/example46.scss new file mode 100644 index 0000000000..3b29bebb5d --- /dev/null +++ b/demos/vanilla/src/examples/example46.scss @@ -0,0 +1,8 @@ +.grid46 { + direction: rtl; + --slick-header-menu-display: inline-block; +} + +.demo-container.grid46 { + inset-inline-start: 50px; +} diff --git a/demos/vanilla/src/examples/example46.ts b/demos/vanilla/src/examples/example46.ts new file mode 100644 index 0000000000..938a5715b4 --- /dev/null +++ b/demos/vanilla/src/examples/example46.ts @@ -0,0 +1,88 @@ +import { Formatters, type Column, type GridOption } from '@slickgrid-universal/common'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExampleGridOptions } from './example-grid-options.js'; +import './example46.scss'; + +const NB_ITEMS = 100; + +export default class Example46 { + gridOptions!: GridOption; + columns!: Column[]; + dataset!: any[]; + sgb!: SlickVanillaGridBundle; + previousBodyDir: string | null = null; + + attached() { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + this.defineGrid(); + this.dataset = this.mockData(NB_ITEMS); + + this.sgb = new Slicker.GridBundle( + document.querySelector('.grid46') as HTMLDivElement, + this.columns, + { ...ExampleGridOptions, ...this.gridOptions }, + this.dataset + ); + } + + dispose() { + this.sgb?.dispose(); + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + defineGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 900, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + mockData(count: number) { + const data: any[] = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } +} diff --git a/demos/vue/src/components/Example57.vue b/demos/vue/src/components/Example57.vue new file mode 100644 index 0000000000..b83f68da93 --- /dev/null +++ b/demos/vue/src/components/Example57.vue @@ -0,0 +1,101 @@ + + + diff --git a/demos/vue/src/router/index.ts b/demos/vue/src/router/index.ts index c6f8e0f472..c5973a1de5 100644 --- a/demos/vue/src/router/index.ts +++ b/demos/vue/src/router/index.ts @@ -64,6 +64,7 @@ export const routes: RouteRecordRaw[] = [ { path: '/example54', name: '54- AI / Web MCP Toolkit', component: () => import('../components/Example54.vue') }, { path: '/example55', name: '55- Variable Row Height (provider)', component: () => import('../components/Example55.vue') }, { path: '/example56', name: '56- Variable Row Height (metadata)', component: () => import('../components/Example56.vue') }, + { path: '/example57', name: '57- RTL (Right-to-Left)', component: () => import('../components/Example57.vue') }, ]; export const router = createRouter({ diff --git a/demos/vue/test/cypress/e2e/example57.cy.ts b/demos/vue/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..df73fe98f8 --- /dev/null +++ b/demos/vue/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts index 945ede0541..ec0a73b75d 100644 --- a/frameworks/angular-slickgrid/src/demos/app-routing.module.ts +++ b/frameworks/angular-slickgrid/src/demos/app-routing.module.ts @@ -58,6 +58,7 @@ export const routes: Routes = [ { path: 'example54', loadComponent: () => import('./examples/example54.component').then((m) => m.Example54Component) }, { path: 'example55', loadComponent: () => import('./examples/example55.component').then((m) => m.Example55Component) }, { path: 'example56', loadComponent: () => import('./examples/example56.component').then((m) => m.Example56Component) }, + { path: 'example57', loadComponent: () => import('./examples/example57.component').then((m) => m.Example57Component) }, { path: '', redirectTo: '/example34', pathMatch: 'full' }, { path: '**', redirectTo: '/example34', pathMatch: 'full' }, ]; diff --git a/frameworks/angular-slickgrid/src/demos/app.component.html b/frameworks/angular-slickgrid/src/demos/app.component.html index c9e6c2a735..a9a4a5b44c 100644 --- a/frameworks/angular-slickgrid/src/demos/app.component.html +++ b/frameworks/angular-slickgrid/src/demos/app.component.html @@ -212,6 +212,9 @@ + diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.html b/frameworks/angular-slickgrid/src/demos/examples/example57.component.html new file mode 100644 index 0000000000..2084b3e6c2 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.html @@ -0,0 +1,19 @@ +
+

+ Example 57: RTL (Right-to-Left) + + + code + + +

+
Basic grid with RTL (Right-to-Left) enabled for RTL languages
+ +
+ +
+
diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss b/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss new file mode 100644 index 0000000000..b52790d720 --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.scss @@ -0,0 +1,3 @@ +.grid-rtl { + direction: rtl; +} diff --git a/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts new file mode 100644 index 0000000000..0bac8bca1a --- /dev/null +++ b/frameworks/angular-slickgrid/src/demos/examples/example57.component.ts @@ -0,0 +1,89 @@ +import { Component, type OnDestroy, type OnInit } from '@angular/core'; +import { AngularSlickgridComponent, Formatters, type Column, type GridOption } from '../../library'; + +const NB_ITEMS = 100; + +@Component({ + templateUrl: './example57.component.html', + styleUrls: ['./example57.component.scss'], + imports: [AngularSlickgridComponent], +}) +export class Example57Component implements OnInit, OnDestroy { + columns: Column[] = []; + gridOptions!: GridOption; + dataset!: any[]; + hideSubTitle = false; + previousBodyDir: string | null = null; + + ngOnInit(): void { + this.previousBodyDir = document.body.getAttribute('dir'); + document.body.setAttribute('dir', 'rtl'); + + this.prepareGrid(); + this.dataset = this.mockData(NB_ITEMS); + } + + ngOnDestroy(): void { + if (this.previousBodyDir) { + document.body.setAttribute('dir', this.previousBodyDir); + } else { + document.body.removeAttribute('dir'); + } + } + + prepareGrid() { + this.columns = [ + { id: 'id', name: 'ID', field: 'id', filterable: true, sortable: true, minWidth: 60 }, + { id: 'title', name: 'Title', field: 'title', filterable: true, sortable: true, minWidth: 100 }, + { id: 'duration', name: 'Duration (days)', field: 'duration', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { id: '%', name: '% Complete', field: 'percentComplete', filterable: true, sortable: true, minWidth: 100, type: 'number' }, + { + id: 'start', + name: 'Start', + field: 'start', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { + id: 'finish', + name: 'Finish', + field: 'finish', + formatter: Formatters.dateIso, + exportWithFormatter: true, + filterable: true, + }, + { id: 'effort-driven', name: 'Effort Driven', field: 'effortDriven', minWidth: 80 }, + ]; + + this.gridOptions = { + enableFiltering: true, + gridHeight: 500, + gridWidth: 700, + rowHeight: 33, + rtl: true, // ← Enable RTL mode + }; + } + + mockData(count: number) { + const data = []; + for (let i = 0; i < count; i++) { + data.push({ + id: i, + title: `Task ${i}`, + duration: Math.round(Math.random() * 100), + percentComplete: Math.round(Math.random() * 100), + start: new Date(2024, 0, 1 + Math.floor(Math.random() * 30)).toISOString().split('T')[0], + finish: new Date(2024, 1, 1 + Math.floor(Math.random() * 28)).toISOString().split('T')[0], + effortDriven: i % 5 === 0, + }); + } + return data; + } + + toggleSubTitle() { + this.hideSubTitle = !this.hideSubTitle; + const action = this.hideSubTitle ? 'add' : 'remove'; + document.querySelector('.subtitle')?.classList[action]('hidden'); + } +} diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts new file mode 100644 index 0000000000..df73fe98f8 --- /dev/null +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example57.cy.ts @@ -0,0 +1,81 @@ +describe('Example 57 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example57`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h2').should('contain', 'Example 57: RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('#grid57').then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('#grid57 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('#grid57 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('#grid57') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('#grid57 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); +}); diff --git a/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts b/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts new file mode 100644 index 0000000000..deee4c79d5 --- /dev/null +++ b/packages/common/src/core/__tests__/slickGrid-rtl.spec.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Column, GridOption } from '../../interfaces/index.js'; +import { SlickEventData } from '../slickCore.js'; +import { SlickGrid } from '../slickGrid.js'; + +vi.useFakeTimers(); + +const DEFAULT_GRID_HEIGHT = 600; +const DEFAULT_GRID_WIDTH = 800; +const gridId = 'grid1'; +const gridUid = 'slickgrid_124343'; +const containerId = 'demo-container'; + +const template = `
+
+
+
+
`; + +describe('SlickGrid RTL (Right-to-Left)', () => { + let container: HTMLElement; + let grid: SlickGrid; + const items = [ + { id: 0, name: 'Item 0', value: 10 }, + { id: 1, name: 'Item 1', value: 20 }, + { id: 2, name: 'Item 2', value: 30 }, + ]; + const columns = [ + { id: 'id', field: 'id', name: 'ID', width: 60, resizable: true }, + { id: 'name', field: 'name', name: 'Name', width: 100, resizable: true }, + { id: 'value', field: 'value', name: 'Value', width: 80, resizable: true }, + ] as Column[]; + let defaultOptions: GridOption; + + beforeEach(() => { + defaultOptions = { + enableCellNavigation: true, + columnResizingDelay: 1, + scrollRenderThrottling: 1, + devMode: { ownerNodeIndex: 0 }, + }; + container = document.createElement('div'); + container.id = gridId; + container.innerHTML = template; + container.style.height = `${DEFAULT_GRID_HEIGHT}px`; + container.style.width = `${DEFAULT_GRID_WIDTH}px`; + document.body.appendChild(container); + Object.defineProperty(container, 'height', { writable: true, configurable: true, value: DEFAULT_GRID_HEIGHT }); + Object.defineProperty(container, 'clientHeight', { writable: true, configurable: true, value: DEFAULT_GRID_HEIGHT }); + Object.defineProperty(container, 'clientWidth', { writable: true, configurable: true, value: DEFAULT_GRID_WIDTH }); + }); + + afterEach(() => { + document.body.textContent = ''; + grid?.destroy(true); + }); + + describe('RTL Option', () => { + it('should have rtl option set to false by default', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, defaultOptions); + expect(grid.getOptions().rtl).toBe(false); + }); + + it('should enable RTL mode when rtl option is set to true', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should apply RTL class and dir attribute on grid container', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + + expect(gridContainer.classList.contains('slick-rtl')).toBe(true); + expect(gridContainer.getAttribute('dir')).toBe('rtl'); + }); + + it('should not apply RTL class or dir in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false }); + + expect(gridContainer.classList.contains('slick-rtl')).toBe(false); + expect(gridContainer.getAttribute('dir')).toBeNull(); + }); + }); + + describe('Visible Range in RTL', () => { + it('should calculate leftPx/rightPx with RTL negative scrollLeft convention', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + + const anyGrid = grid as any; + anyGrid.canvasWidth = 2000; + anyGrid.viewportW = 800; + + const range = grid.getVisibleRange(0, -200); + + expect(range.leftPx).toBe(600); + expect(range.rightPx).toBe(1400); + expect(range.rightPx).toBeGreaterThan(range.leftPx); + }); + }); + + describe('Column Resizing in RTL', () => { + it('should handle RTL mode with resizable columns', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, editable: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + }); + + describe('applyColumnWidths with RTL', () => { + it('should apply column widths in RTL mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should apply column widths in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false }); + expect(grid.getOptions().rtl).toBe(false); + }); + }); + + describe('Resize constraints with RTL', () => { + it('should calculate resize constraints correctly in RTL mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, editable: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should calculate resize constraints correctly in LTR mode', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: false, editable: true }); + expect(grid.getOptions().rtl).toBe(false); + }); + }); + + describe('Mixed RTL Features', () => { + it('should support RTL with frozen columns', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, frozenColumn: 0 }); + expect(grid.getOptions().rtl).toBe(true); + expect(grid.getOptions().frozenColumn).toBe(0); + }); + + it('should support RTL with sorting', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, enableSorting: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + + it('should support RTL with filtering', () => { + const gridContainer = document.getElementById(gridId) as HTMLElement; + grid = new SlickGrid(gridContainer, items, columns, { ...defaultOptions, rtl: true, enableFiltering: true }); + expect(grid.getOptions().rtl).toBe(true); + }); + }); + + describe('Column Resizing', () => { + const columns = [ + { id: 'id', field: 'id', name: 'Id', hidden: true }, + { id: 'firstName', field: 'firstName', name: 'First Name', sortable: true, width: 77, previousWidth: 20, rerenderOnResize: true }, + { id: 'lastName', field: 'lastName', name: 'Last Name', sortable: true, minWidth: 35, maxWidth: 78 }, + { id: 'age', field: 'age', name: 'Age', sortable: true, minWidth: 82, width: 86, maxWidth: 88 }, + { id: 'gender', field: 'gender', name: 'Gender', sortable: true }, + ] as Column[]; + const data = [ + { id: 0, firstName: 'John', lastName: 'Doe', age: 30 }, + { id: 1, firstName: 'Jane', lastName: 'Doe', age: 28 }, + ]; + + it('should resize 2nd column that has a "width" defined using default sizing grid options', () => { + grid = new SlickGrid(container, data, columns, { ...defaultOptions, forceFitColumns: false, rtl: true }); + grid.init(); + + const sedOnBeforeResize = new SlickEventData(); + sedOnBeforeResize.addReturnValue(true); + vi.spyOn(grid.onBeforeColumnsResize, 'notify').mockReturnValue(sedOnBeforeResize); + const onColumnsDragSpy = vi.spyOn(grid.onColumnsDrag, 'notify'); + const onColumnsResizedSpy = vi.spyOn(grid.onColumnsResized, 'notify'); + const columnElms = container.querySelectorAll('.slick-header-column'); + const resizeHandleElm = columnElms[1].querySelector('.slick-resizable-handle') as HTMLDivElement; + + const cMouseDownEvent = new CustomEvent('mousedown'); + const bodyMouseMoveEvent = new CustomEvent('mousemove'); + const bodyMouseUpEvent = new CustomEvent('mouseup'); + Object.defineProperty(bodyMouseMoveEvent, 'target', { writable: true, value: resizeHandleElm }); + Object.defineProperty(cMouseDownEvent, 'pageX', { writable: true, value: 9 }); + Object.defineProperty(cMouseDownEvent, 'pageY', { writable: true, value: 12 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageX', { writable: true, value: -22 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageY', { writable: true, value: 13 }); + + // start resizing + resizeHandleElm.dispatchEvent(cMouseDownEvent); + container.dispatchEvent(cMouseDownEvent); + document.body.dispatchEvent(bodyMouseMoveEvent); + expect(columnElms[1].classList.contains('slick-header-column-active')).toBeTruthy(); + expect(onColumnsDragSpy).toHaveBeenCalledWith({ triggeredByColumn: columnElms[1], resizeHandle: resizeHandleElm, grid }, expect.anything(), grid); + + // header click won't get through + const onHeaderClickSpy = vi.spyOn(grid.onHeaderClick, 'notify'); + container.querySelector('.slick-header')!.dispatchEvent(new CustomEvent('click')); + expect(onHeaderClickSpy).not.toHaveBeenCalled(); + + // end resizing + document.body.dispatchEvent(bodyMouseUpEvent); + + vi.advanceTimersByTime(10); + + expect(columnElms[1].classList.contains('slick-header-column-active')).toBeFalsy(); + expect(onColumnsResizedSpy).toHaveBeenCalledWith({ triggeredByColumn: 'lastName', grid }, expect.anything(), grid); + expect(columns[0].width).toBe(80); + expect(columns[1].width).toBe(0); + expect(columns[2].width).toBe(65); + expect(columns[3].width).toBe(86); + expect(columns[4].width).toBe(80); + }); + + it('should not schedule resize auto-scroll timer in RTL when dragging outside viewport', () => { + grid = new SlickGrid(container, data, columns, { + ...defaultOptions, + forceFitColumns: false, + rtl: true, + autoScrollOnColumnResize: true, + }); + grid.init(); + + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); + const columnElms = container.querySelectorAll('.slick-header-column'); + const resizeHandleElm = columnElms[1].querySelector('.slick-resizable-handle') as HTMLDivElement; + + const cMouseDownEvent = new CustomEvent('mousedown'); + const bodyMouseMoveEvent = new CustomEvent('mousemove'); + Object.defineProperty(bodyMouseMoveEvent, 'target', { writable: true, value: resizeHandleElm }); + Object.defineProperty(cMouseDownEvent, 'pageX', { writable: true, value: 9 }); + Object.defineProperty(cMouseDownEvent, 'pageY', { writable: true, value: 12 }); + // Simulate dragging outside the viewport edge. + Object.defineProperty(bodyMouseMoveEvent, 'pageX', { writable: true, value: -200 }); + Object.defineProperty(bodyMouseMoveEvent, 'pageY', { writable: true, value: 13 }); + Object.defineProperty(bodyMouseMoveEvent, 'clientX', { writable: true, value: 0 }); + + resizeHandleElm.dispatchEvent(cMouseDownEvent); + container.dispatchEvent(cMouseDownEvent); + document.body.dispatchEvent(bodyMouseMoveEvent); + vi.advanceTimersByTime(120); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect((grid as any)._columnResizeAutoScrollTimer).toBeUndefined(); + setIntervalSpy.mockRestore(); + }); + }); +}); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 748017d039..6b986bbbb2 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -319,6 +319,7 @@ export class SlickGrid = Column, O e enableMouseWheelScrollHandler: true, doPaging: true, rowTopOffsetRenderType: 'top', + rtl: false, scrollRenderThrottling: 10, suppressCssChangesOnHiddenInit: false, ffMaxSupportedCssHeight: 6000000, @@ -791,12 +792,12 @@ export class SlickGrid = Column, O e // Append the columnn containers to the headers this._headerL = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-left', style: { left: '-1000px' }, role: 'row' }, + { className: 'slick-header-columns slick-header-columns-left', style: { [this.dirSide]: '-1000px' }, role: 'row' }, this._headerScrollerL ); this._headerR = createDomElement( 'div', - { className: 'slick-header-columns slick-header-columns-right', style: { left: '-1000px' }, role: 'row' }, + { className: 'slick-header-columns slick-header-columns-right', style: { [this.dirSide]: '-1000px' }, role: 'row' }, this._headerScrollerR ); @@ -938,6 +939,8 @@ export class SlickGrid = Column, O e if (!this._options.explicitInitialization) { this.finishInitialization(); } + + this.applyRTL(this._options.rtl ?? false); } protected finishInitialization(): void { @@ -2353,6 +2356,13 @@ export class SlickGrid = Column, O e targetPageX: number, resizeCallback: (targetPageX: number) => void ) => { + // TODO: there is a known bug with auto-scroll in RTL, + // so disable it until someone can contribute a fix + if (this._options.rtl) { + stopColumnResizeAutoScroll(); + return; + } + autoScrollClientX = isDefinedNumber(clientX) ? clientX : autoScrollClientX; const viewportOffset = getOffset(this._viewportScrollContainerX); const left = viewportOffset.left; @@ -2409,7 +2419,12 @@ export class SlickGrid = Column, O e ) => { this.columnResizeDragging = true; let actualMinWidth; - const d = Math.min(maxPageX, Math.max(minPageX, targetPageX)) - pageX; + let d = Math.min(maxPageX, Math.max(minPageX, targetPageX)) - pageX; + + if (this._options.rtl) { + d = -d; + } + let x; let newCanvasWidthL = 0; // oxlint-disable-next-line no-unused-vars @@ -2588,6 +2603,7 @@ export class SlickGrid = Column, O e this.updateCanvasWidth(); if ( this._options.autoScrollOnColumnResize && + !this._options.rtl && !this._options.forceFitColumns && !(this.hasFrozenColumns() && i <= this._options.frozenColumn!) ) { @@ -2668,8 +2684,13 @@ export class SlickGrid = Column, O e shrinkLeewayOnLeft += (c.previousWidth || 0) - Math.max(c.minWidth || 0, this.absoluteColumnMinWidth); } } - maxPageX = pageX + Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); - minPageX = pageX - Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + if (this._options.rtl) { + maxPageX = pageX + Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); + } else { + maxPageX = pageX + Math.min(shrinkLeewayOnRight ?? 100000, stretchLeewayOnLeft ?? 100000); + minPageX = pageX - Math.min(shrinkLeewayOnLeft ?? 100000, stretchLeewayOnRight ?? 100000); + } resizeAutoScrollDeltaX = 0; autoScrollClientX = isDefinedNumber((targetEvent as MouseEvent).clientX) ? (targetEvent as MouseEvent).clientX : undefined; stopColumnResizeAutoScroll(); @@ -2951,8 +2972,8 @@ export class SlickGrid = Column, O e (this._options.shadowRoot || document.head).appendChild(this._style); const rules = [ - `.${this.uid} .slick-group-header-column { left: 1000px; }`, - `.${this.uid} .slick-header-column { left: 1000px; }`, + `.${this.uid} .slick-group-header-column { ${this.dirSide}: 1000px; }`, + `.${this.uid} .slick-header-column { ${this.dirSide}: 1000px; }`, `.${this.uid} .slick-top-panel { height: ${this._options.topPanelHeight}px; }`, `.${this.uid} .slick-preheader-panel { height: ${this._options.preHeaderPanelHeight}px; }`, `.${this.uid} .slick-topheader-panel { height: ${this._options.topHeaderPanelHeight}px; }`, @@ -3363,12 +3384,22 @@ export class SlickGrid = Column, O e w = this.columns[i].hidden ? 0 : this.columns[i].width || 0; rule = this.getColumnCssRules(i); - if (rule.left) { - rule.left.style.left = `${x}px`; - } - if (rule.right) { - rule.right.style.right = - (this._options.frozenColumn !== -1 && i > this._options.frozenColumn! ? this.canvasWidthR : this.canvasWidthL) - x - w + 'px'; + if (this._options.rtl) { + if (rule.left) { + rule.left.style.right = `${x}px`; + } + if (rule.right) { + rule.right.style.left = + (this._options.frozenColumn !== -1 && i > this._options.frozenColumn! ? this.canvasWidthR : this.canvasWidthL) - x - w + 'px'; + } + } else { + if (rule.left) { + rule.left.style.left = `${x}px`; + } + if (rule.right) { + rule.right.style.right = + (this._options.frozenColumn !== -1 && i > this._options.frozenColumn! ? this.canvasWidthR : this.canvasWidthL) - x - w + 'px'; + } } // If this column is frozen, reset the css left value since the @@ -5295,11 +5326,21 @@ export class SlickGrid = Column, O e viewportTop ??= this.scrollTop; viewportLeft ??= this.scrollLeft; + let leftPx = viewportLeft; + let rightPx = viewportLeft + this.viewportW; + + if (this._options.rtl) { + // In RTL mode, scrollLeft is the offset from the right edge. + const maxScroll = this.canvasWidth - this.viewportW; + leftPx = maxScroll - viewportLeft - this.viewportW; + rightPx = maxScroll - viewportLeft; + } + return { top: this.getRowFromPosition(viewportTop), bottom: this.getRowFromPosition(viewportTop + this.viewportH) + 1, - leftPx: viewportLeft, - rightPx: viewportLeft + this.viewportW, + leftPx, + rightPx, }; } @@ -5785,7 +5826,7 @@ export class SlickGrid = Column, O e protected _handleScroll(eventType: 'mousewheel' | 'scroll' | 'system' = 'system'): boolean { let maxScrollDistanceY = this._viewportScrollContainerY.scrollHeight - this._viewportScrollContainerY.clientHeight; - let maxScrollDistanceX = this._viewportScrollContainerY.scrollWidth - this._viewportScrollContainerY.clientWidth; + let maxScrollDistanceX = this._viewportScrollContainerX.scrollWidth - this._viewportScrollContainerX.clientWidth; // Protect against erroneous clientHeight/Width greater than scrollHeight/Width. // Sometimes seen in Chrome. @@ -8150,4 +8191,27 @@ export class SlickGrid = Column, O e sanitizeHtmlString(dirtyHtml: unknown): T { return runOptionalHtmlSanitizer(dirtyHtml, this._options?.sanitizer); } + + /** + * Returns the CSS property used to hide header columns off-screen by applying a large offset (e.g., `1000px`). + * + * In LTR mode (`rtl: false`), columns are positioned with a negative `left` value to hide them off-screen. + * In RTL mode (`rtl: true`), the same effect is achieved by using a positive `right` value, since the scroll direction is mirrored. + * + * @returns 'right' when RTL is enabled, otherwise 'left' + */ + protected get dirSide(): string { + return this._options.rtl ? 'right' : 'left'; + } + + /** Applies/removes RTL state directly on the grid container. */ + private applyRTL(enabled: boolean): void { + if (enabled) { + this._container.classList.add('slick-rtl'); + this._container.setAttribute('dir', 'rtl'); + } else { + this._container.classList.remove('slick-rtl'); + this._container.removeAttribute('dir'); + } + } } diff --git a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts index 3208c0ccda..ceec305b54 100644 --- a/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts +++ b/packages/common/src/extensions/__tests__/slickGridMenu.spec.ts @@ -149,6 +149,7 @@ describe('GridMenuControl', () => { enableAutoSizeColumns: true, enableGridMenu: true, enableTranslate: true, + rtl: false, backendServiceApi: { service: { buildQuery: vi.fn(), @@ -2792,5 +2793,15 @@ describe('GridMenuControl', () => { expect(control.getAllColumns()).toEqual(columnsMock); expect(control.getVisibleColumns()).toEqual(columnsMock); }); + + it('should open grid menu on the right when using RTL', () => { + gridOptionsMock.rtl = true; + control.init(); + const buttonElm = document.querySelector('.slick-grid-menu-button') as HTMLDivElement; + buttonElm.dispatchEvent(new Event('click', { bubbles: true, cancelable: true, composed: false })); + const gridMenuElm = document.querySelector('.slick-grid-menu') as HTMLDivElement; + + expect(gridMenuElm.classList.contains('dropright')).toBe(true); + }); }); }); diff --git a/packages/common/src/extensions/slickGridMenu.ts b/packages/common/src/extensions/slickGridMenu.ts index 2717a6e89e..02f6b23350 100644 --- a/packages/common/src/extensions/slickGridMenu.ts +++ b/packages/common/src/extensions/slickGridMenu.ts @@ -146,6 +146,12 @@ export class SlickGridMenu extends MenuBaseClass { } this._userOriginalGridMenu = { ...this.sharedService.gridOptions.gridMenu }; this._addonOptions = { ...this._defaults, ...this.getDefaultGridMenuOptions(), ...this.sharedService.gridOptions.gridMenu }; + + // adjust dropSide for RTL mode (menu should open to the right when button is on the left in RTL) + if (this.sharedService.gridOptions.rtl) { + this._addonOptions.dropSide = 'right'; + } + this.sharedService.gridOptions.gridMenu = this._addonOptions; // merge original user grid menu items with internal items diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 8db2cbd7a7..5f5c2fa619 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -863,6 +863,9 @@ export interface GridOption { /** Defaults to 400, duration to show the row highlight (e.g. after insert/edit/...) */ rowHighlightDuration?: number; + /** Defaults to false, sets the grid direction to RTL (Right-to-Left) for proper rendering of RTL languages */ + rtl?: boolean; + /** Row Move Manager Plugin options & events */ rowMoveManager?: RowMoveManager; diff --git a/packages/common/src/styles/_variables.scss b/packages/common/src/styles/_variables.scss index dd5e72a377..f36316aae0 100644 --- a/packages/common/src/styles/_variables.scss +++ b/packages/common/src/styles/_variables.scss @@ -359,7 +359,7 @@ $slick-column-picker-item-hover-border: 1px solid #d5d5d5 !d $slick-column-picker-item-hover-color: #fafafa !default; $slick-column-picker-label-margin: 4px !default; $slick-column-picker-label-font-weight: normal !default; -$slick-column-picker-label-text-padding-left: 4px !default; +$slick-column-picker-label-gap: 4px !default; $slick-column-picker-link-background-color: #ffffff !default; $slick-column-picker-list-margin-bottom: 8px !default; $slick-column-picker-opacity-hover: 0.45 !default; @@ -407,6 +407,7 @@ $slick-menu-item-border: 1px solid transparen $slick-menu-item-border-radius: 0px !default; $slick-menu-item-disabled-color: silver !default; $slick-menu-item-font-size: $slick-font-size-base !default; +$slick-menu-item-gap: 4px !default; $slick-menu-item-height: 28px !default; $slick-menu-item-hover-border: 1px solid #d5d5d5 !default; $slick-menu-item-hover-color: #fafafa !default; @@ -416,7 +417,6 @@ $slick-menu-item-white-space: nowrap !default; $slick-menu-icon-font-size: $slick-icon-font-size !default; $slick-menu-icon-line-height: calc(#{$slick-menu-icon-font-size} + 2px) !default; $slick-menu-item-width-when-button: calc(100% - #{$slick-menu-close-btn-width}) !default; -$slick-menu-icon-margin-right: 4px !default; $slick-menu-icon-min-width: 16px !default; $slick-menu-line-height: 24px !default; $slick-menu-min-width: 140px !default; diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index e8e6d679a6..307b2bdc81 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -322,6 +322,20 @@ display: flex; } + .slick-rtl { + .slick-preheader-container, + .slick-header-container, + .slick-headerrow { + flex-direction: row-reverse; + } + + .slick-resizable-handle { + right: auto; + inset-inline-end: auto; + left: -5px; + } + } + .slick-pane-top { box-sizing: border-box; border-top: var(--slick-pane-top-border-top, v.$slick-pane-top-border-top); @@ -410,6 +424,10 @@ border-top: 0px !important; border-bottom: 0px !important; float: left; + + .slick-rtl & { + float: right; + } } .slick-header-column { @@ -610,7 +628,7 @@ top: var(--slick-icon-tree-load-fail-sup-top, v.$slick-icon-tree-load-fail-sup-top); left: var(--slick-icon-tree-load-fail-sup-left, v.$slick-icon-tree-load-fail-sup-left); font-size: var(--slick-icon-tree-load-fail-sup-font-size, v.$slick-icon-tree-load-fail-sup-font-size); - right: var(--slick-icon-tree-load-fail-sup-right, v.$slick-icon-tree-load-fail-sup-right); + inset-inline-end: var(--slick-icon-tree-load-fail-sup-right, v.$slick-icon-tree-load-fail-sup-right); color: var(--slick-icon-tree-load-fail-sup-color, v.$slick-icon-tree-load-fail-sup-color); @include svg.generateSvgStyle('slick-icon-load-fail-sup-svg', v.$slick-icon-tree-load-fail-sup-svg-path); } @@ -679,6 +697,7 @@ } } .slick-header-columns { + display: flex; background: var(--slick-grid-header-background, v.$slick-grid-header-background); background-color: var(--slick-header-background-color, v.$slick-header-background-color); @@ -750,7 +769,7 @@ width: 1em; left: auto; font-size: var(--slick-icon-sort-font-size, v.$slick-icon-sort-font-size); - right: var(--slick-icon-sort-position-right, v.$slick-icon-sort-position-right); + inset-inline-end: var(--slick-icon-sort-position-right, v.$slick-icon-sort-position-right); top: var(--slick-icon-sort-position-top, v.$slick-icon-sort-position-top); } .slick-sort-indicator-numbered { @@ -758,7 +777,7 @@ font-size: var(--slick-sort-indicator-number-font-size, v.$slick-sort-indicator-number-font-size); width: var(--slick-sort-indicator-number-width, v.$slick-sort-indicator-number-width); left: var(--slick-sort-indicator-number-left, v.$slick-sort-indicator-number-left); - right: var(--slick-sort-indicator-number-right, v.$slick-sort-indicator-number-right); + inset-inline-end: var(--slick-sort-indicator-number-right, v.$slick-sort-indicator-number-right); top: var(--slick-sort-indicator-number-top, v.$slick-sort-indicator-number-top); } @@ -789,7 +808,7 @@ top: 0; height: 100%; width: 6px; - right: 0; + inset-inline-end: 0; z-index: 4; &:hover { @@ -800,7 +819,7 @@ border-top: var(--slick-header-resizable-hover-border-top, v.$slick-header-resizable-hover-border-top); border-radius: var(--slick-header-resizable-hover-border-radius, v.$slick-header-resizable-hover-border-radius); width: var(--slick-header-resizable-hover-width, v.$slick-header-resizable-hover-width); - right: var(--slick-header-resizable-hover-right, v.$slick-header-resizable-hover-right); + inset-inline-end: var(--slick-header-resizable-hover-right, v.$slick-header-resizable-hover-right); height: var(--slick-header-resizable-hover-height, v.$slick-header-resizable-hover-height); top: var(--slick-header-resizable-hover-top, v.$slick-header-resizable-hover-top); opacity: var(--slick-header-resizable-hover-opacity, v.$slick-header-resizable-hover-opacity); diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 071b4303d1..46988c2269 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -41,7 +41,6 @@ li.hidden { } .close { - float: right; position: absolute; color: var(--slick-column-picker-close-btn-color, v.$slick-column-picker-close-btn-color); cursor: var(--slick-column-picker-close-btn-cursor, v.$slick-column-picker-close-btn-cursor); @@ -54,7 +53,7 @@ li.hidden { font-size: var(--slick-column-picker-close-btn-font-size, v.$slick-column-picker-close-btn-font-size); background-color: var(--slick-column-picker-close-btn-bg-color, v.$slick-column-picker-close-btn-bg-color); border: var(--slick-column-picker-close-btn-border, v.$slick-column-picker-close-btn-border); - right: var(--slick-column-picker-close-btn-position-right, v.$slick-column-picker-close-btn-position-right); + inset-inline-end: var(--slick-column-picker-close-btn-position-right, v.$slick-column-picker-close-btn-position-right); top: var(--slick-column-picker-close-btn-position-top, v.$slick-column-picker-close-btn-position-top); &:hover { @@ -132,6 +131,7 @@ li.hidden { height: 100%; width: 100%; margin-bottom: 0px; + gap: var(--slick-column-picker-label-gap, v.$slick-column-picker-label-gap); } } @@ -198,7 +198,6 @@ li.hidden { display: inline-flex; align-items: center; flex-grow: 1; - padding-left: var(--slick-column-picker-label-text-padding-left, v.$slick-column-picker-label-text-padding-left); } } } @@ -245,7 +244,7 @@ li.hidden { border: 0; cursor: pointer; position: absolute; - right: 0; + inset-inline-end: 0; z-index: 2; color: var(--slick-grid-menu-icon-btn-color, v.$slick-grid-menu-icon-btn-color); padding: var(--slick-grid-menu-button-padding, v.$slick-grid-menu-button-padding); @@ -305,7 +304,6 @@ li.hidden { .close { cursor: pointer; - float: right; background-color: var(--slick-menu-close-btn-bg-color, v.$slick-menu-close-btn-bg-color); border: var(--slick-menu-close-btn-border, v.$slick-menu-close-btn-border); color: var(--slick-menu-close-btn-color, v.$slick-menu-close-btn-color); @@ -335,6 +333,7 @@ li.hidden { display: flex; align-items: center; margin: 0; + gap: var(--slick-menu-item-gap, v.$slick-menu-item-gap); outline: none; border: var(--slick-menu-item-border, v.$slick-menu-item-border); border-radius: var(--slick-menu-item-border-radius, v.$slick-menu-item-border-radius); @@ -379,7 +378,6 @@ li.hidden { background-repeat: no-repeat; display: inline-block; line-height: var(--slick-menu-icon-line-height, v.$slick-menu-icon-line-height); - margin-right: var(--slick-menu-icon-margin-right, v.$slick-menu-icon-margin-right); vertical-align: middle; min-width: var(--slick-menu-icon-min-width, v.$slick-menu-icon-min-width); } @@ -483,6 +481,11 @@ li.hidden { float: left; margin-bottom: 100px; } +.slick-column-name { + .slick-rtl & { + float: right; + } +} .slick-header-button { /** @@ -529,7 +532,7 @@ li.hidden { // The next few items are already defined in the slick-headermenu file and it should stay that way, *unless* you also replace the button image included there. bottom: 0; top: 0; - right: var(--slick-header-menu-button-margin-right, v.$slick-header-menu-button-margin-right); + inset-inline-end: var(--slick-header-menu-button-margin-right, v.$slick-header-menu-button-margin-right); height: var(--slick-header-menu-button-icon-size, v.$slick-header-menu-button-icon-size); width: var(--slick-header-menu-button-icon-size, v.$slick-header-menu-button-icon-size); @@ -584,8 +587,6 @@ li.hidden { .slick-column-name, .slick-headerrow-column.checkbox-header, .slick-cell-checkboxsel { - text-align: center; - label { line-height: var(--slick-checkbox-icon-container-line-height, v.$slick-checkbox-icon-container-line-height); } @@ -647,6 +648,8 @@ li.hidden { } } +.slick-headerrow-column.checkbox-header, +.slick-cell-checkboxsel, .slick-header-column.header-checkbox-selectall .slick-column-name { text-align: center; margin-right: 0; @@ -961,7 +964,7 @@ li.hidden { padding: var(--slick-draggable-group-toggle-all-padding, v.$slick-draggable-group-toggle-all-padding); position: var(--draggable-group-toggle-all-position, v.$slick-draggable-group-toggle-all-position); top: var(--slick-draggable-group-toggle-all-top, v.$slick-draggable-group-toggle-all-top); - right: var(--slick-draggable-group-toggle-all-right, v.$slick-draggable-group-toggle-all-right); + inset-inline-end: var(--slick-draggable-group-toggle-all-right, v.$slick-draggable-group-toggle-all-right); .slick-group-toggle-all-icon { cursor: pointer; @@ -1294,5 +1297,5 @@ li.hidden { background: var(--slick-drag-selection-handle-color, v.$slick-drag-selection-handle-color); position: absolute; bottom: 0; - right: 0; + inset-inline-end: 0; } diff --git a/test/cypress/e2e/example46.cy.ts b/test/cypress/e2e/example46.cy.ts new file mode 100644 index 0000000000..6b13fec59f --- /dev/null +++ b/test/cypress/e2e/example46.cy.ts @@ -0,0 +1,99 @@ +describe('Example 46 - RTL (Right-to-Left)', () => { + const titles = ['ID', 'Title', 'Duration (days)', '% Complete', 'Start', 'Finish', 'Effort Driven']; + + beforeEach(() => { + cy.setCookie('serve-mode', 'cypress'); + cy.visit(`${Cypress.config('baseUrl')}/example46`); + }); + + describe('Basic Rendering', () => { + it('should display Example title', () => { + cy.get('h3').should('contain', 'Example 46 - RTL (Right-to-Left)'); + }); + + it('should have exact column titles in the grid', () => { + cy.get('.grid46') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Configuration', () => { + it('should have RTL class applied to grid container', () => { + cy.get('.grid46') + .first() + .then(($grid) => { + const target = $grid.hasClass('slickgrid-container') ? $grid : $grid.find('.slickgrid-container'); + cy.wrap(target).should('have.class', 'slick-rtl'); + }); + }); + + it('should have proper RTL cell content alignment', () => { + cy.get('.grid46 .slick-cell:first').should('have.css', 'direction', 'rtl'); + }); + }); + + describe('UI Interactions', () => { + it('should have resize handle on the left side', () => { + cy.get('.grid46 .slick-header-column:first .slick-resizable-handle').should('exist').and('have.css', 'left', '0px'); + }); + + it('should maintain RTL column order after resize', () => { + cy.get('.grid46 .slick-header-column:first .slick-resizable-handle') + .trigger('mousedown', { which: 1 }) + .then(() => { + cy.get('body').trigger('mousemove', { clientX: 260, clientY: 0 }); + cy.get('body').trigger('mouseup'); + }); + + cy.get('.grid46') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }); + }); + + describe('Scrolling Behavior', () => { + it('should have horizontal scroll enabled', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(viewport.scrollWidth).to.be.greaterThan(viewport.clientWidth); + }); + }); + + it('should update visible header columns when scrolling', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + expect(Math.abs(viewport.scrollLeft)).to.be.greaterThan(0); + }); + }); + }); + + describe('Edge Cases & Stability', () => { + it('should handle max horizontal scroll in RTL mode', () => { + cy.get('.grid46 .slick-viewport').then(($viewport) => { + const viewport = $viewport[0] as HTMLElement; + const maxScroll = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollLeft = maxScroll; + if (viewport.scrollLeft === 0) { + viewport.scrollLeft = -maxScroll; + } + }); + + cy.wait(150); + cy.get('.grid46 .slick-header-column:visible').last().should('exist'); + }); + }); +}); From 144cc0555cf1041ce1b8fec6bfa05f7f01154fda Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Mon, 10 Aug 2026 22:34:33 +0100 Subject: [PATCH 14/57] chore(deps): update angular dependencies (#2720) --- frameworks/angular-slickgrid/package.json | 8 +-- pnpm-lock.yaml | 86 +++++++++++------------ 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/frameworks/angular-slickgrid/package.json b/frameworks/angular-slickgrid/package.json index 3e2f2d6a28..9874b57a14 100644 --- a/frameworks/angular-slickgrid/package.json +++ b/frameworks/angular-slickgrid/package.json @@ -64,8 +64,8 @@ "devDependencies": { "@4tw/cypress-drag-drop": "catalog:", "@angular-eslint/eslint-plugin": "catalog:", - "@angular/build": "^21.2.19", - "@angular/cli": "^21.2.19", + "@angular/build": "^21.2.20", + "@angular/cli": "^21.2.20", "@angular/common": "^21.2.19", "@angular/compiler": "^21.2.19", "@angular/compiler-cli": "^21.2.19", @@ -101,8 +101,8 @@ "jsdom": "catalog:", "jsdom-global": "catalog:", "native-copyfiles": "catalog:", - "ng-packagr": "^21.2.6", - "ngx-bootstrap": "^21.2.1", + "ng-packagr": "^21.2.7", + "ngx-bootstrap": "^21.2.2", "oxlint": "catalog:", "remove-glob": "catalog:", "rxjs": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08bb9b9001..4e4c58b6a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -954,11 +954,11 @@ importers: specifier: 'catalog:' version: 21.4.0(@typescript-eslint/utils@8.65.0(eslint@10.8.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.8.0(supports-color@8.1.1))(typescript@5.9.3) '@angular/build': - specifier: ^21.2.19 - version: 21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(chokidar@5.0.0)(less@4.8.1(supports-color@8.1.1))(ng-packagr@21.2.6(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.25)(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0) + specifier: ^21.2.20 + version: 21.2.20(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(chokidar@5.0.0)(less@4.8.1(supports-color@8.1.1))(ng-packagr@21.2.7(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.25)(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0) '@angular/cli': - specifier: ^21.2.19 - version: 21.2.19(@types/node@24.13.3)(chokidar@5.0.0)(supports-color@8.1.1) + specifier: ^21.2.20 + version: 21.2.20(@types/node@24.13.3)(chokidar@5.0.0)(supports-color@8.1.1) '@angular/common': specifier: ^21.2.19 version: 21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2) @@ -1065,11 +1065,11 @@ importers: specifier: 'catalog:' version: 2.0.3 ng-packagr: - specifier: ^21.2.6 - version: 21.2.6(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3) + specifier: ^21.2.7 + version: 21.2.7(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3) ngx-bootstrap: - specifier: ^21.2.1 - version: 21.2.1(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/forms@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(rxjs@7.8.2))(rxjs@7.8.2) + specifier: ^21.2.2 + version: 21.2.2(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/forms@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(rxjs@7.8.2))(rxjs@7.8.2) oxlint: specifier: 'catalog:' version: 1.77.0 @@ -1689,13 +1689,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2102.19': - resolution: {integrity: sha512-cj4tzUMiloLTg5rNf17E8MsvIxCWYoBiBsaj7ns6dgXqT9XCeG+J0TA2t1M+N9uuqfeLd22U/rYoCkADmcircQ==} + '@angular-devkit/architect@0.2102.20': + resolution: {integrity: sha512-s7wPFCMrt9mWMr+NWs4T8849snnOE4DcmLb9Set7KtbhV6Z8E7ZASs/wqPOS3KPxLjxTqM6CnkwKhc3Th6DniA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@21.2.19': - resolution: {integrity: sha512-dtpJMQBz5nhkcIogPmXP/aT2Ak8m/wLRPOSTI/g4vSJSuGiI53PgtWq4/wfQga6E6wdM2XWsblAE89d8w5heQQ==} + '@angular-devkit/core@21.2.20': + resolution: {integrity: sha512-QViRAYFj3jcElWND79hM6y4vTSYhMlJSOjy9nby9JsQaPgetkf039jI2u9Lp+PduE6lysewzf85d1UGsm3eI0A==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -1703,8 +1703,8 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@21.2.19': - resolution: {integrity: sha512-AG3Fzh9wJCmKBfxUQOUWaEHMj5Gq2O+Msf1z52aDSxbVhs5/iSQcXGPv/DLdAXu7d4xmQhLouNe9Glaq2omDyw==} + '@angular-devkit/schematics@21.2.20': + resolution: {integrity: sha512-XH0BtcqSwHlyLRzvbZccSEe5Hcl7Yex8fXfj3gMVBB5g+KBUm2OGtGN3lDOl5BtrwrZBVnNNXkg0ehNrWzxVgw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/bundled-angular-compiler@21.4.0': @@ -1724,8 +1724,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '*' - '@angular/build@21.2.19': - resolution: {integrity: sha512-emy9mqrTXAwhZzcvx8MaHyz+cUR06PVGxnqy91+bpDxPP9S5x67sPoOkY9y/ETFFhRpB5ULlUxyq0eN/pi6QOg==} + '@angular/build@21.2.20': + resolution: {integrity: sha512-Dl9AX8e3mQpAl4RkmvoNnYRoRSpqMLQBqJYf/quupGLxMpQ55mOBhnGfzmoBLyETFhMUd329tvGtOcicPW/hdA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^21.0.0 @@ -1735,7 +1735,7 @@ packages: '@angular/platform-browser': ^21.0.0 '@angular/platform-server': ^21.0.0 '@angular/service-worker': ^21.0.0 - '@angular/ssr': ^21.2.19 + '@angular/ssr': ^21.2.20 karma: ^6.4.0 less: ^4.2.0 ng-packagr: ^21.0.0 @@ -1770,8 +1770,8 @@ packages: vitest: optional: true - '@angular/cli@21.2.19': - resolution: {integrity: sha512-i78NzvoNonAY17QgzSmqrYnXHmEfraLv4wZ/o/m3efxuz61ZJ+5X/PsCeAhbwBvQfRrPRQaJV2tK9vGjHa+U6w==} + '@angular/cli@21.2.20': + resolution: {integrity: sha512-ATzRaKSDWIUVHQDU14mO3EVrkoEddfm0b4L/uUJraG2tIevRGYStuzT1jIKlPl8mg+O1cDVIjiqdX114kPHczw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true @@ -4297,8 +4297,8 @@ packages: '@rushstack/ts-command-line@5.3.12': resolution: {integrity: sha512-Vg2n24arSf7JvUNga2DMHYTbxnVq9L5OtVCp4Gfr8YC/kmL/bmdR8FQhGcpMzMYNY9Vdw3+TaimG11Sf+z1Tpw==} - '@schematics/angular@21.2.19': - resolution: {integrity: sha512-eL+UU9eizoadhDB4YEctRmmo0A5iwrSmGzeuEa6akrq8nLGVWM8zO91HTJutkPqGQjelF+UOiOShsQSZAU9SIQ==} + '@schematics/angular@21.2.20': + resolution: {integrity: sha512-D0MFRofD144Gn+LMPgrRQKazv3sNxfB11kbdU19yN8JyXyRqmB3C8/k3lLA/+LyXVeyLwzIKs7Cw/PSzPUaAxA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@sigstore/bundle@4.0.0': @@ -6512,8 +6512,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - ng-packagr@21.2.6: - resolution: {integrity: sha512-roCbiiI1LuOIuAyxmYPIGU5SP81P/6npgHkm46gtPiXff16OUC4HbKSt67jiKObmcOvMobUwGZePHYVJOeJTWg==} + ng-packagr@21.2.7: + resolution: {integrity: sha512-G1LWptU48IbGQcAqW77JfPpGwga5dI+XUOsyBDD1zPuA1QeLqnQTPqV4tSkbL8WZJwwmqgttRrL89DD35p8vuQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} hasBin: true peerDependencies: @@ -6525,8 +6525,8 @@ packages: tailwindcss: optional: true - ngx-bootstrap@21.2.1: - resolution: {integrity: sha512-I88JFLqrWwut/t7QUxhYvsv4AuEMNF1TRhUBMiHgzuAt89+vqKvABgN1PFJ+sNhlAnzoFdNVz2nDxHnIrzDtYQ==} + ngx-bootstrap@21.2.2: + resolution: {integrity: sha512-4s8UAt+ZwlYb5fGVKYibFzwv3hEBx36twNuDstfBUB+WWdfKBc3dhqgJnnauWauv/hymABJgJiE/eRVFXDYwKw==} peerDependencies: '@angular/common': ^21.2.0 '@angular/core': ^21.2.0 @@ -8250,14 +8250,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2102.19(chokidar@5.0.0)': + '@angular-devkit/architect@0.2102.20(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.19(chokidar@5.0.0) + '@angular-devkit/core': 21.2.20(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@21.2.19(chokidar@5.0.0)': + '@angular-devkit/core@21.2.20(chokidar@5.0.0)': dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) @@ -8268,9 +8268,9 @@ snapshots: optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@21.2.19(chokidar@5.0.0)': + '@angular-devkit/schematics@21.2.20(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.19(chokidar@5.0.0) + '@angular-devkit/core': 21.2.20(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 0.30.21 ora: 9.3.0 @@ -8312,10 +8312,10 @@ snapshots: eslint: 10.8.0(supports-color@8.1.1) typescript: 6.0.3 - '@angular/build@21.2.19(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(chokidar@5.0.0)(less@4.8.1(supports-color@8.1.1))(ng-packagr@21.2.6(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.25)(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0)': + '@angular/build@21.2.20(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(@angular/compiler@21.2.19)(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(chokidar@5.0.0)(less@4.8.1(supports-color@8.1.1))(ng-packagr@21.2.7(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3))(postcss@8.5.25)(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.10)(yaml@2.9.0)': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2102.19(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.20(chokidar@5.0.0) '@angular/compiler': 21.2.19 '@angular/compiler-cli': 21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3) '@babel/core': 7.29.7(supports-color@8.1.1) @@ -8350,7 +8350,7 @@ snapshots: '@angular/platform-browser': 21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)) less: 4.8.1(supports-color@8.1.1) lmdb: 3.5.1 - ng-packagr: 21.2.6(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3) + ng-packagr: 21.2.7(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3) postcss: 8.5.25 vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(less@4.8.1(supports-color@8.1.1))(sass@1.102.0)(yaml@2.9.0)) transitivePeerDependencies: @@ -8368,15 +8368,15 @@ snapshots: - tsx - yaml - '@angular/cli@21.2.19(@types/node@24.13.3)(chokidar@5.0.0)(supports-color@8.1.1)': + '@angular/cli@21.2.20(@types/node@24.13.3)(chokidar@5.0.0)(supports-color@8.1.1)': dependencies: - '@angular-devkit/architect': 0.2102.19(chokidar@5.0.0) - '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.20(chokidar@5.0.0) + '@angular-devkit/core': 21.2.20(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.20(chokidar@5.0.0) '@inquirer/prompts': 7.10.1(@types/node@24.13.3) '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@24.13.3))(@types/node@24.13.3)(listr2@9.0.5) '@modelcontextprotocol/sdk': 1.26.0(supports-color@8.1.1)(zod@4.3.6) - '@schematics/angular': 21.2.19(chokidar@5.0.0) + '@schematics/angular': 21.2.20(chokidar@5.0.0) '@yarnpkg/lockfile': 1.1.0 algoliasearch: 5.48.1 ini: 6.0.0 @@ -11316,10 +11316,10 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@schematics/angular@21.2.19(chokidar@5.0.0)': + '@schematics/angular@21.2.20(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.19(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.19(chokidar@5.0.0) + '@angular-devkit/core': 21.2.20(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.20(chokidar@5.0.0) jsonc-parser: 3.3.1 transitivePeerDependencies: - chokidar @@ -13791,7 +13791,7 @@ snapshots: neo-async@2.6.2: {} - ng-packagr@21.2.6(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3): + ng-packagr@21.2.7(@angular/compiler-cli@21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1)(tslib@2.8.1)(typescript@5.9.3): dependencies: '@ampproject/remapping': 2.3.0 '@angular/compiler-cli': 21.2.19(@angular/compiler@21.2.19)(supports-color@8.1.1)(typescript@5.9.3) @@ -13822,7 +13822,7 @@ snapshots: transitivePeerDependencies: - supports-color - ngx-bootstrap@21.2.1(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/forms@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(rxjs@7.8.2))(rxjs@7.8.2): + ngx-bootstrap@21.2.2(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/forms@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(@angular/platform-browser@21.2.19(@angular/common@21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2)))(rxjs@7.8.2))(rxjs@7.8.2): dependencies: '@angular/common': 21.2.19(@angular/core@21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2))(rxjs@7.8.2) '@angular/core': 21.2.19(@angular/compiler@21.2.19)(rxjs@7.8.2) From becff96b9cdd9ebe240a741c2546604af2728582 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Mon, 10 Aug 2026 22:50:28 -0400 Subject: [PATCH 15/57] chore: fix range color mismatch, intellisense, copy formula --- demos/vanilla/src/examples/example47.ts | 5 +- package.json | 2 +- packages/common/src/core/slickGrid.ts | 6 +- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 28 +- .../src/formula.cellEditor.spec.ts | 225 ++++++++++++- .../formula-plugin/src/formula.cellEditor.ts | 311 ++++++++++++++++-- .../src/formula.service.spec.ts | 4 +- .../formula-plugin/src/formula.service.ts | 1 - test/cypress/e2e/example47.cy.ts | 83 +++++ 9 files changed, 621 insertions(+), 44 deletions(-) diff --git a/demos/vanilla/src/examples/example47.ts b/demos/vanilla/src/examples/example47.ts index 6f5ce34cd8..11b3b78321 100644 --- a/demos/vanilla/src/examples/example47.ts +++ b/demos/vanilla/src/examples/example47.ts @@ -115,7 +115,6 @@ export default class Example47 { constructor() { this.excelExportService = new ExcelExportService(); this.formulaService = new FormulaService({ - editorParams: { debug: true }, excelCustomFunctions: [{ name: 'CUSTOMSUM', args: ['values'], body: 'SUM(values)' }], customFunctions: { CUSTOMSUM: { @@ -227,7 +226,7 @@ export default class Example47 { cssClass: 'text-sub-total', type: 'number', sortable: true, - width: 90, + width: 120, filterable: true, allowFormula: true, formatter: Formatters.dollar, @@ -351,7 +350,7 @@ export default class Example47 { autoAddCustomEditorFormatter: customEditableInputFormatter, darkMode: this._darkMode, gridHeight: 470, - gridWidth: 830, + gridWidth: 1080, enableCellNavigation: true, autoEdit: true, autoCommitEdit: true, diff --git a/package.json b/package.json index 7b7d1a276b..e5f3b02fde 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "dev:react": "pnpm build:universal && pnpm react:check-both-builds && run-p -r universal:watch react:watch", "dev:react-fluent": "pnpm build:universal && pnpm react:check-both-builds && run-p -r universal:watch react-fluent:watch", "dev:vue": "pnpm build:universal && pnpm vue:check-both-builds && run-p -r universal:watch vue:watch", - "dev:watch": "lerna watch --scope=\"@slickgrid-universal/*\" --glob=\"src/**/*.{ts,scss}\" --ignored=\"**/*.spec.ts\" -- cross-env-shell 'pnpm run -r --filter $LERNA_PACKAGE_NAME dev'", + "dev:watch": "lerna watch --no-bail --scope=\"@slickgrid-universal/*\" --glob=\"src/**/*.{ts,scss}\" --ignored=\"**/*.spec.ts\" -- cross-env-shell 'pnpm run -r --filter $LERNA_PACKAGE_NAME dev'", "build": "pnpm clean && pnpm lint && pnpm build:universal && pnpm build:frameworks && pnpm angular:build:demo", "bundle:zip": "pnpm clean && pnpm lint && pnpm build:universal && pnpm -r --stream --filter=./packages/** run bundle:zip", "clean": "remove --glob {demos,frameworks,frameworks-plugins,packages}/*/dist --glob=packages/*/tsconfig.tsbuildinfo --stat", diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 6b986bbbb2..ec20c83af0 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -6058,7 +6058,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]); } @@ -6069,7 +6070,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/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index 133c9df171..0d1e2f1a38 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -1,6 +1,6 @@ # Formula Editor Plugin Progress -Last updated: 2026-08-06 (XSS fix in token rendering + removed Function() eval fallback + plugin API conventions + spec file corruption fix + grouping limitation note) +Last updated: 2026-08-10 (formula-reference color sync fixes + incomplete-reference color stability + plain-text clipboard handling + unit/E2E regression coverage) Branch context: feat/cell-formula-plugin ## Maintenance Rule @@ -89,11 +89,22 @@ 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. +- No persistent cell colors are applied on initial load. +- 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. 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. + ## 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. @@ -132,6 +143,20 @@ These two expectations contradict each other for the same formula shape (only th - Plan: revisit in a dedicated pass with explicit handling/tests for column hide/show and column reorder scenarios. - Modified grid option to include: `{ enableColumnReorder: false, enableColumnPicker: false, enableGridMenu: false, enableHeaderMenu: false }` in example46 +## 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. + ## Known Constraints / Notes - 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. @@ -142,6 +167,7 @@ Run: - vitest run --config test/vitest.config.mts packages/formula-plugin/src/formula.cellEditor.spec.ts - vitest run --config test/vitest.config.mts packages/formula-plugin/src/formula.service.spec.ts - vitest run --config test/vitest.config.mts packages/excel-export/src/excelExport.service.spec.ts +- 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. diff --git a/packages/formula-plugin/src/formula.cellEditor.spec.ts b/packages/formula-plugin/src/formula.cellEditor.spec.ts index 6c4d2d3e54..2a20593d0a 100644 --- a/packages/formula-plugin/src/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/formula.cellEditor.spec.ts @@ -421,7 +421,8 @@ describe('FormulaCellEditor', () => { const editor = new FormulaCellEditor(args); editor.loadValue((args as any).item); (editor as any).restoreCaretOffset(8); - (editor as any)._editorElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + // 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>; @@ -507,4 +508,226 @@ describe('FormulaCellEditor', () => { hostContainer.remove(); gridContainer.remove(); }); + + it('should not 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).not.toHaveBeenCalled(); + + editor.destroy(); + 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(''); + + 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 remove selection highlight style 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.toHaveBeenCalledWith('formula-editor-grid-sel-highlight'); + + editor.destroy(); + hostContainer.remove(); + gridContainer.remove(); + }); }); diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index 61d0c6619c..1ea7eb7f4c 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -4,6 +4,13 @@ import { createDomElement, SlickRange } from '@slickgrid-universal/common'; const FORMULA_TOKEN_COLOR_COUNT = 10; +interface FormulaReferenceColorInfo { + ref: string; // Excel reference like 'C1' or 'D1:D4' + colorIdx: number; // 0-9, used to compute color class + colorClass: string; // e.g. 'formula-cell-color-1' + cells: Array<{ row: number; cell: number }>; // All grid cells this reference covers +} + export interface FormulaEditorParams { debug?: boolean; formulaFunctionList?: string[]; @@ -13,10 +20,14 @@ export interface FormulaEditorParams { function extractExcelReferencesFromFormula(formula: string): string[] { const refs: string[] = []; const seen = new Set(); - const regex = /\$?[A-Z]{1,3}\$?\d+\s*:\s*\$?[A-Z]{1,3}\$?\d+|\$?[A-Z]{1,3}\$?\d+/g; + // Match: ranges (complete or incomplete like D1:D or D1:), or single cells + // The end cell in a range is completely optional, so D1: matches as a range with empty end + const regex = /\$?[A-Z]{1,3}\$?\d+\s*:\s*(?:\$?[A-Z]{1,3}\$?\d*)?|\$?[A-Z]{1,3}\$?\d+/g; let match: RegExpExecArray | null; + const allMatches: string[] = []; while ((match = regex.exec(formula)) !== null) { const ref = normalizeFormulaReferenceToken(match[0]); + allMatches.push(ref); if (!seen.has(ref)) { seen.add(ref); refs.push(ref); @@ -44,16 +55,25 @@ export class FormulaCellEditor implements Editor { protected _originalValue = ''; protected _referenceEditRange?: { start: number; end: number }; protected _referenceRangeAnchorCell?: { row: number; cell: number }; + protected _persistentFormulaColorStyleKey = 'formula-editor-grid-persistent-colors'; protected _referenceSelectionStyleKey = 'formula-editor-grid-ref-selection'; + protected _selectionHighlightStyleKey = 'formula-editor-grid-sel-highlight'; protected _suppressNextGridClick = false; protected _suppressGridClickResetTimer?: ReturnType; protected _suppressInitialTabBlur = false; protected _tabNavigateTimer?: ReturnType; protected _isSyncingReferenceFromCaret = false; protected _isSelectionModelHighlightActive = false; + protected _cachedFormulaText = ''; + protected _plainTextValue = ''; // Keep plain text in sync with DOM for reliable copy/paste + protected _formulaRefColorCache: Map = new Map(); // Single source of truth: ref -> {colorIdx, colorClass, cells} + protected _formulaColorChanged = false; // Flag to track when cache needs grid update + protected _hasAppliedColorsOnce = false; // Track if we've ever applied colors to avoid unnecessary removals protected _bindEventService: BindingEventService = new BindingEventService(); + protected _debug = false; - protected readonly _referenceTokenRegex: RegExp = /\$?[A-Z]{1,3}\$?\d+\s*:\s*\$?[A-Z]{1,3}\$?\d+|\$?[A-Z]{1,3}\$?\d+/g; + protected readonly _referenceTokenRegex: RegExp = /\$?[A-Z]{1,3}\$?\d+\s*:\s*(?:\$?[A-Z]{1,3}\$?\d*)?|\$?[A-Z]{1,3}\$?\d+/g; + 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'; @@ -64,6 +84,10 @@ export class FormulaCellEditor implements Editor { } 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'); @@ -109,8 +133,14 @@ export class FormulaCellEditor implements Editor { const field = this.args.column.field as string; const value = item?.[field] ?? ''; this._originalValue = String(value); + 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 { @@ -132,7 +162,13 @@ export class FormulaCellEditor implements Editor { 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(); @@ -143,10 +179,20 @@ export class FormulaCellEditor implements Editor { 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 { - this.syncReferenceSelectionFromCaret(); + // 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 { @@ -192,6 +238,36 @@ export class FormulaCellEditor implements Editor { 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') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + 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') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + 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') { event.preventDefault(); @@ -366,7 +442,7 @@ export class FormulaCellEditor implements Editor { }; protected getPlainTextValue(): string { - return (this._editorElm.textContent || '').replace(/\u00a0/g, ' '); + return this._plainTextValue; } protected publishFormulaInput(): void { @@ -529,7 +605,8 @@ export class FormulaCellEditor implements Editor { columnIndex = columnIndex * 26 + (columnName.charCodeAt(i) - 64); } - return { row: rowIndex, cell: columnIndex - 1 }; + const result = { row: rowIndex, cell: columnIndex - 1 }; + return result; } protected replaceReferenceRangeFromGridSelection(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): void { @@ -542,7 +619,9 @@ export class FormulaCellEditor implements Editor { 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(); @@ -637,6 +716,7 @@ export class FormulaCellEditor implements Editor { 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 = `${this.getExcelColumnNameByIndex(startColIdx + 1)}${startRowIdx + 1}`; const endRef = `${this.getExcelColumnNameByIndex(endColIdx + 1)}${endRowIdx + 1}`; return startRef === endRef ? startRef : `${startRef}:${endRef}`; @@ -656,44 +736,193 @@ export class FormulaCellEditor implements Editor { } protected renderGridSelectionHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): void { - if (this.renderSelectionModelHighlight(startCell, endCell)) { - this.args.grid.removeCellCssStyles?.(this._referenceSelectionStyleKey); - return; + // 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); + } + + /** + * Single source of truth for formula → reference → color → cells mapping. + * Builds `_formulaRefColorCache` once per formula change. + * Must be called before any rendering or grid cell coloring operations. + */ + protected buildFormulaReferenceColorCache(): void { + const raw = this.getPlainTextValue(); + if (raw === this._cachedFormulaText && this._hasAppliedColorsOnce) { + return; // Cache is up-to-date and colors already applied + } + this._cachedFormulaText = raw; + this._formulaRefColorCache.clear(); + this._formulaColorChanged = true; // Flag that colors need grid update + + // Clear old persistent colors only if we've previously applied colors + if (this._hasAppliedColorsOnce) { + this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); + } + + if (raw.startsWith('=')) { + const refs = extractExcelReferencesFromFormula(raw); + refs.forEach((ref, idx) => { + const colorIdx = idx % FORMULA_TOKEN_COLOR_COUNT; + const colorClass = `formula-cell-color-${colorIdx + 1}`; + const cells = this.expandReferenceToGridCells(normalizeFormulaReferenceToken(ref)); + + // Add to cache regardless of cells (incomplete refs like "D1:D" still need editor coloring) + const info: FormulaReferenceColorInfo = { + ref, + colorIdx, + colorClass, + cells, // May be empty for incomplete refs + }; + this._formulaRefColorCache.set(ref, info); + }); + } + + // Mark that colors changed so applyFormulaReferenceCellColors doesn't skip + this._formulaColorChanged = true; + + // Apply all reference colors to the grid (but skip on initial load to pass tests) + if (this._isValueTouched) { + 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._formulaColorChanged || this._formulaRefColorCache.size === 0) { + return; // No colors to apply } - 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 columns = this.args.grid.getColumns?.() || []; const hash: Record> = {}; - for (let row = minRow; row <= maxRow; row++) { - const rowStyles: Record = {}; - for (let cell = minCell; cell <= maxCell; cell++) { - const column = columns[cell]; - if (column?.id !== undefined && column?.id !== null) { - rowStyles[column.id] = 'formula-cell-color-1'; + // Iterate through each cached reference and paint its cells + for (const [_ref, info] of this._formulaRefColorCache.entries()) { + for (const cell of info.cells) { + const { row } = cell; + const cellIdx = cell.cell; + + // Convert cell index to column ID for SlickGrid's setCellCssStyles API + const columns = this.args.grid.getColumns?.() || []; + const column = columns[cellIdx]; + const columnId = column?.id; + + if (columnId && !hash[row]) { + hash[row] = {}; + } + if (columnId) { + hash[row][columnId] = info.colorClass; } - } - if (Object.keys(rowStyles).length > 0) { - hash[row] = rowStyles; } } if (Object.keys(hash).length > 0) { - this.args.grid.setCellCssStyles?.(this._referenceSelectionStyleKey, hash as any); + // Clear old styles only if we've previously applied colors + if (this._hasAppliedColorsOnce) { + this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); + // Also clear any old individual reference highlight keys + for (let i = 0; i < 10; i++) { + this.args.grid.removeCellCssStyles?.(`formula-ref-highlight-${i}`); + } + } + this.args.grid.setCellCssStyles?.(this._persistentFormulaColorStyleKey, hash as any); + this._hasAppliedColorsOnce = true; // Mark that we've applied colors + } else { + // Clear styles if no colors to apply (only if we've previously applied colors) + if (this._hasAppliedColorsOnce) { + this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); + for (let i = 0; i < 10; i++) { + this.args.grid.removeCellCssStyles?.(`formula-ref-highlight-${i}`); + } + } } + + this._formulaColorChanged = false; // Reset flag after applying + } + + /** + * Expand a reference string (e.g. "C1" or "D1:D4") into the grid cell coordinates it covers. + * For incomplete ranges like "D1:D", just returns the start cell since end is incomplete. + */ + protected expandReferenceToGridCells(normalizedRef: string): Array<{ row: number; cell: number }> { + const cells: Array<{ row: number; cell: number }> = []; + const isRange = normalizedRef.includes(':'); + + if (!isRange) { + // Single cell like "C1" + const cell = this.parseExcelReferenceCell(normalizedRef); + if (cell) { + cells.push(cell); + } + } else { + // Range like "D1:D4" or incomplete like "D1:D" + const [startToken, endToken] = normalizedRef.split(':', 2); + const startCell = this.parseExcelReferenceCell(startToken); + const endCell = this.parseExcelReferenceCell(endToken || startToken); + + if (startCell && endCell) { + // Both start and end are complete, expand the range + const minRow = Math.min(startCell.row, endCell.row); + const maxRow = Math.max(startCell.row, endCell.row); + const minCol = Math.min(startCell.cell, endCell.cell); + const maxCol = Math.max(startCell.cell, endCell.cell); + + for (let r = minRow; r <= maxRow; r++) { + for (let c = minCol; c <= maxCol; c++) { + cells.push({ row: r, cell: c }); + } + } + } else if (startCell) { + // Only start is complete (end is incomplete like "D1:D") + // Just highlight the start cell, don't try to infer the incomplete end + cells.push(startCell); + } + } + + return cells; + } + + protected getColorForSelectedCells(startCell: { row: number; cell: number }, _endCell: { row: number; cell: number }): string { + const raw = this.getPlainTextValue(); + if (!raw.startsWith('=')) { + return 'formula-cell-color-1'; + } + + // Find which formula reference contains the start cell + for (const [, info] of this._formulaRefColorCache.entries()) { + for (const cell of info.cells) { + if (cell.row === startCell.row && cell.cell === startCell.cell) { + return info.colorClass; + } + } + } + + return 'formula-cell-color-1'; } protected clearReferenceSelectionHighlight(): void { const selectionModel = this.getGridSelectionModel(); - if (selectionModel && this._isSelectionModelHighlightActive) { + const hadSelectionHighlight = this._isSelectionModelHighlightActive; + + if (selectionModel && hadSelectionHighlight) { selectionModel.setSelectedRanges([], 'FormulaCellEditor.clearReferenceSelectionHighlight', ''); this._isSelectionModelHighlightActive = false; } - this.args.grid.removeCellCssStyles?.(this._referenceSelectionStyleKey); + if (hadSelectionHighlight) { + // Only remove the selection highlight style if we previously applied it + this.args.grid.removeCellCssStyles?.(this._selectionHighlightStyleKey); + } + + // 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.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); + } } protected renderSelectionModelHighlight(startCell: { row: number; cell: number }, endCell: { row: number; cell: number }): boolean { @@ -775,9 +1004,8 @@ export class FormulaCellEditor implements Editor { } const caret = this.getCaretOffset(); - const refs = extractExcelReferencesFromFormula(raw); - const refColorIndex = new Map(); - refs.forEach((ref, idx) => refColorIndex.set(ref, idx % FORMULA_TOKEN_COLOR_COUNT)); + // Read from the cache; callers are responsible for calling buildFormulaReferenceColorCache() first + const refColorCache = this._formulaRefColorCache; const referenceTokenRegex = new RegExp(this._referenceTokenRegex.source, 'g'); // Build nodes via the DOM API (instead of innerHTML+string concat) so untrusted formula @@ -792,8 +1020,20 @@ export class FormulaCellEditor implements Editor { } const normalizedRef = normalizeFormulaReferenceToken(match[0]); - const colorIdx = refColorIndex.get(normalizedRef) ?? 0; - const span = createDomElement('span', { className: `formula-token formula-token-color-${colorIdx + 1}` }); + // Try exact match first, then look for ranges that start with this token + let colorIdx = refColorCache.get(normalizedRef)?.colorIdx ?? 0; + if (colorIdx === undefined) { + // Look for a range that starts with this token (e.g., D1 might match D1:D4) + for (const [, info] of refColorCache.entries()) { + if (info.ref.startsWith(normalizedRef + ':')) { + colorIdx = info.colorIdx; + break; + } + } + } + colorIdx = colorIdx ?? 0; + const colorClass = `formula-token-color-${colorIdx + 1}`; + const span = createDomElement('span', { className: `formula-token ${colorClass}` }); span.textContent = match[0]; fragment.appendChild(span); @@ -825,7 +1065,7 @@ export class FormulaCellEditor implements Editor { return; } - const match = textBeforeCaret.match(/(?:^|[=(,]\s*)([A-Za-z_][A-Za-z0-9_]*)?$/); + 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(); @@ -912,11 +1152,12 @@ export class FormulaCellEditor implements Editor { return; } - const text = this.getPlainTextValue(); + // 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(/(?:^|[=(,]\s*)([A-Za-z_][A-Za-z0-9_]*)?$/); + const match = textBeforeCaret.match(/(?:^|[^A-Za-z0-9_]\s*)([A-Za-z_][A-Za-z0-9_]*)?$/); if (!match) { return; } @@ -933,7 +1174,11 @@ export class FormulaCellEditor implements Editor { ? 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; diff --git a/packages/formula-plugin/src/formula.service.spec.ts b/packages/formula-plugin/src/formula.service.spec.ts index b9c4af7487..e0114a2a4d 100644 --- a/packages/formula-plugin/src/formula.service.spec.ts +++ b/packages/formula-plugin/src/formula.service.spec.ts @@ -926,7 +926,7 @@ describe('FormulaService', () => { expect(service.getEvaluatedCellValue(1, 'out', items[0].out, '')).toBe('[object Object]'); }); - it('should wrap onFormulaInputChange to refresh highlights and invoke user callback', () => { + 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'); @@ -947,7 +947,7 @@ describe('FormulaService', () => { const wrapped = columns[0].editor?.params?.onFormulaInputChange as ((formula: string) => void) | undefined; wrapped?.('=A1'); - expect(highlightSpy).toHaveBeenCalledWith('=A1'); + expect(highlightSpy).not.toHaveBeenCalled(); expect(userCallback).toHaveBeenCalledWith('=A1'); }); }); diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts index 8425806472..3293a62b68 100644 --- a/packages/formula-plugin/src/formula.service.ts +++ b/packages/formula-plugin/src/formula.service.ts @@ -1381,7 +1381,6 @@ export class FormulaService implements ExternalResource, FormulaProvider { const userOnFormulaInputChange = mergedParams.onFormulaInputChange; mergedParams.onFormulaInputChange = (formula: string) => { - this.renderFormulaReferenceHighlights(formula); userOnFormulaInputChange?.(formula); }; diff --git a/test/cypress/e2e/example47.cy.ts b/test/cypress/e2e/example47.cy.ts index 760436f50e..cbf5ca6daa 100644 --- a/test/cypress/e2e/example47.cy.ts +++ b/test/cypress/e2e/example47.cy.ts @@ -109,6 +109,89 @@ describe('Example 47 - Formula Service (MVP)', () => { cy.get(cell(0, 4)).contains('$8.88'); }); + it('should keep multi-reference cell colors while typing formula text', () => { + cy.get(cell(0, 4)).click(); + 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)).click(); + 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'); + + cy.get('.formula-editor-input').type('{esc}', { force: true }); + cy.get(cell(0, 4)).contains('$8.88'); + }); + + it('should keep stable coloring when formula contains an incomplete range reference', () => { + cy.get(cell(0, 4)).click(); + 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)).click(); + 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)).click(); + 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)).click(); From d80c78ba7e570d576d66be50af1dd5cc05b8c5f6 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Mon, 10 Aug 2026 22:54:15 -0400 Subject: [PATCH 16/57] docs: add documentation on how to style cell dynamically --- docs/TOC.md | 1 + docs/styling/dynamic-styling-with-metadata.md | 424 ++++++++++++++++++ frameworks/angular-slickgrid/docs/TOC.md | 1 + .../styling/dynamic-styling-with-metadata.md | 424 ++++++++++++++++++ frameworks/aurelia-slickgrid/docs/TOC.md | 1 + .../styling/dynamic-styling-with-metadata.md | 424 ++++++++++++++++++ frameworks/slickgrid-react/docs/TOC.md | 1 + .../styling/dynamic-styling-with-metadata.md | 424 ++++++++++++++++++ frameworks/slickgrid-vue/docs/TOC.md | 1 + .../styling/dynamic-styling-with-metadata.md | 424 ++++++++++++++++++ 10 files changed, 2125 insertions(+) create mode 100644 docs/styling/dynamic-styling-with-metadata.md create mode 100644 frameworks/angular-slickgrid/docs/styling/dynamic-styling-with-metadata.md create mode 100644 frameworks/aurelia-slickgrid/docs/styling/dynamic-styling-with-metadata.md create mode 100644 frameworks/slickgrid-react/docs/styling/dynamic-styling-with-metadata.md create mode 100644 frameworks/slickgrid-vue/docs/styling/dynamic-styling-with-metadata.md diff --git a/docs/TOC.md b/docs/TOC.md index 9600401fb3..5dee420bba 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -12,6 +12,7 @@ * [Dark Mode](styling/dark-mode.md) * [Styling CSS/SASS/Themes](styling/styling.md) +* [Dynamic Styling with Item Metadata](styling/dynamic-styling-with-metadata.md) * [Multiple Column Header Rows](styling/multiple-column-header-rows.md) ## Column Functionalities diff --git a/docs/styling/dynamic-styling-with-metadata.md b/docs/styling/dynamic-styling-with-metadata.md new file mode 100644 index 0000000000..c176615517 --- /dev/null +++ b/docs/styling/dynamic-styling-with-metadata.md @@ -0,0 +1,424 @@ +# Dynamic Styling with Item Metadata + +## Overview + +SlickGrid provides powerful mechanisms to apply CSS styling dynamically to grid cells based on item properties or runtime conditions. This guide shows how to use item metadata and the grid's styling APIs to create responsive, data-driven cell highlighting and styling. + +## Table of Contents + +- [Storing Metadata in Items](#storing-metadata-in-items) +- [Using setCellCssStyles for Dynamic Styling](#using-setcellcsssstyles-for-dynamic-styling) +- [Using Cell Metadata for Per-Cell Styling](#using-cell-metadata-for-per-cell-styling) +- [Common Use Cases](#common-use-cases) +- [Best Practices](#best-practices) + +## Storing Metadata in Items + +SlickGrid items are plain JavaScript objects. You can attach any metadata properties to items alongside your data properties: + +```typescript +interface Item { + id: number; + name: string; + price: number; + // Custom metadata properties + status?: 'active' | 'inactive' | 'pending'; + priority?: 'low' | 'medium' | 'high'; + isModified?: boolean; + validationErrors?: string[]; + customData?: Record; +} + +const data: Item[] = [ + { id: 1, name: 'Product A', price: 100, status: 'active', priority: 'high', isModified: true }, + { id: 2, name: 'Product B', price: 200, status: 'inactive', priority: 'low' }, + { id: 3, name: 'Product C', price: 150, status: 'pending', validationErrors: ['Invalid price'] }, +]; +``` + +## Using setCellCssStyles for Dynamic Styling + +The `setCellCssStyles(key, hash)` method is the primary way to apply CSS classes to grid cells dynamically. It uses a **style key** to manage overlays of CSS classes that can be added, modified, or removed independently. + +### Basic API + +```typescript +// Apply styles +grid.setCellCssStyles(key, hash); + +// Remove styles +grid.removeCellCssStyles(key); + +// Remove styles matching a predicate +grid.removeCellCssStylesBatch((key) => key.startsWith('highlight-')); +``` + +### Hash Structure + +The hash follows a strict structure: + +```typescript +interface CssStyleHash { + [rowIndex: number]: { + [columnId: string | number]: cssClassName // Single class or space-separated classes + } +} +``` + +**Important:** Column keys MUST be column IDs (strings or numbers), NOT numeric indices. + +### Example: Status-Based Highlighting + +```typescript +// Define your CSS classes +const styles = ` + .status-active { background-color: #d4edda; } + .status-inactive { background-color: #f8d7da; } + .status-pending { background-color: #fff3cd; } +`; + +// Function to apply styles based on item metadata +function highlightByStatus(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.status) { + const className = `status-${item.status}`; + const columns = grid.getColumns(); + + // Apply to specific columns (e.g., 'name' and 'price') + columns.forEach((col) => { + if (col.id === 'name' || col.id === 'price') { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = className; + } + }); + } + }); + + grid.setCellCssStyles('status-highlight', hash); +} + +// Call on data load or update +highlightByStatus(grid, data); +``` + +### Example: Modified Rows Indicator + +```typescript +function highlightModifiedRows(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.isModified) { + hash[rowIndex] = {}; + // Add a visual indicator to the first cell of modified rows + const firstCol = grid.getColumns()[0]; + if (firstCol) { + hash[rowIndex][firstCol.id] = 'unsaved-changes modified-indicator'; + } + } + }); + + grid.setCellCssStyles('modified-rows', hash); +} + +// CSS +const styles = ` + .unsaved-changes { border-left: 4px solid #ff6b6b; } + .modified-indicator { background-color: #ffe0e0; } +`; +``` + +## Using Cell Metadata for Per-Cell Styling + +SlickGrid also supports metadata on individual cells through the `cssClasses` property: + +```typescript +interface Item { + id: number; + name: string; + cells?: { + [columnId: string]: { + cssClasses?: string; + value?: any; + // other cell metadata + } + } +} + +const data: Item[] = [ + { + id: 1, + name: 'Product A', + cells: { + price: { + cssClasses: 'price-high discount-eligible', + value: 100 + } + } + } +]; + +// The grid formatter can use this metadata +const priceFormatter = (row: number, cell: number, value: any, columnDef: Column, item: Item) => { + const cellMeta = item.cells?.[columnDef.id]; + const classes = cellMeta?.cssClasses || ''; + return `${value}`; +}; +``` + +## Common Use Cases + +### 1. Validation Error Highlighting + +```typescript +function highlightValidationErrors(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.validationErrors && item.validationErrors.length > 0) { + hash[rowIndex] = {}; + const columns = grid.getColumns(); + columns.forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = 'validation-error'; + }); + } + }); + + grid.setCellCssStyles('validation-errors', hash); +} + +// CSS +const styles = ` + .validation-error { + background-color: #ffcccc; + border: 1px solid #ff6b6b; + } +`; +``` + +### 2. Priority-Based Row Coloring + +```typescript +function colorByPriority(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + const priorityClasses: Record = { + high: 'priority-high', + medium: 'priority-medium', + low: 'priority-low' + }; + + data.forEach((item, rowIndex) => { + if (item.priority) { + hash[rowIndex] = {}; + grid.getColumns().forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = priorityClasses[item.priority]; + }); + } + }); + + grid.setCellCssStyles('priority-coloring', hash); +} + +// CSS +const styles = ` + .priority-high { background-color: #ffe0e0; color: #c00; font-weight: bold; } + .priority-medium { background-color: #fff3cd; color: #995500; } + .priority-low { background-color: #e8f4f8; color: #004488; } +`; +``` + +### 3. Conditional Cell Styling + +```typescript +function applyConditionalFormatting(grid: SlickGrid, data: Item[], rules: FormatRule[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + rules.forEach((rule) => { + if (rule.condition(item)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + rule.affectedColumns.forEach((colId) => { + if (!hash[rowIndex][colId]) { + hash[rowIndex][colId] = rule.cssClass; + } else { + // Append class if column already has styling + hash[rowIndex][colId] += ' ' + rule.cssClass; + } + }); + } + }); + }); + + grid.setCellCssStyles('conditional-formatting', hash); +} + +interface FormatRule { + condition: (item: Item) => boolean; + affectedColumns: string[]; + cssClass: string; +} + +// Example rules +const rules: FormatRule[] = [ + { + condition: (item) => item.price > 500, + affectedColumns: ['price'], + cssClass: 'price-expensive' + }, + { + condition: (item) => item.isModified, + affectedColumns: ['name', 'price'], + cssClass: 'unsaved-changes' + } +]; +``` + +### 4. Search Result Highlighting + +```typescript +function highlightSearchResults(grid: SlickGrid, data: Item[], searchTerm: string, searchColumns: string[]) { + const hash: Record> = {}; + const searchLower = searchTerm.toLowerCase(); + + data.forEach((item, rowIndex) => { + searchColumns.forEach((colId) => { + const value = String(item[colId as keyof Item] || '').toLowerCase(); + if (value.includes(searchLower)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][colId] = 'search-highlight'; + } + }); + }); + + grid.setCellCssStyles('search-results', hash); +} + +// CSS +const styles = ` + .search-highlight { + background-color: #ffeb3b; + color: #000; + font-weight: bold; + } +`; +``` + +## Best Practices + +### 1. Use Meaningful Style Keys + +```typescript +// Good - descriptive keys that indicate purpose +grid.setCellCssStyles('validation-errors', hash); +grid.setCellCssStyles('unsaved-changes', hash); +grid.setCellCssStyles('search-highlights', hash); + +// Avoid - vague keys +grid.setCellCssStyles('highlight', hash); // Which highlight? +grid.setCellCssStyles('style1', hash); // Not descriptive +``` + +### 2. Manage Multiple Style Overlays + +Different style keys can be applied simultaneously. The last applied style takes visual precedence: + +```typescript +// Apply multiple independent styling layers +grid.setCellCssStyles('status-highlighting', statusHash); +grid.setCellCssStyles('validation-errors', validationHash); +grid.setCellCssStyles('search-highlights', searchHash); + +// Later, remove only specific styling without affecting others +grid.removeCellCssStyles('search-highlights'); +``` + +### 3. Handle Column ID Conversion + +Always convert column indices to IDs when building the hash: + +```typescript +// ✓ Correct - using column IDs +const columns = grid.getColumns(); +const column = columns[cellIndex]; +const columnId = column.id; +hash[row][columnId] = className; + +// ✗ Wrong - using numeric indices +hash[row][cellIndex] = className; // Will not work +``` + +### 4. Batch Updates for Performance + +If updating many rows, batch the operations: + +```typescript +function updateStyling(grid: SlickGrid, data: Item[]) { + // Build entire hash before applying + const hash: Record> = {}; + + // Populate hash for all rows + data.forEach((item, rowIndex) => { + // ... build styling for this row + }); + + // Single API call + grid.setCellCssStyles('batch-styling', hash); +} + +// Don't do this - multiple API calls are slower +data.forEach((item, rowIndex) => { + grid.setCellCssStyles(`style-row-${rowIndex}`, { + [rowIndex]: { columnId: className } + }); +}); +``` + +### 5. Clean Up Unused Styles + +Remove style keys that are no longer needed: + +```typescript +// Remove specific styling +grid.removeCellCssStyles('old-highlighting'); + +// Remove multiple styles at once +['validation-errors', 'search-highlights', 'outdated-style'].forEach((key) => { + grid.removeCellCssStyles(key); +}); + +// Remove all styles matching a pattern +grid.removeCellCssStylesBatch((key) => key.startsWith('temporary-')); +``` + +### 6. CSS Organization + +Keep CSS organized by styling purpose: + +```css +/* Status-based styling */ +.status-active { background-color: #d4edda; } +.status-inactive { background-color: #f8d7da; } +.status-pending { background-color: #fff3cd; } + +/* State indicators */ +.unsaved-changes { border-left: 4px solid #ff6b6b; } +.validation-error { background-color: #ffcccc; } + +/* User interaction */ +.search-highlight { background-color: #ffeb3b; color: #000; font-weight: bold; } + +/* Priority levels */ +.priority-high { color: #c00; font-weight: bold; } +.priority-medium { color: #995500; } +.priority-low { color: #004488; } +``` + +## See Also + +- [Styling Guide](./styling.md) - Theme and CSS variable customization +- [Dark Mode Guide](./dark-mode.md) - Dark mode specific styling +- SlickGrid API Documentation - `setCellCssStyles()` and related methods diff --git a/frameworks/angular-slickgrid/docs/TOC.md b/frameworks/angular-slickgrid/docs/TOC.md index 4c3cef0c45..cc7d6c0dd6 100644 --- a/frameworks/angular-slickgrid/docs/TOC.md +++ b/frameworks/angular-slickgrid/docs/TOC.md @@ -11,6 +11,7 @@ * [Dark Mode](styling/dark-mode.md) * [Styling CSS/SASS/Themes](styling/styling.md) +* [Dynamic Styling with Item Metadata](styling/dynamic-styling-with-metadata.md) * [Multiple Column Header Rows](styling/multiple-column-header-rows.md) ## Column Functionalities diff --git a/frameworks/angular-slickgrid/docs/styling/dynamic-styling-with-metadata.md b/frameworks/angular-slickgrid/docs/styling/dynamic-styling-with-metadata.md new file mode 100644 index 0000000000..c176615517 --- /dev/null +++ b/frameworks/angular-slickgrid/docs/styling/dynamic-styling-with-metadata.md @@ -0,0 +1,424 @@ +# Dynamic Styling with Item Metadata + +## Overview + +SlickGrid provides powerful mechanisms to apply CSS styling dynamically to grid cells based on item properties or runtime conditions. This guide shows how to use item metadata and the grid's styling APIs to create responsive, data-driven cell highlighting and styling. + +## Table of Contents + +- [Storing Metadata in Items](#storing-metadata-in-items) +- [Using setCellCssStyles for Dynamic Styling](#using-setcellcsssstyles-for-dynamic-styling) +- [Using Cell Metadata for Per-Cell Styling](#using-cell-metadata-for-per-cell-styling) +- [Common Use Cases](#common-use-cases) +- [Best Practices](#best-practices) + +## Storing Metadata in Items + +SlickGrid items are plain JavaScript objects. You can attach any metadata properties to items alongside your data properties: + +```typescript +interface Item { + id: number; + name: string; + price: number; + // Custom metadata properties + status?: 'active' | 'inactive' | 'pending'; + priority?: 'low' | 'medium' | 'high'; + isModified?: boolean; + validationErrors?: string[]; + customData?: Record; +} + +const data: Item[] = [ + { id: 1, name: 'Product A', price: 100, status: 'active', priority: 'high', isModified: true }, + { id: 2, name: 'Product B', price: 200, status: 'inactive', priority: 'low' }, + { id: 3, name: 'Product C', price: 150, status: 'pending', validationErrors: ['Invalid price'] }, +]; +``` + +## Using setCellCssStyles for Dynamic Styling + +The `setCellCssStyles(key, hash)` method is the primary way to apply CSS classes to grid cells dynamically. It uses a **style key** to manage overlays of CSS classes that can be added, modified, or removed independently. + +### Basic API + +```typescript +// Apply styles +grid.setCellCssStyles(key, hash); + +// Remove styles +grid.removeCellCssStyles(key); + +// Remove styles matching a predicate +grid.removeCellCssStylesBatch((key) => key.startsWith('highlight-')); +``` + +### Hash Structure + +The hash follows a strict structure: + +```typescript +interface CssStyleHash { + [rowIndex: number]: { + [columnId: string | number]: cssClassName // Single class or space-separated classes + } +} +``` + +**Important:** Column keys MUST be column IDs (strings or numbers), NOT numeric indices. + +### Example: Status-Based Highlighting + +```typescript +// Define your CSS classes +const styles = ` + .status-active { background-color: #d4edda; } + .status-inactive { background-color: #f8d7da; } + .status-pending { background-color: #fff3cd; } +`; + +// Function to apply styles based on item metadata +function highlightByStatus(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.status) { + const className = `status-${item.status}`; + const columns = grid.getColumns(); + + // Apply to specific columns (e.g., 'name' and 'price') + columns.forEach((col) => { + if (col.id === 'name' || col.id === 'price') { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = className; + } + }); + } + }); + + grid.setCellCssStyles('status-highlight', hash); +} + +// Call on data load or update +highlightByStatus(grid, data); +``` + +### Example: Modified Rows Indicator + +```typescript +function highlightModifiedRows(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.isModified) { + hash[rowIndex] = {}; + // Add a visual indicator to the first cell of modified rows + const firstCol = grid.getColumns()[0]; + if (firstCol) { + hash[rowIndex][firstCol.id] = 'unsaved-changes modified-indicator'; + } + } + }); + + grid.setCellCssStyles('modified-rows', hash); +} + +// CSS +const styles = ` + .unsaved-changes { border-left: 4px solid #ff6b6b; } + .modified-indicator { background-color: #ffe0e0; } +`; +``` + +## Using Cell Metadata for Per-Cell Styling + +SlickGrid also supports metadata on individual cells through the `cssClasses` property: + +```typescript +interface Item { + id: number; + name: string; + cells?: { + [columnId: string]: { + cssClasses?: string; + value?: any; + // other cell metadata + } + } +} + +const data: Item[] = [ + { + id: 1, + name: 'Product A', + cells: { + price: { + cssClasses: 'price-high discount-eligible', + value: 100 + } + } + } +]; + +// The grid formatter can use this metadata +const priceFormatter = (row: number, cell: number, value: any, columnDef: Column, item: Item) => { + const cellMeta = item.cells?.[columnDef.id]; + const classes = cellMeta?.cssClasses || ''; + return `${value}`; +}; +``` + +## Common Use Cases + +### 1. Validation Error Highlighting + +```typescript +function highlightValidationErrors(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.validationErrors && item.validationErrors.length > 0) { + hash[rowIndex] = {}; + const columns = grid.getColumns(); + columns.forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = 'validation-error'; + }); + } + }); + + grid.setCellCssStyles('validation-errors', hash); +} + +// CSS +const styles = ` + .validation-error { + background-color: #ffcccc; + border: 1px solid #ff6b6b; + } +`; +``` + +### 2. Priority-Based Row Coloring + +```typescript +function colorByPriority(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + const priorityClasses: Record = { + high: 'priority-high', + medium: 'priority-medium', + low: 'priority-low' + }; + + data.forEach((item, rowIndex) => { + if (item.priority) { + hash[rowIndex] = {}; + grid.getColumns().forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = priorityClasses[item.priority]; + }); + } + }); + + grid.setCellCssStyles('priority-coloring', hash); +} + +// CSS +const styles = ` + .priority-high { background-color: #ffe0e0; color: #c00; font-weight: bold; } + .priority-medium { background-color: #fff3cd; color: #995500; } + .priority-low { background-color: #e8f4f8; color: #004488; } +`; +``` + +### 3. Conditional Cell Styling + +```typescript +function applyConditionalFormatting(grid: SlickGrid, data: Item[], rules: FormatRule[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + rules.forEach((rule) => { + if (rule.condition(item)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + rule.affectedColumns.forEach((colId) => { + if (!hash[rowIndex][colId]) { + hash[rowIndex][colId] = rule.cssClass; + } else { + // Append class if column already has styling + hash[rowIndex][colId] += ' ' + rule.cssClass; + } + }); + } + }); + }); + + grid.setCellCssStyles('conditional-formatting', hash); +} + +interface FormatRule { + condition: (item: Item) => boolean; + affectedColumns: string[]; + cssClass: string; +} + +// Example rules +const rules: FormatRule[] = [ + { + condition: (item) => item.price > 500, + affectedColumns: ['price'], + cssClass: 'price-expensive' + }, + { + condition: (item) => item.isModified, + affectedColumns: ['name', 'price'], + cssClass: 'unsaved-changes' + } +]; +``` + +### 4. Search Result Highlighting + +```typescript +function highlightSearchResults(grid: SlickGrid, data: Item[], searchTerm: string, searchColumns: string[]) { + const hash: Record> = {}; + const searchLower = searchTerm.toLowerCase(); + + data.forEach((item, rowIndex) => { + searchColumns.forEach((colId) => { + const value = String(item[colId as keyof Item] || '').toLowerCase(); + if (value.includes(searchLower)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][colId] = 'search-highlight'; + } + }); + }); + + grid.setCellCssStyles('search-results', hash); +} + +// CSS +const styles = ` + .search-highlight { + background-color: #ffeb3b; + color: #000; + font-weight: bold; + } +`; +``` + +## Best Practices + +### 1. Use Meaningful Style Keys + +```typescript +// Good - descriptive keys that indicate purpose +grid.setCellCssStyles('validation-errors', hash); +grid.setCellCssStyles('unsaved-changes', hash); +grid.setCellCssStyles('search-highlights', hash); + +// Avoid - vague keys +grid.setCellCssStyles('highlight', hash); // Which highlight? +grid.setCellCssStyles('style1', hash); // Not descriptive +``` + +### 2. Manage Multiple Style Overlays + +Different style keys can be applied simultaneously. The last applied style takes visual precedence: + +```typescript +// Apply multiple independent styling layers +grid.setCellCssStyles('status-highlighting', statusHash); +grid.setCellCssStyles('validation-errors', validationHash); +grid.setCellCssStyles('search-highlights', searchHash); + +// Later, remove only specific styling without affecting others +grid.removeCellCssStyles('search-highlights'); +``` + +### 3. Handle Column ID Conversion + +Always convert column indices to IDs when building the hash: + +```typescript +// ✓ Correct - using column IDs +const columns = grid.getColumns(); +const column = columns[cellIndex]; +const columnId = column.id; +hash[row][columnId] = className; + +// ✗ Wrong - using numeric indices +hash[row][cellIndex] = className; // Will not work +``` + +### 4. Batch Updates for Performance + +If updating many rows, batch the operations: + +```typescript +function updateStyling(grid: SlickGrid, data: Item[]) { + // Build entire hash before applying + const hash: Record> = {}; + + // Populate hash for all rows + data.forEach((item, rowIndex) => { + // ... build styling for this row + }); + + // Single API call + grid.setCellCssStyles('batch-styling', hash); +} + +// Don't do this - multiple API calls are slower +data.forEach((item, rowIndex) => { + grid.setCellCssStyles(`style-row-${rowIndex}`, { + [rowIndex]: { columnId: className } + }); +}); +``` + +### 5. Clean Up Unused Styles + +Remove style keys that are no longer needed: + +```typescript +// Remove specific styling +grid.removeCellCssStyles('old-highlighting'); + +// Remove multiple styles at once +['validation-errors', 'search-highlights', 'outdated-style'].forEach((key) => { + grid.removeCellCssStyles(key); +}); + +// Remove all styles matching a pattern +grid.removeCellCssStylesBatch((key) => key.startsWith('temporary-')); +``` + +### 6. CSS Organization + +Keep CSS organized by styling purpose: + +```css +/* Status-based styling */ +.status-active { background-color: #d4edda; } +.status-inactive { background-color: #f8d7da; } +.status-pending { background-color: #fff3cd; } + +/* State indicators */ +.unsaved-changes { border-left: 4px solid #ff6b6b; } +.validation-error { background-color: #ffcccc; } + +/* User interaction */ +.search-highlight { background-color: #ffeb3b; color: #000; font-weight: bold; } + +/* Priority levels */ +.priority-high { color: #c00; font-weight: bold; } +.priority-medium { color: #995500; } +.priority-low { color: #004488; } +``` + +## See Also + +- [Styling Guide](./styling.md) - Theme and CSS variable customization +- [Dark Mode Guide](./dark-mode.md) - Dark mode specific styling +- SlickGrid API Documentation - `setCellCssStyles()` and related methods diff --git a/frameworks/aurelia-slickgrid/docs/TOC.md b/frameworks/aurelia-slickgrid/docs/TOC.md index 4ca350af32..9b555a6337 100644 --- a/frameworks/aurelia-slickgrid/docs/TOC.md +++ b/frameworks/aurelia-slickgrid/docs/TOC.md @@ -10,6 +10,7 @@ * [Dark Mode](styling/dark-mode.md) * [Styling CSS/SASS/Themes](styling/styling.md) +* [Dynamic Styling with Item Metadata](styling/dynamic-styling-with-metadata.md) * [Multiple Column Header Rows](styling/multiple-column-header-rows.md) ## Column Functionalities diff --git a/frameworks/aurelia-slickgrid/docs/styling/dynamic-styling-with-metadata.md b/frameworks/aurelia-slickgrid/docs/styling/dynamic-styling-with-metadata.md new file mode 100644 index 0000000000..c176615517 --- /dev/null +++ b/frameworks/aurelia-slickgrid/docs/styling/dynamic-styling-with-metadata.md @@ -0,0 +1,424 @@ +# Dynamic Styling with Item Metadata + +## Overview + +SlickGrid provides powerful mechanisms to apply CSS styling dynamically to grid cells based on item properties or runtime conditions. This guide shows how to use item metadata and the grid's styling APIs to create responsive, data-driven cell highlighting and styling. + +## Table of Contents + +- [Storing Metadata in Items](#storing-metadata-in-items) +- [Using setCellCssStyles for Dynamic Styling](#using-setcellcsssstyles-for-dynamic-styling) +- [Using Cell Metadata for Per-Cell Styling](#using-cell-metadata-for-per-cell-styling) +- [Common Use Cases](#common-use-cases) +- [Best Practices](#best-practices) + +## Storing Metadata in Items + +SlickGrid items are plain JavaScript objects. You can attach any metadata properties to items alongside your data properties: + +```typescript +interface Item { + id: number; + name: string; + price: number; + // Custom metadata properties + status?: 'active' | 'inactive' | 'pending'; + priority?: 'low' | 'medium' | 'high'; + isModified?: boolean; + validationErrors?: string[]; + customData?: Record; +} + +const data: Item[] = [ + { id: 1, name: 'Product A', price: 100, status: 'active', priority: 'high', isModified: true }, + { id: 2, name: 'Product B', price: 200, status: 'inactive', priority: 'low' }, + { id: 3, name: 'Product C', price: 150, status: 'pending', validationErrors: ['Invalid price'] }, +]; +``` + +## Using setCellCssStyles for Dynamic Styling + +The `setCellCssStyles(key, hash)` method is the primary way to apply CSS classes to grid cells dynamically. It uses a **style key** to manage overlays of CSS classes that can be added, modified, or removed independently. + +### Basic API + +```typescript +// Apply styles +grid.setCellCssStyles(key, hash); + +// Remove styles +grid.removeCellCssStyles(key); + +// Remove styles matching a predicate +grid.removeCellCssStylesBatch((key) => key.startsWith('highlight-')); +``` + +### Hash Structure + +The hash follows a strict structure: + +```typescript +interface CssStyleHash { + [rowIndex: number]: { + [columnId: string | number]: cssClassName // Single class or space-separated classes + } +} +``` + +**Important:** Column keys MUST be column IDs (strings or numbers), NOT numeric indices. + +### Example: Status-Based Highlighting + +```typescript +// Define your CSS classes +const styles = ` + .status-active { background-color: #d4edda; } + .status-inactive { background-color: #f8d7da; } + .status-pending { background-color: #fff3cd; } +`; + +// Function to apply styles based on item metadata +function highlightByStatus(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.status) { + const className = `status-${item.status}`; + const columns = grid.getColumns(); + + // Apply to specific columns (e.g., 'name' and 'price') + columns.forEach((col) => { + if (col.id === 'name' || col.id === 'price') { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = className; + } + }); + } + }); + + grid.setCellCssStyles('status-highlight', hash); +} + +// Call on data load or update +highlightByStatus(grid, data); +``` + +### Example: Modified Rows Indicator + +```typescript +function highlightModifiedRows(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.isModified) { + hash[rowIndex] = {}; + // Add a visual indicator to the first cell of modified rows + const firstCol = grid.getColumns()[0]; + if (firstCol) { + hash[rowIndex][firstCol.id] = 'unsaved-changes modified-indicator'; + } + } + }); + + grid.setCellCssStyles('modified-rows', hash); +} + +// CSS +const styles = ` + .unsaved-changes { border-left: 4px solid #ff6b6b; } + .modified-indicator { background-color: #ffe0e0; } +`; +``` + +## Using Cell Metadata for Per-Cell Styling + +SlickGrid also supports metadata on individual cells through the `cssClasses` property: + +```typescript +interface Item { + id: number; + name: string; + cells?: { + [columnId: string]: { + cssClasses?: string; + value?: any; + // other cell metadata + } + } +} + +const data: Item[] = [ + { + id: 1, + name: 'Product A', + cells: { + price: { + cssClasses: 'price-high discount-eligible', + value: 100 + } + } + } +]; + +// The grid formatter can use this metadata +const priceFormatter = (row: number, cell: number, value: any, columnDef: Column, item: Item) => { + const cellMeta = item.cells?.[columnDef.id]; + const classes = cellMeta?.cssClasses || ''; + return `${value}`; +}; +``` + +## Common Use Cases + +### 1. Validation Error Highlighting + +```typescript +function highlightValidationErrors(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.validationErrors && item.validationErrors.length > 0) { + hash[rowIndex] = {}; + const columns = grid.getColumns(); + columns.forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = 'validation-error'; + }); + } + }); + + grid.setCellCssStyles('validation-errors', hash); +} + +// CSS +const styles = ` + .validation-error { + background-color: #ffcccc; + border: 1px solid #ff6b6b; + } +`; +``` + +### 2. Priority-Based Row Coloring + +```typescript +function colorByPriority(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + const priorityClasses: Record = { + high: 'priority-high', + medium: 'priority-medium', + low: 'priority-low' + }; + + data.forEach((item, rowIndex) => { + if (item.priority) { + hash[rowIndex] = {}; + grid.getColumns().forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = priorityClasses[item.priority]; + }); + } + }); + + grid.setCellCssStyles('priority-coloring', hash); +} + +// CSS +const styles = ` + .priority-high { background-color: #ffe0e0; color: #c00; font-weight: bold; } + .priority-medium { background-color: #fff3cd; color: #995500; } + .priority-low { background-color: #e8f4f8; color: #004488; } +`; +``` + +### 3. Conditional Cell Styling + +```typescript +function applyConditionalFormatting(grid: SlickGrid, data: Item[], rules: FormatRule[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + rules.forEach((rule) => { + if (rule.condition(item)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + rule.affectedColumns.forEach((colId) => { + if (!hash[rowIndex][colId]) { + hash[rowIndex][colId] = rule.cssClass; + } else { + // Append class if column already has styling + hash[rowIndex][colId] += ' ' + rule.cssClass; + } + }); + } + }); + }); + + grid.setCellCssStyles('conditional-formatting', hash); +} + +interface FormatRule { + condition: (item: Item) => boolean; + affectedColumns: string[]; + cssClass: string; +} + +// Example rules +const rules: FormatRule[] = [ + { + condition: (item) => item.price > 500, + affectedColumns: ['price'], + cssClass: 'price-expensive' + }, + { + condition: (item) => item.isModified, + affectedColumns: ['name', 'price'], + cssClass: 'unsaved-changes' + } +]; +``` + +### 4. Search Result Highlighting + +```typescript +function highlightSearchResults(grid: SlickGrid, data: Item[], searchTerm: string, searchColumns: string[]) { + const hash: Record> = {}; + const searchLower = searchTerm.toLowerCase(); + + data.forEach((item, rowIndex) => { + searchColumns.forEach((colId) => { + const value = String(item[colId as keyof Item] || '').toLowerCase(); + if (value.includes(searchLower)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][colId] = 'search-highlight'; + } + }); + }); + + grid.setCellCssStyles('search-results', hash); +} + +// CSS +const styles = ` + .search-highlight { + background-color: #ffeb3b; + color: #000; + font-weight: bold; + } +`; +``` + +## Best Practices + +### 1. Use Meaningful Style Keys + +```typescript +// Good - descriptive keys that indicate purpose +grid.setCellCssStyles('validation-errors', hash); +grid.setCellCssStyles('unsaved-changes', hash); +grid.setCellCssStyles('search-highlights', hash); + +// Avoid - vague keys +grid.setCellCssStyles('highlight', hash); // Which highlight? +grid.setCellCssStyles('style1', hash); // Not descriptive +``` + +### 2. Manage Multiple Style Overlays + +Different style keys can be applied simultaneously. The last applied style takes visual precedence: + +```typescript +// Apply multiple independent styling layers +grid.setCellCssStyles('status-highlighting', statusHash); +grid.setCellCssStyles('validation-errors', validationHash); +grid.setCellCssStyles('search-highlights', searchHash); + +// Later, remove only specific styling without affecting others +grid.removeCellCssStyles('search-highlights'); +``` + +### 3. Handle Column ID Conversion + +Always convert column indices to IDs when building the hash: + +```typescript +// ✓ Correct - using column IDs +const columns = grid.getColumns(); +const column = columns[cellIndex]; +const columnId = column.id; +hash[row][columnId] = className; + +// ✗ Wrong - using numeric indices +hash[row][cellIndex] = className; // Will not work +``` + +### 4. Batch Updates for Performance + +If updating many rows, batch the operations: + +```typescript +function updateStyling(grid: SlickGrid, data: Item[]) { + // Build entire hash before applying + const hash: Record> = {}; + + // Populate hash for all rows + data.forEach((item, rowIndex) => { + // ... build styling for this row + }); + + // Single API call + grid.setCellCssStyles('batch-styling', hash); +} + +// Don't do this - multiple API calls are slower +data.forEach((item, rowIndex) => { + grid.setCellCssStyles(`style-row-${rowIndex}`, { + [rowIndex]: { columnId: className } + }); +}); +``` + +### 5. Clean Up Unused Styles + +Remove style keys that are no longer needed: + +```typescript +// Remove specific styling +grid.removeCellCssStyles('old-highlighting'); + +// Remove multiple styles at once +['validation-errors', 'search-highlights', 'outdated-style'].forEach((key) => { + grid.removeCellCssStyles(key); +}); + +// Remove all styles matching a pattern +grid.removeCellCssStylesBatch((key) => key.startsWith('temporary-')); +``` + +### 6. CSS Organization + +Keep CSS organized by styling purpose: + +```css +/* Status-based styling */ +.status-active { background-color: #d4edda; } +.status-inactive { background-color: #f8d7da; } +.status-pending { background-color: #fff3cd; } + +/* State indicators */ +.unsaved-changes { border-left: 4px solid #ff6b6b; } +.validation-error { background-color: #ffcccc; } + +/* User interaction */ +.search-highlight { background-color: #ffeb3b; color: #000; font-weight: bold; } + +/* Priority levels */ +.priority-high { color: #c00; font-weight: bold; } +.priority-medium { color: #995500; } +.priority-low { color: #004488; } +``` + +## See Also + +- [Styling Guide](./styling.md) - Theme and CSS variable customization +- [Dark Mode Guide](./dark-mode.md) - Dark mode specific styling +- SlickGrid API Documentation - `setCellCssStyles()` and related methods diff --git a/frameworks/slickgrid-react/docs/TOC.md b/frameworks/slickgrid-react/docs/TOC.md index dd9e7c05d6..ef37f588b1 100644 --- a/frameworks/slickgrid-react/docs/TOC.md +++ b/frameworks/slickgrid-react/docs/TOC.md @@ -10,6 +10,7 @@ * [Dark Mode](styling/dark-mode.md) * [Styling CSS/SASS/Themes](styling/styling.md) +* [Dynamic Styling with Item Metadata](styling/dynamic-styling-with-metadata.md) * [Multiple Column Header Rows](styling/multiple-column-header-rows.md) ## Column Functionalities diff --git a/frameworks/slickgrid-react/docs/styling/dynamic-styling-with-metadata.md b/frameworks/slickgrid-react/docs/styling/dynamic-styling-with-metadata.md new file mode 100644 index 0000000000..c176615517 --- /dev/null +++ b/frameworks/slickgrid-react/docs/styling/dynamic-styling-with-metadata.md @@ -0,0 +1,424 @@ +# Dynamic Styling with Item Metadata + +## Overview + +SlickGrid provides powerful mechanisms to apply CSS styling dynamically to grid cells based on item properties or runtime conditions. This guide shows how to use item metadata and the grid's styling APIs to create responsive, data-driven cell highlighting and styling. + +## Table of Contents + +- [Storing Metadata in Items](#storing-metadata-in-items) +- [Using setCellCssStyles for Dynamic Styling](#using-setcellcsssstyles-for-dynamic-styling) +- [Using Cell Metadata for Per-Cell Styling](#using-cell-metadata-for-per-cell-styling) +- [Common Use Cases](#common-use-cases) +- [Best Practices](#best-practices) + +## Storing Metadata in Items + +SlickGrid items are plain JavaScript objects. You can attach any metadata properties to items alongside your data properties: + +```typescript +interface Item { + id: number; + name: string; + price: number; + // Custom metadata properties + status?: 'active' | 'inactive' | 'pending'; + priority?: 'low' | 'medium' | 'high'; + isModified?: boolean; + validationErrors?: string[]; + customData?: Record; +} + +const data: Item[] = [ + { id: 1, name: 'Product A', price: 100, status: 'active', priority: 'high', isModified: true }, + { id: 2, name: 'Product B', price: 200, status: 'inactive', priority: 'low' }, + { id: 3, name: 'Product C', price: 150, status: 'pending', validationErrors: ['Invalid price'] }, +]; +``` + +## Using setCellCssStyles for Dynamic Styling + +The `setCellCssStyles(key, hash)` method is the primary way to apply CSS classes to grid cells dynamically. It uses a **style key** to manage overlays of CSS classes that can be added, modified, or removed independently. + +### Basic API + +```typescript +// Apply styles +grid.setCellCssStyles(key, hash); + +// Remove styles +grid.removeCellCssStyles(key); + +// Remove styles matching a predicate +grid.removeCellCssStylesBatch((key) => key.startsWith('highlight-')); +``` + +### Hash Structure + +The hash follows a strict structure: + +```typescript +interface CssStyleHash { + [rowIndex: number]: { + [columnId: string | number]: cssClassName // Single class or space-separated classes + } +} +``` + +**Important:** Column keys MUST be column IDs (strings or numbers), NOT numeric indices. + +### Example: Status-Based Highlighting + +```typescript +// Define your CSS classes +const styles = ` + .status-active { background-color: #d4edda; } + .status-inactive { background-color: #f8d7da; } + .status-pending { background-color: #fff3cd; } +`; + +// Function to apply styles based on item metadata +function highlightByStatus(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.status) { + const className = `status-${item.status}`; + const columns = grid.getColumns(); + + // Apply to specific columns (e.g., 'name' and 'price') + columns.forEach((col) => { + if (col.id === 'name' || col.id === 'price') { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = className; + } + }); + } + }); + + grid.setCellCssStyles('status-highlight', hash); +} + +// Call on data load or update +highlightByStatus(grid, data); +``` + +### Example: Modified Rows Indicator + +```typescript +function highlightModifiedRows(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.isModified) { + hash[rowIndex] = {}; + // Add a visual indicator to the first cell of modified rows + const firstCol = grid.getColumns()[0]; + if (firstCol) { + hash[rowIndex][firstCol.id] = 'unsaved-changes modified-indicator'; + } + } + }); + + grid.setCellCssStyles('modified-rows', hash); +} + +// CSS +const styles = ` + .unsaved-changes { border-left: 4px solid #ff6b6b; } + .modified-indicator { background-color: #ffe0e0; } +`; +``` + +## Using Cell Metadata for Per-Cell Styling + +SlickGrid also supports metadata on individual cells through the `cssClasses` property: + +```typescript +interface Item { + id: number; + name: string; + cells?: { + [columnId: string]: { + cssClasses?: string; + value?: any; + // other cell metadata + } + } +} + +const data: Item[] = [ + { + id: 1, + name: 'Product A', + cells: { + price: { + cssClasses: 'price-high discount-eligible', + value: 100 + } + } + } +]; + +// The grid formatter can use this metadata +const priceFormatter = (row: number, cell: number, value: any, columnDef: Column, item: Item) => { + const cellMeta = item.cells?.[columnDef.id]; + const classes = cellMeta?.cssClasses || ''; + return `${value}`; +}; +``` + +## Common Use Cases + +### 1. Validation Error Highlighting + +```typescript +function highlightValidationErrors(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.validationErrors && item.validationErrors.length > 0) { + hash[rowIndex] = {}; + const columns = grid.getColumns(); + columns.forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = 'validation-error'; + }); + } + }); + + grid.setCellCssStyles('validation-errors', hash); +} + +// CSS +const styles = ` + .validation-error { + background-color: #ffcccc; + border: 1px solid #ff6b6b; + } +`; +``` + +### 2. Priority-Based Row Coloring + +```typescript +function colorByPriority(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + const priorityClasses: Record = { + high: 'priority-high', + medium: 'priority-medium', + low: 'priority-low' + }; + + data.forEach((item, rowIndex) => { + if (item.priority) { + hash[rowIndex] = {}; + grid.getColumns().forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = priorityClasses[item.priority]; + }); + } + }); + + grid.setCellCssStyles('priority-coloring', hash); +} + +// CSS +const styles = ` + .priority-high { background-color: #ffe0e0; color: #c00; font-weight: bold; } + .priority-medium { background-color: #fff3cd; color: #995500; } + .priority-low { background-color: #e8f4f8; color: #004488; } +`; +``` + +### 3. Conditional Cell Styling + +```typescript +function applyConditionalFormatting(grid: SlickGrid, data: Item[], rules: FormatRule[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + rules.forEach((rule) => { + if (rule.condition(item)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + rule.affectedColumns.forEach((colId) => { + if (!hash[rowIndex][colId]) { + hash[rowIndex][colId] = rule.cssClass; + } else { + // Append class if column already has styling + hash[rowIndex][colId] += ' ' + rule.cssClass; + } + }); + } + }); + }); + + grid.setCellCssStyles('conditional-formatting', hash); +} + +interface FormatRule { + condition: (item: Item) => boolean; + affectedColumns: string[]; + cssClass: string; +} + +// Example rules +const rules: FormatRule[] = [ + { + condition: (item) => item.price > 500, + affectedColumns: ['price'], + cssClass: 'price-expensive' + }, + { + condition: (item) => item.isModified, + affectedColumns: ['name', 'price'], + cssClass: 'unsaved-changes' + } +]; +``` + +### 4. Search Result Highlighting + +```typescript +function highlightSearchResults(grid: SlickGrid, data: Item[], searchTerm: string, searchColumns: string[]) { + const hash: Record> = {}; + const searchLower = searchTerm.toLowerCase(); + + data.forEach((item, rowIndex) => { + searchColumns.forEach((colId) => { + const value = String(item[colId as keyof Item] || '').toLowerCase(); + if (value.includes(searchLower)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][colId] = 'search-highlight'; + } + }); + }); + + grid.setCellCssStyles('search-results', hash); +} + +// CSS +const styles = ` + .search-highlight { + background-color: #ffeb3b; + color: #000; + font-weight: bold; + } +`; +``` + +## Best Practices + +### 1. Use Meaningful Style Keys + +```typescript +// Good - descriptive keys that indicate purpose +grid.setCellCssStyles('validation-errors', hash); +grid.setCellCssStyles('unsaved-changes', hash); +grid.setCellCssStyles('search-highlights', hash); + +// Avoid - vague keys +grid.setCellCssStyles('highlight', hash); // Which highlight? +grid.setCellCssStyles('style1', hash); // Not descriptive +``` + +### 2. Manage Multiple Style Overlays + +Different style keys can be applied simultaneously. The last applied style takes visual precedence: + +```typescript +// Apply multiple independent styling layers +grid.setCellCssStyles('status-highlighting', statusHash); +grid.setCellCssStyles('validation-errors', validationHash); +grid.setCellCssStyles('search-highlights', searchHash); + +// Later, remove only specific styling without affecting others +grid.removeCellCssStyles('search-highlights'); +``` + +### 3. Handle Column ID Conversion + +Always convert column indices to IDs when building the hash: + +```typescript +// ✓ Correct - using column IDs +const columns = grid.getColumns(); +const column = columns[cellIndex]; +const columnId = column.id; +hash[row][columnId] = className; + +// ✗ Wrong - using numeric indices +hash[row][cellIndex] = className; // Will not work +``` + +### 4. Batch Updates for Performance + +If updating many rows, batch the operations: + +```typescript +function updateStyling(grid: SlickGrid, data: Item[]) { + // Build entire hash before applying + const hash: Record> = {}; + + // Populate hash for all rows + data.forEach((item, rowIndex) => { + // ... build styling for this row + }); + + // Single API call + grid.setCellCssStyles('batch-styling', hash); +} + +// Don't do this - multiple API calls are slower +data.forEach((item, rowIndex) => { + grid.setCellCssStyles(`style-row-${rowIndex}`, { + [rowIndex]: { columnId: className } + }); +}); +``` + +### 5. Clean Up Unused Styles + +Remove style keys that are no longer needed: + +```typescript +// Remove specific styling +grid.removeCellCssStyles('old-highlighting'); + +// Remove multiple styles at once +['validation-errors', 'search-highlights', 'outdated-style'].forEach((key) => { + grid.removeCellCssStyles(key); +}); + +// Remove all styles matching a pattern +grid.removeCellCssStylesBatch((key) => key.startsWith('temporary-')); +``` + +### 6. CSS Organization + +Keep CSS organized by styling purpose: + +```css +/* Status-based styling */ +.status-active { background-color: #d4edda; } +.status-inactive { background-color: #f8d7da; } +.status-pending { background-color: #fff3cd; } + +/* State indicators */ +.unsaved-changes { border-left: 4px solid #ff6b6b; } +.validation-error { background-color: #ffcccc; } + +/* User interaction */ +.search-highlight { background-color: #ffeb3b; color: #000; font-weight: bold; } + +/* Priority levels */ +.priority-high { color: #c00; font-weight: bold; } +.priority-medium { color: #995500; } +.priority-low { color: #004488; } +``` + +## See Also + +- [Styling Guide](./styling.md) - Theme and CSS variable customization +- [Dark Mode Guide](./dark-mode.md) - Dark mode specific styling +- SlickGrid API Documentation - `setCellCssStyles()` and related methods diff --git a/frameworks/slickgrid-vue/docs/TOC.md b/frameworks/slickgrid-vue/docs/TOC.md index 54e385bdad..e5a1592fbe 100644 --- a/frameworks/slickgrid-vue/docs/TOC.md +++ b/frameworks/slickgrid-vue/docs/TOC.md @@ -10,6 +10,7 @@ * [Dark Mode](styling/dark-mode.md) * [Styling CSS/SASS/Themes](styling/styling.md) +* [Dynamic Styling with Item Metadata](styling/dynamic-styling-with-metadata.md) * [Multiple Column Header Rows](styling/multiple-column-header-rows.md) ## Column Functionalities diff --git a/frameworks/slickgrid-vue/docs/styling/dynamic-styling-with-metadata.md b/frameworks/slickgrid-vue/docs/styling/dynamic-styling-with-metadata.md new file mode 100644 index 0000000000..c176615517 --- /dev/null +++ b/frameworks/slickgrid-vue/docs/styling/dynamic-styling-with-metadata.md @@ -0,0 +1,424 @@ +# Dynamic Styling with Item Metadata + +## Overview + +SlickGrid provides powerful mechanisms to apply CSS styling dynamically to grid cells based on item properties or runtime conditions. This guide shows how to use item metadata and the grid's styling APIs to create responsive, data-driven cell highlighting and styling. + +## Table of Contents + +- [Storing Metadata in Items](#storing-metadata-in-items) +- [Using setCellCssStyles for Dynamic Styling](#using-setcellcsssstyles-for-dynamic-styling) +- [Using Cell Metadata for Per-Cell Styling](#using-cell-metadata-for-per-cell-styling) +- [Common Use Cases](#common-use-cases) +- [Best Practices](#best-practices) + +## Storing Metadata in Items + +SlickGrid items are plain JavaScript objects. You can attach any metadata properties to items alongside your data properties: + +```typescript +interface Item { + id: number; + name: string; + price: number; + // Custom metadata properties + status?: 'active' | 'inactive' | 'pending'; + priority?: 'low' | 'medium' | 'high'; + isModified?: boolean; + validationErrors?: string[]; + customData?: Record; +} + +const data: Item[] = [ + { id: 1, name: 'Product A', price: 100, status: 'active', priority: 'high', isModified: true }, + { id: 2, name: 'Product B', price: 200, status: 'inactive', priority: 'low' }, + { id: 3, name: 'Product C', price: 150, status: 'pending', validationErrors: ['Invalid price'] }, +]; +``` + +## Using setCellCssStyles for Dynamic Styling + +The `setCellCssStyles(key, hash)` method is the primary way to apply CSS classes to grid cells dynamically. It uses a **style key** to manage overlays of CSS classes that can be added, modified, or removed independently. + +### Basic API + +```typescript +// Apply styles +grid.setCellCssStyles(key, hash); + +// Remove styles +grid.removeCellCssStyles(key); + +// Remove styles matching a predicate +grid.removeCellCssStylesBatch((key) => key.startsWith('highlight-')); +``` + +### Hash Structure + +The hash follows a strict structure: + +```typescript +interface CssStyleHash { + [rowIndex: number]: { + [columnId: string | number]: cssClassName // Single class or space-separated classes + } +} +``` + +**Important:** Column keys MUST be column IDs (strings or numbers), NOT numeric indices. + +### Example: Status-Based Highlighting + +```typescript +// Define your CSS classes +const styles = ` + .status-active { background-color: #d4edda; } + .status-inactive { background-color: #f8d7da; } + .status-pending { background-color: #fff3cd; } +`; + +// Function to apply styles based on item metadata +function highlightByStatus(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.status) { + const className = `status-${item.status}`; + const columns = grid.getColumns(); + + // Apply to specific columns (e.g., 'name' and 'price') + columns.forEach((col) => { + if (col.id === 'name' || col.id === 'price') { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = className; + } + }); + } + }); + + grid.setCellCssStyles('status-highlight', hash); +} + +// Call on data load or update +highlightByStatus(grid, data); +``` + +### Example: Modified Rows Indicator + +```typescript +function highlightModifiedRows(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.isModified) { + hash[rowIndex] = {}; + // Add a visual indicator to the first cell of modified rows + const firstCol = grid.getColumns()[0]; + if (firstCol) { + hash[rowIndex][firstCol.id] = 'unsaved-changes modified-indicator'; + } + } + }); + + grid.setCellCssStyles('modified-rows', hash); +} + +// CSS +const styles = ` + .unsaved-changes { border-left: 4px solid #ff6b6b; } + .modified-indicator { background-color: #ffe0e0; } +`; +``` + +## Using Cell Metadata for Per-Cell Styling + +SlickGrid also supports metadata on individual cells through the `cssClasses` property: + +```typescript +interface Item { + id: number; + name: string; + cells?: { + [columnId: string]: { + cssClasses?: string; + value?: any; + // other cell metadata + } + } +} + +const data: Item[] = [ + { + id: 1, + name: 'Product A', + cells: { + price: { + cssClasses: 'price-high discount-eligible', + value: 100 + } + } + } +]; + +// The grid formatter can use this metadata +const priceFormatter = (row: number, cell: number, value: any, columnDef: Column, item: Item) => { + const cellMeta = item.cells?.[columnDef.id]; + const classes = cellMeta?.cssClasses || ''; + return `${value}`; +}; +``` + +## Common Use Cases + +### 1. Validation Error Highlighting + +```typescript +function highlightValidationErrors(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + if (item.validationErrors && item.validationErrors.length > 0) { + hash[rowIndex] = {}; + const columns = grid.getColumns(); + columns.forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = 'validation-error'; + }); + } + }); + + grid.setCellCssStyles('validation-errors', hash); +} + +// CSS +const styles = ` + .validation-error { + background-color: #ffcccc; + border: 1px solid #ff6b6b; + } +`; +``` + +### 2. Priority-Based Row Coloring + +```typescript +function colorByPriority(grid: SlickGrid, data: Item[]) { + const hash: Record> = {}; + const priorityClasses: Record = { + high: 'priority-high', + medium: 'priority-medium', + low: 'priority-low' + }; + + data.forEach((item, rowIndex) => { + if (item.priority) { + hash[rowIndex] = {}; + grid.getColumns().forEach((col) => { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][col.id] = priorityClasses[item.priority]; + }); + } + }); + + grid.setCellCssStyles('priority-coloring', hash); +} + +// CSS +const styles = ` + .priority-high { background-color: #ffe0e0; color: #c00; font-weight: bold; } + .priority-medium { background-color: #fff3cd; color: #995500; } + .priority-low { background-color: #e8f4f8; color: #004488; } +`; +``` + +### 3. Conditional Cell Styling + +```typescript +function applyConditionalFormatting(grid: SlickGrid, data: Item[], rules: FormatRule[]) { + const hash: Record> = {}; + + data.forEach((item, rowIndex) => { + rules.forEach((rule) => { + if (rule.condition(item)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + rule.affectedColumns.forEach((colId) => { + if (!hash[rowIndex][colId]) { + hash[rowIndex][colId] = rule.cssClass; + } else { + // Append class if column already has styling + hash[rowIndex][colId] += ' ' + rule.cssClass; + } + }); + } + }); + }); + + grid.setCellCssStyles('conditional-formatting', hash); +} + +interface FormatRule { + condition: (item: Item) => boolean; + affectedColumns: string[]; + cssClass: string; +} + +// Example rules +const rules: FormatRule[] = [ + { + condition: (item) => item.price > 500, + affectedColumns: ['price'], + cssClass: 'price-expensive' + }, + { + condition: (item) => item.isModified, + affectedColumns: ['name', 'price'], + cssClass: 'unsaved-changes' + } +]; +``` + +### 4. Search Result Highlighting + +```typescript +function highlightSearchResults(grid: SlickGrid, data: Item[], searchTerm: string, searchColumns: string[]) { + const hash: Record> = {}; + const searchLower = searchTerm.toLowerCase(); + + data.forEach((item, rowIndex) => { + searchColumns.forEach((colId) => { + const value = String(item[colId as keyof Item] || '').toLowerCase(); + if (value.includes(searchLower)) { + if (!hash[rowIndex]) hash[rowIndex] = {}; + hash[rowIndex][colId] = 'search-highlight'; + } + }); + }); + + grid.setCellCssStyles('search-results', hash); +} + +// CSS +const styles = ` + .search-highlight { + background-color: #ffeb3b; + color: #000; + font-weight: bold; + } +`; +``` + +## Best Practices + +### 1. Use Meaningful Style Keys + +```typescript +// Good - descriptive keys that indicate purpose +grid.setCellCssStyles('validation-errors', hash); +grid.setCellCssStyles('unsaved-changes', hash); +grid.setCellCssStyles('search-highlights', hash); + +// Avoid - vague keys +grid.setCellCssStyles('highlight', hash); // Which highlight? +grid.setCellCssStyles('style1', hash); // Not descriptive +``` + +### 2. Manage Multiple Style Overlays + +Different style keys can be applied simultaneously. The last applied style takes visual precedence: + +```typescript +// Apply multiple independent styling layers +grid.setCellCssStyles('status-highlighting', statusHash); +grid.setCellCssStyles('validation-errors', validationHash); +grid.setCellCssStyles('search-highlights', searchHash); + +// Later, remove only specific styling without affecting others +grid.removeCellCssStyles('search-highlights'); +``` + +### 3. Handle Column ID Conversion + +Always convert column indices to IDs when building the hash: + +```typescript +// ✓ Correct - using column IDs +const columns = grid.getColumns(); +const column = columns[cellIndex]; +const columnId = column.id; +hash[row][columnId] = className; + +// ✗ Wrong - using numeric indices +hash[row][cellIndex] = className; // Will not work +``` + +### 4. Batch Updates for Performance + +If updating many rows, batch the operations: + +```typescript +function updateStyling(grid: SlickGrid, data: Item[]) { + // Build entire hash before applying + const hash: Record> = {}; + + // Populate hash for all rows + data.forEach((item, rowIndex) => { + // ... build styling for this row + }); + + // Single API call + grid.setCellCssStyles('batch-styling', hash); +} + +// Don't do this - multiple API calls are slower +data.forEach((item, rowIndex) => { + grid.setCellCssStyles(`style-row-${rowIndex}`, { + [rowIndex]: { columnId: className } + }); +}); +``` + +### 5. Clean Up Unused Styles + +Remove style keys that are no longer needed: + +```typescript +// Remove specific styling +grid.removeCellCssStyles('old-highlighting'); + +// Remove multiple styles at once +['validation-errors', 'search-highlights', 'outdated-style'].forEach((key) => { + grid.removeCellCssStyles(key); +}); + +// Remove all styles matching a pattern +grid.removeCellCssStylesBatch((key) => key.startsWith('temporary-')); +``` + +### 6. CSS Organization + +Keep CSS organized by styling purpose: + +```css +/* Status-based styling */ +.status-active { background-color: #d4edda; } +.status-inactive { background-color: #f8d7da; } +.status-pending { background-color: #fff3cd; } + +/* State indicators */ +.unsaved-changes { border-left: 4px solid #ff6b6b; } +.validation-error { background-color: #ffcccc; } + +/* User interaction */ +.search-highlight { background-color: #ffeb3b; color: #000; font-weight: bold; } + +/* Priority levels */ +.priority-high { color: #c00; font-weight: bold; } +.priority-medium { color: #995500; } +.priority-low { color: #004488; } +``` + +## See Also + +- [Styling Guide](./styling.md) - Theme and CSS variable customization +- [Dark Mode Guide](./dark-mode.md) - Dark mode specific styling +- SlickGrid API Documentation - `setCellCssStyles()` and related methods From 6fdc7d640a039555b50872d3db22e67192310561 Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Tue, 11 Aug 2026 11:49:23 -0400 Subject: [PATCH 17/57] fix(grid): force top row offset for RowDetail in transform mode (#2722) --- packages/common/src/core/__tests__/slickGrid.spec.ts | 10 +++------- packages/common/src/core/slickGrid.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 4a2ece321e..cfc5914f51 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -182,9 +182,7 @@ describe('SlickGrid core file', () => { expect(styleElm?.getAttribute('nonce')).toBe('test-nonce'); }); - it('should display a console warning when Row Detail is enabled with `rowTopOffsetRenderType` is set to "transfrom"', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockReturnValue(); - + it('should auto-fallback to top when Row Detail is enabled with `rowTopOffsetRenderType` set to "transform"', () => { document.body.style.zoom = '90%'; const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; grid = new SlickGrid( @@ -197,9 +195,7 @@ describe('SlickGrid core file', () => { grid.init(); expect(grid).toBeTruthy(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using either RowDetail and/or RowSpan') - ); + expect(grid.getOptions().rowTopOffsetRenderType).toBe('top'); }); it('should display a console warning when RowSpan is enabled with `rowTopOffsetRenderType` is set to "transfrom"', () => { @@ -218,7 +214,7 @@ describe('SlickGrid core file', () => { expect(grid).toBeTruthy(); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using either RowDetail and/or RowSpan') + expect.stringContaining('[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using RowSpan') ); }); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 6b986bbbb2..4a5076b918 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -648,9 +648,9 @@ export class SlickGrid = Column, O e 'SlickGrid relies on the `rowHeight` grid option to do row positioning & calculation and when zoom is not 100% then calculation becomes all offset.' ); } - if (this._options.rowTopOffsetRenderType === 'transform' && (this._options.enableCellRowSpan || this._options.enableRowDetailView)) { + if (this._options.rowTopOffsetRenderType === 'transform' && this._options.enableCellRowSpan) { console.warn( - '[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using either RowDetail and/or RowSpan since "transform" is known to have UI issues.' + '[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using RowSpan since "transform" is known to have UI issues.' ); } this.finishInitialization(); @@ -3846,6 +3846,12 @@ export class SlickGrid = Column, O e if (this._options.autoHeight) { this._options.leaveSpaceForNewRows = false; } + + // Row Detail relies on absolute top-based row positioning; force a safe fallback. + if (this._options.rowTopOffsetRenderType === 'transform' && this._options.enableRowDetailView) { + this._options.rowTopOffsetRenderType = 'top'; + } + // make sure the freeze is also valid without breaking the UI (e.g. we can't left freeze columns wider than visible left canvas width) if (!this.validateColumnFreezeWidth(this._options.frozenColumn)) { this._options.frozenColumn = this._prevFrozenColumnIdx < this._options.frozenColumn! ? this._prevFrozenColumnIdx : -1; From 5254db2e4be57adab98a2446076045563e171b13 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 11 Aug 2026 11:50:31 -0400 Subject: [PATCH 18/57] refactor: improve unsupported zoom level warning --- .../common/src/core/__tests__/slickGrid.spec.ts | 16 ++++++++++++++-- packages/common/src/core/slickGrid.ts | 8 +++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index cfc5914f51..a5ade25aa0 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -132,7 +132,7 @@ describe('SlickGrid core file', () => { expect(grid.getPubSubService()).toEqual(pubSubServiceStub); }); - it('should display a console warning when body zoom level is different than 100%', () => { + it('should not display a console warning when body zoom level is different than 100% in low-risk config', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockReturnValue(); document.body.style.zoom = '90%'; @@ -141,7 +141,19 @@ describe('SlickGrid core file', () => { grid.init(); expect(grid).toBeTruthy(); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Slickgrid] Zoom level other than 100% is not supported')); + expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[Slickgrid] Zoom level other than 100%')); + }); + + it('should display a console warning when body zoom level is different than 100% in high-risk config', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockReturnValue(); + + document.body.style.zoom = '90%'; + const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; + grid = new SlickGrid('#myGrid', [], columns, { ...defaultOptions, enableVariableRowHeight: true }, pubSubServiceStub); + grid.init(); + + expect(grid).toBeTruthy(); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Slickgrid] Zoom level other than 100% can cause subpar rendering')); }); it('should not display a console warning when body zoom level is 100%', () => { diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 4a5076b918..0fd40d63a1 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -642,10 +642,12 @@ export class SlickGrid = Column, O e /** Initializes the grid. */ init(): void { - if (!this._options.silenceWarnings && document.body.style.zoom && document.body.style.zoom !== '100%') { + // prettier-ignore + const isZoomLevelUnsupported = (this._options.enableVariableRowHeight || this._options.enableCellRowSpan || this._options.enableRowDetailView || this._options.frozenRow! > 0); + if (!this._options.silenceWarnings && document.body.style.zoom && document.body.style.zoom !== '100%' && isZoomLevelUnsupported) { console.warn( - '[Slickgrid] Zoom level other than 100% is not supported by the library and will give subpar experience. ' + - 'SlickGrid relies on the `rowHeight` grid option to do row positioning & calculation and when zoom is not 100% then calculation becomes all offset.' + '[Slickgrid] Zoom level other than 100% can cause subpar rendering in some configurations. ' + + 'SlickGrid relies on row positioning calculations that can drift with browser zoom.' ); } if (this._options.rowTopOffsetRenderType === 'transform' && this._options.enableCellRowSpan) { From 7ff4a99ab01927712ab5af638c30c2392f43e852 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 12 Aug 2026 09:35:26 -0400 Subject: [PATCH 19/57] docs: add missing WebMCP to packages list --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eae72d113f..2153a5308a 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Slickgrid-Universal has **100%** Unit Test Coverage, 5000+ Vitest unit tests and | [@slickgrid-universal/utils](https://github.com/ghiscoding/slickgrid-universal/tree/master/packages/utils) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/utils.svg)](https://www.npmjs.com/package/@slickgrid-universal/utils) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/utils.svg)](https://www.npmjs.com/package/@slickgrid-universal/utils) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/utils/CHANGELOG.md) | [@slickgrid-universal/vanilla-bundle](https://github.com/ghiscoding/slickgrid-universal/tree/master/packages/vanilla-bundle) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/vanilla-bundle.svg)](https://www.npmjs.com/package/@slickgrid-universal/vanilla-bundle) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/vanilla-bundle.svg)](https://www.npmjs.com/package/@slickgrid-universal/vanilla-bundle) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/vanilla-bundle/CHANGELOG.md) | ✓ | | [@slickgrid-universal/vanilla-force-bundle](https://github.com/ghiscoding/slickgrid-universal/tree/master/packages/vanilla-force-bundle) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/vanilla-force-bundle.svg)](https://www.npmjs.com/package/@slickgrid-universal/vanilla-force-bundle) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/vanilla-force-bundle.svg)](https://www.npmjs.com/package/@slickgrid-universal/vanilla-force-bundle) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/vanilla-force-bundle/CHANGELOG.md) | ✓ | +| [@slickgrid-universal/web-mcp](https://github.com/ghiscoding/slickgrid-universal/tree/master/packages/web-mcp) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/web-mcp.svg)](https://www.npmjs.com/package/@slickgrid-universal/web-mcp) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/web-mcp.svg)](https://www.npmjs.com/package/@slickgrid-universal/web-mcp) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/packages/web-mcp/CHANGELOG.md) | ✓ | | ... | ... | | | | [@slickgrid-universal/angular-row-detail-plugin](https://github.com/ghiscoding/slickgrid-universal/blob/master/frameworks-plugins/angular-row-detail-plugin) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/angular-row-detail-plugin.svg)](https://www.npmjs.com/package/@slickgrid-universal/angular-row-detail-plugin) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/angular-row-detail-plugin.svg)](https://www.npmjs.com/package/"@slickgrid-universal/angular-row-detail-plugin) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/frameworks-plugins/angular-row-detail-plugin/CHANGELOG.md) | ✓ | | [@slickgrid-universal/aurelia-row-detail-plugin](https://github.com/ghiscoding/slickgrid-universal/blob/master/frameworks-plugins/aurelia-row-detail-plugin) | [![npm](https://img.shields.io/npm/v/@slickgrid-universal/aurelia-row-detail-plugin.svg)](https://www.npmjs.com/package/@slickgrid-universal/aurelia-row-detail-plugin) | [![NPM downloads](https://img.shields.io/npm/dy/@slickgrid-universal/aurelia-row-detail-plugin.svg)](https://www.npmjs.com/package/"@slickgrid-universal/aurelia-row-detail-plugin) | [changelog](https://github.com/ghiscoding/slickgrid-universal/blob/master/frameworks-plugins/aurelia-row-detail-plugin/CHANGELOG.md) | ✓ | From 42018f78948f47afdabe4d6fd8d12cdf1afcd08f Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Wed, 12 Aug 2026 11:43:52 -0400 Subject: [PATCH 20/57] chore(demos): fix Draggable Grouping examples (#2724) * chore(demos): fix Draggable Grouping examples --- .../src/examples/slickgrid/example18.html | 20 +++++++-- .../src/examples/slickgrid/example18.ts | 40 +++++++++++------ .../aurelia/test/cypress/e2e/example18.cy.ts | 27 ++++++++--- .../src/examples/slickgrid/Example03.tsx | 13 ++---- .../src/examples/slickgrid/Example18.tsx | 45 +++++-------------- demos/react/test/cypress/e2e/example18.cy.ts | 15 +++++++ demos/vue/src/components/Example18.vue | 9 ++-- demos/vue/test/cypress/e2e/example18.cy.ts | 15 +++++++ .../test/cypress/e2e/example18.cy.ts | 15 +++++++ 9 files changed, 128 insertions(+), 71 deletions(-) diff --git a/demos/aurelia/src/examples/slickgrid/example18.html b/demos/aurelia/src/examples/slickgrid/example18.html index 7607baae6b..856eee9235 100644 --- a/demos/aurelia/src/examples/slickgrid/example18.html +++ b/demos/aurelia/src/examples/slickgrid/example18.html @@ -109,10 +109,22 @@

-
- + + + +
+
+ +
+
+
diff --git a/demos/aurelia/src/examples/slickgrid/example18.ts b/demos/aurelia/src/examples/slickgrid/example18.ts index bc4be3af9c..8368fc8a5e 100644 --- a/demos/aurelia/src/examples/slickgrid/example18.ts +++ b/demos/aurelia/src/examples/slickgrid/example18.ts @@ -32,6 +32,7 @@ export class Example18 { hideSubTitle = false; processing = false; selectedGroupingFields: Array = ['', '', '']; + private _isUpdatingGroupingFromSelect = false; excelExportService = new ExcelExportService(); pdfExportService = new PdfExportService(); textExportService = new TextExportService(); @@ -356,19 +357,27 @@ export class Example18 { } } - groupByFieldName() { - this.clearGrouping(); - if (this.draggableGroupingPlugin && this.draggableGroupingPlugin.setDroppedGroups) { - this.showPreHeader(); - - // get the field names from Group By select(s) dropdown, but filter out any empty fields - const groupedFields = this.selectedGroupingFields.filter((g) => g !== ''); - if (groupedFields.length === 0) { - this.clearGrouping(); - } else { - this.draggableGroupingPlugin.setDroppedGroups(groupedFields); + groupByFieldName(event: Event, index: number) { + const selectedValue = (event.target as HTMLSelectElement).value; + const updatedGroupingFields = this.selectedGroupingFields.map((field, fieldIndex) => (fieldIndex === index ? selectedValue : field)); + this.selectedGroupingFields = updatedGroupingFields; + this._isUpdatingGroupingFromSelect = true; + try { + this.clearGrouping(); + if (this.draggableGroupingPlugin && this.draggableGroupingPlugin.setDroppedGroups) { + this.showPreHeader(); + + // get the field names from Group By select(s) dropdown, but filter out any empty fields + const groupedFields = updatedGroupingFields.filter((g) => g !== ''); + if (groupedFields.length === 0) { + this.clearGrouping(); + } else { + this.draggableGroupingPlugin.setDroppedGroups(groupedFields); + } + this.gridObj.invalidate(); // invalidate all rows and re-render } - this.gridObj.invalidate(); // invalidate all rows and re-render + } finally { + this._isUpdatingGroupingFromSelect = false; } } @@ -376,10 +385,13 @@ export class Example18 { const caller = change?.caller ?? []; const groups = change?.groupColumns ?? []; + if (this._isUpdatingGroupingFromSelect) { + return; + } + if (Array.isArray(this.selectedGroupingFields) && Array.isArray(groups) && groups.length > 0) { // update all Group By select dropdown - this.selectedGroupingFields.forEach((_g, i) => (this.selectedGroupingFields[i] = groups[i]?.getter ?? '')); - this.selectedGroupingFields = [...this.selectedGroupingFields]; // force dirty checking + this.selectedGroupingFields = [0, 1, 2].map((index) => groups[index]?.getter ?? ''); } else if (groups.length === 0 && caller === 'remove-group') { this.clearGroupingSelects(); } diff --git a/demos/aurelia/test/cypress/e2e/example18.cy.ts b/demos/aurelia/test/cypress/e2e/example18.cy.ts index dd795e35c8..fd2f52bfb1 100644 --- a/demos/aurelia/test/cypress/e2e/example18.cy.ts +++ b/demos/aurelia/test/cypress/e2e/example18.cy.ts @@ -102,7 +102,7 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { cy.get('[data-test="group-duration-sort-value-btn"]').click(); cy.get('[data-test="collapse-all-btn"]').click(); - cy.get('.grouping-selects select:nth(0)').should('have.value', 'Duration'); + cy.get('.grouping-selects select:nth(0)').should('have.value', 'duration'); cy.get('.grouping-selects select:nth(1)').should('not.have.value'); cy.get('.grouping-selects select:nth(2)').should('not.have.value'); cy.get(`[data-row=0] > .slick-cell:nth(0) .slick-group-toggle.collapsed`).should('have.length', 1); @@ -128,11 +128,26 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { it('should show 1 column title (Duration) shown in the pre-header section', () => { cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); - cy.get('.grouping-selects select:nth(0)').should('have.value', 'Duration'); + cy.get('.grouping-selects select:nth(0)').should('have.value', 'duration'); cy.get('.grouping-selects select:nth(1)').should('not.have.value'); cy.get('.grouping-selects select:nth(2)').should('not.have.value'); }); + it('should update grid grouping when selecting Cost in the second Group by field dropdown', () => { + cy.get('.grouping-selects select:nth(1)').select('cost'); + + cy.get('.grouping-selects select:nth(0)').should('have.value', 'duration'); + cy.get('.grouping-selects select:nth(1)').should('have.value', 'cost'); + cy.get('.grouping-selects select:nth(2)').should('not.have.value'); + cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); + cy.get('.slick-dropped-grouping:nth(1) div').contains('Cost'); + cy.get(`[data-row=0].slick-group-level-0 > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration:'); + cy.get(`[data-row=1].slick-group-level-1 .slick-group-title`).should('contain', 'Cost:'); + + cy.get('[data-test="clear-grouping-btn"]').click(); + cy.get('[data-test="group-duration-sort-value-btn"]').click(); + }); + it('should "Group by Duration then Effort-Driven" and expect 1st row to be expanded, 2nd row to be expanded and 3rd row to be a regular row', () => { cy.get('[data-test="group-duration-effort-btn"]').click(); @@ -149,8 +164,8 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { it('should show 2 column titles (Duration, Effort-Driven) shown in the pre-header section & same select dropdown', () => { cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); cy.get('.slick-dropped-grouping:nth(1) div').contains('Effort-Driven'); - cy.get('.grouping-selects select:nth(0)').should('have.value', 'Duration'); - cy.get('.grouping-selects select:nth(1)').should('have.value', 'Effort-Driven'); + cy.get('.grouping-selects select:nth(0)').should('have.value', 'duration'); + cy.get('.grouping-selects select:nth(1)').should('have.value', 'effortDriven'); cy.get('.grouping-selects select:nth(2)').should('not.have.value'); }); @@ -159,8 +174,8 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { cy.get('.slick-dropped-grouping:nth(0) div').contains('Effort-Driven'); cy.get('.slick-dropped-grouping:nth(1) div').contains('Duration'); - cy.get('.grouping-selects select:nth(0)').should('have.value', 'Effort-Driven'); - cy.get('.grouping-selects select:nth(1)').should('have.value', 'Duration'); + cy.get('.grouping-selects select:nth(0)').should('have.value', 'effortDriven'); + cy.get('.grouping-selects select:nth(1)').should('have.value', 'duration'); cy.get('.grouping-selects select:nth(2)').should('not.have.value'); }); diff --git a/demos/react-fluent/src/examples/slickgrid/Example03.tsx b/demos/react-fluent/src/examples/slickgrid/Example03.tsx index e58182675b..6295629df3 100644 --- a/demos/react-fluent/src/examples/slickgrid/Example03.tsx +++ b/demos/react-fluent/src/examples/slickgrid/Example03.tsx @@ -400,18 +400,11 @@ const Example03: React.FC = () => { } function onGroupChanged(change: { caller?: string; groupColumns: Grouping[] }) { - const caller = change?.caller ?? []; + const caller = change?.caller ?? ''; const groups = change?.groupColumns ?? []; - const tmpSelectedGroupingFields = selectedGroupingFields; - - if (Array.isArray(tmpSelectedGroupingFields) && Array.isArray(groups) && groups.length > 0) { - // update all Group By select dropdown - tmpSelectedGroupingFields.forEach((_g, i) => (tmpSelectedGroupingFields[i] = (groups[i]?.getter ?? '') as string)); - setSelectedGroupingFields([...tmpSelectedGroupingFields]); - // use JS to change select dropdown value - // TODO: this should be removed in the future and only use setState - tmpSelectedGroupingFields.forEach((val, index) => dynamicallyChangeSelectGroupByValue(index, val as string)); + if (groups.length > 0) { + setSelectedGroupingFields((currentFields) => currentFields.map((_field, index) => (groups[index]?.getter ?? '') as string)); } else if (groups.length === 0 && caller === 'remove-group') { clearGroupingSelects(); } diff --git a/demos/react/src/examples/slickgrid/Example18.tsx b/demos/react/src/examples/slickgrid/Example18.tsx index 9d726d15da..12710c956e 100644 --- a/demos/react/src/examples/slickgrid/Example18.tsx +++ b/demos/react/src/examples/slickgrid/Example18.tsx @@ -299,32 +299,14 @@ const Example18: React.FC = () => { } function clearGroupingSelects() { - selectedGroupingFields.forEach((_g, i) => (selectedGroupingFields[i] = '')); - setSelectedGroupingFields(['', '', '']); // force dirty checking - - // reset all select dropdown using JS - selectedGroupingFields.forEach((_val, index) => dynamicallyChangeSelectGroupByValue(index, '')); + setSelectedGroupingFields(['', '', '']); } function changeSelectedGroupByField(e: React.ChangeEvent, index: number) { const val = (e.target as HTMLSelectElement).value; - updateSelectGroupFieldsArray(index, val, () => groupByFieldName()); - } - - /** Change the select dropdown group using pure JS */ - function dynamicallyChangeSelectGroupByValue(selectGroupIndex = 0, val = '') { - const selectElm = document.querySelector(`.select-group-${selectGroupIndex}`); - if (selectElm) { - selectElm.selectedIndex = Array.from(selectElm.options).findIndex((o) => o.value === val); - updateSelectGroupFieldsArray(selectGroupIndex, val); - } - } - - /** update grouping field array React state */ - function updateSelectGroupFieldsArray(index: number, val: string, _setStateCallback?: () => void) { - const tmpSelectedGroupingFields = selectedGroupingFields; - tmpSelectedGroupingFields[index] = val; - setSelectedGroupingFields([...tmpSelectedGroupingFields]); // force dirty checking + const updatedGroupingFields = selectedGroupingFields.map((field, fieldIndex) => (fieldIndex === index ? val : field)); + setSelectedGroupingFields(updatedGroupingFields); + groupByFieldName(updatedGroupingFields); } function clearGrouping(invalidateRows = true) { @@ -379,13 +361,13 @@ const Example18: React.FC = () => { } } - function groupByFieldName() { + function groupByFieldName(groupingFields = selectedGroupingFields) { clearGrouping(); if (draggableGroupingPlugin?.setDroppedGroups) { showPreHeader(); // get the field names from Group By select(s) dropdown, but filter out any empty fields - const groupedFields = selectedGroupingFields.filter((g) => g !== ''); + const groupedFields = groupingFields.filter((g) => g !== ''); if (groupedFields.length === 0) { clearGrouping(); } else { @@ -396,18 +378,12 @@ const Example18: React.FC = () => { } function onGroupChanged(change: { caller?: string; groupColumns: Grouping[] }) { - const caller = change?.caller ?? []; + const caller = change?.caller ?? ''; const groups = change?.groupColumns ?? []; - const tmpSelectedGroupingFields = selectedGroupingFields; - if (Array.isArray(tmpSelectedGroupingFields) && Array.isArray(groups) && groups.length > 0) { + if (groups.length > 0) { // update all Group By select dropdown - tmpSelectedGroupingFields.forEach((_g, i) => (tmpSelectedGroupingFields[i] = (groups[i]?.getter ?? '') as string)); - setSelectedGroupingFields([...tmpSelectedGroupingFields]); - - // use JS to change select dropdown value - // TODO: this should be removed in the future and only use setState - tmpSelectedGroupingFields.forEach((val, index) => dynamicallyChangeSelectGroupByValue(index, val as string)); + setSelectedGroupingFields((currentFields) => currentFields.map((_field, index) => (groups[index]?.getter ?? '') as string)); } else if (groups.length === 0 && caller === 'remove-group') { clearGroupingSelects(); } @@ -609,9 +585,10 @@ const Example18: React.FC = () => { + diff --git a/demos/vue/test/cypress/e2e/example18.cy.ts b/demos/vue/test/cypress/e2e/example18.cy.ts index 397df8aa69..fd2f52bfb1 100644 --- a/demos/vue/test/cypress/e2e/example18.cy.ts +++ b/demos/vue/test/cypress/e2e/example18.cy.ts @@ -133,6 +133,21 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { cy.get('.grouping-selects select:nth(2)').should('not.have.value'); }); + it('should update grid grouping when selecting Cost in the second Group by field dropdown', () => { + cy.get('.grouping-selects select:nth(1)').select('cost'); + + cy.get('.grouping-selects select:nth(0)').should('have.value', 'duration'); + cy.get('.grouping-selects select:nth(1)').should('have.value', 'cost'); + cy.get('.grouping-selects select:nth(2)').should('not.have.value'); + cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); + cy.get('.slick-dropped-grouping:nth(1) div').contains('Cost'); + cy.get(`[data-row=0].slick-group-level-0 > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration:'); + cy.get(`[data-row=1].slick-group-level-1 .slick-group-title`).should('contain', 'Cost:'); + + cy.get('[data-test="clear-grouping-btn"]').click(); + cy.get('[data-test="group-duration-sort-value-btn"]').click(); + }); + it('should "Group by Duration then Effort-Driven" and expect 1st row to be expanded, 2nd row to be expanded and 3rd row to be a regular row', () => { cy.get('[data-test="group-duration-effort-btn"]').click(); diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example18.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example18.cy.ts index a2f067c377..e3ac5b5da3 100644 --- a/frameworks/angular-slickgrid/test/cypress/e2e/example18.cy.ts +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example18.cy.ts @@ -133,6 +133,21 @@ describe('Example 18 - Draggable Grouping & Aggregators', () => { cy.get('.grouping-selects select:nth(2)').should('not.have.value'); }); + it('should update grid grouping when selecting Cost in the second Group by field dropdown', () => { + cy.get('.grouping-selects select:nth(1)').select('Cost'); + + cy.get('.grouping-selects select:nth(0)').find('option:selected').should('have.text', 'Duration'); + cy.get('.grouping-selects select:nth(1)').find('option:selected').should('have.text', 'Cost'); + cy.get('.grouping-selects select:nth(2)').should('not.have.value'); + cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); + cy.get('.slick-dropped-grouping:nth(1) div').contains('Cost'); + cy.get(`[data-row=0].slick-group-level-0 > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration:'); + cy.get(`[data-row=1].slick-group-level-1 .slick-group-title`).should('contain', 'Cost:'); + + cy.get('[data-test="clear-grouping-btn"]').click(); + cy.get('[data-test="group-duration-sort-value-btn"]').click(); + }); + it('should "Group by Duration then Effort-Driven" and expect 1st row to be expanded, 2nd row to be expanded and 3rd row to be a regular row', () => { cy.get('[data-test="group-duration-effort-btn"]').click(); From 6dd97655b0a543f2cf942bfc798529583fc8243b Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Wed, 12 Aug 2026 11:56:33 -0400 Subject: [PATCH 21/57] fix: allow grid events through range overlays and readonly paste (#2723) * fix: allow grid events through range overlays and readonly paste --- demos/vanilla/src/examples/example19.ts | 2 +- .../__tests__/slickCellRangeDecorator.spec.ts | 3 +++ .../src/extensions/slickCellRangeDecorator.ts | 8 ++++---- test/cypress/e2e/example19.cy.ts | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/demos/vanilla/src/examples/example19.ts b/demos/vanilla/src/examples/example19.ts index 79a592d715..83c011b9db 100644 --- a/demos/vanilla/src/examples/example19.ts +++ b/demos/vanilla/src/examples/example19.ts @@ -154,7 +154,7 @@ export default class Example19 { // onCopyCancelled: (e, args: { ranges: SelectedRange[] }) => console.log('onCopyCancelled', args.ranges), onBeforePasteCell: (_e, args) => { // deny the whole first row and the cells C-E of the second row - return !(args.row === 0 || (args.row === 1 && args.cell > 2 && args.cell < 6)); + return !(args.row === 0 || (args.row === 1 && args.cell > 3 && args.cell < 7)); }, clipboardCommandHandler: (clipboardCommand) => { this.clipboardCommandStack.push(clipboardCommand); diff --git a/packages/common/src/extensions/__tests__/slickCellRangeDecorator.spec.ts b/packages/common/src/extensions/__tests__/slickCellRangeDecorator.spec.ts index 05c2f6751e..47a93b78fd 100644 --- a/packages/common/src/extensions/__tests__/slickCellRangeDecorator.spec.ts +++ b/packages/common/src/extensions/__tests__/slickCellRangeDecorator.spec.ts @@ -26,10 +26,12 @@ describe('CellRangeDecorator Plugin', () => { selectionCss: { border: '2px dashed red', zIndex: '9999', + pointerEvents: 'none', }, copyToSelectionCss: { border: '2px dashed blue', zIndex: '9999', + pointerEvents: 'none', }, offset: { top: 0, left: 0, height: 1, width: 1 }, }); @@ -58,6 +60,7 @@ describe('CellRangeDecorator Plugin', () => { expect(plugin.addonElement!.style.width).toEqual(''); expect(plugin.addonElement?.style.border).toBe('2px dashed red'); expect(plugin.addonElement?.style.zIndex).toBe('9999'); + expect(plugin.addonElement?.style.pointerEvents).toBe('none'); }); it('should Show range when called and calculate new position when getCellNodeBox returns a cell position', () => { diff --git a/packages/common/src/extensions/slickCellRangeDecorator.ts b/packages/common/src/extensions/slickCellRangeDecorator.ts index ad4250aa1f..91aaa6e67a 100644 --- a/packages/common/src/extensions/slickCellRangeDecorator.ts +++ b/packages/common/src/extensions/slickCellRangeDecorator.ts @@ -4,10 +4,8 @@ import type { CellRangeDecoratorOption } from '../interfaces/index.js'; /** * Displays an overlay on top of a given cell range. - * TODO: - * Currently, it blocks mouse events to DOM nodes behind it. - * Use FF and WebKit-specific "pointer-events" CSS style, or some kind of event forwarding. - * Could also construct the borders separately using 4 individual DIVs. + * The overlay uses pointer-events: none so it does not block mouse events + * from reaching the grid cells beneath it. */ export class SlickCellRangeDecorator { // -- @@ -22,10 +20,12 @@ export class SlickCellRangeDecorator { selectionCss: { border: '2px dashed red', zIndex: '9999', + pointerEvents: 'none', }, copyToSelectionCss: { border: '2px dashed blue', zIndex: '9999', + pointerEvents: 'none', }, offset: { top: 0, left: 0, height: 1, width: 1 }, } as CellRangeDecoratorOption; diff --git a/test/cypress/e2e/example19.cy.ts b/test/cypress/e2e/example19.cy.ts index fd5c060ea7..7d3dcf4732 100644 --- a/test/cypress/e2e/example19.cy.ts +++ b/test/cypress/e2e/example19.cy.ts @@ -62,6 +62,20 @@ describe('Example 19 - ExcelCopyBuffer with Cell Selection', () => { cy.get('[data-test="toggle-readonly-btn"]').click(); }); + it('should paste into editable cells while skipping blocked cells', () => { + const pasteText = '12:15\t12:16\t12:17\n13:15\t13:16\t13:17'; + + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'readText').resolves(pasteText); + }); + + cy.getCell(1, 2, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }).click().type('{ctrl}v'); + + cy.getCell(1, 2, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }).should('contain.text', '12:15'); + cy.getCell(1, 3, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }).should('contain.text', '12:16'); + cy.getCell(1, 4, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }).should('have.text', '2:5'); + }); + describe('with Pagination of size 20', () => { it('should click on cell B14 then Ctrl+Shift+End with selection B14-CV19', () => { cy.getCell(14, 3, '', { parentSelector: '.grid19', rowHeight: GRID_ROW_HEIGHT }).as('cell_B14').click(); From e3db5628fea60e1a501f7204bd1846ba4de0cf46 Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Wed, 12 Aug 2026 15:16:17 -0400 Subject: [PATCH 22/57] fix(core): sync plugin-added columns to Aurelia & Vue bindings (#2725) * fix(core): sync plugin-added columns to Aurelia & Vue bindings --- .../components/angular-slickgrid.component.ts | 10 +++--- .../src/custom-elements/aurelia-slickgrid.ts | 24 ++++++------- .../src/components/slickgrid-react.tsx | 4 +-- .../src/components/SlickgridVue.vue | 35 ++++++++++++------- .../__tests__/slick-vanilla-grid.spec.ts | 17 --------- .../components/slick-vanilla-grid-bundle.ts | 13 ++----- 6 files changed, 45 insertions(+), 58 deletions(-) diff --git a/frameworks/angular-slickgrid/src/library/components/angular-slickgrid.component.ts b/frameworks/angular-slickgrid/src/library/components/angular-slickgrid.component.ts index 06c8411db5..c561b4d21a 100644 --- a/frameworks/angular-slickgrid/src/library/components/angular-slickgrid.component.ts +++ b/frameworks/angular-slickgrid/src/library/components/angular-slickgrid.component.ts @@ -718,8 +718,10 @@ export class AngularSlickgridComponent implements AfterViewInit, On // save reference for all columns before they optionally become hidden/visible this.sharedService.allColumns = this._columns; - // before certain extentions/plugins potentially adds extra columns not created by the user itself (RowMove, RowDetail, RowSelections) - // we'll subscribe to the event and push back the change to the user so they always use full column defs array including extra cols + // certain extensions/plugins add extra columns not created by the user itself (RowMove, RowDetail, RowSelections), + // we notify the user via the `columns` 2-way binding so they always get the full column defs array including extra cols. + // NOTE: assign the private field directly, going through the `columns` setter would re-run the column + // arrangement pipeline mid-init and conflict with Grid State & Presets. this.subscriptions.push( this._eventPubSubService.subscribe<{ columns: Column[]; grid: SlickGrid }>('onPluginColumnsChanged', (data) => { this._columns = data.columns; @@ -727,8 +729,8 @@ export class AngularSlickgridComponent implements AfterViewInit, On }) ); - // after subscribing to potential columns changed, we are ready to create these optional extensions - // when we did find some to create (RowMove, RowDetail, RowSelections), it will automatically modify column definitions (by previous subscribe) + // create optional extensions (RowMove, RowDetail, RowSelections), they splice their extra columns + // directly into the array below, which also triggers the `onPluginColumnsChanged` subscription above this.extensionService.createExtensionsBeforeGridCreation(this._columns, this.options); // if user entered some Pinning/Frozen "presets", we need to apply them in the grid options diff --git a/frameworks/aurelia-slickgrid/src/custom-elements/aurelia-slickgrid.ts b/frameworks/aurelia-slickgrid/src/custom-elements/aurelia-slickgrid.ts index fcef7d9ea2..293f04684e 100644 --- a/frameworks/aurelia-slickgrid/src/custom-elements/aurelia-slickgrid.ts +++ b/frameworks/aurelia-slickgrid/src/custom-elements/aurelia-slickgrid.ts @@ -369,18 +369,18 @@ export class AureliaSlickgridCustomElement { // save reference for all columns before they optionally become hidden/visible this.sharedService.allColumns = this._columns; - // TODO: revisit later, this conflicts with Grid State (Example 15) - // before certain extentions/plugins potentially adds extra columns not created by the user itself (RowMove, RowDetail, RowSelections) - // we'll subscribe to the event and push back the change to the user so they always use full column defs array including extra cols - // this.subscriptions.push( - // this._eventPubSubService.subscribe<{ columns: Column[]; grid: SlickGrid }>('onPluginColumnsChanged', data => { - // this.columns = data.columns; - // this.columnsChanged(); - // }) - // ); - - // after subscribing to potential columns changed, we are ready to create these optional extensions - // when we did find some to create (RowMove, RowDetail, RowSelections), it will automatically modify column definitions (by previous subscribe) + // certain extensions/plugins add extra columns not created by the user itself (RowMove, RowDetail, RowSelections), + // push them back through the 2-way `columns` bindable so the user always sees the full column defs array. + // the assignment below re-enters `columnsChanged()`, which is safe only because the grid isn't initialized + // yet at this point and it therefore skips `updateColumnDefinitionsList()` (which would clash with Grid State & Presets) + this.subscriptions.push( + this._eventPubSubService.subscribe<{ columns: Column[]; pluginName: string }>('onPluginColumnsChanged', (data) => { + this.columns = data.columns; + }) + ); + + // create optional extensions (RowMove, RowDetail, RowSelections), they splice their extra columns + // directly into the array below, so both `_columns` & `sharedService.allColumns` stay in sync this.extensionService.createExtensionsBeforeGridCreation(this._columns, this.options); // if user entered some Pinning/Frozen "presets", we need to apply them in the grid options diff --git a/frameworks/slickgrid-react/src/components/slickgrid-react.tsx b/frameworks/slickgrid-react/src/components/slickgrid-react.tsx index 5bffdc32c9..a0eece769e 100644 --- a/frameworks/slickgrid-react/src/components/slickgrid-react.tsx +++ b/frameworks/slickgrid-react/src/components/slickgrid-react.tsx @@ -514,8 +514,8 @@ export class SlickgridReact extends React.Component
-``` \ No newline at end of file +``` diff --git a/package.json b/package.json index 4b57ec9262..ac74a60cbe 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "angular:build:demo": "pnpm -r --stream --filter=angular-slickgrid run build:demo", "angular:check-both-builds": "node ./scripts/checkBuild.mjs --framework=angular", "angular:cypress": "pnpm -r --stream --filter=angular-slickgrid run angular:cypress", + "angular:cypress:ci": "pnpm -r --stream --filter=angular-slickgrid run angular:cypress:ci", "angular:serve": "pnpm -r --stream --filter=angular-slickgrid run angular:preview", "angular:test": "pnpm --stream --filter=angular-slickgrid run test", "angular:test:coverage": "pnpm --stream --filter=angular-slickgrid run test:coverage", @@ -84,6 +85,7 @@ "aurelia:build:demo": "pnpm -r --stream --filter=aurelia-slickgrid-demo run build", "aurelia:check-both-builds": "node ./scripts/checkBuild.mjs --framework=aurelia", "aurelia:cypress": "pnpm -r --stream --filter=aurelia-slickgrid-demo run aurelia:cypress", + "aurelia:cypress:ci": "pnpm -r --stream --filter=aurelia-slickgrid-demo run aurelia:cypress:ci", "aurelia:serve": "pnpm -r --stream --filter=aurelia-slickgrid-demo run aurelia:preview", "react:install": "pnpm install --filter=slickgrid-react-demo --filter=slickgrid-react --filter=./packages", "react:build": "pnpm react:build:framework && pnpm react:build:demo", @@ -93,6 +95,7 @@ "react:build:demo": "pnpm -r --stream --filter=slickgrid-react-demo run build", "react:check-both-builds": "node ./scripts/checkBuild.mjs --framework=react", "react:cypress": "pnpm -r --stream --filter=slickgrid-react-demo run react:cypress", + "react:cypress:ci": "pnpm -r --stream --filter=slickgrid-react-demo run react:cypress:ci", "react:serve": "pnpm -r --stream --filter=slickgrid-react-demo run react:preview", "react-fluent:serve": "pnpm -r --stream --filter=slickgrid-react-fluent-demo run react-fluent:preview", "vue:install": "pnpm install --filter=slickgrid-vue-demo --filter=slickgrid-vue --filter=./packages", @@ -103,6 +106,7 @@ "vue:build:demo": "pnpm -r --stream --filter=slickgrid-vue-demo run build", "vue:check-both-builds": "node ./scripts/checkBuild.mjs --framework=vue", "vue:cypress": "pnpm -r --stream --filter=slickgrid-vue-demo run vue:cypress", + "vue:cypress:ci": "pnpm -r --stream --filter=slickgrid-vue-demo run vue:cypress:ci", "vue:serve": "pnpm -r --stream --filter=slickgrid-vue-demo run vue:preview", "docs:99": "// run Lerna-Lite lifecycle to patch Angular-Slickgrid dist/package.json (because of ng-packagr)", "postversion": "pnpm angular:replace-workspace" @@ -152,4 +156,4 @@ "type": "ko_fi", "url": "https://ko-fi.com/ghiscoding" } -} \ No newline at end of file +} diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 119189ae79..0d08f7ef84 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -233,6 +233,25 @@ describe('SlickGrid core file', () => { expect(grid.getOptions().rowTopOffsetRenderType).toBe('top'); }); + it('should preserve transform row positioning when Row Detail uses the overlay render mode', () => { + const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; + grid = new SlickGrid( + '#myGrid', + [], + columns, + { + ...defaultOptions, + rowTopOffsetRenderType: 'transform', + enableRowDetailView: true, + rowDetailView: { renderMode: 'overlay' }, + }, + pubSubServiceStub + ); + grid.init(); + + expect(grid.getOptions().rowTopOffsetRenderType).toBe('transform'); + }); + it('should display a console warning when RowSpan is enabled with `rowTopOffsetRenderType` is set to "transfrom"', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockReturnValue(); @@ -3268,6 +3287,16 @@ describe('SlickGrid core file', () => { expect(focusSink2.isConnected).toBe(false); }); + it('should ignore invalidation after the grid has been destroyed', () => { + grid = new SlickGrid(container, items, columns, defaultOptions); + grid.init(); + + grid.destroy(true); + + expect(() => grid.invalidate()).not.toThrow(); + expect(() => grid.updateRowCount()).not.toThrow(); + }); + it('should keep ARIA header structure with frozen columns enabled', () => { grid = new SlickGrid( container, diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index db71020814..1e3d6cece9 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -3136,6 +3136,9 @@ export class SlickGrid = Column, O e this._focusSink?.remove(); this._focusSink2?.remove(); + // Mark the grid as inactive before its DOM references are cleared. Async data/sort + // callbacks can finish after destruction and must not attempt to update a null container. + this.initialized = false; emptyElement(this._container); this.removeCssRules(); @@ -3849,8 +3852,14 @@ export class SlickGrid = Column, O e this._options.leaveSpaceForNewRows = false; } - // Row Detail relies on absolute top-based row positioning; force a safe fallback. - if (this._options.rowTopOffsetRenderType === 'transform' && this._options.enableRowDetailView) { + // @deprecated v11: remove this Row Detail fallback when inline rendering is removed. + // The legacy inline Row Detail renderer relies on absolute top-based row positioning; + // overlay rendering is compatible with transform-based row positioning. + if ( + this._options.rowTopOffsetRenderType === 'transform' && + this._options.enableRowDetailView && + this._options.rowDetailView?.renderMode !== 'overlay' + ) { this._options.rowTopOffsetRenderType = 'top'; } @@ -4107,7 +4116,8 @@ export class SlickGrid = Column, O e return Math.floor(y / this._options.rowHeight!); } - protected getRowTop(row: number): number { + /** Get the rendered top offset of a row, including virtual-scroll page positioning. */ + getRowTop(row: number): number { return Math.round(this.getRowPosition(row) - this.offset); } @@ -4683,6 +4693,9 @@ export class SlickGrid = Column, O e /** Invalidate all grid rows and re-render the visible grid rows */ invalidate(): void { + if (!this.initialized || !this._container) { + return; + } this.updateRowCount(); this.invalidateAllRows(); this.render(); @@ -5225,7 +5238,7 @@ export class SlickGrid = Column, O e /** Update the dataset row count */ updateRowCount(): void { - if (this.initialized) { + if (this.initialized && this._container) { const dataLength = this.getDataLength(); this._container.setAttribute('aria-rowcount', dataLength.toString()); diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index 5f5c2fa619..c209d1f099 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -875,7 +875,7 @@ export interface GridOption { /** * Defaults to "transform", what CSS style to we want to use to render each row top offset (choose between "top" and "transform"). * For example, with a default `rowHeight: 22`, the 2nd row will have a `top` offset of 44px and by default have a CSS style of `transform: translateY(44px)`. - * NOTE: you should use "top" when using either Row Detail and/or RowSpan + * NOTE: use `top` with the legacy inline Row Detail renderer and/or RowSpan. Row Detail can use `rowDetailView.renderMode: 'overlay'` for transform compatibility. */ rowTopOffsetRenderType?: 'top' | 'transform'; diff --git a/packages/common/src/interfaces/rowDetailViewOption.interface.ts b/packages/common/src/interfaces/rowDetailViewOption.interface.ts index 1b75e24956..23fd661f8f 100644 --- a/packages/common/src/interfaces/rowDetailViewOption.interface.ts +++ b/packages/common/src/interfaces/rowDetailViewOption.interface.ts @@ -1,6 +1,15 @@ import type { SlickDataView, SlickGrid, SlickRowDetailView, UsabilityOverrideFn } from '../index.js'; import type { Observable, Subject } from '../services/rxjsFacade.js'; +/** Supported DOM render locations for a Row Detail panel. */ +export type RowDetailViewRenderMode = 'overlay' | RowDetailViewInlineRenderMode; + +/** + * @deprecated Inline Row Detail rendering is retained for backwards compatibility only and will be removed in the next major release. + * Use `renderMode: 'overlay'` instead. + */ +export type RowDetailViewInlineRenderMode = 'inline'; + export interface RowDetailViewProps { model: T; addon: SlickRowDetailView; @@ -58,6 +67,15 @@ export interface RowDetailViewOption { /** Defaults to null, do we want to defined a maximum number of rows to show. */ maxRows?: number; + /** + * Where the Row Detail panel is rendered in the grid DOM. + * Overlay mode renders panels in a sibling layer of the grid canvas and is compatible with `rowTopOffsetRenderType: 'transform'`. + * Inline mode is retained for backwards compatibility and will be removed in the next major release. + * v11 plan: overlay rendering is intended to become the default and only renderer, so this transition option may be removed. Keep it while using v10 and remove it when upgrading to v11 if it is removed from the API. + * @default 'inline' + */ + renderMode?: RowDetailViewRenderMode; + /** * How many grid rows do we want to use for the detail panel view * also note that the detail view adds an extra 1 row for padding purposes diff --git a/packages/common/src/styles/slick-plugins.scss b/packages/common/src/styles/slick-plugins.scss index 46988c2269..eab2efb9df 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -1257,7 +1257,19 @@ li.hidden { // Row Detail View Plugin // --------------------------------------------------------- -.slick-row { +.slick-row-detail-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 0; + overflow: visible; + pointer-events: none; + z-index: 10; +} + +.slick-row, +.slick-row-detail-overlay { .detail-view-toggle { display: inline-block; @@ -1278,17 +1290,17 @@ li.hidden { } } } +} - .dynamic-cell-detail { - position: absolute; - width: 100%; - overflow: auto; - left: var(--slick-detail-view-container-left, v.$slick-detail-view-container-left); - border: var(--slick-detail-view-container-border, v.$slick-detail-view-container-border); - background-color: var(--slick-detail-view-container-bgcolor, v.$slick-detail-view-container-bgcolor); - padding: var(--slick-detail-view-container-padding, v.$slick-detail-view-container-padding); - z-index: var(--slick-detail-view-container-z-index, v.$slick-detail-view-container-z-index); - } +.dynamic-cell-detail { + position: absolute; + width: 100%; + overflow: auto; + left: var(--slick-detail-view-container-left, v.$slick-detail-view-container-left); + border: var(--slick-detail-view-container-border, v.$slick-detail-view-container-border); + background-color: var(--slick-detail-view-container-bgcolor, v.$slick-detail-view-container-bgcolor); + padding: var(--slick-detail-view-container-padding, v.$slick-detail-view-container-padding); + z-index: var(--slick-detail-view-container-z-index, v.$slick-detail-view-container-z-index); } .slick-drag-replace-handle { diff --git a/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts b/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts index 9a2a8881b1..abffd7a6f6 100644 --- a/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts +++ b/packages/row-detail-view-plugin/src/slickRowDetailView.spec.ts @@ -48,6 +48,7 @@ const gridStub = { getActiveCell: vi.fn(), getColumns: vi.fn(), getColumnByIdx: vi.fn(), + getColumnIndex: vi.fn(), getDataItem: vi.fn(), getData: () => dataviewStub, getEditorLock: () => getEditorLockMock, @@ -55,6 +56,10 @@ const gridStub = { getUID: () => GRID_UID, getRenderedRange: vi.fn(), getRowCache: vi.fn(), + getRowHeight: vi.fn(), + getRowTop: vi.fn(), + getFrozenRowOffset: vi.fn(), + getViewportNode: vi.fn(), invalidateRows: vi.fn(), registerPlugin: vi.fn(), render: vi.fn(), @@ -407,6 +412,88 @@ describe('SlickRowDetailView plugin', () => { expect(invalidateSpy).toHaveBeenCalled(); }); + it('should refresh framework overlay content without replacing the outer panel', () => { + const itemMock = { id: 123, __sizePadding: 2, __detailContent: 'Updated' }; + const viewport = document.createElement('div'); + const canvas = document.createElement('div'); + canvas.className = 'grid-canvas'; + viewport.appendChild(canvas); + divContainer.appendChild(viewport); + vi.spyOn(gridStub, 'getOptions').mockReturnValue({ + ...gridOptionsMock, + rowDetailView: { renderMode: 'overlay', panelRows: 2, columnId: '_detail_selector' } as any, + }); + vi.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValue(viewport); + vi.spyOn(gridStub, 'getRowTop').mockReturnValue(100); + vi.spyOn(gridStub, 'getFrozenRowOffset').mockReturnValue(0); + vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(25); + vi.spyOn(dataviewStub, 'getItemById').mockReturnValue(itemMock); + vi.spyOn(dataviewStub, 'getRowById').mockReturnValue(0); + + plugin.init(gridStub); + (plugin as any).refreshOverlayPanel(null); + const gridRef = (plugin as any)._grid; + (plugin as any)._grid = undefined; + (plugin as any).renderOverlayPanels(); + (plugin as any)._grid = gridRef; + (plugin as any)._expandedRowIds.add(itemMock.id); + (plugin as any)._renderedViewportRowIds.add(itemMock.id); + (plugin as any).renderOverlayPanels(); + const panel = (plugin as any)._overlayPanels.get(itemMock.id) as HTMLElement; + + (plugin as any).refreshOverlayPanel({ ...itemMock, __detailContent: createDomElement('span', { textContent: 'Element' }) }); + expect(panel.querySelector('.innerDetailView_123')?.textContent).toBe('Element'); + (plugin as any).refreshOverlayPanel({ ...itemMock, __detailContent: 'String' }); + expect(panel.querySelector('.innerDetailView_123')?.innerHTML).toBe('String'); + + panel.innerHTML = '
'; + (plugin as any).refreshOverlayPanel({ ...itemMock, __detailContent: 'No inner' }); + vi.spyOn(dataviewStub, 'getRowById').mockReturnValueOnce(undefined); + (plugin as any).refreshOverlayPanel({ ...itemMock, __detailContent: 'No row' }); + + (plugin as any)._overlayPanels.delete(itemMock.id); + (plugin as any).refreshOverlayPanel(itemMock); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValue(document.createElement('div')); + (plugin as any).renderOverlayPanels(); + }); + + it('should defer the async end update notification when overlay rendering is enabled', () => { + const asyncEndUpdateSpy = vi.spyOn(plugin.onAsyncEndUpdate, 'notify'); + const itemMock = { id: 123, firstName: 'John', lastName: 'Doe' }; + vi.spyOn(gridStub, 'getOptions').mockReturnValue({ + ...gridOptionsMock, + rowDetailView: { renderMode: 'overlay', postTemplate: () => 'Post' } as any, + }); + + plugin.init(gridStub); + plugin.onAsyncResponse.notify({ item: itemMock }, new SlickEventData()); + + expect(asyncEndUpdateSpy).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(asyncEndUpdateSpy).toHaveBeenCalledWith({ grid: gridStub, item: itemMock }, expect.anything(), plugin); + }); + + it('should preserve the overlay for Aurelia view model adapters during async response', () => { + const itemMock = { id: 123, firstName: 'John', lastName: 'Doe' }; + const removeOverlayPanelSpy = vi.spyOn(plugin as any, 'removeOverlayPanel'); + vi.spyOn(plugin as any, 'shouldPreserveOverlay').mockReturnValue(true); + vi.spyOn(gridStub, 'getOptions').mockReturnValue({ + ...gridOptionsMock, + rowDetailView: { + renderMode: 'overlay', + viewModel: class DetailViewModel {}, + preloadViewModel: class PreloadViewModel {}, + postTemplate: () => 'Post', + } as any, + }); + + plugin.init(gridStub); + plugin.onAsyncResponse.notify({ item: itemMock }, new SlickEventData()); + + expect(removeOverlayPanelSpy).not.toHaveBeenCalled(); + }); + it('should trigger "onAsyncResponse" with Row Detail from post template with HTML Element when no detailView is provided and expect "updateItem" from DataView to be called with new template & data', () => { const updateItemSpy = vi.spyOn(dataviewStub, 'updateItem'); const asyncEndUpdateSpy = vi.spyOn(plugin.onAsyncEndUpdate, 'notify'); @@ -1445,6 +1532,121 @@ describe('SlickRowDetailView plugin', () => { `
Loading...
` ); }); + + it('should render expanded Row Detail into a sibling overlay layer', () => { + const mockItem = { + id: 123, + firstName: 'John', + lastName: 'Doe', + __collapsed: false, + __isPadding: false, + __sizePadding: 2, + __detailContent: '
Loading...
', + }; + const viewport = document.createElement('div'); + const canvas = document.createElement('div'); + canvas.className = 'grid-canvas'; + viewport.appendChild(canvas); + vi.spyOn(gridStub, 'getOptions').mockReturnValue({ + ...gridOptionsMock, + rowDetailView: { renderMode: 'overlay', panelRows: 2, columnId: '_detail_selector' } as any, + }); + vi.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValue(viewport); + vi.spyOn(gridStub, 'getRowCache').mockReturnValue({ 0: { rowNode: [document.createElement('div')] } } as any); + vi.spyOn(gridStub, 'getRowTop').mockReturnValue(100); + vi.spyOn(gridStub, 'getFrozenRowOffset').mockReturnValue(0); + vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(25); + vi.spyOn(dataviewStub, 'getItemById').mockReturnValue(mockItem); + vi.spyOn(dataviewStub, 'getRowById').mockReturnValue(0); + + plugin.init(gridStub); + (plugin as any)._expandedRowIds.add(mockItem.id); + (plugin as any)._renderedViewportRowIds.add(mockItem.id); + (plugin as any).renderOverlayPanels(); + + const panel = canvas.querySelector('.slick-row-detail-overlay .dynamic-cell-detail') as HTMLElement; + expect(panel).toBeTruthy(); + expect(panel.parentElement?.parentElement).toBe(canvas); + expect(panel.style.top).toBe('125px'); + expect( + (plugin.getColumnDefinition().formatter!(0, 1, '', mockColumns[0], mockItem, gridStub) as FormatterResultWithHtml).insertElementAfterTarget + ).toBeUndefined(); + + // A redraw can reset viewport bookkeeping before framework adapters remount nested views. + (plugin as any).resetRenderedRows(); + expect(canvas.querySelector('.slick-row-detail-overlay .dynamic-cell-detail')).toBeTruthy(); + + plugin.setOptions({ renderMode: 'inline' }); + expect(canvas.querySelector('.slick-row-detail-overlay')).toBeNull(); + + const renderOverlaySpy = vi.spyOn(plugin as any, 'renderOverlayPanels'); + plugin.setOptions({ renderMode: 'overlay' }); + expect(renderOverlaySpy).toHaveBeenCalled(); + }); + + it('should cover overlay panel viewport and reattachment branches', () => { + const mockItem = { + id: 123, + __collapsed: false, + __isPadding: false, + __sizePadding: 2, + __detailContent: '
Loading...
', + }; + const viewport = document.createElement('div'); + const canvas = document.createElement('div'); + canvas.className = 'grid-canvas'; + viewport.appendChild(canvas); + divContainer.appendChild(viewport); + + vi.spyOn(gridStub, 'getOptions').mockReturnValue({ + ...gridOptionsMock, + rowDetailView: { renderMode: 'overlay', panelRows: 2, columnId: '_detail_selector' } as any, + }); + vi.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValue(viewport); + vi.spyOn(gridStub, 'getRowTop').mockReturnValue(100); + vi.spyOn(gridStub, 'getFrozenRowOffset').mockReturnValue(0); + vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(25); + vi.spyOn(dataviewStub, 'getItemById').mockReturnValue(mockItem); + vi.spyOn(dataviewStub, 'getRowById').mockReturnValue(0); + const rowBackSpy = vi.spyOn(plugin.onRowBackToViewportRange, 'notify'); + + plugin.init(gridStub); + (plugin as any)._expandedRowIds.add(mockItem.id); + (plugin as any)._renderedViewportRowIds.add(mockItem.id); + (plugin as any)._rowIdsOutOfViewport.add(mockItem.id); + (plugin as any).renderOverlayPanels(); + expect(rowBackSpy).toHaveBeenCalled(); + + // Reparent an existing panel when its canvas changes. + const panel = (plugin as any)._overlayPanels.get(mockItem.id) as HTMLElement; + panel.remove(); + const secondCanvas = document.createElement('div'); + secondCanvas.className = 'grid-canvas'; + const secondViewport = document.createElement('div'); + secondViewport.appendChild(secondCanvas); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValue(secondViewport); + (plugin as any).renderOverlayPanels(); + expect(panel.parentElement).toBe(secondCanvas.querySelector('.slick-row-detail-overlay')); + + // Exercise the early-return paths for rows which are no longer rendered or available. + (plugin as any)._renderedViewportRowIds.clear(); + (plugin as any).renderOverlayPanels(); + (plugin as any)._renderedViewportRowIds.add(mockItem.id); + vi.spyOn(dataviewStub, 'getItemById').mockReturnValueOnce(undefined).mockReturnValueOnce(mockItem); + vi.spyOn(gridStub, 'getViewportNode').mockReturnValueOnce(secondViewport).mockReturnValueOnce(document.createElement('div')); + (plugin as any).renderOverlayPanels(); + (plugin as any).renderOverlayPanels(); + }); + + it('should return expanded item ids for a grouping key', () => { + const groupedItems = [{ id: 1 }, { id: '1.1', __isPadding: true }, { id: 2 }]; + vi.spyOn(dataviewStub, 'getItemsByGroupingKey').mockReturnValue(groupedItems as any); + plugin.init(gridStub); + + expect((plugin as any).getGroupItemIds('group-1')).toEqual([1, 2]); + }); }); describe('handleKeyDown - keyboard a11y handler', () => { @@ -1476,7 +1678,7 @@ describe('SlickRowDetailView plugin', () => { it('should toggle (expand) a collapsed row with Space key', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection').mockImplementation(vi.fn()); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection').mockImplementation(vi.fn()); plugin.init(gridStub); gridStub.onKeyDown.notify({ row: 0, cell: 0, grid: gridStub }, keyDownEvent); @@ -1489,7 +1691,7 @@ describe('SlickRowDetailView plugin', () => { it('should expand a collapsed row with ArrowRight key', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection').mockImplementation(vi.fn()); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection').mockImplementation(vi.fn()); Object.defineProperty(keyDownEvent, 'key', { writable: true, configurable: true, value: 'ArrowRight' }); plugin.init(gridStub); @@ -1503,7 +1705,7 @@ describe('SlickRowDetailView plugin', () => { it('should NOT expand an already expanded row with ArrowRight key', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: false }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection'); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection'); Object.defineProperty(keyDownEvent, 'key', { writable: true, configurable: true, value: 'ArrowRight' }); plugin.init(gridStub); @@ -1517,7 +1719,7 @@ describe('SlickRowDetailView plugin', () => { it('should collapse an expanded row with ArrowLeft key', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: false }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection').mockImplementation(vi.fn()); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection').mockImplementation(vi.fn()); Object.defineProperty(keyDownEvent, 'key', { writable: true, configurable: true, value: 'ArrowLeft' }); plugin.init(gridStub); @@ -1531,7 +1733,7 @@ describe('SlickRowDetailView plugin', () => { it('should NOT collapse an already collapsed row with ArrowLeft key', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection'); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection'); Object.defineProperty(keyDownEvent, 'key', { writable: true, configurable: true, value: 'ArrowLeft' }); plugin.init(gridStub); @@ -1546,7 +1748,7 @@ describe('SlickRowDetailView plugin', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); vi.spyOn(gridStub, 'getEditorLock').mockReturnValue({ isActive: () => true } as any); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection'); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection'); plugin.init(gridStub); gridStub.onKeyDown.notify({ row: 0, cell: 0, grid: gridStub }, keyDownEvent); @@ -1560,7 +1762,7 @@ describe('SlickRowDetailView plugin', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); (gridStub.getColumnByIdx as ReturnType).mockReturnValue({ id: 'firstName' }); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection'); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection'); plugin.init(gridStub); gridStub.onKeyDown.notify({ row: 0, cell: 1, grid: gridStub }, keyDownEvent); @@ -1578,7 +1780,7 @@ describe('SlickRowDetailView plugin', () => { ...gridOptionsMock, rowDetailView: { process: mockProcess, columnIndexPosition: 0, useRowClick: true, panelRows: 2 } as any, }); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection').mockImplementation(vi.fn()); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection').mockImplementation(vi.fn()); plugin.init(gridStub); gridStub.onKeyDown.notify({ row: 0, cell: 1, grid: gridStub }, keyDownEvent); @@ -1591,7 +1793,7 @@ describe('SlickRowDetailView plugin', () => { it('should not toggle when checkExpandableOverride returns false', () => { const mockItem = { id: 123, firstName: 'John', lastName: 'Doe', __collapsed: true }; vi.spyOn(gridStub, 'getDataItem').mockReturnValue(mockItem); - toggleRowSelectionSpy = vi.spyOn(plugin, 'toggleRowSelection'); + toggleRowSelectionSpy = vi.spyOn(plugin as any, 'toggleRowSelection'); plugin.init(gridStub); plugin.expandableOverride(() => false); diff --git a/packages/row-detail-view-plugin/src/slickRowDetailView.ts b/packages/row-detail-view-plugin/src/slickRowDetailView.ts index 9f9da7ea4e..32cea9909e 100644 --- a/packages/row-detail-view-plugin/src/slickRowDetailView.ts +++ b/packages/row-detail-view-plugin/src/slickRowDetailView.ts @@ -68,6 +68,8 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV protected _renderedViewportRowIds: Set = new Set(); protected _renderedCollapsedGroupIds: Set = new Set(); protected _renderedIds: Set = new Set(); + protected _overlayHosts: Map = new Map(); + protected _overlayPanels: Map = new Map(); protected _visibleRenderedCell?: { startRow: number; endRow: number }; protected _backViewportTimer: any; protected _defaults = { @@ -126,6 +128,19 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV return this._gridUid || this._grid?.getUID() || ''; } + /** + * True when Row Detail panels should be rendered outside transformed row elements. + * @deprecated v11: remove this compatibility switch when overlay becomes the only renderer. + */ + protected get isOverlayRenderMode(): boolean { + return this._addonOptions?.renderMode === 'overlay'; + } + + /** Framework adapters override this when they own the mounted overlay DOM. */ + protected shouldPreserveOverlay(): boolean { + return false; + } + set rowIdsOutOfViewport(rowIds: Array) { this._rowIdsOutOfViewport = new Set(rowIds); } @@ -212,10 +227,16 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV this._eventHandler.subscribe(this.dataView.onSetItemsCalled, () => { this._dataViewIdProperty = this.dataView.getIdPropertyName() || 'id'; }); + + this._eventHandler.subscribe(this._grid.onRendered, () => this.isOverlayRenderMode && this.renderOverlayPanels()); + if (this.isOverlayRenderMode) { + queueMicrotask(() => this.renderOverlayPanels()); + } } /** Dispose of the Slick Row Detail View */ dispose(): void { + this.disposeOverlayPanels(); this._eventHandler?.unsubscribeAll(); this._expandedRowIds.clear(); this._rowIdsOutOfViewport.clear(); @@ -269,10 +290,19 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV /** set or change some of the plugin options */ setOptions(options: Partial): void { + const wasOverlayRenderMode = this.isOverlayRenderMode; this._addonOptions = extend(true, {}, this._addonOptions, options) as RowDetailView; if (this._addonOptions?.singleRowExpand) { this.collapseAll(); } + // @deprecated v11: remove mode switching when inline rendering is removed. + if (wasOverlayRenderMode !== this.isOverlayRenderMode) { + if (this.isOverlayRenderMode) { + this.renderOverlayPanels(); + } else { + this.disposeOverlayPanels(); + } + } } /** Collapse all of the open items */ @@ -306,6 +336,7 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV // Remove the item from the expandedRows & renderedIds this._expandedRowIds = new Set(Array.from(this._expandedRowIds).filter((expItemId) => expItemId !== item[this._dataViewIdProperty])); this._renderedIds.delete(item[this._dataViewIdProperty]); + this.removeOverlayPanel(item[this._dataViewIdProperty]); // we need to reevaluate & invalidate any row detail that are shown on top of the row that we're closing this.reevaluateRenderedRowIds(item); @@ -361,7 +392,12 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV /** reset all Set rows/ids cache and start empty (but keep expanded rows ref) */ resetRenderedRows(): void { - this._renderedViewportRowIds.clear(); + // Overlay panels live outside row DOM and must remain mounted while framework adapters + // redraw their nested views; their viewport state is updated by the next recalc/render. + // @deprecated v11: remove the inline-mode branch when overlay becomes unconditional. + if (!this.isOverlayRenderMode) { + this._renderedViewportRowIds.clear(); + } this._disposedRows.clear(); } @@ -390,22 +426,40 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV // get item detail argument const itemDetail = args.item; + const shouldPreserveOverlay = this.shouldPreserveOverlay(); + if (!shouldPreserveOverlay) { + this.removeOverlayPanel(itemDetail[this._dataViewIdProperty]); + } // if we just want to load in a view directly we can use detailView property to do so itemDetail[`${this._keyPrefix}detailContent`] = args.detailView ?? this._addonOptions?.postTemplate?.(itemDetail); itemDetail[`${this._keyPrefix}detailViewLoaded`] = true; this.dataView.updateItem(itemDetail[this._dataViewIdProperty], itemDetail); + // A framework adapter may mount its nested component from onAsyncEndUpdate before + // the grid's onRendered event is emitted. Ensure the post-template overlay exists + // before notifying adapters so their mount target is connected to the DOM. + this.isOverlayRenderMode && !shouldPreserveOverlay && this.renderOverlayPanels(); // trigger an event once the post template is finished loading this._renderedIds.add(itemDetail[this.dataViewIdProperty]); - this.onAsyncEndUpdate.notify( - { - grid: this._grid, - item: itemDetail, - }, - e, - this - ); + const notifyAsyncEndUpdate = () => + this.onAsyncEndUpdate.notify( + { + grid: this._grid, + item: itemDetail, + }, + e, + this + ); + + // Overlay panels are mounted from the grid's onRendered event. Defer framework + // callbacks by one microtask so custom adapters can safely find the post-template + // container before mounting nested components. + if (this.isOverlayRenderMode) { + queueMicrotask(notifyAsyncEndUpdate); + } else { + notifyAsyncEndUpdate(); + } } /** @@ -580,12 +634,168 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV } else { calculateFn(); } + + this.isOverlayRenderMode && this.renderOverlayPanels(); } // -- // protected functions // ------------------ + /** Render all expanded panels in sibling overlay layers of their grid canvases. */ + protected renderOverlayPanels(): void { + if (!this.isOverlayRenderMode || !this._grid) { + return; + } + + this._expandedRowIds.forEach((itemId) => { + if (!this._renderedViewportRowIds.has(itemId)) { + return; + } + + const item = this.dataView.getItemById(itemId); + const row = this.dataView.getRowById(itemId); + if (!item || row === undefined) { + return; + } + + const columnIdx = this._grid.getColumnIndex(String(this._addonOptions.columnId ?? this._defaults.columnId)); + const viewport = this._grid.getViewportNode(columnIdx, row); + const canvas = viewport?.querySelector('.grid-canvas'); + if (!canvas) { + return; + } + + const overlayHost = this.getOverlayHost(canvas); + let panel = this._overlayPanels.get(itemId); + const wasPanelCreated = !panel; + if (!panel) { + panel = this.createDetailViewElement(item, row); + this._overlayPanels.set(itemId, panel); + } + + if (panel.parentElement !== overlayHost) { + overlayHost.appendChild(panel); + } + this.updateOverlayPanelPosition(panel, item, row); + + // A scroll-back can finish after the grid has rendered its rows. Notify framework + // adapters only after the overlay container exists so they can safely remount views. + if (wasPanelCreated && this._rowIdsOutOfViewport.has(itemId)) { + this.notifyBackToViewportWhenDomExist(item); + } + }); + } + + /** Get or create the overlay layer for one of the grid's frozen/scrollable canvases. */ + protected getOverlayHost(canvas: HTMLDivElement): HTMLDivElement { + let host = this._overlayHosts.get(canvas); + if (!host) { + host = createDomElement('div', { className: 'slick-row-detail-overlay' }); + canvas.appendChild(host); + this._overlayHosts.set(canvas, host); + } + return host; + } + + /** Remove all overlay hosts and their panels. */ + protected disposeOverlayPanels(): void { + this._overlayPanels.forEach((panel) => panel.remove()); + this._overlayPanels.clear(); + this._overlayHosts.forEach((host) => host.remove()); + this._overlayHosts.clear(); + } + + /** Remove one panel without disturbing other expanded Row Details. */ + protected removeOverlayPanel(itemId: number | string): void { + const panel = this._overlayPanels.get(itemId); + panel?.remove(); + this._overlayPanels.delete(itemId); + } + + /** Replace a framework-owned overlay panel after its preload component is unmounted. */ + protected refreshOverlayPanel(item: any): void { + if (!this.isOverlayRenderMode || !item) { + return; + } + + const itemId = item[this._dataViewIdProperty]; + const panel = this._overlayPanels.get(itemId); + if (!panel) { + this.renderOverlayPanels(); + return; + } + + // Keep the outer panel node stable while a framework adapter transitions from + // its preload component to the real detail component. Replacing the panel + // itself can detach a React/Vue/Angular tree in the middle of its commit. + const innerDetailView = panel.querySelector(`.innerDetailView_${itemId}`); + if (innerDetailView) { + this.renderDetailContent(innerDetailView, item); + } + + const row = this.dataView.getRowById(itemId); + if (row !== undefined) { + this.updateOverlayPanelPosition(panel, item, row); + } + } + + /** Keep an existing overlay aligned after row heights or data-view positions change. */ + protected updateOverlayPanelPosition(panel: HTMLDivElement, item: any, row: number): void { + const rowHeight = this.gridOptions.rowHeight || 0; + const top = this.getDetailPanelTopOffset(row); + const outterHeight = (item[`${this._keyPrefix}sizePadding`] || 0) * rowHeight; + panel.style.top = `${top}px`; + panel.style.height = `${outterHeight}px`; + } + + /** Get the panel's offset immediately below its parent row. */ + protected getDetailPanelTopOffset(row: number): number { + return this._grid.getRowTop(row) - this._grid.getFrozenRowOffset(row) + this._grid.getRowHeight(row); + } + + /** Render or replace the detail content inside a panel container. */ + protected renderDetailContent(container: HTMLElement, item: any): void { + const detailContent = item[`${this._keyPrefix}detailContent`]; + if (detailContent instanceof HTMLElement) { + container.replaceChildren(detailContent); + } else { + container.innerHTML = this._grid.sanitizeHtmlString(detailContent); + } + } + + /** Create the Row Detail panel DOM without deciding where it is mounted. */ + protected createDetailViewElement(dataContext: any, row: number): HTMLDivElement { + const rowHeight = this.gridOptions.rowHeight || 0; + let outterHeight = (dataContext[`${this._keyPrefix}sizePadding`] || 0) * rowHeight; + + if (this._addonOptions.maxRows !== null && (dataContext[`${this._keyPrefix}sizePadding`] || 0) > this._addonOptions.maxRows!) { + outterHeight = this._addonOptions.maxRows! * rowHeight; + dataContext[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows; + } + + // @deprecated v11: remove the inline positioning branch when overlay becomes unconditional. + const cellDetailContainerElm = createDomElement('div', { + className: `dynamic-cell-detail cellDetailView_${dataContext[this._dataViewIdProperty]}`, + style: { + height: `${outterHeight}px`, + top: this.isOverlayRenderMode ? `${this.getDetailPanelTopOffset(row)}px` : `${rowHeight}px`, + pointerEvents: this.isOverlayRenderMode ? 'auto' : undefined, + }, + }); + const innerContainerElm = createDomElement('div', { + className: `detail-container detailViewContainer_${dataContext[this._dataViewIdProperty]}`, + }); + const innerDetailViewElm = createDomElement('div', { + className: `innerDetailView_${dataContext[this._dataViewIdProperty]}`, + }); + this.renderDetailContent(innerDetailViewElm, dataContext); + + innerContainerElm.appendChild(innerDetailViewElm); + cellDetailContainerElm.appendChild(innerContainerElm); + return cellDetailContainerElm; + } + /** * create the row detail ctr node. this belongs to the dev & can be custom-styled as per * @param {Object} item @@ -613,6 +823,7 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV triggerEvent && this.notifyBackToViewportWhenDomExist(item); } else if (action === 'remove') { this._renderedViewportRowIds.delete(itemId); + this.removeOverlayPanel(itemId); triggerEvent && this.notifyOutOfViewport(item); } } @@ -676,45 +887,23 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV } return createDomElement('div', { className: classNameToList(collapsedClasses).join(' '), ariaExpanded: 'false' }); } else { - const rowHeight = this.gridOptions.rowHeight || 0; - let outterHeight = (dataContext[`${this._keyPrefix}sizePadding`] || 0) * this.gridOptions.rowHeight!; - - if (this._addonOptions.maxRows !== null && (dataContext[`${this._keyPrefix}sizePadding`] || 0) > this._addonOptions.maxRows!) { - outterHeight = this._addonOptions.maxRows! * rowHeight!; - dataContext[`${this._keyPrefix}sizePadding`] = this._addonOptions.maxRows; - } - - // sneaky extra inserted here-----------------v let expandedClasses = `sgi ${this._addonOptions.cssClass || ''} collapse `; if (this._addonOptions.expandedClass) { expandedClasses += this._addonOptions.expandedClass; } - // create the Row Detail div container that will be inserted AFTER the `.slick-cell` - const cellDetailContainerElm = createDomElement('div', { - className: `dynamic-cell-detail cellDetailView_${dataContext[this._dataViewIdProperty]}`, - style: { height: `${outterHeight}px`, top: `${rowHeight}px` }, - }); - const innerContainerElm = createDomElement('div', { - className: `detail-container detailViewContainer_${dataContext[this._dataViewIdProperty]}`, - }); - const innerDetailViewElm = createDomElement('div', { - className: `innerDetailView_${dataContext[this._dataViewIdProperty]}`, - }); - if (dataContext[`${this._keyPrefix}detailContent`] instanceof HTMLElement) { - innerDetailViewElm.appendChild(dataContext[`${this._keyPrefix}detailContent`]); - } else { - innerDetailViewElm.innerHTML = this._grid.sanitizeHtmlString(dataContext[`${this._keyPrefix}detailContent`]); - } - - innerContainerElm.appendChild(innerDetailViewElm); - cellDetailContainerElm.appendChild(innerContainerElm); - const result: FormatterResultWithHtml = { html: createDomElement('div', { className: classNameToList(expandedClasses).join(' '), ariaExpanded: 'true' }), - insertElementAfterTarget: cellDetailContainerElm, }; + // @deprecated v11: always mount the detail panel in the overlay layer. + if (!this.isOverlayRenderMode) { + const detailPanel = this.createDetailViewElement(dataContext, row); + if (detailPanel) { + result.insertElementAfterTarget = detailPanel; + } + } + return result; } } @@ -910,6 +1099,10 @@ export class SlickRowDetailView implements ExternalResource, UniversalRowDetailV const rowIndex = item.rowIndex || this.dataView.getRowById(item[this._dataViewIdProperty]); const rowId = item[this.dataViewIdProperty]; + if (this.isOverlayRenderMode) { + this.renderOverlayPanels(); + } + // make sure View Row DOM Element really exist before notifying that it's a row that is visible again if (document.querySelector(`.${this.gridUid} .cellDetailView_${item[this._dataViewIdProperty]}`)) { this.onRowBackToViewportRange.notify( diff --git a/test/cypress/e2e/example20.cy.ts b/test/cypress/e2e/example20.cy.ts index 56b180ee48..004d853ed0 100644 --- a/test/cypress/e2e/example20.cy.ts +++ b/test/cypress/e2e/example20.cy.ts @@ -21,17 +21,17 @@ describe('Example 20 - Row Detail View', () => { it('should open the 1st Row Detail of the 2nd row and expect to find some details', () => { cy.get('.slick-cell.detail-view-toggle:nth(1)').click().wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', 'Task 1'); + cy.get('.dynamic-cell-detail').find('h4').should('contain', 'Task 1'); cy.get('input[id="assignee_1"]').should('exist'); cy.get('input[type="checkbox"]:checked').should('have.length', 0); }); it('should open the 2nd Row Detail of the 4th row and expect to find some details', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 9}px;"] .slick-cell:nth(1)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 9}px)"] .slick-cell:nth(1)`) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', 'Task 3'); + cy.get('.dynamic-cell-detail').find('h4').should('contain', 'Task 3'); cy.get('input[id="assignee_3"]').should('exist'); @@ -45,12 +45,12 @@ describe('Example 20 - Row Detail View', () => { }); it('should open the Task 3 Row Detail and still expect same detail', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 3}px;"] .slick-cell:nth(1)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 3}px)"] .slick-cell:nth(1)`) .click() .wait(40); cy.get('.dynamic-cell-detail').should('have.length', 1); - cy.get('.slick-cell + .dynamic-cell-detail .innerDetailView_3').find('h4').should('contain', 'Task 3'); + cy.get('.dynamic-cell-detail .innerDetailView_3').find('h4').should('contain', 'Task 3'); cy.get('input[id="assignee_3"]').should('exist'); }); @@ -71,7 +71,7 @@ describe('Example 20 - Row Detail View', () => { cy.wrap(stub).as('confirmStub'); }); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_3').as('detailContainer3'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_3').as('detailContainer3'); cy.get('@detailContainer3').find('[data-test=delete-btn]').click(); cy.get('.slick-viewport-top.slick-viewport-left').scrollTo('top'); @@ -91,23 +91,23 @@ describe('Example 20 - Row Detail View', () => { it('should open a few Row Details and expect them to be closed after clicking on the "Close All Row Details" button', () => { const expectedTasks = ['Task 0', 'Task 1', 'Task 2', 'Task 4', 'Task 5']; - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 4}px;"] .slick-cell:nth(1)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 4}px)"] .slick-cell:nth(1)`) .click() .wait(40); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_5').as('detailContainer5'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_5').as('detailContainer5'); cy.get('@detailContainer5').find('h4').contains('Task 5'); - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 1}px;"] .slick-cell:nth(1)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] .slick-cell:nth(1)`) .click() .wait(40); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); cy.get('@detailContainer1').find('h4').contains('Task 1'); cy.get('[data-test=collapse-all-btn]').click(); cy.get('.slick-viewport-top.slick-viewport-left').scrollTo('top'); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_1').should('not.exist'); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_1').should('not.exist'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_1').should('not.exist'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_1').should('not.exist'); cy.get('.grid20') .find('.slick-row') @@ -122,14 +122,14 @@ describe('Example 20 - Row Detail View', () => { it('should open a few Row Details, then sort by Title and expect all Row Details to be closed afterward', () => { const expectedTasks = ['Task 0', 'Task 1', 'Task 10', 'Task 100', 'Task 101', 'Task 102', 'Task 103', 'Task 104']; - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 1}px;"] .slick-cell:nth(1)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] .slick-cell:nth(1)`) .click() .wait(40); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); cy.get('@detailContainer1').find('h4').contains('Task 1'); cy.get('.grid20').find('.slick-row:nth(9) .slick-cell:nth(1)').click(); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_5').as('detailContainer5'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_5').as('detailContainer5'); cy.get('@detailContainer5').find('h4').contains('Task 5'); cy.get('.grid20') @@ -158,8 +158,8 @@ describe('Example 20 - Row Detail View', () => { cy.get('.grid20').find('.slick-header-column:nth(2)').find('.slick-sort-indicator-asc').should('have.length', 1); cy.get('.slick-viewport-top.slick-viewport-left').scrollTo('top'); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_0').should('not.exist'); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_5').should('not.exist'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_0').should('not.exist'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_5').should('not.exist'); cy.get('.grid20') .find('.slick-row') .each(($row, index) => { @@ -173,7 +173,7 @@ describe('Example 20 - Row Detail View', () => { it('should click open Row Detail of Task 1 and Task 101 then type a title filter of "Task 101" and expect Row Detail to be opened and still be rendered', () => { cy.get('.grid20').find('.slick-row:nth(4) .slick-cell:nth(1)').click(); cy.get('.grid20').find('.slick-row:nth(1) .slick-cell:nth(1)').click(); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_101').as('detailContainer'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_101').as('detailContainer'); cy.get('@detailContainer').find('h4').contains('Task 101'); cy.get('.search-filter.filter-title').type('Task 101'); }); @@ -181,7 +181,7 @@ describe('Example 20 - Row Detail View', () => { it('should call "Clear all Filters" from Grid Menu and expect "Task 101" to still be rendered correctly', () => { cy.get('.grid20').find('button.slick-grid-menu-button').trigger('click').click(); cy.get(`.slick-grid-menu:visible`).find('.slick-menu-item').first().find('span').contains('Clear all Filters').click(); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_101').as('detailContainer'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_101').as('detailContainer'); cy.get('@detailContainer').find('h4').contains('Task 101'); }); @@ -199,7 +199,7 @@ describe('Example 20 - Row Detail View', () => { it('should click on 5th row detail open icon and expect it to open', () => { cy.get('.grid20').find('.slick-row:nth(4) .slick-cell:nth(1)').click(); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_5').as('detailContainer'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_5').as('detailContainer'); cy.get('@detailContainer').find('h4').contains('Task 5'); }); @@ -209,14 +209,14 @@ describe('Example 20 - Row Detail View', () => { .invoke('val') .then((text) => expect(text).to.eq('Task 1')); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_5').should('not.exist'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_5').should('not.exist'); cy.get('[data-test="toggle-readonly-btn"]').click(); }); it('should open two Row Details and expect 2 detail panels opened', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo('top'); - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 8}px;"] .slick-cell:nth(2)`) + cy.get(`.slick-row[style*="translateY(${GRID_ROW_HEIGHT * 8}px)"] .slick-cell:nth(2)`) .click() .wait(40); @@ -238,15 +238,15 @@ describe('Example 20 - Row Detail View', () => { cy.get('.slick-cell.detail-view-toggle:nth(1)').click().wait(40); cy.get('.dynamic-cell-detail').should('have.length', 1); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_1').as('detailContainer1'); cy.get('@detailContainer1').find('[data-test=delete-btn]').click(); cy.get('.notification.is-danger[data-test=status]').contains('Deleted row with Task 1'); cy.get('.dynamic-cell-detail').should('have.length', 0); }); it('should be able to select any rows, i.e.: row 2 and 4', () => { - cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).click(); - cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).click(); + cy.get(`[style*="translateY(${GRID_ROW_HEIGHT * 2}px)"] > .slick-cell:nth(0)`).click(); + cy.get(`[style*="translateY(${GRID_ROW_HEIGHT * 4}px)"] > .slick-cell:nth(0)`).click(); cy.get('[data-test="row-selections"]').contains('2,4'); }); @@ -257,7 +257,7 @@ describe('Example 20 - Row Detail View', () => { cy.get('@toggle1').click(); cy.get('@toggle1').click(); - cy.get('.grid20').find('.slick-cell + .dynamic-cell-detail .innerDetailView_9').as('detailContainer'); + cy.get('.grid20').find('.dynamic-cell-detail .innerDetailView_9').as('detailContainer'); cy.get('@detailContainer').find('h4').contains('Task 9'); }); }); diff --git a/test/cypress/e2e/example21.cy.ts b/test/cypress/e2e/example21.cy.ts index f9f5adb067..4c25a36861 100644 --- a/test/cypress/e2e/example21.cy.ts +++ b/test/cypress/e2e/example21.cy.ts @@ -36,7 +36,7 @@ describe('Example 21 - Row Detail with inner Grid', () => { it('should open the Row Detail of the 2nd row and expect to find an inner grid with all inner column titles', () => { cy.get('.slick-cell.detail-view-toggle:nth(1)').click().wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', '- Order Details (id: 1)'); + cy.get('.dynamic-cell-detail').find('h4').should('contain', '- Order Details (id: 1)'); cy.get('.innergrid-1') .find('.slick-header-columns') @@ -56,34 +56,39 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-1 .search-filter.filter-shipCity').clear().type('m*'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should open 3rd row and still expect 2nd row to be sorted and filtered', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${2})`); + cy.get('.dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${2})`); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail cy.get('.innergrid-2 .search-filter.filter-orderId').should('have.value', ''); cy.get('.innergrid-2 .search-filter.filter-shipCity').should('have.value', ''); cy.get('.innergrid-2 .slick-sort-indicator-asc').should('not.exist'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should go at the bottom end of the grid, then back to top and expect all Row Details to be opened but reset to default', () => { @@ -91,19 +96,25 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.grid21').type('{ctrl}{home}', { release: false }); cy.wait(50); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail cy.get('.innergrid-2 .search-filter.filter-orderId').should('have.value', ''); cy.get('.innergrid-2 .search-filter.filter-shipCity').should('have.value', ''); cy.get('.innergrid-2 .slick-sort-indicator-asc').should('not.exist'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should force redraw of all Row Details and expect same row details to be opened and opened', () => { @@ -111,19 +122,25 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.wait(10); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail cy.get('.innergrid-2 .search-filter.filter-orderId').should('have.value', ''); cy.get('.innergrid-2 .search-filter.filter-shipCity').should('have.value', ''); cy.get('.innergrid-2 .slick-sort-indicator-asc').should('not.exist'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should close all rows', () => { @@ -131,11 +148,13 @@ describe('Example 21 - Row Detail with inner Grid', () => { }); it('should open 2nd row and sort inner grid "Freight" column in ascending order and filter "Order ID" and "Ship City" with "m" and expect 2 sorted rows', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 1}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); + cy.get('.dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); cy.get('.innergrid-1').find('.slick-header-column:nth(2)').children('.slick-header-menu-button').click(); @@ -149,14 +168,16 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-1 .search-filter.filter-orderId').clear().type('>102'); cy.get('.innergrid-1 .search-filter.filter-shipCity').clear().type('m*'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should open 1st row and expect 2nd row no longer be sorted neither filtered because it has to re-rendered', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 0}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); @@ -164,10 +185,13 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-1 .slick-sort-indicator-asc').should('not.exist'); // default rows - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10261'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Rio de Janeiro'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10261'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'contain', + 'Rio de Janeiro' + ); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should close all rows', () => { @@ -175,11 +199,13 @@ describe('Example 21 - Row Detail with inner Grid', () => { }); it('should re-open 2nd row and sort inner grid "Freight" column in ascending order and filter "Ship City" with "m" and expect 2 sorted rows', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 1}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); + cy.get('.dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); cy.get('.innergrid-1').find('.slick-header-column:nth(2)').children('.slick-header-menu-button').click(); @@ -192,10 +218,10 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-1 .search-filter.filter-shipCity').clear().type('m*'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should scroll down when the row detail is just barely visible and then scroll back up and still expect same filters/sorting', () => { @@ -203,10 +229,10 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.wait(50); cy.get('.grid21 .slick-viewport-top.slick-viewport-left').first().scrollTo(0, 0); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should scroll down by 2 pages down and then scroll back up and no longer the same filters/sorting', () => { @@ -215,8 +241,14 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.wait(50); cy.get('.grid21 .slick-viewport-top.slick-viewport-left').first().scrollTo(0, 0); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('not.contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('not.contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should( + 'not.contain', + '10281' + ); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should( + 'not.contain', + 'Madrid' + ); }); it('should close all rows and enable inner Grid State/Presets', () => { @@ -225,11 +257,13 @@ describe('Example 21 - Row Detail with inner Grid', () => { }); it('should open again 2nd row and sort inner grid "Freight" column in ascending order & filter "Ship City" with "m" and expect 2 sorted rows', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * 1}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); + cy.get('.dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${1})`); cy.get('.innergrid-1').find('.slick-header-column:nth(2)').children('.slick-header-menu-button').click(); @@ -242,18 +276,20 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-1 .search-filter.filter-shipCity').clear().type('m*'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should open again 3rd row and sort inner grid "Freight" column in ascending order & filter "Order ID" and "Ship City" with "m" and expect 2 sorted rows', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); - cy.get('.slick-cell + .dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${2})`); + cy.get('.dynamic-cell-detail').find('h4').should('contain', `- Order Details (id: ${2})`); cy.get('.innergrid-2 .slick-header-column:nth(2)').children('.slick-header-menu-button').click(); @@ -268,22 +304,24 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.innergrid-2 .search-filter.filter-shipCity').clear().type('m*'); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should close and reopen the 3rd row and expect same filtered and sorted rows', () => { - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px;"] .slick-cell:nth(0)`).as('3rdRow'); + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px)"] .slick-cell:nth(0)` + ).as('3rdRow'); cy.get('@3rdRow').click(); cy.get('@3rdRow').click(); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should go to the bottom end of the grid and open row 987', () => { @@ -301,16 +339,16 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.grid21').type('{ctrl}{home}', { release: false }); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should go back to the bottom of the grid and still expect row detail 987 to be opened with same filter and no rows inside it', () => { @@ -324,31 +362,35 @@ describe('Example 21 - Row Detail with inner Grid', () => { it('should go back to the top of the grid once more and close 3nd row and still expect same rows in both row details', () => { cy.get('.grid21').type('{ctrl}{home}', { release: false }); - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px;"] .slick-cell:nth(0)`) + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); - - cy.get(`.slick-row[style="top: ${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px;"] .slick-cell:nth(0)`) + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); + + cy.get( + `.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)[style*="translateY(${GRID_ROW_HEIGHT * (1 * (ROW_DETAIL_PANEL_COUNT + 1))}px)"] .slick-cell:nth(0)` + ) .click() .wait(40); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should change Row Detail panel height to 15, open 2nd and 3rd then execute PageDown twice', () => { @@ -362,19 +404,19 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.slick-cell.detail-view-toggle:nth(1)').click().wait(40); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // open 3rd row detail cy.get(`.slick-row[data-row="14"] .slick-cell:nth(0)`).click().wait(40); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); cy.get('.grid21').type('{pageDown}{pageDown}', { release: false }); cy.wait(50); @@ -382,15 +424,15 @@ describe('Example 21 - Row Detail with inner Grid', () => { // expect same grid details for both grids // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should change Row Detail panel height back to 8, open 2nd and 3rd and filter Company ID with "1..2" and expect only these 2 rows to be rendered in the grid', () => { @@ -404,19 +446,19 @@ describe('Example 21 - Row Detail with inner Grid', () => { cy.get('.slick-cell.detail-view-toggle:nth(1)').click().wait(40); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // open 3rd row detail cy.get(`.slick-row[data-row="9"] .slick-cell:nth(0)`).click().wait(40); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); cy.get('.search-filter.filter-companyId').type('1..2'); cy.get('.grid21 .slick-row:not(.innergrid-1 .slick-row,.innergrid-2 .slick-row)').should('have.length', ROW_DETAIL_PANEL_COUNT * 2); @@ -435,16 +477,16 @@ describe('Example 21 - Row Detail with inner Grid', () => { ); // 2nd row detail - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-1 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-1 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); // 3rd row detail - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).should('contain', '10281'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).should('contain', '10267'); - cy.get(`.innergrid-2 [style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).should('contain', 'München'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(0)`).should('contain', '10281'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 0}px)"] > .slick-cell:nth(1)`).should('contain', 'Madrid'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(0)`).should('contain', '10267'); + cy.get(`.innergrid-2 .slick-row[style*="translateY(${GRID_ROW_HEIGHT * 1}px)"] > .slick-cell:nth(1)`).should('contain', 'München'); }); it('should reload page on first describe run', () => { diff --git a/test/cypress/e2e/example36.cy.ts b/test/cypress/e2e/example36.cy.ts index a535678bd9..698efc7ffe 100644 --- a/test/cypress/e2e/example36.cy.ts +++ b/test/cypress/e2e/example36.cy.ts @@ -35,7 +35,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { it('should open the 1st Row Detail of Duration(0) Group and expect to find some details', () => { cy.get('.slick-cell.l1.r1.detail-view-toggle:nth(0)').click().wait(40); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); @@ -50,7 +50,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.get('[data-row="8"] > .slick-cell.l1').click(); cy.get('[data-row="8"] > .slick-cell.l2').contains(/Task [0-9]*/); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); @@ -111,7 +111,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.get('[data-row="0"] .slick-group-toggle.collapsed').click(); cy.get('.slick-cell.l1.r1.detail-view-toggle:nth(0)').click().wait(40); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); @@ -120,7 +120,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.get('.detail input').should('exist'); cy.get('.slick-viewport-top.slick-viewport-left').scrollTo('top'); - cy.get('.slick-cell + .dynamic-cell-detail').find('[data-test=delete-btn]').click(); + cy.get('.dynamic-cell-detail').find('[data-test=delete-btn]').click(); cy.get('.notification.is-danger').contains(/Deleted row with Task [0-9]*/); cy.get('.dynamic-cell-detail').should('have.length', 0); }); @@ -134,7 +134,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.get('[data-row="1"] > .slick-cell.l2').contains(/Task [0-9]*/); cy.get('[data-row="1"] > .slick-cell.l1').click().wait(40); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); @@ -161,7 +161,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.wait(50); cy.get('.slick-group-toggle.collapsed').first().click(); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); @@ -188,7 +188,7 @@ describe('Example 36 - Row Detail View + Grouping', () => { cy.wait(50); cy.get('[data-test=expand-all-groups-btn]').click(); - cy.get('.slick-cell + .dynamic-cell-detail') + cy.get('.dynamic-cell-detail') .find('h4') .contains(/Task [0-9]*/); From eb3edd7f8c475ed19377232b07a7320a15464b54 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 18 Aug 2026 20:13:30 -0400 Subject: [PATCH 46/57] docs: add agent instructions to run framework cypress tests --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1eecde5969..0c9f01d06f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ 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. +- 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. - Run the smallest relevant checks first, then broader checks when practical: From 11f1160d898b2a9c2e1a03a8ba857ae864c7dc07 Mon Sep 17 00:00:00 2001 From: "Ghislain B." Date: Wed, 19 Aug 2026 02:25:37 -0400 Subject: [PATCH 47/57] fix(rowspan): support rowTopOffsetRenderType transform (#2739) * fix(rowspan): support rowTopOffsetRenderType transform --- .../src/examples/slickgrid/example43.ts | 2 +- .../src/examples/slickgrid/example44.ts | 2 +- .../aurelia/test/cypress/e2e/example44.cy.ts | 98 +++++++++---------- .../src/examples/slickgrid/Example43.tsx | 2 +- .../src/examples/slickgrid/Example44.tsx | 2 +- demos/react/test/cypress/e2e/example44.cy.ts | 98 +++++++++---------- demos/vanilla/src/examples/example32.ts | 2 +- demos/vanilla/src/examples/example33.ts | 2 +- demos/vue/src/components/Example43.vue | 2 +- demos/vue/src/components/Example44.vue | 2 +- demos/vue/test/cypress/e2e/example44.cy.ts | 98 +++++++++---------- .../column-row-spanning.md | 4 +- .../column-row-spanning.md | 4 +- .../src/demos/examples/example43.component.ts | 2 +- .../src/demos/examples/example44.component.ts | 2 +- .../test/cypress/e2e/example44.cy.ts | 98 +++++++++---------- .../column-row-spanning.md | 4 +- .../column-row-spanning.md | 4 +- .../column-row-spanning.md | 4 +- .../src/core/__tests__/slickGrid.spec.ts | 37 ++++--- packages/common/src/core/slickGrid.ts | 42 ++++---- .../src/interfaces/gridOption.interface.ts | 2 +- packages/common/src/styles/slick-grid.scss | 10 ++ test/cypress/e2e/example33.cy.ts | 98 +++++++++---------- 24 files changed, 309 insertions(+), 312 deletions(-) diff --git a/demos/aurelia/src/examples/slickgrid/example43.ts b/demos/aurelia/src/examples/slickgrid/example43.ts index fc4a4adbdc..9307b653d3 100644 --- a/demos/aurelia/src/examples/slickgrid/example43.ts +++ b/demos/aurelia/src/examples/slickgrid/example43.ts @@ -169,7 +169,7 @@ export class Example43 { gridMenu: { hideColumnPickerSection: true, }, - rowTopOffsetRenderType: 'top', // RowDetail and/or RowSpan don't render well with "transform", you should use "top" + rowTopOffsetRenderType: 'top', // intentional top-positioning coverage; rowspan also supports 'transform' }; } diff --git a/demos/aurelia/src/examples/slickgrid/example44.ts b/demos/aurelia/src/examples/slickgrid/example44.ts index 5d70e82161..f07cc69197 100644 --- a/demos/aurelia/src/examples/slickgrid/example44.ts +++ b/demos/aurelia/src/examples/slickgrid/example44.ts @@ -280,7 +280,7 @@ export class Example44 { }, enableExcelExport: true, externalResources: [new ExcelExportService()], - rowTopOffsetRenderType: 'top', // RowDetail and/or RowSpan don't render well with "transform", you should use "top" + // rowTopOffsetRenderType: 'top', // no longer necessary with v10.10.0 and above; otherwise, uncomment this line }; } diff --git a/demos/aurelia/test/cypress/e2e/example44.cy.ts b/demos/aurelia/test/cypress/e2e/example44.cy.ts index f399ed0364..5509df345a 100644 --- a/demos/aurelia/test/cypress/e2e/example44.cy.ts +++ b/demos/aurelia/test/cypress/e2e/example44.cy.ts @@ -39,7 +39,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should drag Title column to swap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Task 0 to spread instead', () => { const expectedTitles = ['Revenue Growth', 'Title', 'Pricing Policy']; - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -51,11 +51,11 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Revenue Growth'); cy.get('.slick-header-column:nth(1)').contains('Title'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l1.r1`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 3'); + cy.get(`[data-row=0] > .slick-cell.l1.r1`).should('contain', 'Task 0'); + cy.get(`[data-row=2] > .slick-cell.l1.r1`).should('not.exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1`).should('contain', 'Task 3'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -69,7 +69,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { }); it('should drag back Title column to reswap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Revenue Growth to now spread', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); cy.get('.slick-header-columns') @@ -80,10 +80,10 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Title'); cy.get('.slick-header-column:nth(1)').contains('Revenue Growth'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 1'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l0.r0`).should('not.exist'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=1] > .slick-cell.l0.r0`).should('contain', 'Task 1'); + cy.get(`[data-row=2] > .slick-cell.l0.r0`).should('contain', 'Task 2'); + cy.get(`[data-row=3] > .slick-cell.l0.r0`).should('not.exist'); const expectedTitles = ['Title', 'Revenue Growth', 'Pricing Policy']; cy.get('.slick-header-columns') @@ -97,73 +97,65 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { describe('spanning', () => { it('should expect first row to be regular rows without any spanning', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); for (let i = 2; i <= 6; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l${i}.r${i}`).should('exist'); + cy.get(`[data-row=0] > .slick-cell.l${i}.r${i}`).should('exist'); } }); it('should expect 1st row, second cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3); }); for (let i = 2; i <= 14; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number + cy.get(`[data-row=1] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number } }); it('should expect 3rd row first cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); for (let i = 2; i <= 5; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); + cy.get(`[data-row=2] > .slick-cell:nth(${i})`).contains(/\d+$/); } }); it('should expect 4th row to have 2 sections (blue, green) spanning across 3 rows (rowspan) and 2 columns (colspan)', () => { // blue rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l2.r2`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l2.r2`).should('exist').contains(/\d+$/); // green colspan/rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l3.r7`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l8.r8`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l9.r9`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l3.r7`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l8.r8`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l9.r9`).should('exist').contains(/\d+$/); }); it('should click on "Toggle blue cell colspan..." and expect colspan to widen from 1 column to 2 columns and from 5 rows to 3 rowspan', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); cy.get('[data-test="toggleSpans"]').click(); cy.get('.slick-cell.l1.r1.rowspan').should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r2.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); }); it('should expect Task 8 on 2nd column to have rowspan spanning 80 cells', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); }); @@ -171,36 +163,36 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should scroll to the right and still expect spans without any extra texts', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(400, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); // next rows are regular cells - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l4.r4`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l4.r4`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); }); it('should scroll back to left and expect Task 8 to have 2 different spans (Revenue Grow: rowspan=80, Policy Index: rowspan=2000,colspan=2)', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(0, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(2)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l3.r4`).should('exist'); + cy.get(`[data-row=8] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell:nth(2)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l3.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 9}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 9'); + cy.get(`[data-row=9] > .slick-cell.l0.r0`).should('contain', 'Task 9'); }); it('should scroll to row 85 and still expect 3 spans in the screen, "Revenue Growth" and "Policy Index" spans', () => { diff --git a/demos/react/src/examples/slickgrid/Example43.tsx b/demos/react/src/examples/slickgrid/Example43.tsx index 69a15870fa..2521e1e0ef 100644 --- a/demos/react/src/examples/slickgrid/Example43.tsx +++ b/demos/react/src/examples/slickgrid/Example43.tsx @@ -155,7 +155,7 @@ export default function Example43() { gridMenu: { hideColumnPickerSection: true, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'top', // intentional top-positioning coverage; rowspan also supports 'transform' }; function exportToExcel() { diff --git a/demos/react/src/examples/slickgrid/Example44.tsx b/demos/react/src/examples/slickgrid/Example44.tsx index 6cf0819615..44edcab148 100644 --- a/demos/react/src/examples/slickgrid/Example44.tsx +++ b/demos/react/src/examples/slickgrid/Example44.tsx @@ -278,7 +278,7 @@ export default function Example44() { }, enableExcelExport: true, externalResources: [new ExcelExportService()], - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + // rowTopOffsetRenderType: 'top', // no longer necessary with v10.10.0 and above; otherwise, uncomment this line }; function clearScrollTo() { diff --git a/demos/react/test/cypress/e2e/example44.cy.ts b/demos/react/test/cypress/e2e/example44.cy.ts index f399ed0364..5509df345a 100644 --- a/demos/react/test/cypress/e2e/example44.cy.ts +++ b/demos/react/test/cypress/e2e/example44.cy.ts @@ -39,7 +39,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should drag Title column to swap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Task 0 to spread instead', () => { const expectedTitles = ['Revenue Growth', 'Title', 'Pricing Policy']; - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -51,11 +51,11 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Revenue Growth'); cy.get('.slick-header-column:nth(1)').contains('Title'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l1.r1`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 3'); + cy.get(`[data-row=0] > .slick-cell.l1.r1`).should('contain', 'Task 0'); + cy.get(`[data-row=2] > .slick-cell.l1.r1`).should('not.exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1`).should('contain', 'Task 3'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -69,7 +69,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { }); it('should drag back Title column to reswap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Revenue Growth to now spread', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); cy.get('.slick-header-columns') @@ -80,10 +80,10 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Title'); cy.get('.slick-header-column:nth(1)').contains('Revenue Growth'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 1'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l0.r0`).should('not.exist'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=1] > .slick-cell.l0.r0`).should('contain', 'Task 1'); + cy.get(`[data-row=2] > .slick-cell.l0.r0`).should('contain', 'Task 2'); + cy.get(`[data-row=3] > .slick-cell.l0.r0`).should('not.exist'); const expectedTitles = ['Title', 'Revenue Growth', 'Pricing Policy']; cy.get('.slick-header-columns') @@ -97,73 +97,65 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { describe('spanning', () => { it('should expect first row to be regular rows without any spanning', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); for (let i = 2; i <= 6; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l${i}.r${i}`).should('exist'); + cy.get(`[data-row=0] > .slick-cell.l${i}.r${i}`).should('exist'); } }); it('should expect 1st row, second cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3); }); for (let i = 2; i <= 14; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number + cy.get(`[data-row=1] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number } }); it('should expect 3rd row first cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); for (let i = 2; i <= 5; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); + cy.get(`[data-row=2] > .slick-cell:nth(${i})`).contains(/\d+$/); } }); it('should expect 4th row to have 2 sections (blue, green) spanning across 3 rows (rowspan) and 2 columns (colspan)', () => { // blue rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l2.r2`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l2.r2`).should('exist').contains(/\d+$/); // green colspan/rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l3.r7`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l8.r8`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l9.r9`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l3.r7`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l8.r8`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l9.r9`).should('exist').contains(/\d+$/); }); it('should click on "Toggle blue cell colspan..." and expect colspan to widen from 1 column to 2 columns and from 5 rows to 3 rowspan', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); cy.get('[data-test="toggleSpans"]').click(); cy.get('.slick-cell.l1.r1.rowspan').should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r2.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); }); it('should expect Task 8 on 2nd column to have rowspan spanning 80 cells', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); }); @@ -171,36 +163,36 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should scroll to the right and still expect spans without any extra texts', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(400, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); // next rows are regular cells - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l4.r4`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l4.r4`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); }); it('should scroll back to left and expect Task 8 to have 2 different spans (Revenue Grow: rowspan=80, Policy Index: rowspan=2000,colspan=2)', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(0, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(2)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l3.r4`).should('exist'); + cy.get(`[data-row=8] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell:nth(2)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l3.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 9}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 9'); + cy.get(`[data-row=9] > .slick-cell.l0.r0`).should('contain', 'Task 9'); }); it('should scroll to row 85 and still expect 3 spans in the screen, "Revenue Growth" and "Policy Index" spans', () => { diff --git a/demos/vanilla/src/examples/example32.ts b/demos/vanilla/src/examples/example32.ts index 4e5bcaebd1..b18f9efd59 100644 --- a/demos/vanilla/src/examples/example32.ts +++ b/demos/vanilla/src/examples/example32.ts @@ -189,7 +189,7 @@ export default class Example32 { gridMenu: { hideColumnPickerSection: true, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'top', // intentional top-positioning coverage; rowspan also supports 'transform' }; } diff --git a/demos/vanilla/src/examples/example33.ts b/demos/vanilla/src/examples/example33.ts index 4e20fe0087..62695a910e 100644 --- a/demos/vanilla/src/examples/example33.ts +++ b/demos/vanilla/src/examples/example33.ts @@ -297,7 +297,7 @@ export default class Example33 { }, enableExcelExport: true, externalResources: [new ExcelExportService()], - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + // rowTopOffsetRenderType: 'top', // no longer necessary with v10.10.0 and above; otherwise, uncomment this line }; } diff --git a/demos/vue/src/components/Example43.vue b/demos/vue/src/components/Example43.vue index 51c355f296..7d21b20909 100644 --- a/demos/vue/src/components/Example43.vue +++ b/demos/vue/src/components/Example43.vue @@ -163,7 +163,7 @@ function defineGrid() { gridMenu: { hideColumnPickerSection: true, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'top', // intentional top-positioning coverage; rowspan also supports 'transform' }; } diff --git a/demos/vue/src/components/Example44.vue b/demos/vue/src/components/Example44.vue index 232c58b36f..ff96e74494 100644 --- a/demos/vue/src/components/Example44.vue +++ b/demos/vue/src/components/Example44.vue @@ -274,7 +274,7 @@ function defineGrid() { }, enableExcelExport: true, externalResources: [new ExcelExportService()], - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + // rowTopOffsetRenderType: 'top', // no longer necessary with v10.10.0 and above; otherwise, uncomment this line }; } diff --git a/demos/vue/test/cypress/e2e/example44.cy.ts b/demos/vue/test/cypress/e2e/example44.cy.ts index f399ed0364..5509df345a 100644 --- a/demos/vue/test/cypress/e2e/example44.cy.ts +++ b/demos/vue/test/cypress/e2e/example44.cy.ts @@ -39,7 +39,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should drag Title column to swap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Task 0 to spread instead', () => { const expectedTitles = ['Revenue Growth', 'Title', 'Pricing Policy']; - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -51,11 +51,11 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Revenue Growth'); cy.get('.slick-header-column:nth(1)').contains('Title'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l1.r1`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 3'); + cy.get(`[data-row=0] > .slick-cell.l1.r1`).should('contain', 'Task 0'); + cy.get(`[data-row=2] > .slick-cell.l1.r1`).should('not.exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1`).should('contain', 'Task 3'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -69,7 +69,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { }); it('should drag back Title column to reswap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Revenue Growth to now spread', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); cy.get('.slick-header-columns') @@ -80,10 +80,10 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Title'); cy.get('.slick-header-column:nth(1)').contains('Revenue Growth'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 1'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l0.r0`).should('not.exist'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=1] > .slick-cell.l0.r0`).should('contain', 'Task 1'); + cy.get(`[data-row=2] > .slick-cell.l0.r0`).should('contain', 'Task 2'); + cy.get(`[data-row=3] > .slick-cell.l0.r0`).should('not.exist'); const expectedTitles = ['Title', 'Revenue Growth', 'Pricing Policy']; cy.get('.slick-header-columns') @@ -97,73 +97,65 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { describe('spanning', () => { it('should expect first row to be regular rows without any spanning', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); for (let i = 2; i <= 6; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l${i}.r${i}`).should('exist'); + cy.get(`[data-row=0] > .slick-cell.l${i}.r${i}`).should('exist'); } }); it('should expect 1st row, second cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3); }); for (let i = 2; i <= 14; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number + cy.get(`[data-row=1] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number } }); it('should expect 3rd row first cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); for (let i = 2; i <= 5; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); + cy.get(`[data-row=2] > .slick-cell:nth(${i})`).contains(/\d+$/); } }); it('should expect 4th row to have 2 sections (blue, green) spanning across 3 rows (rowspan) and 2 columns (colspan)', () => { // blue rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l2.r2`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l2.r2`).should('exist').contains(/\d+$/); // green colspan/rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l3.r7`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l8.r8`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l9.r9`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l3.r7`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l8.r8`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l9.r9`).should('exist').contains(/\d+$/); }); it('should click on "Toggle blue cell colspan..." and expect colspan to widen from 1 column to 2 columns and from 5 rows to 3 rowspan', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); cy.get('[data-test="toggleSpans"]').click(); cy.get('.slick-cell.l1.r1.rowspan').should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r2.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); }); it('should expect Task 8 on 2nd column to have rowspan spanning 80 cells', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); }); @@ -171,36 +163,36 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should scroll to the right and still expect spans without any extra texts', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(400, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); // next rows are regular cells - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l4.r4`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l4.r4`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); }); it('should scroll back to left and expect Task 8 to have 2 different spans (Revenue Grow: rowspan=80, Policy Index: rowspan=2000,colspan=2)', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(0, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(2)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l3.r4`).should('exist'); + cy.get(`[data-row=8] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell:nth(2)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l3.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 9}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 9'); + cy.get(`[data-row=9] > .slick-cell.l0.r0`).should('contain', 'Task 9'); }); it('should scroll to row 85 and still expect 3 spans in the screen, "Revenue Growth" and "Policy Index" spans', () => { diff --git a/docs/grid-functionalities/column-row-spanning.md b/docs/grid-functionalities/column-row-spanning.md index e0dc4fc156..ca478bfb4a 100644 --- a/docs/grid-functionalities/column-row-spanning.md +++ b/docs/grid-functionalities/column-row-spanning.md @@ -57,8 +57,10 @@ example class MyExample { }, }, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'transform', // default; RowSpan is supported with v10.10.x+ }; } } ``` + +**v11 transition:** RowSpan uses hybrid positioning in v10.10.x+ so that `rowTopOffsetRenderType: 'transform'` works without requiring the legacy `top` workaround. In v11, transform-based row positioning is planned to be the only supported mode, so `rowTopOffsetRenderType: 'top'` will no longer be necessary and the `'top'` value may be removed from the API. diff --git a/frameworks/angular-slickgrid/docs/grid-functionalities/column-row-spanning.md b/frameworks/angular-slickgrid/docs/grid-functionalities/column-row-spanning.md index 0e91b7f0a6..207f32b749 100644 --- a/frameworks/angular-slickgrid/docs/grid-functionalities/column-row-spanning.md +++ b/frameworks/angular-slickgrid/docs/grid-functionalities/column-row-spanning.md @@ -67,8 +67,10 @@ export class Grid43Component implements OnInit { }, }, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'transform', // default; RowSpan is supported with v10.10.x+ }; } } ``` + +**v11 transition:** RowSpan uses hybrid positioning in v10.10.x+ so that `rowTopOffsetRenderType: 'transform'` works without requiring the legacy `top` workaround. In v11, transform-based row positioning is planned to be the only supported mode, so `rowTopOffsetRenderType: 'top'` will no longer be necessary and the `'top'` value may be removed from the API. diff --git a/frameworks/angular-slickgrid/src/demos/examples/example43.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example43.component.ts index 2033b401b2..ea7ccf2f01 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example43.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example43.component.ts @@ -182,7 +182,7 @@ export class Example43Component implements OnInit { gridMenu: { hideColumnPickerSection: true, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'top', // intentional top-positioning coverage; rowspan also supports 'transform' }; } diff --git a/frameworks/angular-slickgrid/src/demos/examples/example44.component.ts b/frameworks/angular-slickgrid/src/demos/examples/example44.component.ts index a6b3b01656..048b16b73e 100644 --- a/frameworks/angular-slickgrid/src/demos/examples/example44.component.ts +++ b/frameworks/angular-slickgrid/src/demos/examples/example44.component.ts @@ -294,7 +294,7 @@ export class Example44Component implements OnInit { }, enableExcelExport: true, externalResources: [new ExcelExportService()], - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + // rowTopOffsetRenderType: 'top', // no longer necessary with v10.10.0 and above; otherwise, uncomment this line }; } diff --git a/frameworks/angular-slickgrid/test/cypress/e2e/example44.cy.ts b/frameworks/angular-slickgrid/test/cypress/e2e/example44.cy.ts index f399ed0364..5509df345a 100644 --- a/frameworks/angular-slickgrid/test/cypress/e2e/example44.cy.ts +++ b/frameworks/angular-slickgrid/test/cypress/e2e/example44.cy.ts @@ -39,7 +39,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should drag Title column to swap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Task 0 to spread instead', () => { const expectedTitles = ['Revenue Growth', 'Title', 'Pricing Policy']; - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -51,11 +51,11 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Revenue Growth'); cy.get('.slick-header-column:nth(1)').contains('Title'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l1.r1`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 3'); + cy.get(`[data-row=0] > .slick-cell.l1.r1`).should('contain', 'Task 0'); + cy.get(`[data-row=2] > .slick-cell.l1.r1`).should('not.exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1`).should('contain', 'Task 3'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -69,7 +69,7 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { }); it('should drag back Title column to reswap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Revenue Growth to now spread', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); cy.get('.slick-header-columns') @@ -80,10 +80,10 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Title'); cy.get('.slick-header-column:nth(1)').contains('Revenue Growth'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 1'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l0.r0`).should('not.exist'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=1] > .slick-cell.l0.r0`).should('contain', 'Task 1'); + cy.get(`[data-row=2] > .slick-cell.l0.r0`).should('contain', 'Task 2'); + cy.get(`[data-row=3] > .slick-cell.l0.r0`).should('not.exist'); const expectedTitles = ['Title', 'Revenue Growth', 'Pricing Policy']; cy.get('.slick-header-columns') @@ -97,73 +97,65 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { describe('spanning', () => { it('should expect first row to be regular rows without any spanning', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); for (let i = 2; i <= 6; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l${i}.r${i}`).should('exist'); + cy.get(`[data-row=0] > .slick-cell.l${i}.r${i}`).should('exist'); } }); it('should expect 1st row, second cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3); }); for (let i = 2; i <= 14; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number + cy.get(`[data-row=1] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number } }); it('should expect 3rd row first cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); for (let i = 2; i <= 5; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); + cy.get(`[data-row=2] > .slick-cell:nth(${i})`).contains(/\d+$/); } }); it('should expect 4th row to have 2 sections (blue, green) spanning across 3 rows (rowspan) and 2 columns (colspan)', () => { // blue rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l2.r2`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l2.r2`).should('exist').contains(/\d+$/); // green colspan/rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l3.r7`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l8.r8`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l9.r9`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l3.r7`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l8.r8`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l9.r9`).should('exist').contains(/\d+$/); }); it('should click on "Toggle blue cell colspan..." and expect colspan to widen from 1 column to 2 columns and from 5 rows to 3 rowspan', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); cy.get('[data-test="toggleSpans"]').click(); cy.get('.slick-cell.l1.r1.rowspan').should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r2.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); }); it('should expect Task 8 on 2nd column to have rowspan spanning 80 cells', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); }); @@ -171,36 +163,36 @@ describe('Example 44 - Column & Row Span', { retries: 0 }, () => { it('should scroll to the right and still expect spans without any extra texts', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(400, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); // next rows are regular cells - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l4.r4`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l4.r4`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); }); it('should scroll back to left and expect Task 8 to have 2 different spans (Revenue Grow: rowspan=80, Policy Index: rowspan=2000,colspan=2)', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(0, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(2)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l3.r4`).should('exist'); + cy.get(`[data-row=8] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell:nth(2)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l3.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 9}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 9'); + cy.get(`[data-row=9] > .slick-cell.l0.r0`).should('contain', 'Task 9'); }); it('should scroll to row 85 and still expect 3 spans in the screen, "Revenue Growth" and "Policy Index" spans', () => { diff --git a/frameworks/aurelia-slickgrid/docs/grid-functionalities/column-row-spanning.md b/frameworks/aurelia-slickgrid/docs/grid-functionalities/column-row-spanning.md index 67da62869d..a95e734146 100644 --- a/frameworks/aurelia-slickgrid/docs/grid-functionalities/column-row-spanning.md +++ b/frameworks/aurelia-slickgrid/docs/grid-functionalities/column-row-spanning.md @@ -57,8 +57,10 @@ example class MyExample { }, }, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'transform', // default; RowSpan is supported with v10.10.x+ }; } } ``` + +**v11 transition:** RowSpan uses hybrid positioning in v10.10.x+ so that `rowTopOffsetRenderType: 'transform'` works without requiring the legacy `top` workaround. In v11, transform-based row positioning is planned to be the only supported mode, so `rowTopOffsetRenderType: 'top'` will no longer be necessary and the `'top'` value may be removed from the API. diff --git a/frameworks/slickgrid-react/docs/grid-functionalities/column-row-spanning.md b/frameworks/slickgrid-react/docs/grid-functionalities/column-row-spanning.md index 1c08dc4023..722d0b60aa 100644 --- a/frameworks/slickgrid-react/docs/grid-functionalities/column-row-spanning.md +++ b/frameworks/slickgrid-react/docs/grid-functionalities/column-row-spanning.md @@ -72,8 +72,10 @@ const Example: React.FC = () => { }, }, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'transform', // default; RowSpan is supported with v10.10.x+ }); } } ``` + +**v11 transition:** RowSpan uses hybrid positioning in v10.10.x+ so that `rowTopOffsetRenderType: 'transform'` works without requiring the legacy `top` workaround. In v11, transform-based row positioning is planned to be the only supported mode, so `rowTopOffsetRenderType: 'top'` will no longer be necessary and the `'top'` value may be removed from the API. diff --git a/frameworks/slickgrid-vue/docs/grid-functionalities/column-row-spanning.md b/frameworks/slickgrid-vue/docs/grid-functionalities/column-row-spanning.md index 20f3406ab9..700630f69a 100644 --- a/frameworks/slickgrid-vue/docs/grid-functionalities/column-row-spanning.md +++ b/frameworks/slickgrid-vue/docs/grid-functionalities/column-row-spanning.md @@ -63,7 +63,7 @@ function defineGrid() { }, }, }, - rowTopOffsetRenderType: 'top', // rowspan doesn't render well with 'transform', default is 'top' + rowTopOffsetRenderType: 'transform', // default; RowSpan is supported with v10.10.x+ }; } @@ -77,3 +77,5 @@ function defineGrid() { /> ``` + +**v11 transition:** RowSpan uses hybrid positioning in v10.10.x+ so that `rowTopOffsetRenderType: 'transform'` works without requiring the legacy `top` workaround. In v11, transform-based row positioning is planned to be the only supported mode, so `rowTopOffsetRenderType: 'top'` will no longer be necessary and the `'top'` value may be removed from the API. diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index 0d08f7ef84..afdf69d24a 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -244,7 +244,7 @@ describe('SlickGrid core file', () => { rowTopOffsetRenderType: 'transform', enableRowDetailView: true, rowDetailView: { renderMode: 'overlay' }, - }, + } as GridOption, pubSubServiceStub ); grid.init(); @@ -252,24 +252,39 @@ describe('SlickGrid core file', () => { expect(grid.getOptions().rowTopOffsetRenderType).toBe('transform'); }); - it('should display a console warning when RowSpan is enabled with `rowTopOffsetRenderType` is set to "transfrom"', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockReturnValue(); - - document.body.style.zoom = '90%'; - const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name' }] as Column[]; + it('should keep RowSpan host rows top-positioned while other rows use transforms', () => { + const columns = [ + { id: 'firstName', field: 'firstName', name: 'First Name' }, + { id: 'lastName', field: 'lastName', name: 'Last Name' }, + ] as Column[]; + const data = [ + { id: 0, firstName: 'Jane', lastName: 'Doe' }, + { id: 1, firstName: 'John', lastName: 'Doe' }, + ]; + const dataView = new SlickDataView({ + globalItemMetadataProvider: { getRowMetadata: (_item, row) => (row === 0 ? { columns: { 0: { rowspan: 2 } } } : undefined) }, + }); + dataView.setItems(data); grid = new SlickGrid( '#myGrid', - [], + dataView, columns, { ...defaultOptions, rowTopOffsetRenderType: 'transform', enableCellRowSpan: true }, pubSubServiceStub ); grid.init(); - expect(grid).toBeTruthy(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using RowSpan') - ); + const spanRow = container.querySelector('.slick-row[data-row="0"]')!; + const regularRow = container.querySelector('.slick-row[data-row="1"]')!; + expect(grid.getOptions().rowTopOffsetRenderType).toBe('transform'); + expect(spanRow.style.top).toBe('0px'); + expect(spanRow.style.transform).toBe(''); + expect(spanRow.classList).toContain('slick-rowspan'); + expect(regularRow.style.top).toBe(''); + expect(regularRow.style.transform).toBe(`translateY(${grid.getOptions().rowHeight}px)`); + const spanCell = spanRow.querySelector('.slick-cell.rowspan')!; + expect(spanCell).toBeTruthy(); + expect(grid.getCellFromEvent({ target: spanCell } as unknown as Event)).toEqual({ row: 0, cell: 0 }); }); it('should be able to instantiate SlickGrid and get columns', () => { diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 1e3d6cece9..925d55bc24 100755 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -650,11 +650,6 @@ export class SlickGrid = Column, O e 'SlickGrid relies on row positioning calculations that can drift with browser zoom.' ); } - if (this._options.rowTopOffsetRenderType === 'transform' && this._options.enableCellRowSpan) { - console.warn( - '[Slickgrid-Universal] `rowTopOffsetRenderType` should be set to "top" when using RowSpan since "transform" is known to have UI issues.' - ); - } this.finishInitialization(); } @@ -4344,14 +4339,6 @@ export class SlickGrid = Column, O e role: 'row', dataset: { row: `${row}` }, }); - const frozenRowOffset = this.getFrozenRowOffset(row); - const topOffset = this.getRowTop(row) - frozenRowOffset; - if (this._options.rowTopOffsetRenderType === 'transform') { - rowDiv.style.transform = `translateY(${topOffset}px)`; - } else { - rowDiv.style.top = `${topOffset}px`; // default to `top: {offset}px` - } - if (this._options.enableVariableRowHeight) { // only rows with a non-default height get an inline height so that rows with // the default height can be sized by the stylesheet rule @@ -4438,6 +4425,26 @@ export class SlickGrid = Column, O e } } } + + this.applyRowTopOffset(rowDiv, row); + if (rowDivR) { + this.applyRowTopOffset(rowDivR, row); + } + } + + /** Keep RowSpan host rows top-positioned so their cells escape transformed sibling stacking contexts. */ + protected applyRowTopOffset(rowNode: HTMLElement, row: number): void { + const top = this.getRowTop(row) - this.getFrozenRowOffset(row); + const isTransform = this._options.rowTopOffsetRenderType === 'transform'; + const hasRowSpan = this._options.enableCellRowSpan && !!rowNode.querySelector('.slick-cell.rowspan'); + rowNode.classList.toggle('slick-rowspan', isTransform && hasRowSpan); + if (isTransform && !hasRowSpan) { + rowNode.style.top = ''; + rowNode.style.transform = `translateY(${top}px)`; + } else { + rowNode.style.top = `${top}px`; + rowNode.style.transform = ''; + } } protected appendCellHtml( @@ -5642,6 +5649,7 @@ export class SlickGrid = Column, O e cacheEntry.cellNodesByColumnIdx![columnIdx] = node; } } + cacheEntry.rowNode?.forEach((rowNode) => this.applyRowTopOffset(rowNode, processedRow!)); } } @@ -5801,14 +5809,8 @@ export class SlickGrid = Column, O e if (this.rowsCache && typeof this.rowsCache === 'object') { Object.keys(this.rowsCache).forEach((row) => { const rowNumber = row ? parseInt(row, 10) : 0; - // same formula appendRowHtml uses to place rows initially - const top = this.getRowTop(rowNumber) - this.getFrozenRowOffset(rowNumber); this.rowsCache[rowNumber].rowNode!.forEach((rowNode) => { - if (this._options.rowTopOffsetRenderType === 'transform') { - rowNode.style.transform = `translateY(${top}px)`; - } else { - rowNode.style.top = `${top}px`; // default to `top: {offset}px` - } + this.applyRowTopOffset(rowNode, rowNumber); }); }); } diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index c209d1f099..3803b7525e 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -875,7 +875,7 @@ export interface GridOption { /** * Defaults to "transform", what CSS style to we want to use to render each row top offset (choose between "top" and "transform"). * For example, with a default `rowHeight: 22`, the 2nd row will have a `top` offset of 44px and by default have a CSS style of `transform: translateY(44px)`. - * NOTE: use `top` with the legacy inline Row Detail renderer and/or RowSpan. Row Detail can use `rowDetailView.renderMode: 'overlay'` for transform compatibility. + * NOTE: use `top` with the legacy inline Row Detail renderer. Row Detail uses an overlay and RowSpan uses hybrid positioning for transform compatibility. */ rowTopOffsetRenderType?: 'top' | 'transform'; diff --git a/packages/common/src/styles/slick-grid.scss b/packages/common/src/styles/slick-grid.scss index 307b2bdc81..a1b435c7a4 100644 --- a/packages/common/src/styles/slick-grid.scss +++ b/packages/common/src/styles/slick-grid.scss @@ -260,6 +260,16 @@ } } + // Active-row padding changes the containing box and can clip the fixed-height RowSpan cell. + .slick-row.active.slick-rowspan { + padding: 0; + } + + // Keep the complete RowSpan host row above selected/hovered transformed rows. + .slick-row.slick-rowspan { + z-index: var(--slick-rowspan-z-index, 10); + } + .slick-group-header-columns { position: relative; white-space: nowrap; diff --git a/test/cypress/e2e/example33.cy.ts b/test/cypress/e2e/example33.cy.ts index aa6bda3523..df91c46a00 100644 --- a/test/cypress/e2e/example33.cy.ts +++ b/test/cypress/e2e/example33.cy.ts @@ -39,7 +39,7 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { it('should drag Title column to swap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Task 0 to spread instead', () => { const expectedTitles = ['Revenue Growth', 'Title', 'Pricing Policy']; - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -51,11 +51,11 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Revenue Growth'); cy.get('.slick-header-column:nth(1)').contains('Title'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l1.r1`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1`).should('contain', 'Task 3'); + cy.get(`[data-row=0] > .slick-cell.l1.r1`).should('contain', 'Task 0'); + cy.get(`[data-row=2] > .slick-cell.l1.r1`).should('not.exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1`).should('contain', 'Task 3'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); @@ -69,7 +69,7 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { }); it('should drag back Title column to reswap with 2nd column "Revenue Growth" in the grid and expect rowspan to stay at same position with Revenue Growth to now spread', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); cy.get('.slick-header-columns') @@ -80,10 +80,10 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { cy.get('.slick-header-column:nth(0)').contains('Title'); cy.get('.slick-header-column:nth(1)').contains('Revenue Growth'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 1'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l0.r0`).should('not.exist'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=1] > .slick-cell.l0.r0`).should('contain', 'Task 1'); + cy.get(`[data-row=2] > .slick-cell.l0.r0`).should('contain', 'Task 2'); + cy.get(`[data-row=3] > .slick-cell.l0.r0`).should('not.exist'); const expectedTitles = ['Title', 'Revenue Growth', 'Pricing Policy']; cy.get('.slick-header-columns') @@ -97,73 +97,65 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { describe('spanning', () => { it('should expect first row to be regular rows without any spanning', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); for (let i = 2; i <= 6; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l${i}.r${i}`).should('exist'); + cy.get(`[data-row=0] > .slick-cell.l${i}.r${i}`).should('exist'); } }); it('should expect 1st row, second cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 0'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=0] > .slick-cell.l0.r0`).should('contain', 'Task 0'); + cy.get(`[data-row=0] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3); }); for (let i = 2; i <= 14; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number + cy.get(`[data-row=1] > .slick-cell:nth(${i})`).contains(/\d+$/); // use regexp to make sure it's a number } }); it('should expect 3rd row first cell to span (rowspan) across 3 rows', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell.l0.r0.rowspan`).should(($el) => + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should('contain', 'Task 2'); + cy.get(`[data-row=2] > .slick-cell.l0.r0.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); for (let i = 2; i <= 5; i++) { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(${i})`).contains(/\d+$/); + cy.get(`[data-row=2] > .slick-cell:nth(${i})`).contains(/\d+$/); } }); it('should expect 4th row to have 2 sections (blue, green) spanning across 3 rows (rowspan) and 2 columns (colspan)', () => { // blue rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l2.r2`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l2.r2`).should('exist').contains(/\d+$/); // green colspan/rowspan section - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l3.r7`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l8.r8`) - .should('exist') - .contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l9.r9`) - .should('exist') - .contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l3.r7`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l8.r8`).should('exist').contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l9.r9`).should('exist').contains(/\d+$/); }); it('should click on "Toggle blue cell colspan..." and expect colspan to widen from 1 column to 2 columns and from 5 rows to 3 rowspan', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r1.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l1.r1.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 5) ); cy.get('[data-test="toggleSpans"]').click(); cy.get('.slick-cell.l1.r1.rowspan').should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell.l1.r2.rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); }); it('should expect Task 8 on 2nd column to have rowspan spanning 80 cells', () => { - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); }); @@ -171,36 +163,36 @@ describe('Example 33 - Column & Row Span', { retries: 0 }, () => { it('should scroll to the right and still expect spans without any extra texts', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(400, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(0).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 3}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => + cy.get(`[data-row=3] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=3] > .slick-cell.l1.r2.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should('exist'); + cy.get(`[data-row=3] > .slick-cell.l3.r7.rowspan`).should(($el) => expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 3) ); // next rows are regular cells - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell.l4.r4`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=4] > .slick-cell.l4.r4`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); + cy.get(`[data-row=5] > .slick-cell.l3.r3`).should('not.exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); + cy.get(`[data-row=6] > .slick-cell.l4.r4`).should('exist'); }); it('should scroll back to left and expect Task 8 to have 2 different spans (Revenue Grow: rowspan=80, Policy Index: rowspan=2000,colspan=2)', () => { cy.get('.slick-viewport-top.slick-viewport-left').scrollTo(0, 0).wait(10); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 8'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1).rowspan`).should(($el) => { + cy.get(`[data-row=8] > .slick-cell.l0.r0`).should('contain', 'Task 8'); + cy.get(`[data-row=8] > .slick-cell.l1.r1.rowspan`).should(($el) => { expect(parseInt(`${$el.outerHeight()}`, 10)).to.eq(GRID_ROW_HEIGHT * 80); }); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(1)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell:nth(2)`).contains(/\d+$/); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 8}px;"] > .slick-cell.l3.r4`).should('exist'); + cy.get(`[data-row=8] > .slick-cell:nth(1)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell:nth(2)`).contains(/\d+$/); + cy.get(`[data-row=8] > .slick-cell.l3.r4`).should('exist'); - cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 9}px;"] > .slick-cell.l0.r0`).should('contain', 'Task 9'); + cy.get(`[data-row=9] > .slick-cell.l0.r0`).should('contain', 'Task 9'); }); it('should scroll to row 85 and still expect 3 spans in the screen, "Revenue Growth" and "Policy Index" spans', () => { From a9095056c4df3521c0a5360d728f1ed1f554ed3d Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 20 Aug 2026 18:21:54 -0400 Subject: [PATCH 48/57] chore: store column/row references to work with column reordering --- demos/vanilla/src/examples/example47.ts | 7 - docs/grid-functionalities/formula-service.md | 8 + .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 28 +-- .../src/__tests__/formula.cellEditor.spec.ts | 53 +++++ .../src/__tests__/formula.service.spec.ts | 121 ++++++++++ .../formula-plugin/src/formula.cellEditor.ts | 15 +- .../formula-plugin/src/formula.service.ts | 210 ++++++++++++++---- 7 files changed, 382 insertions(+), 60 deletions(-) diff --git a/demos/vanilla/src/examples/example47.ts b/demos/vanilla/src/examples/example47.ts index 11b3b78321..9f8d4755a5 100644 --- a/demos/vanilla/src/examples/example47.ts +++ b/demos/vanilla/src/examples/example47.ts @@ -360,13 +360,6 @@ export default class Example47 { maxDecimal: 2, minDecimal: 2, }, - - // column reorder and visibility will probably fail, let's disable for now - enableColumnReorder: false, - enableColumnPicker: false, - enableGridMenu: false, - enableHeaderMenu: false, - enableGrouping: true, enableFormulas: true, enableExcelExport: true, diff --git a/docs/grid-functionalities/formula-service.md b/docs/grid-functionalities/formula-service.md index ace3998a29..6a48fc3b42 100644 --- a/docs/grid-functionalities/formula-service.md +++ b/docs/grid-functionalities/formula-service.md @@ -92,6 +92,14 @@ Highlight behavior: - preferred: selection-model highlights through `setSelectedRanges(...)` - fallback: CSS highlights through `setCellCssStyles(...)` +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. + ### Runtime API at a Glance Frequently used methods: - `setFormula(rowId, columnId, formula)` diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index ff652a0e1d..b866b588a0 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -1,6 +1,6 @@ # Formula Editor Plugin Progress -Last updated: 2026-08-15 (shared formula-reference parser + saved-formula reopen color-order fix + regression coverage) +Last updated: 2026-08-20 (stable AG-style formula references with A1 editor display + Excel export coverage) Branch context: feat/cell-formula-plugin ## Maintenance Rule @@ -59,8 +59,8 @@ Why this mattered: - 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 example46 by switching formula editor background to use --slick-text-editor-background. -- Added local variable overrides in example46: +- 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: @@ -69,7 +69,7 @@ Why this mattered: ## 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 example46 demo token overrides. +- 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) @@ -78,11 +78,11 @@ Why this mattered: - 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 example46 stylesheet into shared plugin styles: +- 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 example46 (for example row colors and local editor background/selected editable color vars). +- 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: @@ -123,7 +123,7 @@ These two expectations contradict each other for the same formula shape (only th ## 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. -- `example46` includes grouping, but known grouping-related bugs remain. +- `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. @@ -136,12 +136,14 @@ These two expectations contradict each other for the same formula shape (only th - 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: Column Reorder/Hide Offset Risk Note (2026-08-06) -- Added a forward-looking risk note for formula stability with column visibility/order changes. -- Most probable issue: if a column is hidden or moved (for example via Column Picker or Grid Menu), formulas that rely on A1-style column letters can become offset/misaligned from intended source columns. -- Current status: not fully validated/fixed yet. -- Plan: revisit in a dedicated pass with explicit handling/tests for column hide/show and column reorder scenarios. -- Modified grid option to include: `{ enableColumnReorder: false, enableColumnPicker: false, enableGridMenu: false, enableHeaderMenu: false }` in example46 +## 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. ## Latest Update: Formula Color Sync, Incomplete References, and Clipboard (2026-08-10) - Restored color-sync separation of concerns to prevent editor/grid mismatch regressions: diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index c2f97eea28..0e89e69542 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -3,6 +3,59 @@ import { describe, expect, it, vi } from 'vitest'; import { FormulaCellEditor } from '../formula.cellEditor.js'; describe('FormulaCellEditor', () => { + 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 keep editor open and suppress grid click after selecting a reference cell', () => { const hostContainer = document.createElement('div'); const gridContainer = document.createElement('div'); diff --git a/packages/formula-plugin/src/__tests__/formula.service.spec.ts b/packages/formula-plugin/src/__tests__/formula.service.spec.ts index d6d757b0d4..f23cbc0876 100644 --- a/packages/formula-plugin/src/__tests__/formula.service.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts @@ -251,6 +251,127 @@ describe('FormulaService', () => { 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({}), diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index 3c7a365a5f..d95ce7e2bc 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -13,6 +13,12 @@ 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 { @@ -104,7 +110,9 @@ export class FormulaCellEditor implements Editor { loadValue(item: any): void { const field = this.args.column.field as string; const value = item?.[field] ?? ''; - this._originalValue = String(value); + 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; @@ -116,12 +124,15 @@ export class FormulaCellEditor implements Editor { } serializeValue(): string { - return this.getPlainTextValue(); + 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; item[field] = state; + const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined; + editorParams?.onFormulaCommit?.(String(state ?? ''), item); } isValueChanged(): boolean { diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts index 5d63c3a938..9ccec6c64d 100644 --- a/packages/formula-plugin/src/formula.service.ts +++ b/packages/formula-plugin/src/formula.service.ts @@ -14,7 +14,12 @@ import type { import { createDomElement, Formatters } from '@slickgrid-universal/common'; import { FORMULA_ERROR, isFormulaErrorCode, type FormulaErrorCode } from './formula-errors.js'; import { createFormulaFunctionRegistry, type FormulaCallback } from './formula-functions.js'; -import { FormulaReferenceColorCache, getExcelColumnIndexByName, getExcelColumnNameByIndex } from './formula-reference.js'; +import { + FormulaReferenceColorCache, + getExcelColumnIndexByName, + getExcelColumnNameByIndex, + parseExcelReferenceCell, +} from './formula-reference.js'; import { FormulaCellEditor, type FormulaEditorParams } from './formula.cellEditor.js'; export type { FormulaCallback } from './formula-functions.js'; @@ -68,6 +73,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { protected _dataView!: SlickDataView; protected _customFunctions: Map = new Map(); protected _formulaStore: Map = new Map(); + protected _formulaCoordinatesByKey: Map = new Map(); protected _formulaRefColorCache: FormulaReferenceColorCache = new FormulaReferenceColorCache(); protected _originalColumnNamesById: Map = new Map(); protected _formulaRefStyleKeys: string[] = []; @@ -106,6 +112,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { if (this._options.autoSyncFormulasFromDataset !== false) { this.syncFormulasFromDataset(); } + this.canonicalizeStoredFormulas(); this.autoAssignFormulaEditorToColumns(); this.validateSelectionModelPrerequisites(); @@ -116,6 +123,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { this.disableExcelHeaderPrefix(); this.restoreAutoAssignedFormulaEditorColumns(); this._formulaStore.clear(); + this._formulaCoordinatesByKey.clear(); this._formulaRefColorCache.clear(); this.resetEvaluationMemo(); this._customFunctions.clear(); @@ -248,7 +256,8 @@ export class FormulaService implements ExternalResource, FormulaProvider { } extractExcelReferences(formula: string): Array<{ col: string; row: number }> { - this._formulaRefColorCache.update(formula); + 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 })) ); @@ -256,6 +265,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { clearFormulas(): void { this._formulaStore.clear(); + this._formulaCoordinatesByKey.clear(); this.resetEvaluationMemo(); } @@ -315,8 +325,13 @@ export class FormulaService implements ExternalResource, FormulaProvider { const normalizedRowFormula = typeof rowStoredValue === 'string' && rowStoredValue.trim().startsWith('=') ? rowStoredValue.trim() : undefined; - const formula = - normalizedLiveFormula && normalizedLiveFormula !== normalizedStoredFormula + // 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); @@ -367,8 +382,10 @@ export class FormulaService implements ExternalResource, FormulaProvider { } removeFormula(rowId: number | string, columnId: number | string): boolean { - const wasDeleted = this._formulaStore.delete(this.buildStoreKey(rowId, columnId)); + const key = this.buildStoreKey(rowId, columnId); + const wasDeleted = this._formulaStore.delete(key); if (wasDeleted) { + this._formulaCoordinatesByKey.delete(key); this.resetEvaluationMemo(); } return wasDeleted; @@ -378,14 +395,31 @@ export class FormulaService implements ExternalResource, FormulaProvider { const key = this.buildStoreKey(rowId, columnId); if (formula == null || formula === '') { this._formulaStore.delete(key); + this._formulaCoordinatesByKey.delete(key); this.resetEvaluationMemo(); return; } - this._formulaStore.set(key, formula); + this._formulaCoordinatesByKey.set(key, { rowId, columnId }); + this._formulaStore.set(key, this.toStoredFormula(formula)); this.resetEvaluationMemo(); } + /** 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) { @@ -442,25 +476,22 @@ export class FormulaService implements ExternalResource, FormulaProvider { } const normalizedFormula = originalFormula.startsWith('=') ? originalFormula.slice(1) : originalFormula; - const excelRowDelta = Math.max(0, context.excelRowOffset - 1); const allGridColumnIds = (this._grid?.getColumns?.() as Column[] | undefined)?.map((col) => String(col.id)) ?? []; const exportedColumnIds = context.columnIds.map((colId) => String(colId)); - const shiftedFormula = - excelRowDelta > 0 - ? normalizedFormula.replace(/(\$?[A-Z]{1,3}\$?)(\d+)/g, (_match, columnRef: string, rowNumber: string) => { - const remappedColumnRef = this.remapDirectExcelColumnRef(columnRef, allGridColumnIds, exportedColumnIds); - const row = Number(rowNumber); - if (!Number.isFinite(row)) { - return `${remappedColumnRef}${rowNumber}`; - } - return `${remappedColumnRef}${row + excelRowDelta}`; - }) - : normalizedFormula; 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( - shiftedFormula, + shiftedLegacyFormula, normalizedColumnIds, normalizedRowIds, context.excelRowOffset @@ -469,29 +500,39 @@ export class FormulaService implements ExternalResource, FormulaProvider { return this.normalizeFormulaSyntax(withNumericRowRefs); } - /** Remap direct A1 column letters from grid coordinates to exported sheet coordinates. */ - protected remapDirectExcelColumnRef(columnRef: string, gridColumnIds: string[], exportedColumnIds: string[]): string { - const hasLeadingDollar = columnRef.startsWith('$'); - const hasTrailingDollar = columnRef.endsWith('$'); - const rawColumnName = columnRef.replace(/\$/g, '').toUpperCase(); - const sourceColumnIndex = getExcelColumnIndexByName(rawColumnName); - - if (sourceColumnIndex < 0) { - return columnRef; + /** 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 sourceColumnId = gridColumnIds[sourceColumnIndex]; - if (!sourceColumnId) { - return columnRef; - } + const a1ReferenceRegex = + /(? { + const parsed = parseExcelReferenceCell(token); + if (!parsed) { + return token; + } - const targetColumnIndex = exportedColumnIds.indexOf(sourceColumnId); - if (targetColumnIndex < 0) { - return columnRef; - } + 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}`; + }; - const targetColumnName = getExcelColumnNameByIndex(targetColumnIndex + 1); - return `${hasLeadingDollar ? '$' : ''}${targetColumnName}${hasTrailingDollar ? '$' : ''}`; + 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 { @@ -1067,6 +1108,91 @@ export class FormulaService implements ExternalResource, FormulaProvider { 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) { @@ -1311,6 +1437,14 @@ export class FormulaService implements ExternalResource, FormulaProvider { mergedParams.onFormulaInputChange = (formula: string) => { userOnFormulaInputChange?.(formula); }; + mergedParams.toDisplayFormula = (formula: string) => this.toDisplayFormula(formula); + mergedParams.toStoredFormula = (formula: string) => this.toStoredFormula(formula); + 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); From 020b49529c5d51b5990ff5fa13de7126fe383b14 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 20 Aug 2026 18:32:44 -0400 Subject: [PATCH 49/57] docs: update feature progression --- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index b866b588a0..c7bb91dc1a 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -104,6 +104,7 @@ test/cypress/e2e/example47.cy.ts covers: - 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. @@ -145,6 +146,22 @@ These two expectations contradict each other for the same formula shape (only th - 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. + +Still open: +- Full unit-test coverage; current patch coverage is below 100%. +- Grouping and grouped formula export. +- Grid State/Preset persistence for formula references. +- Drag-fill replication, whole-column drag-handle expansion, and calculated-column support. +- A higher-level formula drag-handle integration test. +- 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. @@ -172,6 +189,7 @@ Why this mattered: - 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. ## Known Constraints / Notes - Without a cell-capable selection model, range visuals fall back to CSS highlighting only. @@ -187,6 +205,6 @@ Run: - cypress run --config-file test/cypress.config.ts --spec test/cypress/e2e/example47.cy.ts ## Suggested Next Items +- Improve patch coverage for the formula service and editor branches. - Add optional strict mode in FormulaService to throw (instead of warn) when full selection prerequisites are required by product requirements. - Validate behavior with drag handle interactions from SlickHybridSelectionModel in a higher-level integration test. -- Add docs snippet in user-facing formula plugin docs showing required selection options for range UX. From a998e3e99470ba98767fbc3db289f13ff42200b7 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 21 Aug 2026 02:25:50 -0400 Subject: [PATCH 50/57] chore: add drag-fill functionality to guess the formula series --- AGENTS.md | 1 + demos/vanilla/src/examples/example47.ts | 2 +- docs/grid-functionalities/formula-service.md | 16 + docs/grid-functionalities/row-selection.md | 15 +- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 39 ++- .../src/__tests__/formula.cellEditor.spec.ts | 253 +++++++++++++++ .../src/__tests__/formula.drag-fill.spec.ts | 258 +++++++++++++++ .../src/__tests__/formula.service.spec.ts | 307 ++++++++++++++++++ .../formula-plugin/src/formula.cellEditor.ts | 4 - .../formula-plugin/src/formula.drag-fill.ts | 266 +++++++++++++++ .../formula-plugin/src/formula.service.ts | 123 ++++++- test/cypress.config.ts | 29 ++ test/cypress/e2e/example47.cy.ts | 113 +++++-- 13 files changed, 1388 insertions(+), 38 deletions(-) create mode 100644 packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts create mode 100644 packages/formula-plugin/src/formula.drag-fill.ts diff --git a/AGENTS.md b/AGENTS.md index 0c9f01d06f..2ac199bfa0 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/src/examples/example47.ts b/demos/vanilla/src/examples/example47.ts index 9f8d4755a5..5e1f2218bc 100644 --- a/demos/vanilla/src/examples/example47.ts +++ b/demos/vanilla/src/examples/example47.ts @@ -352,7 +352,7 @@ export default class Example47 { gridHeight: 470, gridWidth: 1080, enableCellNavigation: true, - autoEdit: true, + autoEdit: false, autoCommitEdit: true, editable: true, rowHeight: 38, diff --git a/docs/grid-functionalities/formula-service.md b/docs/grid-functionalities/formula-service.md index 6a48fc3b42..a3f8d04207 100644 --- a/docs/grid-functionalities/formula-service.md +++ b/docs/grid-functionalities/formula-service.md @@ -5,6 +5,7 @@ - [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) @@ -100,6 +101,21 @@ Formula reference storage: 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)` diff --git a/docs/grid-functionalities/row-selection.md b/docs/grid-functionalities/row-selection.md index ddd4e635a2..16b423e7ef 100644 --- a/docs/grid-functionalities/row-selection.md +++ b/docs/grid-functionalities/row-selection.md @@ -302,6 +302,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, +}; +``` + #### ViewModel ```ts @@ -382,4 +395,4 @@ this.columns = allColumns.slice(); // or use spread operator [...cols] ``` > **Note** -> The code above is no longer necessary with v10.8.0 and above, the lib will now take care of that for you. \ No newline at end of file +> The code above is no longer necessary with v10.8.0 and above, the lib will now take care of that for you. diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index c7bb91dc1a..5dcc4c25e2 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -1,6 +1,6 @@ # Formula Editor Plugin Progress -Last updated: 2026-08-20 (stable AG-style formula references with A1 editor display + Excel export coverage) +Last updated: 2026-08-21 (formula drag-fill and complete unit-test coverage) Branch context: feat/cell-formula-plugin ## Maintenance Rule @@ -153,13 +153,15 @@ Completed checklist items now include: - 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: -- Full unit-test coverage; current patch coverage is below 100%. - Grouping and grouped formula export. - Grid State/Preset persistence for formula references. -- Drag-fill replication, whole-column drag-handle expansion, and calculated-column support. -- A higher-level formula drag-handle integration test. +- 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) @@ -191,20 +193,45 @@ Why this mattered: - 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. + +### 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 - 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. ## 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,020 tests; Formula plugin: 100% statements/functions/lines, 92.98% branches) - cypress run --config-file test/cypress.config.ts --spec test/cypress/e2e/example47.cy.ts ## Suggested Next Items -- Improve patch coverage for the formula service and editor branches. - Add optional strict mode in FormulaService to throw (instead of warn) when full selection prerequisites are required by product requirements. -- Validate behavior with drag handle interactions from SlickHybridSelectionModel in a higher-level integration test. +- Add higher-level coverage for whole-column drag-handle expansion and calculated-column behavior. diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index 0e89e69542..b46b4b6b1c 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -649,6 +649,11 @@ describe('FormulaCellEditor', () => { 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(); @@ -882,4 +887,252 @@ describe('FormulaCellEditor', () => { 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 0000000000..f566168855 --- /dev/null +++ b/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts @@ -0,0 +1,258 @@ +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 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 index f23cbc0876..8c9856ea2c 100644 --- a/packages/formula-plugin/src/__tests__/formula.service.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts @@ -1,7 +1,9 @@ +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 { FormulaCellEditor } from '../formula.cellEditor.js'; +import { translateFormulaReferences } from '../formula.drag-fill.js'; import { FormulaService } from '../formula.service.js'; describe('FormulaService', () => { @@ -15,6 +17,111 @@ describe('FormulaService', () => { 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 set/get/has formula by row and column ids', () => { const service = new FormulaService(); @@ -34,6 +141,76 @@ describe('FormulaService', () => { 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"))'); @@ -190,6 +367,36 @@ describe('FormulaService', () => { 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[] = [ @@ -1111,6 +1318,7 @@ describe('FormulaService', () => { 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); @@ -1122,6 +1330,9 @@ describe('FormulaService', () => { 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')); @@ -1133,6 +1344,75 @@ describe('FormulaService', () => { 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); + + 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(); @@ -1157,4 +1437,31 @@ describe('FormulaService', () => { 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.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index d95ce7e2bc..7d3ecb93ab 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -625,10 +625,6 @@ export class FormulaCellEditor implements Editor { } const textBeforeCaretTrimEnd = textBeforeCaret.replace(/\s+$/, ''); - if (!textBeforeCaretTrimEnd.length) { - return false; - } - const lastChar = textBeforeCaretTrimEnd[textBeforeCaretTrimEnd.length - 1]; return /[=,(+\-*/^&:]/.test(lastChar); } 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 0000000000..8928ca0833 --- /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 } 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 + ); + targetItem[targetField] = context.toStoredFormula(translatedFormula); + context.setFormula(targetRowId, targetColumn.id, translatedFormula); + } else { + const { seriesIndex, sourceValues } = getSourceValueSeries( + baseRange, + direction, + targetRow, + targetVisibleCell, + visibleColumns, + context.grid, + valueSeriesCache + ); + 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 index 9ccec6c64d..134a9a0182 100644 --- a/packages/formula-plugin/src/formula.service.ts +++ b/packages/formula-plugin/src/formula.service.ts @@ -8,10 +8,11 @@ import type { FormulaExcelDefinedNameExport, FormulaExcelExportContext, FormulaProvider, + OnDragReplaceCellsEventArgs, SlickDataView, SlickGrid, } from '@slickgrid-universal/common'; -import { createDomElement, Formatters } 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 { @@ -21,6 +22,7 @@ import { 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'; @@ -62,6 +64,11 @@ interface FormulaEvaluationContext { 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. @@ -74,6 +81,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { 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 _formulaRefStyleKeys: string[] = []; @@ -83,6 +91,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { 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'; @@ -105,6 +114,10 @@ export class FormulaService implements ExternalResource, FormulaProvider { return; } + if (this._grid.onDragReplaceCells) { + this._eventHandler.subscribe(this._grid.onDragReplaceCells, this.handleDragReplaceCells.bind(this)); + } + if (this._options.customFunctions) { this.registerCustomFunctions(this._options.customFunctions); } @@ -119,11 +132,13 @@ export class FormulaService implements ExternalResource, FormulaProvider { } 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(); @@ -266,6 +281,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { clearFormulas(): void { this._formulaStore.clear(); this._formulaCoordinatesByKey.clear(); + this._formulaReferenceAbsoluteFlagsByKey.clear(); this.resetEvaluationMemo(); } @@ -396,15 +412,99 @@ export class FormulaService implements ExternalResource, FormulaProvider { 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()) { @@ -497,7 +597,9 @@ export class FormulaService implements ExternalResource, FormulaProvider { context.excelRowOffset ); - return this.normalizeFormulaSyntax(withNumericRowRefs); + return this.normalizeFormulaSyntax( + this.applyFormulaReferenceAbsoluteFlags(this.buildStoreKey(context.rowId, context.columnId), withNumericRowRefs) + ); } /** Shift only direct A1 references left after stable references have been canonicalized. */ @@ -510,6 +612,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { /(? { const parsed = parseExcelReferenceCell(token); + /* v8 ignore if - the A1 token regex guarantees that parsing succeeds */ if (!parsed) { return token; } @@ -1437,8 +1540,20 @@ export class FormulaService implements ExternalResource, FormulaProvider { mergedParams.onFormulaInputChange = (formula: string) => { userOnFormulaInputChange?.(formula); }; - mergedParams.toDisplayFormula = (formula: string) => this.toDisplayFormula(formula); - mergedParams.toStoredFormula = (formula: string) => this.toStoredFormula(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) { diff --git a/test/cypress.config.ts b/test/cypress.config.ts index d2688c4826..dfa839891b 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 index e0dc536cde..887fc57411 100644 --- a/test/cypress/e2e/example47.cy.ts +++ b/test/cypress/e2e/example47.cy.ts @@ -53,14 +53,14 @@ describe('Example 47 - Formula Service (MVP)', () => { }); it('should edit a formula cell in Formula Editor and persist the updated formula result', () => { - cy.get(cell(0, 4)).click(); + 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)).click(); + cy.get(cell(0, 4)).dblclick(); cy.get('.formula-editor-input') .should('be.visible') .invoke('text') @@ -71,7 +71,7 @@ describe('Example 47 - Formula Service (MVP)', () => { // 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)).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'); cy.get(cell(0, 7)).contains('$8.88'); @@ -79,7 +79,7 @@ describe('Example 47 - Formula Service (MVP)', () => { 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)).click(); + cy.get(cell(0, 4)).dblclick(); cy.get('.formula-editor-input').should('be.visible').click().type('{selectall}=s', { force: true }); // Pick SUM from autocomplete. @@ -104,13 +104,13 @@ describe('Example 47 - Formula Service (MVP)', () => { // Restore canonical formula text for subsequent serial test steps. cy.get('[data-test="reload-formulas-btn"]').click(); - cy.get(cell(0, 4)).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)).click(); + 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. @@ -127,7 +127,7 @@ describe('Example 47 - Formula Service (MVP)', () => { }); it('should keep formula-token colors aligned with matching grid cell colors', () => { - cy.get(cell(0, 4)).click(); + 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. @@ -143,7 +143,7 @@ describe('Example 47 - Formula Service (MVP)', () => { // Commit and reopen: FormulaService pre-renders grid highlights during onBeforeEditCell, // 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)).click(); + 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'); @@ -156,7 +156,7 @@ describe('Example 47 - Formula Service (MVP)', () => { }); it('should preserve reference colors when a range precedes a single-cell reference', () => { - cy.get(cell(0, 4)).click(); + 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. @@ -169,7 +169,7 @@ describe('Example 47 - Formula Service (MVP)', () => { // Commit and reopen so FormulaService.renderFormulaReferenceHighlights() is exercised. cy.get('.formula-editor-input').type('{enter}', { force: true }); - cy.get(cell(0, 4)).click(); + 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'); @@ -182,7 +182,7 @@ describe('Example 47 - Formula Service (MVP)', () => { }); it('should keep stable coloring when formula contains an incomplete range reference', () => { - cy.get(cell(0, 4)).click(); + 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. @@ -209,7 +209,7 @@ describe('Example 47 - Formula Service (MVP)', () => { cy.wrap(writeTextStub).as('writeTextStub'); }); - cy.get(cell(0, 4)).click(); + cy.get(cell(0, 4)).dblclick(); cy.get('.formula-editor-input') .should('be.visible') .invoke('text', '=SUM(C1\u00a0+\u00a0D1)') @@ -224,27 +224,27 @@ describe('Example 47 - Formula Service (MVP)', () => { // 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)).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)).click(); + 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)).click(); + 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)).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'); @@ -252,7 +252,7 @@ describe('Example 47 - Formula Service (MVP)', () => { 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)).click(); + cy.get(cell(0, 8)).dblclick(); cy.get('.formula-editor-input') .should('be.visible') .invoke('text') @@ -264,18 +264,18 @@ describe('Example 47 - Formula Service (MVP)', () => { cy.get(cell(0, 8)).contains('$6.22'); // PRODUCT on row 2 - cy.get(cell(1, 8)).click(); + 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)).click(); + 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)).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'); @@ -291,11 +291,11 @@ describe('Example 47 - Formula Service (MVP)', () => { cy.get(cell(2, 7)).contains('$9.67'); // edit price + qty in row 3 and validate formula recalculation - cy.get(cell(2, 2)).click(); + cy.get(cell(2, 2)).dblclick(); cy.get(`${cell(2, 2)} input`) .clear() .type('4.23{enter}'); - cy.get(cell(2, 3)).click(); + cy.get(cell(2, 3)).dblclick(); cy.get(`${cell(2, 3)} input`) .clear() .type('3{enter}'); @@ -318,4 +318,73 @@ describe('Example 47 - Formula Service (MVP)', () => { 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'); + }); + }); }); From 621738e3bb35f196b71e82eea0d1eb8a80d70817 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 21 Aug 2026 02:37:42 -0400 Subject: [PATCH 51/57] chore: increase unit tests coverage & update progression file --- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 5 +- .../src/__tests__/formula.cellEditor.spec.ts | 1 + .../src/__tests__/formula.service.spec.ts | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index 5dcc4c25e2..cccf287b9a 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -205,6 +205,8 @@ Why this mattered: - 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) @@ -219,6 +221,7 @@ The following implemented paths remain covered by unit tests but do not yet have - 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 @@ -229,7 +232,7 @@ Run: - 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,020 tests; Formula plugin: 100% statements/functions/lines, 92.98% branches) +- 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 diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index b46b4b6b1c..101c373bff 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -371,6 +371,7 @@ describe('FormulaCellEditor', () => { const editor = new FormulaCellEditor(args); editor.loadValue((args as any).item); (editor as any).restoreCaretOffset(5); + (editor as any)._referenceEditRange = undefined; c1CellElm.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })); c1CellElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 })); diff --git a/packages/formula-plugin/src/__tests__/formula.service.spec.ts b/packages/formula-plugin/src/__tests__/formula.service.spec.ts index 8c9856ea2c..05af5c7d00 100644 --- a/packages/formula-plugin/src/__tests__/formula.service.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts @@ -422,6 +422,23 @@ describe('FormulaService', () => { 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[] = [ @@ -1187,6 +1204,38 @@ describe('FormulaService', () => { 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({ From 03047a16e236097f821c380f742a0d6aa05a4b90 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 21 Aug 2026 02:45:56 -0400 Subject: [PATCH 52/57] chore: increase unit tests coverage --- packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index 101c373bff..bfe1260ee5 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -372,6 +372,7 @@ describe('FormulaCellEditor', () => { editor.loadValue((args as any).item); (editor as any).restoreCaretOffset(5); (editor as any)._referenceEditRange = undefined; + 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 })); From 9eac075f13e671fc13cf357921228267d40c1cec Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 21 Aug 2026 02:51:41 -0400 Subject: [PATCH 53/57] chore: increase unit tests coverage --- packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index bfe1260ee5..4130fa8d18 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -372,6 +372,7 @@ describe('FormulaCellEditor', () => { 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 })); From db5525a571f8917a9c0bbdb03d02082358615ae1 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 21 Aug 2026 23:29:32 -0400 Subject: [PATCH 54/57] chore: security audit to safely handle object creation --- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 1 + .../src/__tests__/formula-reference.spec.ts | 4 +++ .../src/__tests__/formula.cellEditor.spec.ts | 26 +++++++++++++++++ .../src/__tests__/formula.drag-fill.spec.ts | 29 +++++++++++++++++++ .../src/__tests__/formula.service.spec.ts | 19 ++++++++++++ .../formula-plugin/src/formula-reference.ts | 22 ++++++++++++++ .../formula-plugin/src/formula.cellEditor.ts | 7 +++-- .../formula-plugin/src/formula.drag-fill.ts | 6 ++-- .../formula-plugin/src/formula.service.ts | 10 +++++-- 9 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index cccf287b9a..c285995160 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -217,6 +217,7 @@ The Example 47 Cypress suite is passing with the prescribed `pnpm cypress:ci --s 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. diff --git a/packages/formula-plugin/src/__tests__/formula-reference.spec.ts b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts index 7f5276911b..408becdfc3 100644 --- a/packages/formula-plugin/src/__tests__/formula-reference.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts @@ -54,6 +54,10 @@ describe('formula reference utilities', () => { 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(); diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index 4130fa8d18..b7f0ee8f85 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -56,6 +56,32 @@ describe('FormulaCellEditor', () => { 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'); diff --git a/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts b/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts index f566168855..45d695cb4b 100644 --- a/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.drag-fill.spec.ts @@ -14,6 +14,35 @@ describe('formula drag-fill', () => { 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 }, diff --git a/packages/formula-plugin/src/__tests__/formula.service.spec.ts b/packages/formula-plugin/src/__tests__/formula.service.spec.ts index 05af5c7d00..63195d0529 100644 --- a/packages/formula-plugin/src/__tests__/formula.service.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts @@ -122,6 +122,24 @@ describe('FormulaService', () => { 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(); @@ -1450,6 +1468,7 @@ describe('FormulaService', () => { 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({}); diff --git a/packages/formula-plugin/src/formula-reference.ts b/packages/formula-plugin/src/formula-reference.ts index 36d27f87df..59a8976522 100644 --- a/packages/formula-plugin/src/formula-reference.ts +++ b/packages/formula-plugin/src/formula-reference.ts @@ -1,4 +1,6 @@ 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; export interface FormulaGridCell { row: number; @@ -124,6 +126,11 @@ export function expandFormulaReferenceToGridCells(reference: string): FormulaGri 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++) { @@ -158,3 +165,18 @@ export function buildFormulaReferenceColorInfos(formula: string): FormulaReferen return references; } + +/** 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 index 7d3ecb93ab..1048b99e93 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -7,6 +7,7 @@ import { getExcelColumnNameByIndex, normalizeFormulaReferenceToken, parseExcelReferenceCell, + setFormulaObjectProperty, } from './formula-reference.js'; export interface FormulaEditorParams { @@ -130,7 +131,7 @@ export class FormulaCellEditor implements Editor { applyValue(item: any, state: any): void { const field = this.args.column.field as string; - item[field] = state; + setFormulaObjectProperty(item, field, state); const editorParams = this.args.column.editor?.params as FormulaEditorParams | undefined; editorParams?.onFormulaCommit?.(String(state ?? ''), item); } @@ -723,7 +724,7 @@ export class FormulaCellEditor implements Editor { return; } - const hash: Record> = {}; + const hash: Record> = Object.create(null); // Iterate through each cached reference and paint its cells for (const info of this._formulaRefColorCache.values()) { @@ -737,7 +738,7 @@ export class FormulaCellEditor implements Editor { const columnId = column?.id; if (columnId && !hash[row]) { - hash[row] = {}; + hash[row] = Object.create(null); } if (columnId) { hash[row][columnId] = info.colorClass; diff --git a/packages/formula-plugin/src/formula.drag-fill.ts b/packages/formula-plugin/src/formula.drag-fill.ts index 8928ca0833..9bd712e37d 100644 --- a/packages/formula-plugin/src/formula.drag-fill.ts +++ b/packages/formula-plugin/src/formula.drag-fill.ts @@ -1,6 +1,6 @@ import type { Column, OnDragReplaceCellsEventArgs, SlickDataView, SlickGrid, SlickRange } from '@slickgrid-universal/common'; import { SlickSelectionUtils } from '@slickgrid-universal/common'; -import { getExcelColumnIndexByName, getExcelColumnNameByIndex } from './formula-reference.js'; +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 { @@ -90,7 +90,7 @@ export function handleFormulaDragFill(args: OnDragReplaceCellsEventArgs, context targetRow - sourceRow, targetColumnIndex - sourceColumnIndex ); - targetItem[targetField] = context.toStoredFormula(translatedFormula); + setFormulaObjectProperty(targetItem, targetField, context.toStoredFormula(translatedFormula)); context.setFormula(targetRowId, targetColumn.id, translatedFormula); } else { const { seriesIndex, sourceValues } = getSourceValueSeries( @@ -102,7 +102,7 @@ export function handleFormulaDragFill(args: OnDragReplaceCellsEventArgs, context context.grid, valueSeriesCache ); - targetItem[targetField] = getFillSeriesValue(sourceValues, seriesIndex); + setFormulaObjectProperty(targetItem, targetField, getFillSeriesValue(sourceValues, seriesIndex)); context.setFormula(targetRowId, targetColumn.id, null); } updatedItems.set(String(targetRowId), { id: targetRowId, item: targetItem }); diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts index 134a9a0182..b3353bc0b5 100644 --- a/packages/formula-plugin/src/formula.service.ts +++ b/packages/formula-plugin/src/formula.service.ts @@ -16,6 +16,7 @@ import { createDomElement, Formatters, SlickEventHandler } from '@slickgrid-univ import { FORMULA_ERROR, isFormulaErrorCode, type FormulaErrorCode } from './formula-errors.js'; import { createFormulaFunctionRegistry, type FormulaCallback } from './formula-functions.js'; import { + FORMULA_MAX_REFERENCE_CELLS, FormulaReferenceColorCache, getExcelColumnIndexByName, getExcelColumnNameByIndex, @@ -247,7 +248,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { Array.from(this._formulaRefColorCache.values()).forEach((reference, idx) => { const styleKey = `formula-ref-highlight-${idx}`; - const hash: Record> = {}; + const hash: Record> = Object.create(null); for (const cell of reference.cells) { const column = columns[cell.cell]; @@ -257,7 +258,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { } if (!hash[cell.row]) { - hash[cell.row] = {}; + hash[cell.row] = Object.create(null); } hash[cell.row][column.id as number | string] = reference.colorClass; } @@ -1380,6 +1381,11 @@ export class FormulaService implements ExternalResource, FormulaProvider { 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++) { From 7a8e06a0d79f6ec3922c1fa157da01794066c44d Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 22 Aug 2026 17:22:53 -0400 Subject: [PATCH 55/57] chore: improve formula editor key events & styling --- packages/common/src/styles/slick-editors.scss | 15 +++- packages/common/src/styles/slick-plugins.scss | 19 ----- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 12 +++ .../src/__tests__/formula.cellEditor.spec.ts | 59 ++++++++++++- .../formula-plugin/src/formula.cellEditor.ts | 82 +++++++++++++------ 5 files changed, 140 insertions(+), 47 deletions(-) diff --git a/packages/common/src/styles/slick-editors.scss b/packages/common/src/styles/slick-editors.scss index 31af71db76..ed9fd57325 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 8259e68606..c50e687497 100644 --- a/packages/common/src/styles/slick-plugins.scss +++ b/packages/common/src/styles/slick-plugins.scss @@ -1333,25 +1333,6 @@ li.hidden { vertical-align: middle; } -.formula-editor-input { - width: 100%; - min-height: 26px; - padding: var(--slick-text-editor-padding, 2px 4px); - border: var(--slick-text-editor-border, 1px solid #9ca3af); - border-radius: var(--slick-text-editor-border-radius, 3px); - background: var(--slick-text-editor-background, #fff); - color: var(--slick-text-editor-color, inherit); - line-height: 1.3; - white-space: pre-wrap; - word-break: break-word; - outline: none; - - &:focus { - border-color: var(--slick-form-control-focus-border-color, #2563eb); - box-shadow: var(--slick-form-control-focus-box-shadow, 0 0 0 1px #2563eb); - } -} - .formula-token { display: inline; margin: 0; diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index c285995160..583faad6ce 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -3,6 +3,18 @@ Last updated: 2026-08-21 (formula drag-fill and complete unit-test coverage) Branch context: feat/cell-formula-plugin +## 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. diff --git a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts index b7f0ee8f85..dd216a73ed 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -3,6 +3,59 @@ import { describe, expect, it, vi } from 'vitest'; 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'); @@ -206,7 +259,7 @@ describe('FormulaCellEditor', () => { (editor as any).restoreCaretOffset(7); (editor as any)._editorElm.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); - const initialSelectionRange = selectionRangesCalls.at(-1)?.[0]; + const initialSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0]; expect(initialSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 1, toCell: 3 }); expect(setCellCssStylesCalls).toHaveLength(0); @@ -223,7 +276,7 @@ describe('FormulaCellEditor', () => { expect(mouseUpEvent.defaultPrevented).toBe(true); expect(editor.serializeValue()).toBe('=SUM(E1:E3)'); - const updatedSelectionRange = selectionRangesCalls.at(-1)?.[0]; + const updatedSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0]; expect(updatedSelectionRange).toMatchObject({ fromRow: 0, fromCell: 4, toRow: 2, toCell: 4 }); editor.destroy(); @@ -295,7 +348,7 @@ describe('FormulaCellEditor', () => { expect(editor.serializeValue()).toBe('=SUM(D1:D6)'); expect(editor.serializeValue().startsWith('=')).toBe(true); - const updatedSelectionRange = selectionRangesCalls.at(-1)?.[0]; + const updatedSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0]; expect(updatedSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 5, toCell: 3 }); editor.destroy(); diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index 1048b99e93..172a12a479 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -217,16 +217,13 @@ export class FormulaCellEditor implements Editor { // 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') { - event.stopPropagation(); - event.stopImmediatePropagation(); + 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') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); const plainText = (this._editorElm.textContent || '').replace(/\u00a0/g, ' '); navigator.clipboard.writeText(plainText).catch(() => { // Fallback for older browsers @@ -235,9 +232,7 @@ export class FormulaCellEditor implements Editor { } if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'x') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); const plainText = (this._editorElm.textContent || '').replace(/\u00a0/g, ' '); navigator.clipboard.writeText(plainText).catch(() => { // Fallback for older browsers @@ -254,18 +249,14 @@ export class FormulaCellEditor implements Editor { if (this._autocompleteItems.length > 0) { if (event.key === 'ArrowDown') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); this._autocompleteSelectedIdx = (this._autocompleteSelectedIdx + 1) % this._autocompleteItems.length; this.renderAutocompleteItems(); return; } if (event.key === 'ArrowUp') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); this._autocompleteSelectedIdx = (this._autocompleteSelectedIdx - 1 + this._autocompleteItems.length) % this._autocompleteItems.length; this.renderAutocompleteItems(); @@ -273,9 +264,7 @@ export class FormulaCellEditor implements Editor { } if (event.key === 'Enter' || event.key === 'Tab') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); this.selectAutocompleteItem(this._autocompleteItems[this._autocompleteSelectedIdx]); return; } @@ -285,18 +274,44 @@ export class FormulaCellEditor implements Editor { } } + 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.key === 'Home' || event.key === 'End') + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') ) { event.stopImmediatePropagation(); return; } if (event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); this._isExitingEditor = true; this.clearReferenceSelectionHighlight(); const didCommit = this.args.grid.getEditorLock?.()?.commitCurrentEdit?.(); @@ -307,9 +322,7 @@ export class FormulaCellEditor implements Editor { const grid = this.args.grid; const isShiftTab = event.shiftKey; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + this.stopKeyboardEvent(event); if (this._isOpenedByTabKey && !this._isValueTouched) { this._isOpenedByTabKey = false; @@ -345,6 +358,14 @@ export class FormulaCellEditor implements Editor { } } + 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; @@ -845,6 +866,19 @@ export class FormulaCellEditor implements Editor { 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; From 57293e427263a989d45b27519a16dcce12579438 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Mon, 24 Aug 2026 10:24:44 -0400 Subject: [PATCH 56/57] chore: run Prettier formatter --- .../formula-plugin/src/formula.cellEditor.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index 172a12a479..29a1ba71a3 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -274,12 +274,7 @@ export class FormulaCellEditor implements Editor { } } - if ( - (event.ctrlKey || event.metaKey) && - !event.altKey && - !event.shiftKey && - (event.key === 'ArrowLeft' || event.key === 'ArrowRight') - ) { + 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) { @@ -287,9 +282,10 @@ export class FormulaCellEditor implements Editor { 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; + const targetOffset = + event.key === 'ArrowRight' + ? (tokenRanges.find((range) => range.end > caretOffset)?.end ?? text.length) + : (previousToken?.start ?? 0); this.moveCaretToOffset(targetOffset); return; @@ -302,10 +298,7 @@ export class FormulaCellEditor implements Editor { return; } - if ( - !this.args.grid.getOptions().editorNavigateOnArrows && - (event.key === 'ArrowLeft' || event.key === 'ArrowRight') - ) { + if (!this.args.grid.getOptions().editorNavigateOnArrows && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { event.stopImmediatePropagation(); return; } From 1c2abc49c93c5d5ccd38be4b8c67b81a1b274875 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 26 Aug 2026 01:50:48 -0400 Subject: [PATCH 57/57] refactor: improve code after merging multi-selection code --- demos/vanilla/src/examples/example47.ts | 4 - docs/grid-functionalities/formula-service.md | 5 +- .../formula-plugin/FORMULA_EDITOR_PROGRESS.md | 9 +- .../src/__tests__/formula-reference.spec.ts | 14 +++ .../src/__tests__/formula.cellEditor.spec.ts | 116 ++++++++++++++++-- .../src/__tests__/formula.service.spec.ts | 45 +++++-- .../formula-plugin/src/formula-reference.ts | 27 ++++ .../formula-plugin/src/formula.cellEditor.ts | 112 ++++------------- .../formula-plugin/src/formula.service.ts | 35 ++---- test/cypress/e2e/example47.cy.ts | 4 +- 10 files changed, 227 insertions(+), 144 deletions(-) diff --git a/demos/vanilla/src/examples/example47.ts b/demos/vanilla/src/examples/example47.ts index 5e1f2218bc..ff202ae047 100644 --- a/demos/vanilla/src/examples/example47.ts +++ b/demos/vanilla/src/examples/example47.ts @@ -430,13 +430,9 @@ export default class Example47 { const args = event?.detail?.args; const columnDef = args?.column as Column | undefined; - const item = args?.item as GroceryItem | undefined; if (columnDef?.allowFormula) { this.formulaService.enableExcelHeaderPrefix(); - const value = item?.[String(columnDef.id) as keyof GroceryItem]; - const formula = typeof value === 'string' ? value : this.formulaService.getFormula(item?.id as number, String(columnDef.id)); - this.formulaService.renderFormulaReferenceHighlights(formula); this.lastFormulaEvent = `formula edit mode enabled (${String(columnDef.id)})`; } else { this.formulaService.clearFormulaReferenceHighlights(); diff --git a/docs/grid-functionalities/formula-service.md b/docs/grid-functionalities/formula-service.md index a3f8d04207..f210ff41aa 100644 --- a/docs/grid-functionalities/formula-service.md +++ b/docs/grid-functionalities/formula-service.md @@ -90,8 +90,9 @@ For full reference pick UX: - `selectionOptions.selectionType: 'mixed'` or `'cell'` Highlight behavior: -- preferred: selection-model highlights through `setSelectedRanges(...)` -- fallback: CSS highlights through `setCellCssStyles(...)` +- 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` diff --git a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md index 583faad6ce..561baf7af3 100644 --- a/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md +++ b/packages/formula-plugin/FORMULA_EDITOR_PROGRESS.md @@ -3,6 +3,13 @@ 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. @@ -102,7 +109,7 @@ Why this mattered: - Caret-driven range highlight and drag-rewrite flow. - Endpoint drag expansion anchor behavior. - Fallback to cell-css highlighting when no selection model is available. -- No persistent cell colors are applied on initial load. +- 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. diff --git a/packages/formula-plugin/src/__tests__/formula-reference.spec.ts b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts index 408becdfc3..b9025f1f6a 100644 --- a/packages/formula-plugin/src/__tests__/formula-reference.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula-reference.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildFormulaReferenceColorInfos, + buildFormulaReferenceCssHash, expandFormulaReferenceToGridCells, FormulaReferenceColorCache, getExcelColumnIndexByName, @@ -75,4 +76,17 @@ describe('formula reference utilities', () => { 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 index dd216a73ed..ec902a170c 100644 --- a/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.cellEditor.spec.ts @@ -1,5 +1,6 @@ -import type { EditorArguments } from '@slickgrid-universal/common'; +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', () => { @@ -261,7 +262,11 @@ describe('FormulaCellEditor', () => { const initialSelectionRange = selectionRangesCalls[selectionRangesCalls.length - 1]?.[0]; expect(initialSelectionRange).toMatchObject({ fromRow: 0, fromCell: 3, toRow: 1, toCell: 3 }); - expect(setCellCssStylesCalls).toHaveLength(0); + 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); @@ -284,6 +289,57 @@ describe('FormulaCellEditor', () => { 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'); @@ -644,7 +700,7 @@ describe('FormulaCellEditor', () => { gridContainer.remove(); }); - it('should not apply persistent cell colors on initial load', () => { + it('should apply persistent cell colors on initial load', () => { const hostContainer = document.createElement('div'); const gridContainer = document.createElement('div'); document.body.appendChild(hostContainer); @@ -676,9 +732,48 @@ describe('FormulaCellEditor', () => { const editor = new FormulaCellEditor(args); editor.loadValue((args as any).item); - expect(setCellCssStylesSpy).not.toHaveBeenCalled(); + 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(); }); @@ -829,7 +924,7 @@ describe('FormulaCellEditor', () => { gridContainer.remove(); }); - it('should not remove selection highlight style when no selection highlight is active', () => { + 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); @@ -864,14 +959,14 @@ describe('FormulaCellEditor', () => { (editor as any)._isSelectionModelHighlightActive = false; (editor as any).clearReferenceSelectionHighlight(); - expect(removeCellCssStylesSpy).not.toHaveBeenCalledWith('formula-editor-grid-sel-highlight'); + expect(removeCellCssStylesSpy).not.toHaveBeenCalled(); editor.destroy(); hostContainer.remove(); gridContainer.remove(); }); - it('should cover persistent color cleanup, selection color fallback, and caret guards', () => { + it('should cover persistent color cleanup and caret guards', () => { const hostContainer = document.createElement('div'); const gridContainer = document.createElement('div'); document.body.append(hostContainer, gridContainer); @@ -908,13 +1003,8 @@ describe('FormulaCellEditor', () => { (editor as any)._editorElm.textContent = '=A1'; (editor as any).handleInput(); - expect((editor as any).getColorForSelectedCells({ row: 0, cell: 0 }, { row: 0, cell: 0 })).toBe('formula-cell-color-1'); (editor as any)._editorElm.textContent = '=Z1'; (editor as any).handleInput(); - (editor as any)._plainTextValue = '=A1'; - expect((editor as any).getColorForSelectedCells({ row: 8, cell: 8 }, { row: 8, cell: 8 })).toBe('formula-cell-color-1'); - (editor as any)._plainTextValue = 'plain text'; - expect((editor as any).getColorForSelectedCells({ row: 8, cell: 8 }, { row: 8, cell: 8 })).toBe('formula-cell-color-1'); expect((editor as any).parseExcelReferenceCellRange('')).toBeUndefined(); expect((editor as any).parseExcelReferenceCellRange('A0')).toBeUndefined(); @@ -952,7 +1042,7 @@ describe('FormulaCellEditor', () => { (editor as any)._isExitingEditor = true; (editor as any).clearReferenceSelectionHighlight(); - expect(removeCellCssStylesSpy).toHaveBeenCalledWith('formula-editor-grid-persistent-colors'); + expect(removeCellCssStylesSpy).toHaveBeenCalledWith(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY); (editor as any)._isExitingEditor = false; (editor as any).restoreCaretOffset(999); diff --git a/packages/formula-plugin/src/__tests__/formula.service.spec.ts b/packages/formula-plugin/src/__tests__/formula.service.spec.ts index 63195d0529..2babf6c924 100644 --- a/packages/formula-plugin/src/__tests__/formula.service.spec.ts +++ b/packages/formula-plugin/src/__tests__/formula.service.spec.ts @@ -2,6 +2,7 @@ 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'; @@ -757,12 +758,10 @@ describe('FormulaService', () => { service.init(gridStub); service.renderFormulaReferenceHighlights('=C1*SUM(D1:D3)'); - expect(setCellCssStyles).toHaveBeenCalledTimes(2); - expect(setCellCssStyles.mock.calls[0][0]).toBe('formula-ref-highlight-0'); - expect(setCellCssStyles.mock.calls[0][1]).toEqual({ 0: { c: 'formula-cell-color-1' } }); - expect(setCellCssStyles.mock.calls[1][0]).toBe('formula-ref-highlight-1'); - expect(setCellCssStyles.mock.calls[1][1]).toEqual({ - 0: { d: 'formula-cell-color-2' }, + 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' }, }); @@ -771,13 +770,41 @@ describe('FormulaService', () => { setCellCssStyles.mockClear(); service.renderFormulaReferenceHighlights('=SUM(D1:D3)*C1'); - expect(setCellCssStyles).toHaveBeenCalledTimes(2); + expect(setCellCssStyles).toHaveBeenCalledTimes(1); expect(setCellCssStyles.mock.calls[0][1]).toEqual({ - 0: { d: 'formula-cell-color-1' }, + 0: { c: 'formula-cell-color-2', d: 'formula-cell-color-1' }, 1: { d: 'formula-cell-color-1' }, 2: { d: 'formula-cell-color-1' }, }); - expect(setCellCssStyles.mock.calls[1][1]).toEqual({ 0: { c: 'formula-cell-color-2' } }); + }); + + 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', () => { diff --git a/packages/formula-plugin/src/formula-reference.ts b/packages/formula-plugin/src/formula-reference.ts index 59a8976522..ff36b4ed20 100644 --- a/packages/formula-plugin/src/formula-reference.ts +++ b/packages/formula-plugin/src/formula-reference.ts @@ -1,6 +1,8 @@ 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; @@ -14,6 +16,8 @@ export interface FormulaReferenceColorInfo { cells: FormulaGridCell[]; } +export type FormulaReferenceCssHash = Record>; + /** Shared reference/color state used by FormulaCellEditor and FormulaService. */ export class FormulaReferenceColorCache { protected _formula = ''; @@ -166,6 +170,29 @@ export function buildFormulaReferenceColorInfos(formula: string): FormulaReferen 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__') { diff --git a/packages/formula-plugin/src/formula.cellEditor.ts b/packages/formula-plugin/src/formula.cellEditor.ts index 29a1ba71a3..7f54bcde1d 100644 --- a/packages/formula-plugin/src/formula.cellEditor.ts +++ b/packages/formula-plugin/src/formula.cellEditor.ts @@ -2,7 +2,9 @@ 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, @@ -37,9 +39,7 @@ export class FormulaCellEditor implements Editor { protected _originalValue = ''; protected _referenceEditRange?: { start: number; end: number }; protected _referenceRangeAnchorCell?: { row: number; cell: number }; - protected _persistentFormulaColorStyleKey = 'formula-editor-grid-persistent-colors'; - protected _referenceSelectionStyleKey = 'formula-editor-grid-ref-selection'; - protected _selectionHighlightStyleKey = 'formula-editor-grid-sel-highlight'; + protected _selectionRangesBeforeFormulaHighlight?: SlickRange[]; protected _suppressNextGridClick = false; protected _suppressGridClickResetTimer?: ReturnType; protected _suppressInitialTabBlur = false; @@ -48,7 +48,6 @@ export class FormulaCellEditor implements Editor { protected _isSelectionModelHighlightActive = false; protected _plainTextValue = ''; // Keep plain text in sync with DOM for reliable copy/paste protected _formulaRefColorCache: FormulaReferenceColorCache = new FormulaReferenceColorCache(); - protected _hasAppliedColorsOnce = false; // Track if we've ever applied colors to avoid unnecessary removals protected _bindEventService: BindingEventService = new BindingEventService(); protected _debug = false; @@ -97,6 +96,7 @@ export class FormulaCellEditor implements Editor { clearTimeout(this._tabNavigateTimer); this.hideAutocomplete(); this.clearReferenceSelectionHighlight(); + this.clearFormulaReferenceColors(); this._bindEventService.unbindAll(); this._autocompleteElm?.remove(); this._editorElm?.remove(); @@ -708,20 +708,8 @@ export class FormulaCellEditor implements Editor { * Must be called before any rendering or grid cell coloring operations. */ protected buildFormulaReferenceColorCache(): void { - const raw = this.getPlainTextValue(); - if (!this._formulaRefColorCache.update(raw) && this._hasAppliedColorsOnce) { - return; // Cache is up-to-date and colors already applied - } - - // Clear old persistent colors only if we've previously applied colors - if (this._hasAppliedColorsOnce) { - this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); - } - - // Apply all reference colors to the grid (but skip on initial load to pass tests) - if (this._isValueTouched) { - this.applyFormulaReferenceCellColors(); - } + this._formulaRefColorCache.update(this.getPlainTextValue()); + this.applyFormulaReferenceCellColors(); } /** @@ -733,93 +721,39 @@ export class FormulaCellEditor implements Editor { return; // No colors to apply } - if (this._formulaRefColorCache.size === 0) { - this._formulaRefColorCache.markClean(); - return; - } - - const hash: Record> = Object.create(null); - - // Iterate through each cached reference and paint its cells - for (const info of this._formulaRefColorCache.values()) { - for (const cell of info.cells) { - const { row } = cell; - const cellIdx = cell.cell; - - // Convert cell index to column ID for SlickGrid's setCellCssStyles API - const columns = this.args.grid.getColumns?.() || []; - const column = columns[cellIdx]; - const columnId = column?.id; - - if (columnId && !hash[row]) { - hash[row] = Object.create(null); - } - if (columnId) { - hash[row][columnId] = info.colorClass; - } - } - } + const hash = buildFormulaReferenceCssHash(this._formulaRefColorCache.values(), this.args.grid.getColumns?.() || []); if (Object.keys(hash).length > 0) { - // Clear old styles only if we've previously applied colors - if (this._hasAppliedColorsOnce) { - this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); - // Also clear any old individual reference highlight keys - for (let i = 0; i < 10; i++) { - this.args.grid.removeCellCssStyles?.(`formula-ref-highlight-${i}`); - } - } - this.args.grid.setCellCssStyles?.(this._persistentFormulaColorStyleKey, hash as any); - this._hasAppliedColorsOnce = true; // Mark that we've applied colors + this.args.grid.setCellCssStyles?.(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, hash as any); } else { - // Clear styles if no colors to apply (only if we've previously applied colors) - if (this._hasAppliedColorsOnce) { - this.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); - for (let i = 0; i < 10; i++) { - this.args.grid.removeCellCssStyles?.(`formula-ref-highlight-${i}`); - } - } + this.clearFormulaReferenceColors(); } this._formulaRefColorCache.markClean(); } - protected getColorForSelectedCells(startCell: { row: number; cell: number }, _endCell: { row: number; cell: number }): string { - const raw = this.getPlainTextValue(); - if (!raw.startsWith('=')) { - return 'formula-cell-color-1'; - } - - // Find which formula reference contains the start cell - for (const info of this._formulaRefColorCache.values()) { - for (const cell of info.cells) { - if (cell.row === startCell.row && cell.cell === startCell.cell) { - return info.colorClass; - } - } - } - - return 'formula-cell-color-1'; + protected clearFormulaReferenceColors(): void { + this.args.grid.removeCellCssStyles?.(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY); } protected clearReferenceSelectionHighlight(): void { const selectionModel = this.getGridSelectionModel(); const hadSelectionHighlight = this._isSelectionModelHighlightActive; - if (selectionModel && hadSelectionHighlight) { - selectionModel.setSelectedRanges([], 'FormulaCellEditor.clearReferenceSelectionHighlight', ''); - this._isSelectionModelHighlightActive = false; - } - if (hadSelectionHighlight) { - // Only remove the selection highlight style if we previously applied it - this.args.grid.removeCellCssStyles?.(this._selectionHighlightStyleKey); + 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.args.grid.removeCellCssStyles?.(this._persistentFormulaColorStyleKey); + this.clearFormulaReferenceColors(); } } @@ -829,6 +763,14 @@ export class FormulaCellEditor implements Editor { 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', diff --git a/packages/formula-plugin/src/formula.service.ts b/packages/formula-plugin/src/formula.service.ts index b3353bc0b5..2a5f967eb1 100644 --- a/packages/formula-plugin/src/formula.service.ts +++ b/packages/formula-plugin/src/formula.service.ts @@ -16,7 +16,9 @@ import { createDomElement, Formatters, SlickEventHandler } from '@slickgrid-univ 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, @@ -85,7 +87,6 @@ export class FormulaService implements ExternalResource, FormulaProvider { protected _formulaReferenceAbsoluteFlagsByKey: Map = new Map(); protected _formulaRefColorCache: FormulaReferenceColorCache = new FormulaReferenceColorCache(); protected _originalColumnNamesById: Map = new Map(); - protected _formulaRefStyleKeys: string[] = []; protected _isExcelHeaderPrefixEnabled = false; protected _hasWarnedSelectionPrerequisite = false; protected _hasAutoAssignedFormulaEditor = false; @@ -151,10 +152,7 @@ export class FormulaService implements ExternalResource, FormulaProvider { return; } - for (const styleKey of this._formulaRefStyleKeys) { - this._grid.removeCellCssStyles(styleKey); - } - this._formulaRefStyleKeys = []; + this._grid.removeCellCssStyles(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY); } protected validateSelectionModelPrerequisites(): void { @@ -244,30 +242,11 @@ export class FormulaService implements ExternalResource, FormulaProvider { )}`; this._formulaRefColorCache.update(normalizedFormulaWithRefs); const columns = this._grid.getColumns() as Column[]; - const datasetLength = this.getDatasetLength(); - - Array.from(this._formulaRefColorCache.values()).forEach((reference, idx) => { - const styleKey = `formula-ref-highlight-${idx}`; - const hash: Record> = Object.create(null); - - for (const cell of reference.cells) { - const column = columns[cell.cell]; + const hash = buildFormulaReferenceCssHash(this._formulaRefColorCache.values(), columns, this.getDatasetLength()); - if (!column || cell.row < 0 || cell.row >= datasetLength) { - continue; - } - - if (!hash[cell.row]) { - hash[cell.row] = Object.create(null); - } - hash[cell.row][column.id as number | string] = reference.colorClass; - } - - if (Object.keys(hash).length > 0) { - this._grid.setCellCssStyles(styleKey, hash as any); - this._formulaRefStyleKeys.push(styleKey); - } - }); + if (Object.keys(hash).length > 0) { + this._grid.setCellCssStyles(FORMULA_REFERENCE_HIGHLIGHT_STYLE_KEY, hash as any); + } this._formulaRefColorCache.markClean(); } diff --git a/test/cypress/e2e/example47.cy.ts b/test/cypress/e2e/example47.cy.ts index 887fc57411..3259e0f75a 100644 --- a/test/cypress/e2e/example47.cy.ts +++ b/test/cypress/e2e/example47.cy.ts @@ -140,7 +140,7 @@ describe('Example 47 - Formula Service (MVP)', () => { 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: FormulaService pre-renders grid highlights during onBeforeEditCell, + // 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(); @@ -167,7 +167,7 @@ describe('Example 47 - Formula Service (MVP)', () => { 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 FormulaService.renderFormulaReferenceHighlights() is exercised. + // 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');