From 37375bc66f94c31807a08fc859b7d0096bbf5f0c Mon Sep 17 00:00:00 2001 From: David Skewis Date: Fri, 5 Jun 2026 15:42:43 +0100 Subject: [PATCH 1/2] AG-17284 fix interface docs shiki highlighting (#13954) --- .../components/InterfaceDocumentation.astro | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/documentation/ag-grid-docs/src/components/reference-documentation/components/InterfaceDocumentation.astro b/documentation/ag-grid-docs/src/components/reference-documentation/components/InterfaceDocumentation.astro index b308d3eb0f7..55b0b60c8d1 100644 --- a/documentation/ag-grid-docs/src/components/reference-documentation/components/InterfaceDocumentation.astro +++ b/documentation/ag-grid-docs/src/components/reference-documentation/components/InterfaceDocumentation.astro @@ -5,6 +5,9 @@ import { getInterfaceDocumentationModel } from '../utils/getInterfaceDocumentati import { getOverrides } from '../utils/getOverrides'; import { getJsonFile } from '@utils/pages'; import CodeShiki from '@ag-website-shared/components/code/CodeShiki'; +import { codeToHtml } from 'shiki'; +import agDocsTheme from '@ag-website-shared/components/code/theme.json'; +import { extractDecorations } from '@ag-website-shared/components/code/keepMarkup'; const { interfaceName, overrideSrc, names, exclude, config } = Astro.props; @@ -23,11 +26,21 @@ const model = getInterfaceDocumentationModel({ codeLookup, interfaceLookup, }); + +let preHighlightedHtml: string | undefined; +if (model.type === 'code') { + const { cleanCode, decorations } = extractDecorations(model.code.trimEnd()); + preHighlightedHtml = await codeToHtml(cleanCode, { + lang: 'typescript', + theme: agDocsTheme, + decorations, + } as any); +} --- { model.type === 'code' ? ( - + ) : ( Date: Fri, 5 Jun 2026 12:39:20 -0300 Subject: [PATCH 2/2] [Calculated Columns] - Fixed API issues docs (#13969) * AG-17408 - [Calculated Columns] - API Changes * fixed small issues and API --- community-modules/locale/src/en-US.ts | 1 + .../api-documentation/grid-api/api.json | 18 - .../main.ts | 2 +- .../calculated-columns-row-groups/main.ts | 3 +- .../_examples/calculated-columns/main.ts | 3 +- .../docs/calculated-columns/index.mdoc | 52 +-- packages/ag-grid-community/src/api/gridApi.ts | 26 -- .../src/api/gridApiFunctions.ts | 3 - .../src/columns/columnStateUtils.ts | 4 +- .../src/interfaces/iCalculatedColumns.ts | 14 +- .../rules/gridOptionsValidations.ts | 6 +- .../calculatedColumns/calculatedColumnsApi.ts | 16 +- .../calculatedColumnsModule.ts | 10 +- .../calculatedColumnsService.ts | 226 ++++++++----- .../src/menu/menuItemMapper.ts | 2 +- .../behavioural/src/columns/row-span.test.ts | 33 +- .../calculated-columns-ordering.test.ts | 51 ++- .../formulas/calculated-columns-pivot.test.ts | 8 +- .../src/formulas/calculated-columns.test.ts | 320 +++++++++++------- 19 files changed, 465 insertions(+), 333 deletions(-) diff --git a/community-modules/locale/src/en-US.ts b/community-modules/locale/src/en-US.ts index 71078f3eaab..5e303abc051 100644 --- a/community-modules/locale/src/en-US.ts +++ b/community-modules/locale/src/en-US.ts @@ -848,6 +848,7 @@ export const AG_GRID_LOCALE_EN = { calculatedColumnExpressionAmbiguousReference: 'Ambiguous column reference "${variable}". Use the Columns list or a more specific group path.', calculatedColumnExpressionUnknownReference: 'Unknown column reference "${variable}".', + calculatedColumnExpressionEmpty: 'Enter an expression.', calculatedColumnApply: 'Apply', calculatedColumnCancel: 'Cancel', diff --git a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json index eafe5575284..abdf235c16b 100644 --- a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json +++ b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json @@ -1406,24 +1406,6 @@ "url": "./calculated-columns/" } }, - "addCalculatedColumn": { - "more": { - "name": "Calculated Columns API", - "url": "./calculated-columns/#api" - } - }, - "updateCalculatedColumn": { - "more": { - "name": "Calculated Columns API", - "url": "./calculated-columns/#api" - } - }, - "removeCalculatedColumn": { - "more": { - "name": "Calculated Columns API", - "url": "./calculated-columns/#api" - } - }, "openCalculatedColumnDialog": { "more": { "name": "Calculated Columns API", diff --git a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-dialog-highlighting/main.ts b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-dialog-highlighting/main.ts index 22c603efa18..f45d3d9635a 100644 --- a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-dialog-highlighting/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-dialog-highlighting/main.ts @@ -50,7 +50,7 @@ const gridOptions: GridOptions = { columnDefs, rowData, calculatedColumns: { - columnHighlighting: true, + suppressColumnHighlighting: true, }, defaultColDef: { flex: 1, diff --git a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/main.ts b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/main.ts index 9b2115b7bdd..c096ff53e2e 100644 --- a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/main.ts @@ -6,11 +6,12 @@ import { ValidationModule, createGrid, } from 'ag-grid-community'; -import { CalculatedColumnsModule, RowGroupingModule } from 'ag-grid-enterprise'; +import { CalculatedColumnsModule, ColumnMenuModule, RowGroupingModule } from 'ag-grid-enterprise'; ModuleRegistry.registerModules([ ClientSideRowModelModule, CalculatedColumnsModule, + ColumnMenuModule, RowGroupingModule, NumberFilterModule, ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), diff --git a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns/main.ts b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns/main.ts index 88ae57e7792..d6b828f926f 100644 --- a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns/main.ts @@ -7,11 +7,12 @@ import { ValidationModule, createGrid, } from 'ag-grid-community'; -import { CalculatedColumnsModule } from 'ag-grid-enterprise'; +import { CalculatedColumnsModule, ColumnMenuModule } from 'ag-grid-enterprise'; ModuleRegistry.registerModules([ ClientSideRowModelModule, CalculatedColumnsModule, + ColumnMenuModule, NumberEditorModule, NumberFilterModule, ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), diff --git a/documentation/ag-grid-docs/src/content/docs/calculated-columns/index.mdoc b/documentation/ag-grid-docs/src/content/docs/calculated-columns/index.mdoc index 8a0feb24bb9..e5be609b040 100644 --- a/documentation/ag-grid-docs/src/content/docs/calculated-columns/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/calculated-columns/index.mdoc @@ -7,7 +7,7 @@ Calculated Columns let you add read-only values to the grid without storing thos ## Enabling Calculated Columns -Calculated columns are enabled by registering `CalculatedColumnsModule` and setting `calculatedExpression` on a column definition. In source code and Grid API calls, bracket references such as `[revenue]` resolve to the same-row value from the column with that `colId`. +Calculated columns are enabled by registering `CalculatedColumnsModule` and setting `calculatedExpression` on a column definition. In source code, bracket references such as `[revenue]` resolve to the same-row value from the column with that `colId`. ```{% frameworkTransform=true %} const gridOptions = { @@ -110,23 +110,23 @@ This only controls the expression picker buttons. Use an empty array or `null` t {% gridExampleRunner title="Dialog Expression Pickers" name="calculated-columns-dialog-helper-lists" exampleHeight=350 /%} -### columnHighlighting +### suppressColumnHighlighting -Use `columnHighlighting` to highlight the calculated column while its edit dialog is open: +Calculated columns are highlighted by default while their edit dialog is open. Use `suppressColumnHighlighting` to disable this highlight: ```{% frameworkTransform=true %} const gridOptions = { theme: myTheme, calculatedColumns: { - columnHighlighting: true, + suppressColumnHighlighting: true, }, }; ``` -When enabled, the highlighted header and cells highlight colour can also be customised with the `--ag-calculated-column-highlight-color` CSS variable. +When highlighting is not suppressed, the header and cells highlight colour can be customised with the `--ag-calculated-column-highlight-color` CSS variable. -{% gridExampleRunner title="Dialog Column Highlighting" name="calculated-columns-dialog-highlighting" exampleHeight=350 /%} +{% gridExampleRunner title="Suppress Dialog Column Highlighting" name="calculated-columns-dialog-highlighting" exampleHeight=350 /%} ## Advanced Calculated Columns @@ -161,32 +161,40 @@ const gridOptions = { Header cells and grid cells for calculated columns include the `ag-calculated-column` CSS class, which can be used for custom styling. -Calculated columns can also be managed from the Grid API. API-created columns are appended to the current column tree; use the column moving API afterwards if you need a specific display position. The original application-provided `columnDefs` are not mutated, but `api.getColumnDefs()` includes dynamic calculated columns for persistence. +Calculated columns are managed through column definitions, like other columns. To add, update or remove a calculated column from application code, update the `columnDefs` grid option. -A column added from the header menu is placed after the column it was created from, where that anchor is a leaf column. When the anchor is a column-group header or the auto-group column, or when the anchor column is later removed, the calculated column falls back to the end of the column tree. - -Replacing the grid's columns with `setColumnDefs` (or `updateGridOptions({ columnDefs })`) is a full reset: it clears calculated columns added through the API or the dialog. To keep user-created calculated columns across a replacement, read the current definitions from `api.getColumnDefs()` and include them in the new array. +A column added from the header menu is placed after the column it was created from. The dialog manages calculated columns inside the grid without mutating the `columnDefs` array supplied by the application. To persist user-created calculated columns, read the complete current definitions from `api.getColumnDefs()`. ```{% frameworkTransform=true %} const calculatedColumns = api .getColumns() .filter(column => column.getColDef().calculatedExpression != null); -api.addCalculatedColumn({ - colId: 'profitMargin', - calculatedExpression: '([revenue] - [cost]) / [revenue]', - cellDataType: 'number', - valueFormatter: params => `${(params.value * 100).toFixed(1)}%`, -}); +api.setGridOption('columnDefs', [ + ...(api.getColumnDefs() ?? []), + { + colId: 'profitMargin', + calculatedExpression: '([revenue] - [cost]) / [revenue]', + cellDataType: 'number', + valueFormatter: params => `${(params.value * 100).toFixed(1)}%`, + }, +]); -api.updateCalculatedColumn('profitMargin', { - calculatedExpression: '([revenue] - [cost]) / [revenue]', - valueFormatter: params => `${(params.value * 100).toFixed(2)}%`, -}); +api.setGridOption( + 'columnDefs', + (api.getColumnDefs() ?? []).map(colDef => + colDef.colId === 'profitMargin' + ? { ...colDef, calculatedExpression: '([revenue] - [cost]) / [revenue]' } + : colDef + ) +); api.openCalculatedColumnDialog('profitMargin'); -api.removeCalculatedColumn('profitMargin'); +api.setGridOption( + 'columnDefs', + (api.getColumnDefs() ?? []).filter(colDef => colDef.colId !== 'profitMargin') +); ``` Calculated Column events also use stored `colId` references in their expression payloads. For example, an event raised from the dialog still reports `[revenue]`, not `[Revenue]`. @@ -199,7 +207,7 @@ Calculated Column events also use stored `colId` references in their expression ### Grid API -{% apiDocumentation source="grid-api/api.json" section="calculatedColumns" names=["addCalculatedColumn", "updateCalculatedColumn", "removeCalculatedColumn", "openCalculatedColumnDialog"] /%} +{% apiDocumentation source="grid-api/api.json" section="calculatedColumns" names=["openCalculatedColumnDialog"] /%} ### Grid Options diff --git a/packages/ag-grid-community/src/api/gridApi.ts b/packages/ag-grid-community/src/api/gridApi.ts index f236e60d2a5..c78f6052554 100644 --- a/packages/ag-grid-community/src/api/gridApi.ts +++ b/packages/ag-grid-community/src/api/gridApi.ts @@ -38,7 +38,6 @@ import type { } from '../interfaces/autoSize'; import type { CsvExportParams } from '../interfaces/exportParams'; import type { GridState, GridStateKey } from '../interfaces/gridState'; -import type { CalculatedColumnDef, CalculatedColumnUpdate } from '../interfaces/iCalculatedColumns'; import type { RenderedRowEvent } from '../interfaces/iCallbackParams'; import type { EditingCellPosition, @@ -1780,31 +1779,6 @@ export interface _FormulaGridApi { /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface _CalculatedColumnsGridApi { - /** - * Add a new calculated column to the end of the column definitions. - * The `calculatedExpression` should reference other columns by `colId`, e.g. `[revenue] - [cost]`. - * @agModule `CalculatedColumnsModule` - */ - addCalculatedColumn(colDef: CalculatedColumnDef): void; - - /** - * Update an existing calculated column. - * The `calculatedExpression` should reference other columns by `colId`, e.g. `[revenue] - [cost]`. - * No-op if the supplied column key does not resolve to a calculated column. - * @agModule `CalculatedColumnsModule` - */ - updateCalculatedColumn( - column: ColKey, - colDef: CalculatedColumnUpdate - ): void; - - /** - * Remove an existing calculated column. - * No-op if the supplied column key does not resolve to a calculated column. - * @agModule `CalculatedColumnsModule` - */ - removeCalculatedColumn(column: ColKey): void; - /** * Open the Calculated Column dialog for an existing calculated column. * No-op if the supplied column key does not resolve to a calculated column. diff --git a/packages/ag-grid-community/src/api/gridApiFunctions.ts b/packages/ag-grid-community/src/api/gridApiFunctions.ts index 584a4c3db4a..80f9bf9b680 100644 --- a/packages/ag-grid-community/src/api/gridApiFunctions.ts +++ b/packages/ag-grid-community/src/api/gridApiFunctions.ts @@ -379,9 +379,6 @@ export const gridApiFunctionsMap: Record = }), ...mod<_CalculatedColumnsGridApi>('CalculatedColumns', { - addCalculatedColumn: 0, - updateCalculatedColumn: 0, - removeCalculatedColumn: 0, openCalculatedColumnDialog: 0, }), diff --git a/packages/ag-grid-community/src/columns/columnStateUtils.ts b/packages/ag-grid-community/src/columns/columnStateUtils.ts index 7574667ca6e..2277d810ec0 100644 --- a/packages/ag-grid-community/src/columns/columnStateUtils.ts +++ b/packages/ag-grid-community/src/columns/columnStateUtils.ts @@ -106,7 +106,7 @@ export function _applyColumnState( colIds[i] = state[i].colId; } if (calculatedColsSvc?.restoreDynamicColumnDefs(colIds)) { - colModel.refreshDynamicColumns(source); + calculatedColsSvc.refreshProjectedColumns(source); } } @@ -301,7 +301,7 @@ export function _resetColumnState(beans: BeanCollection, source: ColumnEventType const { colModel, autoColSvc, selectionColSvc, eventSvc, gos, calculatedColsSvc } = beans; if (calculatedColsSvc?.resetDynamicColumnDefs(true)) { - colModel.refreshDynamicColumns(source); + calculatedColsSvc.refreshProjectedColumns(source); } const primaryCols = colModel.getColDefCols(); diff --git a/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts b/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts index b975757ca42..515776a7d70 100644 --- a/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts +++ b/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts @@ -1,6 +1,7 @@ import type { Bean } from '../context/bean'; import type { AgColumn } from '../entities/agColumn'; -import type { ColDef, ColGroupDef, ColKey } from '../entities/colDef'; +import type { ColDef, ColGroupDef } from '../entities/colDef'; +import type { ColumnEventType } from '../events'; export type CalculatedColumnExpressionPicker = 'columns' | 'functions' | 'operators'; @@ -17,10 +18,10 @@ export interface CalculatedColumnsOptions { */ expressionPickers?: CalculatedColumnExpressionPicker[] | null; /** - * Highlight the calculated column currently being edited by the dialog. + * Suppress highlighting the calculated column currently being edited by the dialog. * @default false */ - columnHighlighting?: boolean; + suppressColumnHighlighting?: boolean; } export type CalculatedColumnDef = ColDef & { @@ -33,14 +34,13 @@ export type CalculatedColumnUpdate = Partial Validations = () => { return 'calculatedColumns should be an object.'; } - const { dataTypes, expressionPickers, columnHighlighting } = calculatedColumns; + const { dataTypes, expressionPickers, suppressColumnHighlighting } = calculatedColumns; if (dataTypes != null) { if (!Array.isArray(dataTypes) || dataTypes.some((dataType) => typeof dataType !== 'string')) { return 'calculatedColumns.dataTypes should be an array of strings.'; @@ -201,8 +201,8 @@ const GRID_OPTION_VALIDATIONS: () => Validations = () => { return "calculatedColumns.expressionPickers should contain only 'columns', 'functions' or 'operators'."; } } - if (columnHighlighting != null && typeof columnHighlighting !== 'boolean') { - return 'calculatedColumns.columnHighlighting should be a boolean.'; + if (suppressColumnHighlighting != null && typeof suppressColumnHighlighting !== 'boolean') { + return 'calculatedColumns.suppressColumnHighlighting should be a boolean.'; } return null; diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts index 50160f33505..519b37905fe 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts @@ -1,17 +1,5 @@ -import type { BeanCollection, CalculatedColumnDef, CalculatedColumnUpdate, ColKey } from 'ag-grid-community'; - -export function addCalculatedColumn(beans: BeanCollection, colDef: CalculatedColumnDef): void { - beans.calculatedColsSvc?.addCalculatedColumn(colDef); -} - -export function updateCalculatedColumn(beans: BeanCollection, column: ColKey, colDef: CalculatedColumnUpdate): void { - beans.calculatedColsSvc?.updateCalculatedColumn(column, colDef); -} - -export function removeCalculatedColumn(beans: BeanCollection, column: ColKey): void { - beans.calculatedColsSvc?.removeCalculatedColumn(beans.colModel.getColDefColOrCol(column)); -} +import type { BeanCollection, ColKey } from 'ag-grid-community'; export function openCalculatedColumnDialog(beans: BeanCollection, column: ColKey): void { - beans.calculatedColsSvc?.openCalculatedColumnDialog(beans.colModel.getColDefColOrCol(column), 'edit'); + beans.calculatedColsSvc?.openCalculatedColumnDialog(beans.colModel.getColDefColOrCol(column), 'edit', false); } diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsModule.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsModule.ts index 86515060eb3..99866ee166b 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsModule.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsModule.ts @@ -4,12 +4,7 @@ import { ColumnApiModule, TooltipModule, _PopupModule } from 'ag-grid-community' import { FormulaModule } from '../formula/formulaModule'; import { VERSION } from '../version'; import calculatedColumnsCSS from './calculatedColumns.css'; -import { - addCalculatedColumn, - openCalculatedColumnDialog, - removeCalculatedColumn, - updateCalculatedColumn, -} from './calculatedColumnsApi'; +import { openCalculatedColumnDialog } from './calculatedColumnsApi'; import { CalculatedColumnsService } from './calculatedColumnsService'; /** @@ -20,9 +15,6 @@ export const CalculatedColumnsModule: _ModuleWithApi<_CalculatedColumnsGridApi(); // guards the first validation pass so we don't emit spurious change events before the baseline exists. private validationStatesInitialised = false; - // re-entry counter: when > 0, projection-triggered refreshes skip validation checks. + // guards the first lifecycle pass so static columnDefs do not emit spurious created events. + private lifecycleInitialised = false; + private knownCalculatedColumns = new Map(); + // re-entry counter: when > 0, projection-triggered refreshes skip validation/lifecycle checks. private suppressValidationChecks = 0; private readonly openDialogsByColId = new Map(); public postConstruct(): void { this.addManagedEventListeners({ - newColumnsLoaded: (event) => this.checkValidationStates(event.source), + newColumnsLoaded: (event) => { + this.checkColumnLifecycle(event.source); + this.checkValidationStates(event.source); + }, gridColumnsChanged: () => this.refreshCalculatedColumnSpans(), columnMoved: (event) => this.releaseVisibleAnchors(event.columns), }); @@ -135,7 +145,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa public isHighlightedColumn(column: AgColumn | null): boolean { return ( column != null && - this.gos.get('calculatedColumns')?.columnHighlighting === true && + this.gos.get('calculatedColumns')?.suppressColumnHighlighting !== true && this.openDialogsByColId.get(column.colId)?.highlight === true ); } @@ -175,39 +185,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } } - public addCalculatedColumn(colDef: CalculatedColumnDef, source: 'api' | 'calculatedColumn' = 'api'): void { - if (!_isStringLargerThan(colDef.calculatedExpression, 0, true)) { - _warnOnce('addCalculatedColumn: calculatedExpression is required and cannot be empty.'); - return; - } - if ( - !this.validateColumnReferences(colDef.calculatedExpression) || - !this.validateFormulaExpression(colDef.calculatedExpression) - ) { - return; - } - - const colId = colDef.colId ?? this.createUniqueColId(); - const nextColDef = this.toCalculatedColDef({ ...colDef, colId }); - this.removeInactiveDynamicColumn(colId); - this.dynamicColumns.push({ colId, colDef: nextColDef }); - this.refreshDynamicColumns(source); - - const column = this.beans.colModel.getColById(colId); - if (column) { - this.dispatchCreatedOrRemovedEvent( - 'calculatedColumnCreated', - this.getEventCommonParams(column, colDef.calculatedExpression, source) - ); - } - this.checkValidationStates(source, true); - } - - public updateCalculatedColumn( - column: ColKey, - colDef: CalculatedColumnUpdate, - source: 'api' | 'calculatedColumn' = 'api' - ): void { + private updateCalculatedColumn(column: ColKey, colDef: CalculatedColumnUpdate): void { const targetColumn = this.beans.colModel.getColDefColOrCol(column); if (targetColumn?.colDef.calculatedExpression == null) { return; @@ -239,16 +217,16 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa }); this.dynamicSuppressions.delete(targetColId); } - this.refreshDynamicColumns(source); + this.refreshDynamicColumns('calculatedColumn'); const nextColumn = this.beans.colModel.getColById(targetColId) ?? targetColumn; const newExpression = nextColumn.colDef.calculatedExpression ?? oldExpression; if (colDef.calculatedExpression !== undefined && oldExpression !== newExpression) { this.dispatchExpressionChangedEvent( - this.getEventCommonParams(nextColumn, newExpression, source), + this.getEventCommonParams(nextColumn, newExpression, 'calculatedColumn'), oldExpression ); } - this.checkValidationStates(source, true); + this.checkValidationStates('calculatedColumn', true); this.refreshCalculatedColumn(targetColId); } @@ -294,42 +272,47 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa return true; } - public openCalculatedColumnDialog(column: AgColumn | null, mode: 'add' | 'edit'): void { + public openCalculatedColumnDialog(column: AgColumn | null, mode: 'add' | 'edit', focusDialog = true): void { if (mode === 'add') { const colId = this.createUniqueColId(); const headerName = this.getLocaleTextFunc()('calculatedColumnDefaultTitle', 'New title'); const draft: CalculatedColumnDraft = { colId, headerName, ...this.getDefaultDraft() }; - this.showDialog(draft, (nextDraft) => { - const isDynamicAnchor = column != null && this.getDynamicColumn(column.colId) != null; - const anchorColDef = isDynamicAnchor ? undefined : column?.getUserProvidedColDef(); - const nextColDef = this.toColDef(nextDraft); - const columnGroupShow = column?.colDef.columnGroupShow; - - if (columnGroupShow != null) { - nextColDef.columnGroupShow = columnGroupShow; - } - - const shouldUseColumnAsAnchor = - anchorColDef == null || isDynamicAnchor || this.gos.get('maintainColumnOrder'); - this.removeInactiveDynamicColumn(nextDraft.colId); - this.dynamicColumns.push({ - colId: nextDraft.colId, - colDef: nextColDef, - anchorColId: column?.colId, - anchorColDef, - visibleAnchorColId: shouldUseColumnAsAnchor ? column?.colId : undefined, - }); - this.refreshDynamicColumns('calculatedColumn'); - this.focusCalculatedColumn(nextDraft.colId); - const newColumn = this.beans.colModel.getColById(nextDraft.colId); - if (newColumn) { - this.dispatchCreatedOrRemovedEvent( - 'calculatedColumnCreated', - this.getEventCommonParams(newColumn, nextDraft.calculatedExpression, 'calculatedColumn') - ); - } - this.checkValidationStates('calculatedColumn', true); - }); + this.showDialog( + draft, + (nextDraft) => { + const isDynamicAnchor = column != null && this.getDynamicColumn(column.colId) != null; + const anchorColDef = isDynamicAnchor ? undefined : column?.getUserProvidedColDef(); + const nextColDef = this.toColDef(nextDraft); + const columnGroupShow = column?.colDef.columnGroupShow; + + if (columnGroupShow != null) { + nextColDef.columnGroupShow = columnGroupShow; + } + + const shouldUseColumnAsAnchor = + anchorColDef == null || isDynamicAnchor || this.gos.get('maintainColumnOrder'); + this.removeInactiveDynamicColumn(nextDraft.colId); + this.dynamicColumns.push({ + colId: nextDraft.colId, + colDef: nextColDef, + anchorColId: column?.colId, + anchorColDef, + visibleAnchorColId: shouldUseColumnAsAnchor ? column?.colId : undefined, + }); + this.refreshDynamicColumns('calculatedColumn'); + this.focusCalculatedColumn(nextDraft.colId); + const newColumn = this.beans.colModel.getColById(nextDraft.colId); + if (newColumn) { + this.dispatchCreatedOrRemovedEvent( + 'calculatedColumnCreated', + this.getEventCommonParams(newColumn, nextDraft.calculatedExpression, 'calculatedColumn') + ); + } + this.checkValidationStates('calculatedColumn', true); + }, + undefined, + focusDialog + ); return; } @@ -341,13 +324,14 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa draft, (nextDraft) => { const { colId: _, ...update } = this.toColDef(nextDraft); - this.updateCalculatedColumn(column.colId, update, 'calculatedColumn'); + this.updateCalculatedColumn(column.colId, update); }, - column + column, + focusDialog ); } - public removeCalculatedColumn(column: AgColumn | null, source: 'api' | 'calculatedColumn' = 'api'): void { + public removeCalculatedColumn(column: AgColumn | null): void { if (column?.colDef.calculatedExpression == null) { return; } @@ -364,12 +348,12 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa targetColDef: column.getUserProvidedColDef(), }); } - this.refreshDynamicColumns(source); + this.refreshDynamicColumns('calculatedColumn'); this.dispatchCreatedOrRemovedEvent( 'calculatedColumnRemoved', - this.getEventCommonParams(column, expression, source) + this.getEventCommonParams(column, expression, 'calculatedColumn') ); - this.checkValidationStates(source, true); + this.checkValidationStates('calculatedColumn', true); } public createProjectedColumnDefs( @@ -481,16 +465,22 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa private refreshDynamicColumns(source: ColumnEventType): void { const columnGroupState = this.beans.colGroupSvc?.getColumnGroupState(); + this.refreshProjectedColumns(source); + + if (columnGroupState?.length) { + this.beans.colGroupSvc?.setColumnGroupState(columnGroupState, source); + } + } + + // re-runs the column projection with lifecycle/validation checks suppressed, so column-state + // operations (reset/restore of dynamic calc cols) do not emit spurious created/removed events. + public refreshProjectedColumns(source: ColumnEventType): void { this.suppressValidationChecks++; try { this.beans.colModel.refreshDynamicColumns(source); } finally { this.suppressValidationChecks--; } - - if (columnGroupState?.length) { - this.beans.colGroupSvc?.setColumnGroupState(columnGroupState, source); - } } private createUniqueColId(): string { @@ -683,11 +673,14 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa private showDialog( draft: CalculatedColumnDraft, onApply: (draft: CalculatedColumnDraft) => void, - columnToHighlight?: AgColumn | null + columnToHighlight?: AgColumn | null, + focusDialog = true ): void { const openDialogState = this.openDialogsByColId.get(draft.colId); if (openDialogState) { - openDialogState.dialog.getGui().focus({ preventScroll: true }); + if (focusDialog) { + openDialogState.dialog.getGui().focus({ preventScroll: true }); + } return; } @@ -701,6 +694,14 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa const getValidatedExpression = ( nextDraft: CalculatedColumnDraft ): { valid: true; expression: string } | { valid: false; error: string } => { + // An empty expression is "incomplete", not a malformed formula — surface a calc-column + // message rather than the formula parser's "Formulas must begin with =." error. + if (!_isStringLargerThan(nextDraft.calculatedExpression, 0, true)) { + return { + valid: false, + error: this.getLocaleTextFunc()('calculatedColumnExpressionEmpty', 'Enter an expression.'), + }; + } const result = mapper.toInternalExpression(nextDraft.calculatedExpression); if ('error' in result) { return { @@ -767,6 +768,9 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa state.close = () => dialog.close(); this.openDialogsByColId.set(draft.colId, { dialog, highlight: columnToHighlight != null }); this.refreshCalculatedColumnHighlight(columnToHighlight ?? null); + if (focusDialog) { + dialog.getGui().focus({ preventScroll: true }); + } const destroyDialogMouseListeners = this.addManagedElementListeners(dialog.getGui(), { mousedown: () => form.hideSuggestions(), }); @@ -860,7 +864,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa }; } - private toCalculatedColDef(colDef: CalculatedColumnDef | ColDef): ColDef { + private toCalculatedColDef(colDef: ColDef): ColDef { // strip fields that conflict with calculatedExpression invariants (see colDefValidations.ts). const sanitised: ColDef = { ...colDef }; const invariantProperties: (keyof ColDef)[] = [ @@ -886,6 +890,53 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa return this.getFormulaExpressionError(expression) == null ? 'valid' : 'invalidExpression'; } + private checkColumnLifecycle(source: ColumnEventType): void { + const previousColumns = this.knownCalculatedColumns; + const nextColumns = new Map(); + const shouldDispatch = this.lifecycleInitialised && this.suppressValidationChecks === 0; + + for (const column of this.beans.colModel.getCols() ?? []) { + const expression = column.colDef.calculatedExpression; + if (expression == null) { + continue; + } + + const colId = column.colId; + nextColumns.set(colId, { column, expression }); + + if (!shouldDispatch) { + continue; + } + + const previousColumn = previousColumns.get(colId); + if (previousColumn == null) { + this.dispatchCreatedOrRemovedEvent( + 'calculatedColumnCreated', + this.getEventCommonParams(column, expression, source) + ); + } else if (previousColumn.expression !== expression) { + this.dispatchExpressionChangedEvent( + this.getEventCommonParams(column, expression, source), + previousColumn.expression + ); + } + } + + if (shouldDispatch) { + for (const [colId, previousColumn] of previousColumns) { + if (!nextColumns.has(colId)) { + this.dispatchCreatedOrRemovedEvent( + 'calculatedColumnRemoved', + this.getEventCommonParams(previousColumn.column, previousColumn.expression, source) + ); + } + } + } + + this.knownCalculatedColumns = nextColumns; + this.lifecycleInitialised = true; + } + private checkValidationStates(source: ColumnEventType, forceDispatch = false): void { if (this.suppressValidationChecks > 0) { return; @@ -906,7 +957,10 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa nextStates.set(colId, state); const previousState = previousStates.get(colId); - if (shouldDispatch && previousState !== undefined && previousState !== state) { + const hasPreviousState = previousState !== undefined; + const stateChanged = + (hasPreviousState && previousState !== state) || (!hasPreviousState && state !== 'valid'); + if (shouldDispatch && stateChanged) { const valid = state === 'valid'; this.dispatchValidationStateChangedEvent( this.getEventCommonParams(column, expression, source), diff --git a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts index 0f72f0542dc..d5878f4da73 100644 --- a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts +++ b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts @@ -500,7 +500,7 @@ export class MenuItemMapper extends BeanStub implements NamedBean { ? { name: localeTextFunc('calculatedColumnRemove', 'Remove Calculated Column'), icon: _createIconNoSpan('calculatedColumnRemove', beans, null), - action: () => calculatedColsSvc.removeCalculatedColumn(column, 'calculatedColumn'), + action: () => calculatedColsSvc.removeCalculatedColumn(column), } : null; case 'sortUnSort': diff --git a/testing/behavioural/src/columns/row-span.test.ts b/testing/behavioural/src/columns/row-span.test.ts index 3f2220d7b96..d3957adea1b 100644 --- a/testing/behavioural/src/columns/row-span.test.ts +++ b/testing/behavioural/src/columns/row-span.test.ts @@ -1,4 +1,4 @@ -import type { GridApi, GridOptions, IRowNode } from 'ag-grid-community'; +import type { ColDef, ColGroupDef, GridApi, GridOptions, IRowNode } from 'ag-grid-community'; import { CellSpanModule, ClientSideRowModelModule, @@ -48,6 +48,31 @@ describe('row spanning', () => { return gridsManager.createGrid('myGrid', { enableCellSpan: true, ...options }); } + function addCalculatedColumnDef(api: GridApi, colDef: ColDef): void { + api.setGridOption('columnDefs', [...(api.getColumnDefs() ?? []), colDef]); + } + + function updateCalculatedColumnDef(api: GridApi, colId: string, colDefUpdate: ColDef): void { + api.setGridOption('columnDefs', updateColumnDef(api.getColumnDefs() ?? [], colId, colDefUpdate)); + } + + function updateColumnDef( + columnDefs: (ColDef | ColGroupDef)[], + colId: string, + colDefUpdate: ColDef + ): (ColDef | ColGroupDef)[] { + const nextColumnDefs: (ColDef | ColGroupDef)[] = []; + for (let i = 0, len = columnDefs.length; i < len; ++i) { + const colDef = columnDefs[i]; + if ('children' in colDef) { + nextColumnDefs.push({ ...colDef, children: updateColumnDef(colDef.children, colId, colDefUpdate) }); + } else { + nextColumnDefs.push((colDef.colId ?? colDef.field) === colId ? { ...colDef, ...colDefUpdate } : colDef); + } + } + return nextColumnDefs; + } + // ── Basic / documented behaviour ──────────────────────────────────────────────────────────── describe('basic', () => { @@ -633,7 +658,7 @@ describe('row spanning', () => { └── LEAF id:2 athlete:"B" `); - api.addCalculatedColumn({ colId: 'copy', calculatedExpression: '[athlete]' }); + addCalculatedColumnDef(api, { colId: 'copy', calculatedExpression: '[athlete]' }); await settle(); // regression guard: 'copy' must span 0-1 (by evaluated value), not 0-2. await new GridColumns(api, 'calc-add columns after').checkColumns(` @@ -661,7 +686,7 @@ describe('row spanning', () => { { a: 'Y', b: 'Q' }, ], }); - api.addCalculatedColumn({ colId: 'calc', calculatedExpression: '[a]', spanRows: true } as any); + addCalculatedColumnDef(api, { colId: 'calc', calculatedExpression: '[a]', spanRows: true } as any); await settle(); await new GridRows(api, 'calc-update rows by a').check(` ROOT id:ROOT_NODE_ID @@ -670,7 +695,7 @@ describe('row spanning', () => { └── LEAF id:2 a:"Y" b:"Q" calc:"Y" `); - api.updateCalculatedColumn('calc', { calculatedExpression: '[b]' }); + updateCalculatedColumnDef(api, 'calc', { calculatedExpression: '[b]' }); await settle(); await new GridRows(api, 'calc-update rows by b').check(` ROOT id:ROOT_NODE_ID diff --git a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts index 2a54bb15532..1c5250dd6c4 100644 --- a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts @@ -61,6 +61,27 @@ describe('calculated columns - display ordering', () => { return gridsManager.createGrid(id, { getRowId: (params) => params.data?.id, ...opts }); } + function addCalculatedColumnDef(api: GridApi, colDef: ColDef): void { + api.setGridOption('columnDefs', [...(api.getColumnDefs() ?? []), colDef]); + } + + function removeColumnDef(api: GridApi, colId: string): void { + api.setGridOption('columnDefs', removeColumnDefFromDefs(api.getColumnDefs() ?? [], colId)); + } + + function removeColumnDefFromDefs(columnDefs: (ColDef | ColGroupDef)[], colId: string): (ColDef | ColGroupDef)[] { + const nextColumnDefs: (ColDef | ColGroupDef)[] = []; + for (let i = 0, len = columnDefs.length; i < len; ++i) { + const colDef = columnDefs[i]; + if ('children' in colDef) { + nextColumnDefs.push({ ...colDef, children: removeColumnDefFromDefs(colDef.children, colId) }); + } else if ((colDef.colId ?? colDef.field) !== colId) { + nextColumnDefs.push(colDef); + } + } + return nextColumnDefs; + } + function order(api: GridApi): string[] { return api.getAllGridColumns()!.map((col) => col.getColId()); } @@ -227,7 +248,7 @@ describe('calculated columns - display ordering', () => { }); expect(order(api)).toEqual(['revenue', 'profit', 'cost']); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); // The group's only child is gone — the group must be pruned, not left empty in the colId tree. @@ -251,7 +272,7 @@ describe('calculated columns - display ordering', () => { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', @@ -271,13 +292,13 @@ describe('calculated columns - display ordering', () => { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', }); await asyncSetTimeout(1); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'margin', calculatedExpression: '[profit] / [revenue]', cellDataType: 'number', @@ -301,7 +322,7 @@ describe('calculated columns - display ordering', () => { }); api.moveColumns(['c'], 0); expect(order(api)).toEqual(['c', 'a', 'b']); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b]', cellDataType: 'number' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b]', cellDataType: 'number' }); await asyncSetTimeout(1); expect(order(api)).toEqual(['c', 'a', 'b', 'sum']); await new GridColumns(api, 'addCalculatedColumn appends after a manual reorder, preserving the reorder') @@ -484,7 +505,7 @@ describe('calculated columns - display ordering', () => { api.moveColumns(['c'], 0); expect(order(api)).toEqual(['c', 'a', first, second, 'b']); - api.removeCalculatedColumn(first); + removeColumnDef(api, first); await asyncSetTimeout(1); expect(order(api)).toEqual(['c', 'a', second, 'b']); await new GridColumns(api, 'removing an anchor preserves the user reorder and keeps the dependent in place') @@ -824,7 +845,7 @@ describe('calculated columns - display ordering', () => { const second = await addViaDialog(api, first, '[Revenue] - [Cost]'); expect(order(api)).toEqual(['revenue', first, second, 'cost']); - api.removeCalculatedColumn(first); + removeColumnDef(api, first); await asyncSetTimeout(1); // Only `first` is removed; `second` lost its anchor but keeps its displayed slot (order // maintained) rather than jumping to the end. @@ -851,7 +872,7 @@ describe('calculated columns - display ordering', () => { const second = await addViaDialog(api, first, '[Revenue] - [Cost]'); expect(order(api)).toEqual(['revenue', first, second, 'cost', 'tax']); - api.removeCalculatedColumn(first); + removeColumnDef(api, first); await asyncSetTimeout(1); // `second` is in the MIDDLE (cost + tax follow it) — it stays between revenue and cost, and the // trailing columns are untouched. @@ -873,13 +894,13 @@ describe('calculated columns - display ordering', () => { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', }); await asyncSetTimeout(1); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'margin', calculatedExpression: '[profit] / [revenue]', cellDataType: 'number', @@ -887,7 +908,7 @@ describe('calculated columns - display ordering', () => { await asyncSetTimeout(1); expect(order(api)).toEqual(['revenue', 'cost', 'profit', 'margin']); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(order(api)).toEqual(['revenue', 'cost', 'margin']); await new GridColumns(api, 'removing a dynamic calc col leaves the remaining order intact').checkColumns(` @@ -908,7 +929,7 @@ describe('calculated columns - display ordering', () => { ], }); expect(order(api)).toEqual(['revenue', 'profit', 'cost']); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(order(api)).toEqual(['revenue', 'cost']); await new GridColumns(api, 'removing a static calc col (suppression) leaves the remaining order intact') @@ -927,7 +948,7 @@ describe('calculated columns - display ordering', () => { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: baseDefs, }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', @@ -950,7 +971,7 @@ describe('calculated columns - display ordering', () => { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', @@ -984,7 +1005,7 @@ describe('calculated columns - display ordering', () => { rowNumbers: true, }); await asyncSetTimeout(1); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', diff --git a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts index d0ca215704f..204679eee82 100644 --- a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest'; -import type { GridApi, GridOptions, Module } from 'ag-grid-community'; +import type { ColDef, GridApi, GridOptions, Module } from 'ag-grid-community'; import { ClientSideRowModelModule, ValidationModule } from 'ag-grid-community'; import { CalculatedColumnsModule, FormulaModule, PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; @@ -30,6 +30,10 @@ describe('calculated columns - pivot mode', () => { return gridsManager.createGrid(id, { getRowId: (params) => params.data?.id, ...opts }); } + function addCalculatedColumnDef(api: GridApi, colDef: ColDef): void { + api.setGridOption('columnDefs', [...(api.getColumnDefs() ?? []), colDef]); + } + function order(api: GridApi): string[] { return api.getAllGridColumns()!.map((col) => col.getColId()); } @@ -128,7 +132,7 @@ describe('calculated columns - pivot mode', () => { await asyncSetTimeout(10); const before = order(api); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', diff --git a/testing/behavioural/src/formulas/calculated-columns.test.ts b/testing/behavioural/src/formulas/calculated-columns.test.ts index d55747bd5da..e2a00b8f4d4 100644 --- a/testing/behavioural/src/formulas/calculated-columns.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns.test.ts @@ -2,7 +2,7 @@ import { waitFor } from '@testing-library/dom'; import type { MockInstance } from 'vitest'; import { vi } from 'vitest'; -import type { ColDef, ColGroupDef, GridOptions, Module } from 'ag-grid-community'; +import type { ColDef, ColGroupDef, GridApi, GridOptions, Module } from 'ag-grid-community'; import { CellSpanModule, ClientSideRowModelModule, @@ -87,6 +87,45 @@ describe('ag-grid calculated columns', () => { return gridsManager.createGrid(id, options); } + function addCalculatedColumnDef(api: GridApi, colDef: ColDef): void { + api.setGridOption('columnDefs', [...(api.getColumnDefs() ?? []), colDef]); + } + + function updateCalculatedColumnDef(api: GridApi, colId: string, colDefUpdate: ColDef): void { + api.setGridOption('columnDefs', updateColumnDef(api.getColumnDefs() ?? [], colId, colDefUpdate)); + } + + function removeColumnDef(api: GridApi, colId: string): void { + api.setGridOption('columnDefs', removeColumnDefFromDefs(api.getColumnDefs() ?? [], colId)); + } + + function updateColumnDef( + columnDefs: (ColDef | ColGroupDef)[], + colId: string, + colDefUpdate: ColDef + ): (ColDef | ColGroupDef)[] { + return columnDefs.map((colDef) => { + if ('children' in colDef) { + return { ...colDef, children: updateColumnDef(colDef.children, colId, colDefUpdate) }; + } + + return (colDef.colId ?? colDef.field) === colId ? { ...colDef, ...colDefUpdate } : colDef; + }); + } + + function removeColumnDefFromDefs(columnDefs: (ColDef | ColGroupDef)[], colId: string): (ColDef | ColGroupDef)[] { + const nextColumnDefs: (ColDef | ColGroupDef)[] = []; + for (let i = 0, len = columnDefs.length; i < len; ++i) { + const colDef = columnDefs[i]; + if ('children' in colDef) { + nextColumnDefs.push({ ...colDef, children: removeColumnDefFromDefs(colDef.children, colId) }); + } else if ((colDef.colId ?? colDef.field) !== colId) { + nextColumnDefs.push(colDef); + } + } + return nextColumnDefs; + } + function enableOffsetParentPolyfill(): void { if (restoreOffsetParent) { return; @@ -202,6 +241,18 @@ describe('ag-grid calculated columns', () => { return button!; } + async function selectDataType(label: string): Promise { + getCalculatedColumnDialog() + .querySelector('.ag-select .ag-picker-field-wrapper')! + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + await asyncSetTimeout(1); + const option = Array.from(document.querySelectorAll('.ag-list-item')).find( + (element) => element.textContent?.trim() === label + ); + expect(option).toBeTruthy(); + option!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + } + function getSuggestionLabels(): string[] { return Array.from(document.querySelectorAll('.ag-autocomplete-row-label')).map( (element) => element.textContent?.trim() ?? '' @@ -408,7 +459,7 @@ describe('ag-grid calculated columns', () => { columnDefs: [{ field: 'athlete' }], }); - api.addCalculatedColumn({ colId: 'athleteCopy', calculatedExpression: '[athlete]' }); + addCalculatedColumnDef(api, { colId: 'athleteCopy', calculatedExpression: '[athlete]' }); await asyncSetTimeout(1); await new GridRows(api, 'dynamic calculated span rows', gridRowsOpts).check(` ROOT id:ROOT_NODE_ID @@ -506,7 +557,7 @@ describe('ag-grid calculated columns', () => { columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', headerName: 'Profit', calculatedExpression: '[revenue] - [cost]', @@ -520,7 +571,7 @@ describe('ag-grid calculated columns', () => { └── LEAF id:r1 revenue:10 cost:3 profit:7 `); - api.updateCalculatedColumn('profit', { + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] * [cost]', }); await asyncSetTimeout(1); @@ -530,7 +581,7 @@ describe('ag-grid calculated columns', () => { └── LEAF id:r1 revenue:10 cost:3 profit:30 `); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); await new GridColumns(api, 'removed calculated column').checkColumns(` @@ -554,14 +605,14 @@ describe('ag-grid calculated columns', () => { columnDefs, }); - api.addCalculatedColumn({ colId: 'margin', calculatedExpression: '[profit] / [revenue]' }); + addCalculatedColumnDef(api, { colId: 'margin', calculatedExpression: '[profit] / [revenue]' }); await asyncSetTimeout(1); expect(columnDefs).toEqual([revenueColDef, costColDef, profitColDef]); expect(columnDefs).toHaveLength(3); expect(findColumnDef(api.getColumnDefs()!, 'margin')?.calculatedExpression).toBe('[profit] / [revenue]'); - api.updateCalculatedColumn('profit', { headerName: 'Profit', calculatedExpression: '[revenue] * [cost]' }); + updateCalculatedColumnDef(api, 'profit', { headerName: 'Profit', calculatedExpression: '[revenue] * [cost]' }); await asyncSetTimeout(1); expect(profitColDef).toEqual({ @@ -577,7 +628,7 @@ describe('ag-grid calculated columns', () => { }) ); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(columnDefs).toEqual([revenueColDef, costColDef, profitColDef]); @@ -610,24 +661,32 @@ describe('ag-grid calculated columns', () => { onCalculatedColumnRemoved: removed, }); - api.addCalculatedColumn({ colId: 'margin', calculatedExpression: '[profit] / [revenue]' }); + showColumnMenu(api, 'profit'); + await asyncSetTimeout(10); + await clickColumnMenuItem('Add Calculated Column'); + await asyncSetTimeout(1); + setExpression('[Profit] / [Revenue]'); + clickDialogButton('Apply'); await asyncSetTimeout(1); - expect(api.getColumn('margin')).toBeTruthy(); + expect(api.getColumn('calculated_1')).toBeTruthy(); expect(api.getAllDisplayedColumns().map((column) => column.getColId())).toEqual([ 'revenue', 'cost', 'profit', - 'margin', + 'calculated_1', ]); const columnState = api.getColumnState(); - api.updateCalculatedColumn('profit', { - headerName: 'Updated Profit', - calculatedExpression: '[revenue] * [cost]', - }); + api.openCalculatedColumnDialog('profit'); + await asyncSetTimeout(1); + setExpression('[Revenue] * [Cost]'); + clickDialogButton('Apply'); await asyncSetTimeout(1); - api.removeCalculatedColumn('profit'); + + showColumnMenu(api, 'profit'); + await asyncSetTimeout(10); + await clickColumnMenuItem('Remove Calculated Column'); await asyncSetTimeout(1); expect(api.getColumn('profit')).toBeNull(); @@ -636,10 +695,10 @@ describe('ag-grid calculated columns', () => { api.resetColumnState(); await asyncSetTimeout(1); - expect(api.getColumn('margin')).toBeNull(); + expect(api.getColumn('calculated_1')).toBeNull(); expect(api.getColumn('profit')).toBeTruthy(); expect(removed).toHaveBeenCalledTimes(1); - expect(findColumnDef(api.getColumnDefs()!, 'margin')).toBeUndefined(); + expect(findColumnDef(api.getColumnDefs()!, 'calculated_1')).toBeUndefined(); expect(findColumnDef(api.getColumnDefs()!, 'profit')).toEqual( expect.objectContaining({ colId: 'profit', @@ -652,13 +711,13 @@ describe('ag-grid calculated columns', () => { expect(api.applyColumnState({ state: columnState, applyOrder: true })).toBe(true); await asyncSetTimeout(1); - expect(api.getColumn('margin')).toBeTruthy(); - expect(findColumnDef(api.getColumnDefs()!, 'margin')?.calculatedExpression).toBe('[profit] / [revenue]'); + expect(api.getColumn('calculated_1')).toBeTruthy(); + expect(findColumnDef(api.getColumnDefs()!, 'calculated_1')?.calculatedExpression).toBe('[profit] / [revenue]'); expect(api.getAllDisplayedColumns().map((column) => column.getColId())).toEqual([ 'revenue', 'cost', 'profit', - 'margin', + 'calculated_1', ]); await new GridColumns( api, @@ -668,11 +727,11 @@ describe('ag-grid calculated columns', () => { ├── revenue "Revenue" width:200 ├── cost "Cost" width:200 ├── profit "Profit" width:200 - └── margin width:200 + └── calculated_1 "New title" width:200 `); }); - test('grid api updates calculated column cellDataType without keeping stale boolean renderer', async () => { + test('edit dialog updates calculated column cellDataType without keeping stale boolean renderer', async () => { const api = createGrid('calculated-grid-api-cell-data-type', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -687,18 +746,20 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(1); - api.updateCalculatedColumn('profitable', { - calculatedExpression: '[revenue] > [cost]', - cellDataType: 'boolean', - }); + api.openCalculatedColumnDialog('profitable'); + await asyncSetTimeout(1); + setExpression('[revenue] > [cost]'); + await selectDataType('Boolean'); + clickDialogButton('Apply'); await asyncSetTimeout(1); expect(api.getColumn('profitable')!.getColDef().cellRenderer).toBe('agCheckboxCellRenderer'); - api.updateCalculatedColumn('profitable', { - calculatedExpression: 'IF([revenue] > [cost], "yes", "no")', - cellDataType: 'text', - }); + api.openCalculatedColumnDialog('profitable'); + await asyncSetTimeout(1); + setExpression('IF([revenue] > [cost], "yes", "no")'); + await selectDataType('Text'); + clickDialogButton('Apply'); await asyncSetTimeout(1); await new GridRows(api, 'updated calculated column cell data type', gridRowsOpts).check(` @@ -759,7 +820,7 @@ describe('ag-grid calculated columns', () => { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], }); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b]' }); await asyncSetTimeout(600); const gridDiv = getGridElement(api)!; @@ -1039,7 +1100,7 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(1); const created = waitForEvent('calculatedColumnCreated', api); - api.addCalculatedColumn({ + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number', @@ -1313,6 +1374,33 @@ describe('ag-grid calculated columns', () => { `); }); + test('clearing the expression shows an empty-expression message, not the formula error', async () => { + const api = createGrid('calculated-empty-expression', { + rowData: [{ id: 'r1', revenue: 10, cost: 3 }], + columnDefs: [{ field: 'revenue' }, { field: 'cost' }], + }); + + showColumnMenu(api, 'revenue'); + await asyncSetTimeout(10); + await clickColumnMenuItem('Add Calculated Column'); + await asyncSetTimeout(1); + + // Type a reference, then clear it back to empty (the reported scenario). + setExpression('[gold]'); + setExpression(''); + + const input = getExpressionInput(); + expect(input.validationMessage).toBe('Enter an expression.'); + expect(input.validationMessage).not.toContain('begin with'); + expect(input).toHaveClass('invalid'); + expect(getDialogButton('Apply')).toBeDisabled(); + + // Applying an empty expression must not create a column. + clickDialogButton('Apply'); + await asyncSetTimeout(1); + expect(api.getColumn('calculated_1')).toBeNull(); + }); + test('dialog column picker renders group path and leaf as fixed-height clickable rows', async () => { const api = createGrid('calculated-dialog-column-picker-group-path', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], @@ -1797,7 +1885,7 @@ describe('ag-grid calculated columns', () => { `); }); - test('dispatches calculated column API lifecycle events', async () => { + test('dispatches calculated column columnDefs lifecycle events', async () => { const created = vi.fn(); const changed = vi.fn(); const removed = vi.fn(); @@ -1808,17 +1896,17 @@ describe('ag-grid calculated columns', () => { onCalculatedColumnExpressionChanged: changed, onCalculatedColumnRemoved: removed, }); - await new GridColumns(api, `dispatches calculated column API lifecycle events setup`).checkColumns(` + await new GridColumns(api, `dispatches calculated column columnDefs lifecycle events setup`).checkColumns(` CENTER ├── revenue "Revenue" width:200 └── cost "Cost" width:200 `); - await new GridRows(api, `dispatches calculated column API lifecycle events setup`).check(` + await new GridRows(api, `dispatches calculated column columnDefs lifecycle events setup`).check(` ROOT id:ROOT_NODE_ID └── LEAF id:r1 revenue:10 cost:3 `); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); expect(created).toHaveBeenCalledWith( expect.objectContaining({ @@ -1828,11 +1916,11 @@ describe('ag-grid calculated columns', () => { }) ); - api.updateCalculatedColumn('profit', { headerName: 'Profit' }); + updateCalculatedColumnDef(api, 'profit', { headerName: 'Profit' }); await asyncSetTimeout(1); expect(changed).not.toHaveBeenCalled(); - api.updateCalculatedColumn('profit', { calculatedExpression: '[revenue] * [cost]' }); + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] * [cost]' }); await asyncSetTimeout(1); expect(changed).toHaveBeenCalledWith( expect.objectContaining({ @@ -1844,7 +1932,7 @@ describe('ag-grid calculated columns', () => { ); const removedColumn = api.getColumn('profit'); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(removed).toHaveBeenCalledWith( expect.objectContaining({ @@ -1853,13 +1941,13 @@ describe('ag-grid calculated columns', () => { source: 'api', }) ); - await new GridRows(api, `dispatches calculated column API lifecycle events final state`).check(` + await new GridRows(api, `dispatches calculated column columnDefs lifecycle events final state`).check(` ROOT id:ROOT_NODE_ID └── LEAF id:r1 revenue:10 cost:3 `); }); - test('addCalculatedColumn / updateCalculatedColumn / removeCalculatedColumn dispatch newColumnsLoaded', async () => { + test('calculated column columnDefs mutations dispatch newColumnsLoaded', async () => { const newColumnsLoaded = vi.fn(); const api = createGrid('calc-col-newColumnsLoaded', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], @@ -1870,17 +1958,17 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(1); newColumnsLoaded.mockClear(); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); expect(newColumnsLoaded).toHaveBeenCalledTimes(1); newColumnsLoaded.mockClear(); - api.updateCalculatedColumn('profit', { calculatedExpression: '[revenue] * [cost]' }); + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] * [cost]' }); await asyncSetTimeout(1); expect(newColumnsLoaded).toHaveBeenCalledTimes(1); newColumnsLoaded.mockClear(); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(newColumnsLoaded).toHaveBeenCalledTimes(1); }); @@ -1895,15 +1983,15 @@ describe('ag-grid calculated columns', () => { columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); - api.removeCalculatedColumn('profit'); + removeColumnDef(api, 'profit'); await asyncSetTimeout(1); expect(api.getColumn('profit')).toBeNull(); // Re-add the SAME colId. Must NOT resurrect the destroyed AgColumn from the first add. - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); expect(api.getColumn('profit')).toBeTruthy(); @@ -1932,22 +2020,22 @@ describe('ag-grid calculated columns', () => { columnDefs: [{ field: 'revenue' }, { field: 'cost' }], onNewColumnsLoaded: newColumnsLoaded, }); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); newColumnsLoaded.mockClear(); // Same expression — should be a no-op. - api.updateCalculatedColumn('profit', { calculatedExpression: '[revenue] - [cost]' }); + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(1); expect(newColumnsLoaded).not.toHaveBeenCalled(); // Different expression — should fire. - api.updateCalculatedColumn('profit', { calculatedExpression: '[revenue] * [cost]' }); + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] * [cost]' }); await asyncSetTimeout(1); expect(newColumnsLoaded).toHaveBeenCalledTimes(1); }); - test('updateCalculatedColumn invalidates the formula service per-cell cache', async () => { + test('calculated column columnDefs updates invalidate the formula service per-cell cache', async () => { const api = createGrid('calc-col-formula-cache-invalidation', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -1961,16 +2049,16 @@ describe('ag-grid calculated columns', () => { const rowNode = api.getRowNode('r1')!; expect(api.getCellValue({ rowNode, colKey: 'result', useFormatter: false })).toBe(7); - api.updateCalculatedColumn('result', { calculatedExpression: '[revenue] * [cost]' }); + updateCalculatedColumnDef(api, 'result', { calculatedExpression: '[revenue] * [cost]' }); await asyncSetTimeout(1); expect(api.getCellValue({ rowNode, colKey: 'result', useFormatter: false })).toBe(30); - api.updateCalculatedColumn('result', { calculatedExpression: '[revenue] + [cost]' }); + updateCalculatedColumnDef(api, 'result', { calculatedExpression: '[revenue] + [cost]' }); await asyncSetTimeout(1); expect(api.getCellValue({ rowNode, colKey: 'result', useFormatter: false })).toBe(13); }); - test('updateCalculatedColumn applies column-state changes (width, pinned, hide) to the live column', async () => { + test('calculated column columnDefs updates apply column-state changes (width, pinned, hide) to the live column', async () => { const api = createGrid('calc-col-state-update', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -1986,9 +2074,7 @@ describe('ag-grid calculated columns', () => { expect(profit.isPinned()).toBe(false); expect(profit.isVisible()).toBe(true); - // Static calc col → `dynamicOverrides` → builder.applyOverride. Must re-sync runtime state - // from the merged colDef, same as the normal column reuse path. - api.updateCalculatedColumn('profit', { width: 250, pinned: 'left', hide: true }); + updateCalculatedColumnDef(api, 'profit', { width: 250, pinned: 'left', hide: true }); await asyncSetTimeout(1); const updatedProfit = api.getColumn('profit')!; @@ -1996,14 +2082,13 @@ describe('ag-grid calculated columns', () => { expect(updatedProfit.getPinned()).toBe('left'); expect(updatedProfit.isVisible()).toBe(false); - // Dynamic (API-added) calc col → `applyColDefTo` reuse path. Same invariant. - api.addCalculatedColumn({ colId: 'margin', calculatedExpression: '[revenue] - [cost]', width: 120 }); + addCalculatedColumnDef(api, { colId: 'margin', calculatedExpression: '[revenue] - [cost]', width: 120 }); await asyncSetTimeout(1); const margin = api.getColumn('margin')!; expect(margin.getActualWidth()).toBe(120); - api.updateCalculatedColumn('margin', { width: 260, pinned: 'right' }); + updateCalculatedColumnDef(api, 'margin', { width: 260, pinned: 'right' }); await asyncSetTimeout(1); const updatedMargin = api.getColumn('margin')!; @@ -2011,7 +2096,7 @@ describe('ag-grid calculated columns', () => { expect(updatedMargin.getPinned()).toBe('right'); await new GridColumns( api, - 'updateCalculatedColumn applies column-state changes (width, pinned, hide) to the live column' + 'calculated column columnDefs updates apply column-state changes (width, pinned, hide) to the live column' ).checkColumns(` CENTER ├── revenue "Revenue" width:200 @@ -2021,10 +2106,10 @@ describe('ag-grid calculated columns', () => { `); }); - test('does not dispatch calculated column lifecycle events for rejected API mutations', async () => { + test('dispatches lifecycle events for invalid calculated column columnDefs mutations', async () => { const created = vi.fn(); const changed = vi.fn(); - const api = createGrid('calculated-rejected-api-events', { + const api = createGrid('calculated-invalid-coldef-events', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ { field: 'revenue' }, @@ -2036,38 +2121,45 @@ describe('ag-grid calculated columns', () => { }); await new GridColumns( api, - `does not dispatch calculated column lifecycle events for rejected API mutations setup` + `dispatches lifecycle events for invalid calculated column columnDefs mutations setup` ).checkColumns(` CENTER ├── revenue "Revenue" width:200 ├── cost "Cost" width:200 └── profit width:200 `); - await new GridRows(api, `does not dispatch calculated column lifecycle events for rejected API mutations setup`) + await new GridRows(api, `dispatches lifecycle events for invalid calculated column columnDefs mutations setup`) .check(` ROOT id:ROOT_NODE_ID └── LEAF id:r1 revenue:10 cost:3 profit:7 `); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - try { - api.addCalculatedColumn({ colId: 'bad', calculatedExpression: '[missing] + 1' }); - api.updateCalculatedColumn('profit', { calculatedExpression: '[missing] + 1' }); - await asyncSetTimeout(1); + addCalculatedColumnDef(api, { colId: 'bad', calculatedExpression: '[missing] + 1' }); + updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[missing] + 1' }); + await asyncSetTimeout(1); - expect(api.getColumn('bad')).toBeNull(); - expect(created).not.toHaveBeenCalled(); - expect(changed).not.toHaveBeenCalled(); - expect(api.getColumn('profit')?.getColDef().calculatedExpression).toBe('[revenue] - [cost]'); - } finally { - consoleWarnSpy.mockRestore(); - } + expect(api.getColumn('bad')).toBeTruthy(); + expect(created).toHaveBeenCalledWith( + expect.objectContaining({ + column: api.getColumn('bad'), + expression: '[missing] + 1', + source: 'api', + }) + ); + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ + column: api.getColumn('profit'), + oldExpression: '[revenue] - [cost]', + expression: '[missing] + 1', + source: 'api', + }) + ); await new GridRows( api, - `does not dispatch calculated column lifecycle events for rejected API mutations final state` + `dispatches lifecycle events for invalid calculated column columnDefs mutations final state` ).check(` ROOT id:ROOT_NODE_ID - └── LEAF id:r1 revenue:10 cost:3 profit:7 + └── LEAF id:r1 revenue:10 cost:3 profit:"#PARSE!" bad:"#PARSE!" `); }); @@ -2518,11 +2610,8 @@ describe('ag-grid calculated columns', () => { } }); - test('calculated columns add calculated column classes and opt-in edit highlighting', async () => { + test('calculated columns add calculated column classes and edit highlighting by default', async () => { const api = createGrid('calculated-column-classes', { - calculatedColumns: { - columnHighlighting: true, - }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ { field: 'revenue' }, @@ -2552,6 +2641,7 @@ describe('ag-grid calculated columns', () => { await clickColumnMenuItem('Edit Calculated Column'); await asyncSetTimeout(1); + expect(document.activeElement?.closest('.ag-dialog')).toBeTruthy(); expect(gridDiv.querySelector('[col-id="profit"].ag-header-cell')).toHaveClass( 'ag-calculated-column-highlighted' ); @@ -2570,7 +2660,7 @@ describe('ag-grid calculated columns', () => { ); }); - test('toggling columnHighlighting while the dialog is open updates the highlight live', async () => { + test('toggling suppressColumnHighlighting while the dialog is open updates the highlight live', async () => { const api = createGrid('calculated-column-highlight-toggle', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -2590,25 +2680,27 @@ describe('ag-grid calculated columns', () => { const header = () => gridDiv.querySelector('[col-id="profit"].ag-header-cell'); const cell = () => gridDiv.querySelector('[row-index="0"] [col-id="profit"]'); - // Highlighting is off by default, so an open edit dialog shows no highlight. - expect(header()).not.toHaveClass('ag-calculated-column-highlighted'); - expect(cell()).not.toHaveClass('ag-calculated-column-highlighted'); - - // Enabling it while the dialog is open highlights the edited column immediately. - api.setGridOption('calculatedColumns', { columnHighlighting: true }); - await asyncSetTimeout(1); + // Highlighting is on by default, so an open edit dialog highlights the edited column. expect(header()).toHaveClass('ag-calculated-column-highlighted'); expect(cell()).toHaveClass('ag-calculated-column-highlighted'); - // Disabling it again removes the highlight without closing the dialog. - api.setGridOption('calculatedColumns', { columnHighlighting: false }); + // Suppressing it removes the highlight without closing the dialog. + api.setGridOption('calculatedColumns', { suppressColumnHighlighting: true }); await asyncSetTimeout(1); expect(header()).not.toHaveClass('ag-calculated-column-highlighted'); expect(cell()).not.toHaveClass('ag-calculated-column-highlighted'); + + api.setGridOption('calculatedColumns', { suppressColumnHighlighting: false }); + await asyncSetTimeout(1); + expect(header()).toHaveClass('ag-calculated-column-highlighted'); + expect(cell()).toHaveClass('ag-calculated-column-highlighted'); }); - test('calculated column edit highlighting is disabled by default', async () => { + test('calculated column edit highlighting can be suppressed', async () => { const api = createGrid('calculated-column-highlight-disabled', { + calculatedColumns: { + suppressColumnHighlighting: true, + }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ { field: 'revenue' }, @@ -2637,9 +2729,6 @@ describe('ag-grid calculated columns', () => { test('adding a calculated column does not highlight the new column', async () => { const api = createGrid('calculated-column-add-no-highlight', { - calculatedColumns: { - columnHighlighting: true, - }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], }); @@ -2665,9 +2754,6 @@ describe('ag-grid calculated columns', () => { test('multiple open calculated column dialogs highlight each edited column', async () => { const api = createGrid('calculated-column-multi-highlight', { - calculatedColumns: { - columnHighlighting: true, - }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ { field: 'revenue' }, @@ -2724,9 +2810,6 @@ describe('ag-grid calculated columns', () => { test('openCalculatedColumnDialog opens the edit dialog for an existing calculated column', async () => { const api = createGrid('calculated-column-open-dialog-api', { - calculatedColumns: { - columnHighlighting: true, - }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ { field: 'revenue' }, @@ -2746,6 +2829,7 @@ describe('ag-grid calculated columns', () => { const dialog = getCalculatedColumnDialog(); expect(dialog).toBeTruthy(); expect(dialog.querySelector('input')!.value).toBe('Profit'); + expect(document.activeElement?.closest('.ag-dialog')).toBeNull(); expect(document.activeElement?.closest('[col-id="profit"].ag-header-cell')).toBeNull(); const gridDiv = document.querySelector('#calculated-column-open-dialog-api')!; @@ -2823,7 +2907,7 @@ describe('ag-grid calculated columns', () => { validationGridsManager.createGrid('calculated-option-validation', { calculatedColumns: { - columnHighlighting: true, + suppressColumnHighlighting: true, }, rowData: [{ revenue: 10 }], columnDefs: [{ field: 'revenue' }], @@ -2971,7 +3055,7 @@ describe('ag-grid calculated columns', () => { // Add a calculated column. Its round-trip through `updateGridOptions({ columnDefs })` // must not reset the reorder. - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); expect(api.getAllGridColumns()!.map((col) => col.getColId())).toEqual(['c', 'a', 'b', 'sum']); @@ -3007,7 +3091,7 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(0); // Add a calculated column at top level (no target column passed). - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); // After the round-trip: `c` stays first, group G still wraps [a, b], sum at the end. @@ -3047,7 +3131,7 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(0); expect(api.getAllGridColumns()!.map((col) => col.getColId())).toEqual(['c', 'a', 'b']); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); expect(api.getAllGridColumns()!.map((col) => col.getColId())).toEqual(['c', 'a', 'b', 'sum']); @@ -3095,7 +3179,7 @@ describe('ag-grid calculated columns', () => { expect(monthVirtualBefore).not.toBeNull(); // Add a calc col — full updateGridOptions round-trip. - api.addCalculatedColumn({ colId: 'doubled', calculatedExpression: '[amount] * 2' }); + addCalculatedColumnDef(api, { colId: 'doubled', calculatedExpression: '[amount] * 2' }); await asyncSetTimeout(0); // Virtuals still present and alive after the round-trip. @@ -3178,7 +3262,7 @@ describe('ag-grid calculated columns', () => { api.moveColumns(['c'], 0); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); await new GridColumns(api, 'maintainColumnOrder=true: move + addCalcCol').checkColumns(` @@ -3206,7 +3290,7 @@ describe('ag-grid calculated columns', () => { api.moveColumns(['c'], 0); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); // Order preservation now comes from the incremental snapshot, not maintainColumnOrder. @@ -3238,7 +3322,7 @@ describe('ag-grid calculated columns', () => { api.updateGridOptions({ columnDefs: [{ field: 'b' }, { field: 'a' }, { field: 'c' }] }); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); await new GridColumns(api, 'maintainColumnOrder=true: updateColDefs + addCalcCol').checkColumns(` @@ -3289,7 +3373,7 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(0); await new GridColumns(api, 'rowGroup + calc col').checkColumns(` @@ -3337,7 +3421,7 @@ describe('ag-grid calculated columns', () => { // A calc col is a primary (non-value) column, so the pivot cross-tab has no cell for it: // adding one while pivot is active does NOT add it to the pivot display, and the pivot result // is unaffected. It stays a resolvable primary column (and reappears when pivot is off). - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(0); expect(api.getColumn('profit')).toBeTruthy(); @@ -3364,7 +3448,7 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(0); await new GridColumns(api, 'rowSelection + calc col').checkColumns(` @@ -3392,7 +3476,7 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); + addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); await asyncSetTimeout(0); await new GridColumns(api, 'rowNumbers + calc col').checkColumns(` @@ -3418,7 +3502,7 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(0); - api.addCalculatedColumn({ colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); + addCalculatedColumnDef(api, { colId: 'sum', calculatedExpression: '[a] + [b] + [c]' }); await asyncSetTimeout(0); // Move sum to position 0 after creation. @@ -3426,7 +3510,7 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(0); // Add another calc col; sum's runtime position should still be 0. - api.addCalculatedColumn({ colId: 'avg', calculatedExpression: '([a] + [b] + [c]) / 3' }); + addCalculatedColumnDef(api, { colId: 'avg', calculatedExpression: '([a] + [b] + [c]) / 3' }); await asyncSetTimeout(0); await new GridColumns(api, 'moveColumns on calc col + subsequent add').checkColumns(`