From b4f702cdb465e31445bfff91ba6fd4b8131e4023 Mon Sep 17 00:00:00 2001 From: Salvatore Previti Date: Mon, 8 Jun 2026 11:30:51 +0100 Subject: [PATCH 1/2] AG-17366-full-colmodel-rewrite (#13973) * AG-17366-full-colmodel-rewrite --- .../src/alignedGrids/alignedGridsService.ts | 17 +- packages/ag-grid-community/src/api/gridApi.ts | 15 +- .../autoGenerateColumnsService.ts | 9 +- .../columnAutosize/columnAutosizeService.ts | 49 +- .../columnDrag/bodyDropPivotTarget.ts | 32 +- .../columnDrag/moveColumnFeature.ts | 24 +- .../src/columnMove/columnMoveService.ts | 61 +- .../src/columnMove/columnMoveUtils.ts | 153 +- .../internalColumnMoveUtils.test.ts | 50 +- .../src/columnMove/internalColumnMoveUtils.ts | 82 +- .../src/columnResize/columnResizeService.ts | 9 +- .../src/columnResize/groupResizeFeature.ts | 24 +- .../src/columns/baseColsService.ts | 431 ------ .../src/columns/baseSingleColService.ts | 68 + .../src/columns/buildColumnTree.ts | 505 +++++++ .../src/columns/colDefUtils.ts | 116 ++ .../src/columns/colsApplyPrevOrder.ts | 207 +++ .../src/columns/columnApi.ts | 29 +- .../src/columns/columnDefFactory.ts | 177 +-- .../src/columns/columnEventUtils.ts | 12 +- .../src/columns/columnFactoryUtils.ts | 468 ------- .../src/columns/columnFlexService.ts | 9 +- .../columns/columnGroups/colWrapperCache.ts | 94 ++ .../columns/columnGroups/columnGroupApi.ts | 17 +- .../columnGroups/columnGroupService.ts | 668 ++------- .../columns/columnGroups/columnGroupState.ts | 70 + .../columns/columnGroups/columnGroupUtils.ts | 16 - .../src/columns/columnKeyCreator.ts | 52 - .../src/columns/columnModel.ts | 1113 +++++++-------- .../src/columns/columnNameService.test.ts | 2 +- .../src/columns/columnNameService.ts | 13 +- .../src/columns/columnStateUtils.ts | 1231 +++++++++-------- .../src/columns/columnUtils.ts | 235 ++-- .../src/columns/dataTypeService.ts | 54 +- .../src/columns/groupInstanceIdCreator.ts | 16 +- .../src/columns/selectionColService.ts | 199 +-- .../src/columns/visibleColsService.ts | 867 ++++++------ .../ag-grid-community/src/context/context.ts | 14 +- .../src/dragAndDrop/rowDragService.ts | 11 +- .../ag-grid-community/src/edit/editService.ts | 2 +- .../src/edit/strategy/strategyUtils.ts | 2 +- .../src/edit/utils/editors.ts | 14 +- .../src/entities/agColumn.ts | 580 ++++---- .../src/entities/agColumnGroup.ts | 302 ++-- .../src/entities/agProvidedColumnGroup.ts | 238 ++-- .../ag-grid-community/src/entities/colDef.ts | 2 + .../ag-grid-community/src/entities/rowNode.ts | 4 +- .../src/export/baseGridSerializingSession.ts | 4 +- .../src/export/gridSerializer.ts | 32 +- .../src/export/iGridSerializer.ts | 4 +- .../src/filter/columnFilterApi.ts | 10 +- .../src/filter/columnFilterService.ts | 26 +- .../src/filter/filterComp.ts | 2 +- .../src/filter/filterValueService.ts | 2 +- .../src/filter/quickFilterService.ts | 13 +- .../src/gridBodyComp/gridBodyCtrl.ts | 6 +- .../src/gridBodyComp/gridBodyScrollFeature.ts | 11 +- .../abstractCell/abstractHeaderCellCtrl.ts | 2 +- .../cells/column/agColumnHeader.ts | 4 +- .../cells/column/headerCellCtrl.ts | 6 +- .../cells/columnGroup/agColumnGroupHeader.ts | 5 +- .../cells/columnGroup/headerGroupCellCtrl.ts | 12 +- .../src/headerRendering/headerUtils.ts | 14 +- .../src/headerRendering/row/headerRowCtrl.ts | 17 +- .../rowContainer/headerRowContainerCtrl.ts | 3 +- .../src/infiniteRowModel/infiniteRowModel.ts | 5 +- .../src/interfaces/formulas.ts | 3 +- .../src/interfaces/iAutoColService.ts | 15 + .../src/interfaces/iCalculatedColumns.ts | 23 +- .../src/interfaces/iColsService.ts | 90 +- .../src/interfaces/iColumn.ts | 4 +- .../interfaces/iColumnCollectionService.ts | 25 - .../interfaces/iColumnStateUpdateStrategy.ts | 11 +- .../interfaces/iGroupHierarchyColService.ts | 28 +- .../src/interfaces/iPivotResultColsService.ts | 42 +- .../interfaces/iShowRowGroupColsService.ts | 8 +- .../src/interfaces/rowNumbers.ts | 6 +- .../ag-grid-community/src/main-internal.ts | 51 +- packages/ag-grid-community/src/main.ts | 1 + .../src/misc/menu/menuService.ts | 3 +- .../src/misc/state/stateService.ts | 73 +- .../src/navigation/headerNavigationService.ts | 21 +- .../src/pinnedColumns/pinnedColumnService.ts | 4 +- .../src/rendering/cell/cellCtrl.ts | 3 +- .../src/rendering/cell/cellPositionFeature.ts | 8 +- .../src/rendering/row/normalRowFeature.ts | 20 +- .../src/rendering/row/rowAutoHeightService.ts | 5 +- .../src/rendering/rowRenderer.ts | 8 +- .../src/rendering/spanning/rowSpanCache.ts | 35 +- .../src/rendering/spanning/rowSpanService.ts | 202 ++- .../src/selection/selectAllFeature.ts | 5 +- .../src/sort/rowNodeSorter.ts | 6 +- .../src/sort/sortIndicatorComp.ts | 11 +- .../ag-grid-community/src/sort/sortService.ts | 462 +++---- .../src/undoRedo/undoRedoService.ts | 3 +- .../src/utils/mergeDeep.test.ts | 203 ++- .../ag-grid-community/src/utils/mergeDeep.ts | 140 +- .../src/validation/rules/colDefValidations.ts | 8 +- .../rules/gridOptionsValidations.ts | 4 +- .../src/valueService/cellApi.ts | 2 +- .../src/valueService/valueService.ts | 102 +- .../advancedFilterExpressionService.ts | 19 +- .../advancedFilter/advancedFilterService.ts | 2 +- .../colFilterExpressionParser.ts | 2 +- .../src/aggregation/aggColumnNameService.ts | 4 +- .../src/aggregation/aggDataUtils.ts | 6 +- .../src/aggregation/aggregationApi.ts | 8 +- .../src/aggregation/aggregationStage.ts | 17 +- .../src/aggregation/valueColsSvc.ts | 200 ++- .../src/aiToolkit/structuredSchema.ts | 36 +- .../calculatedColumnReferenceMapper.test.ts | 7 + .../calculatedColumnUtils.ts | 54 +- .../calculatedColumns/calculatedColumnsApi.ts | 2 +- .../calculatedColumnsService.ts | 625 +++------ .../chartComp/datasource/chartDatasource.ts | 2 +- .../chartComp/services/chartColumnService.ts | 4 +- .../services/chartCrossFilterService.ts | 2 +- .../src/clipboard/clipboardService.ts | 8 +- .../columnToolPanel/agPrimaryColsHeader.ts | 2 +- .../src/columnToolPanel/agPrimaryColsList.ts | 43 +- .../src/columnToolPanel/columnToolPanel.ts | 4 +- .../columnToolPanel/toolPanelContextMenu.ts | 17 +- .../columnStateUpdateExecutionStrategy.ts | 331 +++-- .../updates/columnStateUpdateStrategy.ts | 6 +- .../updates/columnStateUpdateTypes.ts | 8 +- .../src/columns/baseColsService.ts | 322 +++++ .../src/columns/columnTreeEdit.ts | 131 ++ .../src/columns/orderedColsService.ts | 277 ++++ .../filterToolPanel/agFiltersToolPanelList.ts | 2 +- .../newFilterToolPanel/filterPanelService.ts | 4 +- .../selectableFilterService.ts | 2 +- .../src/formula/ast/parsers.ts | 6 +- .../src/formula/ast/serializer.ts | 4 +- .../src/formula/formulaService.ts | 28 +- .../src/formula/functions/resolver.ts | 10 +- .../groupHierarchyColService.ts | 476 +++---- .../src/groupHierarchy/groupHierarchyUtils.ts | 13 +- .../src/menu/columnChooserFactory.ts | 4 +- .../src/menu/enterpriseMenu.ts | 7 +- .../src/menu/menuItemMapper.ts | 6 +- .../ag-grid-enterprise/src/pivot/pivotApi.ts | 3 +- .../src/pivot/pivotColDefService.ts | 9 +- .../src/pivot/pivotColsSvc.ts | 114 +- .../src/pivot/pivotResultColsService.ts | 350 +++-- .../src/pivot/pivotStage.ts | 5 +- .../src/rangeSelection/agFillHandle.ts | 18 +- .../src/rangeSelection/rangeService.ts | 74 +- .../columnDropZones/dropZoneColumnComp.ts | 6 +- .../groupFilter/groupFilterService.ts | 11 +- .../src/rowGrouping/rowGroupColsSvc.ts | 151 +- .../src/rowGrouping/rowGroupingApi.ts | 4 +- .../rowGrouping/rowGroupingEditValueSvc.ts | 2 +- .../src/rowHierarchy/autoColService.ts | 312 ++--- .../rendering/groupCellRendererCtrl.ts | 4 +- .../showRowGroupColValueService.ts | 7 +- .../rowHierarchy/showRowGroupColsService.ts | 102 +- .../src/rowNumbers/rowNumbersService.ts | 273 ++-- .../listeners/listenerUtils.ts | 10 +- .../listeners/sortListener.ts | 4 +- .../serverSideRowModel/serverSideRowModel.ts | 5 +- .../sideBar/common/toolPanelColDefService.ts | 6 +- .../providedItems/pivotPanelToolbarItem.ts | 4 +- packages/ag-stack/src/main-internal.ts | 4 +- packages/ag-stack/src/utils/array.test.ts | 91 +- packages/ag-stack/src/utils/array.ts | 135 +- .../src/benchmarks/column-update.bench.ts | 25 +- .../deferred-pivot-mode.test.ts | 121 +- .../deferred-suppress-sync-layout.test.ts | 6 +- .../src/columns/cols-service-events.test.ts | 289 ++++ .../src/columns/column-api-extended.test.ts | 64 +- .../src/columns/column-api.test.ts | 119 +- .../src/columns/column-autosize.test.ts | 56 +- .../src/columns/column-destruction.test.ts | 49 +- .../src/columns/column-edge-cases.test.ts | 9 +- .../src/columns/column-groups.test.ts | 152 +- .../src/columns/column-lookup.test.ts | 144 +- .../columns/column-model-rewrite-p2.test.ts | 213 +++ .../apply-column-state.test.ts | 296 +++- .../column-mutations/column-identity.test.ts | 182 ++- .../services-and-trees.test.ts | 6 +- .../column-mutations/setColumnDefs.test.ts | 18 +- ...olumn-prototype-key-ids-enterprise.test.ts | 6 +- .../columns/column-prototype-key-ids.test.ts | 3 +- .../columns/order/column-move-drag.test.ts | 166 +++ .../src/columns/order/pivot.test.ts | 9 +- .../calculated-columns-ordering.test.ts | 70 +- .../formulas/calculated-columns-pivot.test.ts | 3 +- .../src/formulas/calculated-columns.test.ts | 154 ++- .../src/grid-state/grid-state.test.ts | 30 + .../grouping-column-visibility-events.test.ts | 27 +- ...rouping-show-columns-when-expanded.test.ts | 10 +- .../pivot-column-defs-update.test.ts | 9 +- .../pivot-group-hierarchy.test.ts | 290 +++- .../selection/row-numbers-selection.test.ts | 82 +- .../selection-column-autohide.test.ts | 110 ++ .../src/sorting/sort-service.test.ts | 68 + .../behavioural/src/sorting/sorting.test.ts | 28 + .../gridColumns/columns-diagram/formatting.ts | 3 +- .../gridColumnsDomValidator.ts | 8 +- .../gridColumnsValidator.ts | 13 +- .../gridColumns/gridColumnsOptions.ts | 24 +- testing/behavioural/src/test-utils/utils.ts | 27 +- 202 files changed, 9815 insertions(+), 7655 deletions(-) delete mode 100644 packages/ag-grid-community/src/columns/baseColsService.ts create mode 100644 packages/ag-grid-community/src/columns/baseSingleColService.ts create mode 100644 packages/ag-grid-community/src/columns/buildColumnTree.ts create mode 100644 packages/ag-grid-community/src/columns/colDefUtils.ts create mode 100644 packages/ag-grid-community/src/columns/colsApplyPrevOrder.ts delete mode 100644 packages/ag-grid-community/src/columns/columnFactoryUtils.ts create mode 100644 packages/ag-grid-community/src/columns/columnGroups/colWrapperCache.ts create mode 100644 packages/ag-grid-community/src/columns/columnGroups/columnGroupState.ts delete mode 100644 packages/ag-grid-community/src/columns/columnGroups/columnGroupUtils.ts delete mode 100644 packages/ag-grid-community/src/columns/columnKeyCreator.ts create mode 100644 packages/ag-grid-community/src/interfaces/iAutoColService.ts delete mode 100644 packages/ag-grid-community/src/interfaces/iColumnCollectionService.ts create mode 100644 packages/ag-grid-enterprise/src/columns/baseColsService.ts create mode 100644 packages/ag-grid-enterprise/src/columns/columnTreeEdit.ts create mode 100644 packages/ag-grid-enterprise/src/columns/orderedColsService.ts create mode 100644 testing/behavioural/src/columns/cols-service-events.test.ts create mode 100644 testing/behavioural/src/columns/column-model-rewrite-p2.test.ts create mode 100644 testing/behavioural/src/columns/order/column-move-drag.test.ts create mode 100644 testing/behavioural/src/selection/selection-column-autohide.test.ts diff --git a/packages/ag-grid-community/src/alignedGrids/alignedGridsService.ts b/packages/ag-grid-community/src/alignedGrids/alignedGridsService.ts index 433b955188e..ff2f25be221 100644 --- a/packages/ag-grid-community/src/alignedGrids/alignedGridsService.ts +++ b/packages/ag-grid-community/src/alignedGrids/alignedGridsService.ts @@ -1,6 +1,7 @@ import type { AgEvent } from 'ag-stack'; import type { GridApi } from '../api/gridApi'; +import { _setColGroupOpen } from '../columns/columnGroups/columnGroupState'; import { _applyColumnState } from '../columns/columnStateUtils'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; @@ -169,23 +170,21 @@ export class AlignedGridsService extends BeanStub implements NamedBean { } private processGroupOpenedEvent(groupOpenedEvent: ColumnGroupOpenedEvent): void { - const { colGroupSvc } = this.beans; - if (!colGroupSvc) { - return; - } + const beans = this.beans; + const colsGroupsById = beans.colModel.colsGroupsById; for (const masterGroup of groupOpenedEvent.columnGroups) { // likewise for column group - let otherColumnGroup: AgProvidedColumnGroup | null = null; + let otherColumnGroup: AgProvidedColumnGroup | undefined; if (masterGroup) { - otherColumnGroup = colGroupSvc.getProvidedColGroup(masterGroup.getGroupId()); + otherColumnGroup = colsGroupsById.get(masterGroup.getGroupId()); } if (masterGroup && !otherColumnGroup) { continue; } - colGroupSvc.setColumnGroupOpened(otherColumnGroup, masterGroup.isExpanded(), 'alignedGridChanged'); + _setColGroupOpen(beans, otherColumnGroup, masterGroup.isExpanded(), 'alignedGridChanged'); } } @@ -193,12 +192,12 @@ export class AlignedGridsService extends BeanStub implements NamedBean { // the column in the event is from the master grid. need to // look up the equivalent from this (other) grid const masterColumn = colEvent.column; - let otherColumn: AgColumn | null = null; + let otherColumn: AgColumn | undefined; const beans = this.beans; const { colResize, colModel, scrollVisibleSvc } = beans; if (masterColumn) { - otherColumn = colModel.getColDefCol(masterColumn.getColId()); + otherColumn = colModel.getNonPivotCol(masterColumn.getColId()); } // if event was with respect to a master column, that is not present in this // grid, then we ignore the event diff --git a/packages/ag-grid-community/src/api/gridApi.ts b/packages/ag-grid-community/src/api/gridApi.ts index c78f6052554..842d3a67d39 100644 --- a/packages/ag-grid-community/src/api/gridApi.ts +++ b/packages/ag-grid-community/src/api/gridApi.ts @@ -6,7 +6,15 @@ import type { RowDropPositionIndicator, SetRowDropPositionIndicatorParams, } from '../dragAndDrop/rowDropHighlightService'; -import type { ColDef, ColGroupDef, ColKey, ColumnChooserParams, HeaderLocation, IAggFunc } from '../entities/colDef'; +import type { + ColAggFunc, + ColDef, + ColGroupDef, + ColKey, + ColumnChooserParams, + HeaderLocation, + IAggFunc, +} from '../entities/colDef'; import type { ChartRef, GridOptions, SelectAllMode } from '../entities/gridOptions'; import type { AgPublicEventType } from '../eventTypes'; import type { @@ -1542,10 +1550,7 @@ export interface _AggregationGridApi { * Sets the agg function for a column. `aggFunc` can be one of the built-in aggregations or a custom aggregation by name or direct function. * @agModule `RowGroupingModule / PivotModule / TreeDataModule` */ - setColumnAggFunc( - key: ColKey, - aggFunc: string | IAggFunc | null | undefined - ): void; + setColumnAggFunc(key: ColKey, aggFunc: ColAggFunc): void; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ diff --git a/packages/ag-grid-community/src/autoGenerateColumns/autoGenerateColumnsService.ts b/packages/ag-grid-community/src/autoGenerateColumns/autoGenerateColumnsService.ts index 387a2f4c97d..b07300e634a 100644 --- a/packages/ag-grid-community/src/autoGenerateColumns/autoGenerateColumnsService.ts +++ b/packages/ag-grid-community/src/autoGenerateColumns/autoGenerateColumnsService.ts @@ -4,6 +4,7 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { ColDef, ColGroupDef } from '../entities/colDef'; import type { AutoGenerateColumnDefsOptions } from '../entities/gridOptions'; +import { _isPlainObject } from '../utils/mergeDeep'; export class AutoGenerateColumnsService extends BeanStub implements NamedBean { beanName = 'autoGenColsSvc' as const; @@ -118,11 +119,3 @@ function _isPrimitiveArray(arr: unknown[]): boolean { const first = arr[0]; return first != null && !Array.isArray(first) && !_isPlainObject(first) && typeof first !== 'function'; } - -function _isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) { - return false; - } - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} diff --git a/packages/ag-grid-community/src/columnAutosize/columnAutosizeService.ts b/packages/ag-grid-community/src/columnAutosize/columnAutosizeService.ts index 3b6b352352a..f0adc6e11b2 100644 --- a/packages/ag-grid-community/src/columnAutosize/columnAutosizeService.ts +++ b/packages/ag-grid-community/src/columnAutosize/columnAutosizeService.ts @@ -1,7 +1,7 @@ import { _getInnerWidth, _removeFromArray } from 'ag-stack'; import { dispatchColumnResizedEvent } from '../columns/columnEventUtils'; -import { _columnsMatch, getWidthOfColsInList, isRowNumberCol, isSpecialCol } from '../columns/columnUtils'; +import { getWidthOfColsInList, isSpecialCol } from '../columns/columnUtils'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; @@ -65,7 +65,7 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { } public autoSizeCols(params: AutoSizeColumnParams): void { - const { eventSvc, visibleCols, colModel } = this.beans; + const { eventSvc, colModel } = this.beans; setWidthAnimation(this.beans, true); @@ -80,13 +80,14 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { const availableGridWidth = getAvailableWidth(this.beans); - const isLeftCol = (col: ColKey) => visibleCols.leftCols.some((leftCol) => _columnsMatch(leftCol, col)); - const isRightCol = (col: ColKey) => visibleCols.rightCols.some((rightCol) => _columnsMatch(rightCol, col)); - - // We exclude all pinned columns here, we only want columns in the main viewport to be scaled up + // We exclude pinned columns here, we only want columns in the main viewport to be scaled up. const colKeys = params.colKeys.filter((col) => { - const allowAutoSize = !colModel.getCol(col)?.colDef.suppressAutoSize; - return allowAutoSize && !isRowNumberCol(col) && !isLeftCol(col) && !isRightCol(col); + const resolved = colModel.getCol(col); + if (!resolved || resolved.colDef.suppressAutoSize || resolved.colKind === 'row-number') { + return false; + } + const pinned = resolved.pinned; + return !(resolved.displayed && (pinned === 'left' || pinned === 'right')); }); this.sizeColumnsToFit(availableGridWidth, params.source, true, { @@ -170,7 +171,7 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { const updatedColumns: AgColumn[] = []; for (const key of colKeys) { - if (!key || isSpecialCol(key)) { + if (!key) { continue; } const column = colModel.getCol(key); @@ -179,6 +180,9 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { if (!column || columnsAutoSized.has(column) || column.colDef.suppressAutoSize) { continue; } + if (isSpecialCol(column)) { + continue; + } // get how wide this col should be const preferredWidth = autoWidthCalc!.getPreferredWidthForColumn(column, shouldSkipHeader); @@ -198,7 +202,9 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { } if (updatedColumns.length) { - visibleCols.refresh(source); + // skipTreeBuild=true: autosize only changes widths, leaving liveCols/pins/visibility — and + // thus the section/group trees — unchanged. + visibleCols.refresh(source, true); } } @@ -217,12 +223,12 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { private autoSizeColumnGroupsByColumns(keys: ColKey[], source: ColumnEventType, stopAtGroup?: AgColumnGroup): void { const { colModel, ctrlsSvc } = this.beans; const columnGroups = new Set(); - const columns = colModel.getColsForKeys(keys); - for (const col of columns) { - let parent = col.parent; + for (let i = 0, len = keys.length; i < len; ++i) { + const col = colModel.getCol(keys[i]); + let parent = col?.parent; while (parent && parent != stopAtGroup) { - if (!parent.isPadding()) { + if (!parent.providedColumnGroup.padding) { columnGroups.add(parent); } parent = parent.parent; @@ -363,7 +369,7 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { setWidthAnimation(beans, true); } - const limitsMap: { [colId: string]: Omit } = {}; + const limitsMap: { [colId: string]: Omit } = Object.create(null); for (const { key, ...dimensions } of params?.columnLimits ?? []) { limitsMap[typeof key === 'string' ? key : key.getColId()] = dimensions; } @@ -403,7 +409,7 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { const colsToNotSpread: AgColumn[] = []; for (const column of allDisplayedColumns) { - const isIncluded = params?.colKeys?.some((key) => _columnsMatch(column, key)) ?? true; + const isIncluded = params?.colKeys?.some((key) => columnsMatch(column, key)) ?? true; if (column.colDef.suppressSizeToFit || !isIncluded) { colsToNotSpread.push(column); } else { @@ -420,7 +426,7 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { colsToNotSpread.push(column); }; - const currentWidths: Partial> = {}; + const currentWidths: Partial> = Object.create(null); // resetting cols to their original width makes the sizeColumnsToFit more deterministic, // rather than depending on the current size of the columns. most users call sizeColumnsToFit @@ -501,9 +507,8 @@ export class ColumnAutosizeService extends BeanStub implements NamedBean { col.fireColumnWidthChangedEvent(source); } - const visibleCols = beans.visibleCols; - visibleCols.setLeftValues(source); - visibleCols.updateBodyWidths(); + const visibleCols = this.beans.visibleCols; + visibleCols.updateBodyWidths(visibleCols.setLeftValues(source)); if (silent) { return; @@ -627,3 +632,7 @@ function setWidthAnimation({ ctrlsSvc, gos }: BeanCollection, enable: boolean): classList.remove(WIDTH_ANIMATION_CLASS); } } + +function columnsMatch(column: AgColumn, key: ColKey): boolean { + return column === key || column.colId == key || column.colDef === key; +} diff --git a/packages/ag-grid-community/src/columnMove/columnDrag/bodyDropPivotTarget.ts b/packages/ag-grid-community/src/columnMove/columnDrag/bodyDropPivotTarget.ts index 7f1432a51be..2475dd3fddd 100644 --- a/packages/ag-grid-community/src/columnMove/columnDrag/bodyDropPivotTarget.ts +++ b/packages/ag-grid-community/src/columnMove/columnDrag/bodyDropPivotTarget.ts @@ -58,8 +58,7 @@ export class BodyDropPivotTarget extends BeanStub implements DropListener { } /** Callback for when drag leaves */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onDragLeave(draggingEvent: GridDraggingEvent): void { + public onDragLeave(_draggingEvent: GridDraggingEvent): void { // if we are taking columns out of the center, then we remove them from the report this.clearColumnsList(); } @@ -71,21 +70,24 @@ export class BodyDropPivotTarget extends BeanStub implements DropListener { } /** Callback for when dragging */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onDragging(draggingEvent: GridDraggingEvent): void {} + public onDragging(_draggingEvent: GridDraggingEvent): void {} /** Callback for when drag stops */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public onDragStop(draggingEvent: GridDraggingEvent): void { - const { valueColsSvc, rowGroupColsSvc, pivotColsSvc } = this.beans; - if (this.columnsToAggregate.length > 0) { - valueColsSvc?.addColumns(this.columnsToAggregate, 'toolPanelDragAndDrop'); - } - if (this.columnsToGroup.length > 0) { - rowGroupColsSvc?.addColumns(this.columnsToGroup, 'toolPanelDragAndDrop'); - } - if (this.columnsToPivot.length > 0) { - pivotColsSvc?.addColumns(this.columnsToPivot, 'toolPanelDragAndDrop'); + public onDragStop(_draggingEvent: GridDraggingEvent): void { + const { colModel, valueColsSvc, rowGroupColsSvc, pivotColsSvc } = this.beans; + colModel.beginColBatch(); + try { + if (this.columnsToAggregate.length > 0) { + valueColsSvc?.addColumns(this.columnsToAggregate, 'toolPanelDragAndDrop'); + } + if (this.columnsToGroup.length > 0) { + rowGroupColsSvc?.addColumns(this.columnsToGroup, 'toolPanelDragAndDrop'); + } + if (this.columnsToPivot.length > 0) { + pivotColsSvc?.addColumns(this.columnsToPivot, 'toolPanelDragAndDrop'); + } + } finally { + colModel.endColBatch('toolPanelDragAndDrop'); } } diff --git a/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts b/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts index 6a95b8e3021..c83e7391fd0 100644 --- a/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts +++ b/packages/ag-grid-community/src/columnMove/columnDrag/moveColumnFeature.ts @@ -1,5 +1,6 @@ import { _exists, _last, _missing } from 'ag-stack'; +import { _setColsVisible } from '../../columns/columnStateUtils'; import { BeanStub } from '../../context/beanStub'; import type { DragAndDropIcon, GridDraggingEvent } from '../../dragAndDrop/dragAndDropService'; import { DragSourceType } from '../../dragAndDrop/dragAndDropService'; @@ -171,28 +172,17 @@ export class MoveColumnFeature extends BeanStub implements DropListener { } public setColumnsVisible(columns: AgColumn[] | null | undefined, visible: boolean, source: ColumnEventType) { - if (!columns?.length) { - return; - } - - const allowedCols = columns.filter((c) => !c.getColDef().lockVisible); - if (!allowedCols.length) { - return; + if (columns?.length) { + _setColsVisible(this.beans, columns, visible, source, true); } - this.beans.colModel.setColsVisible(allowedCols, visible, source); } private finishColumnMoving(): void { this.clearHighlighted(); - const lastMovedInfo = this.lastMovedInfo; - if (!lastMovedInfo) { - return; + if (lastMovedInfo) { + this.beans.colMoves!.moveColumns(lastMovedInfo.columns, lastMovedInfo.toIndex, 'uiColumnMoved', true); } - - const { columns, toIndex } = lastMovedInfo; - - this.beans.colMoves!.moveColumns(columns, toIndex, 'uiColumnMoved', true); } private updateDragItemContainerType(): void { @@ -429,8 +419,8 @@ export class MoveColumnFeature extends BeanStub implements DropListener { } const visibleColumns = visibleCols.allCols; - const movingColIndex = visibleColumns.indexOf(firstMovingCol); - const targetIndex = visibleColumns.indexOf(column); + const movingColIndex = firstMovingCol.allColsIndex; + const targetIndex = column.allColsIndex; const isBefore = position === ColumnHighlightPosition.Before; const fromLeft = movingColIndex < targetIndex || (movingColIndex === targetIndex && !isBefore); let diff: number = 0; diff --git a/packages/ag-grid-community/src/columnMove/columnMoveService.ts b/packages/ag-grid-community/src/columnMove/columnMoveService.ts index 12a8ca60b14..190aca36dc3 100644 --- a/packages/ag-grid-community/src/columnMove/columnMoveService.ts +++ b/packages/ag-grid-community/src/columnMove/columnMoveService.ts @@ -1,6 +1,7 @@ import type { HorizontalDirection } from 'ag-stack'; import { _last, _moveInArray, _removeFromArray } from 'ag-stack'; +import { _setColsVisible } from '../columns/columnStateUtils'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { GridDragSource } from '../dragAndDrop/dragAndDropService'; @@ -10,7 +11,7 @@ import type { AgColumnGroup } from '../entities/agColumnGroup'; import { isColumnGroup } from '../entities/agColumnGroup'; import type { ColDef, ColKey } from '../entities/colDef'; import type { ColumnEventType } from '../events'; -import type { Column, ColumnPinnedType } from '../interfaces/iColumn'; +import type { ColumnPinnedType } from '../interfaces/iColumn'; import type { DragItem } from '../interfaces/iDragItem'; import { _warn } from '../validation/logging'; import { BodyDropTarget } from './columnDrag/bodyDropTarget'; @@ -26,12 +27,7 @@ export class ColumnMoveService extends BeanStub implements NamedBean { beanName = 'colMoves' as const; public moveColumnByIndex(fromIndex: number, toIndex: number, source: ColumnEventType): void { - const gridColumns = this.beans.colModel.getCols(); - if (!gridColumns) { - return; - } - - const column = gridColumns[fromIndex]; + const column = this.beans.colModel.colsList[fromIndex]; this.moveColumns([column], toIndex, source); } @@ -41,11 +37,9 @@ export class ColumnMoveService extends BeanStub implements NamedBean { source: ColumnEventType, finished: boolean = true ): void { - const { colModel, colAnimation, visibleCols, eventSvc } = this.beans; - const gridColumns = colModel.getCols(); - if (!gridColumns) { - return; - } + const { colModel, visibleCols } = this.beans; + const colAnimation = this.beans.colAnimation; + const gridColumns = colModel.colsList; if (toIndex > gridColumns.length - columnsToMoveKeys.length) { // Trying to insert in invalid position @@ -55,12 +49,19 @@ export class ColumnMoveService extends BeanStub implements NamedBean { colAnimation?.start(); // we want to pull all the columns out first and put them into an ordered list - const movedColumns = colModel.getColsForKeys(columnsToMoveKeys); + const movedColumns: AgColumn[] = []; + for (let i = 0, len = columnsToMoveKeys.length; i < len; ++i) { + const col = colModel.getCol(columnsToMoveKeys[i]); + if (col) { + movedColumns.push(col); + } + } if (this.doesMovePassRules(movedColumns, toIndex)) { - _moveInArray(colModel.getCols(), movedColumns, toIndex); - visibleCols.refresh(source); - eventSvc.dispatchEvent({ + _moveInArray(colModel.colsList, movedColumns, toIndex); + colModel.markColsListIndexDirty(); + visibleCols.refresh(source, false); + this.eventSvc.dispatchEvent({ type: 'columnMoved', columns: movedColumns, column: movedColumns.length === 1 ? movedColumns[0] : null, @@ -81,7 +82,7 @@ export class ColumnMoveService extends BeanStub implements NamedBean { public doesOrderPassRules(gridOrder: AgColumn[]) { const { colModel, gos } = this.beans; - if (!doesMovePassMarryChildren(gridOrder, colModel.getColTree())) { + if (colModel.hasMarryChildren && !doesMovePassMarryChildren(gridOrder, colModel.colsTree)) { return false; } @@ -120,8 +121,7 @@ export class ColumnMoveService extends BeanStub implements NamedBean { } public getProposedColumnOrder(columnsToMove: AgColumn[], toIndex: number): AgColumn[] { - const gridColumns = this.beans.colModel.getCols(); - const proposedColumnOrder = gridColumns.slice(); + const proposedColumnOrder = this.beans.colModel.colsList.slice(); _moveInArray(proposedColumnOrder, columnsToMove, toIndex); return proposedColumnOrder; } @@ -208,7 +208,8 @@ export class ColumnMoveService extends BeanStub implements NamedBean { column: AgColumn | AgColumnGroup, displayName: string | null ): GridDragSource { - const { gos, colModel, dragAndDrop, visibleCols } = this.beans; + const beans = this.beans; + const { gos, dragAndDrop, visibleCols } = beans; let hideColumnOnExit = !gos.get('suppressDragLeaveHidesColumns'); const isGroup = isColumnGroup(column); const columns = isGroup ? column.getProvidedColumnGroup().getLeafColumns() : [column]; @@ -230,19 +231,15 @@ export class ColumnMoveService extends BeanStub implements NamedBean { onGridEnter: (dragItem) => { if (hideColumnOnExit) { const { columns = [], visibleState } = dragItem ?? {}; - const hasVisibleState = isGroup - ? (col: Column) => !visibleState || visibleState[col.getColId()] - : () => true; - const unlockedColumns = columns.filter( - (col) => !col.getColDef().lockVisible && hasVisibleState(col) - ); - colModel.setColsVisible(unlockedColumns as AgColumn[], true, 'uiColumnMoved'); + const visibleStateCols = isGroup + ? columns.filter((col: AgColumn) => !visibleState || visibleState[col.colId]) + : columns; + _setColsVisible(beans, visibleStateCols as AgColumn[], true, 'uiColumnMoved', true); } }, onGridExit: (dragItem) => { if (hideColumnOnExit) { - const unlockedColumns = dragItem?.columns?.filter((col) => !col.getColDef().lockVisible) || []; - colModel.setColsVisible(unlockedColumns as AgColumn[], false, 'uiColumnMoved'); + _setColsVisible(beans, (dragItem?.columns ?? []) as AgColumn[], false, 'uiColumnMoved', true); } }, }; @@ -255,7 +252,7 @@ export class ColumnMoveService extends BeanStub implements NamedBean { function findGroupWidthId(columnGroup: AgColumnGroup | null, id: any): AgColumnGroup | undefined { while (columnGroup) { - if (columnGroup.getGroupId() === id) { + if (columnGroup.groupId === id) { return columnGroup; } columnGroup = columnGroup.parent; @@ -265,7 +262,7 @@ function findGroupWidthId(columnGroup: AgColumnGroup | null, id: any): AgColumnG } function createDragItem(column: AgColumn): DragItem { - const visibleState: { [key: string]: boolean } = {}; + const visibleState: { [key: string]: boolean } = Object.create(null); visibleState[column.getId()] = column.isVisible(); return { @@ -281,7 +278,7 @@ function createDragItemForGroup(columnGroup: AgColumnGroup, allCols: AgColumn[]) const allColumnsOriginalOrder = columnGroup.getProvidedColumnGroup().getLeafColumns(); // capture visible state, used when re-entering grid to dictate which columns should be visible - const visibleState: { [key: string]: boolean } = {}; + const visibleState: { [key: string]: boolean } = Object.create(null); for (const column of allColumnsOriginalOrder) { visibleState[column.getId()] = column.isVisible(); } diff --git a/packages/ag-grid-community/src/columnMove/columnMoveUtils.ts b/packages/ag-grid-community/src/columnMove/columnMoveUtils.ts index cef6a692af1..e7ae6d46749 100644 --- a/packages/ag-grid-community/src/columnMove/columnMoveUtils.ts +++ b/packages/ag-grid-community/src/columnMove/columnMoveUtils.ts @@ -1,71 +1,124 @@ -import { depthFirstOriginalTreeSearch } from '../columns/columnFactoryUtils'; +import { _indexMap } from 'ag-stack'; + import type { AgColumn } from '../entities/agColumn'; import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; import { isProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; import type { GridOptionsService } from '../gridOptionsService'; export function placeLockedColumns(cols: AgColumn[], gos: GridOptionsService): AgColumn[] { - const left: AgColumn[] = []; - const normal: AgColumn[] = []; - const right: AgColumn[] = []; - cols.forEach((col: AgColumn) => { - const position = col.colDef.lockPosition; - if (position === 'right') { - right.push(col); - } else if (position === 'left' || position === true) { - left.push(col); + let leftCount = 0; + let rightCount = 0; + const len = cols.length; + let firstLeftIdx = len; + let lastLeftIdx = -1; + let firstRightIdx = len; + let lastRightIdx = -1; + for (let i = 0; i < len; ++i) { + const pos = cols[i].colDef.lockPosition; + if (pos === 'right') { + ++rightCount; + if (firstRightIdx === len) { + firstRightIdx = i; + } + lastRightIdx = i; + } else if (pos === 'left' || pos === true) { + ++leftCount; + if (firstLeftIdx === len) { + firstLeftIdx = i; + } + lastLeftIdx = i; + } + } + if (leftCount === 0 && rightCount === 0) { + return cols; // Fast path: no locked cols — input order is already correct. + } + let leftIdx: number; + let normalIdx: number; + let rightIdx: number; + if (gos.get('enableRtl')) { + if (lastRightIdx === rightCount - 1 && firstLeftIdx === len - leftCount) { + return cols; + } + rightIdx = 0; + normalIdx = rightCount; + leftIdx = len - leftCount; + } else { + if (lastLeftIdx === leftCount - 1 && firstRightIdx === len - rightCount) { + return cols; + } + leftIdx = 0; + normalIdx = leftCount; + rightIdx = len - rightCount; + } + const result = new Array(len); + for (let i = 0; i < len; ++i) { + const col = cols[i]; + const pos = col.colDef.lockPosition; + let idx: number; + if (pos === 'right') { + idx = rightIdx++; + } else if (pos === 'left' || pos === true) { + idx = leftIdx++; } else { - normal.push(col); + idx = normalIdx++; } - }); - - const isRtl = gos.get('enableRtl'); - if (isRtl) { - return [...right, ...normal, ...left]; + result[idx] = col; } - - return [...left, ...normal, ...right]; + return result; } +/** Callers gate on `colModel.hasMarryChildren`, so a married group always exists here — the + * position-index Map is always needed, hence built eagerly. */ export function doesMovePassMarryChildren( allColumnsCopy: AgColumn[], gridBalancedTree: (AgColumn | AgProvidedColumnGroup)[] ): boolean { - let rulePassed = true; - - depthFirstOriginalTreeSearch(null, gridBalancedTree, (child) => { - if (!isProvidedColumnGroup(child)) { - return; - } - - const columnGroup = child; - const colGroupDef = columnGroup.getColGroupDef(); - const marryChildren = colGroupDef?.marryChildren; + const positionByCol = _indexMap(allColumnsCopy); + // Current married group's leaf spread; SMI scalars not an object (no alloc). Reset per accumulate. + let min = 0; + let max = 0; + let count = 0; - if (!marryChildren) { - return; + /** Walks the subtree, updating `min` / `max` / `count` with leaf positions in `positionByCol`. */ + const accumulate = (children: (AgColumn | AgProvidedColumnGroup)[]): void => { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + if (isProvidedColumnGroup(child)) { + accumulate(child.children); + continue; + } + const idx = positionByCol.get(child) ?? -1; + if (count === 0) { + min = idx; + max = idx; + } else if (idx < min) { + min = idx; + } else if (idx > max) { + max = idx; + } + ++count; } + }; - const newIndexes: number[] = []; - for (const col of columnGroup.getLeafColumns()) { - const newColIndex = allColumnsCopy.indexOf(col); - newIndexes.push(newColIndex); - } - - // eslint-disable-next-line prefer-spread - const maxIndex = Math.max.apply(Math, newIndexes); - // eslint-disable-next-line prefer-spread - const minIndex = Math.min.apply(Math, newIndexes); - - // spread is how far the first column in this group is away from the last column - const spread = maxIndex - minIndex; - const maxSpread = columnGroup.getLeafColumns().length - 1; - - // if the columns - if (spread > maxSpread) { - rulePassed = false; + const visit = (tree: (AgColumn | AgProvidedColumnGroup)[]): boolean => { + for (let i = 0, len = tree.length; i < len; ++i) { + const child = tree[i]; + if (!isProvidedColumnGroup(child)) { + continue; + } + if (child.colGroupDef?.marryChildren) { + count = 0; + accumulate(child.children); + if (count > 1 && max - min > count - 1) { + return false; + } + } + if (!visit(child.children)) { + return false; + } } - }); + return true; + }; - return rulePassed; + return visit(gridBalancedTree); } diff --git a/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.test.ts b/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.test.ts index 689d5dde828..0b6481e1b8e 100644 --- a/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.test.ts +++ b/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.test.ts @@ -1,6 +1,8 @@ +import type { HorizontalDirection } from 'ag-stack'; + import type { CtrlsService } from '../ctrlsService'; import type { ColumnPinnedType } from '../interfaces/iColumn'; -import { clientXToSectionX, normaliseX } from './internalColumnMoveUtils'; +import { clientXToSectionX, normaliseDirection, normaliseX } from './internalColumnMoveUtils'; function createSectionElements(layout: { left: number; pinnedLeftWidth: number; scrollingWidth: number }) { const { left, pinnedLeftWidth, scrollingWidth } = layout; @@ -49,6 +51,52 @@ describe('normaliseX', () => { expect(result).toBe(250); }); + + test('RTL mirrors x within the section width', () => { + const viewport = createSectionElements({ left: 100, pinnedLeftWidth: 50, scrollingWidth: 900 }); + // center section width = 900; mirrored x = 900 - 250 = 650 + const result = normaliseX({ x: 250, pinned: undefined, isRtl: true, ctrlsSvc: createCtrlsSvc(viewport) }); + + expect(result).toBe(650); + }); + + test('RTL leaves pinned-left untouched (left section is not mirrored)', () => { + const viewport = createSectionElements({ left: 100, pinnedLeftWidth: 50, scrollingWidth: 900 }); + const result = normaliseX({ x: 30, pinned: 'left', isRtl: true, ctrlsSvc: createCtrlsSvc(viewport) }); + + expect(result).toBe(30); + }); + + test('RTL returns 0 when the section element is not found', () => { + const viewport = document.createElement('div'); // no section elements + const result = normaliseX({ x: 250, pinned: undefined, isRtl: true, ctrlsSvc: createCtrlsSvc(viewport) }); + + expect(result).toBe(0); + }); +}); + +describe('normaliseDirection', () => { + test('returns the direction unchanged in LTR mode', () => { + expect(normaliseDirection('left', false, null)).toBe('left'); + expect(normaliseDirection('right', false, null)).toBe('right'); + }); + + test('flips the direction in RTL for centre and pinned-right sections', () => { + const cases: [HorizontalDirection, ColumnPinnedType, HorizontalDirection][] = [ + ['left', null, 'right'], + ['right', null, 'left'], + ['left', 'right', 'right'], + ['right', 'right', 'left'], + ]; + for (const [input, pinned, expected] of cases) { + expect(normaliseDirection(input, true, pinned)).toBe(expected); + } + }); + + test('does not flip the pinned-left section in RTL', () => { + expect(normaliseDirection('left', true, 'left')).toBe('left'); + expect(normaliseDirection('right', true, 'left')).toBe('right'); + }); }); describe('clientXToSectionX', () => { diff --git a/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.ts b/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.ts index 0169201e3eb..2a3033fa287 100644 --- a/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.ts +++ b/packages/ag-grid-community/src/columnMove/internalColumnMoveUtils.ts @@ -1,5 +1,5 @@ import type { HorizontalDirection } from 'ag-stack'; -import { _areEqual, _last } from 'ag-stack'; +import { _areEqual } from 'ag-stack'; import type { ColumnModel } from '../columns/columnModel'; import type { VisibleColsService } from '../columns/visibleColsService'; @@ -27,21 +27,19 @@ export interface ColumnMoveParams { // returns the provided cols sorted in same order as they appear in this.cols, eg if this.cols // contains [a,b,c,d,e] and col passed is [e,a] then the passed cols are sorted into [a,e] -function sortColsLikeCols(colsList: AgColumn[], cols: AgColumn[]): void { +function sortColsLikeCols(colModel: ColumnModel, cols: AgColumn[]): void { if (!cols || cols.length <= 1) { return; } - const notAllColsPresent = cols.filter((c) => colsList.indexOf(c) < 0).length > 0; - if (notAllColsPresent) { + // Can only order by colsList position when every col is live in `colsList`. + if (cols.some((c) => !c.inColsList)) { return; } - cols.sort((a, b) => { - const indexA = colsList.indexOf(a); - const indexB = colsList.indexOf(b); - return indexA - indexB; - }); + // `colsListIndex` is each col's index in `colsList` (O(1)) — no per-element indexOf / index-map. + colModel.ensureColsListIndex(); + cols.sort((a, b) => a.colsListIndex - b.colsListIndex); } /** @@ -51,6 +49,7 @@ function sortColsLikeCols(colsList: AgColumn[], cols: AgColumn[]): void { function getColsToMove(allMovingColumns: AgColumn[]): AgColumn[] { // If the columns we're dragging are the only visible columns of their group, move the hidden ones too const newCols: AgColumn[] = [...allMovingColumns]; + const newColsSet = new Set(newCols); for (const col of allMovingColumns) { let movingGroup: AgColumnGroup | null = null; @@ -67,8 +66,10 @@ function getColsToMove(allMovingColumns: AgColumn[]): AgColumn[] { movingGroup.getProvidedColumnGroup().getLeafColumns() : movingGroup.getLeafColumns(); - for (const newCol of columnsToMove) { - if (!newCols.includes(newCol)) { + for (let j = 0, n = columnsToMove.length; j < n; ++j) { + const newCol = columnsToMove[j]; + if (!newColsSet.has(newCol)) { + newColsSet.add(newCol); newCols.push(newCol); } } @@ -80,15 +81,12 @@ function getColsToMove(allMovingColumns: AgColumn[]): AgColumn[] { function getLowestFragMove( validMoves: number[], allMovingColumnsOrdered: AgColumn[], - colMoves: ColumnMoveService, - visibleCols: VisibleColsService + colMoves: ColumnMoveService ): { move: number; fragCount: number } | null { // From when we find a move that passes all the rules // Remember what that move would look like in terms of displayed cols // keep going with further moves until we find a different result in displayed output // In this way potentialMoves contains all potential moves over 'hidden' columns - const displayedCols = visibleCols.allCols; - let lowestFragMove: { move: number; fragCount: number } | null = null; let targetOrder: AgColumn[] | null = null; @@ -100,7 +98,7 @@ function getLowestFragMove( continue; } - const displayedOrder = order.filter((col) => displayedCols.includes(col)); + const displayedOrder = order.filter((col) => col.displayed); if (targetOrder === null) { targetOrder = displayedOrder; } else if (!_areEqual(displayedOrder, targetOrder)) { @@ -132,7 +130,7 @@ export function getBestColumnMoveIndexFromXPosition( // could themselves be part of 'married children' groups, which means we need to maintain the order within // the moving list. const allMovingColumnsOrdered = allMovingColumns.slice(); - sortColsLikeCols(colModel.getCols(), allMovingColumnsOrdered); + sortColsLikeCols(colModel, allMovingColumnsOrdered); const validMoves = calculateValidMoves({ movingCols: allMovingColumnsOrdered, @@ -181,7 +179,7 @@ export function getBestColumnMoveIndexFromXPosition( } } - const lowestFragMove = getLowestFragMove(validMoves, allMovingColumnsOrdered, colMoves, visibleCols); + const lowestFragMove = getLowestFragMove(validMoves, allMovingColumnsOrdered, colMoves); if (!lowestFragMove) { // No valid moves found @@ -189,7 +187,7 @@ export function getBestColumnMoveIndexFromXPosition( } const toIndex = lowestFragMove.move; - if (toIndex > colModel.getCols().length - allMovingColumnsOrdered.length) { + if (toIndex > colModel.colsList.length - allMovingColumnsOrdered.length) { return; } @@ -214,14 +212,25 @@ export function attemptMoveColumns( // returns the index of the first column in the list ONLY if the cols are all beside // each other. if the cols are not beside each other, then returns null function calculateOldIndex(movingCols: AgColumn[], colModel: ColumnModel): number | null { - const gridCols: AgColumn[] = colModel.getCols(); - const indexes = movingCols.map((col) => gridCols.indexOf(col)).sort((a, b) => a - b); - const firstIndex = indexes[0]; - const lastIndex = _last(indexes); - const spread = lastIndex - firstIndex; - const gapsExist = spread !== indexes.length - 1; - - return gapsExist ? null : firstIndex; + const len = movingCols.length; + if (len === 0) { + return null; + } + colModel.ensureColsListIndex(); + const first = movingCols[0]; + let min = first.inColsList ? first.colsListIndex : -1; + let max = min; + for (let i = 1; i < len; ++i) { + const col = movingCols[i]; + const idx = col.inColsList ? col.colsListIndex : -1; + if (idx < min) { + min = idx; + } else if (idx > max) { + max = idx; + } + } + // Adjacent iff the indexes form a gap-free, dup-free run (span === count - 1); returns the leftmost index. + return max - min === len - 1 ? min : null; } // A measure of how fragmented in terms of groups an order of columns is @@ -261,6 +270,14 @@ function getDisplayedColumns(visibleCols: VisibleColsService, type: ColumnPinned } } +function notDisplayedInSection(col: AgColumn | undefined, section: ColumnPinnedType): boolean { + if (!col?.displayed) { + return true; // not displayed in any section + } + const p = col.pinned; + return section === 'left' || section === 'right' ? p !== section : p != null; +} + function calculateValidMoves(params: { movingCols: AgColumn[]; draggingRight: boolean; @@ -280,11 +297,12 @@ function calculateValidMoves(params: { const allDisplayedCols = getDisplayedColumns(visibleCols, pinned); // but this list is the list of all cols, when we move a col it's the index within this list that gets used, // so the result we return has to be and index location for this list - const allGridCols = colModel.getCols(); + const allGridCols = colModel.colsList; + const movingColsSet = new Set(movingCols); - const movingDisplayedCols = allDisplayedCols.filter((col) => movingCols.includes(col)); - const otherDisplayedCols = allDisplayedCols.filter((col) => !movingCols.includes(col)); - const otherGridCols = allGridCols.filter((col) => !movingCols.includes(col)); + const movingDisplayedCols = allDisplayedCols.filter((col) => movingColsSet.has(col)); + const otherDisplayedCols = allDisplayedCols.filter((col) => !movingColsSet.has(col)); + const otherGridCols = allGridCols.filter((col) => !movingColsSet.has(col)); // work out how many DISPLAYED columns fit before the 'x' position. this gives us the displayIndex. // for example, if cols are a,b,c,d and we find a,b fit before 'x', then we want to place the moving @@ -369,7 +387,7 @@ function calculateValidMoves(params: { let displacedCol = allGridCols[pointer]; // takes into account visible=false and group=closed, ie it is not displayed - while (pointer <= lastIndex && allDisplayedCols.indexOf(displacedCol) < 0) { + while (pointer <= lastIndex && notDisplayedInSection(displacedCol, pinned)) { pointer++; validMoves.push(pointer); displacedCol = allGridCols[pointer]; diff --git a/packages/ag-grid-community/src/columnResize/columnResizeService.ts b/packages/ag-grid-community/src/columnResize/columnResizeService.ts index 85974f49229..3b6fc4c8cb8 100644 --- a/packages/ag-grid-community/src/columnResize/columnResizeService.ts +++ b/packages/ag-grid-community/src/columnResize/columnResizeService.ts @@ -34,7 +34,7 @@ export class ColumnResizeService extends BeanStub implements NamedBean { const { colModel, gos, visibleCols } = this.beans; for (const columnWidth of columnWidths) { - const col = colModel.getColDefColOrCol(columnWidth.key); + const col = colModel.getCol(columnWidth.key); if (!col) { continue; @@ -112,8 +112,8 @@ export class ColumnResizeService extends BeanStub implements NamedBean { // keep track of pixels used, and last column gets the remaining, // to cater for rounding errors, and min width adjustments - const newWidths: { [colId: string]: number } = {}; - const finishedCols: { [colId: string]: boolean } = {}; + const newWidths: { [colId: string]: number } = Object.create(null); + const finishedCols: { [colId: string]: boolean } = Object.create(null); for (const col of columns) { allResizedCols.push(col); @@ -214,8 +214,7 @@ export class ColumnResizeService extends BeanStub implements NamedBean { resizingCols: allResizedCols, skipSetLeft: true, }) ?? []; - visibleCols.setLeftValues(source); - visibleCols.updateBodyWidths(); + visibleCols.updateBodyWidths(visibleCols.setLeftValues(source)); colViewport.checkViewportColumns(); } diff --git a/packages/ag-grid-community/src/columnResize/groupResizeFeature.ts b/packages/ag-grid-community/src/columnResize/groupResizeFeature.ts index e2b0ea38905..2d6affd2b9d 100644 --- a/packages/ag-grid-community/src/columnResize/groupResizeFeature.ts +++ b/packages/ag-grid-community/src/columnResize/groupResizeFeature.ts @@ -1,6 +1,8 @@ +import type { VisibleColsService } from '../columns/visibleColsService'; import { BeanStub } from '../context/beanStub'; import type { AgColumn } from '../entities/agColumn'; import type { AgColumnGroup } from '../entities/agColumnGroup'; +import { edgeLeafColumn, getColGroupAtLevel } from '../entities/agColumnGroup'; import type { ColumnEventType } from '../events'; import type { IHeaderResizeFeature } from '../headerRendering/cells/abstractCell/abstractHeaderCellCtrl'; import type { IHeaderGroupCellComp } from '../headerRendering/cells/columnGroup/headerGroupCellCtrl'; @@ -105,7 +107,7 @@ export class GroupResizeFeature extends BeanStub implements IHeaderResizeFeature let groupAfter: AgColumnGroup | null = null; if (shiftKey) { - groupAfter = this.beans.colGroupSvc?.getGroupAtDirection(this.columnGroup, 'After') ?? null; + groupAfter = getColGroupAfter(this.beans.visibleCols, this.columnGroup); } if (groupAfter) { @@ -208,7 +210,7 @@ export class GroupResizeFeature extends BeanStub implements IHeaderResizeFeature private normaliseDragChange(dragChange: number): number { let result = dragChange; const { columnGroup } = this; - const firstDisplayedLeafCol = columnGroup.getDisplayedLeafColumns()[0]; + const firstDisplayedLeafCol = edgeLeafColumn(columnGroup, true, false); const pinned = firstDisplayedLeafCol?.getPinned() ?? columnGroup.getPinned(); if (this.gos.get('enableRtl')) { @@ -234,3 +236,21 @@ export class GroupResizeFeature extends BeanStub implements IHeaderResizeFeature this.resizeTakeFromRatios = undefined; } } + +/** Scan leaf-by-leaf from `columnGroup`'s trailing edge to the adjacent displayed group at the same level. */ +const getColGroupAfter = (visibleCols: VisibleColsService, columnGroup: AgColumnGroup): AgColumnGroup | null => { + const requiredLevel = columnGroup.providedColumnGroup.level + columnGroup.getPaddingLevel(); + let col = edgeLeafColumn(columnGroup, true, true); + while (col) { + const column = visibleCols.getColAfter(col); + if (!column) { + return null; + } + const groupPointer = getColGroupAtLevel(column, requiredLevel); + if (groupPointer !== columnGroup) { + return groupPointer; + } + col = column; + } + return null; +}; diff --git a/packages/ag-grid-community/src/columns/baseColsService.ts b/packages/ag-grid-community/src/columns/baseColsService.ts deleted file mode 100644 index c6952fefcd5..00000000000 --- a/packages/ag-grid-community/src/columns/baseColsService.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { _removeFromArray } from 'ag-stack'; - -import { BeanStub } from '../context/beanStub'; -import type { BeanCollection } from '../context/context'; -import type { AgColumn } from '../entities/agColumn'; -import type { ColKey } from '../entities/colDef'; -import type { ColumnEvent, ColumnEventType } from '../events'; -import type { IAggFuncService } from '../interfaces/iAggFuncService'; -import type { - ColumnExtractors, - ColumnOrdering, - ColumnProcessor, - ColumnProcessors, - IColsService, -} from '../interfaces/iColsService'; -import type { WithoutGridCommon } from '../interfaces/iCommon'; -import type { IGroupHierarchyColService } from '../interfaces/iGroupHierarchyColService'; -import type { ColumnChangedEventType } from './columnApi'; -import { dispatchColumnChangedEvent } from './columnEventUtils'; -import type { ColumnModel, Maybe } from './columnModel'; -import type { ColumnState, ColumnStateParams } from './columnStateUtils'; -import type { VisibleColsService } from './visibleColsService'; - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export abstract class BaseColsService extends BeanStub implements IColsService { - protected colModel: ColumnModel; - protected aggFuncSvc?: IAggFuncService; - protected visibleCols: VisibleColsService; - protected groupHierarchCols?: IGroupHierarchyColService; - protected dispatchColumnChangedEvent = dispatchColumnChangedEvent; - - abstract eventName: ColumnChangedEventType; - abstract columnProcessors: ColumnProcessors; - abstract columnExtractors: ColumnExtractors; - columnOrdering: ColumnOrdering; - - public columns: AgColumn[] = []; - public columnIndexMap: { [key: string]: number } = {}; - - public wireBeans(beans: BeanCollection): void { - this.colModel = beans.colModel; - this.aggFuncSvc = beans.aggFuncSvc; - this.visibleCols = beans.visibleCols; - this.groupHierarchCols = beans.groupHierarchyColSvc; - } - - public sortColumns(compareFn: (a: AgColumn, b: AgColumn) => number): void { - const { groupHierarchCols } = this; - this.columns.sort((a, b) => groupHierarchCols?.compareVirtualColumns(a, b) ?? compareFn(a, b)); - this.updateIndexMap(); - } - - public setColumns(colKeys: ColKey[] | undefined, source: ColumnEventType): void { - this.setColList(colKeys, this.columns, this.eventName, true, true, this.columnProcessors.set, source); - } - - public addColumns(colKeys: ColKey[] | undefined, source: ColumnEventType): void { - this.updateColList(colKeys, this.columns, true, true, this.columnProcessors.add, this.eventName, source); - } - - public removeColumns(colKeys: ColKey[] | undefined, source: ColumnEventType): void { - this.updateColList(colKeys, this.columns, false, true, this.columnProcessors.remove, this.eventName, source); - } - - public getColumnIndex(colId: string): number | undefined { - return this.columnIndexMap[colId]; - } - - protected updateIndexMap = (): void => { - this.columnIndexMap = {}; - this.columns.forEach((col, index) => (this.columnIndexMap[col.getId()] = index)); - }; - - private setColList( - colKeys: ColKey[] = [], - masterList: AgColumn[], - eventName: IColsService['eventName'], - detectOrderChange: boolean, - autoGroupsNeedBuilding: boolean, - columnCallback: ColumnProcessor, - source: ColumnEventType - ): void { - const gridColumns = this.colModel.getCols(); - if (!gridColumns || gridColumns.length === 0) { - return; - } - - const changes: Map = new Map(); - // store all original cols and their index. - masterList.forEach((col, idx) => changes.set(col, idx)); - - masterList.length = 0; - - for (const key of colKeys) { - const column = this.colModel.getColDefCol(key); - if (column) { - masterList.push(column); - } - } - - masterList.forEach((col, idx) => { - const oldIndex = changes.get(col); - // if the column was not in the list, we add it as it's a change - // idx is irrelevant now. - if (oldIndex === undefined) { - changes.set(col, 0); - return; - } - - if (detectOrderChange && oldIndex !== idx) { - // if we're detecting order changes, and the indexes differ, we retain this as it's changed - return; - } - - // otherwise remove this col, as it's unchanged. - changes.delete(col); - }); - - this.updateIndexMap(); - - const primaryCols = this.colModel.getColDefCols(); - - for (const column of primaryCols ?? []) { - const added = masterList.indexOf(column) >= 0; - columnCallback(column, added, source); - } - - if (autoGroupsNeedBuilding) { - this.colModel.refreshCols(false, source); - } - - this.visibleCols.refresh(source); - - this.dispatchColumnChangedEvent(this.eventSvc, eventName, [...changes.keys()], source); - } - - private updateColList( - keys: Maybe[] = [], - masterList: AgColumn[], - actionIsAdd: boolean, - autoGroupsNeedBuilding: boolean, - columnCallback: ColumnProcessor, - eventType: IColsService['eventName'], - source: ColumnEventType - ) { - if (!keys || keys.length === 0) { - return; - } - - let atLeastOne = false; - const updatedCols: Set = new Set(); - - for (const key of keys) { - if (!key) { - continue; - } - const columnToAdd = this.colModel.getColDefCol(key); - if (!columnToAdd) { - continue; - } - updatedCols.add(columnToAdd); - - if (actionIsAdd) { - if (masterList.indexOf(columnToAdd) >= 0) { - continue; - } - masterList.push(columnToAdd); - } else { - const currentIndex = masterList.indexOf(columnToAdd); - if (currentIndex < 0) { - continue; - } - for (let i = currentIndex + 1; i < masterList.length; i++) { - // row indexes of subsequent columns have changed - updatedCols.add(masterList[i]); - } - _removeFromArray(masterList, columnToAdd); - } - - columnCallback(columnToAdd, actionIsAdd, source); - atLeastOne = true; - } - - if (!atLeastOne) { - return; - } - - this.updateIndexMap(); - - if (autoGroupsNeedBuilding) { - this.colModel.refreshCols(false, source); - } - - this.visibleCols.refresh(source); - - const eventColumns = Array.from(updatedCols); - this.eventSvc.dispatchEvent({ - type: eventType, - columns: eventColumns, - column: eventColumns.length === 1 ? eventColumns[0] : null, - source, - } as WithoutGridCommon); - } - - public extractCols(source: ColumnEventType, oldProvidedCols: AgColumn[] = []): AgColumn[] { - const previousCols = this.columns; - const colsWithIndex: AgColumn[] = []; - const colsWithValue: AgColumn[] = []; - - const { setFlagFunc, getIndexFunc, getInitialIndexFunc, getValueFunc, getInitialValueFunc } = - this.columnExtractors; - - const primaryCols = this.colModel.getColDefCols(); - - // go though all cols. - // if value, change - // if default only, change only if new - for (const col of primaryCols ?? []) { - const colIsNew = !oldProvidedCols.includes(col); - const colDef = col.colDef; - - const value = getValueFunc(colDef); - const initialValue = getInitialValueFunc(colDef); - const index = getIndexFunc(colDef); - const initialIndex = getInitialIndexFunc(colDef); - - let include: boolean; - - const valuePresent = value !== undefined; - const indexPresent = index !== undefined; - const initialValuePresent = initialValue !== undefined; - const initialIndexPresent = initialIndex !== undefined; - - if (valuePresent) { - include = value!; // boolean value is guaranteed as attrToBoolean() is used above - } else if (indexPresent) { - if (index === null) { - // if col is new we don't want to use the default / initial if index is set to null. Similarly, - // we don't want to include the property for existing columns, i.e. we want to 'clear' it. - include = false; - } else { - // note that 'null >= 0' evaluates to true which means 'rowGroupIndex = null' would enable row - // grouping if the null check didn't exist above. - include = index >= 0; - } - } else if (colIsNew) { - // as no value or index is 'present' we use the default / initial when col is new - if (initialValuePresent) { - include = initialValue!; - } else if (initialIndexPresent) { - include = initialIndex != null && initialIndex >= 0; - } else { - include = false; - } - } else { - // otherwise include it if included last time, e.g. if we are extracting row group cols and this col - // is an existing row group col (i.e. it exists in 'previousCols') then we should include it. - include = previousCols.indexOf(col) >= 0; - } - - if (include) { - const useIndex = colIsNew ? index != null || initialIndex != null : index != null; - if (useIndex) { - colsWithIndex.push(col); - } else { - colsWithValue.push(col); - } - } - } - - const getIndexForCol = (col: AgColumn): number => { - const colDef = col.colDef; - return getIndexFunc(colDef) ?? getInitialIndexFunc(colDef)!; - }; - - // sort cols with index, and add these first - colsWithIndex.sort((colA, colB) => getIndexForCol(colA) - getIndexForCol(colB)); - - const res: AgColumn[] = []; - - const groupHierarchCols = this.groupHierarchCols; - const addCol = (col: AgColumn) => { - if (groupHierarchCols) { - groupHierarchCols.expandColumnInto(res, col); - } else { - res.push(col); - } - }; - - // Columns with an index specified need to have any virtual hierarchical columns expanded - colsWithIndex.forEach(addCol); - - // next, add columns that were there before and in the same order as they were before, - // so we are preserving order of current grouping of columns that simply have rowGroup=true... - for (const col of previousCols) { - if (colsWithValue.indexOf(col) >= 0) { - // ...with the caveat that each column added also has any associated virtual columns added here - // so they appear before it in the group hierarchy. This is purely a matter of ordering; adding the - // virtual columns here means they will not be added below when iterating over `colsWithValue`. - addCol(col); - } - } - - // lastly put in all remaining cols - for (const col of colsWithValue) { - if (res.indexOf(col) < 0) { - addCol(col); - } - } - - // set flag=false for removed cols - for (const col of previousCols) { - if (res.indexOf(col) < 0) { - setFlagFunc(col, false, source); - } - } - // set flag=true for newly added cols - for (const col of res) { - if (previousCols.indexOf(col) < 0) { - setFlagFunc(col, true, source); - } - } - - this.columns = res; - this.updateIndexMap(); - return this.columns; - } - - public abstract syncColumnWithState( - column: AgColumn, - source: ColumnEventType, - getValue: ( - key1: U, - key2?: S - ) => { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined }, - rowIndex: { [key: string]: number } | null - ): void; - - public restoreColumnOrder( - columnStateAccumulator: { [colId: string]: ColumnState }, - incomingColumnState: { [colId: string]: ColumnState } - ): { [colId: string]: ColumnState } { - const colList = this.columns; - - const primaryCols = this.colModel.getColDefCols(); - if (!colList.length || !primaryCols) { - return columnStateAccumulator; - } - const updatedColIdArray = Object.keys(incomingColumnState); - const updatedColIds = new Set(updatedColIdArray); - const newColIds = new Set(updatedColIdArray); - const allColIds = new Set( - colList - .map((column) => { - const colId = column.colId; - newColIds.delete(colId); - return colId; - }) - .concat(updatedColIdArray) - ); - - const colIdsInOriginalOrder: string[] = []; - const originalOrderMap: { [colId: string]: number } = {}; - let orderIndex = 0; - for (let i = 0; i < primaryCols.length; i++) { - const colId = primaryCols[i].colId; - if (allColIds.has(colId)) { - colIdsInOriginalOrder.push(colId); - originalOrderMap[colId] = orderIndex++; - } - } - - // follow approach in `resetColumnState` - let index = 1000; - let hasAddedNewCols = false; - let lastIndex = 0; - - const enableProp = this.columnOrdering.enableProp; - const initialEnableProp = this.columnOrdering.initialEnableProp; - const indexProp = this.columnOrdering.indexProp; - const initialIndexProp = this.columnOrdering.initialIndexProp; - - const processPrecedingNewCols = (colId: string) => { - const originalOrderIndex = originalOrderMap[colId]; - for (let i = lastIndex; i < originalOrderIndex; i++) { - const newColId = colIdsInOriginalOrder[i]; - if (newColIds.has(newColId)) { - incomingColumnState[newColId][indexProp] = index++; - newColIds.delete(newColId); - } - } - lastIndex = originalOrderIndex; - }; - - for (const column of colList) { - const colId = column.colId; - if (updatedColIds.has(colId)) { - // New col already exists. Add any other new cols that should be before it. - processPrecedingNewCols(colId); - incomingColumnState[colId][indexProp] = index++; - } else { - const colDef = column.colDef; - const missingIndex = - colDef[indexProp] === null || (colDef[indexProp] === undefined && colDef[initialIndexProp] == null); - if (missingIndex) { - if (!hasAddedNewCols) { - const propEnabled = - colDef[enableProp] || (colDef[enableProp] === undefined && colDef[initialEnableProp]); - if (propEnabled) { - processPrecedingNewCols(colId); - } else { - // Reached the first manually added column. Add all the new columns now. - for (const newColId of newColIds) { - // Rather than increment the index, just use the original order index - doesn't need to be contiguous. - incomingColumnState[newColId][indexProp] = index + originalOrderMap[newColId]; - } - index += colIdsInOriginalOrder.length; - hasAddedNewCols = true; - } - } - if (!columnStateAccumulator[colId]) { - columnStateAccumulator[colId] = { colId }; - } - columnStateAccumulator[colId][indexProp] = index++; - } - } - } - - return columnStateAccumulator; - } -} diff --git a/packages/ag-grid-community/src/columns/baseSingleColService.ts b/packages/ag-grid-community/src/columns/baseSingleColService.ts new file mode 100644 index 00000000000..c06dbcd5747 --- /dev/null +++ b/packages/ag-grid-community/src/columns/baseSingleColService.ts @@ -0,0 +1,68 @@ +import { BeanStub } from '../context/beanStub'; +import { AgColumn } from '../entities/agColumn'; +import type { ColKind } from '../entities/agColumn'; +import type { ColDef } from '../entities/colDef'; +import type { ColumnEventType } from '../events'; +import { _applyColumnState } from './columnStateUtils'; +import { _getColumnStateFromColDef } from './columnUtils'; + +/** Base for services owning a single optional generated column (selection, row-numbers); shared create/refresh/teardown. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export abstract class BaseSingleColService extends BeanStub { + /** The owned column, or null when disabled. Only ever 0 or 1 column — singular by design, no array allocation. */ + public column: AgColumn | null = null; + + /** The `AgColumn` kind discriminator for the generated column. */ + protected abstract readonly colKind: ColKind; + + /** Whether the column should currently exist, from grid options. */ + public abstract isEnabled(): boolean; + + /** Build the colDef for the column from current grid options. */ + protected abstract createColDef(): ColDef; + + public override destroy(): void { + this.destroyColumn(); + super.destroy(); + } + + /** Generate or destroy the column based on current options. */ + public refreshCols(): AgColumn | null { + const want = this.isEnabled(); + const existing = this.column; + if (want && !existing) { + const colDef = this.createColDef(); + const colId = colDef.colId!; + this.gos.validateColDef(colDef, colId, true); + const col = new AgColumn(colDef, null, colId, false, this.colKind); + this.beans.context.createBean(col); + this.column = col; + return col; + } + if (!want && existing) { + this.destroyColumn(); + return null; + } + return existing; + } + + /** Rebuild the colDef on the existing column and re-apply its state. No-op when the column doesn't exist. */ + protected refreshColDef(source: ColumnEventType): void { + const col = this.column; + if (col) { + const colDef = this.createColDef(); + col.setColDef(colDef, null, source); + _applyColumnState(this.beans, { state: [_getColumnStateFromColDef(colDef, col.colId)] }, source); + } + } + + protected destroyColumn(): void { + const existing = this.column; + if (existing) { + this.column = null; + if (existing.isAlive()) { + existing.destroy(); + } + } + } +} diff --git a/packages/ag-grid-community/src/columns/buildColumnTree.ts b/packages/ag-grid-community/src/columns/buildColumnTree.ts new file mode 100644 index 00000000000..2cd5b7dfc97 --- /dev/null +++ b/packages/ag-grid-community/src/columns/buildColumnTree.ts @@ -0,0 +1,505 @@ +import type { BeanCollection } from '../context/context'; +import type { AgColumn } from '../entities/agColumn'; +import { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; +import type { ColDef, ColGroupDef } from '../entities/colDef'; +import type { ColumnEventType } from '../events'; +import { _mergedEqual } from '../utils/mergeDeep'; +import { _warn } from '../validation/logging'; +import { _createUserColumn } from './colDefUtils'; +import type { ColWrapperCache } from './columnGroups/colWrapperCache'; + +/** Opaque edit session over a build's leaves; splicing is enterprise-only (hierarchy/calc cols). + * Community only calls {@link commit} from `finalizeColumnTree`. @internal AG_GRID_INTERNAL */ +export interface ColumnTreeEdit { + commit(build: ColumnTreeBuild): void; +} + +/** Result of {@link _buildColumnTree}; the mutable tree spliced across one rebuild, emitted by + * `finalizeColumnTree`. At depth 0 `columnTree` === `columns`. @internal AG_GRID_INTERNAL */ +export interface ColumnTreeBuild { + columnTree: (AgColumn | AgProvidedColumnGroup)[]; + treeDepth: number; + columns: AgColumn[]; + /** Every group built/reused (padding + non-padding); fed back as the next build's sweep input. */ + allGroups: AgProvidedColumnGroup[]; + marryChildren: boolean; + /** Non-padding groups by `groupId`; fed back as next call's `existingGroupsById`. */ + groupsById: Map; + /** Cols keyed by `colId` / `userProvidedColDef` ref / `field`; for O(1) reuse. */ + colsByKey: Map; + source: ColumnEventType; + buildToken: number; + /** Padding-wrapper cache for the editable (hierarchy/calc) path; `null` for pivot result trees + * (a one-shot build that never splices). */ + wrapperCache: ColWrapperCache | null; + /** Open edit session; null until the first splice. */ + edit?: ColumnTreeEdit | null; +} + +/** Build a balanced column tree from `defs`, reusing cols/groups by colId / field / userColDef ref / + * groupId. Id allocation is deterministic (master/slave grids produce identical ids). Static calc-col + * overrides ({@link ICalculatedColumnsService.overrideFor}) drop/replace a leaf mid-build, never its group. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _buildColumnTree( + beans: BeanCollection, + defs: (ColDef | ColGroupDef)[] | null | undefined, + primaryColumns: boolean, + existingGroupsById: Map, + existingColsByKey: Map, + existingColsById: { readonly [id: string]: AgColumn }, + source: ColumnEventType, + buildToken: number, + wrapperCache: ColWrapperCache | null +): ColumnTreeBuild { + const { context, dataTypeSvc, gos, calculatedColsSvc } = beans; + const defaultColGroupDef = gos.get('defaultColGroupDef'); + // Stateless padded groups share one colGroupDef ref; the cast is safe (padding owns its own `children`). + const paddingDef = (defaultColGroupDef ?? null) as ColGroupDef | null; + // Reserved = ids allocated this call + every live colId (primary / pivot / service / hierarchy). + const allocatedKeys = new Set(); + const reservedUserKeys = new Set(); + const isReserved = (id: string): boolean => allocatedKeys.has(id) || id in existingColsById; + + // Pre-walk for maxDepth + anonymous-slot count. Padded ids start AFTER anonymous-col ids + // (`'0'..''` vs `''+`) so the streams stay disjoint — needed for master/slave determinism. + let treeDepth = 0; + let paddedIdHint = 0; + if (defs) { + const measure = (nodes: (ColDef | ColGroupDef)[], level: number): void => { + if (level > treeDepth) { + treeDepth = level; + } + for (let i = 0, len = nodes.length; i < len; ++i) { + const def = nodes[i]; + const childDefs = (def as ColGroupDef).children; + if (childDefs) { + if ((def as ColGroupDef).groupId == null) { + ++paddedIdHint; + } + measure(childDefs, level + 1); + } else { + const base = (def as ColDef).colId ?? (def as ColDef).field; + if (base == null) { + ++paddedIdHint; + } else { + reservedUserKeys.add(base); + } + } + } + }; + measure(defs, 0); + + // Reserve reusable padding-chain ids before any allocation: a reused chain keeps its prior id, so a + // fresh group must not be handed it (keeps allocation collision-free regardless of def order). + if (treeDepth > 0) { + const reserveReusedPaddingIds = (nodes: (ColDef | ColGroupDef)[]): void => { + for (let i = 0, len = nodes.length; i < len; ++i) { + const def = nodes[i]; + const childDefs = (def as ColGroupDef).children; + if (childDefs) { + reserveReusedPaddingIds(childDefs); + continue; + } + const colDef = def as ColDef; + const existing = existingColsByKey.get(colDef.colId ?? colDef.field ?? def); + let node = existing ? innermostPaddingHead(existing, treeDepth) : null; + while (node?.padding) { + allocatedKeys.add(node.groupId); + node = node.originalParent; + } + } + }; + reserveReusedPaddingIds(defs); + } + } + + let userIdHint = 0; + const getUniqueKey = (groupId: string | null | undefined): string => { + let id: string; + if (groupId == null) { + do { + id = `${userIdHint++}`; + } while (isReserved(id) || reservedUserKeys.has(id)); + allocatedKeys.add(id); + return id; + } + if (!isReserved(groupId)) { + allocatedKeys.add(groupId); + return groupId; + } + let count = 1; + do { + id = `${groupId}_${count++}`; + } while (isReserved(id)); + _warn(273, { providedId: groupId, usedId: id }); + allocatedKeys.add(id); + return id; + }; + + const columns: AgColumn[] = []; + // The next build's sweep walks this rather than `.children`, which would miss orphans when a + // parent's array was replaced. + const allGroups: AgProvidedColumnGroup[] = []; + let hasMarryChildren = false; + const newColsByKey = new Map(); + + const isReusableUserCol = (col: AgColumn): boolean => + col.colKind === 'user' && col.primary === primaryColumns && col.buildToken !== buildToken; + + /** Reuse/create an anonymous leaf (no colId/field) after the ref missed. Positional reuse on the + * `userIdHint` stream keeps a recreated `{...}` def on its id instead of drifting (losing state). */ + const buildAnonymousColumn = (def: ColDef): AgColumn => { + while (true) { + const id = `${userIdHint++}`; + if (allocatedKeys.has(id) || reservedUserKeys.has(id)) { + continue; + } + const existing = existingColsById[id]; + if (existing !== undefined) { + const userDef = existing.userProvidedColDef; + if (isReusableUserCol(existing) && userDef && userDef.colId == null && userDef.field == null) { + allocatedKeys.add(id); + existing.buildToken = buildToken; + existing.reapplyColDef(def, source); + return existing; + } + continue; + } + allocatedKeys.add(id); + return _createUserColumn(beans, def, id, primaryColumns, buildToken); + } + }; + + /** Reuse/create a keyed (`colId`/`field`) leaf after the ref missed: key for the unique case, else a + * positional scan so a replaced-ref duplicate keeps its state. `buildToken` skips a claimed col. */ + const buildKeyedColumn = (def: ColDef, colId: string | undefined, field: string | undefined): AgColumn => { + const base = colId ?? field!; + const keyed = existingColsByKey.get(base); + if (keyed?.colId === base && isReusableUserCol(keyed)) { + keyed.buildToken = buildToken; + keyed.reapplyColDef(def, source); + return keyed; + } + let count = 0; + while (true) { + const id = count === 0 ? base : `${base}_${count}`; + count++; + if (allocatedKeys.has(id)) { + continue; + } + const existing = existingColsById[id]; + if (existing !== undefined) { + const existingDef = existing.userProvidedColDef; + const existingBase = existingDef ? (existingDef.colId ?? existingDef.field) : null; + if (existingBase !== base || !isReusableUserCol(existing)) { + continue; // a different col owns this id, it's already claimed, or it's not a reusable user col + } + } + allocatedKeys.add(id); + if (colId != null && id !== colId) { + _warn(273, { providedId: colId, usedId: id }); // colId collided; suffixed + } + if (existing !== undefined) { + existing.buildToken = buildToken; + existing.reapplyColDef(def, source); + return existing; + } + return _createUserColumn(beans, def, id, primaryColumns, buildToken); + } + }; + + /** Build/reuse a leaf for `def` (or `undefined` if a calc-col override dropped it). Identity is the + * `colId` when present (reuse only the same-colId column — a changed colId is a new column even on a + * retained colDef ref); without a colId it is the colDef ref, then field/positional in buildKeyedColumn. */ + const buildColumn = (def: ColDef): AgColumn | undefined => { + const override = calculatedColsSvc?.overrideFor(def); + if (override === null) { + return undefined; // dropped (e.g. a calc col the user deleted): never built + } + if (override !== undefined) { + def = override; + } + const colId = def.colId; + let column: AgColumn | undefined; + if (colId != null) { + const byId = existingColsByKey.get(colId); + column = byId?.colId === colId && isReusableUserCol(byId) ? byId : undefined; + } else { + const byRef = existingColsByKey.get(def); + column = byRef !== undefined && isReusableUserCol(byRef) ? byRef : undefined; + } + if (column !== undefined) { + column.buildToken = buildToken; + column.reapplyColDef(def, source); + } else { + const field = def.field; + column = colId == null && field == null ? buildAnonymousColumn(def) : buildKeyedColumn(def, colId, field); + } + // Hierarchy / service cols have no `userProvidedColDef` and are never looked up by ref / field. + const userDef = column.userProvidedColDef; + if (userDef) { + newColsByKey.set(column.colId, column); + if (!newColsByKey.has(userDef)) { + newColsByKey.set(userDef, column); + } + const userField = userDef.field; + if (userField && !newColsByKey.has(userField)) { + newColsByKey.set(userField, column); + } + } + dataTypeSvc?.addColumnListeners(column); + return column; + }; + + // Non-padding groups by `groupId`; exposed via ColumnModel for group-lookup hot paths. + const newGroupsById = new Map(); + + if (treeDepth === 0) { + // Flat case: `columnTree` and `columns` share one array. + const flat: AgColumn[] = []; + const len = defs?.length ?? 0; + for (let i = 0; i < len; ++i) { + const col = buildColumn(defs![i] as ColDef); + if (col === undefined) { + continue; // removed leaf: never built + } + col.originalParent = null; + flat.push(col); + } + return { + columnTree: flat, + treeDepth: 0, + columns: flat, + allGroups, + marryChildren: false, + groupsById: newGroupsById, + colsByKey: newColsByKey, + source, + buildToken, + wrapperCache, + }; + } + + /** Reuse `col`'s prior padding chain when valid. Atomic chain builds mean validating only the + * innermost padding suffices — the rest follows by invariant. */ + const tryReusePaddingChain = ( + col: AgColumn, + level: number, + parent: AgProvidedColumnGroup | null + ): AgProvidedColumnGroup | null => { + let node = innermostPaddingHead(col, treeDepth); + if (node === null || node.buildToken === buildToken) { + return null; + } + while (node.level > level) { + node.buildToken = buildToken; + allGroups.push(node); + node = node.originalParent!; + } + node.buildToken = buildToken; + allGroups.push(node); + node.originalParent = parent; + return node; + }; + + if (paddingDef) { + gos.validateColDef(paddingDef, ''); + } + + /** Wrap `payload` in synthetic padded groups from `level` up to `maxDepth`; returns the OUTERMOST. */ + const buildPaddedChain = ( + level: number, + parent: AgProvidedColumnGroup | null, + payload: AgColumn[] + ): AgProvidedColumnGroup => { + let outer: AgProvidedColumnGroup | undefined; + let current: AgProvidedColumnGroup | undefined; + for (let j = level; j < treeDepth; ++j) { + let newId: string; + do { + newId = `${paddedIdHint++}`; + } while (isReserved(newId) || reservedUserKeys.has(newId)); + allocatedKeys.add(newId); + const padded = new AgProvidedColumnGroup(paddingDef, newId, true, j); + padded.buildToken = buildToken; + context.createBean(padded); + allGroups.push(padded); + if (current) { + current.children = [padded]; + current.setExpandable(); + padded.originalParent = current; + } else { + padded.originalParent = parent; + outer = padded; + } + current = padded; + } + const innermost = current!; + innermost.children = payload; + innermost.setExpandable(); + for (let i = 0, n = payload.length; i < n; ++i) { + payload[i].originalParent = innermost; + } + return outer!; + }; + + /** Reuse an existing non-padded group matching `userGroupId` with a structurally identical merged + * def (ignoring `children`). `null` when no candidate; else `{ reused, existing }` with `existing` + * always set and `reused` null when the candidate's def changed (caller carries `expanded` over). */ + const tryReuseGroup = ( + userGroupId: string | undefined, + merged: ColGroupDef + ): { reused: AgProvidedColumnGroup | null; existing: AgProvidedColumnGroup } | null => { + if (userGroupId == null) { + return null; + } + const candidate = existingGroupsById.get(userGroupId); + if (!candidate || candidate.buildToken === buildToken || candidate.padding) { + return null; + } + if (allocatedKeys.has(candidate.groupId) || !_mergedEqual(merged, candidate.colGroupDef, 'children')) { + // Rejected: leave `buildToken` stale so the post-build sweep destroys this orphan. + return { reused: null, existing: candidate }; + } + candidate.buildToken = buildToken; // stamp only on success — also the "already claimed" guard + // Refresh def ref so `getColGroupDef().children` reflects current children (excluded from compare). + candidate.colGroupDef = merged; + allocatedKeys.add(candidate.groupId); + return { reused: candidate, existing: candidate }; + }; + + /** Single-pass tree build: cols/groups, padding, parent wiring, `setExpandable`. */ + const buildSubtree = ( + defs: (ColDef | ColGroupDef)[], + level: number, + parent: AgProvidedColumnGroup | null + ): (AgColumn | AgProvidedColumnGroup)[] => { + const len = defs.length; + if (len === 0) { + return []; + } + const needsPadding = level < treeDepth; + // All-leaves shortcut: one shared padded chain instead of one chain per col. + if (needsPadding) { + let allLeaves = true; + for (let i = 0; i < len; ++i) { + if ((defs[i] as ColGroupDef).children !== undefined) { + allLeaves = false; + break; + } + } + if (allLeaves) { + const wrapped: AgColumn[] = []; + for (let i = 0; i < len; ++i) { + const col = buildColumn(defs[i] as ColDef); + if (col === undefined) { + continue; // removed leaf: never built + } + columns.push(col); + wrapped.push(col); + } + if (wrapped.length === 0) { + return wrapped; // every leaf removed — nothing to wrap (empty array) + } + // Reuse the chain only when every col still shares one innermost padding (this + // all-leaves chain existed last refresh); refresh children on reuse. + const sharedInnermost = wrapped[0].originalParent; + let allShareChain = sharedInnermost !== null; + if (allShareChain) { + for (let i = 1, wLen = wrapped.length; i < wLen; ++i) { + if (wrapped[i].originalParent !== sharedInnermost) { + allShareChain = false; + break; + } + } + } + if (allShareChain) { + const reusedTop = tryReusePaddingChain(wrapped[0], level, parent); + if (reusedTop !== null) { + sharedInnermost!.children = wrapped; + for (let i = 0, wLen = wrapped.length; i < wLen; ++i) { + wrapped[i].originalParent = sharedInnermost!; + } + return [reusedTop]; + } + } + return [buildPaddedChain(level, parent, wrapped)]; + } + } + const result: (AgColumn | AgProvidedColumnGroup)[] = []; + for (let i = 0; i < len; ++i) { + const def = defs[i]; + const groupChildren = (def as ColGroupDef).children; + if (groupChildren) { + const groupDef = def as ColGroupDef; + const userGroupId = groupDef.groupId; + const merged: ColGroupDef = { ...defaultColGroupDef, ...groupDef }; + + const candidate = tryReuseGroup(userGroupId, merged); + const reused = candidate?.reused; + let group: AgProvidedColumnGroup; + if (reused) { + reused.level = level; + group = reused; + } else { + const groupId = getUniqueKey(userGroupId); + gos.validateColDef(merged, groupId); + group = new AgProvidedColumnGroup(merged, groupId, false, level); + group.buildToken = buildToken; + context.createBean(group); + // Preserve expand state: a recreated (e.g. generated-id) group copies it from the prior same-id group. + const prior = candidate?.existing ?? existingGroupsById.get(groupId); + if (prior) { + group.setExpanded(prior.expanded); + } + } + group.children = buildSubtree(groupChildren, level + 1, group); + group.originalParent = parent; + group.setExpandable(); + hasMarryChildren ||= !!merged.marryChildren; + newGroupsById.set(group.groupId, group); + allGroups.push(group); + result.push(group); + } else { + const col = buildColumn(def as ColDef); + if (col === undefined) { + continue; // removed leaf: never built + } + columns.push(col); + if (needsPadding) { + // Naked col in a mixed level — pad under its own chain to preserve def order. + result.push(tryReusePaddingChain(col, level, parent) ?? buildPaddedChain(level, parent, [col])); + } else { + col.originalParent = parent; + result.push(col); + } + } + } + return result; + }; + + const columnTree = buildSubtree(defs!, 0, null); + + return { + columnTree, + treeDepth, + columns, + allGroups, + marryChildren: hasMarryChildren, + groupsById: newGroupsById, + colsByKey: newColsByKey, + source, + buildToken, + wrapperCache, + }; +} + +const innermostPaddingHead = (col: AgColumn, maxDepth: number): AgProvidedColumnGroup | null => { + const node = col.originalParent; + return node != null && node.padding && node.level === maxDepth - 1 ? node : null; +}; + +export const finalizeColumnTree = (build: ColumnTreeBuild): void => { + build.edit?.commit(build); + build.wrapperCache?.evict(build.buildToken); +}; diff --git a/packages/ag-grid-community/src/columns/colDefUtils.ts b/packages/ag-grid-community/src/columns/colDefUtils.ts new file mode 100644 index 00000000000..73cf97e2fbc --- /dev/null +++ b/packages/ag-grid-community/src/columns/colDefUtils.ts @@ -0,0 +1,116 @@ +import type { BeanCollection } from '../context/context'; +import { AgColumn } from '../entities/agColumn'; +import type { ColDef } from '../entities/colDef'; +import { DefaultColumnTypes } from '../entities/defaultColumnTypes'; +import { _isColumnsSortingCoupledToGroup } from '../gridOptionsUtils'; +import { _mergeDeep } from '../utils/mergeDeep'; +import { _warn } from '../validation/logging'; +import { convertColumnTypes } from './columnUtils'; + +/** Constructs + registers a primary ('user'-kind) column from a user colDef: merges defaults/types, + * stamps the build token, registers the bean. Sole birthplace for build/calc columns. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _createUserColumn( + beans: BeanCollection, + userColDef: ColDef, + colId: string, + isPrimary: boolean, + buildToken: number +): AgColumn { + const merged = _addColumnDefaultAndTypes(beans, userColDef, colId); + const column = new AgColumn(merged, userColDef, colId, isPrimary, 'user'); + column.buildToken = buildToken; + beans.context.createBean(column); + return column; +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _addColumnDefaultAndTypes( + beans: BeanCollection, + colDef: ColDef, + colId: string, + isAutoCol?: boolean +): ColDef { + const { gos, dataTypeSvc } = beans; + const res: ColDef = {} as ColDef; + + const defaultColDef = gos.get('defaultColDef'); + _mergeDeep(res, defaultColDef, false, true); + + const dataTypeDefinitionColumnType = dataTypeSvc?.updateColDefAndGetColumnType(res, colDef, colId); + const columnTypes = colDef.type ?? dataTypeDefinitionColumnType ?? res.type; + res.type = columnTypes; + if (columnTypes) { + assignColumnTypes(beans, convertColumnTypes(columnTypes), res); + } + + const cellDataType = res.cellDataType; + + _mergeDeep(res, colDef, false, true); + + if (cellDataType !== undefined) { + // `cellDataType: true` in provided def would overwrite inferred result type otherwise + res.cellDataType = cellDataType; + } + + const autoGroupColDef = gos.get('autoGroupColumnDef'); + if (autoGroupColDef && colDef.rowGroup && _isColumnsSortingCoupledToGroup(gos)) { + _mergeDeep( + res, + { sort: autoGroupColDef.sort, initialSort: autoGroupColDef.initialSort } satisfies Partial, + false, + true + ); + } + + dataTypeSvc?.postProcess(res); + dataTypeSvc?.validateColDef(res, colDef, defaultColDef, colId); + gos.validateColDef(res, colId, isAutoCol); + + return res; +} + +function assignColumnTypes(beans: BeanCollection, typeKeys: string[], colDefMerged: ColDef): void { + const typeKeysLen = typeKeys.length; + if (typeKeysLen === 0) { + return; + } + const userTypes = beans.gos.get('columnTypes'); + // Fast path: no user types — read `DefaultColumnTypes` directly, skipping the merged-map copy and validation walk. + if (userTypes == null) { + mergeTypeKeys(colDefMerged, typeKeys, typeKeysLen, DefaultColumnTypes); + return; + } + const allColumnTypes = { ...DefaultColumnTypes }; + const userKeys = Object.keys(userTypes); + for (let i = 0, len = userKeys.length; i < len; ++i) { + const key = userKeys[i]; + const value = userTypes[key]; + if (key in allColumnTypes) { + _warn(34, { key }); // default column types cannot be overridden + } else { + if ((value as any).type) { + _warn(35); // type should not be defined in column types + } + allColumnTypes[key] = value; + } + } + mergeTypeKeys(colDefMerged, typeKeys, typeKeysLen, allColumnTypes); +} + +function mergeTypeKeys( + colDefMerged: ColDef, + typeKeys: string[], + typeKeysLen: number, + typeMap: { [key: string]: ColDef } +): void { + for (let i = 0; i < typeKeysLen; ++i) { + const t = typeKeys[i].trim(); + const typeColDef = typeMap[t]; + if (typeColDef) { + _mergeDeep(colDefMerged, typeColDef, false, true); + } else { + _warn(36, { t }); + } + } +} diff --git a/packages/ag-grid-community/src/columns/colsApplyPrevOrder.ts b/packages/ag-grid-community/src/columns/colsApplyPrevOrder.ts new file mode 100644 index 00000000000..8d1d6c6b94a --- /dev/null +++ b/packages/ag-grid-community/src/columns/colsApplyPrevOrder.ts @@ -0,0 +1,207 @@ +import { _pushToMapArray } from 'ag-stack'; + +import type { AgColumn } from '../entities/agColumn'; +import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; + +export const applyPrevColumnsOrder = ( + colsList: AgColumn[], + colsById: Record, + prevOrder: string[] +): AgColumn[] => { + const colsListLen = colsList.length; + const prevOrderLen = prevOrder.length; + // Fast path: same length and colId at every index -> already ordered (width/sort/hide-only refresh). + if (colsListLen === prevOrderLen) { + let inOrder = true; + for (let i = 0; i < prevOrderLen; ++i) { + if (colsList[i].colId !== prevOrder[i]) { + inOrder = false; + break; + } + } + if (inOrder) { + return colsList; + } + } + // Phase 1: resolve prevOrder colIds to live cols. In-order walk keeps positions monotonic, so + // `groupHighestLeaf` ends holding each group's highest leaf (the O(1) answer findPreviousSibling needs). + const preservedOrder: AgColumn[] = []; + const colPositionMap = new Map(); + const groupHighestLeaf = new Map(); + for (let i = 0; i < prevOrderLen; ++i) { + const current = colsById[prevOrder[i]]; + if (current != null) { + colPositionMap.set(current, preservedOrder.length); + preservedOrder.push(current); + let g = current.originalParent; + while (g != null) { + groupHighestLeaf.set(g, current); + g = g.originalParent; + } + } + } + if (preservedOrder.length === colsListLen) { + return preservedOrder; // all preserved — order already correct + } + if (preservedOrder.length === 0) { + return colsList; // no preserved anchors; keep current order (service cols already at head) + } + + // Phase 2: bucket new cols. Service -> head; new calc col -> right after its (preserved) anchor, + // so same-anchor adds stack newest-first; anchor not yet preserved (chained on a sibling added this + // build) -> after `lastPreserved` (else front); unanchored calc -> tail (`endCalc`); rest -> `additionalCols`. + const servicePrepend: AgColumn[] = []; + const additionalCols: AgColumn[] = []; + const frontCalc: AgColumn[] = []; + const endCalc: AgColumn[] = []; + let calcFollowers: Map | null = null; + let lastPreserved: AgColumn | null = null; + for (let i = 0; i < colsListLen; ++i) { + const col = colsList[i]; + if (colPositionMap.has(col)) { + lastPreserved = col; + continue; + } + const colKind = col.colKind; + if (colKind === 'auto-group' || colKind === 'selection' || colKind === 'row-number') { + servicePrepend.push(col); + } else if (col.isCalculatedCol) { + // Gated on `isCalculatedCol` for perf: only calc cols carry `anchoredToColId` today, so we + // skip the field read for every other column (the field itself is column-kind agnostic). + const anchorId = col.anchoredToColId; + const anchor = anchorId != null ? colsById[anchorId] : undefined; + if (anchor !== undefined && colPositionMap.has(anchor)) { + calcFollowers ??= new Map(); + _pushToMapArray(calcFollowers, anchor, col); + } else if (anchorId == null) { + endCalc.push(col); + } else if (lastPreserved === null) { + frontCalc.push(col); + } else { + calcFollowers ??= new Map(); + _pushToMapArray(calcFollowers, lastPreserved, col); + } + } else { + additionalCols.push(col); + } + } + + // Phase 3: resolve group-sibling anchors for non-calc additional cols (skipped for flat colDefs). + let followers: Map | null = null; + let noSiblings: AgColumn[] = additionalCols; + if (additionalCols.length > 0 && anyPreservedHasSiblings(preservedOrder)) { + const partitioned = partitionBySiblings(additionalCols, colPositionMap, groupHighestLeaf); + followers = partitioned.followers; + noSiblings = partitioned.orphans; + } + + // Phase 4: emit forward — service head, front calc, each preserved col with its calc then + // group-sibling followers, then non-calc orphans, then end calc cols. + const result = new Array(colsListLen); + let pos = 0; + for (let i = 0, len = servicePrepend.length; i < len; ++i) { + result[pos++] = servicePrepend[i]; + } + for (let i = 0, len = frontCalc.length; i < len; ++i) { + result[pos++] = frontCalc[i]; + } + for (let i = 0, len = preservedOrder.length; i < len; ++i) { + const col = preservedOrder[i]; + result[pos++] = col; + const calcBucket = calcFollowers?.get(col); + if (calcBucket !== undefined) { + for (let j = 0, m = calcBucket.length; j < m; ++j) { + result[pos++] = calcBucket[j]; + } + } + const bucket = followers?.get(col); + if (bucket !== undefined) { + for (let j = 0, m = bucket.length; j < m; ++j) { + result[pos++] = bucket[j]; + } + } + } + for (let i = 0, len = noSiblings.length; i < len; ++i) { + result[pos++] = noSiblings[i]; + } + for (let i = 0, len = endCalc.length; i < len; ++i) { + result[pos++] = endCalc[i]; + } + return result; +}; + +/** True when any preserved col sits in a group with siblings — i.e. there are anchors to resolve. */ +const anyPreservedHasSiblings = (preservedOrder: AgColumn[]): boolean => { + for (let i = 0, len = preservedOrder.length; i < len; ++i) { + let ancestor = preservedOrder[i].originalParent; + while (ancestor != null) { + if (ancestor.children.length > 1) { + return true; + } + ancestor = ancestor.originalParent; + } + } + return false; +}; + +/** Map each `additionalCols` entry to its previous-refresh sibling anchor: + * `{ followers: anchor -> following cols, orphans: cols with no anchor }`. */ +const partitionBySiblings = ( + additionalCols: AgColumn[], + colPositionMap: Map, + groupHighestLeaf: Map +): { followers: Map; orphans: AgColumn[] } => { + const followers = new Map(); + const orphans: AgColumn[] = []; + for (let i = 0, len = additionalCols.length; i < len; ++i) { + const col = additionalCols[i]; + const anchor = findPreviousSibling(col, colPositionMap, groupHighestLeaf); + if (anchor == null) { + orphans.push(col); + continue; + } + _pushToMapArray(followers, anchor, col); + } + return { followers, orphans }; +}; + +/** Walk up the parent chain for a cousin already in `positionMap`, returning the highest-positioned one. + * `groupHighestLeaf` gives a group subtree's highest leaf in O(1) (vs a recursive leaf walk). */ +const findPreviousSibling = ( + col: AgColumn, + positionMap: Map, + groupHighestLeaf: Map +): AgColumn | null => { + let parent = col.originalParent; + let currentGroup: AgProvidedColumnGroup | null = null; + while (parent != null) { + let highestIdx = -1; + let highestSibling: AgColumn | null = null; + const children = parent.children; + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + if (child === currentGroup || child === col) { + continue; + } + let candidate: AgColumn | undefined; + if (child.isColumn) { + candidate = child; + } else { + candidate = groupHighestLeaf.get(child); + } + if (candidate !== undefined) { + const idx = positionMap.get(candidate); + if (idx !== undefined && idx > highestIdx) { + highestIdx = idx; + highestSibling = candidate; + } + } + } + if (highestSibling != null) { + return highestSibling; + } + currentGroup = parent; + parent = parent.originalParent; + } + return null; +}; diff --git a/packages/ag-grid-community/src/columns/columnApi.ts b/packages/ag-grid-community/src/columns/columnApi.ts index f2f6d35b213..26619146575 100644 --- a/packages/ag-grid-community/src/columns/columnApi.ts +++ b/packages/ag-grid-community/src/columns/columnApi.ts @@ -2,21 +2,19 @@ import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; import type { ColDef, ColGroupDef, ColKey, HeaderLocation } from '../entities/colDef'; import type { Column, ColumnPinnedType } from '../interfaces/iColumn'; -import type { ApplyColumnStateParams, ColumnState } from './columnStateUtils'; -import { _applyColumnState, _getColumnState, _resetColumnState } from './columnStateUtils'; - -export type ColumnChangedEventType = 'columnValueChanged' | 'columnPivotChanged' | 'columnRowGroupChanged'; +import { _applyColumnState, _getColumnState, _resetColumnState, _setColsVisible } from './columnStateUtils'; +import type { ApplyColumnStateParams } from './columnStateUtils'; export function getColumnDef( beans: BeanCollection, key: string | Column ): ColDef | null { - const column = beans.colModel.getColDefColOrCol(key); + const column = beans.colModel.getCol(key); return column ? column.colDef : null; } export function getColumnDefs(beans: BeanCollection): (ColDef | ColGroupDef)[] | undefined { - return beans.colModel.getColumnDefs(true); + return beans.colDefFactory?.getColumnDefs(); } export function getDisplayNameForColumn(beans: BeanCollection, column: Column, location: HeaderLocation): string { @@ -27,35 +25,34 @@ export function getColumn( beans: BeanCollection, key: ColKey ): Column | null { - return beans.colModel.getColDefColOrCol(key); + return beans.colModel.getCol(key) ?? null; } export function getColumns(beans: BeanCollection): Column[] | null { - return beans.colModel.getColDefCols(); + const colModel = beans.colModel; + return colModel.ready ? colModel.colDefList : null; } export function applyColumnState(beans: BeanCollection, params: ApplyColumnStateParams): boolean { return _applyColumnState(beans, params, 'api'); } -export function getColumnState(beans: BeanCollection): ColumnState[] { - return _getColumnState(beans); -} +export const getColumnState = _getColumnState; export function resetColumnState(beans: BeanCollection): void { _resetColumnState(beans, 'api'); } export function isPinning(beans: BeanCollection): boolean { - return beans.visibleCols.isPinningLeft() || beans.visibleCols.isPinningRight(); + return beans.visibleCols.leftCols.length > 0 || beans.visibleCols.rightCols.length > 0; } export function isPinningLeft(beans: BeanCollection): boolean { - return beans.visibleCols.isPinningLeft(); + return beans.visibleCols.leftCols.length > 0; } export function isPinningRight(beans: BeanCollection): boolean { - return beans.visibleCols.isPinningRight(); + return beans.visibleCols.rightCols.length > 0; } export function getDisplayedColAfter(beans: BeanCollection, col: Column): Column | null { @@ -67,7 +64,7 @@ export function getDisplayedColBefore(beans: BeanCollection, col: Column): Colum } export function setColumnsVisible(beans: BeanCollection, keys: (string | Column)[], visible: boolean): void { - beans.colModel.setColsVisible(keys as (string | AgColumn)[], visible, 'api'); + _setColsVisible(beans, keys as (string | AgColumn)[], visible, 'api'); } export function setColumnsPinned(beans: BeanCollection, keys: ColKey[], pinned: ColumnPinnedType): void { @@ -75,7 +72,7 @@ export function setColumnsPinned(beans: BeanCollection, keys: ColKey[], pinned: } export function getAllGridColumns(beans: BeanCollection): Column[] { - return beans.colModel.getCols(); + return beans.colModel.colsList; } export function getDisplayedLeftColumns(beans: BeanCollection): Column[] { diff --git a/packages/ag-grid-community/src/columns/columnDefFactory.ts b/packages/ag-grid-community/src/columns/columnDefFactory.ts index 3e508e97e39..967cbd3c25a 100644 --- a/packages/ag-grid-community/src/columns/columnDefFactory.ts +++ b/packages/ag-grid-community/src/columns/columnDefFactory.ts @@ -2,132 +2,95 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; -import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; import type { ColDef, ColGroupDef } from '../entities/colDef'; -import type { IColsService } from '../interfaces/iColsService'; -import { SKIP_JS_BUILTINS } from '../utils/mergeDeep'; +import { _isPlainObject, _isProtoPollutionKey } from '../utils/mergeDeep'; +import type { ColumnModel } from './columnModel'; -// returns copy of an object, doing a deep clone of any objects with that object. -// this is used for eg creating copies of Column Definitions, where we want to -// deep copy all objects, but do not want to deep copy functions (eg when user provides -// a function or class for colDef.cellRenderer) +// Deep-clones a ColDef; functions/classes (eg cellRenderer) are copied by reference. /** @knipIgnore Used in tests */ -export function _deepCloneDefinition(object: T, keysToSkip?: string[]): T | undefined { +export function _deepCloneDefinition(object: T, rootKeyToSkip?: string): T | undefined { if (!object) { return; } - const obj = object as any; const res: any = {}; - for (const key of Object.keys(obj)) { - if ((keysToSkip && keysToSkip.indexOf(key) >= 0) || SKIP_JS_BUILTINS.has(key)) { + if (key === rootKeyToSkip || _isProtoPollutionKey(key)) { continue; } - const value = obj[key]; - - // 'simple object' means a bunch of key/value pairs, eg {filter: 'myFilter'}. it does - // NOT include the following: - // 1) arrays - // 2) functions or classes (eg api instance) - const sourceIsSimpleObject = typeof value === 'object' && value !== null && value.constructor === Object; - - if (sourceIsSimpleObject) { + if (_isPlainObject(value)) { res[key] = _deepCloneDefinition(value); } else { res[key] = value; } } - return res; } export class ColumnDefFactory extends BeanStub implements NamedBean { beanName = 'colDefFactory' as const; - private rowGroupColsSvc?: IColsService; - private pivotColsSvc?: IColsService; + private colModel: ColumnModel; public wireBeans(beans: BeanCollection): void { - this.rowGroupColsSvc = beans.rowGroupColsSvc; - this.pivotColsSvc = beans.pivotColsSvc; + this.colModel = beans.colModel; } - public getColumnDefs( - colDefColsList: AgColumn[], - showingPivotResult: boolean, - lastOrder: AgColumn[] | null, - colsList: AgColumn[], - sorted: boolean = false - ): (ColDef | ColGroupDef)[] | undefined { - const cols = colDefColsList.slice(); - - if (showingPivotResult) { - cols.sort((a, b) => lastOrder!.indexOf(a) - lastOrder!.indexOf(b)); - } else if (lastOrder || sorted) { - cols.sort((a, b) => colsList.indexOf(a) - colsList.indexOf(b)); + /** Snapshot of the column tree as `ColDef[] | ColGroupDef[]`, display-ordered. Backs `getColumnDefs`. */ + public getColumnDefs(): (ColDef | ColGroupDef)[] | undefined { + const colModel = this.colModel; + if (!colModel.ready) { + return undefined; } + const colDefColsList = colModel.colDefList; + // Pivot primaries keep their pre-pivot `colsListIndex` (absent from the pivot `colsList`) — the order to report. + colModel.ensureColsListIndex(); + const cols = colDefColsList.slice().sort(byColsListIndex); - const rowGroupColumns = this.rowGroupColsSvc?.columns; - const pivotColumns = this.pivotColsSvc?.columns; - - return this.buildColumnDefs(cols, rowGroupColumns, pivotColumns); - } - - private buildColumnDefs( - cols: AgColumn[], - rowGroupColumns: AgColumn[] = [], - pivotColumns: AgColumn[] = [] - ): (ColDef | ColGroupDef)[] { const res: (ColDef | ColGroupDef)[] = []; - - const colGroupDefs: { [id: string]: ColGroupDef } = {}; - - for (const col of cols) { - const colDef = this.createDefFromColumn(col, rowGroupColumns, pivotColumns); + const colGroupDefs: { [id: string]: ColGroupDef } = Object.create(null); + const maxAncestors = colModel.colDefTreeDepth + 1; + + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + // Skip hierarchy virtuals — round-tripping them through updateGridOptions causes `_1`-suffixed dupes. + if (col.colKind === 'hierarchy') { + continue; + } + const colDef = createDefFromColumn(col); let addToResult = true; - let childDef: ColDef | ColGroupDef = colDef; - let pointer = col.getOriginalParent(); - let lastPointer: AgProvidedColumnGroup | null = null; + let pointer = col.originalParent; + let ancestors = 0; while (pointer) { - // we don't include padding groups, as the column groups provided - // by application didn't have these. the whole point of padding groups - // is to balance the column tree that the user provided. - if (pointer.isPadding()) { - pointer = pointer.getOriginalParent(); + if (++ancestors > maxAncestors) { + break; // safety net for malformed (cyclic) chain — bail rather than hang + } + // Padding groups balance tree depth; not user-defined, so skip. + if (pointer.padding) { + pointer = pointer.originalParent; continue; } - - // if colDef for this group already exists, use it - const existingParentDef = colGroupDefs[pointer.getGroupId()]; + // Sibling already built this group — nest under it and stop (also breaks any malformed parent cycle). + const existingParentDef = colGroupDefs[pointer.groupId]; if (existingParentDef) { existingParentDef.children.push(childDef); - // if we added to result, it would be the second time we did it addToResult = false; - // we don't want to continue up the tree, as it has already been - // done for this group break; } - - const parentDef = this.createDefFromGroup(pointer); - - if (parentDef) { - parentDef.children = [childDef]; - colGroupDefs[parentDef.groupId!] = parentDef; - childDef = parentDef; - pointer = pointer.getOriginalParent(); - } - - if (pointer != null && lastPointer === pointer) { + const parentDef = _deepCloneDefinition(pointer.colGroupDef, 'children'); + if (!parentDef) { addToResult = false; break; } - // Ensure we don't get stuck in an infinite loop - lastPointer = pointer; + parentDef.groupId = pointer.groupId; + parentDef.children = [childDef]; + colGroupDefs[parentDef.groupId] = parentDef; + childDef = parentDef; + pointer = pointer.originalParent; } if (addToResult) { @@ -137,34 +100,26 @@ export class ColumnDefFactory extends BeanStub implements NamedBean { return res; } - - private createDefFromGroup(group: AgProvidedColumnGroup): ColGroupDef | null | undefined { - const defCloned = _deepCloneDefinition(group.getColGroupDef(), ['children']); - - if (defCloned) { - defCloned.groupId = group.getGroupId(); - } - - return defCloned; - } - - private createDefFromColumn(col: AgColumn, rowGroupColumns: AgColumn[], pivotColumns: AgColumn[]): ColDef { - const colDefCloned = _deepCloneDefinition(col.colDef)!; - - colDefCloned.colId = col.colId; - - colDefCloned.width = col.getActualWidth(); - colDefCloned.rowGroup = col.isRowGroupActive(); - colDefCloned.rowGroupIndex = col.isRowGroupActive() ? rowGroupColumns.indexOf(col) : null; - colDefCloned.pivot = col.isPivotActive(); - colDefCloned.pivotIndex = col.isPivotActive() ? pivotColumns.indexOf(col) : null; - colDefCloned.aggFunc = col.isValueActive() ? col.getAggFunc() : null; - colDefCloned.hide = col.isVisible() ? undefined : true; - colDefCloned.pinned = col.isPinned() ? col.getPinned() : null; - - colDefCloned.sort = col.getSortDef(); - colDefCloned.sortIndex = col.getSortIndex() != null ? col.getSortIndex() : null; - - return colDefCloned; - } } + +/** User-facing `ColDef` from a live column's runtime state (width, sort, pin, agg, …). */ +const createDefFromColumn = (col: AgColumn): ColDef => { + const { colId, colDef, actualWidth, aggregationActive, rowGroupActive, visible, pivotActive, pinned } = col; + const colDefCloned = _deepCloneDefinition(colDef)!; + colDefCloned.colId = colId; + colDefCloned.width = actualWidth; + colDefCloned.rowGroup = rowGroupActive; + // `rowGroupActiveIndex`/`pivotActiveIndex` are stamped on each active col by its cols service (valid when active). + colDefCloned.rowGroupIndex = rowGroupActive ? col.rowGroupActiveIndex : null; + colDefCloned.pivot = pivotActive; + colDefCloned.pivotIndex = pivotActive ? col.pivotActiveIndex : null; + colDefCloned.aggFunc = aggregationActive ? col.aggFunc : null; + colDefCloned.hide = visible ? undefined : true; + colDefCloned.pinned = pinned === 'left' || pinned === 'right' ? pinned : null; + colDefCloned.sort = col.getSortDef(); + colDefCloned.sortIndex = col.sortIndex ?? null; + return colDefCloned; +}; + +/** Sort comparator by `colsListIndex` (display order). */ +const byColsListIndex = (a: AgColumn, b: AgColumn): number => a.colsListIndex - b.colsListIndex; diff --git a/packages/ag-grid-community/src/columns/columnEventUtils.ts b/packages/ag-grid-community/src/columns/columnEventUtils.ts index e0dfda148cc..de5fdae4e89 100644 --- a/packages/ag-grid-community/src/columns/columnEventUtils.ts +++ b/packages/ag-grid-community/src/columns/columnEventUtils.ts @@ -1,5 +1,6 @@ import type { AgColumn } from '../entities/agColumn'; import type { ColumnEvent, ColumnEventType } from '../events'; +import type { ColumnChangedEventType } from '../interfaces/iColsService'; import type { WithoutGridCommon } from '../interfaces/iCommon'; import type { IEventService } from '../interfaces/iEventService'; @@ -45,6 +46,7 @@ export function dispatchColumnPinnedEvent( }); } +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function dispatchColumnVisibleEvent( eventSvc: IEventService, changedColumns: AgColumn[], @@ -69,9 +71,13 @@ export function dispatchColumnVisibleEvent( }); } -export function dispatchColumnChangedEvent< - T extends 'columnValueChanged' | 'columnPivotChanged' | 'columnRowGroupChanged', ->(eventSvc: IEventService, type: T, columns: AgColumn[], source: ColumnEventType): void { +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _dispatchColumnChangedEvent( + eventSvc: IEventService, + type: T, + columns: AgColumn[], + source: ColumnEventType +): void { eventSvc.dispatchEvent({ type, columns, diff --git a/packages/ag-grid-community/src/columns/columnFactoryUtils.ts b/packages/ag-grid-community/src/columns/columnFactoryUtils.ts deleted file mode 100644 index 8738e11999b..00000000000 --- a/packages/ag-grid-community/src/columns/columnFactoryUtils.ts +++ /dev/null @@ -1,468 +0,0 @@ -import type { BeanCollection } from '../context/context'; -import { AgColumn } from '../entities/agColumn'; -import { AgProvidedColumnGroup, isProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; -import type { ColDef, ColGroupDef } from '../entities/colDef'; -import { DefaultColumnTypes } from '../entities/defaultColumnTypes'; -import type { ColumnEventType } from '../events'; -import { _isColumnsSortingCoupledToGroup } from '../gridOptionsUtils'; -import type { SortDef, SortDirection } from '../interfaces/iSort'; -import { _mergeDeep } from '../utils/mergeDeep'; -import { _warn } from '../validation/logging'; -import { createMergedColGroupDef } from './columnGroups/columnGroupUtils'; -import type { IColumnKeyCreator } from './columnKeyCreator'; -import { ColumnKeyCreator } from './columnKeyCreator'; -import { convertColumnTypes } from './columnUtils'; - -const depthFirstCallback = (child: AgColumn | AgProvidedColumnGroup, parent: AgProvidedColumnGroup) => { - if (isProvidedColumnGroup(child)) { - child.setupExpandable(); - } - // we set the original parents at the end, rather than when we go along, as balancing the tree - // adds extra levels into the tree. so we can only set parents when balancing is done. - child.originalParent = parent; -}; - -/** - * A performant approach to _createColumnTree where the function assumes all defs have an ID. - * Used for Pivoting. - * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. - */ -export function _createColumnTreeWithIds( - beans: BeanCollection, - defs: (ColDef | ColGroupDef)[] | null | undefined = null, - primaryColumns: boolean, - existingTree: (AgColumn | AgProvidedColumnGroup)[] | undefined, - source: ColumnEventType -): { columnTree: (AgColumn | AgProvidedColumnGroup)[]; treeDepth: number } { - const { existingCols, existingGroups } = extractExistingTreeData(existingTree); - const colIdMap = new Map(existingCols.map((col) => [col.getId(), col])); - const colGroupIdMap = new Map(existingGroups.map((group) => [group.getId(), group])); - - let maxDepth = 0; - const recursivelyProcessColDef = (def: ColDef | ColGroupDef, level: number): AgColumn | AgProvidedColumnGroup => { - maxDepth = Math.max(maxDepth, level); - if (isColumnGroupDef(def)) { - if (!beans.colGroupSvc) { - return null!; - } - - const groupId = def.groupId!; - const group = colGroupIdMap.get(groupId); - - const colGroupDef = createMergedColGroupDef(beans, def, groupId); - const newGroup = new AgProvidedColumnGroup(colGroupDef, groupId, false, level); - beans.context.createBean(newGroup); - - if (group) { - newGroup.setExpanded(group.isExpanded()); - } - - newGroup.setChildren(def.children.map((child) => recursivelyProcessColDef(child, level + 1))); - return newGroup; - } - - const colId = def.colId!; - - let column = colIdMap.get(colId); - const colDefMerged = _addColumnDefaultAndTypes(beans, def, column?.colId ?? colId); - if (!column) { - // no existing column, need to create one - column = new AgColumn(colDefMerged, def, colId, primaryColumns); - beans.context.createBean(column); - } else { - column.setColDef(colDefMerged, def, source); - _updateColumnState(beans, column, colDefMerged, source); - } - - beans.dataTypeSvc?.addColumnListeners(column); - - return column; - }; - - const root = defs?.map((def) => recursivelyProcessColDef(def, 0)) ?? []; - let counter = 0; - const keyCreator: IColumnKeyCreator = { - getUniqueKey: (_colId: string, _field: string | undefined) => String(++counter), - }; - const columnTree = beans.colGroupSvc ? beans.colGroupSvc.balanceColumnTree(root, 0, maxDepth, keyCreator) : root; - - depthFirstOriginalTreeSearch(null, columnTree, depthFirstCallback); - - return { - columnTree, - treeDepth: maxDepth, - }; -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _createColumnTree( - beans: BeanCollection, - defs: (ColDef | ColGroupDef)[] | null | undefined = null, - primaryColumns: boolean, - existingTree: (AgColumn | AgProvidedColumnGroup)[] | undefined, - source: ColumnEventType -): { columnTree: (AgColumn | AgProvidedColumnGroup)[]; treeDepth: number } { - // column key creator dishes out unique column id's in a deterministic way, - // so if we have two grids (that could be master/slave) with same column definitions, - // then this ensures the two grids use identical id's. - const columnKeyCreator = new ColumnKeyCreator(); - - const { existingCols, existingGroups, existingColKeys } = extractExistingTreeData(existingTree); - columnKeyCreator.addExistingKeys(existingColKeys); - - // create am unbalanced tree that maps the provided definitions - const unbalancedTree = _recursivelyCreateColumns( - beans, - defs, - 0, - primaryColumns, - existingCols, - columnKeyCreator, - existingGroups, - source - ); - const { colGroupSvc } = beans; - const treeDepth = colGroupSvc?.findMaxDepth(unbalancedTree, 0) ?? 0; - const columnTree = colGroupSvc - ? colGroupSvc.balanceColumnTree(unbalancedTree, 0, treeDepth, columnKeyCreator) - : unbalancedTree; - - depthFirstOriginalTreeSearch(null, columnTree, depthFirstCallback); - - return { - columnTree, - treeDepth, - }; -} - -function extractExistingTreeData(existingTree?: (AgColumn | AgProvidedColumnGroup)[]): { - existingCols: AgColumn[]; - existingGroups: AgProvidedColumnGroup[]; - existingColKeys: string[]; -} { - const existingCols: AgColumn[] = []; - const existingGroups: AgProvidedColumnGroup[] = []; - const existingColKeys: string[] = []; - - if (existingTree) { - depthFirstOriginalTreeSearch(null, existingTree, (item: AgColumn | AgProvidedColumnGroup) => { - if (isProvidedColumnGroup(item)) { - const group = item; - existingGroups.push(group); - } else { - const col = item; - existingColKeys.push(col.getId()); - existingCols.push(col); - } - }); - } - - return { existingCols, existingGroups, existingColKeys }; -} - -export function _recursivelyCreateColumns( - beans: BeanCollection, - defs: (ColDef | ColGroupDef)[] | null, - level: number, - primaryColumns: boolean, - existingColsCopy: AgColumn[], - columnKeyCreator: IColumnKeyCreator, - existingGroups: AgProvidedColumnGroup[], - source: ColumnEventType -): (AgColumn | AgProvidedColumnGroup)[] { - if (!defs) { - return []; - } - - const { colGroupSvc } = beans; - const result = new Array(defs.length); - for (let i = 0; i < result.length; i++) { - const def = defs[i]; - if (colGroupSvc && isColumnGroupDef(def)) { - result[i] = colGroupSvc.createProvidedColumnGroup( - primaryColumns, - def, - level, - existingColsCopy, - columnKeyCreator, - existingGroups, - source - ); - } else { - result[i] = createColumn(beans, primaryColumns, def as ColDef, existingColsCopy, columnKeyCreator, source); - } - } - return result; -} - -function createColumn( - beans: BeanCollection, - primaryColumns: boolean, - colDef: ColDef, - existingColsCopy: AgColumn[] | null, - columnKeyCreator: IColumnKeyCreator, - source: ColumnEventType -): AgColumn { - // see if column already exists - const existingColAndIndex = findExistingColumn(colDef, existingColsCopy); - - // make sure we remove, so if user provided duplicate id, then we don't have more than - // one column instance for colDef with common id - if (existingColAndIndex) { - existingColsCopy?.splice(existingColAndIndex.idx, 1); - } - - let column = existingColAndIndex?.column; - if (!column) { - // no existing column, need to create one - const colId = columnKeyCreator.getUniqueKey(colDef.colId, colDef.field); - const colDefMerged = _addColumnDefaultAndTypes(beans, colDef, colId); - column = new AgColumn(colDefMerged, colDef, colId, primaryColumns); - beans.context.createBean(column); - } else { - const colDefMerged = _addColumnDefaultAndTypes(beans, colDef, column.colId); - column.setColDef(colDefMerged, colDef, source); - _updateColumnState(beans, column, colDefMerged, source); - } - - beans.dataTypeSvc?.addColumnListeners(column); - - return column; -} - -/** Updates hide, sort, sortIndex, pinned and flex */ - -export function updateSomeColumnState( - beans: BeanCollection, - column: AgColumn, - hide: boolean | null | undefined, - sort: SortDirection | SortDef | undefined, - sortIndex: number | null | undefined, - pinned: boolean | 'left' | 'right' | null | undefined, - flex: number | null | undefined, - source: ColumnEventType -): void { - const { sortSvc, pinnedCols, colFlex } = beans; - - // hide - anything but undefined, thus null will clear the hide - if (hide !== undefined) { - column.setVisible(!hide, source); - } - - if (sortSvc) { - // sort - anything but undefined will set sort, thus null or empty string will clear the sort - sortSvc.updateColSort(column, sort, source); - - // sorted at - anything but undefined, thus null will clear the sortIndex - if (sortIndex !== undefined) { - sortSvc.setColSortIndex(column, sortIndex); - } - } - - // pinned - anything but undefined, thus null or empty string will remove pinned - if (pinned !== undefined) { - pinnedCols?.setColPinned(column, pinned); - } - - // flex - if (flex !== undefined) { - colFlex?.setColFlex(column, flex); - } -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _updateColumnState( - beans: BeanCollection, - column: AgColumn, - colDef: ColDef, - source: ColumnEventType -): void { - updateSomeColumnState( - beans, - column, - colDef.hide, - colDef.sort, - colDef.sortIndex, - colDef.pinned, - colDef.flex, - source - ); - - const colFlex = column.getFlex(); - - // width - we only set width if column is not flexing - if (colFlex != null && colFlex > 0) { - return; - } - - // both null and undefined means we skip, as it's not possible to 'clear' width (a column must have a width) - if (colDef.width != null) { - column.setActualWidth(colDef.width, source); - } else { - // otherwise set the width again, in case min or max width has changed, - // and width needs to be adjusted. - const widthBeforeUpdate = column.getActualWidth(); - column.setActualWidth(widthBeforeUpdate, source); - } -} - -function findExistingColumn( - newColDef: ColDef, - existingColsCopy: AgColumn[] | null -): { idx: number; column: AgColumn } | undefined { - if (!existingColsCopy) { - return undefined; - } - - for (let i = 0; i < existingColsCopy.length; i++) { - const def = existingColsCopy[i].getUserProvidedColDef(); - if (!def) { - continue; - } - - const newHasId = newColDef.colId != null; - if (newHasId) { - if (existingColsCopy[i].getId() === newColDef.colId) { - return { idx: i, column: existingColsCopy[i] }; - } - continue; - } - - const newHasField = newColDef.field != null; - if (newHasField) { - if (def.field === newColDef.field) { - return { idx: i, column: existingColsCopy[i] }; - } - continue; - } - - if (def === newColDef) { - return { idx: i, column: existingColsCopy[i] }; - } - } - return undefined; -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _addColumnDefaultAndTypes( - beans: BeanCollection, - colDef: ColDef, - colId: string, - isAutoCol?: boolean -): ColDef { - const { gos, dataTypeSvc } = beans; - // start with empty merged definition - const res: ColDef = {} as ColDef; - - // merge properties from default column definitions - const defaultColDef = gos.get('defaultColDef'); - _mergeDeep(res, defaultColDef, false, true); - - const columnType = updateColDefAndGetColumnType(beans, res, colDef, colId); - - if (columnType) { - assignColumnTypes(beans, columnType, res); - } - - const cellDataType = res.cellDataType; - - // merge properties from column definitions - _mergeDeep(res, colDef, false, true); - - if (cellDataType !== undefined) { - // `cellDataType: true` in provided def will overwrite inferred result type otherwise - res.cellDataType = cellDataType; - } - - const autoGroupColDef = gos.get('autoGroupColumnDef'); - const isSortingCoupled = _isColumnsSortingCoupledToGroup(gos); - if (colDef.rowGroup && autoGroupColDef && isSortingCoupled) { - // override the sort for row group columns where the autoGroupColDef defines these values. - _mergeDeep( - res, - { - sort: autoGroupColDef.sort, - initialSort: autoGroupColDef.initialSort, - } as ColDef, - false, - true - ); - } - - dataTypeSvc?.postProcess(res); - dataTypeSvc?.validateColDef(res, colDef, defaultColDef, colId); - - gos.validateColDef(res, colId, isAutoCol); - - return res; -} - -function updateColDefAndGetColumnType( - beans: BeanCollection, - colDef: ColDef, - userColDef: ColDef, - colId: string -): string[] | undefined { - const dataTypeDefinitionColumnType = beans.dataTypeSvc?.updateColDefAndGetColumnType(colDef, userColDef, colId); - const columnTypes = userColDef.type ?? dataTypeDefinitionColumnType ?? colDef.type; - colDef.type = columnTypes; - return columnTypes ? convertColumnTypes(columnTypes) : undefined; -} - -function assignColumnTypes(beans: BeanCollection, typeKeys: string[], colDefMerged: ColDef) { - if (!typeKeys.length) { - return; - } - - // merge user defined with default column types - const allColumnTypes = Object.assign({}, DefaultColumnTypes); - const userTypes = beans.gos.get('columnTypes') || {}; - - for (const key of Object.keys(userTypes)) { - const value = userTypes[key]; - if (key in allColumnTypes) { - // default column types cannot be overridden - _warn(34, { key }); - } else { - const colType = value as any; - if (colType.type) { - // type should not be defined in column types - _warn(35); - } - - allColumnTypes[key] = value; - } - } - - for (const t of typeKeys) { - const typeColDef = allColumnTypes[t.trim()]; - if (typeColDef) { - _mergeDeep(colDefMerged, typeColDef, false, true); - } else { - _warn(36, { t }); - } - } -} - -// if object has children, we assume it's a group -function isColumnGroupDef(abstractColDef: ColDef | ColGroupDef): abstractColDef is ColGroupDef { - return (abstractColDef as ColGroupDef).children !== undefined; -} - -export function depthFirstOriginalTreeSearch( - parent: AgProvidedColumnGroup | null, - tree: (AgColumn | AgProvidedColumnGroup)[], - callback: (treeNode: AgColumn | AgProvidedColumnGroup, parent: AgProvidedColumnGroup | null) => void -): void { - if (!tree) { - return; - } - - for (let i = 0; i < tree.length; i++) { - const child = tree[i]; - if (isProvidedColumnGroup(child)) { - depthFirstOriginalTreeSearch(child, child.getChildren(), callback); - } - callback(child, parent); - } -} diff --git a/packages/ag-grid-community/src/columns/columnFlexService.ts b/packages/ag-grid-community/src/columns/columnFlexService.ts index 7573d70f3d7..77b797ce2ca 100644 --- a/packages/ag-grid-community/src/columns/columnFlexService.ts +++ b/packages/ag-grid-community/src/columns/columnFlexService.ts @@ -21,7 +21,7 @@ export class ColumnFlexService extends BeanStub implements NamedBean { beanName = 'colFlex' as const; private flexViewportWidth: number; - private columnsHidden = false; + public columnsHidden = false; public refreshFlexedColumns( params: { @@ -186,12 +186,9 @@ export class ColumnFlexService extends BeanStub implements NamedBean { } } - if (!params.skipSetLeft) { - visibleCols.setLeftValues(source); - } - + const widths = params.skipSetLeft ? undefined : visibleCols.setLeftValues(source); if (params.updateBodyWidths) { - visibleCols.updateBodyWidths(); + visibleCols.updateBodyWidths(widths); } const unconstrainedFlexColumns = items diff --git a/packages/ag-grid-community/src/columns/columnGroups/colWrapperCache.ts b/packages/ag-grid-community/src/columns/columnGroups/colWrapperCache.ts new file mode 100644 index 00000000000..4e7abc622d2 --- /dev/null +++ b/packages/ag-grid-community/src/columns/columnGroups/colWrapperCache.ts @@ -0,0 +1,94 @@ +import type { BeanCollection, Context } from '../../context/context'; +import type { AgColumn } from '../../entities/agColumn'; +import { AgProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; + +interface WrapperEntry { + wrapper: AgColumn | AgProvidedColumnGroup; + depth: number; + /** Build that last wrapped this col; `evict` drops entries whose token is stale. */ + buildToken: number; +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export class ColWrapperCache { + private readonly entries = new Map(); + private readonly context: Context; + + public constructor(beans: BeanCollection) { + this.context = beans.context; + } + + /** Return the cached wrapper for `(col, depth)`, or build one. Stamps `buildToken` so a later + * `evict(buildToken)` drops wrappers not refreshed this build. */ + public wrap(col: AgColumn, depth: number, buildToken: number): AgColumn | AgProvidedColumnGroup { + const entries = this.entries; + const cached = entries.get(col); + if (cached?.depth === depth) { + cached.buildToken = buildToken; + return cached.wrapper; + } + // Depth changed (or first build): drop any stale chain. The leaf `col` survives the destroy. + if (cached !== undefined) { + destroyAutoWrapperChain(cached.wrapper); + } + + // Depth 0 wraps to the column itself — no chain, so nothing to cache. + if (depth === 0) { + if (cached !== undefined) { + entries.delete(col); + } + col.originalParent = null; + return col; + } + + // Wrap `col` in `depth` dummy `AgProvidedColumnGroup` nodes so the leaf aligns to tree depth. + const colId = col.colId; + const context = this.context; + let wrapper: AgColumn | AgProvidedColumnGroup = col; + for (let i = depth - 1; i >= 0; --i) { + const autoGroup = new AgProvidedColumnGroup(null, 'FAKE_PATH_' + colId + '_' + i, true, i); + context.createBean(autoGroup); + autoGroup.children = [wrapper]; + wrapper.originalParent = autoGroup; + wrapper = autoGroup; + } + wrapper.originalParent = null; + entries.set(col, { wrapper, depth, buildToken }); + return wrapper; + } + + /** Destroy and remove entries whose token doesn't match the current build. */ + public evict(buildToken: number): void { + const entries = this.entries; + if (entries.size === 0) { + return; + } + entries.forEach((entry, col) => { + if (entry.buildToken !== buildToken) { + destroyAutoWrapperChain(entry.wrapper); + entries.delete(col); + } + }); + } + + /** Destroy all cached wrapper chains and clear the cache. */ + public destroy(): void { + const entries = this.entries; + entries.forEach(destroyWrapperEntry); + entries.clear(); + } +} + +const destroyWrapperEntry = (entry: WrapperEntry): void => destroyAutoWrapperChain(entry.wrapper); + +/** Idempotent destroy wrapper-group chain above an auto-col, stopping at the leaf (owned by its service). */ +const destroyAutoWrapperChain = (top: AgColumn | AgProvidedColumnGroup): void => { + let node: AgColumn | AgProvidedColumnGroup | null = top; + while (node && !node.isColumn) { + const child: AgColumn | AgProvidedColumnGroup | undefined = node.children[0]; + if (node.isAlive()) { + node.destroy(); + } + node = child ?? null; + } +}; diff --git a/packages/ag-grid-community/src/columns/columnGroups/columnGroupApi.ts b/packages/ag-grid-community/src/columns/columnGroups/columnGroupApi.ts index 013e15ebbb9..bb45522a4ab 100644 --- a/packages/ag-grid-community/src/columns/columnGroups/columnGroupApi.ts +++ b/packages/ag-grid-community/src/columns/columnGroups/columnGroupApi.ts @@ -3,21 +3,23 @@ import type { AgColumnGroup } from '../../entities/agColumnGroup'; import type { AgProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; import type { HeaderLocation } from '../../entities/colDef'; import type { Column, ColumnGroup, ProvidedColumnGroup } from '../../interfaces/iColumn'; +import { _getColGroupState, _resetColGroupState, _setColGroupOpen, _setColGroupState } from './columnGroupState'; export function setColumnGroupOpened( beans: BeanCollection, group: ProvidedColumnGroup | string, newValue: boolean ): void { - beans.colGroupSvc?.setColumnGroupOpened(group as AgProvidedColumnGroup | string, newValue, 'api'); + _setColGroupOpen(beans, group as AgProvidedColumnGroup | string, newValue, 'api'); } export function getColumnGroup(beans: BeanCollection, name: string, instanceId?: number): ColumnGroup | null { - return beans.colGroupSvc?.getColumnGroup(name, instanceId) ?? null; + const instances = name != null ? beans.colModel.colsGroupsById.get(name)?.displayInstances : undefined; + return (instances && (typeof instanceId === 'number' ? instances[instanceId] : instances[0])) || null; } export function getProvidedColumnGroup(beans: BeanCollection, name: string): ProvidedColumnGroup | null { - return beans.colGroupSvc?.getProvidedColGroup(name) ?? null; + return beans.colModel.getColGroup(name) ?? null; } export function getDisplayNameForColumnGroup( @@ -29,15 +31,15 @@ export function getDisplayNameForColumnGroup( } export function getColumnGroupState(beans: BeanCollection): { groupId: string; open: boolean }[] { - return beans.colGroupSvc?.getColumnGroupState() ?? []; + return _getColGroupState(beans); } export function setColumnGroupState(beans: BeanCollection, stateItems: { groupId: string; open: boolean }[]): void { - beans.colGroupSvc?.setColumnGroupState(stateItems, 'api'); + _setColGroupState(beans, stateItems, 'api'); } export function resetColumnGroupState(beans: BeanCollection): void { - beans.colGroupSvc?.resetColumnGroupState('api'); + _resetColGroupState(beans, 'api'); } export function getLeftDisplayedColumnGroups(beans: BeanCollection): (Column | ColumnGroup)[] { @@ -53,5 +55,6 @@ export function getRightDisplayedColumnGroups(beans: BeanCollection): (Column | } export function getAllDisplayedColumnGroups(beans: BeanCollection): (Column | ColumnGroup)[] | null { - return beans.visibleCols.getAllTrees(); + const { treeLeft, treeCenter, treeRight } = beans.visibleCols; + return treeLeft.concat(treeCenter, treeRight); } diff --git a/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts b/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts index 9f70d32dea0..686fa73ca1c 100644 --- a/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts +++ b/packages/ag-grid-community/src/columns/columnGroups/columnGroupService.ts @@ -1,583 +1,163 @@ -import { _exists, _last } from 'ag-stack'; - import type { NamedBean } from '../../context/bean'; import { BeanStub } from '../../context/beanStub'; +import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; -import { AgColumnGroup, createUniqueColumnGroupId, isColumnGroup } from '../../entities/agColumnGroup'; -import { AgProvidedColumnGroup, isProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; -import type { ColGroupDef } from '../../entities/colDef'; -import type { ColumnEventType } from '../../events'; -import type { ColumnPinnedType, HeaderColumnId } from '../../interfaces/iColumn'; -import { _recursivelyCreateColumns, depthFirstOriginalTreeSearch } from '../columnFactoryUtils'; -import type { IColumnKeyCreator } from '../columnKeyCreator'; +import { AgColumnGroup } from '../../entities/agColumnGroup'; +import type { AgProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; +import type { ColumnPinnedType } from '../../interfaces/iColumn'; +import type { ColumnModel } from '../columnModel'; +import type { ColumnViewportService } from '../columnViewportService'; import type { GroupInstanceIdCreator } from '../groupInstanceIdCreator'; -import { depthFirstAllColumnTreeSearch } from '../visibleColsService'; -import { createMergedColGroupDef } from './columnGroupUtils'; - -export interface CreateGroupsParams { - // all displayed columns sorted - this is the columns the grid should show - columns: AgColumn[]; - // creates unique id's for the group - idCreator: GroupInstanceIdCreator; - // whether it's left, right or center col - pinned: ColumnPinnedType; - // we try to reuse old groups if we can, to allow gui to do animation - oldDisplayedGroups?: (AgColumn | AgColumnGroup)[]; - // set `isStandaloneStructure` to true if this structure will not be used - // by the grid UI. This is useful for export modules (gridSerializer). - isStandaloneStructure?: boolean; -} +import { ColWrapperCache } from './colWrapperCache'; export class ColumnGroupService extends BeanStub implements NamedBean { beanName = 'colGroupSvc' as const; - public getColumnGroupState(): { groupId: string; open: boolean }[] { - const columnGroupState: { groupId: string; open: boolean }[] = []; - const gridBalancedTree = this.beans.colModel.getColTree(); - - depthFirstOriginalTreeSearch(null, gridBalancedTree, (node) => { - if (isProvidedColumnGroup(node)) { - columnGroupState.push({ - groupId: node.getGroupId(), - open: node.isExpanded(), - }); - } - }); - - return columnGroupState; - } - - public resetColumnGroupState(source: ColumnEventType): void { - const primaryColumnTree = this.beans.colModel.getColDefColTree(); - if (!primaryColumnTree) { - return; - } - - const stateItems: { groupId: string; open: boolean | undefined }[] = []; - - depthFirstOriginalTreeSearch(null, primaryColumnTree, (child) => { - if (isProvidedColumnGroup(child)) { - const colGroupDef = child.getColGroupDef(); - const groupState = { - groupId: child.getGroupId(), - open: !colGroupDef ? undefined : colGroupDef.openByDefault, - }; - stateItems.push(groupState); - } - }); - - this.setColumnGroupState(stateItems, source); - } - - public setColumnGroupState( - stateItems: { groupId: string; open: boolean | undefined }[], - source: ColumnEventType - ): void { - const { colModel, colAnimation, visibleCols, eventSvc } = this.beans; - const gridBalancedTree = colModel.getColTree(); - if (!gridBalancedTree.length) { - return; - } - - colAnimation?.start(); - - const impactedGroups: AgProvidedColumnGroup[] = []; - - for (const stateItem of stateItems) { - const groupKey = stateItem.groupId; - const newValue = stateItem.open; - const providedColumnGroup = this.getProvidedColGroup(groupKey); - - if (!providedColumnGroup) { - continue; - } - if (providedColumnGroup.isExpanded() === newValue) { - continue; - } - - providedColumnGroup.setExpanded(newValue); - impactedGroups.push(providedColumnGroup); - } - - visibleCols.refresh(source, true); + private colModel: ColumnModel; + private colViewport: ColumnViewportService; - if (impactedGroups.length) { - eventSvc.dispatchEvent({ - type: 'columnGroupOpened', - columnGroup: impactedGroups.length === 1 ? impactedGroups[0] : undefined, - columnGroups: impactedGroups, - }); - } + /** Cache service-column wrappers (auto-group/selection/row-numbers) across `refreshCols` by `(col, depth)`. */ + public wrapperCache: ColWrapperCache; - colAnimation?.finish(); + public wireBeans(beans: BeanCollection): void { + this.colModel = beans.colModel; + this.colViewport = beans.colViewport; + this.wrapperCache = new ColWrapperCache(beans); } - // called by headerRenderer - when a header is opened or closed - public setColumnGroupOpened( - key: AgProvidedColumnGroup | string | null, - newValue: boolean, - source: ColumnEventType - ): void { - let keyAsString: string; - - if (isProvidedColumnGroup(key)) { - keyAsString = key.getId(); - } else { - keyAsString = key || ''; - } - this.setColumnGroupState([{ groupId: keyAsString, open: newValue }], source); + public override destroy(): void { + this.wrapperCache.destroy(); + super.destroy(); } - public getProvidedColGroup(key: string): AgProvidedColumnGroup | null { - let res: AgProvidedColumnGroup | null = null; - - depthFirstOriginalTreeSearch(null, this.beans.colModel.getColTree(), (node) => { - if (isProvidedColumnGroup(node)) { - if (node.getId() === key) { - res = node; + /** Build one pinned section's displayed group tree from already-sorted `columns`. + * @param buildToken Stamp reused/created groups for `prune` to drop stale tail entries. + * @param isStandaloneStructure Build detached output (e.g. exports) without reuse, bean registration, or parent wiring. */ + public createGroups( + columns: AgColumn[], + idCreator: GroupInstanceIdCreator, + pinned: ColumnPinnedType, + buildToken: number = 1, + isStandaloneStructure: boolean = false + ): (AgColumn | AgColumnGroup)[] { + const setParents = !isStandaloneStructure; + const colViewport = this.colViewport; + + // Fast path: if the first leaf has `originalParent === null`, treat all leaves as ungrouped and return columns. + if (columns.length === 0 || columns[0].originalParent === null) { + if (setParents) { + for (let i = 0, len = columns.length; i < len; ++i) { + const col = columns[i]; + if (col.parent) { + col.parent = null; + colViewport.colsWithinViewportHash = ''; + } } } - }); - - return res; - } - - public getGroupAtDirection(columnGroup: AgColumnGroup, direction: 'After' | 'Before'): AgColumnGroup | null { - // pick the last displayed column in this group - const requiredLevel = columnGroup.getProvidedColumnGroup().getLevel() + columnGroup.getPaddingLevel(); - const colGroupLeafColumns = columnGroup.getDisplayedLeafColumns(); - const col: AgColumn | null = direction === 'After' ? _last(colGroupLeafColumns) : colGroupLeafColumns[0]; - const getDisplayColMethod: 'getColAfter' | 'getColBefore' = `getCol${direction}` as any; - - while (true) { - // keep moving to the next col, until we get to another group - const column = this.beans.visibleCols[getDisplayColMethod](col); - - if (!column) { - return null; - } - - const groupPointer = this.getColGroupAtLevel(column, requiredLevel); - - if (groupPointer !== columnGroup) { - return groupPointer; - } - } - } - - public getColGroupAtLevel(column: AgColumn, level: number): AgColumnGroup | null { - // get group at same level as the one we are looking for - let groupPointer: AgColumnGroup = column.parent!; - let originalGroupLevel: number; - let groupPointerLevel: number; - - while (true) { - const groupPointerProvidedColumnGroup = groupPointer.getProvidedColumnGroup(); - originalGroupLevel = groupPointerProvidedColumnGroup.getLevel(); - groupPointerLevel = groupPointer.getPaddingLevel(); - - if (originalGroupLevel + groupPointerLevel <= level) { - break; - } - groupPointer = groupPointer.parent!; - } - - return groupPointer; - } - - public updateOpenClosedVisibility(): void { - const allColumnGroups = this.beans.visibleCols.getAllTrees(); - - depthFirstAllColumnTreeSearch(allColumnGroups, false, (child) => { - if (isColumnGroup(child)) { - child.calculateDisplayedColumns(); - } - }); - } - - // returns the group with matching colId and instanceId. If instanceId is missing, - // matches only on the colId. - public getColumnGroup(colId: string | AgColumnGroup, partId?: number): AgColumnGroup | null { - if (!colId) { - return null; - } - if (isColumnGroup(colId)) { - return colId; + return columns; } - const allColumnGroups = this.beans.visibleCols.getAllTrees(); - const checkPartId = typeof partId === 'number'; - let result: AgColumnGroup | null = null; - - depthFirstAllColumnTreeSearch(allColumnGroups, false, (child) => { - if (isColumnGroup(child)) { - const columnGroup = child; - let matched: boolean; - - if (checkPartId) { - matched = colId === columnGroup.getGroupId() && partId === columnGroup.getPartId(); - } else { - matched = colId === columnGroup.getGroupId(); - } - - if (matched) { - result = columnGroup; - } - } - }); - - return result; - } - - public createColumnGroups(params: CreateGroupsParams): (AgColumn | AgColumnGroup)[] { - const { columns, idCreator, pinned, oldDisplayedGroups, isStandaloneStructure } = params; - const oldColumnsMapped = this.mapOldGroupsById(oldDisplayedGroups!); - - /** - * The following logic starts at the leaf level of columns, iterating through them to build their parent - * groups when the parents match. - * - * The created groups are then added to an array, and similarly iterated on until we reach the top level. - * - * When row groups have no original parent, it's added to the result. - */ const topLevelResultCols: (AgColumn | AgColumnGroup)[] = []; - - // this is an array of cols or col groups at one level of depth, starting from leaf and ending at root - let groupsOrColsAtCurrentLevel: (AgColumn | AgColumnGroup)[] = columns; - while (groupsOrColsAtCurrentLevel.length) { - // store what's currently iterating so the function can build the next level of col groups - const currentlyIterating = groupsOrColsAtCurrentLevel; - groupsOrColsAtCurrentLevel = []; - - // store the index of the last row which was different from the previous row, this is used as a slice - // index for finding the children to group together - let lastGroupedColIdx = 0; - - // create a group of children from lastGroupedColIdx to the provided `to` parameter - const createGroupToIndex = (to: number) => { - const from = lastGroupedColIdx; - lastGroupedColIdx = to; - - const previousNode = currentlyIterating[from]; - const previousNodeProvided = isColumnGroup(previousNode) - ? previousNode.getProvidedColumnGroup() - : previousNode; - const previousNodeParent = previousNodeProvided.getOriginalParent(); - - if (previousNodeParent == null) { - // if the last node was different, and had a null parent, then we add all the nodes to the final - // results) - for (let i = from; i < to; i++) { - topLevelResultCols.push(currentlyIterating[i]); - } - return; + let currentLevel: (AgColumn | AgColumnGroup)[] = columns; + + // Walk leaf -> root by collapsing adjacent runs with the same `originalParent` into top-level nodes or groups. + while (currentLevel.length) { + const itLen = currentLevel.length; + const nextLevel: (AgColumn | AgColumnGroup)[] = []; + let runStart = 0; + let runParent = originalParentOf(currentLevel[0]); + // Sentinel pass (`i <= itLen`) emits the final run without a per-level closure. + for (let i = 1; i <= itLen; ++i) { + const thisParent = i === itLen ? undefined : originalParentOf(currentLevel[i]); + if (i < itLen && thisParent === runParent) { + continue; } - // the parent differs from the previous node, so we create a group from the previous node - // and add all to the result array, except the current node. - const newGroup = this.createColumnGroup( - previousNodeParent, - idCreator, - oldColumnsMapped, - pinned, - isStandaloneStructure - ); - - for (let i = from; i < to; i++) { - newGroup.addChild(currentlyIterating[i]); - } - groupsOrColsAtCurrentLevel.push(newGroup); - }; - - for (let i = 1; i < currentlyIterating.length; i++) { - const thisNode = currentlyIterating[i]; - const thisNodeProvided = isColumnGroup(thisNode) ? thisNode.getProvidedColumnGroup() : thisNode; - const thisNodeParent = thisNodeProvided.getOriginalParent(); - - const previousNode = currentlyIterating[lastGroupedColIdx]; - const previousNodeProvided = isColumnGroup(previousNode) - ? previousNode.getProvidedColumnGroup() - : previousNode; - const previousNodeParent = previousNodeProvided.getOriginalParent(); - - if (thisNodeParent !== previousNodeParent) { - createGroupToIndex(i); - } - } - - if (lastGroupedColIdx < currentlyIterating.length) { - createGroupToIndex(currentlyIterating.length); - } - } - - if (!isStandaloneStructure) { - this.setupParentsIntoCols(topLevelResultCols, null); - } - return topLevelResultCols; - } - - public createProvidedColumnGroup( - primaryColumns: boolean, - colGroupDef: ColGroupDef, - level: number, - existingColumns: AgColumn[], - columnKeyCreator: IColumnKeyCreator, - existingGroups: AgProvidedColumnGroup[], - source: ColumnEventType - ): AgProvidedColumnGroup { - const groupId = columnKeyCreator.getUniqueKey(colGroupDef.groupId || null, null); - const colGroupDefMerged = createMergedColGroupDef(this.beans, colGroupDef, groupId); - const providedGroup = new AgProvidedColumnGroup(colGroupDefMerged, groupId, false, level); - this.createBean(providedGroup); - const existingGroupAndIndex = this.findExistingGroup(colGroupDef, existingGroups); - // make sure we remove, so if user provided duplicate id, then we don't have more than - // one column instance for colDef with common id - if (existingGroupAndIndex) { - existingGroups.splice(existingGroupAndIndex.idx, 1); - } - - const existingGroup = existingGroupAndIndex?.group; - if (existingGroup) { - providedGroup.setExpanded(existingGroup.isExpanded()); - } - - const children = _recursivelyCreateColumns( - this.beans, - colGroupDefMerged.children, - level + 1, - primaryColumns, - existingColumns, - columnKeyCreator, - existingGroups, - source - ); - - providedGroup.setChildren(children); - - return providedGroup; - } - - public balanceColumnTree( - unbalancedTree: (AgColumn | AgProvidedColumnGroup)[], - currentDepth: number, - columnDepth: number, - columnKeyCreator: IColumnKeyCreator - ): (AgColumn | AgProvidedColumnGroup)[] { - const result: (AgColumn | AgProvidedColumnGroup)[] = []; - - // go through each child, for groups, recurse a level deeper, - // for columns we need to pad - for (let i = 0; i < unbalancedTree.length; i++) { - const child = unbalancedTree[i]; - if (isProvidedColumnGroup(child)) { - // child is a group, all we do is go to the next level of recursion - const originalGroup = child; - const newChildren = this.balanceColumnTree( - originalGroup.getChildren(), - currentDepth + 1, - columnDepth, - columnKeyCreator - ); - originalGroup.setChildren(newChildren); - result.push(originalGroup); - } else { - // child is a column - so here we add in the padded column groups if needed - let firstPaddedGroup: AgProvidedColumnGroup | undefined; - let currentPaddedGroup: AgProvidedColumnGroup | undefined; - - // this for loop will NOT run any loops if no padded column groups are needed - for (let j = currentDepth; j < columnDepth; j++) { - const newColId = columnKeyCreator.getUniqueKey(null, null); - const colGroupDefMerged = createMergedColGroupDef(this.beans, null, newColId); - - const paddedGroup = new AgProvidedColumnGroup(colGroupDefMerged, newColId, true, j); - this.createBean(paddedGroup); - - if (currentPaddedGroup) { - currentPaddedGroup.setChildren([paddedGroup]); - } - - currentPaddedGroup = paddedGroup; - - if (!firstPaddedGroup) { - firstPaddedGroup = currentPaddedGroup; + if (runParent == null) { + // Top-level run: push and (when not standalone) clear stale parent inline. + for (let j = runStart; j < i; ++j) { + const node = currentLevel[j]; + topLevelResultCols.push(node); + if (setParents && node.parent !== null) { + node.parent = null; + colViewport.colsWithinViewportHash = ''; + } } - } - - // likewise this if statement will not run if no padded groups - if (firstPaddedGroup && currentPaddedGroup) { - result.push(firstPaddedGroup); - const hasGroups = unbalancedTree.some((leaf) => isProvidedColumnGroup(leaf)); - - if (hasGroups) { - currentPaddedGroup.setChildren([child]); - continue; + } else { + let newGroup: AgColumnGroup; + const groupId = runParent.groupId; + const instanceId = idCreator.getInstanceIdForKey(groupId); + // `idCreator` resets per refresh, so reused `instanceId` wrappers must have `pinned` refreshed. + const reuse = setParents ? runParent.displayInstances?.[instanceId] : undefined; + if (reuse && reuse.buildToken !== buildToken) { + reuse.buildToken = buildToken; + reuse.pinned = pinned; + reuse.parent = null; + reuse.children = null; + reuse.displayedChildren = null; + newGroup = reuse; } else { - currentPaddedGroup.setChildren(unbalancedTree); - break; + newGroup = new AgColumnGroup(runParent, groupId, instanceId, pinned); + newGroup.buildToken = buildToken; + if (setParents) { + this.createBean(newGroup); + // Register this display instance in its dense `partId` slot (skip for standalone builds). + let instances = runParent.displayInstances; + if (instances === null) { + instances = []; + runParent.displayInstances = instances; + } + instances[instanceId] = newGroup; + } + } + let groupChildren = newGroup.children; + if (groupChildren === null) { + groupChildren = []; + newGroup.children = groupChildren; } + for (let j = runStart; j < i; ++j) { + const node = currentLevel[j]; + groupChildren.push(node); + if (setParents && node.parent !== newGroup) { + node.parent = newGroup; + colViewport.colsWithinViewportHash = ''; + } + } + nextLevel.push(newGroup); } - result.push(child); - } - } - - return result; - } - - public findDepth(balancedColumnTree: (AgColumn | AgProvidedColumnGroup)[]): number { - let depth = 0; - let pointer = balancedColumnTree; - - while (pointer?.[0] && isProvidedColumnGroup(pointer[0])) { - depth++; - pointer = pointer[0].getChildren(); - } - return depth; - } - - public findMaxDepth(treeChildren: (AgColumn | AgProvidedColumnGroup)[], depth: number): number { - let maxDepthThisLevel = depth; - - for (let i = 0; i < treeChildren.length; i++) { - const abstractColumn = treeChildren[i]; - if (isProvidedColumnGroup(abstractColumn)) { - const originalGroup = abstractColumn; - const newDepth = this.findMaxDepth(originalGroup.getChildren(), depth + 1); - if (maxDepthThisLevel < newDepth) { - maxDepthThisLevel = newDepth; + runStart = i; + if (i < itLen) { + runParent = thisParent!; } } + currentLevel = nextLevel; } - return maxDepthThisLevel; - } - - /** - * Inserts dummy group columns in the hierarchy above auto-generated columns - * in order to ensure auto-generated columns are leaf nodes (and therefore are - * displayed correctly) - */ - public balanceTreeForAutoCols(autoCols: AgColumn[], depth: number): (AgColumn | AgProvidedColumnGroup)[] { - const tree: (AgColumn | AgProvidedColumnGroup)[] = []; - - for (const col of autoCols) { - // at the end, this will be the top of the tree item. - let nextChild: AgColumn | AgProvidedColumnGroup = col; - - for (let i = depth - 1; i >= 0; i--) { - const autoGroup = new AgProvidedColumnGroup(null, `FAKE_PATH_${col.getId()}_${i}`, true, i); - this.createBean(autoGroup); - autoGroup.setChildren([nextChild]); - nextChild.originalParent = autoGroup; - nextChild = autoGroup; - } - - if (depth === 0) { - col.originalParent = null; - } - - // at this point, the nextChild is the top most item in the tree - tree.push(nextChild); - } - - return tree; + return topLevelResultCols; } - private findExistingGroup( - newGroupDef: ColGroupDef, - existingGroups: AgProvidedColumnGroup[] - ): { idx: number; group: AgProvidedColumnGroup } | undefined { - const newHasId = newGroupDef.groupId != null; - if (!newHasId) { - return undefined; - } - - for (let i = 0; i < existingGroups.length; i++) { - const existingGroup = existingGroups[i]; - const existingDef = existingGroup.getColGroupDef(); - if (!existingDef) { + /** Finalise display instances by keeping groups stamped with `buildToken` and truncating stale tail entries. */ + public prune(buildToken: number): void { + const allGroups = this.colModel.colsAllGroups; + for (let i = 0, len = allGroups.length; i < len; ++i) { + const instances = allGroups[i].displayInstances; + if (!instances) { continue; } - - if (existingGroup.getId() === newGroupDef.groupId) { - return { idx: i, group: existingGroup }; - } - } - return undefined; - } - - private createColumnGroup( - providedGroup: AgProvidedColumnGroup, - groupInstanceIdCreator: GroupInstanceIdCreator, - oldColumnsMapped: { [key: string]: AgColumnGroup }, - pinned: ColumnPinnedType, - isStandaloneStructure?: boolean - ): AgColumnGroup { - const groupId = providedGroup.getGroupId(); - const instanceId = groupInstanceIdCreator.getInstanceIdForKey(groupId); - const uniqueId = createUniqueColumnGroupId(groupId, instanceId); - - let columnGroup: AgColumnGroup | null = oldColumnsMapped[uniqueId]; - - // if the user is setting new colDefs, it is possible that the id's overlap, and we - // would have a false match from above. so we double check we are talking about the - // same original column group. - if (columnGroup && columnGroup.getProvidedColumnGroup() !== providedGroup) { - columnGroup = null; - } - - if (_exists(columnGroup)) { - // clean out the old column group here, as we will be adding children into it again - columnGroup.reset(); - } else { - columnGroup = new AgColumnGroup(providedGroup, groupId, instanceId, pinned); - if (!isStandaloneStructure) { - this.createBean(columnGroup); - } - } - - return columnGroup; - } - - // returns back a 2d map of ColumnGroup as follows: groupId -> instanceId -> ColumnGroup - private mapOldGroupsById(displayedGroups: (AgColumn | AgColumnGroup)[]): { - [uniqueId: string]: AgColumnGroup; - } { - const result: { [uniqueId: HeaderColumnId]: AgColumnGroup } = {}; - - const recursive = (columnsOrGroups: (AgColumn | AgColumnGroup)[] | null) => { - for (const columnOrGroup of columnsOrGroups!) { - if (isColumnGroup(columnOrGroup)) { - const columnGroup = columnOrGroup; - result[columnOrGroup.getUniqueId()] = columnGroup; - recursive(columnGroup.getChildren()); + // Compact this refresh's live instances to the front; leftovers from larger prior builds form the stale tail. + let live = 0; + for (let j = 0, jLen = instances.length; j < jLen; ++j) { + const group = instances[j]; + if (group.buildToken === buildToken) { + instances[live++] = group; } } - }; - - if (displayedGroups) { - recursive(displayedGroups); - } - - return result; - } - - private setupParentsIntoCols( - columnsOrGroups: (AgColumn | AgColumnGroup)[] | null, - parent: AgColumnGroup | null - ): void { - for (const columnsOrGroup of columnsOrGroups ?? []) { - if (columnsOrGroup.parent !== parent) { - // parent has explicitly changed - force viewport headers now needed. - this.beans.colViewport.colsWithinViewportHash = ''; - } - columnsOrGroup.parent = parent; - if (isColumnGroup(columnsOrGroup)) { - const columnGroup = columnsOrGroup; - this.setupParentsIntoCols(columnGroup.getChildren(), columnGroup); - } + instances.length = live; } } } + +/** Resolve `originalParent` from either `AgColumn` directly or `AgColumnGroup.providedColumnGroup`. */ +const originalParentOf = (node: AgColumn | AgColumnGroup): AgProvidedColumnGroup | null => + (node.isColumn ? node : node.providedColumnGroup).originalParent; diff --git a/packages/ag-grid-community/src/columns/columnGroups/columnGroupState.ts b/packages/ag-grid-community/src/columns/columnGroups/columnGroupState.ts new file mode 100644 index 00000000000..e5066999158 --- /dev/null +++ b/packages/ag-grid-community/src/columns/columnGroups/columnGroupState.ts @@ -0,0 +1,70 @@ +import type { BeanCollection } from '../../context/context'; +import type { AgProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; +import { isProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; +import type { ColumnEventType } from '../../events'; + +export const _getColGroupState = (beans: BeanCollection): { groupId: string; open: boolean }[] => { + // Include padding groups (all built groups, not just real ones) so saved state round-trips identically. + const allGroups = beans.colModel.colsAllGroups; + const len = allGroups.length; + const result = new Array<{ groupId: string; open: boolean }>(len); + for (let i = 0; i < len; ++i) { + const group = allGroups[i]; + result[i] = { groupId: group.groupId, open: group.expanded }; + } + return result; +}; + +export const _setColGroupOpen = ( + beans: BeanCollection, + key: AgProvidedColumnGroup | string | null | undefined, + newValue: boolean, + source: ColumnEventType +): void => { + const groupId = isProvidedColumnGroup(key) ? key.groupId : key || ''; + _setColGroupState(beans, [{ groupId, open: newValue }], source); +}; + +export const _setColGroupState = ( + beans: BeanCollection, + stateItems: { groupId: string; open: boolean | undefined }[], + source: ColumnEventType +): void => { + const { colAnimation, visibleCols, eventSvc, colModel } = beans; + const groupsById = colModel.colsGroupsById; + const stateLen = stateItems.length; + if (!groupsById.size || !stateLen) { + return; + } + + colAnimation?.start(); + + let impactedGroups: AgProvidedColumnGroup[] | null = null; + for (let i = 0; i < stateLen; ++i) { + const stateItem = stateItems[i]; + const group = groupsById.get(stateItem.groupId); + if (group?.setExpanded(stateItem.open)) { + impactedGroups ??= []; + impactedGroups.push(group); + } + } + + if (impactedGroups) { + visibleCols.refresh(source, true); + eventSvc.dispatchEvent({ + type: 'columnGroupOpened', + columnGroup: impactedGroups.length === 1 ? impactedGroups[0] : undefined, + columnGroups: impactedGroups, + }); + } + + colAnimation?.finish(); +}; + +export const _resetColGroupState = (beans: BeanCollection, source: ColumnEventType): void => { + const stateItems: { groupId: string; open: boolean | undefined }[] = []; + beans.colModel.colDefGroupsById.forEach((group) => { + stateItems.push({ groupId: group.groupId, open: group.colGroupDef?.openByDefault }); + }); + _setColGroupState(beans, stateItems, source); +}; diff --git a/packages/ag-grid-community/src/columns/columnGroups/columnGroupUtils.ts b/packages/ag-grid-community/src/columns/columnGroups/columnGroupUtils.ts deleted file mode 100644 index ecac179d9f9..00000000000 --- a/packages/ag-grid-community/src/columns/columnGroups/columnGroupUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { BeanCollection } from '../../context/context'; -import type { ColGroupDef } from '../../entities/colDef'; - -export function createMergedColGroupDef( - beans: BeanCollection, - colGroupDef: ColGroupDef | null, - groupId: string -): ColGroupDef { - const colGroupDefMerged: ColGroupDef = {} as ColGroupDef; - const gos = beans.gos; - Object.assign(colGroupDefMerged, gos.get('defaultColGroupDef')); - Object.assign(colGroupDefMerged, colGroupDef); - gos.validateColDef(colGroupDefMerged, groupId); - - return colGroupDefMerged; -} diff --git a/packages/ag-grid-community/src/columns/columnKeyCreator.ts b/packages/ag-grid-community/src/columns/columnKeyCreator.ts deleted file mode 100644 index c05818a0381..00000000000 --- a/packages/ag-grid-community/src/columns/columnKeyCreator.ts +++ /dev/null @@ -1,52 +0,0 @@ -// class returns a unique id to use for the column. it checks the existing columns, and if the requested -// id is already taken, it will start appending numbers until it gets a unique id. -// eg, if the col field is 'name', it will try ids: {name, name_1, name_2...} -// if no field or id provided in the col, it will try the ids of natural numbers -import { _toStringOrNull } from 'ag-stack'; - -import { _warn } from '../validation/logging'; - -export type IColumnKeyCreator = { - getUniqueKey(colId?: string | null, colField?: string | null): string; -}; - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export class ColumnKeyCreator implements IColumnKeyCreator { - private existingKeys: { [key: string]: boolean } = {}; - - public addExistingKeys(keys: string[]): void { - for (let i = 0; i < keys.length; i++) { - this.existingKeys[keys[i]] = true; - } - } - - public getUniqueKey(colId?: string | null, colField?: string | null): string { - // in case user passed in number for colId, convert to string - colId = _toStringOrNull(colId); - - let count = 0; - - while (true) { - let idToTry: string | number | null | undefined = colId ?? colField; - if (idToTry) { - if (count !== 0) { - idToTry += '_' + count; - } - } else { - // no point in stringing this, object treats it the same anyway. - idToTry = count; - } - - if (!this.existingKeys[idToTry]) { - const usedId = String(idToTry); - if (colId && count > 0) { - _warn(273, { providedId: colId, usedId }); - } - this.existingKeys[usedId] = true; - return usedId; - } - - count++; - } - } -} diff --git a/packages/ag-grid-community/src/columns/columnModel.ts b/packages/ag-grid-community/src/columns/columnModel.ts index a3317477c90..731378e19a7 100644 --- a/packages/ag-grid-community/src/columns/columnModel.ts +++ b/packages/ag-grid-community/src/columns/columnModel.ts @@ -1,86 +1,97 @@ -import { _areEqual, _forAll } from 'ag-stack'; +import { _areEqual } from 'ag-stack'; import { placeLockedColumns } from '../columnMove/columnMoveUtils'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; -import { AgColumn } from '../entities/agColumn'; +import type { AgColumn } from '../entities/agColumn'; import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; import type { ColDef, ColGroupDef, ColKey } from '../entities/colDef'; import type { GridOptions } from '../entities/gridOptions'; import type { ColumnEventType } from '../events'; import type { PropertyChangedEvent, PropertyValueChangedEvent } from '../gridOptionsService'; -import { _isGroupHideColumnsUntilExpanded, _isRowNumbers, _shouldMaintainColumnOrder } from '../gridOptionsUtils'; -import type { IColumnCollectionService } from '../interfaces/iColumnCollectionService'; -import type { IPivotResultColsService } from '../interfaces/iPivotResultColsService'; -import { _createColumnTree } from './columnFactoryUtils'; -import type { ColumnState } from './columnStateUtils'; -import { _applyColumnState, _compareColumnStatesAndDispatchEvents } from './columnStateUtils'; -import { - _columnsMatch, - _convertColumnEventSourceType, - _destroyColumnTree, - _getColumnsFromTree, - isColumnGroupAutoCol, - isColumnSelectionCol, - isRowNumberCol, -} from './columnUtils'; - -export type Maybe = T | null | undefined; +import { _shouldMaintainColumnOrder } from '../gridOptionsUtils'; +import { _buildColumnTree, finalizeColumnTree } from './buildColumnTree'; +import { applyPrevColumnsOrder } from './colsApplyPrevOrder'; +import { ColWrapperCache } from './columnGroups/colWrapperCache'; +import { captureColumnStateChanges, dispatchColStateChanges } from './columnStateUtils'; +import { _convertColumnEventSourceType, _destroyColumnTreeAll, _destroyColumnTreeUnused } from './columnUtils'; -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export interface ColumnCollections { - // columns in a tree, leaf levels are columns, everything above is group column - tree: (AgColumn | AgProvidedColumnGroup)[]; - treeDepth: number; // depth of the tree above - // leaf level cols of the tree - list: AgColumn[]; - // cols by id, for quick lookup - map: { [id: string]: AgColumn }; -} +// Two parallel col representations: +// colDefList / colDefTree — PRIMARY cols (user-defined leaves + hierarchy virtuals). +// colsList / colsTree — DISPLAY cols: [serviceCols, ...colDefList] (or pivot result). /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class ColumnModel extends BeanStub implements NamedBean { beanName = 'colModel' as const; - // as provided by gridProp columnsDefs - private colDefs?: (ColDef | ColGroupDef)[]; - - // columns generated from columnDefs - // this doesn't change (including order) unless columnDefs prop changses. - public colDefCols?: ColumnCollections; - - // [providedCols OR pivotResultCols] PLUS autoGroupCols PLUS selectionCols - // this cols.list maintains column order. - public cols?: ColumnCollections; - - // if pivotMode is on, however pivot results are NOT shown if no pivot columns are set public pivotMode = false; - - // true when pivotResultCols are in cols - private showingPivotResult: boolean; - - private lastOrder: AgColumn[] | null; - private lastPivotOrder: AgColumn[] | null; - - // true if we are doing column spanning - public colSpanActive: boolean; - + public colSpanActive = false; public ready = false; + /** Suppresses row model refreshes during batch column state dispatching. */ public changeEventsDispatching = false; + public showingPivotResult = false; + + /** >0 inside a {@link beginColBatch}/{@link endColBatch} pair: cols services defer their flush to the outermost close. */ + private colBatchDepth = 0; + /** Set when a staged change needs a display rebuild; consumed (cleared) once by {@link performRefresh}. */ + private pendingRefresh = false; + /** A batched `buildFromColDefs` already raised `columnEverythingChanged`; stops the batch flush re-raising it. */ + private everythingChangedInBatch = false; + public colsList: AgColumn[] = []; + public colsTree: (AgColumn | AgProvidedColumnGroup)[] = []; + public colsTreeDepth = 0; + public colDefList: AgColumn[] = []; + public colDefTree: (AgColumn | AgProvidedColumnGroup)[] = []; + public colDefTreeDepth = 0; + private colDefHasMarryChildren = false; + public hasMarryChildren = false; + + /** Single source of truth for `getCol`. Pivot result colIds are namespaced (`pivot_…`). */ + public colsById: { [id: string]: AgColumn } = Object.create(null); + + public colDefGroupsById: Map = new Map(); + /** Primary cols keyed by colId / userProvidedColDef ref / field; passed back for next-build reuse. */ + private colDefColsByKey: Map = new Map(); + /** Every primary group (padding + non-padding) — sweep uses this to find orphans whose parent's + * `.children` was reassigned. */ + private colDefAllGroups: AgProvidedColumnGroup[] = []; + + /** Non-padding displayed groups by `groupId`. Pivot mode = pivot's groups; else = colDefGroupsById. */ + public colsGroupsById: Map = new Map(); + /** Every displayed group — padding groups carry `displayInstances` */ + public colsAllGroups: AgProvidedColumnGroup[] = []; + + /** Lazy fallback for ColDef-shaped keys not in `colsById`: by-ref (merged + user colDef) plus + * `field` when distinct from colId (first-write-wins). */ + private cachedColsByDef: Map | null = null; + + private cachedAllCols: AgColumn[] | null = null; + + /** Cache for getColsInStateOrder (pivot only). Invalidated on `colsList` mutation — ref equality + * can't substitute, as `moveColumns` reorders in place. */ + private cachedColsInStateOrder: AgColumn[] | null = null; + + /** Prior display order per mode, as colId snapshots, so the next refresh can restore user moves. */ + private lastOrder: string[] | null = null; + private lastPivotOrder: string[] | null = null; + /** Set when colsList order changes; {@link ensureColsListIndex} re-stamps `colsListIndex` lazily. */ + private colsListIndexDirty = true; + + /** User provided column definitions */ + public colDefs: (ColDef | ColGroupDef)[] | undefined = undefined; + + private buildTokenCounter = 0; + + /** Persistent padding-group cache for hierarchy virtual cols, handed to each column-tree build + * so `(col, depth)`-stable wrappers survive rebuilds. Owns the padding-group bean lifecycle. */ + private hierarchyWrapperCache: ColWrapperCache; public postConstruct(): void { this.pivotMode = this.gos.get('pivotMode'); + this.hierarchyWrapperCache = new ColWrapperCache(this.beans); this.addManagedPropertyListeners( - [ - 'groupDisplayType', - 'treeData', - 'treeDataDisplayType', - 'groupHideOpenParents', - 'groupHideColumnsUntilExpanded', - 'rowNumbers', - 'hidePaddedHeaderRows', - ], + ['groupDisplayType', 'treeData', 'treeDataDisplayType', 'groupHideOpenParents', 'hidePaddedHeaderRows'], (event) => this.refreshAll(_convertColumnEventSourceType(event.source)) ); this.addManagedPropertyListeners( @@ -92,645 +103,545 @@ export class ColumnModel extends BeanStub implements NamedBean { ); } - // called from SyncService, when grid has finished initialising - private createColsFromColDefs(source: ColumnEventType, preserveColumnOrder = false): void { - const { beans } = this; - const { - valueCache, - colAutosize, - rowGroupColsSvc, - pivotColsSvc, - valueColsSvc, - visibleCols, - eventSvc, - groupHierarchyColSvc, - } = beans; - // only need to dispatch before/after events if updating columns, never if setting columns for first time - const dispatchEventsFunc = this.colDefCols ? _compareColumnStatesAndDispatchEvents(beans, source) : undefined; - - // always invalidate cache on changing columns, as the column id's for the new columns - // could overlap with the old id's, so the cache would return old values for new columns. - valueCache?.expire(); + public override destroy(): void { + _destroyColumnTreeAll(this.colDefList, this.colDefAllGroups); + this.hierarchyWrapperCache.destroy(); + super.destroy(); + } - const oldCols = this.colDefCols?.list; - const oldTree = this.colDefCols?.tree; - const columnDefs = beans.calculatedColsSvc?.createProjectedColumnDefs(this.colDefs) ?? this.colDefs; - const newTree = _createColumnTree(beans, columnDefs, true, oldTree, source); + public nextBuildToken(): number { + return ++this.buildTokenCounter; + } - _destroyColumnTree(beans, this.colDefCols?.tree, newTree.columnTree); + public isPivotActive(): boolean { + return this.pivotMode && !!this.beans.pivotColsSvc?.columns?.length; + } - const tree = newTree.columnTree; - const treeDepth = newTree.treeDepth; - const list = _getColumnsFromTree(tree); - const map: { [id: string]: AgColumn } = {}; + public getCols(): AgColumn[] { + return this.colsList; + } - for (const col of list) { - map[col.getId()] = col; + /** Every column known to the grid (user, hierarchy, service, pivot result) in display (`colsList`) + * order, with parked pivot primaries appended. Lazily computed on first read after invalidation. */ + public getAllCols(): AgColumn[] { + let allCols = this.cachedAllCols; + if (!allCols) { + // While pivoting, pivot result cols are the full set (primaries are parked); `??` keeps an empty result. + const pivotAllCols = this.showingPivotResult ? this.beans.pivotResultCols?.buildAllCols() : undefined; + allCols = pivotAllCols ?? this.colsList; + this.cachedAllCols = allCols; } + return allCols; + } - this.colDefCols = { tree, treeDepth, list, map }; - - // Must create dateHierarchy columns before rowGroupSvc and pivotSvc run - // so that any groupable date columns exist beforehand. - this.createColumnsForService([groupHierarchyColSvc], this.colDefCols, source); - - rowGroupColsSvc?.extractCols(source, oldCols); - pivotColsSvc?.extractCols(source, oldCols); - valueColsSvc?.extractCols(source, oldCols); - - this.ready = true; - - this.changeEventsDispatching = true; - this.refreshCols(!preserveColumnOrder, source); - this.changeEventsDispatching = false; - - visibleCols.refresh(source); - - // this event is not used by AG Grid, but left here for backwards compatibility, - // in case applications use it - eventSvc.dispatchEvent({ - type: 'columnEverythingChanged', - source, - }); + /** Resolve a provided group by id, falling back to parked primaries while pivoting (mirrors `getCol`). */ + public getColGroup(groupId: string): AgProvidedColumnGroup | undefined { + return ( + this.colsGroupsById.get(groupId) ?? + (this.showingPivotResult ? this.colDefGroupsById.get(groupId) : undefined) + ); + } - // Row Models react to all of these events as well as new columns loaded, - // this flag instructs row model to ignore these events to reduce refreshes. - if (dispatchEventsFunc) { - this.changeEventsDispatching = true; - dispatchEventsFunc(); - this.changeEventsDispatching = false; + /** Columns in column-state order: hidden pivot primaries first, then `colsList`. Must not be mutated. */ + public getColsInStateOrder(): AgColumn[] { + // Only pivot has hidden primaries (built enterprise-side); cached on colsList. + if (!this.showingPivotResult) { + return this.colsList; } - - eventSvc.dispatchEvent({ - type: 'newColumnsLoaded', - source, - }); - - if (source === 'gridInitializing') { - colAutosize?.applyAutosizeStrategy(); + let ordered = this.cachedColsInStateOrder; + if (ordered) { + return ordered; } + ordered = this.beans.pivotResultCols?.buildColsInStateOrder() ?? this.colsList; + this.cachedColsInStateOrder = ordered; + return ordered; } - // called from: buildAutoGroupColumns (events 'groupDisplayType', 'treeData', 'treeDataDisplayType', 'groupHideOpenParents') - // createColsFromColDefs (recreateColumnDefs, setColumnsDefs), - // setPivotMode, applyColumnState, - // functionColsService.setPrimaryColList, functionColsService.updatePrimaryColList, - // pivotResultCols.setPivotResultCols - public refreshCols(newColDefs: boolean, source: ColumnEventType): void { - if (!this.colDefCols) { - return; - } - - const prevColTree = this.cols?.tree; - - this.saveColOrder(); - + /** `newColDefs`: true = colDefs changed (order restored only with `maintainColumnOrder`); false = + * dynamic refresh with unchanged colDefs (prior order restored). */ + private buildFromColDefs(source: ColumnEventType, newColDefs: boolean): void { + const beans = this.beans; const { - autoColSvc, - selectionColSvc, - calculatedColsSvc, - rowNumbersSvc, - quickFilter, - pivotResultCols, - showRowGroupCols, - rowAutoHeight, + valueCache, + colAutosize, + rowGroupColsSvc, + pivotColsSvc, + valueColsSvc, visibleCols, - colViewport, eventSvc, - formula, - } = this.beans; - - const cols = this.selectCols(pivotResultCols, this.colDefCols); - // we need to initialise the formula service before - // attempting to create the column services as currently - // the rowNumbers will automatically activate with formulas - formula?.setFormulasActive(cols); + groupHierarchyColSvc, + calculatedColsSvc, + sortSvc, + } = beans; - this.createColumnsForService([autoColSvc, selectionColSvc, rowNumbersSvc], cols, source); + // only dispatch before/after events when updating an existing column model, not on first set. + const colDefs = this.colDefs; + const stateChanges = this.ready ? captureColumnStateChanges(beans) : undefined; + valueCache?.expire(); // new ids may collide with old ids, so cached values would be wrong for new cols + + const oldCols = this.colDefList; + const oldTree = this.colDefTree; + const oldAllGroups = this.colDefAllGroups; + + const builder = _buildColumnTree( + beans, + colDefs, + true, + this.colDefGroupsById, + this.colDefColsByKey, + this.colsById, + source, + this.nextBuildToken(), + this.hierarchyWrapperCache + ); + groupHierarchyColSvc?.contributeTo(builder); + calculatedColsSvc?.contributeTo(builder); + finalizeColumnTree(builder); + + const tree = builder.columnTree; + const cols = builder.columns; + this.colDefTree = tree; + this.colDefTreeDepth = builder.treeDepth; + this.colDefList = cols; + this.colDefHasMarryChildren = builder.marryChildren; + this.colDefGroupsById = builder.groupsById; + this.colDefColsByKey = builder.colsByKey; + this.colDefAllGroups = builder.allGroups; + + // Seed colsById from the finalized primary cols (user leaves + hierarchy virtuals + calc cols). + const colsById: { [id: string]: AgColumn } = Object.create(null); + for (let i = 0, len = cols.length; i < len; ++i) { + colsById[cols[i].colId] = cols[i]; + } + this.colsById = colsById; + + if (oldTree !== tree) { + // Skip sweep when the tree ref is unchanged (group reuse + unchanged colDefs short-circuit). + _destroyColumnTreeUnused(oldCols, oldAllGroups, builder.buildToken); + } + + this.cachedColsByDef = null; + this.cachedAllCols = null; + this.cachedColsInStateOrder = null; + sortSvc?.invalidate(); + + // Single shared scan: each service classifies independently, bucketing lazily until commit. + const oldProvidedSet = oldCols.length > 0 ? new Set(oldCols) : null; + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + const colIsNew = !oldProvidedSet?.has(col); + rowGroupColsSvc?.extractCol(col, colIsNew); + pivotColsSvc?.extractCol(col, colIsNew); + valueColsSvc?.extractCol(col, colIsNew); + } + rowGroupColsSvc?.commitExtract(source); + pivotColsSvc?.commitExtract(source); + valueColsSvc?.commitExtract(source); - const shouldSortNewColDefs = _shouldMaintainColumnOrder(this.gos, this.showingPivotResult); - if (!newColDefs || shouldSortNewColDefs) { - this.restoreColOrder(cols); + this.ready = true; + this.changeEventsDispatching = true; + try { + this.refreshCols(newColDefs, source); + } finally { + this.changeEventsDispatching = false; } - calculatedColsSvc?.orderDynamicColumns(cols.list); - this.positionLockedCols(cols); - showRowGroupCols?.refresh(); - quickFilter?.refreshCols(); - - this.setColSpanActive(); - rowAutoHeight?.setAutoHeightActive(cols); + visibleCols.refresh(source, false); - // make sure any part of the gui that tries to draw, eg the header, - // will get empty lists of columns rather than stale columns. - // for example, the header will received gridColumnsChanged event, so will try and draw, - // but it will draw successfully when it acts on the virtualColumnsChanged event - visibleCols.clear(); - colViewport.clear(); - - if (!_areEqual(prevColTree, this.cols!.tree)) { - eventSvc.dispatchEvent({ - type: 'gridColumnsChanged', - }); + // unused by AG Grid but kept for backwards compatibility + eventSvc.dispatchEvent({ type: 'columnEverythingChanged', source }); + if (this.colBatchDepth > 0) { + this.everythingChangedInBatch = true; // batch flush must not re-raise it } - } - private createColumnsForService( - services: (IColumnCollectionService | undefined)[], - cols: ColumnCollections, - source: ColumnEventType - ): void { - for (const service of services) { - if (!service) { - continue; + if (stateChanges) { + this.changeEventsDispatching = true; + try { + dispatchColStateChanges(beans, source, stateChanges); + } finally { + this.changeEventsDispatching = false; } - - service.createColumns( - cols, - (updateOrder) => { - this.lastOrder = updateOrder(this.lastOrder); - this.lastPivotOrder = updateOrder(this.lastPivotOrder); - }, - source - ); - service.addColumns(cols); } - } - private selectCols( - pivotResultColsSvc: IPivotResultColsService | undefined, - colDefCols: ColumnCollections - ): ColumnCollections { - const pivotResultCols = this.pivotMode ? (pivotResultColsSvc?.getPivotResultCols() ?? null) : null; - this.showingPivotResult = pivotResultCols != null; - - const { map, list, tree, treeDepth } = pivotResultCols ?? colDefCols; - this.cols = { - list: list.slice(), - map: { ...map }, - tree: tree.slice(), - treeDepth, - }; + eventSvc.dispatchEvent({ type: 'newColumnsLoaded', source }); - if (pivotResultCols) { - // If the current columns are the same or a subset of the previous - // we keep the previous order, otherwise we go back to the order the pivot - // cols are generated in - const hasSameColumns = pivotResultCols.list.some((col) => this.cols?.map[col.colId] !== undefined); - if (!hasSameColumns) { - this.lastPivotOrder = null; - } + if (source === 'gridInitializing') { + colAutosize?.applyAutosizeStrategy(); } - return this.cols; } - public getColsToShow(): AgColumn[] { - if (!this.cols) { - return []; - } - // pivot mode is on, but we are not pivoting, so we only - // show columns we are aggregating on and possibly the selection/row numbers column - const { beans, showingPivotResult, cols } = this; - - const { valueColsSvc, selectionColSvc, gos } = beans; - const showAutoGroupAndValuesOnly = this.pivotMode && !showingPivotResult; - const showSelectionColumn = selectionColSvc?.isSelectionColumnEnabled(); - const showRowNumbers = _isRowNumbers(beans); - const valueColumns = valueColsSvc?.columns; - const hideEmptyAutoColGroups = _isGroupHideColumnsUntilExpanded(gos); - - const res = cols.list.filter((col) => { - const isAutoGroupCol = isColumnGroupAutoCol(col); - if (showAutoGroupAndValuesOnly) { - const isValueCol = valueColumns?.includes(col); - return ( - isValueCol || - (isAutoGroupCol && (!hideEmptyAutoColGroups || col.isVisible())) || - (showSelectionColumn && isColumnSelectionCol(col)) || - (showRowNumbers && isRowNumberCol(col)) - ); - } else { - // keep col if a) it's auto-group (and feature not managing visibility) or b) it's visible - return (isAutoGroupCol && !hideEmptyAutoColGroups) || col.isVisible(); - } - }); - - return res; - } - - // on events 'groupDisplayType', 'treeData', 'treeDataDisplayType', 'groupHideOpenParents' - public refreshAll(source: ColumnEventType) { - if (!this.ready) { - return; - } - this.refreshCols(false, source); - this.beans.visibleCols.refresh(source); + /** Open a column-change batch; mutations until the {@link endColBatch} share one flush. */ + public beginColBatch(): void { + this.colBatchDepth++; } - public setColsVisible(keys: (string | AgColumn)[], visible = false, source: ColumnEventType): void { - _applyColumnState( - this.beans, - { - state: keys.map((key) => ({ - colId: typeof key === 'string' ? key : key.colId, - hide: !visible, - })), - }, - source - ); + /** Close a {@link beginColBatch}; the outermost close flushes once (one source for the whole action). */ + public endColBatch(source: ColumnEventType): void { + this.colBatchDepth--; + this.flushColChanges(source, false); // refresh only if a staged op accumulated one } - /** - * Restores provided columns order to the previous order in this.lastPivotOrder / this.lastOrder - * If columns are not in the last order: - * - Check column groups, and apply column after the last column in the lowest shared group - * - If no sibling is found, apply the column at the end of the cols - */ - private restoreColOrder(cols: ColumnCollections): void { - const lastOrder = this.showingPivotResult ? this.lastPivotOrder : this.lastOrder; - if (!lastOrder) { - return; + /** Refresh once (if needed) + dispatch each touched service. Fires immediately, or defers to {@link endColBatch} + * when batched. `refresh` accumulates into {@link pendingRefresh}: membership passes `true`, order/aggFunc-only + * (`moveColumn`/`setColumnAggFunc`) pass `false` to dispatch without a rebuild. */ + public flushColChanges(source: ColumnEventType, refresh: boolean): void { + if (refresh) { + this.pendingRefresh = true; } - - // get the cols present in both new list and last order, according to the last order - const preservedOrder = lastOrder.filter((col) => cols.map[col.getId()] != null); - - // if no cols in last order are in the new, then order is already correct - if (preservedOrder.length === 0) { - return; + if (this.colBatchDepth > 0) { + return; // inside a batch: endColBatch will flush } - - // if after removing all the cols that are not in the new set, we have no cols left, - // then we don't need to do anything further, as the new order is correct. - if (preservedOrder.length === cols.list.length) { - cols.list = preservedOrder; - return; + const { rowGroupColsSvc, pivotColsSvc, valueColsSvc } = this.beans; + const pendingRefresh = this.pendingRefresh; + // A batched `buildFromColDefs` may already have raised it; consume the flag either way. + const everythingAlreadyRaised = this.everythingChangedInBatch; + this.everythingChangedInBatch = false; + const nothingStaged = + !rowGroupColsSvc?.pendingChanged && !pivotColsSvc?.pendingChanged && !valueColsSvc?.pendingChanged; + if (nothingStaged && !pendingRefresh) { + return; // no staged dispatch and no deferred refresh } - - const hasSiblings = (col: AgColumn | AgProvidedColumnGroup): boolean => { - const ancestor = col.getOriginalParent(); - if (!ancestor) { - return false; - } - const children = ancestor.getChildren(); - if (children.length > 1) { - return true; - } - return hasSiblings(ancestor); - }; - - // if none of the preserved cols have siblings; shortcut, as all new cols can be added to the end - // this is a common scenario due to generated cols. - if (!preservedOrder.some((col) => hasSiblings(col))) { - const preservedOrderSet = new Set(preservedOrder); - for (const col of cols.list) { - if (!preservedOrderSet.has(col)) { - preservedOrder.push(col); - } + if (pendingRefresh) { + this.performRefresh(source); // clears pendingRefresh + // Legacy compat: a role membership change (rowGroup/pivot/value add/remove/set) raised this. + if (!everythingAlreadyRaised) { + this.eventSvc.dispatchEvent({ type: 'columnEverythingChanged', source }); } - cols.list = preservedOrder; - return; - } - - // create map of known col positions and their indices - const colPositionMap = new Map(); - for (let i = 0; i < preservedOrder.length; i++) { - const col = preservedOrder[i]; - colPositionMap.set(col, i); } + rowGroupColsSvc?.dispatchColChange(source); + pivotColsSvc?.dispatchColChange(source); + valueColsSvc?.dispatchColChange(source); + } - // find any cols that have been introduced that are not in the last order - const additionalCols = cols.list.filter((col) => !colPositionMap.has(col)); - - // no additional cols to be inserted, probably means cols were removed, but preserved order is correct. - if (additionalCols.length === 0) { - cols.list = preservedOrder; + public refreshCols(newColDefs: boolean, source: ColumnEventType): void { + if (!this.ready) { return; } - - // Function finds the sibling with the lowest shared parent and highest index in last order - const getPreviousSibling = (col: AgColumn, group: AgProvidedColumnGroup | null): AgColumn | null => { - const parent = group ? group.getOriginalParent() : col.getOriginalParent(); - if (!parent) { - return null; - } - - let highestIdx: number | null = null; - let highestSibling: AgColumn | null = null; - for (const child of parent.getChildren()) { - // shortcut - skip the group that has already been processed - if (child === group || child === col) { - continue; - } - - if (child instanceof AgColumn) { - const colIdx = colPositionMap.get(child); - // if col does not exist in last order, skip - if (colIdx == null) { - continue; - } - - if (highestIdx == null || highestIdx < colIdx) { - highestIdx = colIdx; - highestSibling = child; - } - continue; - } - - child.forEachLeafColumn((leafCol) => { - const colIdx = colPositionMap.get(leafCol); - // if col does not exist in last order, skip - if (colIdx == null) { - return; - } - - if (highestIdx == null || highestIdx < colIdx) { - highestIdx = colIdx; - highestSibling = leafCol; - } - }); - } - - if (highestSibling == null) { - return getPreviousSibling(col, parent); + const beans = this.beans; + const gos = this.gos; + const colDefList = this.colDefList; + const prevColTree = this.colsTree; + const prevWasPivot = this.showingPivotResult; + const resultColsSvc = this.pivotMode ? beans.pivotResultCols : null; + const pivotCols = resultColsSvc?.pivotCols ?? null; + const pivotResultCols = pivotCols != null ? resultColsSvc : null; + const showingPivotResult = !!pivotResultCols; + this.showingPivotResult = showingPivotResult; + const sourceList = pivotCols ?? colDefList; + const sourceTree = pivotResultCols ? pivotResultCols.pivotTree : this.colDefTree; + const sourceTreeDepth = pivotResultCols ? pivotResultCols.pivotTreeDepth : this.colDefTreeDepth; + this.colsTreeDepth = sourceTreeDepth; + this.hasMarryChildren = pivotResultCols ? pivotResultCols.pivotHasMarryChildren : this.colDefHasMarryChildren; + this.colsGroupsById = pivotResultCols ? pivotResultCols.pivotGroupsById : this.colDefGroupsById; + this.colsAllGroups = pivotResultCols ? pivotResultCols.pivotAllGroups : this.colDefAllGroups; + // Service refresh runs in dependency order + beans.formula?.setFormulasActive(sourceList); + const autoCols = beans.autoColSvc?.refreshCols(source); + const selectionCol = beans.selectionColSvc?.refreshCols(); + const rowNumberCol = beans.rowNumbersSvc?.refreshCols(); + // Snapshot prior colsList colIds into the mode's lastOrder so the next refresh restores user moves. + const oldColsList = this.colsList; + if (oldColsList.length > 0) { + if (prevWasPivot) { + this.lastPivotOrder = snapshotColIds(oldColsList, this.lastPivotOrder); + } else { + this.lastOrder = snapshotColIds(oldColsList, this.lastOrder); } - return highestSibling; - }; - - // array of cols that have no siblings in the last order, to be added at the tail of the results - const noSiblingsAvailable: AgColumn[] = []; - - // map is keyed by cols in last order, and values are the cols that should be added after them - // in results array - const previousSiblingPosMap: Map = new Map(); - - // for each new col, find the col it needs inserted after and store for when array is constructed - for (const col of additionalCols) { - const prevSiblingIdx = getPreviousSibling(col, null); - if (prevSiblingIdx == null) { - noSiblingsAvailable.push(col); - continue; + } + // Emit in display order: rowNumbers → selection → autoGroup → user/pivot body cols. + const autoColsLen = autoCols?.length ?? 0; + const sourceListLen = sourceList.length; + const sourceTreeLen = sourceTree.length; + const serviceColsLen = (rowNumberCol ? 1 : 0) + (selectionCol ? 1 : 0) + autoColsLen; + // Pre-allocated at final size — the assemble loops below fill by index, not push. + const colsList = new Array(serviceColsLen + sourceListLen); + const colsTree = new Array(serviceColsLen + sourceTreeLen); + const colsById: { [id: string]: AgColumn } = Object.create(null); + let colsIdx = 0; + if (rowNumberCol) { + colsList[colsIdx++] = rowNumberCol; + } + if (selectionCol) { + colsList[colsIdx++] = selectionCol; + } + for (let i = 0; i < autoColsLen; ++i) { + colsList[colsIdx++] = autoCols![i]; + } + // At depth 0 the wrapper IS the col, so skip the wrap loop; still drop cached wrappers from + // a prior depth>0 build so service cols don't point at a stale wrapper. + const serviceWrapperCache = beans.colGroupSvc.wrapperCache; + if (sourceTreeDepth > 0) { + const buildToken = this.nextBuildToken(); + for (let i = 0; i < serviceColsLen; ++i) { + const col = colsList[i]; + colsById[col.colId] = col; + col.inColsList = true; + colsTree[i] = serviceWrapperCache.wrap(col, sourceTreeDepth, buildToken); } - - const prev = previousSiblingPosMap.get(prevSiblingIdx); - if (prev === undefined) { - previousSiblingPosMap.set(prevSiblingIdx, col); - } else if (Array.isArray(prev)) { - prev.push(col); - } else { - // if we have a single col, then we need to add the new col to the array - previousSiblingPosMap.set(prevSiblingIdx, [prev, col]); + serviceWrapperCache.evict(buildToken); + } else { + serviceWrapperCache.destroy(); + for (let i = 0; i < serviceColsLen; ++i) { + const col = colsList[i]; + colsById[col.colId] = col; + col.inColsList = true; + col.originalParent = null; + colsTree[i] = col; } } - // the following code starts at the tail of the array and works backwards. - // first it applies all of the cols with no siblings (so no location in last order) - // then it works backwards through the preserved order - when a col has siblings, it adds - // them to the array and then adds the col itself. - - const result = new Array(cols.list.length); - let resultPointer = result.length - 1; - // work backwards, first adding no siblings to end - for (let i = noSiblingsAvailable.length - 1; i >= 0; i--) { - result[resultPointer--] = noSiblingsAvailable[i]; - } - - for (let i = preservedOrder.length - 1; i >= 0; i--) { - const nextCol = preservedOrder[i]; - const extraCols = previousSiblingPosMap.get(nextCol); - if (extraCols) { - if (Array.isArray(extraCols)) { - // add the extra cols backwards. - for (let x = extraCols.length - 1; x >= 0; x--) { - const col = extraCols[x]; - result[resultPointer--] = col; - } + // In pivot mode, sourceList = pivotCols; primaries (colDefList) need colsById entries for lookups + // but are parked out of colsList (`inColsList = false`). Non-pivot covers them via the next loop. + if (pivotResultCols) { + // Entering pivot: freeze the current display order so `getColumnDefs` keeps reporting it. + if (!prevWasPivot) { + this.ensureColsListIndex(); + } + // A col added while pivoting has no frozen index (-1); seat it after its left colDef neighbour so + // `getColumnDefs` reports it in colDef order (stable sort breaks the tie) without disturbing others. + let lastIndex = -1; + for (let i = 0, len = colDefList.length; i < len; ++i) { + const col = colDefList[i]; + colsById[col.colId] = col; + col.inColsList = false; + if (col.colsListIndex < 0) { + col.colsListIndex = lastIndex; } else { - result[resultPointer--] = extraCols; + lastIndex = col.colsListIndex; } } - result[resultPointer--] = nextCol; } - cols.list = result; - } - - private positionLockedCols(cols: ColumnCollections): void { - cols.list = placeLockedColumns(cols.list, this.gos); - } - - private saveColOrder(): void { - if (this.showingPivotResult) { - this.lastPivotOrder = this.cols?.list ?? null; - } else { - this.lastOrder = this.cols?.list ?? null; + for (let i = 0; i < sourceListLen; ++i) { + const col = sourceList[i]; + colsList[colsIdx++] = col; + colsById[col.colId] = col; + col.inColsList = true; + } + for (let i = 0; i < sourceTreeLen; ++i) { + colsTree[serviceColsLen + i] = sourceTree[i]; + } + const restoreOrder = !newColDefs || _shouldMaintainColumnOrder(gos, showingPivotResult); + const lastOrder = showingPivotResult ? this.lastPivotOrder : this.lastOrder; + const prevOrder = restoreOrder ? lastOrder : null; + const ordered = prevOrder == null ? colsList : applyPrevColumnsOrder(colsList, colsById, prevOrder); + const finalColsList = placeLockedColumns(ordered, gos); + const colsListChanged = !_areEqual(finalColsList, oldColsList); + if (colsListChanged) { + this.colsListIndexDirty = true; + } + this.colsList = colsListChanged ? finalColsList : oldColsList; + this.colsTree = _areEqual(colsTree, prevColTree) ? prevColTree : colsTree; + this.colsById = colsById; + this.cachedColsByDef = null; + this.cachedAllCols = null; + this.cachedColsInStateOrder = null; + beans.sortSvc?.invalidate(); + this.refreshColsDerivedState(); + if (colsListChanged) { + beans.rowSpanSvc?.refreshCols(); + } + if (this.colsTree !== prevColTree) { + this.eventSvc.dispatchEvent({ type: 'gridColumnsChanged' }); + } + } + + /** Refresh state derived from `colsList` (group + quick-filter cols, colSpan/autoHeight flags) and + * reset displayed-col + viewport caches, ahead of `visibleCols.refresh`. Shared by full refreshCols + * and by a visibility-only change (which leaves `colsList` unchanged, so skips the rebuild). */ + public refreshColsDerivedState(): void { + const beans = this.beans; + beans.showRowGroupCols?.refresh(); + beans.quickFilter?.refreshCols(); + this.computeColSpanAndAutoHeight(); + beans.visibleCols.clear(); + beans.colViewport.clear(); + } + + /** Single pass: set `colSpanActive` and `rowAutoHeight.active` from `colsList`. */ + private computeColSpanAndAutoHeight(): void { + const colsList = this.colsList; + const rowAutoHeight = this.beans.rowAutoHeight; + let colSpan = false; + let autoHeight = false; + for (let i = 0, len = colsList.length; i < len; ++i) { + const col = colsList[i]; + const colDef = col.colDef; + colSpan ||= colDef.colSpan != null; + autoHeight ||= !!rowAutoHeight && !!colDef.autoHeight && col.visible; + if (colSpan && (autoHeight || !rowAutoHeight)) { + break; + } } + this.colSpanActive = colSpan; + rowAutoHeight?.setAutoHeightActive(autoHeight); } - public getColumnDefs(sorted?: boolean): (ColDef | ColGroupDef)[] | undefined { - return ( - this.colDefCols && - this.beans.colDefFactory?.getColumnDefs( - this.colDefCols.list, - this.showingPivotResult, - this.lastOrder, - this.cols?.list ?? [], - sorted - ) - ); - } - - public getProvidedColumnDefs(): (ColDef | ColGroupDef)[] | undefined { - return this.colDefs; + /** Full refresh (rebuild cols + recompute visible); immediate, or deferred to {@link endColBatch} when batched. */ + public refreshAll(source: ColumnEventType): void { + if (!this.ready) { + return; + } + if (this.colBatchDepth > 0) { + this.pendingRefresh = true; // defer; the flush at endColBatch performs it + return; + } + this.performRefresh(source); } - private setColSpanActive(): void { - this.colSpanActive = !!this.cols?.list.some((col) => col.getColDef().colSpan != null); + private performRefresh(source: ColumnEventType): void { + this.pendingRefresh = false; // consumed: this refresh satisfies the pending request + if (this.ready) { + this.refreshCols(false, source); + this.beans.visibleCols.refresh(source, false); + } } - public isPivotMode(): boolean { - return this.pivotMode; + /** Reorder `colDefList` only — `newList` MUST be a permutation of the existing col instances + * (caller owns the invariant). A full refresh should follow to propagate to display cols. + * `colsById` unchanged; `getAllCols` is order-agnostic. */ + public replaceColDefList(newList: AgColumn[]): void { + if (this.ready) { + this.colDefList = newList; + } } private setPivotMode(pivotMode: boolean, source: ColumnEventType): void { if (pivotMode === this.pivotMode) { return; } - this.pivotMode = pivotMode; - - if (!this.ready) { - return; + if (this.ready) { + // Refresh in case the auto-group col must be added/removed: with `groupDisplayType: 'custom'` + // it's only used in pivot mode (where it's mandatory). + this.refreshCols(false, source); + this.beans.visibleCols.refresh(source, false); + this.eventSvc.dispatchEvent({ type: 'columnPivotModeChanged' }); } - - // we need to update grid columns to cover the scenario where user has groupDisplayType = 'custom', as - // this means we don't use auto group column UNLESS we are in pivot mode (it's mandatory in pivot mode), - // so need to updateCols() to check it autoGroupCol needs to be added / removed - this.refreshCols(false, source); - const { visibleCols, eventSvc } = this.beans; - visibleCols.refresh(source); - - eventSvc.dispatchEvent({ - type: 'columnPivotModeChanged', - }); - } - - // + clientSideRowModel - public isPivotActive(): boolean { - const pivotColumns = this.beans.pivotColsSvc?.columns; - return this.pivotMode && !!pivotColumns?.length; } - // called when dataTypes change public recreateColumnDefs(e: PropertyChangedEvent | PropertyValueChangedEvent): void { - if (!this.cols) { - return; + if (this.ready) { + // Auto cols aren't in `colDefs`, so refresh their derived defs before the rebuild re-reads user colDefs. + this.beans.autoColSvc?.updateColumns(e); + this.buildFromColDefs(_convertColumnEventSourceType(e.source), true); } - - // if we aren't going to force, update the auto cols in place - this.beans.autoColSvc?.updateColumns(e); - const source = _convertColumnEventSourceType(e.source); - this.createColsFromColDefs(source); } public setColumnDefs(columnDefs: (ColDef | ColGroupDef)[], source: ColumnEventType) { this.beans.calculatedColsSvc?.resetDynamicColumnDefs(); this.colDefs = columnDefs; - this.createColsFromColDefs(source); + this.buildFromColDefs(source, true); } - public refreshDynamicColumns(source: ColumnEventType): void { - if (!this.ready) { - return; + /** Full structural rebuild from current `colDefs` + all contributors (hierarchy, calc cols, …). + * Used by contributors whose mutation changes tree structure (e.g. calc-col add/update/remove). */ + public rebuildCols(source: ColumnEventType): void { + if (this.ready) { + this.buildFromColDefs(source, false); } - - this.createColsFromColDefs(source, this.beans.calculatedColsSvc?.shouldPreserveColumnOrderOnRefresh()); - } - - public override destroy(): void { - _destroyColumnTree(this.beans, this.colDefCols?.tree); - super.destroy(); } - public getColTree(): (AgColumn | AgProvidedColumnGroup)[] { - return this.cols?.tree ?? []; + /** Mark `AgColumn.colsListIndex` stale — called when `colsList`'s order changes outside a refresh + * (`moveColumns`, `applyColumnState` with `applyOrder`); next `ensureColsListIndex` re-stamps. */ + public markColsListIndexDirty(): void { + this.colsListIndexDirty = true; + this.cachedColsInStateOrder = null; } - // + columnSelectPanel - public getColDefColTree(): (AgColumn | AgProvidedColumnGroup)[] { - return this.colDefCols?.tree ?? []; - } - - // + clientSideRowController -> sorting, building quick filter text - // + headerRenderer -> sorting (clearing icon) - public getColDefCols(): AgColumn[] | null { - return this.colDefCols?.list ?? null; - } - - // + moveColumnController - public getCols(): AgColumn[] { - return this.cols?.list ?? []; + /** Lazily stamp each col's index in `colsList` onto `AgColumn.colsListIndex`, once per order change. + * Readers (e.g. `getColumnDefs`) call this before reading `col.colsListIndex`, so a burst of moves + * costs one O(N) pass on the next read, not one per move. */ + public ensureColsListIndex(): void { + if (this.colsListIndexDirty) { + const colsList = this.colsList; + for (let i = 0, len = colsList.length; i < len; ++i) { + colsList[i].colsListIndex = i; + } + this.colsListIndexDirty = false; + } } - /** - * If callback returns true, exit early. - */ - public forAllCols(callback: (column: AgColumn) => boolean | void): void { - const { pivotResultCols, autoColSvc, selectionColSvc, groupHierarchyColSvc } = this.beans; - if (_forAll(this.colDefCols?.list, callback)) { - return; - } - if (_forAll(autoColSvc?.columns?.list, callback)) { - return; - } - if (_forAll(selectionColSvc?.columns?.list, callback)) { - return; + /** Resolve any key (colId string, AgColumn, or ColDef) to its current AgColumn. Fast path inline: + * string colId, or object whose `colId` hits `colsById` (O(1)); misses delegate to `getColFallback`. */ + public getCol(key: ColKey | null | undefined): AgColumn | undefined { + if (typeof key === 'string') { + return this.colsById[key] ?? this.getColFallback(key); } - if (_forAll(groupHierarchyColSvc?.columns?.list, callback)) { - return; - } - if (_forAll(pivotResultCols?.getPivotResultCols()?.list, callback)) { - return; + // `?.colId` collapses the null/undefined + non-object checks into one access: both yield + // `undefined`, falling through to the slow path. + const id = (key as { colId?: unknown } | null | undefined)?.colId; + if (typeof id === 'string') { + const col = this.colsById[id]; + if (col !== undefined) { + return col; + } } + return key == null ? undefined : this.getColFallback(key); } - public getColsForKeys(keys: ColKey[]): AgColumn[] { - if (!keys) { - return []; + /** Slow-path fallback for `getCol`: ColDef/ColGroupDef ref lookup + `field`-string fallback, building + * the lazy `colsByDef` map on first use. Stale AgColumn refs return `undefined` (unregistered, no `field`). */ + private getColFallback(key: ColKey): AgColumn | undefined { + const map = this.cachedColsByDef ?? this.loadColsByDef(); + const byRef = map.get(key); + if (byRef !== undefined) { + return byRef; } - return keys.map((key) => this.getCol(key)).filter((col): col is AgColumn => col != null); - } - - public getColDefCol(key: ColKey): AgColumn | null { - return this.getColFromCollection(key, this.colDefCols) ?? this.getColFromServiceCols(key); + if (typeof key !== 'object') { + return undefined; + } + const field = (key as { field?: string }).field; + return typeof field === 'string' ? map.get(field) : undefined; } - /** Look up by key across colDefCols, displayed cols, and service columns (auto-group, selection, row-number). */ - public getColDefColOrCol(key: Maybe): AgColumn | null { - if (key == null) { - return null; - } - return ( - this.getColFromCollection(key, this.colDefCols) ?? - this.getColFromCollection(key, this.cols) ?? - this.getColFromServiceCols(key) - ); + /** Find a column excluding pivot result cols. `pivotKeys` (grid-set) is an O(1) discriminator + * standing in for a `pivotCols`-membership test, so `getColumnDefs()` doesn't round-trip them. */ + public getNonPivotCol(key: ColKey): AgColumn | undefined { + const col = this.getCol(key); + return col !== undefined && col.colDef.pivotKeys == null ? col : undefined; } - /** Look up by key across displayed cols, colDefCols, and service columns — prefers displayed cols. */ - public getColOrColDefCol(key: Maybe): AgColumn | null { - if (key == null) { - return null; - } - return ( - this.getColFromCollection(key, this.cols) ?? - this.getColFromCollection(key, this.colDefCols) ?? - this.getColFromServiceCols(key) - ); + /** Like `getNonPivotCol` for hot paths where the key is a known colId string — skips the type-check + field-fallback. */ + public getNonPivotColById(key: string): AgColumn | undefined { + const col = this.colsById[key]; + return col !== undefined && col.colDef.pivotKeys == null ? col : undefined; } - public getCol(key: Maybe): AgColumn | null { - if (key == null) { - return null; + private loadColsByDef(): Map { + const map = new Map(); + addColsToDefMap(map, this.colsList); + // In pivot mode colDefList cols are in colsById but not colsList — include them too + if (this.showingPivotResult) { + addColsToDefMap(map, this.colDefList); } - return this.getColFromCollection(key, this.cols) ?? this.getColFromServiceCols(key); + this.cachedColsByDef = map; + return map; } +} - /** - * Get column exclusively by ID. - * - * Note getCol/getColFromCollection have poor performance when col has been removed. - */ - public getColById(key: string): AgColumn | null { - return this.cols?.map[key] ?? null; +const snapshotColIds = (list: AgColumn[], out?: string[] | null): string[] => { + const len = list.length; + out ??= []; + out.length = len; + for (let i = 0; i < len; ++i) { + out[i] = list[i].colId; } + return out; +}; - public getColFromCollection(key: ColKey, cols?: ColumnCollections): AgColumn | null { - if (cols == null) { - return null; +/** Indexes each col by its colDef, its user-provided colDef and (as a fallback) its field. */ +const addColsToDefMap = (map: Map, list: AgColumn[]): void => { + for (let i = 0, len = list.length; i < len; ++i) { + const col = list[i]; + const colDef = col.colDef; + map.set(colDef, col); + const provided = col.userProvidedColDef; + if (provided != null) { + map.set(provided, col); } - const map = cols.map; - // most of the time this method gets called the key is a string, so we put this shortcut in - // for performance reasons, to see if we can match for ID (it doesn't do auto columns, that's done below) - if (typeof key == 'string' && map[key]) { - return map[key]; - } - - const list = cols.list; - for (let i = 0, len = list.length; i < len; ++i) { - if (_columnsMatch(list[i], key)) { - return list[i]; - } + const field = colDef.field; + if (field && field !== col.colId && !map.has(field)) { + map.set(field, col); } - return null; } - - private getColFromServiceCols(key: ColKey): AgColumn | null { - const beans = this.beans; - return ( - beans.autoColSvc?.getColumn(key) ?? - beans.selectionColSvc?.getColumn(key) ?? - beans.groupHierarchyColSvc?.getColumn(key) ?? - null - ); - } -} +}; diff --git a/packages/ag-grid-community/src/columns/columnNameService.test.ts b/packages/ag-grid-community/src/columns/columnNameService.test.ts index 942e1914815..0b0f49832cf 100644 --- a/packages/ag-grid-community/src/columns/columnNameService.test.ts +++ b/packages/ag-grid-community/src/columns/columnNameService.test.ts @@ -12,7 +12,7 @@ describe('_camelCaseToHumanText', () => { ['person.address.town', 'Person Address Town'], ['person_address.town', 'Person_address Town'], ])('Value: %s', (field, expected) => { - const column = new AgColumn({ field }, null, field, false); + const column = new AgColumn({ field }, null, field, false, 'user'); const columnNameService = new ColumnNameService(); (columnNameService as any).beans = {} as any; // Mock beans diff --git a/packages/ag-grid-community/src/columns/columnNameService.ts b/packages/ag-grid-community/src/columns/columnNameService.ts index a6217a66d9c..a05560b63b6 100644 --- a/packages/ag-grid-community/src/columns/columnNameService.ts +++ b/packages/ag-grid-community/src/columns/columnNameService.ts @@ -13,21 +13,18 @@ export class ColumnNameService extends BeanStub implements NamedBean { beanName = 'colNames' as const; public getDisplayNameForColumn( - column: AgColumn | null, + column: AgColumn | null | undefined, location: HeaderLocation, includeAggFunc = false ): string | null { if (!column) { return null; } - - const headerName: string | null = this.getHeaderName(column.getColDef(), column, null, null, location); - - const { aggColNameSvc } = this.beans; + const headerName: string | null = this.getHeaderName(column.colDef, column, null, null, location); + const aggColNameSvc = this.beans.aggColNameSvc; if (includeAggFunc && aggColNameSvc) { return aggColNameSvc.getHeaderName(column, headerName); } - return headerName; } @@ -36,7 +33,7 @@ export class ColumnNameService extends BeanStub implements NamedBean { providedColumnGroup: AgProvidedColumnGroup | null, location: HeaderLocation ): string | null { - const colGroupDef = providedColumnGroup?.getColGroupDef(); + const colGroupDef = providedColumnGroup?.colGroupDef; if (colGroupDef) { return this.getHeaderName(colGroupDef, null, columnGroup, providedColumnGroup, location); @@ -46,7 +43,7 @@ export class ColumnNameService extends BeanStub implements NamedBean { } public getDisplayNameForColumnGroup(columnGroup: AgColumnGroup, location: HeaderLocation): string | null { - return this.getDisplayNameForProvidedColumnGroup(columnGroup, columnGroup.getProvidedColumnGroup(), location); + return this.getDisplayNameForProvidedColumnGroup(columnGroup, columnGroup.providedColumnGroup, location); } // location is where the column is going to appear, ie who is calling us diff --git a/packages/ag-grid-community/src/columns/columnStateUtils.ts b/packages/ag-grid-community/src/columns/columnStateUtils.ts index 2277d810ec0..cbbfd7b3541 100644 --- a/packages/ag-grid-community/src/columns/columnStateUtils.ts +++ b/packages/ag-grid-community/src/columns/columnStateUtils.ts @@ -1,34 +1,29 @@ -import { _areEqual, _exists, _missing, _removeFromArray } from 'ag-stack'; +import { _areEqual, _symmetricDiff } from 'ag-stack'; import { doesMovePassMarryChildren, placeLockedColumns } from '../columnMove/columnMoveUtils'; import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; import { - _areSortDefsEqual, - _getSortDefFromInput, - _isSortDirectionValid, - _isSortTypeValid, - _normalizeSortDirection, _normalizeSortType, + getSortDefFromInput, + isSortDirectionValid, + isSortTypeValid, + normalizeSortDirection, } from '../entities/agColumn'; -import type { IAggFunc } from '../entities/colDef'; -import type { ColumnEvent, ColumnEventType, ColumnsResetEvent } from '../events'; -import type { GridOptionsService } from '../gridOptionsService'; +import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; +import type { ColAggFunc, IAggFunc } from '../entities/colDef'; +import type { ColumnEventType, ColumnsResetEvent } from '../events'; import { _addGridCommonParams } from '../gridOptionsUtils'; import type { ColumnPinnedType } from '../interfaces/iColumn'; -import type { WithoutGridCommon } from '../interfaces/iCommon'; -import type { IEventService } from '../interfaces/iEventService'; -import type { SortDirection, SortType } from '../interfaces/iSort'; +import type { SortDef, SortDirection, SortType } from '../interfaces/iSort'; import { _warn } from '../validation/logging'; import { - dispatchColumnChangedEvent, + _dispatchColumnChangedEvent, dispatchColumnPinnedEvent, dispatchColumnResizedEvent, dispatchColumnVisibleEvent, } from './columnEventUtils'; -import { updateSomeColumnState } from './columnFactoryUtils'; -import type { ColumnCollections, ColumnModel } from './columnModel'; -import { GROUP_AUTO_COLUMN_ID, _getColumnsFromTree, getValueFactory, isColumnSelectionCol } from './columnUtils'; +import { GROUP_AUTO_COLUMN_ID, SELECTION_COLUMN_ID } from './columnUtils'; export interface ColumnStateParams { /** True if the column is hidden */ @@ -71,679 +66,763 @@ export interface ApplyColumnStateParams { defaultState?: ColumnStateParams; } -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +/** Pre-mutation snapshot; `dispatchColStateChanges` diffs against it to fire the right column events. */ +interface ColumnStateChanges { + /** Pre-mutation snapshot (`sortColumns` mutates `.columns` in place); `undefined` when empty/absent. */ + rowGroupColumns: AgColumn[] | undefined; + pivotColumns: AgColumn[] | undefined; + /** Per-column scalar snapshot keyed by colId; insertion order = capture order. */ + before: Map; + /** Pre-mutation `colsList` ref. Unchanged ref ⇒ order untouched (only ever reassigned) ⇒ skip the O(n) diff. */ + colsList: AgColumn[]; +} + +/** Minimal pre-apply snapshot: only the fields `dispatchColumnFieldChanges` diffs. */ +interface ColumnStateBefore { + width: number; + hide: boolean; + pinned: ColumnPinnedType; + sort: SortDirection; + sortType: SortType | undefined; + sortIndex: number | null; + aggFunc: ColAggFunc; +} + +/** Updates hide/sort/sortIndex/pinned/flex. Per field: `null`/empty clears, only `undefined` is skipped. */ +export const updateSomeColumnState = ( + beans: BeanCollection, + column: AgColumn, + hide: boolean | null | undefined, + sort: SortDirection | SortDef | undefined, + sortIndex: number | null | undefined, + pinned: boolean | 'left' | 'right' | null | undefined, + flex: number | null | undefined, + source: ColumnEventType +): void => { + const { sortSvc, pinnedCols, colFlex } = beans; + if (hide !== undefined) { + column.setVisible(!hide, source); + } + if (sortSvc) { + sortSvc.updateColSort(column, sort, source); + if (sortIndex !== undefined) { + sortSvc.setColSortIndex(column, sortIndex); + } + } + if (pinned !== undefined) { + pinnedCols?.setColPinned(column, pinned); + } + if (flex !== undefined) { + colFlex?.setColFlex(column, flex); + } +}; + +/** Show/hide columns — skips the full `_applyColumnState` rebuild (visibility can't change colsList membership/order). + * `filterLockedColumns` (UI paths) skips `lockVisible` cols; the API path passes `false`. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _setColsVisible( + beans: BeanCollection, + keys: (string | AgColumn)[], + visible = false, + source: ColumnEventType, + filterLockedColumns = false +): void { + const colModel = beans.colModel; + const newVisible = visible === true; + let changed: AgColumn[] | null = null; + for (let i = 0, len = keys.length; i < len; ++i) { + const key = keys[i]; + const col = typeof key === 'string' ? colModel.getCol(key) : key; + if (col === undefined || (filterLockedColumns && col.colDef.lockVisible)) { + continue; + } + if (col.visible !== newVisible) { + col.setVisible(newVisible, source); + changed ??= []; + changed.push(col); + } + } + if (changed) { + const { colAnimation, eventSvc } = beans; + colAnimation?.start(); + colModel.refreshColsDerivedState(); + beans.visibleCols.refresh(source, false); + eventSvc.dispatchEvent({ type: 'columnEverythingChanged', source }); + dispatchColumnVisibleEvent(eventSvc, changed, source); + colAnimation?.finish(); + } +} + +/** Apply `ColumnState` across two passes, then — once for the whole operation — re-order, refresh the + * visible cols and dispatch the resulting events. Returns `true` if every provided state matched a column. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _applyColumnState( beans: BeanCollection, params: ApplyColumnStateParams, source: ColumnEventType ): boolean { - const { - colModel, - rowGroupColsSvc, - calculatedColsSvc, - pivotColsSvc, - autoColSvc, - selectionColSvc, - colAnimation, - visibleCols, - pivotResultCols, - environment, - valueColsSvc, - eventSvc, - gos, - } = beans; - - const state = params?.state; - if (state && !state.forEach) { - // state is not an array - _warn(32); + const { colModel, colAnimation, calculatedColsSvc } = beans; + const state = params.state; + if (state && !Array.isArray(state)) { + _warn(32); // state is not an array return false; } - if (state) { - const colIds = new Array(state.length); - for (let i = 0, len = state.length; i < len; ++i) { - colIds[i] = state[i].colId; - } - if (calculatedColsSvc?.restoreDynamicColumnDefs(colIds)) { - calculatedColsSvc.refreshProjectedColumns(source); - } + // Re-add calc cols this state names that a prior reset parked — via the calc svc, so the rebuild + // suppresses calc lifecycle/validation events (a state op, not a user calc-col change). + if (state && calculatedColsSvc?.restoreDynamicColumnDefs(state)) { + calculatedColsSvc.refreshDynamicColumns(source); } - const providedCols = colModel.getColDefCols() ?? []; - const selectionCols = selectionColSvc?.getColumns(); - if (!providedCols.length && !selectionCols?.length) { + const providedCols = colModel.colDefList; + const selectionCol = beans.selectionColSvc?.column; + if (!providedCols.length && !selectionCol) { return false; } - const syncColumnWithStateItem = ( - column: AgColumn | null, - stateItem: ColumnState | null, - rowGroupIndexes: { [key: string]: number } | null, - pivotIndexes: { [key: string]: number } | null, - autoCol: boolean - ) => { - if (!column) { - return; - } + colAnimation?.start(); - const getValue = getValueFactory(stateItem, params.defaultState); - - const flex = getValue('flex').value1; - - const maybeSortDirection = getValue('sort').value1; - const maybeSortType = getValue('sortType').value1; - const isSortUpdate = _isSortDirectionValid(maybeSortDirection) || _isSortTypeValid(maybeSortType); - /** - * If only a direction is provided, we treat it as a default sort type. - * User must provide both sortType and direction if they wish to preserve sort type - */ - const type = _normalizeSortType(maybeSortType); - const direction = _normalizeSortDirection(maybeSortDirection); - const newSortDef = isSortUpdate ? { type, direction } : undefined; - - updateSomeColumnState( - beans, - column, - getValue('hide').value1, - newSortDef, - getValue('sortIndex').value1, - getValue('pinned').value1, - flex, - source - ); - - // if flex is null or undefined, fall back to setting width - if (flex == null) { - // if no flex, then use width if it's there - const width = getValue('width').value1; - if (width != null) { - // if width provided and valid, use it, otherwise stick with the old width - const minColWidth = column.getColDef().minWidth ?? environment.getDefaultColumnMinWidth(); - if (minColWidth != null && width >= minColWidth) { - column.setActualWidth(width, source); - } - } - } + // Capture once, before any mutation: the single dispatch below diffs the whole operation. + const stateChanges = captureColumnStateChanges(beans); - // we do not do aggFunc, rowGroup or pivot for auto cols or secondary cols - if (autoCol || !column.primary) { - return; - } + // Pass 1 — primary cols. Structural: `refreshCols` (re)creates auto/pivot-result/service cols. + let unmatched = applyStateToCols(beans, state ?? null, providedCols, params, source, true); - valueColsSvc?.syncColumnWithState(column, source, getValue); - rowGroupColsSvc?.syncColumnWithState(column, source, getValue, rowGroupIndexes); - pivotColsSvc?.syncColumnWithState(column, source, getValue, pivotIndexes); - }; + // Pass 2 — leftover states/defaults land on pass 1's pivot-result cols. Non-structural, no `refreshCols`. + if (unmatched !== null || params.defaultState) { + const pivotResultColsList = beans.pivotResultCols?.pivotCols; + unmatched = applyStateToCols(beans, unmatched, pivotResultColsList, params, source, false); + } - const applyStates = ( - states: ColumnState[], - existingColumns: AgColumn[], - getById: (id: string) => AgColumn | null - ) => { - const dispatchEventsFunc = _compareColumnStatesAndDispatchEvents(beans, source); - - // at the end below, this list will have all columns we got no state for - const columnsWithNoState = existingColumns.slice(); - - const rowGroupIndexes: { [key: string]: number } = {}; - const pivotIndexes: { [key: string]: number } = {}; - const autoColStates: ColumnState[] = []; - const selectionColStates: ColumnState[] = []; - // If pivoting is modified, these are the states we try to reapply after - // the pivot result cols are re-generated - const unmatchedAndAutoStates: ColumnState[] = []; - let unmatchedCount = 0; - - const previousRowGroupCols = rowGroupColsSvc?.columns.slice() ?? []; - const previousPivotCols = pivotColsSvc?.columns.slice() ?? []; - - for (const state of states) { - const colId = state.colId; + // Finalize once: re-order, single visible-cols refresh, single everything-changed + dispatch. + finalizeChange(beans, params, source, stateChanges); - // auto group columns are re-created so deferring syncing with ColumnState - const isAutoGroupColumn = colId.startsWith(GROUP_AUTO_COLUMN_ID); - if (isAutoGroupColumn) { - autoColStates.push(state); - unmatchedAndAutoStates.push(state); - continue; - } + colAnimation?.finish(); - if (isColumnSelectionCol(colId)) { - selectionColStates.push(state); - unmatchedAndAutoStates.push(state); - continue; - } + return unmatched === null; // true ⇒ every provided state matched a column +} - const column = getById(colId); +/** Apply `states` to `existingColumns` (by colId); the primary pass also runs the structural changes. + * Ordering/refresh/dispatch happen once in the caller. Returns the unmatched states (`null` if all matched). */ +function applyStateToCols( + beans: BeanCollection, + states: ColumnState[] | null, + existingColumns: AgColumn[] | null | undefined, + params: ApplyColumnStateParams, + source: ColumnEventType, + primaryPass: boolean +): ColumnState[] | null { + const colModel = beans.colModel; + const defaultState = params.defaultState; + let autoColStates: ColumnState[] | null = null; + let selectionColStates: ColumnState[] | null = null; + let unmatchedStates: ColumnState[] | null = null; + const matched: Set | null = defaultState ? new Set() : null; + + if (states) { + for (let i = 0, len = states.length; i < len; ++i) { + const state = states[i]; + const colId = state.colId; + let column: AgColumn | null | undefined; + + if (colId != null) { + // Service cols are (re)created by the refresh, so collect their states for `syncServiceColumnsWithState` + // — not into `unmatchedStates` (that would wrongly force the pivot pass). + if (colId.startsWith(GROUP_AUTO_COLUMN_ID)) { + autoColStates ??= []; + autoColStates.push(state); + continue; + } + if (colId.startsWith(SELECTION_COLUMN_ID)) { + selectionColStates ??= []; + selectionColStates.push(state); + continue; + } + if (primaryPass) { + column = colModel.getNonPivotColById(colId); + } else { + // Pivot-result pass: only match generated pivot cols (those carrying pivotKeys). + const col = colModel.getCol(colId); + column = col?.colDef.pivotKeys == null ? null : col; + } + } if (!column) { - unmatchedAndAutoStates.push(state); - unmatchedCount += 1; + unmatchedStates ??= []; + unmatchedStates.push(state); } else { - syncColumnWithStateItem(column, state, rowGroupIndexes, pivotIndexes, false); - _removeFromArray(columnsWithNoState, column); + applyFieldState(beans, column, state, defaultState, source); + matched?.add(column); } } + } - // anything left over, we got no data for, so add in the column as non-value, non-rowGroup and hidden - const applyDefaultsFunc = (col: AgColumn) => - syncColumnWithStateItem(col, null, rowGroupIndexes, pivotIndexes, false); + // Cols not named in `state` get `defaultState` (loop skipped when none supplied or no cols). + if (matched !== null && existingColumns) { + for (let i = 0, len = existingColumns.length; i < len; ++i) { + const col = existingColumns[i]; + if (!matched.has(col)) { + applyFieldState(beans, col, null, defaultState, source); + } + } + } - columnsWithNoState.forEach(applyDefaultsFunc); + // Only the primary pass is structural (rowGroup/pivot membership, service cols, sort/refresh/sync). + if (primaryPass) { + applyStructuralStateChanges(beans, autoColStates, selectionColStates, defaultState, source); + } - rowGroupColsSvc?.sortColumns(comparatorByIndex.bind(rowGroupColsSvc, rowGroupIndexes, previousRowGroupCols)); - pivotColsSvc?.sortColumns(comparatorByIndex.bind(pivotColsSvc, pivotIndexes, previousPivotCols)); + return unmatchedStates; +} - colModel.refreshCols(false, source); +/** Primary-pass tail: order row-group/pivot cols, rebuild cols, then sync the (re)created service cols. */ +function applyStructuralStateChanges( + beans: BeanCollection, + autoColStates: ColumnState[] | null, + selectionColStates: ColumnState[] | null, + defaultState: ColumnStateParams | undefined, + source: ColumnEventType +): void { + const { autoColSvc, selectionColSvc, rowGroupColsSvc, pivotColsSvc } = beans; - const syncColStates = ( - getCol: (colId: string) => AgColumn | null, - colStates: ColumnState[], - columns: AgColumn[] = [] - ) => { - for (const stateItem of colStates) { - const col = getCol(stateItem.colId); - _removeFromArray(columns, col); - syncColumnWithStateItem(col, stateItem, null, null, true); - } - columns.forEach(applyDefaultsFunc); - }; + // Must run before refreshCols, which reads `service.columns` as-is to build auto cols + colsList. + rowGroupColsSvc?.sortByPendingState(); + pivotColsSvc?.sortByPendingState(); - // sync newly created auto group columns with ColumnState - syncColStates( - (colId: string) => autoColSvc?.getColumn(colId) ?? null, - autoColStates, - autoColSvc?.getColumns()?.slice() - ); - - // sync selection columns with ColumnState - syncColStates( - (colId: string) => selectionColSvc?.getColumn(colId) ?? null, - selectionColStates, - selectionColSvc?.getColumns()?.slice() - ); - - orderLiveColsLikeState(params, colModel, gos); - visibleCols.refresh(source); - eventSvc.dispatchEvent({ - type: 'columnEverythingChanged', - source, - }); + beans.colModel.refreshCols(false, source); - dispatchEventsFunc(); // Will trigger pivot result col changes if pivoting modified - return { unmatchedAndAutoStates, unmatchedCount }; - }; + const selectionCol = selectionColSvc?.column; + syncServiceColumnsWithState(beans, autoColStates, autoColSvc?.columns ?? [], defaultState, source); + syncServiceColumnsWithState(beans, selectionColStates, selectionCol ? [selectionCol] : [], defaultState, source); +} - colAnimation?.start(); +/** Sync service cols (auto/selection) post-refresh: apply each state to its matching `serviceCols` entry, + * and `defaultState` to the rest. `serviceCols` is read-only. */ +function syncServiceColumnsWithState( + beans: BeanCollection, + colStates: ColumnState[] | null, + serviceCols: readonly AgColumn[], + defaultState: ColumnStateParams | undefined, + source: ColumnEventType +): void { + let matched: Set | null = null; + if (colStates !== null) { + matched = new Set(); + for (let s = 0, sLen = colStates.length; s < sLen; ++s) { + const stateItem = colStates[s]; + const stateColId = stateItem.colId; + for (let i = 0, len = serviceCols.length; i < len; ++i) { + const sc = serviceCols[i]; + if (sc.colId === stateColId) { + matched.add(sc); + applyFieldState(beans, sc, stateItem, defaultState, source); + break; + } + } + } + } + // Service cols with no matching state get `defaultState`; skipped entirely when none supplied. + if (defaultState) { + for (let i = 0, len = serviceCols.length; i < len; ++i) { + const c = serviceCols[i]; + if (!matched?.has(c)) { + applyFieldState(beans, c, null, defaultState, source); + } + } + } +} - let { unmatchedAndAutoStates, unmatchedCount } = applyStates(state || [], providedCols, (id) => - colModel.getColDefCol(id) +/** Apply one `ColumnState`/`defaultState` to a column: field state always; membership only for primary cols. */ +function applyFieldState( + beans: BeanCollection, + column: AgColumn, + stateItem: ColumnState | null, + defaultState: ColumnStateParams | undefined, + source: ColumnEventType +): void { + // `orDefault` falls back only on `undefined` — an explicit `null` is kept, so state can clear a property. + const flex = orDefault(stateItem?.flex, defaultState?.flex); + const maybeSortDir = orDefault(stateItem?.sort, defaultState?.sort); + const maybeSortType = orDefault(stateItem?.sortType, defaultState?.sortType); + const isSortUpdate = isSortDirectionValid(maybeSortDir) || isSortTypeValid(maybeSortType); + // Direction alone → default sort type; both must be provided to preserve a specific sort type. + const newSortDef = isSortUpdate + ? { type: _normalizeSortType(maybeSortType), direction: normalizeSortDirection(maybeSortDir) } + : undefined; + + updateSomeColumnState( + beans, + column, + orDefault(stateItem?.hide, defaultState?.hide), + newSortDef, + orDefault(stateItem?.sortIndex, defaultState?.sortIndex), + orDefault(stateItem?.pinned, defaultState?.pinned), + flex, + source ); - // If there are still states left over, see if we can apply them to newly generated - // pivot result cols or auto cols. Also if defaults exist, ensure they are applied to pivot resul cols - if (unmatchedAndAutoStates.length > 0 || _exists(params.defaultState)) { - const pivotResultColsList = pivotResultCols?.getPivotResultCols()?.list ?? []; - unmatchedCount = applyStates( - unmatchedAndAutoStates, - pivotResultColsList, - (id) => pivotResultCols?.getPivotResultCol(id) ?? null - ).unmatchedCount; + // No flex → fall back to width. + if (flex == null) { + const width = orDefault(stateItem?.width, defaultState?.width); + if (width != null) { + // Apply width only if valid (>= min), else keep the old width. + const minColWidth = column.colDef.minWidth ?? beans.environment.getDefaultColumnMinWidth(); + if (minColWidth != null && width >= minColWidth) { + column.setActualWidth(width, source); + } + } + } + + // Membership is for primary user cols only — never auto-group (primary, but generated) or non-primary cols. + if (column.colKind === 'auto-group' || !column.primary) { + return; } - colAnimation?.finish(); - return unmatchedCount === 0; // Successful if no states unaccounted for + beans.valueColsSvc?.syncColState(column, stateItem, defaultState, source); + beans.rowGroupColsSvc?.syncColState(column, stateItem, defaultState, source); + beans.pivotColsSvc?.syncColState(column, stateItem, defaultState, source); } -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +/** Reset all columns to the state declared in their colDefs (`initial*`/explicit), re-apply the colDef + * order, and fire `columnsReset`. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _resetColumnState(beans: BeanCollection, source: ColumnEventType): void { - const { colModel, autoColSvc, selectionColSvc, eventSvc, gos, calculatedColsSvc } = beans; + const { colModel, autoColSvc, selectionColSvc, eventSvc, gos, colAnimation, calculatedColsSvc } = beans; + // Park API/dialog-added calc cols and revert edits/removals of declared ones, so the reset below runs + // against the original set — via the calc svc, so the rebuild emits no calc lifecycle/validation events. if (calculatedColsSvc?.resetDynamicColumnDefs(true)) { - calculatedColsSvc.refreshProjectedColumns(source); + calculatedColsSvc.refreshDynamicColumns(source); } - const primaryCols = colModel.getColDefCols(); - if (!primaryCols?.length) { + if (!colModel.colDefList.length) { return; } - // NOTE = there is one bug here that no customer has noticed - if a column has colDef.lockPosition, - // this is ignored below when ordering the cols. to work, we should always put lockPosition cols first. - // As a work around, developers should just put lockPosition columns first in their colDef list. - - // we can't use 'allColumns' as the order might of messed up, so get the primary ordered list - const primaryColumnTree = colModel.getColDefColTree(); - const primaryColumns = _getColumnsFromTree(primaryColumnTree); - const columnStates: ColumnState[] = []; - - // we start at 1000, so if user has mix of rowGroup and group specified, it will work with both. - // eg IF user has ColA.rowGroupIndex=0, ColB.rowGroupIndex=1, ColC.rowGroup=true, - // THEN result will be ColA.rowGroupIndex=0, ColB.rowGroupIndex=1, ColC.rowGroup=1000 - let letRowGroupIndex = 1000; - let letPivotIndex = 1000; - + // Reset state per colDef, tracking max explicit rowGroup/pivot index so boolean-only cols get fallback + // indexes after them. `colDefList` is `colDefTree`'s flat leaf set (same count), sizing the array exactly. + const selectionCol = selectionColSvc?.column ?? null; + const initialAutoCols = autoColSvc?.columns; + const initialAutoLen = initialAutoCols?.length ?? 0; + const columnStates: ColumnState[] = new Array(initialAutoLen + (selectionCol ? 1 : 0) + colModel.colDefList.length); + let stateIdx = 0; + let maxRowGroupIndex = -1; + let maxPivotIndex = -1; const addColState = (col: AgColumn) => { const stateItem = getColumnStateFromColDef(col); - - if (_missing(stateItem.rowGroupIndex) && stateItem.rowGroup) { - stateItem.rowGroupIndex = letRowGroupIndex++; + const { rowGroupIndex, pivotIndex } = stateItem; + if (rowGroupIndex != null && rowGroupIndex > maxRowGroupIndex) { + maxRowGroupIndex = rowGroupIndex; } - - if (_missing(stateItem.pivotIndex) && stateItem.pivot) { - stateItem.pivotIndex = letPivotIndex++; + if (pivotIndex != null && pivotIndex > maxPivotIndex) { + maxPivotIndex = pivotIndex; } - - columnStates.push(stateItem); - }; - - autoColSvc?.getColumns()?.forEach(addColState); - selectionColSvc?.getColumns()?.forEach(addColState); - primaryColumns?.forEach(addColState); - - // apply state before ordering, as changes in row grouping will introduce new columns - _applyColumnState(beans, { state: columnStates }, source); - - const autoCols = autoColSvc?.getColumns() ?? []; - const selectionCols = selectionColSvc?.getColumns() ?? []; - const orderedCols = [...selectionCols, ...autoCols, ...primaryColumns]; - const orderedColState = orderedCols.map((col) => ({ colId: col.colId })); - - // apply the new order when all the cols have been created & are available - _applyColumnState(beans, { state: orderedColState, applyOrder: true }, source); - - eventSvc.dispatchEvent(_addGridCommonParams(gos, { type: 'columnsReset', source })); -} - -/** - * calculates what events to fire between column state changes. gets used when: - * a) apply column state - * b) apply new column definitions (so changes from old cols) - */ -export function _compareColumnStatesAndDispatchEvents(beans: BeanCollection, source: ColumnEventType): () => void { - const { rowGroupColsSvc, pivotColsSvc, valueColsSvc, colModel, sortSvc, eventSvc } = beans; - const startState = { - rowGroupColumns: rowGroupColsSvc?.columns.slice() ?? [], - pivotColumns: pivotColsSvc?.columns.slice() ?? [], - valueColumns: valueColsSvc?.columns.slice() ?? [], + columnStates[stateIdx++] = stateItem; }; - const columnStateBefore = _getColumnState(beans); - const columnStateBeforeMap: { [colId: string]: ColumnState } = {}; - - for (const col of columnStateBefore) { - columnStateBeforeMap[col.colId] = col; + if (initialAutoCols) { + for (let i = 0; i < initialAutoLen; ++i) { + addColState(initialAutoCols[i]); + } } - - return () => { - // dispatches generic ColumnEvents where all columns are returned rather than what has changed - const dispatchWhenListsDifferent = ( - eventType: 'columnPivotChanged' | 'columnRowGroupChanged', - colsBefore: AgColumn[], - colsAfter: AgColumn[], - idMapper: (column: AgColumn) => string - ) => { - const beforeList = colsBefore.map(idMapper); - const afterList = colsAfter.map(idMapper); - const unchanged = _areEqual(beforeList, afterList); - - if (unchanged) { - return; - } - - const changes = new Set(colsBefore); - for (const id of colsAfter) { - // if the first list had it, delete it, as it's unchanged. - if (!changes.delete(id)) { - // if the second list has it, and first doesn't, add it. - changes.add(id); - } - } - - const changesArr = [...changes]; - - eventSvc.dispatchEvent({ - type: eventType, - columns: changesArr, - column: changesArr.length === 1 ? changesArr[0] : null, - source: source, - } as WithoutGridCommon); - }; - - // determines which columns have changed according to supplied predicate - const getChangedColumns = (changedPredicate: (cs: ColumnState, c: AgColumn) => boolean): AgColumn[] => { - const changedColumns: AgColumn[] = []; - - colModel.forAllCols((column) => { - const colStateBefore = columnStateBeforeMap[column.colId]; - if (colStateBefore && changedPredicate(colStateBefore, column)) { - changedColumns.push(column); - } - }); - - return changedColumns; - }; - - const columnIdMapper = (c: AgColumn) => c.colId; - - dispatchWhenListsDifferent( - 'columnRowGroupChanged', - startState.rowGroupColumns, - rowGroupColsSvc?.columns ?? [], - columnIdMapper - ); - - dispatchWhenListsDifferent( - 'columnPivotChanged', - startState.pivotColumns, - pivotColsSvc?.columns ?? [], - columnIdMapper - ); - - const valueChangePredicate = (cs: ColumnState, c: AgColumn) => { - const oldActive = cs.aggFunc != null; - - const activeChanged = oldActive != c.isValueActive(); - // we only check aggFunc if the agg is active - const aggFuncChanged = oldActive && cs.aggFunc != c.getAggFunc(); - - return activeChanged || aggFuncChanged; - }; - const changedValues = getChangedColumns(valueChangePredicate); - if (changedValues.length > 0) { - dispatchColumnChangedEvent(eventSvc, 'columnValueChanged', changedValues, source); + if (selectionCol) { + addColState(selectionCol); + } + // Leaves in colDef declaration order (stable; colDefList can be permuted by a tool-panel reorder). + forEachColTreeLeaf(colModel.colDefTree, addColState); + + // Second pass: assign fallback indexes to boolean-only group/pivot cols (after the max above). + for (let i = 0, len = columnStates.length; i < len; ++i) { + const stateItem = columnStates[i]; + if (stateItem.rowGroup && stateItem.rowGroupIndex == null) { + stateItem.rowGroupIndex = ++maxRowGroupIndex; } - - const resizeChangePredicate = (cs: ColumnState, c: AgColumn) => cs.width != c.getActualWidth(); - dispatchColumnResizedEvent(eventSvc, getChangedColumns(resizeChangePredicate), true, source); - - const pinnedChangePredicate = (cs: ColumnState, c: AgColumn) => cs.pinned != c.getPinned(); - dispatchColumnPinnedEvent(eventSvc, getChangedColumns(pinnedChangePredicate), source); - - const visibilityChangePredicate = (cs: ColumnState, c: AgColumn) => cs.hide == c.isVisible(); - dispatchColumnVisibleEvent(eventSvc, getChangedColumns(visibilityChangePredicate), source); - - const sortChangePredicate = (cs: ColumnState, c: AgColumn) => - !_areSortDefsEqual(c.getSortDef(), { - type: _normalizeSortType(cs.sortType), - direction: _normalizeSortDirection(cs.sort), - }) || cs.sortIndex != c.getSortIndex(); - const changedColumns = getChangedColumns(sortChangePredicate); - if (changedColumns.length > 0) { - sortSvc?.dispatchSortChangedEvents(source, changedColumns); + if (stateItem.pivot && stateItem.pivotIndex == null) { + stateItem.pivotIndex = ++maxPivotIndex; } - - const colStateAfter = _getColumnState(beans); - // special handling for moved column events - normaliseColumnMovedEventForColumnState(columnStateBefore, colStateAfter, source, colModel, eventSvc); - }; -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _getColumnState(beans: BeanCollection): ColumnState[] { - const { colModel, rowGroupColsSvc, pivotColsSvc } = beans; - const primaryCols = colModel.getColDefCols(); - - if (_missing(primaryCols) || !colModel.isAlive()) { - return []; } - const rowGroupColumns = rowGroupColsSvc?.columns; - const pivotColumns = pivotColsSvc?.columns; - const res: ColumnState[] = []; - - const createStateItemFromColumn = (column: AgColumn) => { - const rowGroupIndex = column.isRowGroupActive() && rowGroupColumns ? rowGroupColumns.indexOf(column) : null; - const pivotIndex = column.isPivotActive() && pivotColumns ? pivotColumns.indexOf(column) : null; - - const aggFunc = column.isValueActive() ? column.getAggFunc() : null; - const sortIndex = column.getSortIndex() != null ? column.getSortIndex() : null; - - res.push({ - colId: column.colId, - width: column.getActualWidth(), - hide: !column.isVisible(), - pinned: column.getPinned(), - sort: column.getSort(), - sortType: column.getSortDef()?.type ?? null, - sortIndex, - aggFunc, - rowGroup: column.isRowGroupActive(), - rowGroupIndex, - pivot: column.isPivotActive(), - pivotIndex: pivotIndex, - flex: column.getFlex() ?? null, - }); - }; - colModel.forAllCols((col) => createStateItemFromColumn(col)); - - // for fast looking, store the index of each column - const colIdToGridIndexMap = new Map(colModel.getCols().map((col, index) => [col.colId, index])); - - res.sort((itemA: any, itemB: any) => { - const posA = colIdToGridIndexMap.has(itemA.colId) ? colIdToGridIndexMap.get(itemA.colId) : -1; - const posB = colIdToGridIndexMap.has(itemB.colId) ? colIdToGridIndexMap.get(itemB.colId) : -1; - return posA! - posB!; - }); - - return res; -} - -export function getColumnStateFromColDef(column: AgColumn): ColumnState { - const getValueOrNull = (a: T, b: T) => (a != null ? a : b != null ? b : null); - - const colDef = column.colDef; - const sortDefFromColDef = _getSortDefFromInput(getValueOrNull(colDef.sort, colDef.initialSort)); - const sort = sortDefFromColDef.direction; - const sortType = sortDefFromColDef.type; - const sortIndex = getValueOrNull(colDef.sortIndex, colDef.initialSortIndex); - const hide = getValueOrNull(colDef.hide, colDef.initialHide); - const pinned = getValueOrNull(colDef.pinned, colDef.initialPinned); - - const width = getValueOrNull(colDef.width, colDef.initialWidth); - const flex = getValueOrNull(colDef.flex, colDef.initialFlex); - - let rowGroupIndex: number | null | undefined = getValueOrNull(colDef.rowGroupIndex, colDef.initialRowGroupIndex); - let rowGroup: boolean | null | undefined = getValueOrNull(colDef.rowGroup, colDef.initialRowGroup); - - if (rowGroupIndex == null && !rowGroup) { - rowGroupIndex = null; - rowGroup = null; + colAnimation?.start(); + // Single capture before any mutation — the lone finalize dispatch diffs the whole reset. + const stateChanges = captureColumnStateChanges(beans); + + // Apply field state; the structural pass (re)creates auto/service cols. No dispatch yet. + applyStateToCols(beans, columnStates, colModel.colDefList, {}, source, true); + + // Order from the now-current service cols: auto cols may have been recreated above (their colIds change + // with the row-group set), so use the post-apply instances, not the pre-apply ids in `columnStates`. + const autoCols = autoColSvc?.columns; + const autoColsLen = autoCols?.length ?? 0; + const orderState = new Array((selectionCol ? 1 : 0) + autoColsLen + colModel.colDefList.length); + let orderIdx = 0; + if (selectionCol) { + orderState[orderIdx++] = { colId: selectionCol.colId }; } - - let pivotIndex: number | null | undefined = getValueOrNull(colDef.pivotIndex, colDef.initialPivotIndex); - let pivot: boolean | null | undefined = getValueOrNull(colDef.pivot, colDef.initialPivot); - - if (pivotIndex == null && !pivot) { - pivotIndex = null; - pivot = null; + for (let i = 0; i < autoColsLen; ++i) { + orderState[orderIdx++] = { colId: autoCols![i].colId }; } + forEachColTreeLeaf(colModel.colDefTree, (col) => { + orderState[orderIdx++] = { colId: col.colId }; + }); - const aggFunc = getValueOrNull(colDef.aggFunc, colDef.initialAggFunc); + // Re-order + refresh + dispatch once, over the final (ordered) structure. + finalizeChange(beans, { state: orderState, applyOrder: true }, source, stateChanges); + colAnimation?.finish(); - return { - colId: column.colId, - sort, - sortType, - sortIndex, - hide, - pinned, - width, - flex, - rowGroup, - rowGroupIndex, - pivot, - pivotIndex, - aggFunc, - }; + eventSvc.dispatchEvent(_addGridCommonParams(gos, { type: 'columnsReset', source })); } -function orderLiveColsLikeState(params: ApplyColumnStateParams, colModel: ColumnModel, gos: GridOptionsService): void { - if (!params.applyOrder || !params.state) { - return; - } - const colIds: string[] = []; - for (const item of params.state) { - if (item.colId != null) { - colIds.push(item.colId); - } - } - sortColsLikeKeys(colModel.cols, colIds, colModel, gos); +/** Shared tail of a state change: apply order, refresh visible cols once, dispatch the diffed events. + * Separate so {@link _resetColumnState} can build its order from the post-apply (recreated) service cols. */ +function finalizeChange( + beans: BeanCollection, + params: ApplyColumnStateParams, + source: ColumnEventType, + changes: ColumnStateChanges +): void { + orderLiveColsLikeState(beans, params); + beans.visibleCols.refresh(source, false); + beans.eventSvc.dispatchEvent({ type: 'columnEverythingChanged', source }); + dispatchColStateChanges(beans, source, changes); } -function sortColsLikeKeys( - cols: ColumnCollections | undefined, - colIds: string[], - colModel: ColumnModel, - gos: GridOptionsService -): void { - if (cols == null) { +/** Reorder `colsList`: state-ordered cols first, rest after (auto-group at front), locked cols at the edges. + * Runs post-rebuild, so `col.inColsList` is the live-membership source (`false` for parked pivot primaries). */ +function orderLiveColsLikeState(beans: BeanCollection, params: ApplyColumnStateParams): void { + const colModel = beans.colModel; + const state = params.state; + if (!params.applyOrder || !state || !colModel.ready) { return; } - let newOrder: AgColumn[] = []; - const processedColIds: { [id: string]: boolean } = {}; + const colsById = colModel.colsById; + const currentList = colModel.colsList; + const consumed = new Set(); + const newOrder: AgColumn[] = []; - for (const colId of colIds) { - if (processedColIds[colId]) { + // Pass 1: state-ordered cols that are currently displayed (deduped via `consumed`). + for (let i = 0, len = state.length; i < len; ++i) { + const colId = state[i].colId; + if (colId == null) { continue; } - const col = cols.map[colId]; - if (col) { + const col = colsById[colId]; + if (col != null && col.inColsList && !consumed.has(col)) { newOrder.push(col); - processedColIds[colId] = true; + consumed.add(col); } } - // add in all other columns - let autoGroupInsertIndex = 0; - for (const col of cols.list) { - const colId = col.colId; - const alreadyProcessed = processedColIds[colId] != null; - if (alreadyProcessed) { + // Pass 2: remaining displayed cols in `colsList` order. Auto-group cols collect separately to be prepended. + let autoGroupMissed: AgColumn[] | null = null; + for (let i = 0, len = currentList.length; i < len; ++i) { + const col = currentList[i]; + if (consumed.has(col)) { continue; } - - const isAutoGroupCol = colId.startsWith(GROUP_AUTO_COLUMN_ID); - if (isAutoGroupCol) { - // auto group columns, if missing from state list, are added to the start. - // it's common to have autoGroup missing, as grouping could be on by default - // on a column, but the user could of since removed the grouping via the UI. - // if we don't inc the insert index, autoGroups will be inserted in reverse order - newOrder.splice(autoGroupInsertIndex++, 0, col); + if (col.colKind === 'auto-group') { + autoGroupMissed ??= []; + autoGroupMissed.push(col); } else { - // normal columns, if missing from state list, are added at the end newOrder.push(col); } } - // this is already done in updateCols, however we changed the order above (to match the order of the state - // columns) so we need to do it again. we could of put logic into the order above to take into account fixed - // columns, however if we did then we would have logic for updating fixed columns twice. reusing the logic here - // is less sexy for the code here, but it keeps consistency. - newOrder = placeLockedColumns(newOrder, gos); + const ordered = autoGroupMissed ?? newOrder; + if (autoGroupMissed !== null) { + for (let i = 0, len = newOrder.length; i < len; ++i) { + ordered.push(newOrder[i]); + } + } - if (!doesMovePassMarryChildren(newOrder, colModel.getColTree())) { + // The reorder above ignored lockPosition, so re-place locked cols here. + const finalOrder = placeLockedColumns(ordered, beans.gos); + if (_areEqual(finalOrder, currentList)) { + return; + } + if (colModel.hasMarryChildren && !doesMovePassMarryChildren(finalOrder, colModel.colsTree)) { _warn(39); return; } + colModel.colsList = finalOrder; + colModel.markColsListIndexDirty(); +} - cols.list = newOrder; +/** Snapshot column state before a mutation. Pair with {@link dispatchColStateChanges} after the + * mutation to fire the resulting events. Used by both apply-column-state and apply-new-column-defs. */ +export function captureColumnStateChanges(beans: BeanCollection): ColumnStateChanges { + const { rowGroupColsSvc, pivotColsSvc, colModel } = beans; + const rowGroupCols = rowGroupColsSvc?.columns; + const pivotCols = pivotColsSvc?.columns; + const cols = colModel.getColsInStateOrder(); + const before = new Map(); + for (let i = 0, len = cols.length; i < len; ++i) { + const column = cols[i]; + const sortDef = column.sortDef; + const direction = sortDef.direction; + before.set(column.colId, { + width: column.actualWidth, + hide: !column.visible, + pinned: column.pinned, + sort: direction, + sortType: direction ? sortDef.type : undefined, + sortIndex: column.sortIndex ?? null, + aggFunc: column.aggregationActive ? column.aggFunc : null, + }); + } + return { + rowGroupColumns: rowGroupCols?.length ? rowGroupCols.slice() : undefined, + pivotColumns: pivotCols?.length ? pivotCols.slice() : undefined, + before, + colsList: colModel.colsList, + }; } -function normaliseColumnMovedEventForColumnState( - colStateBefore: ColumnState[], - colStateAfter: ColumnState[], +/** Diff current column state against a {@link captureColumnStateChanges} snapshot and dispatch the changed + * column events (value/resize/pin/visible/sort/rowGroup/pivot/moved). */ +export function dispatchColStateChanges( + beans: BeanCollection, source: ColumnEventType, - colModel: ColumnModel, - eventSvc: IEventService -) { - // we are only interested in columns that were both present and visible before and after + changes: ColumnStateChanges +): void { + const rowGroupCols = beans.rowGroupColsSvc?.columns; + const pivotCols = beans.pivotColsSvc?.columns; + dispatchColumnListChanged(beans, source, 'columnRowGroupChanged', changes.rowGroupColumns, rowGroupCols); + dispatchColumnListChanged(beans, source, 'columnPivotChanged', changes.pivotColumns, pivotCols); + dispatchColumnFieldChanges(beans, source, changes.before); + dispatchColumnMoved(beans, source, changes); +} - const colStateAfterMapped: { [id: string]: ColumnState } = {}; - for (const s of colStateAfter) { - colStateAfterMapped[s.colId] = s; +/** Fire `columnRowGroupChanged`/`columnPivotChanged` when the col list changed in membership OR order. Payload + * is the symmetric diff (added/removed); a pure reorder fires with empty `columns`. No event when identical. */ +function dispatchColumnListChanged( + beans: BeanCollection, + source: ColumnEventType, + eventType: 'columnPivotChanged' | 'columnRowGroupChanged', + before: AgColumn[] | undefined, + after: AgColumn[] | undefined +): void { + if (!areSameColIds(before, after)) { + _dispatchColumnChangedEvent(beans.eventSvc, eventType, _symmetricDiff(before, after), source); } +} - // get id's of cols in both before and after lists - const colsIntersectIds: { [id: string]: boolean } = {}; - for (const s of colStateBefore) { - if (colStateAfterMapped[s.colId]) { - colsIntersectIds[s.colId] = true; +/** Single pass over all cols, bucketing per-field changes against the before-snapshot, then firing one + * event per non-empty bucket (value/resize/pin/visible/sort). */ +function dispatchColumnFieldChanges( + beans: BeanCollection, + source: ColumnEventType, + before: Map +): void { + const { eventSvc, sortSvc } = beans; + const allCols = beans.colModel.getAllCols(); + let changedValues: AgColumn[] | null = null; + let changedResizes: AgColumn[] | null = null; + let changedPinned: AgColumn[] | null = null; + let changedVisible: AgColumn[] | null = null; + let changedSort: AgColumn[] | null = null; + for (let i = 0, len = allCols.length; i < len; ++i) { + const col = allCols[i]; + const cs = before.get(col.colId); + if (!cs) { + continue; + } + if (cs.width != col.actualWidth) { + changedResizes ??= []; + changedResizes.push(col); + } + if (cs.pinned != col.pinned) { + changedPinned ??= []; + changedPinned.push(col); + } + if (cs.hide == col.visible) { + changedVisible ??= []; + changedVisible.push(col); + } + if (isAggChanged(col, cs)) { + changedValues ??= []; + changedValues.push(col); + } + if (sortSvc && isSortChanged(col, cs)) { + changedSort ??= []; + changedSort.push(col); } } + if (changedValues) { + _dispatchColumnChangedEvent(eventSvc, 'columnValueChanged', changedValues, source); + } + if (changedResizes) { + dispatchColumnResizedEvent(eventSvc, changedResizes, true, source); + } + if (changedPinned) { + dispatchColumnPinnedEvent(eventSvc, changedPinned, source); + } + if (changedVisible) { + dispatchColumnVisibleEvent(eventSvc, changedVisible, source); + } + if (changedSort) { + sortSvc?.dispatchSortChangedEvents(source, changedSort); + } +} - // filter state lists, so we only have cols that were present before and after - const beforeFiltered = colStateBefore.filter((c) => colsIntersectIds[c.colId]); - const afterFiltered = colStateAfter.filter((c) => colsIntersectIds[c.colId]); - - // see if any cols are in a different location - const movedColumns: AgColumn[] = []; +/** Fire `columnMoved` for cols whose position changed vs others in both snapshots: compares before-order + * (Map insertion order) against current order position-by-position; a slot mismatch means that col moved. */ +function dispatchColumnMoved(beans: BeanCollection, source: ColumnEventType, changes: ColumnStateChanges): void { + const colModel = beans.colModel; + const after = colModel.colsList; + if (after === changes.colsList) { + return; // Same array ref => order untouched (colsList never mutated in place) + } + const before = changes.before; + const colsById = colModel.colsById; + + // Intersection in before-order (Map insertion order = capture order). + let beforeCommon: AgColumn[] | undefined; + for (const colId of before.keys()) { + const col = colsById[colId]; + if (col?.inColsList) { + beforeCommon ??= []; + beforeCommon.push(col); + } + } + if (!beforeCommon) { + return; + } - afterFiltered.forEach((csAfter: ColumnState, index: number) => { - const csBefore = beforeFiltered?.[index]; - if (csBefore && csBefore.colId !== csAfter.colId) { - const gridCol = colModel.getCol(csBefore.colId); - if (gridCol) { - movedColumns.push(gridCol); + let commonIdx = 0; + let movedColumns: AgColumn[] | undefined; + const commonLen = beforeCommon.length; + for (let i = 0, len = after.length; i < len && commonIdx < commonLen; ++i) { + const col = after[i]; + if (before.has(col.colId)) { + // Same intersection slot in before-order vs after-order — a ref mismatch means it moved. + const beforeCol = beforeCommon[commonIdx++]; + if (beforeCol !== col) { + movedColumns ??= []; + movedColumns.push(beforeCol); } } - }); - - if (!movedColumns.length) { - return; } - eventSvc.dispatchEvent({ - type: 'columnMoved', - columns: movedColumns, - column: movedColumns.length === 1 ? movedColumns[0] : null, - finished: true, - source, - }); + if (movedColumns) { + beans.eventSvc.dispatchEvent({ + type: 'columnMoved', + columns: movedColumns, + column: movedColumns.length === 1 ? movedColumns[0] : null, + finished: true, + source, + }); + } } -// sort the lists according to the indexes that were provided -const comparatorByIndex = (indexes: { [key: string]: number }, oldList: AgColumn[], colA: AgColumn, colB: AgColumn) => { - const indexA = indexes[colA.getId()]; - const indexB = indexes[colB.getId()]; - - const aHasIndex = indexA != null; - const bHasIndex = indexB != null; - - if (aHasIndex && bHasIndex) { - // both a and b are new cols with index, so sort on index - return indexA - indexB; +export const _getColumnState = (beans: BeanCollection): ColumnState[] => { + const colModel = beans.colModel; + if (!colModel.isAlive()) { + return []; } - - if (aHasIndex) { - // a has an index, so it should be before a - return -1; + const cols = colModel.getColsInStateOrder(); + const res = new Array(cols.length); + for (let i = 0, len = cols.length; i < len; ++i) { + const column = cols[i]; + const rowGroupActive = column.rowGroupActive; + const pivotActive = column.pivotActive; + const sortDef = column.sortDef; + const direction = sortDef.direction; + res[i] = { + colId: column.colId, + width: column.actualWidth, + hide: !column.visible, + pinned: column.pinned, + sort: direction, + sortType: direction ? (sortDef.type ?? null) : null, + sortIndex: column.sortIndex ?? null, + aggFunc: column.aggregationActive ? column.aggFunc : null, + rowGroup: rowGroupActive, + rowGroupIndex: rowGroupActive ? column.rowGroupActiveIndex : null, + pivot: pivotActive, + pivotIndex: pivotActive ? column.pivotActiveIndex : null, + flex: column.flex ?? null, + }; } + return res; +}; - if (bHasIndex) { - // b has an index, so it should be before a - return 1; +export function getColumnStateFromColDef(column: AgColumn): ColumnState { + const colDef = column.colDef; + const sortDef = getSortDefFromInput(colDef.sort ?? colDef.initialSort ?? null); + const rowGroupIndex: number | null = colDef.rowGroupIndex ?? colDef.initialRowGroupIndex ?? null; + let rowGroup: boolean | null = colDef.rowGroup ?? colDef.initialRowGroup ?? null; + if (rowGroupIndex == null && rowGroup === false) { + rowGroup = null; // normalise: no index + not grouped → null } + const pivotIndex: number | null = colDef.pivotIndex ?? colDef.initialPivotIndex ?? null; + let pivot: boolean | null = colDef.pivot ?? colDef.initialPivot ?? null; + if (pivotIndex == null && pivot === false) { + pivot = null; // normalise: no index + not pivoted → null + } + return { + colId: column.colId, + sort: sortDef.direction, + sortType: sortDef.type, + sortIndex: colDef.sortIndex ?? colDef.initialSortIndex ?? null, + hide: colDef.hide ?? colDef.initialHide ?? null, + pinned: colDef.pinned ?? colDef.initialPinned ?? null, + width: colDef.width ?? colDef.initialWidth ?? null, + flex: colDef.flex ?? colDef.initialFlex ?? null, + rowGroup, + rowGroupIndex, + pivot, + pivotIndex, + aggFunc: colDef.aggFunc ?? colDef.initialAggFunc ?? null, + }; +} - const oldIndexA = oldList.indexOf(colA); - const oldIndexB = oldList.indexOf(colB); - - const aHasOldIndex = oldIndexA >= 0; - const bHasOldIndex = oldIndexB >= 0; +const orDefault = (stateValue: T | undefined, defaultValue: T | undefined): T | undefined => + stateValue !== undefined ? stateValue : defaultValue; - if (aHasOldIndex && bHasOldIndex) { - // both a and b are old cols, so sort based on last order - return oldIndexA - oldIndexB; +/** Invoke `cb` for each leaf column of a built col tree, in declaration order. */ +const forEachColTreeLeaf = (nodes: (AgColumn | AgProvidedColumnGroup)[], cb: (col: AgColumn) => void): void => { + for (let i = 0, len = nodes.length; i < len; ++i) { + const node = nodes[i]; + const children = (node as Partial).children; + if (children) { + forEachColTreeLeaf(children, cb); + } else { + cb(node as AgColumn); + } } +}; - if (aHasOldIndex) { - // a is old, b is new, so b is first - return -1; +const isAggChanged = (col: AgColumn, before: ColumnStateBefore): boolean => { + const oldAggFunc = before.aggFunc; + const wasActive = oldAggFunc != null; + return wasActive !== col.aggregationActive || (wasActive && oldAggFunc != col.aggFunc); +}; + +const isSortChanged = (col: AgColumn, before: ColumnStateBefore): boolean => { + if (before.sortIndex != col.sortIndex) { + return true; } + const sortDef = col.getSortDef(); + const beforeType = before.sortType ?? 'default'; + return sortDef ? sortDef.direction !== before.sort || sortDef.type !== beforeType : before.sort !== null; +}; - // this bit does matter, means both are new cols - // but without index or that b is old and a is new - return 1; +const areSameColIds = (a: AgColumn[] | undefined, b: AgColumn[] | undefined): boolean => { + if (a === b) { + return true; + } + const len = a?.length ?? 0; + if (len !== (b?.length ?? 0)) { + return false; + } + for (let i = 0; i < len; ++i) { + const colA = a![i]; + const colB = b![i]; + // Same instance ⇒ same colId; only string-compare when they differ. + if (colA !== colB && colA.colId !== colB.colId) { + return false; + } + } + return true; }; diff --git a/packages/ag-grid-community/src/columns/columnUtils.ts b/packages/ag-grid-community/src/columns/columnUtils.ts index d01e8cdbcd9..d6ba165f036 100644 --- a/packages/ag-grid-community/src/columns/columnUtils.ts +++ b/packages/ag-grid-community/src/columns/columnUtils.ts @@ -1,206 +1,129 @@ import type { AgPropertyChangedSource } from 'ag-stack'; -import { _areEqual, _exists } from 'ag-stack'; -import type { BeanCollection } from '../context/context'; import type { AgColumn } from '../entities/agColumn'; -import { _getSortDefFromInput, _isSortDefValid, _isSortDirectionValid, isColumn } from '../entities/agColumn'; +import { _isSortDefValid, getSortDefFromInput, isSortDirectionValid } from '../entities/agColumn'; import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; -import { isProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; -import type { ColDef, ColGroupDef, ColKey } from '../entities/colDef'; +import type { ColDef, ColGroupDef } from '../entities/colDef'; import type { ColumnEventType } from '../events'; -import type { ColumnInstanceId } from '../interfaces/iColumn'; -import { depthFirstOriginalTreeSearch } from './columnFactoryUtils'; -import type { ColumnCollections } from './columnModel'; -import type { ColumnState, ColumnStateParams } from './columnStateUtils'; +import type { Column } from '../interfaces/iColumn'; +import type { ColumnState } from './columnStateUtils'; export const GROUP_AUTO_COLUMN_ID = 'ag-Grid-AutoColumn'; export const SELECTION_COLUMN_ID = 'ag-Grid-SelectionColumn'; export const ROW_NUMBERS_COLUMN_ID = 'ag-Grid-RowNumbersColumn'; export const GROUP_HIERARCHY_COLUMN_ID_PREFIX = 'ag-Grid-HierarchyColumn'; -// Possible candidate for reuse (alot of recursive traversal duplication) -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _getColumnsFromTree(rootColumns: (AgColumn | AgProvidedColumnGroup)[]): AgColumn[] { - const result: AgColumn[] = []; - - const recursiveFindColumns = (childColumns: (AgColumn | AgProvidedColumnGroup)[]): void => { - for (let i = 0; i < childColumns.length; i++) { - const child = childColumns[i]; - if (isColumn(child)) { - result.push(child); - } else if (isProvidedColumnGroup(child)) { - recursiveFindColumns(child.getChildren()); - } - } - }; - - recursiveFindColumns(rootColumns); - - return result; -} - -export function getWidthOfColsInList(columnList: AgColumn[]) { - return columnList.reduce((width, col) => width + col.getActualWidth(), 0); -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _destroyColumnTree( - beans: BeanCollection, - oldTree: (AgColumn | AgProvidedColumnGroup)[] | null | undefined, - newTree?: (AgColumn | AgProvidedColumnGroup)[] | null -): void { - const oldObjectsById: { [id: ColumnInstanceId]: (AgColumn | AgProvidedColumnGroup) | null } = {}; - - if (!oldTree) { - return; +export function getWidthOfColsInList(columnList: AgColumn[]): number { + let width = 0; + for (let i = 0, len = columnList.length; i < len; ++i) { + width += columnList[i].actualWidth; } - - // add in all old columns to be destroyed - depthFirstOriginalTreeSearch(null, oldTree, (child) => { - oldObjectsById[child.getInstanceId()] = child; - }); - - // however we don't destroy anything in the new tree. if destroying the grid, there is no new tree - if (newTree) { - depthFirstOriginalTreeSearch(null, newTree, (child) => { - oldObjectsById[child.getInstanceId()] = null; - }); - } - - // what's left can be destroyed - const colsToDestroy = Object.values(oldObjectsById).filter((item) => item != null); - beans.context.destroyBeans(colsToDestroy); + return width; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function isColumnGroupAutoCol(col: AgColumn): boolean { - const colId = col.getId(); - return colId.startsWith(GROUP_AUTO_COLUMN_ID); +export function isColumnGroupAutoCol(col: Column): boolean { + return (col as AgColumn).colKind === 'auto-group'; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function isColumnSelectionCol(col: ColKey): boolean { - const id = typeof col === 'string' ? col : 'getColId' in col ? col.getColId() : col.colId; - return id?.startsWith(SELECTION_COLUMN_ID) ?? false; +export function isColumnSelectionCol(col: Column): boolean { + return (col as AgColumn).colKind === 'selection'; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function isRowNumberCol(col: ColKey): boolean { - const id = typeof col === 'string' ? col : 'getColId' in col ? col.getColId() : col.colId; - return id?.startsWith(ROW_NUMBERS_COLUMN_ID) ?? false; +export function isRowNumberCol(col: Column): boolean { + return (col as AgColumn).colKind === 'row-number'; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function isSpecialCol(col: ColKey): boolean { - return isColumnSelectionCol(col) || isRowNumberCol(col); +export function isSpecialCol(col: Column): boolean { + const colKind = (col as AgColumn).colKind; + return colKind === 'selection' || colKind === 'row-number'; } export function convertColumnTypes(type: string | string[]): string[] { - let typeKeys: string[] = []; - - if (type instanceof Array) { - typeKeys = type; - } else if (typeof type === 'string') { - typeKeys = type.split(','); - } - return typeKeys; -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _areColIdsEqual(colsA: AgColumn[] | null, colsB: AgColumn[] | null): boolean { - return _areEqual(colsA, colsB, (a, b) => a.colId === b.colId); -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _updateColsMap(cols: ColumnCollections): void { - cols.map = {}; - for (const col of cols.list) { - cols.map[col.getId()] = col; + if (Array.isArray(type)) { + return type; } + return typeof type === 'string' ? type.split(',') : []; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _convertColumnEventSourceType(source: AgPropertyChangedSource): ColumnEventType { - // unfortunately they do not match so need to perform conversion + // The two enums don't match, so convert. return source === 'optionsUpdated' ? 'gridOptionsChanged' : source; } -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _columnsMatch(column: AgColumn, key: ColKey): boolean { - return column === key || column.colId == key || column.colDef === key; -} - -export const getValueFactory = - (stateItem: ColumnState | null, defaultState: ColumnStateParams | undefined) => - ( - key1: U, - key2?: S - ): { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined } => { - const obj: { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined } = { - value1: undefined, - value2: undefined, - }; - let calculated: boolean = false; - - if (stateItem) { - if (stateItem[key1] !== undefined) { - obj.value1 = stateItem[key1]; - calculated = true; - } - if (_exists(key2) && stateItem[key2] !== undefined) { - obj.value2 = stateItem[key2]; - calculated = true; - } - } - - if (!calculated && defaultState) { - if (defaultState[key1] !== undefined) { - obj.value1 = defaultState[key1]; - } - if (_exists(key2) && defaultState[key2] !== undefined) { - obj.value2 = defaultState[key2]; - } - } - - return obj; - }; - /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _getColumnStateFromColDef(colDef: ColDef, colId: string): ColumnState { - const state: ColumnState = { - ...colDef, - sort: undefined, - colId, - }; - const sortDef = _getSortDefFromColDef(colDef); - if (sortDef) { - state.sort = sortDef.direction; - state.sortType = sortDef.type; - } - - return state; + const sortDef = getSortDefFromColDef(colDef); + return sortDef + ? { ...colDef, colId, sort: sortDef.direction, sortType: sortDef.type } + : { ...colDef, colId, sort: undefined }; } -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _getSortDefFromColDef(colDef: ColDef) { +export function getSortDefFromColDef(colDef: ColDef) { const { sort, initialSort } = colDef; - const sortIsValid = _isSortDefValid(sort) || _isSortDirectionValid(sort); - const initialSortIsValid = _isSortDefValid(initialSort) || _isSortDirectionValid(initialSort); - + const sortIsValid = _isSortDefValid(sort) || isSortDirectionValid(sort); + const initialSortIsValid = _isSortDefValid(initialSort) || isSortDirectionValid(initialSort); if (sortIsValid) { - return _getSortDefFromInput(sort); + return getSortDefFromInput(sort); } if (initialSortIsValid) { - return _getSortDefFromInput(initialSort); + return getSortDefFromInput(initialSort); } - return null; } -/** - * Calls `callback` for each leaf `ColDef` in `columnDefs`, recursing into `ColGroupDef` children as required. - * The `callback` is not called on column groups, only their leaf children. - */ +/** Destroys every still-alive node via flat lists (not `.children`, which a reused group may have replaced); + * the `isAlive()` guard de-dups nodes reachable via multiple paths (hierarchy cols sit in colDefList + wrappers). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const _destroyColumnTreeAll = ( + cols: readonly AgColumn[] | null, + allGroups: readonly AgProvidedColumnGroup[] | null +): void => { + if (cols) { + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + if (col.isAlive()) { + col.destroy(); + } + } + } + if (allGroups) { + for (let i = 0, len = allGroups.length; i < len; ++i) { + const group = allGroups[i]; + if (group.isAlive()) { + group.destroy(); + } + } + } +}; + +/** Destroys prev-build nodes absent from the new build. Walks flat prev lists, not `.children`: + * orphans whose parent's array was replaced are otherwise unreachable. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const _destroyColumnTreeUnused = ( + prevCols: readonly AgColumn[], + prevAllGroups: readonly AgProvidedColumnGroup[], + buildToken: number +): void => { + for (let i = 0, len = prevCols.length; i < len; ++i) { + const col = prevCols[i]; + if (col.buildToken !== buildToken && col.isAlive()) { + col.destroy(); + } + } + for (let i = 0, len = prevAllGroups.length; i < len; ++i) { + const group = prevAllGroups[i]; + if (group.buildToken !== buildToken && group.isAlive()) { + group.destroy(); + } + } +}; + +/** Calls `callback` for each leaf `ColDef`, recursing into `ColGroupDef` children; not called on groups. */ export function forEachColDef(columnDefs: (ColDef | ColGroupDef)[], callback: (colDef: ColDef) => void): void { for (let i = 0, len = columnDefs.length; i < len; ++i) { const def = columnDefs[i]; diff --git a/packages/ag-grid-community/src/columns/dataTypeService.ts b/packages/ag-grid-community/src/columns/dataTypeService.ts index da0483ff9a6..0d3946b9740 100644 --- a/packages/ag-grid-community/src/columns/dataTypeService.ts +++ b/packages/ag-grid-community/src/columns/dataTypeService.ts @@ -30,7 +30,7 @@ import { _isClientSideRowModel } from '../gridOptionsUtils'; import type { IClientSideRowModel } from '../interfaces/iClientSideRowModel'; import type { ColumnEventName } from '../interfaces/iColumn'; import { _warn } from '../validation/logging'; -import { _addColumnDefaultAndTypes } from './columnFactoryUtils'; +import { _addColumnDefaultAndTypes } from './colDefUtils'; import type { ColumnModel } from './columnModel'; import type { ColumnState, ColumnStateParams } from './columnStateUtils'; import { _applyColumnState, getColumnStateFromColDef } from './columnStateUtils'; @@ -95,7 +95,7 @@ export class DataTypeService extends BeanStub implements NamedBean { private initialData: any | null | undefined; private isColumnTypeOverrideInDataTypeDefinitions: boolean = false; // keep track of any column state updates whilst waiting for data types to be inferred - private columnStateUpdatesPendingInference: { [colId: string]: Set } = {}; + private columnStateUpdatesPendingInference: { [colId: string]: Set } = Object.create(null); private columnStateUpdateListenerDestroyFuncs: (() => void)[] = []; public postConstruct(): void { @@ -379,7 +379,7 @@ export class DataTypeService extends BeanStub implements NamedBean { destroyFunc?.(); this.isPendingInference = false; this.processColumnsPendingInference(firstRowData, columnTypeOverridesExist); - this.columnStateUpdatesPendingInference = {}; + this.columnStateUpdatesPendingInference = Object.create(null); if (columnTypeOverridesExist) { colAutosize?.processResizeOperations(); } @@ -391,15 +391,16 @@ export class DataTypeService extends BeanStub implements NamedBean { } private processColumnsPendingInference(firstRowData: any, columnTypeOverridesExist: boolean): void { + const beans = this.beans; this.initialData = firstRowData; const state: ColumnState[] = []; this.destroyColumnStateUpdateListeners(); - const newRowGroupColumnStateWithoutIndex: { [colId: string]: ColumnState } = {}; - const newPivotColumnStateWithoutIndex: { [colId: string]: ColumnState } = {}; + const rowGroupColumnStateWithoutIndex: { [colId: string]: ColumnState } = Object.create(null); + const pivotColumnStateWithoutIndex: { [colId: string]: ColumnState } = Object.create(null); for (const colId of Object.keys(this.columnStateUpdatesPendingInference)) { const columnStateUpdates = this.columnStateUpdatesPendingInference[colId]; - const column = this.colModel.getCol(colId); + const column = this.colModel.colsById[colId]; if (!column) { continue; } @@ -411,47 +412,30 @@ export class DataTypeService extends BeanStub implements NamedBean { if (columnTypeOverridesExist && newColDef.type && newColDef.type !== oldColDef.type) { const updatedColumnState = getUpdatedColumnState(column, columnStateUpdates); if (updatedColumnState.rowGroup && updatedColumnState.rowGroupIndex == null) { - newRowGroupColumnStateWithoutIndex[colId] = updatedColumnState; + rowGroupColumnStateWithoutIndex[colId] = updatedColumnState; } if (updatedColumnState.pivot && updatedColumnState.pivotIndex == null) { - newPivotColumnStateWithoutIndex[colId] = updatedColumnState; + pivotColumnStateWithoutIndex[colId] = updatedColumnState; } state.push(updatedColumnState); } } if (columnTypeOverridesExist) { - state.push( - ...this.generateColumnStateForRowGroupAndPivotIndexes( - newRowGroupColumnStateWithoutIndex, - newPivotColumnStateWithoutIndex - ) - ); + const accumulator: { [colId: string]: ColumnState } = Object.create(null); + beans.rowGroupColsSvc?.restoreColumnOrder(rowGroupColumnStateWithoutIndex, accumulator); + beans.pivotColsSvc?.restoreColumnOrder(pivotColumnStateWithoutIndex, accumulator); + const keys = Object.keys(accumulator); + for (let i = 0, len = keys.length; i < len; ++i) { + state.push(accumulator[keys[i]]); + } } if (state.length) { - _applyColumnState(this.beans, { state }, 'cellDataTypeInferred'); + _applyColumnState(beans, { state }, 'cellDataTypeInferred'); } this.initialData = null; } - private generateColumnStateForRowGroupAndPivotIndexes( - updatedRowGroupColumnState: { [colId: string]: ColumnState }, - updatedPivotColumnState: { [colId: string]: ColumnState } - ): ColumnState[] { - // Generally columns should appear in the order they were before. For any new columns, these should appear in the original col def order. - // The exception is for columns that were added via `addGroupColumns`. These should appear at the end. - // We don't have to worry about full updates, as in this case the arrays are correct, and they won't appear in the updated lists. - - const existingColumnStateUpdates: { [colId: string]: ColumnState } = {}; - - const { rowGroupColsSvc, pivotColsSvc } = this.beans; - - rowGroupColsSvc?.restoreColumnOrder(existingColumnStateUpdates, updatedRowGroupColumnState); - pivotColsSvc?.restoreColumnOrder(existingColumnStateUpdates, updatedPivotColumnState); - - return Object.values(existingColumnStateUpdates); - } - private resetColDefIntoCol(column: AgColumn, source: ColumnEventType): boolean { const userColDef = column.getUserProvidedColDef(); if (!userColDef) { @@ -610,7 +594,7 @@ export class DataTypeService extends BeanStub implements NamedBean { useFormatter: true, }, comparator: (a: any, b: any) => { - const column = colModel.getColDefCol(colId); + const column = colModel.getNonPivotColById(colId); const colDef = column?.colDef; if (!column || !colDef) { return 0; @@ -778,7 +762,7 @@ export class DataTypeService extends BeanStub implements NamedBean { this.dataTypeDefinitions = {}; this.dataTypeMatchers = {}; this.formatValueFuncs = {}; - this.columnStateUpdatesPendingInference = {}; + this.columnStateUpdatesPendingInference = Object.create(null); this.destroyColumnStateUpdateListeners(); super.destroy(); } diff --git a/packages/ag-grid-community/src/columns/groupInstanceIdCreator.ts b/packages/ag-grid-community/src/columns/groupInstanceIdCreator.ts index f79613ee6a2..f7f8123b442 100644 --- a/packages/ag-grid-community/src/columns/groupInstanceIdCreator.ts +++ b/packages/ag-grid-community/src/columns/groupInstanceIdCreator.ts @@ -8,23 +8,13 @@ // getInstanceIdForKey('age') => 0 // getInstanceIdForKey('age') => 1 // getInstanceIdForKey('country') => 4 -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class GroupInstanceIdCreator { - // this map contains keys to numbers, so we remember what the last call was - private existingIds: any = {}; + // Per-key counter: remembers the last instance id handed out for each key. + private readonly existingIds: Record = Object.create(null); public getInstanceIdForKey(key: string): number { - const lastResult = this.existingIds[key]; - let result: number; - if (typeof lastResult !== 'number') { - // first time this key - result = 0; - } else { - result = lastResult + 1; - } - + const result = (this.existingIds[key] ?? -1) + 1; this.existingIds[key] = result; - return result; } } diff --git a/packages/ag-grid-community/src/columns/selectionColService.ts b/packages/ag-grid-community/src/columns/selectionColService.ts index 80e09682584..314be7b26a4 100644 --- a/packages/ag-grid-community/src/columns/selectionColService.ts +++ b/packages/ag-grid-community/src/columns/selectionColService.ts @@ -1,32 +1,17 @@ -import { _removeFromArray } from 'ag-stack'; - import type { NamedBean } from '../context/bean'; -import { BeanStub } from '../context/beanStub'; -import { AgColumn } from '../entities/agColumn'; -import type { ColDef, ColKey } from '../entities/colDef'; -import type { GridOptions, SelectionColumnDef } from '../entities/gridOptions'; +import type { ColKind } from '../entities/agColumn'; +import type { ColDef, SortComparatorFn } from '../entities/colDef'; +import type { GridOptions } from '../entities/gridOptions'; import type { ColumnEventType } from '../events'; import type { PropertyValueChangedEvent } from '../gridOptionsService'; import { _getCheckboxLocation, _getCheckboxes, _getHeaderCheckbox, _isRowSelection } from '../gridOptionsUtils'; -import type { IColumnCollectionService } from '../interfaces/iColumnCollectionService'; -import type { ColumnCollections } from './columnModel'; -import { _applyColumnState } from './columnStateUtils'; -import { - ROW_NUMBERS_COLUMN_ID, - SELECTION_COLUMN_ID, - _areColIdsEqual, - _columnsMatch, - _convertColumnEventSourceType, - _destroyColumnTree, - _getColumnStateFromColDef, - _updateColsMap, - isColumnSelectionCol, -} from './columnUtils'; +import { BaseSingleColService } from './baseSingleColService'; +import { SELECTION_COLUMN_ID, _convertColumnEventSourceType } from './columnUtils'; -export class SelectionColService extends BeanStub implements NamedBean, IColumnCollectionService { +export class SelectionColService extends BaseSingleColService implements NamedBean { beanName = 'selectionColSvc' as const; - public columns: ColumnCollections | null; + protected readonly colKind: ColKind = 'selection'; public postConstruct(): void { this.addManagedPropertyListener('rowSelection', (event) => { @@ -40,88 +25,18 @@ export class SelectionColService extends BeanStub implements NamedBean, IColumnC this.addManagedPropertyListener('selectionColumnDef', this.updateColumns.bind(this)); } - public addColumns(cols: ColumnCollections): void { - const selectionCols = this.columns; - if (selectionCols == null) { - return; - } - cols.list = selectionCols.list.concat(cols.list); - cols.tree = selectionCols.tree.concat(cols.tree); - _updateColsMap(cols); - } - - public createColumns( - cols: ColumnCollections, - updateOrders: (callback: (cols: AgColumn[] | null) => AgColumn[] | null) => void - ): void { - const destroyCollection = () => { - _destroyColumnTree(this.beans, this.columns?.tree); - this.columns = null; - }; - - const newTreeDepth = cols.treeDepth; - const oldTreeDepth = this.columns?.treeDepth ?? -1; - const treeDepthSame = oldTreeDepth == newTreeDepth; - - const list = this.generateSelectionCols(); - const areSame = _areColIdsEqual(list, this.columns?.list ?? []); - - if (areSame && treeDepthSame) { - return; - } - - destroyCollection(); - const { colGroupSvc } = this.beans; - const treeDepth = colGroupSvc?.findDepth(cols.tree) ?? 0; - const tree = colGroupSvc?.balanceTreeForAutoCols(list, treeDepth) ?? []; - this.columns = { - list, - tree, - treeDepth, - map: {}, - }; - - const putSelectionColsFirstInList = (cols?: AgColumn[] | null): AgColumn[] | null => { - if (!cols) { - return null; - } - // we use colId, and not instance, to remove old selectionCols - const colsFiltered = cols.filter((col) => !isColumnSelectionCol(col)); - return [...list, ...colsFiltered]; - }; - - updateOrders(putSelectionColsFirstInList); - } - public updateColumns(event: PropertyValueChangedEvent<'selectionColumnDef'>): void { - const source = _convertColumnEventSourceType(event.source); - const { beans } = this; - for (const col of this.columns?.list ?? []) { - const colDef = this.createSelectionColDef(event.currentValue); - col.setColDef(colDef, null, source); - - _applyColumnState(beans, { state: [_getColumnStateFromColDef(colDef, col.colId)] }, source); - } - } - - public getColumn(key: ColKey): AgColumn | null { - return this.columns?.list.find((col) => _columnsMatch(col, key)) ?? null; - } - - public getColumns(): AgColumn[] | null { - return this.columns?.list ?? null; + this.refreshColDef(_convertColumnEventSourceType(event.source)); } - public isSelectionColumnEnabled(): boolean { + public isEnabled(): boolean { const { gos, beans } = this; const rowSelection = gos.get('rowSelection'); if (typeof rowSelection !== 'object' || !_isRowSelection(gos)) { return false; } - const hasAutoCols = (beans.autoColSvc?.getColumns()?.length ?? 0) > 0; - - if (rowSelection.checkboxLocation === 'autoGroupColumn' && hasAutoCols) { + if (rowSelection.checkboxLocation === 'autoGroupColumn' && !!beans.autoColSvc?.columns.length) { return false; } @@ -131,9 +46,9 @@ export class SelectionColService extends BeanStub implements NamedBean, IColumnC return checkboxes || headerCheckbox; } - private createSelectionColDef(def?: SelectionColumnDef): ColDef { + protected createColDef(): ColDef { const { gos } = this; - const selectionColumnDef = def ?? gos.get('selectionColumnDef'); + const selectionColumnDef = gos.get('selectionColumnDef'); const enableRTL = gos.get('enableRtl'); // We don't support row spanning in the selection column @@ -147,11 +62,7 @@ export class SelectionColService extends BeanStub implements NamedBean, IColumnC sortable: false, suppressMovable: true, lockPosition: enableRTL ? 'right' : 'left', - comparator(valueA, valueB, nodeA, nodeB) { - const aSelected = nodeA.isSelected(); - const bSelected = nodeB.isSelected(); - return aSelected === bSelected ? 0 : aSelected ? 1 : -1; - }, + comparator: selectionComparator, editable: false, suppressFillHandle: true, suppressAutoSize: true, @@ -164,19 +75,6 @@ export class SelectionColService extends BeanStub implements NamedBean, IColumnC }; } - private generateSelectionCols(): AgColumn[] { - if (!this.isSelectionColumnEnabled()) { - return []; - } - - const colDef = this.createSelectionColDef(); - const colId = colDef.colId!; - this.gos.validateColDef(colDef, colId, true); - const col = new AgColumn(colDef, null, colId, false); - this.createBean(col); - return [col]; - } - private onSelectionOptionsChanged( current: GridOptions['rowSelection'], prev: GridOptions['rowSelection'], @@ -198,69 +96,10 @@ export class SelectionColService extends BeanStub implements NamedBean, IColumnC this.beans.colModel.refreshAll(source); } } - - public override destroy(): void { - _destroyColumnTree(this.beans, this.columns?.tree); - super.destroy(); - } - - /** - * Refreshes visibility of the selection column based on which columns are currently visible. - * Called by the VisibleColsService with the columns that are currently visible in left/center/right - * containers. This method *MUTATES* those arrays directly. - * - * The selection column should be visible if all of the following are true - * - The selection column is not disabled - * - The number of visible columns excluding the selection column and row numbers column is greater than 0 - * @param leftCols Visible columns in the left-pinned container - * @param centerCols Visible columns in the center viewport - * @param rightCols Visible columns in the right-pinned container - */ - public refreshVisibility(leftCols: AgColumn[], centerCols: AgColumn[], rightCols: AgColumn[]): void { - // columns list will only be populated if selection column is enabled - if (!this.columns?.list.length) { - return; - } - - const numVisibleCols = leftCols.length + centerCols.length + rightCols.length; - if (numVisibleCols === 0) { - return; - } - - // There's only one selection column - const column = this.columns.list[0]; - - // If it's deliberately hidden, we needn't do anything - if (!column.isVisible()) { - return; - } - - const hideSelectionCol = () => { - let cols; - switch (column.pinned) { - case 'left': - case true: - cols = leftCols; - break; - case 'right': - cols = rightCols; - break; - default: - cols = centerCols; - } - if (cols) { - _removeFromArray(cols, column); - } - }; - - const rowNumbersCol = this.beans.rowNumbersSvc?.getColumn(ROW_NUMBERS_COLUMN_ID); - - // two conditions for which we hide selection column: - // 1. Only selection column and row numbers column are visible - // 2. Only selection column is visible - const expectedNumCols = rowNumbersCol ? 2 : 1; - if (expectedNumCols === numVisibleCols) { - hideSelectionCol(); - } - } } + +const selectionComparator: SortComparatorFn = (_valueA, _valueB, nodeA, nodeB) => { + const aSelected = nodeA.isSelected(); + const bSelected = nodeB.isSelected(); + return aSelected === bSelected ? 0 : aSelected ? 1 : -1; +}; diff --git a/packages/ag-grid-community/src/columns/visibleColsService.ts b/packages/ag-grid-community/src/columns/visibleColsService.ts index 6763bedf243..7ff4e13681e 100644 --- a/packages/ag-grid-community/src/columns/visibleColsService.ts +++ b/packages/ag-grid-community/src/columns/visibleColsService.ts @@ -1,341 +1,430 @@ -import { _last } from 'ag-stack'; - import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; +import type { BeanCollection } from '../context/context'; +import type { CtrlsService } from '../ctrlsService'; import type { AgColumn } from '../entities/agColumn'; -import { isColumn } from '../entities/agColumn'; import type { AgColumnGroup } from '../entities/agColumnGroup'; -import { isColumnGroup } from '../entities/agColumnGroup'; +import { edgeLeafColumn, isColumnGroup } from '../entities/agColumnGroup'; import type { RowNode } from '../entities/rowNode'; import type { ColumnEventType } from '../events'; -import type { ColumnPinnedType, HeaderColumnId } from '../interfaces/iColumn'; -import type { ColumnGroupService, CreateGroupsParams } from './columnGroups/columnGroupService'; +import { _isGroupHideColumnsUntilExpanded, _isRowNumbers } from '../gridOptionsUtils'; +import type { ColumnFlexService } from './columnFlexService'; +import type { ColumnGroupService } from './columnGroups/columnGroupService'; import type { ColumnModel } from './columnModel'; import { getWidthOfColsInList } from './columnUtils'; +import type { ColumnViewportService } from './columnViewportService'; import { GroupInstanceIdCreator } from './groupInstanceIdCreator'; -// takes in a list of columns, as specified by the column definitions, and returns column groups +/** Per-section total pixel widths (left-pinned, centre body, right-pinned). */ +type SectionWidths = { left: number; center: number; right: number }; + /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class VisibleColsService extends BeanStub implements NamedBean { beanName = 'visibleCols' as const; - // tree of columns to be displayed for each section - public treeLeft: (AgColumn | AgColumnGroup)[]; - public treeRight: (AgColumn | AgColumnGroup)[]; - public treeCenter: (AgColumn | AgColumnGroup)[]; + private colModel: ColumnModel; + private colGroupSvc: ColumnGroupService; + private colViewport: ColumnViewportService; + private ctrlsSvc: CtrlsService; + private colFlex?: ColumnFlexService; + /** True iff any centre col has `flex > 0` — lets the flex pass be skipped when nothing flexes. */ + private flexActive = false; - // for fast lookup, to see if a column or group is still visible - private colsAndGroupsMap: { [id: HeaderColumnId]: AgColumn | AgColumnGroup } = {}; + // tree of columns to be displayed for each section + public treeLeft: (AgColumn | AgColumnGroup)[] = []; + public treeRight: (AgColumn | AgColumnGroup)[] = []; + public treeCenter: (AgColumn | AgColumnGroup)[] = []; - // leave level columns of the displayed trees public leftCols: AgColumn[] = []; public rightCols: AgColumn[] = []; public centerCols: AgColumn[] = []; - // all three lists above combined + /** `leftCols + centerCols + rightCols` (RTL: right + center + left). */ public allCols: AgColumn[] = []; - public headerGroupRowCount: number = 0; // number of header rows to render + /** `allCols` with `colDef.autoHeight`. Reused across refreshes to stay warm. */ + public readonly autoHeightCols: AgColumn[] = []; - public autoHeightCols: AgColumn[]; + /** Number of header rows to render, accounting for group depth + padding rules. */ + public headerGroupRowCount: number = 0; - // used by: - // + angularGrid -> for setting body width - // + rowController -> setting main row widths (when inserting and resizing) - // need to cache this + /** Centre body width. Cached for body sizing and row insert/resize. */ public bodyWidth = 0; - private leftWidth = 0; - private rightWidth = 0; + public leftWidth = 0; + public rightWidth = 0; + public totalWidth = 0; + /** `bodyWidth` changed in the last `updateBodyWidths()` — drives the RTL virtual-col calc. */ public isBodyWidthDirty = true; - // list of all columns (displayed and hidden) in visible order including pinned - private ariaOrderColumns: AgColumn[]; + /** Prev refresh's pinned-edge cols — drive an O(1) role-swap in `setFirstRightAndLastLeftPinned`. */ + private prevLastLeftPinned: AgColumn | null = null; + private prevFirstRightPinned: AgColumn | null = null; - public refresh(source: ColumnEventType, skipTreeBuild = false): void { - const { colFlex, colModel, colGroupSvc, colViewport, selectionColSvc, ctrlsSvc } = this.beans; - // when we open/close col group, skipTreeBuild=false, as we know liveCols haven't changed + public wireBeans(beans: BeanCollection): void { + this.colModel = beans.colModel; + this.colGroupSvc = beans.colGroupSvc; + this.colViewport = beans.colViewport; + this.ctrlsSvc = beans.ctrlsSvc; + this.colFlex = beans.colFlex; + } + + /** `skipTreeBuild=true` reuses the trees; valid only when liveCols are unchanged (group toggle, width autosize). */ + public refresh(source: ColumnEventType, skipTreeBuild: boolean): void { + const { colFlex, colModel, colViewport, ctrlsSvc } = this; if (!skipTreeBuild) { - this.buildTrees(colModel, colGroupSvc); + this.buildTrees(); } - colGroupSvc?.updateOpenClosedVisibility(); - - this.leftCols = pickDisplayedCols(this.treeLeft); - this.centerCols = pickDisplayedCols(this.treeCenter); - this.rightCols = pickDisplayedCols(this.treeRight); - - selectionColSvc?.refreshVisibility(this.leftCols, this.centerCols, this.rightCols); - - this.joinColsAriaOrder(colModel); - this.joinCols(); - - this.headerGroupRowCount = this.getHeaderRowCount(); - - this.setLeftValues(source); - this.autoHeightCols = this.allCols.filter((col) => col.isAutoHeight()); - // The cached flex viewport width inside `colFlex` only updates from the resize observer - // in viewportSizeFeature. When pinning changes the logical centre width without resizing - // the DOM viewport, we must pass the freshly-derived centre width here. - // Compute pinned widths directly from the just-updated column lists rather than using - // `getCenterWidth()` — the latter reads the cached `leftWidth`/`rightWidth` via - // `getLeftStickyColumnContainerWidth`, which is only refreshed by `updateBodyWidths` below. - const viewportWidth = ctrlsSvc?.getGridBodyCtrl()?.getViewportWidthWithoutScrollbar(); - let flexParams: { viewportWidth: number } | undefined; - if (viewportWidth != null) { - const centerWidth = - viewportWidth - getWidthOfColsInList(this.leftCols) - getWidthOfColsInList(this.rightCols); - flexParams = { viewportWidth: centerWidth > 0 ? centerWidth : 0 }; + // One top-down DFS per section: computes `displayedChildren` and collects displayed leaves. + const treeLeft = this.treeLeft; + const treeCenter = this.treeCenter; + const treeRight = this.treeRight; + let leftCols: AgColumn[]; + let centerCols: AgColumn[]; + let rightCols: AgColumn[]; + if (colModel.colsTreeDepth === 0) { + // Depth 0: trees are flat leaf lists; reuse directly (the DFS would only copy). + leftCols = treeLeft as AgColumn[]; + centerCols = treeCenter as AgColumn[]; + rightCols = treeRight as AgColumn[]; + } else { + leftCols = []; + centerCols = []; + rightCols = []; + for (let i = 0, len = treeLeft.length; i < len; ++i) { + updateGroupsAndCollectLeaves(treeLeft[i], null, leftCols); + } + for (let i = 0, len = treeCenter.length; i < len; ++i) { + updateGroupsAndCollectLeaves(treeCenter[i], null, centerCols); + } + for (let i = 0, len = treeRight.length; i < len; ++i) { + updateGroupsAndCollectLeaves(treeRight[i], null, rightCols); + } + } + this.leftCols = leftCols; + this.centerCols = centerCols; + this.rightCols = rightCols; + + // `joinCols` stamps each col's `left` + returns section widths; then clear stale lefts + groups. + const widths = this.joinCols(source); + this.setLeftValuesOfGroups(source); + + // Run flex sizing when a flex col exists OR cols await a flex-then-reveal pass + // (a "had flex" → "no flex" transition still needs the reveal). + const runFlex = this.flexActive || colFlex?.columnsHidden; + if (runFlex) { + // colFlex's cached viewport width only updates on DOM resize, but pinning changes centre + // width without one — so derive centre width from the just-set section totals. + const viewportWidth = ctrlsSvc?.getGridBodyCtrl()?.getViewportWidthWithoutScrollbar(); + let flexParams: { viewportWidth: number } | undefined; + if (viewportWidth != null) { + const centerWidth = viewportWidth - widths.left - widths.right; + flexParams = { viewportWidth: centerWidth > 0 ? centerWidth : 0 }; + } + colFlex?.refreshFlexedColumns(flexParams); } - colFlex?.refreshFlexedColumns(flexParams); - this.updateBodyWidths(); - this.setFirstRightAndLastLeftPinned(colModel, this.leftCols, this.rightCols, source); + // Reuse the section totals — except after a flex pass, which resized centre cols, so re-sum. + this.updateBodyWidths(runFlex ? undefined : widths); + this.setFirstRightAndLastLeftPinned(leftCols, rightCols, source); colViewport.checkViewportColumns(false); - this.eventSvc.dispatchEvent({ - type: 'displayedColumnsChanged', - source, - }); + this.eventSvc.dispatchEvent({ type: 'displayedColumnsChanged', source }); } - private getHeaderRowCount(): number { - if (!this.gos.get('hidePaddedHeaderRows')) { - return this.beans.colModel.cols!.treeDepth; - } + /** `widths` reuses totals already computed on the hot `refresh` path; omit to re-sum. */ + public updateBodyWidths(widths?: SectionWidths): void { + const newBodyWidth = widths ? widths.center : getWidthOfColsInList(this.centerCols); + const newLeftWidth = widths ? widths.left : getWidthOfColsInList(this.leftCols); + const newRightWidth = widths ? widths.right : getWidthOfColsInList(this.rightCols); - let headerGroupRowCount = 0; - for (const col of this.allCols) { - let parent = col.parent; - while (parent) { - if (!parent.isPadding()) { - const level = parent.getProvidedColumnGroup().getLevel() + 1; - if (level > headerGroupRowCount) { - headerGroupRowCount = level; - } - break; - } + // Drives the RTL virtual-col calc — body-width changes flip y coords. + const bodyWidthDirty = this.bodyWidth !== newBodyWidth; + this.isBodyWidthDirty = bodyWidthDirty; - parent = parent.parent; - } + if (!bodyWidthDirty && this.leftWidth === newLeftWidth && this.rightWidth === newRightWidth) { + return; } - - return headerGroupRowCount; + this.bodyWidth = newBodyWidth; + this.leftWidth = newLeftWidth; + this.rightWidth = newRightWidth; + this.totalWidth = newBodyWidth + newLeftWidth + newRightWidth; + + // `columnContainerWidthChanged` BEFORE `displayedColumnsWidthChanged`: viewport must resize + // before the scrollbar updates its visibility. + const eventSvc = this.eventSvc; + eventSvc.dispatchEvent({ type: 'columnContainerWidthChanged' }); + eventSvc.dispatchEvent({ type: 'displayedColumnsWidthChanged' }); } - // after setColumnWidth or updateGroupsAndPresentedCols - public updateBodyWidths(): void { - const newBodyWidth = getWidthOfColsInList(this.centerCols); - const newLeftWidth = getWidthOfColsInList(this.leftCols); - const newRightWidth = getWidthOfColsInList(this.rightCols); - - // this is used by virtual col calculation, for RTL only, as a change to body width can impact displayed - // columns, due to RTL inverting the y coordinates - this.isBodyWidthDirty = this.bodyWidth !== newBodyWidth; - - const atLeastOneChanged = - this.bodyWidth !== newBodyWidth || this.leftWidth !== newLeftWidth || this.rightWidth !== newRightWidth; - - if (atLeastOneChanged) { - this.bodyWidth = newBodyWidth; - this.leftWidth = newLeftWidth; - this.rightWidth = newRightWidth; - - // this event is fired to allow the grid viewport to resize before the - // scrollbar tries to update its visibility. - this.eventSvc.dispatchEvent({ - type: 'columnContainerWidthChanged', - }); - - // when this fires, it is picked up by the gridPanel, which ends up in - // gridPanel calling setWidthAndScrollPosition(), which in turn calls setViewportPosition() - this.eventSvc.dispatchEvent({ - type: 'displayedColumnsWidthChanged', - }); - } + /** Repositions each col's section-relative `left` without rebuilding the displayed set; returns + * per-section widths. Lighter sibling of `joinCols`, for resize / autosize / flex. */ + public setLeftValues(source: ColumnEventType): SectionWidths { + const left = setLeftsLeftToRight(this.leftCols, source); + const right = setLeftsLeftToRight(this.rightCols, source); + const center = setLeftsLeftToRight(this.centerCols, source); + this.setLeftValuesOfGroups(source); + return { left, center, right }; } - // sets the left pixel position of each column - public setLeftValues(source: ColumnEventType): void { - this.setLeftValuesOfCols(source); - this.setLeftValuesOfGroups(); + private setLeftValuesOfGroups(source: ColumnEventType): void { + // A col that left the displayed set keeps a stale `left`; clear it so it doesn't render offset. + // `setLeft(null)` short-circuits on already-null cols, so cheap on warm refreshes. + const colsList = this.colModel.colsList; + for (let i = 0, len = colsList.length; i < len; ++i) { + const column = colsList[i]; + if (!column.displayed) { + column.setLeft(null, source); + } + } + checkLeftOnGroups(this.treeLeft); + checkLeftOnGroups(this.treeRight); + checkLeftOnGroups(this.treeCenter); } - private setFirstRightAndLastLeftPinned( - colModel: ColumnModel, - leftCols: AgColumn[], - rightCols: AgColumn[], - source: ColumnEventType - ): void { - const lastLeft = leftCols.length ? _last(leftCols) : null; - let firstRight: AgColumn | null = null; - if (rightCols.length) { - firstRight = this.gos.get('enableRtl') ? _last(rightCols) : rightCols[0]; + private setFirstRightAndLastLeftPinned(leftCols: AgColumn[], rightCols: AgColumn[], source: ColumnEventType): void { + const leftLen = leftCols.length; + const newLastLeft = leftLen ? leftCols[leftLen - 1] : null; + let newFirstRight: AgColumn | null = null; + const rightLen = rightCols.length; + if (rightLen) { + newFirstRight = this.gos.get('enableRtl') ? rightCols[rightLen - 1] : rightCols[0]; } - for (const col of colModel.getCols()) { - col.setLastLeftPinned(col === lastLeft, source); - col.setFirstRightPinned(col === firstRight, source); + // Destroyed prev refs are harmless: `AgColumn.destroy` already clears these flags, so the + // false-clear short-circuits via the no-change guard. + const prevLastLeft = this.prevLastLeftPinned; + if (prevLastLeft !== newLastLeft) { + prevLastLeft?.setLastLeftPinned(false, source); + newLastLeft?.setLastLeftPinned(true, source); + this.prevLastLeftPinned = newLastLeft; + } + const prevFirstRight = this.prevFirstRightPinned; + if (prevFirstRight !== newFirstRight) { + prevFirstRight?.setFirstRightPinned(false, source); + newFirstRight?.setFirstRightPinned(true, source); + this.prevFirstRightPinned = newFirstRight; } } - private buildTrees(colModel: ColumnModel, columnGroupSvc: ColumnGroupService | undefined) { - const cols = colModel.getColsToShow(); - - const leftCols = cols.filter((col) => col.getPinned() == 'left'); - const rightCols = cols.filter((col) => col.getPinned() == 'right'); - const centerCols = cols.filter((col) => col.getPinned() != 'left' && col.getPinned() != 'right'); - + private buildTrees() { + const { colModel, colGroupSvc } = this; + const { leftCols, rightCols, centerCols, leftCount, centerCount } = this.partitionVisibleCols(); + this.stampAriaColIndexes(leftCount, centerCount); const idCreator = new GroupInstanceIdCreator(); + if (colGroupSvc) { + const buildToken = colModel.nextBuildToken(); + this.treeLeft = colGroupSvc.createGroups(leftCols, idCreator, 'left', buildToken); + this.treeRight = colGroupSvc.createGroups(rightCols, idCreator, 'right', buildToken); + this.treeCenter = colGroupSvc.createGroups(centerCols, idCreator, null, buildToken); + colGroupSvc.prune(buildToken); + } else { + // No group service: trees are flat lists of cols. + this.treeLeft = leftCols; + this.treeRight = rightCols; + this.treeCenter = centerCols; + } + } + + /** Single pass over `colsList`: filter to displayable cols and bucket by pin. The selection col is held + * back until a non-service col proves it isn't the only displayed col — a lone checkbox adds nothing. */ + private partitionVisibleCols(): { + leftCols: AgColumn[]; + rightCols: AgColumn[]; + centerCols: AgColumn[]; + leftCount: number; + centerCount: number; + } { + const colModel = this.colModel; + const leftCols: AgColumn[] = []; + const rightCols: AgColumn[] = []; + const centerCols: AgColumn[] = []; + // Counts ALL colsList cols by pin (hidden included) to seed the aria cursors in one pass: + // hidden cols still take aria slots, so the displayed buckets can't be used. + let leftCount = 0; + let centerCount = 0; + if (!colModel.ready) { + return { leftCols, rightCols, centerCols, leftCount, centerCount }; + } - const createGroups = (params: CreateGroupsParams): (AgColumn | AgColumnGroup)[] => { - return columnGroupSvc ? columnGroupSvc.createColumnGroups(params) : params.columns; - }; - this.treeLeft = createGroups({ - columns: leftCols, - idCreator, - pinned: 'left', - oldDisplayedGroups: this.treeLeft, - }); - this.treeRight = createGroups({ - columns: rightCols, - idCreator, - pinned: 'right', - oldDisplayedGroups: this.treeRight, - }); - this.treeCenter = createGroups({ - columns: centerCols, - idCreator, - pinned: null, - oldDisplayedGroups: this.treeCenter, - }); - - this.updateColsAndGroupsMap(); + const beans = this.beans; + const showAutoGroupAndValuesOnly = colModel.pivotMode && !colModel.showingPivotResult; + const showSelectionColumn = beans.selectionColSvc?.isEnabled() ?? false; + const showRowNumbers = _isRowNumbers(beans); + const hideEmptyAutoColGroups = _isGroupHideColumnsUntilExpanded(this.gos); + + const colsList = colModel.colsList; + let pending: AgColumn | null = null; + for (let i = 0, len = colsList.length; i < len; ++i) { + const col = colsList[i]; + const colKind = col.colKind; + const pinned = col.pinned; + // right cursor derives from total - left - center, so only left/center need counting + if (pinned !== 'right') { + if (pinned) { + ++leftCount; + } else { + ++centerCount; + } + } + const isAutoGroupCol = colKind === 'auto-group'; + let visible: boolean; + if (showAutoGroupAndValuesOnly) { + // `col.aggregationActive` ⟺ membership of valueColsSvc. + visible = + col.aggregationActive || + (isAutoGroupCol && (!hideEmptyAutoColGroups || col.visible)) || + (showSelectionColumn && colKind === 'selection') || + (showRowNumbers && colKind === 'row-number'); + } else { + visible = (isAutoGroupCol && !hideEmptyAutoColGroups) || col.visible; + } + if (!visible) { + continue; + } + if (colKind === 'selection' && col.visible) { + pending = col; + continue; + } + if (pending !== null && colKind !== 'row-number') { + const selPinned = pending.pinned; + if (selPinned === 'right') { + rightCols.push(pending); + } else if (selPinned) { + leftCols.push(pending); + } else { + centerCols.push(pending); + } + pending = null; + } + if (pinned === 'right') { + rightCols.push(col); + } else if (pinned) { + leftCols.push(col); + } else { + centerCols.push(col); + } + } + return { leftCols, rightCols, centerCols, leftCount, centerCount }; } public clear(): void { + const prevAll = this.allCols; + for (let i = 0, len = prevAll.length; i < len; ++i) { + prevAll[i].allColsIndex = -1; + prevAll[i].displayed = false; + } this.leftCols = []; this.rightCols = []; this.centerCols = []; this.allCols = []; - this.ariaOrderColumns = []; } - private joinColsAriaOrder(colModel: ColumnModel): void { - const allColumns = colModel.getCols(); - const pinnedLeft: AgColumn[] = []; - const center: AgColumn[] = []; - const pinnedRight: AgColumn[] = []; - - for (const col of allColumns) { - const pinned = col.getPinned(); - if (!pinned) { - center.push(col); - } else if (pinned === true || pinned === 'left') { - pinnedLeft.push(col); + private stampAriaColIndexes(leftCount: number, centerCount: number): void { + const cols = this.colModel.colsList; + // 1-based: the value is consumed directly as `aria-colindex` (no `+1` at read time). + let leftCursor = 1; + let centerCursor = leftCount + 1; + let rightCursor = leftCount + centerCount + 1; + for (let i = 0, total = cols.length; i < total; ++i) { + const col = cols[i]; + const pinned = col.pinned; + if (pinned === 'right') { + col.ariaColIndex = rightCursor++; + } else if (pinned) { + col.ariaColIndex = leftCursor++; } else { - pinnedRight.push(col); + col.ariaColIndex = centerCursor++; } } - - this.ariaOrderColumns = pinnedLeft.concat(center).concat(pinnedRight); } - public getAriaColIndex(colOrGroup: AgColumn | AgColumnGroup): number { - let col: AgColumn; - - if (isColumnGroup(colOrGroup)) { - col = colOrGroup.getLeafColumns()[0]; + /** One pass over displayed cols: stamps `allColsIndex` (display order; RTL flips sections) and + * section-relative `left`, returning per-section widths so {@link updateBodyWidths} needn't re-sum. */ + private joinCols(source: ColumnEventType): SectionWidths { + const { leftCols, centerCols, rightCols } = this; + // `skipTreeBuild` path skips `clear()`, so un-stamp the prior set here: departed cols must + // reach `displayed === false` or `setLeftValuesOfGroups` won't clear their stale `left`. + const prevAll = this.allCols; + for (let i = 0, len = prevAll.length; i < len; ++i) { + prevAll[i].allColsIndex = -1; + prevAll[i].displayed = false; + } + const all: AgColumn[] = []; + this.autoHeightCols.length = 0; + // `layoutSection` accumulates `flexActive` / `headerGroupRowCount` across its three calls — reset them first. + this.flexActive = false; + const hidePaddedHeaderRows = !!this.gos.get('hidePaddedHeaderRows'); + this.headerGroupRowCount = hidePaddedHeaderRows ? 0 : this.colModel.colsTreeDepth; + + let leftWidth: number; + let centerWidth: number; + let rightWidth: number; + if (this.gos.get('enableRtl')) { + rightWidth = this.layoutSection(rightCols, all, hidePaddedHeaderRows, source); + centerWidth = this.layoutSection(centerCols, all, hidePaddedHeaderRows, source); + leftWidth = this.layoutSection(leftCols, all, hidePaddedHeaderRows, source); } else { - col = colOrGroup; + leftWidth = this.layoutSection(leftCols, all, hidePaddedHeaderRows, source); + centerWidth = this.layoutSection(centerCols, all, hidePaddedHeaderRows, source); + rightWidth = this.layoutSection(rightCols, all, hidePaddedHeaderRows, source); } - return this.ariaOrderColumns.indexOf(col) + 1; + this.allCols = all; + return { left: leftWidth, center: centerWidth, right: rightWidth }; } - private setLeftValuesOfGroups(): void { - // a groups left value is the lest left value of it's children - for (const columns of [this.treeLeft, this.treeRight, this.treeCenter]) { - for (const column of columns) { - if (isColumnGroup(column)) { - const columnGroup = column; - columnGroup.checkLeft(); - } + /** Lays one section's cols into `all`: stamps `allColsIndex` + section-relative `left`, folding in + * `autoHeightCols`/`flexActive`/`headerGroupRowCount`. A method not a closure, so it allocates nothing. */ + private layoutSection( + cols: AgColumn[], + all: AgColumn[], + hidePaddedHeaderRows: boolean, + source: ColumnEventType + ): number { + const autoHeightCols = this.autoHeightCols; + let left = 0; + // Leaves under one group are contiguous; skip the parent-chain walk for same-parent runs. + let lastParent: AgColumnGroup | null = null; + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + col.allColsIndex = all.length; + col.displayed = true; + col.setLeft(left, source); + all.push(col); + if (col.colDef.autoHeight) { + autoHeightCols.push(col); } - } - } - - private setLeftValuesOfCols(source: ColumnEventType): void { - const { colModel } = this.beans; - if (!colModel.getColDefCols()) { - return; - } - - const displayedCols = new Set(); - for (const columns of [this.leftCols, this.rightCols, this.centerCols]) { - let left = 0; - for (const column of columns) { - column.setLeft(left, source); - left += column.getActualWidth(); - displayedCols.add(column); + if (!this.flexActive && col.pinned == null) { + const flex = col.flex; + if (flex != null && flex > 0) { + this.flexActive = true; + } } - } - - // columns not in the displayed set need their left position reset. this is important for the - // rows, as if a col is made visible, then taken out, then made visible again, we don't want - // the animation of the cell floating in from the old position, whatever that was. - for (const column of colModel.getCols()) { - if (!displayedCols.has(column)) { - column.setLeft(null, source); + if (hidePaddedHeaderRows) { + const parent = col.parent; + if (parent !== null && parent !== lastParent) { + lastParent = parent; + const depth = displayedHeaderGroupDepth(parent); + if (depth > this.headerGroupRowCount) { + this.headerGroupRowCount = depth; + } + } } + left += col.actualWidth; } - } - - private joinCols(): void { - if (this.gos.get('enableRtl')) { - this.allCols = this.rightCols.concat(this.centerCols).concat(this.leftCols); - } else { - this.allCols = this.leftCols.concat(this.centerCols).concat(this.rightCols); - } - } - - public getAllTrees(): (AgColumn | AgColumnGroup)[] | null { - if (this.treeLeft && this.treeRight && this.treeCenter) { - return this.treeLeft.concat(this.treeCenter).concat(this.treeRight); - } - - return null; - } - - // gridPanel -> ensureColumnVisible - public isColDisplayed(column: AgColumn): boolean { - return this.allCols.indexOf(column) >= 0; + return left; } public getLeftColsForRow(rowNode: RowNode): AgColumn[] { - const { - leftCols, - beans: { colModel }, - } = this; - const colSpanActive = colModel.colSpanActive; - if (!colSpanActive) { - return leftCols; - } - - return this.getColsForRow(rowNode, leftCols); + return this.colModel.colSpanActive ? this.getColsForRow(rowNode, this.leftCols) : this.leftCols; } public getRightColsForRow(rowNode: RowNode): AgColumn[] { - const { - rightCols, - beans: { colModel }, - } = this; - const colSpanActive = colModel.colSpanActive; - if (!colSpanActive) { - return rightCols; - } - - return this.getColsForRow(rowNode, rightCols); + return this.colModel.colSpanActive ? this.getColsForRow(rowNode, this.rightCols) : this.rightCols; } + /** `filterCallback` is only set for the centre (virtualised) area. A col-spanned run is kept if + * ANY spanned col passes the filter. */ public getColsForRow( rowNode: RowNode, displayedColumns: AgColumn[], @@ -344,35 +433,17 @@ export class VisibleColsService extends BeanStub implements NamedBean { ): AgColumn[] { const result: AgColumn[] = []; let lastConsideredCol: AgColumn | null = null; + const len = displayedColumns.length; - for (let i = 0; i < displayedColumns.length; i++) { + for (let i = 0; i < len; ++i) { const col = displayedColumns[i]; - const maxAllowedColSpan = displayedColumns.length - i; - const colSpan = Math.min(col.getColSpan(rowNode), maxAllowedColSpan); - const columnsToCheckFilter: AgColumn[] = [col]; - - if (colSpan > 1) { - const colsToRemove = colSpan - 1; + const colSpan = Math.min(col.getColSpan(rowNode), len - i); - for (let j = 1; j <= colsToRemove; j++) { - columnsToCheckFilter.push(displayedColumns[i + j]); - } - - i += colsToRemove; - } - - // see which cols we should take out for column virtualisation let filterPasses: boolean; - if (filterCallback) { - // if user provided a callback, means some columns may not be in the viewport. - // the user will NOT provide a callback if we are talking about pinned areas, - // as pinned areas have no horizontal scroll and do not virtualise the columns. - // if lots of columns, that means column spanning, and we set filterPasses = true - // if one or more of the columns spanned pass the filter. - filterPasses = false; - for (const colForFilter of columnsToCheckFilter) { - if (filterCallback(colForFilter)) { + filterPasses = filterCallback(col); + for (let j = 1; !filterPasses && j < colSpan; ++j) { + if (filterCallback(displayedColumns[i + j])) { filterPasses = true; } } @@ -380,12 +451,13 @@ export class VisibleColsService extends BeanStub implements NamedBean { filterPasses = true; } + if (colSpan > 1) { + i += colSpan - 1; + } + if (filterPasses) { - if (result.length === 0 && lastConsideredCol) { - const gapBeforeColumn = emptySpaceBeforeColumn ? emptySpaceBeforeColumn(col) : false; - if (gapBeforeColumn) { - result.push(lastConsideredCol); - } + if (result.length === 0 && lastConsideredCol && emptySpaceBeforeColumn?.(col)) { + result.push(lastConsideredCol); } result.push(col); } @@ -396,148 +468,137 @@ export class VisibleColsService extends BeanStub implements NamedBean { return result; } - public getContainerWidth(pinned: ColumnPinnedType): number { - switch (pinned) { - case 'left': - return this.leftWidth; - case 'right': - return this.rightWidth; - default: - return this.bodyWidth; - } - } - public getColBefore(col: AgColumn): AgColumn | null { - const allDisplayedColumns = this.allCols; - const oldIndex = allDisplayedColumns.indexOf(col); - - if (oldIndex > 0) { - return allDisplayedColumns[oldIndex - 1]; - } - - return null; - } - - public isPinningLeft(): boolean { - return this.leftCols.length > 0; - } - - public isPinningRight(): boolean { - return this.rightCols.length > 0; - } - - private updateColsAndGroupsMap(): void { - this.colsAndGroupsMap = {}; - - const func = (child: AgColumn | AgColumnGroup) => { - this.colsAndGroupsMap[child.getUniqueId()] = child; - }; - - depthFirstAllColumnTreeSearch(this.treeCenter, false, func); - depthFirstAllColumnTreeSearch(this.treeLeft, false, func); - depthFirstAllColumnTreeSearch(this.treeRight, false, func); - } - - public isVisible(item: AgColumn | AgColumnGroup): boolean { - const fromMap = this.colsAndGroupsMap[item.getUniqueId()]; - // check for reference, in case new column / group with same id is now present - return fromMap === item; + const idx = col.allColsIndex; + return idx > 0 ? this.allCols[idx - 1] : null; } - public getFirstColumn(): AgColumn | null { - const isRtl = this.gos.get('enableRtl'); - const queryOrder: ('leftCols' | 'centerCols' | 'rightCols')[] = ['leftCols', 'centerCols', 'rightCols']; - - if (isRtl) { - queryOrder.reverse(); - } - - for (let i = 0; i < queryOrder.length; i++) { - const container = this[queryOrder[i]]; - if (container.length) { - return isRtl ? _last(container) : container[0]; - } - } - - return null; - } - - // used by: - // + rowRenderer -> for navigation public getColAfter(col: AgColumn): AgColumn | null { - const allDisplayedColumns = this.allCols; - const oldIndex = allDisplayedColumns.indexOf(col); - - if (oldIndex < allDisplayedColumns.length - 1) { - return allDisplayedColumns[oldIndex + 1]; - } - - return null; + const cols = this.allCols; + const idx = col.allColsIndex; + // Not-displayed col (idx === -1) falls through to first col — header navigation relies on it. + return idx < cols.length - 1 ? cols[idx + 1] : null; } - // used by: - // + angularGrid -> setting pinned body width - // note: the cache value `leftWidth` can be stale while actively moving column, so prefer getWidthOfColsInList. + /** Prefer the recomputed width: the `leftWidth` cache can be stale mid column-move. */ public getLeftStickyColumnContainerWidth() { - // sometimes the leftCols are empty after a refresh, so attempt to grab cached values. return this.leftCols.length ? getWidthOfColsInList(this.leftCols) : this.leftWidth; } - // note: the cache value `rightWidth` can be stale while actively moving columns, so prefer getWidthOfColsInList. + /** Prefer the recomputed width: the `rightWidth` cache can be stale mid column-move. */ public getRightStickyColumnContainerWidth() { - // sometimes the rightCols are empty after a refresh, so attempt to grab cached values. return this.rightCols.length ? getWidthOfColsInList(this.rightCols) : this.rightWidth; } public isColAtEdge(col: AgColumn | AgColumnGroup, edge: 'first' | 'last'): boolean { - const allColumns = this.allCols; - if (!allColumns.length) { + const allCols = this.allCols; + const allLen = allCols.length; + if (!allLen) { return false; } - const isFirst = edge === 'first'; - - let columnToCompare: AgColumn; - if (isColumnGroup(col)) { - const leafColumns = col.getDisplayedLeafColumns(); - if (!leafColumns.length) { - return false; - } - - columnToCompare = isFirst ? leafColumns[0] : _last(leafColumns); - } else { - columnToCompare = col; + const target = isColumnGroup(col) ? edgeLeafColumn(col, true, !isFirst) : col; + if (!target) { + return false; } - - return (isFirst ? allColumns[0] : _last(allColumns)) === columnToCompare; + return (isFirst ? allCols[0] : allCols[allLen - 1]) === target; } } -export function depthFirstAllColumnTreeSearch( - tree: (AgColumn | AgColumnGroup)[] | null, - useDisplayedChildren: boolean, - callback: (treeNode: AgColumn | AgColumnGroup) => void -): void { - if (!tree) { - return; +/** Top-down DFS: computes each `group.displayedChildren` and collects displayed leaves into `out` in one pass. + * `parentWithExpansion` carries `columnGroupShow` down (no per-group parent walk). Returns true if anything changed. */ +const updateGroupsAndCollectLeaves = ( + node: AgColumn | AgColumnGroup, + parentWithExpansion: AgColumnGroup | null, + out: AgColumn[] +): boolean => { + if (node.isColumn) { + out.push(node); + return false; } - - for (let i = 0; i < tree.length; i++) { - const child = tree[i]; - if (isColumnGroup(child)) { - const childTree = useDisplayedChildren ? child.getDisplayedChildren() : child.getChildren(); - depthFirstAllColumnTreeSearch(childTree, useDisplayedChildren, callback); + const myParentWithExpansion = node.isPadding() ? parentWithExpansion : node; + const provided = myParentWithExpansion?.providedColumnGroup ?? null; + const expandable = provided?.expandable; + + const oldList = node.displayedChildren; + const oldLen = oldList?.length ?? 0; + let newList: (AgColumn | AgColumnGroup)[] | null = null; + let outLen = 0; + let descendantChanged = false; + const children = node.children; + if (children !== null) { + const expanded = expandable && provided.expanded; + for (let i = 0, childrenLen = children.length; i < childrenLen; ++i) { + const child = children[i]; + if (expandable) { + const show = child.getColumnGroupShow(); + // padding children carry no `columnGroupShow`, so the default branch keeps them + if ((show === 'open' && !expanded) || (show === 'closed' && expanded)) { + continue; + } + const startOut = out.length; + if (updateGroupsAndCollectLeaves(child, myParentWithExpansion, out)) { + descendantChanged = true; + } + if (out.length === startOut) { + // Empty group under an expandable parent — exclude. + continue; + } + } else if (updateGroupsAndCollectLeaves(child, myParentWithExpansion, out)) { + descendantChanged = true; // Not expandable: every child is displayed; recurse only to compute descendants. + } + if (newList !== null) { + newList.push(child); + } else if (outLen >= oldLen || oldList![outLen] !== child) { + if (oldList === null) { + newList = [child]; + } else { + newList = oldList.slice(0, outLen); + newList.push(child); + } + } + ++outLen; } - callback(child); } -} - -function pickDisplayedCols(tree: (AgColumn | AgColumnGroup)[]): AgColumn[] { - const res: AgColumn[] = []; - depthFirstAllColumnTreeSearch(tree, true, (child) => { - if (isColumn(child)) { - res.push(child); + const selfChanged = newList !== null || outLen !== oldLen; + if (selfChanged) { + // `newList === null` => same prefix but oldList was longer => truncate to `outLen`. + node.displayedChildren = newList ?? oldList!.slice(0, outLen); + } + if (selfChanged || descendantChanged) { + node.dispatchLocalEvent({ type: 'displayedChildrenChanged' }); + } + return selfChanged || descendantChanged; +}; + +const displayedHeaderGroupDepth = (group: AgColumnGroup): number => { + let current: AgColumnGroup | null = group; + while (current) { + const provided = current.providedColumnGroup; + if (!provided.padding) { + return provided.level + 1; } - }); - return res; -} + current = current.parent; + } + return 0; +}; + +const checkLeftOnGroups = (tree: (AgColumn | AgColumnGroup)[]): void => { + for (let i = 0, len = tree.length; i < len; ++i) { + const node = tree[i]; + if (isColumnGroup(node)) { + node.checkLeft(); + } + } +}; + +/** Sets each col's `left` to the running offset; returns the total width (the final offset). */ +const setLeftsLeftToRight = (columns: AgColumn[], source: ColumnEventType): number => { + let left = 0; + for (let i = 0, len = columns.length; i < len; ++i) { + const column = columns[i]; + column.setLeft(left, source); + left += column.actualWidth; + } + return left; +}; diff --git a/packages/ag-grid-community/src/context/context.ts b/packages/ag-grid-community/src/context/context.ts index 448f8d5099b..5e480bf3a18 100644 --- a/packages/ag-grid-community/src/context/context.ts +++ b/packages/ag-grid-community/src/context/context.ts @@ -59,10 +59,10 @@ import type { IAdvancedFilterService } from '../interfaces/iAdvancedFilterServic import type { IAggColumnNameService } from '../interfaces/iAggColumnNameService'; import type { IAggFuncService } from '../interfaces/iAggFuncService'; import type { IAggregatedChildrenSvc } from '../interfaces/iAggregatedChildrenSvc'; +import type { IAutoColService } from '../interfaces/iAutoColService'; import type { ICalculatedColumnsService } from '../interfaces/iCalculatedColumns'; import type { IClipboardService } from '../interfaces/iClipboardService'; -import type { IColsService } from '../interfaces/iColsService'; -import type { IColumnCollectionService } from '../interfaces/iColumnCollectionService'; +import type { IPivotColsService, IRowGroupColsService, IValueColsService } from '../interfaces/iColsService'; import type { IColumnStateUpdateStrategy } from '../interfaces/iColumnStateUpdateStrategy'; import type { AgGridCommon } from '../interfaces/iCommon'; import type { IContextMenuService } from '../interfaces/iContextMenu'; @@ -316,14 +316,14 @@ interface CoreBeanCollection extends AgCoreBeanCollection< eRootDiv: HTMLElement; withinStudio?: boolean; pivotResultCols?: IPivotResultColsService; - autoColSvc?: IColumnCollectionService; + autoColSvc?: IAutoColService; selectionColSvc?: SelectionColService; rowNumbersSvc?: IRowNumbersService; colDefFactory?: ColumnDefFactory; colAutosize?: ColumnAutosizeService; - rowGroupColsSvc?: IColsService; - valueColsSvc?: IColsService; - pivotColsSvc?: IColsService; + rowGroupColsSvc?: IRowGroupColsService; + valueColsSvc?: IValueColsService; + pivotColsSvc?: IPivotColsService; quickFilter?: QuickFilterService; showRowGroupCols?: IShowRowGroupColsService; showRowGroupColValueSvc?: IShowRowGroupColsValueService; @@ -389,7 +389,7 @@ interface CoreBeanCollection extends AgCoreBeanCollection< cellFlashSvc?: CellFlashService; masterDetailSvc?: IMasterDetailService; tooltipSvc?: TooltipService; - colGroupSvc?: ColumnGroupService; + colGroupSvc: ColumnGroupService; rowAutoHeight?: RowAutoHeightService; rowChildrenSvc?: IRowChildrenService; footerSvc?: IFooterService; diff --git a/packages/ag-grid-community/src/dragAndDrop/rowDragService.ts b/packages/ag-grid-community/src/dragAndDrop/rowDragService.ts index 117a024fe4c..2fa0ebea6bd 100644 --- a/packages/ag-grid-community/src/dragAndDrop/rowDragService.ts +++ b/packages/ag-grid-community/src/dragAndDrop/rowDragService.ts @@ -125,7 +125,7 @@ export class RowDragService extends BeanStub implements NamedBean { return 'disabled'; } - if (beans.sortSvc?.isSortActive()) { + if (isSortActive(beans.colModel.getAllCols())) { return 'disabled'; } @@ -141,3 +141,12 @@ export class RowDragService extends BeanStub implements NamedBean { } } } + +const isSortActive = (allCols: AgColumn[]): boolean => { + for (let i = 0, len = allCols.length; i < len; ++i) { + if (allCols[i].getSortDef()) { + return true; + } + } + return false; +}; diff --git a/packages/ag-grid-community/src/edit/editService.ts b/packages/ag-grid-community/src/edit/editService.ts index ada62313614..029f0f5525c 100644 --- a/packages/ag-grid-community/src/edit/editService.ts +++ b/packages/ag-grid-community/src/edit/editService.ts @@ -1508,7 +1508,7 @@ export class EditService extends BeanStub implements NamedBean { const edits: EditMap = new Map(); for (let { colId, column, colKey, rowIndex, rowPinned, newValue: pendingValue, state } of cells) { - const col = colId ? colModel.getCol(colId) : colKey ? colModel.getCol(colKey) : column; + const col = colId ? colModel.colsById[colId] : colKey ? colModel.getCol(colKey) : column; if (!col) { continue; diff --git a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts index 57c3f488a06..7cd15eb1558 100644 --- a/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts +++ b/packages/ag-grid-community/src/edit/strategy/strategyUtils.ts @@ -112,7 +112,7 @@ export function isFullRowCellEditable( // check if other cells in row are editable, so starting edit on uneditable cell will still work const { rowNode, column } = position; - for (const col of beans.colModel.getCols()) { + for (const col of beans.colModel.colsList) { if (col !== column && isCellEditable(beans, { rowNode, column: col })) { return true; } diff --git a/packages/ag-grid-community/src/edit/utils/editors.ts b/packages/ag-grid-community/src/edit/utils/editors.ts index f4348b68eb9..97dbb9a673a 100644 --- a/packages/ag-grid-community/src/edit/utils/editors.ts +++ b/packages/ag-grid-community/src/edit/utils/editors.ts @@ -4,7 +4,6 @@ import { _unwrapUserComp } from '../../components/framework/unwrapUserComp'; import { _getCellEditorDetails } from '../../components/framework/userCompUtils'; import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; -import type { ColDef } from '../../entities/colDef'; import type { CellEditingStoppedEvent } from '../../events'; import { _addGridCommonParams } from '../../gridOptionsUtils'; import type { @@ -265,7 +264,7 @@ function _createEditorParams( const rowIndex = position.rowNode?.rowIndex ?? (undefined as unknown as number); const batchEdit = editSvc?.isBatchEditing(); - const agColumn = beans.colModel.getCol(position.column.getId())!; + const agColumn = beans.colModel.getCol(position.column)!; const { rowNode, column } = position; const editor = cellCtrl.comp?.getCellEditor(); @@ -636,12 +635,9 @@ function dispatchEditingStopped( } } -function _columnDefsRequireValidation(columnDefs?: ColDef[]): boolean { - if (!columnDefs) { - return false; - } - for (let i = 0, len = columnDefs.length; i < len; ++i) { - const colDef = columnDefs[i]; +function _columnDefsRequireValidation(cols: AgColumn[]): boolean { + for (let i = 0, len = cols.length; i < len; ++i) { + const colDef = cols[i].colDef; const params = colDef.cellEditorParams; if (!params || (!colDef.editable && !colDef.groupRowEditable)) { continue; @@ -677,7 +673,7 @@ function _editorsRequireValidation(beans: BeanCollection): boolean { function _hasValidationRules(beans: BeanCollection): boolean { return ( !!beans.gos.get('getFullRowEditValidationErrors') || - _columnDefsRequireValidation(beans.colModel.getColumnDefs()) || + _columnDefsRequireValidation(beans.colModel.colDefList) || _editorsRequireValidation(beans) ); } diff --git a/packages/ag-grid-community/src/entities/agColumn.ts b/packages/ag-grid-community/src/entities/agColumn.ts index a24f33feb9b..fce464148ca 100644 --- a/packages/ag-grid-community/src/entities/agColumn.ts +++ b/packages/ag-grid-community/src/entities/agColumn.ts @@ -1,10 +1,13 @@ import type { AgEvent, IAgEventEmitter } from 'ag-stack'; -import { LocalEventService, _escapeString, _exists, _missing } from 'ag-stack'; +import { LocalEventService, _escapeString } from 'ag-stack'; +import { _addColumnDefaultAndTypes } from '../columns/colDefUtils'; +import { updateSomeColumnState } from '../columns/columnStateUtils'; import type { ColumnState } from '../columns/columnStateUtils'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { ColumnEvent, ColumnEventType } from '../events'; +import type { GridOptionsService } from '../gridOptionsService'; import { _addGridCommonParams } from '../gridOptionsUtils'; import type { Column, @@ -20,33 +23,25 @@ import type { import type { IFrameworkEventListenerService } from '../interfaces/iFrameworkEventListenerService'; import type { IRowNode } from '../interfaces/iRowNode'; import type { SortDef, SortDirection, SortType } from '../interfaces/iSort'; -import { _mergeDeep } from '../utils/mergeDeep'; +import { _mergedEqual } from '../utils/mergeDeep'; import { _warn } from '../validation/logging'; import type { AgColumnGroup } from './agColumnGroup'; import type { AgProvidedColumnGroup } from './agProvidedColumnGroup'; import type { AbstractColDef, - BaseColDefParams, + ColAggFunc, ColDef, ColSpanParams, ColumnFunctionCallbackParams, - IAggFunc, RowSpanParams, } from './colDef'; -const COL_DEF_DEFAULTS: Partial = { - resizable: true, - sortable: true, -}; - let instanceIdSequence = 0; export function getNextColInstanceId(): ColumnInstanceId { return instanceIdSequence++ as ColumnInstanceId; } -export function isColumn(col: Column | ColumnGroup | ProvidedColumnGroup): col is AgColumn { - return col instanceof AgColumn; -} +export const isColumn = (col: Column | ColumnGroup | ProvidedColumnGroup): col is AgColumn => col instanceof AgColumn; const DEFAULT_SORTING_ORDER: SortDirection[] = ['asc', 'desc', null]; const DEFAULT_ABSOLUTE_SORTING_ORDER: (SortDef | SortDirection)[] = [ @@ -55,17 +50,15 @@ const DEFAULT_ABSOLUTE_SORTING_ORDER: (SortDef | SortDirection)[] = [ null, ]; -// Wrapper around a user provide column definition. The grid treats the column definition as ready only. -// This class contains all the runtime information about a column, plus some logic (the definition has no logic). -// This class implements both interfaces ColumnGroupChild and ProvidedColumnGroupChild as the class can -// appear as a child of either the original tree or the displayed tree. However the relevant group classes -// for each type only implements one, as each group can only appear in it's associated tree (eg ProvidedColumnGroup -// can only appear in OriginalColumn tree). +/** Origin of an `AgColumn`. `user` = application-supplied ColDef; others = grid-generated. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export type ColKind = 'user' | 'auto-group' | 'selection' | 'row-number' | 'hierarchy'; + +// Runtime wrapper around a (logic-free) column definition, holding all runtime state plus logic. +// Child of either the original or the displayed tree; each group class implements only its own tree's interface. // -// INTERNAL CALLERS: prefer direct property access (column.colDef, column.primary, column.rowGroupActive, -// etc.) over the equivalent getter methods (getColDef(), isPrimary(), isRowGroupActive(), …) on hot paths. -// The getters are kept for the public Column interface; internally the fields are public and reading them -// directly avoids method-call indirection in tight loops (sort, filter, render). +// INTERNAL CALLERS: on hot paths read public fields directly (column.colDef, …) rather than the +// getters — the getters exist only for the public Column interface, direct reads avoid call indirection. /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class AgColumn extends BeanStub @@ -75,63 +68,99 @@ export class AgColumn private frameworkEventListenerService?: IFrameworkEventListenerService; - // used by React (and possibly other frameworks) as key for rendering. also used to - // identify old vs new columns for destroying cols when no longer used. + // framework (React) render key; also identifies old-vs-new cols when destroying unused ones private readonly instanceId = getNextColInstanceId(); + /** Sanitised version of the column id */ public readonly colIdSanitised: string; - private actualWidth: any; + /** Current rendered width in px. Writes must go through `setActualWidth` for min/max clamping and the `widthChanged` event. */ + public actualWidth: number = 0; + + // measured header height when autoHeaderHeight is enabled + public autoHeaderHeight: number | null = null; + + /** User intent: should this column be shown if display rules allow it. */ + public visible: boolean = false; + + /** Most recent build token that claimed this col — used to detect "already used in this refresh". */ + public buildToken: number = 0; - // The measured height of this column's header when autoHeaderHeight is enabled - private autoHeaderHeight: number | null = null; + /** 0-based index in `VisibleColsService.allCols` (displayed, visual order — RTL reversed), stamped each refresh. `-1` = not displayed. */ + public allColsIndex: number = -1; - private visible: any; - public pinned: ColumnPinnedType; - private left: number | null; - private oldLeft: number | null; - public aggFunc: string | IAggFunc | null | undefined; - private sortDef: SortDef = _getSortDefFromInput(); - public sortIndex: number | null | undefined; + /** Whether this column is in the displayed (rendered) columns — kept in lockstep with `allColsIndex >= 0` */ + public displayed: boolean = false; + + /** `true` while in `ColumnModel.colsList` (live cols, hidden included); `false` when only in + * `colsById` — a pivot **primary** parked while a pivot result shows. Set by `refreshCols`. */ + public inColsList: boolean = false; + + /** 1-based `aria-colindex`: position in `colsList` reordered `[left, center, right]` (hidden included). `0` = not in `colsList`. */ + public ariaColIndex: number = 0; + + public pinned: ColumnPinnedType = null; + public left: number | null = null; + public oldLeft: number | null = null; + public aggFunc: ColAggFunc = undefined; + public sortDef: SortDef = getSortDefFromInput(); + public sortIndex: number | null | undefined = undefined; public moving = false; public resizing = false; public menuVisible = false; - public highlighted: ColumnHighlightPosition | null; + public highlighted: ColumnHighlightPosition | null = null; public formulaRef: string | null = null; public isCalculatedCol = false; + /** colId this column sits immediately after in display order. Order restoration seats new cols after + * this anchor — handles anchors absent from the tree (e.g. auto-group col) and stacks same-anchor adds + * newest-first. `undefined` = not anchored. Column-kind agnostic (currently set by the calc-column contributor). */ + public anchoredToColId: string | undefined = undefined; + + /** 0-based index in `ColumnModel.colsList` (stamped lazily by `ensureColsListIndex` for O(1) ordered reads); + * `-1` until first stamped / when not in colsList. In pivot, parked primaries keep their pre-pivot index. */ + public colsListIndex: number = -1; + private lastLeftPinned: boolean = false; private firstRightPinned: boolean = false; - public minWidth: number; - private maxWidth: number; + public minWidth: number = 0; + private maxWidth: number = 0; public filterActive = false; private readonly colEventSvc: LocalEventService = new LocalEventService(); - public fieldContainsDots: boolean; - private tooltipFieldContainsDots: boolean; + public fieldContainsDots: boolean = false; + private tooltipFieldContainsDots: boolean = false; public tooltipEnabled = false; public rowGroupActive = false; + /** Position in `rowGroupColsSvc.columns` when {@link rowGroupActive}; else stale — always pair the read with a `rowGroupActive` check. */ + public rowGroupActiveIndex = -1; public pivotActive = false; + /** Position in `pivotColsSvc.columns` when {@link pivotActive}; else stale — always pair the read with a `pivotActive` check. */ + public pivotActiveIndex = -1; public aggregationActive = false; + /** The display group col that shows this (source) column; set by `showRowGroupCols` on refresh */ + public showRowGroupCol: AgColumn | null = null; public flex: number | null = null; - public parent: AgColumnGroup | null; - public originalParent: AgProvidedColumnGroup | null; + public parent: AgColumnGroup | null = null; + public originalParent: AgProvidedColumnGroup | null = null; + + /** Public so the free `getAvailableSortTypes` sort helper can cache on the column; nulled in {@link setColDef}. */ + public cachedSortTypes: Set | null = null; constructor( public colDef: ColDef, - // We do NOT use this anywhere, we just keep a reference. this is to check object equivalence - // when the user provides an updated list of columns - so we can check if we have a column already - // existing for a col def. we cannot use the this.colDef as that is the result of a merge. - // This is used in ColumnFactory + // kept only for object-identity checks in ColumnFactory (matching an updated col list to an + // existing column); this.colDef can't serve as it is the merge result public userProvidedColDef: ColDef | null, public readonly colId: string, - public readonly primary: boolean + public readonly primary: boolean, + public readonly colKind: ColKind ) { super(); this.colIdSanitised = _escapeString(colId)!; @@ -139,6 +168,12 @@ export class AgColumn public override destroy() { super.destroy(); + this.allColsIndex = -1; + this.displayed = false; + this.colsListIndex = -1; + this.inColsList = false; + this.lastLeftPinned = false; + this.firstRightPinned = false; this.beans.rowSpanSvc?.deregister(this); } @@ -147,43 +182,63 @@ export class AgColumn } private initState(): void { - const { - colDef, - beans: { sortSvc, pinnedCols, colFlex }, - } = this; + const { beans, colDef } = this; + const { sortSvc, pinnedCols, colFlex } = beans; sortSvc?.initCol(this); const hide = colDef.hide; - if (hide !== undefined) { - this.visible = !hide; - } else { - this.visible = !colDef.initialHide; - } + this.visible = hide !== undefined ? !hide : !colDef.initialHide; pinnedCols?.initCol(this); colFlex?.initCol(this); } - // gets called when user provides an alternative colDef, eg + /** Called when user provides an alternative colDef. Returns whether the merged colDef differed (false = nothing changed). */ public setColDef( colDef: ColDef, userProvidedColDef: ColDef | null, source: ColumnEventType - ): void { - const colSpanChanged = colDef.spanRows !== this.colDef.spanRows; - this.colDef = colDef; + ): boolean { + const oldColDef = this.colDef; this.userProvidedColDef = userProvidedColDef; + this.colDef = colDef; + if (_mergedEqual(colDef, oldColDef)) { + return false; + } + this.cachedSortTypes = null; // sort/initialSort/sortingOrder may have changed this.initCalculatedCol(); this.initMinAndMaxWidths(); this.initDotNotation(); this.initTooltip(); - if (colSpanChanged) { - this.beans.rowSpanSvc?.deregister(this); - this.initRowSpan(); + if (colDef.spanRows !== oldColDef.spanRows) { + this.beans.rowSpanSvc?.columnRowSpanChanged(this); } this.dispatchColEvent('colDefChanged', source); + this.beans.pivotResultCols?.recreateColDefsForSource(this, source); + return true; + } + + /** Re-apply `def` to `column`, keeping its colDef and runtime state in sync. */ + public reapplyColDef(def: ColDef, source: ColumnEventType): void { + const merged = _addColumnDefaultAndTypes(this.beans, def, this.colId); + this.setColDef(merged, def, source); + updateSomeColumnState( + this.beans, + this, + merged.hide, + merged.sort, + merged.sortIndex, + merged.pinned, + merged.flex, + source + ); + // Width is owned by the flex layout while flexing, so only set it when not. + const colFlex = this.flex; + if (colFlex == null || colFlex <= 0) { + this.setActualWidth(merged.width ?? this.actualWidth, source); + } } public getUserProvidedColDef(): ColDef | null { @@ -201,35 +256,27 @@ export class AgColumn // this is done after constructor as it uses gridOptionsService public postConstruct(): void { this.initCalculatedCol(); - this.initState(); - this.initMinAndMaxWidths(); - this.resetActualWidth('gridInitializing'); - this.initDotNotation(); - this.initTooltip(); - - this.initRowSpan(); - - this.addPivotListener(); } private initDotNotation(): void { - const { - gos, - colDef: { field, tooltipField }, - } = this; - const suppressDotNotation = gos.get('suppressFieldDotNotation'); - this.fieldContainsDots = _exists(field) && field.includes('.') && !suppressDotNotation; - this.tooltipFieldContainsDots = _exists(tooltipField) && tooltipField.includes('.') && !suppressDotNotation; + const suppress = this.gos.get('suppressFieldDotNotation'); + if (suppress) { + this.fieldContainsDots = false; + this.tooltipFieldContainsDots = false; + } else { + const { field, tooltipField } = this.colDef; + this.fieldContainsDots = typeof field === 'string' && field.includes('.'); + this.tooltipFieldContainsDots = typeof tooltipField === 'string' && tooltipField.includes('.'); + } } private initMinAndMaxWidths(): void { const colDef = this.colDef; - this.minWidth = colDef.minWidth ?? this.beans.environment.getDefaultColumnMinWidth(); this.maxWidth = colDef.maxWidth ?? Number.MAX_SAFE_INTEGER; } @@ -238,27 +285,6 @@ export class AgColumn this.beans.tooltipSvc?.initCol(this); } - private initRowSpan(): void { - if (this.colDef.spanRows) { - this.beans.rowSpanSvc?.register(this); - } - } - - private addPivotListener(): void { - const pivotColDefSvc = this.beans.pivotColDefSvc; - const pivotValueColumn = this.colDef.pivotValueColumn; - if (!pivotColDefSvc || !pivotValueColumn) { - return; - } - - this.addManagedListeners(pivotValueColumn, { - colDefChanged: (evt) => { - const colDef = pivotColDefSvc.recreateColDef(this.colDef); - this.setColDef(colDef, colDef, evt.source); - }, - }); - } - public resetActualWidth(source: ColumnEventType): void { const initialWidth = this.calculateColInitialWidth(this.colDef); this.setActualWidth(initialWidth, source, true); @@ -269,7 +295,7 @@ export class AgColumn return Math.max(Math.min(width, this.maxWidth), this.minWidth); } - public isEmptyGroup(): boolean { + public isEmptyGroup(): false { return false; } @@ -282,10 +308,8 @@ export class AgColumn } public isFilterAllowed(): boolean { - // filter defined means it's a string, class or true. - // if its false, null or undefined then it's false. - const filterDefined = !!this.colDef.filter; - return filterDefined; + // filter defined (string, class or true) is allowed; false/null/undefined is not. + return !!this.colDef.filter; } public isFieldContainsDots(): boolean { @@ -345,12 +369,7 @@ export class AgColumn } public createColumnFunctionCallbackParams(rowNode: IRowNode): ColumnFunctionCallbackParams { - return _addGridCommonParams(this.gos, { - node: rowNode, - data: rowNode.data, - column: this, - colDef: this.colDef, - }); + return _addGridCommonParams(this.gos, { node: rowNode, data: rowNode.data, column: this, colDef: this.colDef }); } public isSuppressNavigable(rowNode: IRowNode): boolean { @@ -386,10 +405,7 @@ export class AgColumn } public isSuppressPaste(rowNode: IRowNode): boolean { - if (this.isCalculatedCol) { - return true; - } - return this.isColumnFunc(rowNode, this.colDef?.suppressPaste ?? null); + return this.isCalculatedCol || this.isColumnFunc(rowNode, this.colDef.suppressPaste ?? null); } private initCalculatedCol(): void { @@ -397,40 +413,16 @@ export class AgColumn } public isResizable(): boolean { - return !!this.getColDefValue('resizable'); - } - - /** Get value from ColDef or default if it exists. */ - private getColDefValue(key: K): ColDef[K] { - return this.colDef[key] ?? COL_DEF_DEFAULTS[key]; + return this.colDef.resizable ?? true; } public isColumnFunc( rowNode: IRowNode, value?: boolean | ((params: ColumnFunctionCallbackParams) => boolean) | null ): boolean { - // if boolean set, then just use it - if (typeof value === 'boolean') { - return value; - } - - // if function, then call the function to find out - if (typeof value === 'function') { - const params = this.createColumnFunctionCallbackParams(rowNode); - const editableFunc = value; - return editableFunc(params); - } - - return false; - } - - private createColumnEvent(type: T, source: ColumnEventType): ColumnEvent { - return _addGridCommonParams(this.gos, { - type, - column: this, - columns: [this], - source, - }); + return typeof value === 'boolean' + ? value + : typeof value === 'function' && value(this.createColumnFunctionCallbackParams(rowNode)); } public isMoving(): boolean { @@ -438,55 +430,14 @@ export class AgColumn } public getSort(): SortDirection { - // soft deprecation as of v35 - use getSortDef instead + // soft-deprecated v35 - use getSortDef instead return this.sortDef.direction; } - /** - * Returns null if no sort direction applied - */ + /** Returns null if no sort direction applied */ public getSortDef(): SortDef | null { - if (!this.sortDef.direction) { - return null; - } - return this.sortDef; - } - - private getColDefAllowedSortTypes(): SortType[] { - const res: SortType[] = []; - const { sort, initialSort } = this.colDef; - - const colDefSortType = sort === null ? sort : _normalizeSortType((sort as SortDef)?.type); - const colDefInitialSortType = - initialSort === null ? initialSort : _normalizeSortType((initialSort as SortDef)?.type); - - if (colDefSortType) { - res.push(colDefSortType); - } - if (colDefInitialSortType) { - res.push(colDefInitialSortType); - } - return res; - } - - public getSortingOrder() { - const defaultSortingOrder = this.getColDefAllowedSortTypes().includes('absolute') - ? DEFAULT_ABSOLUTE_SORTING_ORDER - : DEFAULT_SORTING_ORDER; - - return (this.colDef.sortingOrder ?? this.gos.get('sortingOrder') ?? defaultSortingOrder).map( - (objOrDirection: unknown) => _getSortDefFromInput(objOrDirection) - ); - } - - public getAvailableSortTypes() { - const explicitSortTypesFromSortingOrder = this.getSortingOrder().reduce((acc, so) => { - if (so.direction) { - acc.push(so.type); - } - return acc; - }, this.getColDefAllowedSortTypes()); - return new Set(explicitSortTypesFromSortingOrder); + const sortDef = this.sortDef; + return sortDef.direction ? sortDef : null; } public setSortDef(sortDef: SortDef): void { @@ -494,7 +445,7 @@ export class AgColumn } public isSortable(): boolean { - return !!this.getColDefValue('sortable'); + return this.colDef.sortable ?? true; } /** @deprecated v32 use col.getSort() === 'asc */ @@ -508,12 +459,12 @@ export class AgColumn } /** @deprecated v32 use col.getSort() === undefined */ public isSortNone(): boolean { - return _missing(this.getSort()); + return !this.getSort(); } /** @deprecated v32 use col.getSort() !== undefined */ public isSorting(): boolean { - return _exists(this.getSort()); + return this.getSort() != null; } public getSortIndex(): number | null | undefined { @@ -524,7 +475,7 @@ export class AgColumn return this.menuVisible; } - public getAggFunc(): string | IAggFunc | null | undefined { + public getAggFunc(): ColAggFunc { return this.aggFunc; } @@ -537,12 +488,14 @@ export class AgColumn } public getRight(): number { - return this.left + this.actualWidth; + // `left` is non-null on any displayed col, the only ones `getRight` makes sense for + return this.left! + this.actualWidth; } public setLeft(left: number | null, source: ColumnEventType) { - this.oldLeft = this.left; - if (this.left !== left) { + const oldLeft = this.left; + this.oldLeft = oldLeft; + if (oldLeft !== left) { this.left = left; this.dispatchColEvent('leftChanged', source); } @@ -600,6 +553,13 @@ export class AgColumn const newValue = visible === true; if (this.visible !== newValue) { this.visible = newValue; + let group = this.originalParent; + while (group) { + if (!group.setExpandable()) { + break; + } + group = group.originalParent; + } this.dispatchColEvent('visibleChanged', source); } this.dispatchStateUpdatedEvent('hide'); @@ -613,13 +573,11 @@ export class AgColumn return !this.colDef.suppressSpanHeaderHeight; } - /** - * Returns the first parent that is not a padding group. - */ + /** Returns the first parent that is not a padding group. */ public getFirstRealParent(): AgProvidedColumnGroup | null { - let parent = this.getOriginalParent(); - while (parent?.isPadding()) { - parent = parent.getOriginalParent(); + let parent = this.originalParent; + while (parent?.padding) { + parent = parent.originalParent; } return parent; } @@ -627,7 +585,7 @@ export class AgColumn public getColumnGroupPaddingInfo(): { numberOfParents: number; isSpanningTotal: boolean } { let parent = this.parent; - if (!parent?.isPadding()) { + if (!parent?.providedColumnGroup.padding) { return { numberOfParents: 0, isSpanningTotal: false }; } @@ -635,7 +593,7 @@ export class AgColumn let isSpanningTotal = true; while (parent) { - if (!parent.isPadding()) { + if (!parent.providedColumnGroup.padding) { isSpanningTotal = false; break; } @@ -678,41 +636,32 @@ export class AgColumn /** Returns true if the header height has changed */ public setAutoHeaderHeight(height: number): boolean { - const changed = height !== this.autoHeaderHeight; - this.autoHeaderHeight = height; - return changed; - } - - private createBaseColDefParams(rowNode: IRowNode): BaseColDefParams { - const params: BaseColDefParams = _addGridCommonParams(this.gos, { - node: rowNode, - data: rowNode.data, - colDef: this.colDef, - column: this, - }); - return params; + if (this.autoHeaderHeight !== height) { + this.autoHeaderHeight = height; + return true; + } + return false; } public getColSpan(rowNode: IRowNode): number { - if (_missing(this.colDef.colSpan)) { + const colDef = this.colDef; + if (colDef.colSpan == null) { return 1; } - const params: ColSpanParams = this.createBaseColDefParams(rowNode); - const colSpan = this.colDef.colSpan(params); - // colSpan must be number equal to or greater than 1 - - return Math.max(colSpan, 1); + const params: ColSpanParams = this.createColumnFunctionCallbackParams(rowNode); + const colSpan = colDef.colSpan(params); + return colSpan < 1 ? 1 : colSpan; // colSpan must be number equal to or greater than 1 } public getRowSpan(rowNode: IRowNode): number { - if (_missing(this.colDef.rowSpan)) { + const colDef = this.colDef; + const rowSpan = colDef.rowSpan; + if (rowSpan == null) { return 1; } - const params: RowSpanParams = this.createBaseColDefParams(rowNode); - const rowSpan = this.colDef.rowSpan(params); - // rowSpan must be number equal to or greater than 1 - - return Math.max(rowSpan, 1); + const params: RowSpanParams = this.createColumnFunctionCallbackParams(rowNode); + const rowSpanValue = rowSpan(params); + return rowSpanValue < 1 ? 1 : rowSpanValue; // rowSpan must be number equal to or greater than 1 } public setActualWidth(actualWidth: number, source: ColumnEventType, silent: boolean = false): void { @@ -761,11 +710,12 @@ export class AgColumn } public isAnyFunctionActive(): boolean { - return this.isPivotActive() || this.isRowGroupActive() || this.isValueActive(); + return this.pivotActive || this.rowGroupActive || this.aggregationActive; } public isAnyFunctionAllowed(): boolean { - return this.isAllowPivot() || this.isAllowRowGroup() || this.isAllowValue(); + const colDef = this.colDef; + return colDef.enablePivot === true || colDef.enableRowGroup === true || colDef.enableValue === true; } public isValueActive(): boolean { @@ -789,100 +739,128 @@ export class AgColumn } public dispatchColEvent(type: ColumnEventName, source: ColumnEventType, additionalEventAttributes?: any): void { - const colEvent = this.createColumnEvent(type, source); - if (additionalEventAttributes) { - _mergeDeep(colEvent, additionalEventAttributes); - } - this.colEventSvc.dispatchEvent(colEvent); + this.colEventSvc.dispatchEvent( + _addGridCommonParams(this.gos, { + type, + column: this, + columns: [this], + source, + ...additionalEventAttributes, + }) + ); } public dispatchStateUpdatedEvent(key: keyof ColumnState): void { - this.colEventSvc.dispatchEvent({ - type: 'columnStateUpdated', - key, - } as AgEvent<'columnStateUpdated'>); + this.colEventSvc.dispatchEvent({ type: 'columnStateUpdated', key } as AgEvent<'columnStateUpdated'>); } } -/** - * Helper to convert input into SortDef, does normalisation of direction and type. - * - * If input is already a valid SortDef, we pluck the direction and type from it. - * Otherwise, we normalise the direction and type from input. - * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. - */ -export function _getSortDefFromInput(input?: unknown): SortDef { +/** Convert input into a SortDef: a valid SortDef passes through, otherwise direction and type are normalised. */ +export const getSortDefFromInput = (input?: unknown): SortDef => { if (_isSortDefValid(input)) { return { direction: input.direction, type: input.type }; } - return { direction: _normalizeSortDirection(input), type: _normalizeSortType(input) }; -} - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _isSortDirectionValid(maybeSortDir: unknown): maybeSortDir is SortDirection { - return maybeSortDir === 'asc' || maybeSortDir === 'desc' || maybeSortDir === null; -} + return { direction: normalizeSortDirection(input), type: _normalizeSortType(input) }; +}; -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _isSortTypeValid(maybeSortType: unknown): maybeSortType is SortType { - return maybeSortType === 'default' || maybeSortType === 'absolute'; -} +// Free functions (not class methods) so they tree-shake out of the core bundle when the sort module is unused. -export function _isSortDefValid(maybeSortDef: unknown): maybeSortDef is SortDef { - if (!maybeSortDef || typeof maybeSortDef !== 'object') { - return false; +/** Sort types from `colDef.sort`/`colDef.initialSort`; `null` contributes nothing, bare directions normalise to 'default'. */ +const getColDefAllowedSortTypes = (column: AgColumn): SortType[] => { + const res: SortType[] = []; + const { sort, initialSort } = column.colDef; + if (sort !== null) { + res.push(_normalizeSortType((sort as SortDef)?.type)); + } + if (initialSort !== null) { + res.push(_normalizeSortType((initialSort as SortDef)?.type)); } + return res; +}; - const maybeSortDefT = maybeSortDef as { type?: unknown; direction?: unknown }; - return _isSortTypeValid(maybeSortDefT.type) && _isSortDirectionValid(maybeSortDefT.direction); -} +const getSortingOrderInputs = ( + gos: GridOptionsService, + column: AgColumn, + colDefAllowedSortTypes: SortType[] +): (SortDirection | SortDef)[] => + column.colDef.sortingOrder ?? + gos.get('sortingOrder') ?? + (colDefAllowedSortTypes.includes('absolute') ? DEFAULT_ABSOLUTE_SORTING_ORDER : DEFAULT_SORTING_ORDER); + +export const getSortingOrder = (gos: GridOptionsService, column: AgColumn): SortDef[] => { + const inputs = getSortingOrderInputs(gos, column, getColDefAllowedSortTypes(column)); + const res = new Array(inputs.length); + for (let i = 0, len = inputs.length; i < len; ++i) { + res[i] = getSortDefFromInput(inputs[i]); + } + return res; +}; -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _areSortDefsEqual(sortDef1: SortDef | null | undefined, sortDef2: SortDef | null | undefined): boolean { - if (!sortDef1) { - return sortDef2 ? sortDef2.direction === null : true; +const getAvailableSortTypes = (gos: GridOptionsService, column: AgColumn): Set => { + const cacheable = gos.get('sortingOrder') == null; // deprecated `sortingOrder` disables the cache + const cached = column.cachedSortTypes; + if (cacheable && cached) { + return cached; + } + const colDefAllowedSortTypes = getColDefAllowedSortTypes(column); + const types = new Set(colDefAllowedSortTypes); + // add each directional order entry's type — mirrors `getSortDefFromInput` without allocating a SortDef per entry + const order = getSortingOrderInputs(gos, column, colDefAllowedSortTypes); + for (let i = 0, len = order.length; i < len; ++i) { + const input = order[i]; + if (!_isSortDefValid(input)) { + if (normalizeSortDirection(input)) { + types.add(_normalizeSortType(input)); + } + continue; + } + if (input.direction) { + types.add(input.type); + } } - if (!sortDef2) { - return sortDef1 ? sortDef1.direction === null : true; + if (cacheable) { + column.cachedSortTypes = types; } + return types; +}; - return sortDef1.type === sortDef2.type && sortDef1.direction === sortDef2.direction; -} +export const isSortDirectionValid = (maybeSortDir: unknown): maybeSortDir is SortDirection => + maybeSortDir === 'asc' || maybeSortDir === 'desc' || maybeSortDir === null; -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _normalizeSortDirection(sortDirectionLike?: unknown): SortDirection { - return _isSortDirectionValid(sortDirectionLike) ? sortDirectionLike : null; -} +export const isSortTypeValid = (maybeSortType: unknown): maybeSortType is SortType => + maybeSortType === 'default' || maybeSortType === 'absolute'; + +export const _isSortDefValid = (maybeSortDef: unknown): maybeSortDef is SortDef => { + if (!maybeSortDef || typeof maybeSortDef !== 'object') { + return false; + } + const maybeSortDefT = maybeSortDef as { type?: unknown; direction?: unknown }; + return isSortTypeValid(maybeSortDefT.type) && isSortDirectionValid(maybeSortDefT.direction); +}; + +export const normalizeSortDirection = (sortDirectionLike?: unknown): SortDirection => + isSortDirectionValid(sortDirectionLike) ? sortDirectionLike : null; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _normalizeSortType(sortTypeLike?: unknown): SortType { - return _isSortTypeValid(sortTypeLike) ? sortTypeLike : 'default'; -} +export const _normalizeSortType = (sortTypeLike?: unknown): SortType => + isSortTypeValid(sortTypeLike) ? sortTypeLike : 'default'; + +type SortDefOverride = () => SortDef | null | undefined; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _getDisplaySortForColumn( - column: AgColumn, - beans: BeanCollection, - getSortDefOverride?: () => SortDef | null | undefined -) { - const overrideSortDef = getSortDefOverride?.(); - const sortDef = overrideSortDef ?? beans.sortSvc!.getDisplaySortForColumn(column); +export const _getDisplaySortForColumn = (column: AgColumn, beans: BeanCollection, override?: SortDefOverride) => { + const overrideSortDef = override?.(); + const sortDef = overrideSortDef ?? beans.sortSvc?.getDisplaySort(column); const type = _normalizeSortType(sortDef?.type); - const direction = _normalizeSortDirection(sortDef?.direction); - const allowedSortTypes = column.getAvailableSortTypes(); - const isDefaultSortAllowed = allowedSortTypes.has('default'); - const isAbsoluteSortAllowed = allowedSortTypes.has('absolute'); - const isAbsoluteSort = type === 'absolute'; - const isDefaultSort = type === 'default'; - const isAscending = direction === 'asc'; - const isDescending = direction === 'desc'; + const direction = normalizeSortDirection(sortDef?.direction); + const allowedSortTypes = getAvailableSortTypes(beans.gos, column); return { - isDefaultSortAllowed, - isAbsoluteSortAllowed, - isAbsoluteSort, - isDefaultSort, - isAscending, - isDescending, + isDefaultSortAllowed: allowedSortTypes.has('default'), + isAbsoluteSortAllowed: allowedSortTypes.has('absolute'), + isAbsoluteSort: type === 'absolute', + isDefaultSort: type === 'default', + isAscending: direction === 'asc', + isDescending: direction === 'desc', direction, }; -} +}; diff --git a/packages/ag-grid-community/src/entities/agColumnGroup.ts b/packages/ag-grid-community/src/entities/agColumnGroup.ts index c7995776c19..c6280f368fd 100644 --- a/packages/ag-grid-community/src/entities/agColumnGroup.ts +++ b/packages/ag-grid-community/src/entities/agColumnGroup.ts @@ -10,55 +10,47 @@ import type { HeaderColumnId, } from '../interfaces/iColumn'; import type { AgColumn } from './agColumn'; -import { isColumn } from './agColumn'; import type { AgProvidedColumnGroup } from './agProvidedColumnGroup'; import type { AbstractColDef, ColGroupDef } from './colDef'; -export function createUniqueColumnGroupId(groupId: string, instanceId: number): HeaderColumnId { - return (groupId + '_' + instanceId) as HeaderColumnId; -} - -export function isColumnGroup(col: Column | ColumnGroup | string): col is AgColumnGroup { - return col instanceof AgColumnGroup; -} +export const isColumnGroup = (col: Column | ColumnGroup | string): col is AgColumnGroup => col instanceof AgColumnGroup; +// INTERNAL CALLERS: on hot paths read public fields directly (group.groupId, group.pinned, …) +// rather than the getters — the getters exist only for the public ColumnGroup interface, and +// direct reads avoid method-call indirection in tight loops. /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class AgColumnGroup extends BeanStub implements ColumnGroup { public readonly isColumn = false as const; + public readonly uniqueId: HeaderColumnId; /** Sanitised version of the column id */ public readonly colIdSanitised: string; - // all the children of this group, regardless of whether they are opened or closed - private children: (AgColumn | AgColumnGroup)[] | null; - // depends on the open/closed state of the group, only displaying columns are stored here - private displayedChildren: (AgColumn | AgColumnGroup)[] | null = []; + // all children, regardless of open/closed state + public children: (AgColumn | AgColumnGroup)[] | null = null; + // only the currently displaying children (depends on open/closed state) + public displayedChildren: (AgColumn | AgColumnGroup)[] | null = null; - // The measured height of this column's header when autoHeaderHeight is enabled - private autoHeaderHeight: number | null = null; + // measured header height when autoHeaderHeight is enabled + public autoHeaderHeight: number | null = null; - // private moving = false - private left: number | null; - private oldLeft: number | null; + public left: number | null = null; + public oldLeft: number | null = null; public parent: AgColumnGroup | null = null; + /** Most recent build token that claimed this instance — sweeps use it to spot orphans. */ + public buildToken: number = 0; + constructor( - private readonly providedColumnGroup: AgProvidedColumnGroup, - private readonly groupId: string, - private readonly partId: number, - private readonly pinned: ColumnPinnedType + public readonly providedColumnGroup: AgProvidedColumnGroup, + public readonly groupId: string, + public readonly partId: number, + public pinned: ColumnPinnedType ) { super(); - this.colIdSanitised = _escapeString(this.getUniqueId())!; - } - - // as the user is adding and removing columns, the groups are recalculated. - // this reset clears out all children, ready for children to be added again - public reset(): void { - this.parent = null; - this.children = null; - this.displayedChildren = null; + this.uniqueId = `${groupId}_${partId}` as HeaderColumnId; + this.colIdSanitised = _escapeString(this.uniqueId)!; } public getParent(): AgColumnGroup | null { @@ -66,49 +58,32 @@ export class AgColumnGroup extends BeanStub im } public getUniqueId(): HeaderColumnId { - return createUniqueColumnGroupId(this.groupId, this.partId); + return this.uniqueId; } public isEmptyGroup(): boolean { - return this.displayedChildren!.length === 0; + return !this.displayedChildren?.length; } + /** Returns true only when every leaf column in this group is currently moving. */ public isMoving(): boolean { - const allLeafColumns = this.getProvidedColumnGroup().getLeafColumns(); - if (!allLeafColumns || allLeafColumns.length === 0) { - return false; - } - - return allLeafColumns.every((col) => col.isMoving()); + return getLeafMoving(this.providedColumnGroup) === true; } public checkLeft(): void { - // first get all children to setLeft, as it impacts our decision below - for (const child of this.displayedChildren!) { + const displayedChildren = this.displayedChildren!; + let minLeft: number | null = null; + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + const child = displayedChildren[i]; if (isColumnGroup(child)) { child.checkLeft(); } - } - - // set our left to the minimum child left so ordering changes in displayedChildren - // do not affect group positioning after column moves. - if (this.displayedChildren!.length > 0) { - let minLeft: number | null = null; - for (const child of this.displayedChildren!) { - const childLeft = child.getLeft(); - if (childLeft == null) { - continue; - } - if (minLeft == null || childLeft < minLeft) { - minLeft = childLeft; - } + const childLeft = child.left; + if (childLeft != null && (minLeft == null || childLeft < minLeft)) { + minLeft = childLeft; } - this.setLeft(minLeft); - } else { - // this should never happen, as if we have no displayed columns, then - // this groups should not even exist. - this.setLeft(null); } + this.setLeft(minLeft); } public getLeft(): number | null { @@ -141,43 +116,42 @@ export class AgColumnGroup extends BeanStub im public getActualWidth(): number { let groupActualWidth = 0; - for (const child of this.displayedChildren ?? []) { - groupActualWidth += child.getActualWidth(); + const displayedChildren = this.displayedChildren; + if (displayedChildren) { + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + groupActualWidth += displayedChildren[i].getActualWidth(); + } } return groupActualWidth; } public isResizable(): boolean { - if (!this.displayedChildren) { - return false; - } - - // if at least one child is resizable, then the group is resizable - let result = false; - for (const child of this.displayedChildren) { - if (child.isResizable()) { - result = true; + const displayedChildren = this.displayedChildren; + if (displayedChildren) { + // if at least one child is resizable, then the group is resizable + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + const child = displayedChildren[i]; + if (child.isResizable()) { + return true; + } } } - - return result; + return false; } public getMinWidth(): number { + const displayedChildren = this.displayedChildren; + if (!displayedChildren) { + return 0; + } let result = 0; - for (const groupChild of this.displayedChildren!) { - result += groupChild.getMinWidth(); + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + const child = displayedChildren[i]; + result += child.getMinWidth(); } return result; } - public addChild(child: AgColumn | AgColumnGroup): void { - if (!this.children) { - this.children = []; - } - this.children.push(child); - } - public getDisplayedChildren(): (AgColumn | AgColumnGroup)[] | null { return this.displayedChildren; } @@ -194,32 +168,33 @@ export class AgColumnGroup extends BeanStub im return result; } + /** 1-based aria column index for this group's header cell: first leaf column's index */ + public get ariaColIndex(): number { + return edgeLeafColumn(this, false, false)?.ariaColIndex ?? 0; + } + public getDefinition(): AbstractColDef | null { - return this.providedColumnGroup.getColGroupDef(); + return this.providedColumnGroup.colGroupDef; } public getColGroupDef(): ColGroupDef | null { - return this.providedColumnGroup.getColGroupDef(); + return this.providedColumnGroup.colGroupDef; } public isPadding(): boolean { - return this.providedColumnGroup.isPadding(); + return this.providedColumnGroup.padding; } public isExpandable(): boolean { - return this.providedColumnGroup.isExpandable(); + return this.providedColumnGroup.expandable; } public isExpanded(): boolean { - return this.providedColumnGroup.isExpanded(); - } - - public setExpanded(expanded: boolean): void { - this.providedColumnGroup.setExpanded(expanded); + return !!this.providedColumnGroup.expanded; } public isAutoHeaderHeight(): boolean { - return !!this.getColGroupDef()?.autoHeaderHeight; + return !!this.providedColumnGroup.colGroupDef?.autoHeaderHeight; } public getAutoHeaderHeight(): number | null { @@ -228,27 +203,37 @@ export class AgColumnGroup extends BeanStub im /** Returns true if the header height has changed */ public setAutoHeaderHeight(height: number): boolean { - const changed = height !== this.autoHeaderHeight; + if (height === this.autoHeaderHeight) { + return false; + } this.autoHeaderHeight = height; - return changed; + return true; } private addDisplayedLeafColumns(leafColumns: AgColumn[]): void { - for (const child of this.displayedChildren ?? []) { - if (isColumn(child)) { - leafColumns.push(child); - } else if (isColumnGroup(child)) { - child.addDisplayedLeafColumns(leafColumns); + const displayedChildren = this.displayedChildren; + if (displayedChildren) { + for (let i = 0, len = displayedChildren.length; i < len; ++i) { + const child = displayedChildren[i]; + if (child.isColumn) { + leafColumns.push(child); + } else if (isColumnGroup(child)) { + child.addDisplayedLeafColumns(leafColumns); + } } } } private addLeafColumns(leafColumns: AgColumn[]): void { - for (const child of this.children ?? []) { - if (isColumn(child)) { - leafColumns.push(child); - } else if (isColumnGroup(child)) { - child.addLeafColumns(leafColumns); + const children = this.children; + if (children) { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + if (child.isColumn) { + leafColumns.push(child); + } else if (isColumnGroup(child)) { + child.addLeafColumns(leafColumns); + } } } } @@ -266,64 +251,71 @@ export class AgColumnGroup extends BeanStub im } public getPaddingLevel(): number { - const parent = this.parent; + let level = 0; + let current: AgColumnGroup | null = this; - if (!this.isPadding() || !parent?.isPadding()) { - return 0; + while (current?.providedColumnGroup.padding && current.parent?.providedColumnGroup.padding) { + ++level; + current = current.parent; } - return 1 + parent.getPaddingLevel(); + return level; } +} - public calculateDisplayedColumns() { - // clear out last time we calculated - this.displayedChildren = []; - - // find the column group that is controlling expandable. this is relevant when we have padding (empty) - // groups, where the expandable is actually the first parent that is not a padding group. - let parentWithExpansion: AgColumnGroup | null = this; - while (parentWithExpansion?.isPadding()) { - parentWithExpansion = parentWithExpansion.parent; - } - - const isExpandable = parentWithExpansion ? parentWithExpansion.getProvidedColumnGroup().isExpandable() : false; - // it not expandable, everything is visible - if (!isExpandable) { - this.displayedChildren = this.children; - this.dispatchLocalEvent({ type: 'displayedChildrenChanged' }); - return; - } - - // Add cols based on columnGroupShow - // Note - the below also adds padding groups, these are always added because they never have - // colDef.columnGroupShow set. - for (const child of this.children ?? []) { - // never add empty groups - const emptyGroup = isColumnGroup(child) && !child.displayedChildren?.length; - if (emptyGroup) { - continue; +/** First/last (`last`) leaf under `group`, walking `children` or `displayedChildren` (`displayed`) — the + * `get(Displayed)LeafColumns()` edge without allocating the array; `null` if empty. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const edgeLeafColumn = (group: AgColumnGroup, displayed: boolean, last: boolean): AgColumn | null => { + const children = displayed ? group.displayedChildren : group.children; + if (children) { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[last ? len - 1 - i : i]; + if (child.isColumn) { + return child; } - - const headerGroupShow = child.getColumnGroupShow(); - switch (headerGroupShow) { - case 'open': - // when set to open, only show col if group is open - if (parentWithExpansion!.getProvidedColumnGroup().isExpanded()) { - this.displayedChildren.push(child); - } - break; - case 'closed': - // when set to open, only show col if group is open - if (!parentWithExpansion!.getProvidedColumnGroup().isExpanded()) { - this.displayedChildren.push(child); - } - break; - default: - this.displayedChildren.push(child); - break; + const leaf = edgeLeafColumn(child, displayed, last); + if (leaf) { + return leaf; } } - - this.dispatchLocalEvent({ type: 'displayedChildrenChanged' }); } -} + return null; +}; + +const getLeafMoving = (group: AgProvidedColumnGroup): boolean | null => { + let hasLeafColumn = false; + const children = group.children; + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + if (child.isColumn) { + hasLeafColumn = true; + if (!child.moving) { + return false; + } + continue; + } + const childState = getLeafMoving(child); + if (childState === false) { + return false; + } + if (childState === true) { + hasLeafColumn = true; + } + } + return hasLeafColumn || null; +}; + +/** Walk up `column`'s parent chain to the group sitting at header-row `level`. */ +export const getColGroupAtLevel = (column: AgColumn, level: number): AgColumnGroup | null => { + // Decrement `paddingLevel` inline on each parent step. + let groupPointer = column.parent; + if (groupPointer) { + let paddingLevel = groupPointer.getPaddingLevel(); + while (groupPointer && groupPointer.providedColumnGroup.level + paddingLevel > level) { + groupPointer = groupPointer.parent; + paddingLevel = paddingLevel > 0 ? paddingLevel - 1 : 0; + } + } + return groupPointer; +}; diff --git a/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts b/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts index 234e1c7cfd4..88d399c1ad2 100644 --- a/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts +++ b/packages/ag-grid-community/src/entities/agProvidedColumnGroup.ts @@ -1,10 +1,13 @@ import { BeanStub } from '../context/beanStub'; import type { Column, ColumnGroupShowType, ColumnInstanceId, ProvidedColumnGroup } from '../interfaces/iColumn'; import type { AgColumn } from './agColumn'; -import { getNextColInstanceId, isColumn } from './agColumn'; +import { getNextColInstanceId } from './agColumn'; +import type { AgColumnGroup } from './agColumnGroup'; import type { ColGroupDef } from './colDef'; -export function isProvidedColumnGroup(col: Column | ProvidedColumnGroup | string | null): col is AgProvidedColumnGroup { +export function isProvidedColumnGroup( + col: Column | ProvidedColumnGroup | string | null | undefined +): col is AgProvidedColumnGroup { return col instanceof AgProvidedColumnGroup; } @@ -15,50 +18,33 @@ export class AgProvidedColumnGroup extends BeanStub public originalParent: AgProvidedColumnGroup | null; - private children: (AgColumn | AgProvidedColumnGroup)[]; - private expandable = false; + public children: (AgColumn | AgProvidedColumnGroup)[]; + public expandable = false; - private expanded: boolean; + public expanded: boolean = false; - // used by React (and possibly other frameworks) as key for rendering. also used to - // identify old vs new columns for destroying cols when no longer used. - private readonly instanceId = getNextColInstanceId(); + /** Most recent build token that claimed this group — detects "already used in this refresh". */ + public buildToken: number = 0; + + /** Packed `AgColumnGroup` display instances by dense per-refresh `partId` (`displayInstances[0]` is primary), lazily allocated and pruned by `columnGroupService`. */ + public displayInstances: AgColumnGroup[] | null = null; - private expandableListenerRemoveCallback: (() => void) | null = null; + /** Cache previous `setExpandable` visibility so `AgColumn.setVisible` ancestor walk can stop when unchanged. */ + private lastVisible = false; + + // stable key for framework (React) rendering and old-vs-new destroy diffing + private readonly instanceId = getNextColInstanceId(); constructor( - private colGroupDef: ColGroupDef | null, - private readonly groupId: string, - private readonly padding: boolean, - private level: number + public colGroupDef: ColGroupDef | null, + public readonly groupId: string, + public readonly padding: boolean, + public level: number ) { super(); this.expanded = !!colGroupDef?.openByDefault; } - public override destroy() { - if (this.expandableListenerRemoveCallback) { - this.reset(null, undefined); - } - super.destroy(); - } - - private reset(colGroupDef: ColGroupDef | null, level: number | undefined): void { - this.colGroupDef = colGroupDef; - this.level = level!; - - this.originalParent = null; - - if (this.expandableListenerRemoveCallback) { - this.expandableListenerRemoveCallback(); - } - - // we use ! below, as we want to set the object back to the - // way it was when it was first created - this.children = undefined!; - this.expandable = undefined!; - } - public getInstanceId(): ColumnInstanceId { return this.instanceId; } @@ -71,12 +57,14 @@ export class AgProvidedColumnGroup extends BeanStub return this.level; } + /** Visible iff at least one child is visible. */ public isVisible(): boolean { - // return true if at least one child is visible - if (this.children) { - return this.children.some((child) => child.isVisible()); + const children = this.children; + for (let i = 0, len = children.length; i < len; ++i) { + if (children[i].isVisible()) { + return true; + } } - return false; } @@ -84,9 +72,14 @@ export class AgProvidedColumnGroup extends BeanStub return this.padding; } - public setExpanded(expanded: boolean | undefined): void { - this.expanded = expanded === undefined ? false : expanded; + public setExpanded(expanded: boolean | undefined): boolean { + expanded = !!expanded; + if (this.expanded === expanded) { + return false; + } + this.expanded = expanded; this.dispatchLocalEvent({ type: 'expandedChanged' }); + return true; } public isExpandable(): boolean { @@ -102,11 +95,7 @@ export class AgProvidedColumnGroup extends BeanStub } public getId(): string { - return this.getGroupId(); - } - - public setChildren(children: (AgColumn | AgProvidedColumnGroup)[]): void { - this.children = children; + return this.groupId; } public getChildren(): (AgColumn | AgProvidedColumnGroup)[] { @@ -123,129 +112,68 @@ export class AgProvidedColumnGroup extends BeanStub return result; } - public forEachLeafColumn(callback: (column: AgColumn) => void): void { - if (!this.children) { - return; - } - - for (const child of this.children) { - if (isColumn(child)) { - callback(child); - } else if (isProvidedColumnGroup(child)) { - child.forEachLeafColumn(callback); - } - } - } - - private addLeafColumns(leafColumns: Column[]): void { - if (!this.children) { - return; - } - - for (const child of this.children) { - if (isColumn(child)) { + private addLeafColumns(leafColumns: AgColumn[]): void { + const children = this.children; + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + if (child.isColumn) { leafColumns.push(child); - } else if (isProvidedColumnGroup(child)) { + } else { child.addLeafColumns(leafColumns); } } } public getColumnGroupShow(): ColumnGroupShowType | undefined { - const colGroupDef = this.colGroupDef; - - if (!colGroupDef) { - return; - } - - return colGroupDef.columnGroupShow; - } - - // need to check that this group has at least one col showing when both expanded and contracted. - // if not, then we don't allow expanding and contracting on this group - - public setupExpandable() { - this.setExpandable(); - - if (this.expandableListenerRemoveCallback) { - this.expandableListenerRemoveCallback(); - } - - const listener = this.onColumnVisibilityChanged.bind(this); - for (const col of this.getLeafColumns()) { - col.__addEventListener('visibleChanged', listener); - } - - this.expandableListenerRemoveCallback = () => { - for (const col of this.getLeafColumns()) { - col.__removeEventListener('visibleChanged', listener); - } - this.expandableListenerRemoveCallback = null; - }; + return this.colGroupDef?.columnGroupShow; } - public setExpandable() { - if (this.isPadding()) { - return; - } - // want to make sure the group doesn't disappear when it's open - let atLeastOneShowingWhenOpen = false; - // want to make sure the group doesn't disappear when it's closed - let atLeastOneShowingWhenClosed = false; - // want to make sure the group has something to show / hide - let atLeastOneChangeable = false; - - const children = this.findChildrenRemovingPadding(); - - for (let i = 0, j = children.length; i < j; i++) { - const abstractColumn = children[i]; - if (!abstractColumn.isVisible()) { - continue; - } - // if the abstractColumn is a grid generated group, there will be no colDef - const headerGroupShow = abstractColumn.getColumnGroupShow(); - - if (headerGroupShow === 'open') { - atLeastOneShowingWhenOpen = true; - atLeastOneChangeable = true; - } else if (headerGroupShow === 'closed') { - atLeastOneShowingWhenClosed = true; - atLeastOneChangeable = true; - } else { - atLeastOneShowingWhenOpen = true; - atLeastOneShowingWhenClosed = true; - } + /** Recompute child-driven expandability and return whether `AgColumn.setVisible` should continue ancestor walking. */ + public setExpandable(): boolean { + if (this.padding) { + return true; } - - const expandable = atLeastOneShowingWhenOpen && atLeastOneShowingWhenClosed && atLeastOneChangeable; - + const flags = walkForExpandFlags(this.children, 0); + const expandable = flags === EXPANDABLE_ALL; if (this.expandable !== expandable) { this.expandable = expandable; this.dispatchLocalEvent({ type: 'expandableChanged' }); } + const visible = flags !== 0; + if (this.lastVisible === visible) { + return false; + } + this.lastVisible = visible; + return true; } +} - private findChildrenRemovingPadding(): (AgColumn | AgProvidedColumnGroup)[] { - const res: (AgColumn | AgProvidedColumnGroup)[] = []; - - const process = (items: (AgColumn | AgProvidedColumnGroup)[]) => { - for (const item of items) { - // if padding, we add this children instead of the padding - const skipBecausePadding = isProvidedColumnGroup(item) && item.isPadding(); - if (skipBecausePadding) { - process(item.children); - } else { - res.push(item); - } +// Bit flags accumulated by `walkForExpandFlags`. A group is `expandable` iff all three are set. +const FLAG_SHOWING_WHEN_OPEN = 0b001; +const FLAG_SHOWING_WHEN_CLOSED = 0b010; +const FLAG_CHANGEABLE = 0b100; +const EXPANDABLE_ALL = 0b111; + +/** Bit-flag walk: short-circuits once all three flags are set. Padding groups transparent. */ +const walkForExpandFlags = (items: (AgColumn | AgProvidedColumnGroup)[], flags: number): number => { + for (let i = 0, n = items.length; i < n; ++i) { + const item = items[i]; + if (isProvidedColumnGroup(item) && item.padding) { + flags = walkForExpandFlags(item.children, flags); + } else if (item.isVisible()) { + // `getColumnGroupShow()` returns undefined for grid-generated groups (no colDef). + const show = item.getColumnGroupShow(); + if (show === 'open') { + flags |= FLAG_SHOWING_WHEN_OPEN | FLAG_CHANGEABLE; + } else if (show === 'closed') { + flags |= FLAG_SHOWING_WHEN_CLOSED | FLAG_CHANGEABLE; + } else { + flags |= FLAG_SHOWING_WHEN_OPEN | FLAG_SHOWING_WHEN_CLOSED; } - }; - - process(this.children); - - return res; - } - - private onColumnVisibilityChanged(): void { - this.setExpandable(); + } + if (flags === EXPANDABLE_ALL) { + return flags; + } } -} + return flags; +}; diff --git a/packages/ag-grid-community/src/entities/colDef.ts b/packages/ag-grid-community/src/entities/colDef.ts index e275af722ac..2769ec05ec6 100644 --- a/packages/ag-grid-community/src/entities/colDef.ts +++ b/packages/ag-grid-community/src/entities/colDef.ts @@ -165,6 +165,8 @@ export type IAggFunc = ( export type IAggFuncs = { [key: string]: IAggFunc }; +export type ColAggFunc = string | IAggFunc | null | undefined; + /** * Wrapper returned by the built-in `avg` and `count` aggregation functions, and the recommended * shape for custom agg functions that expose a scalar value alongside metadata (e.g. a count, used diff --git a/packages/ag-grid-community/src/entities/rowNode.ts b/packages/ag-grid-community/src/entities/rowNode.ts index a38379c0c6f..88fa6a4e612 100644 --- a/packages/ag-grid-community/src/entities/rowNode.ts +++ b/packages/ag-grid-community/src/entities/rowNode.ts @@ -579,7 +579,7 @@ export class RowNode return false; // no column } - let column = colModel.getColOrColDefCol(colKey); + let column = colModel.getCol(colKey); if (!column) { return false; // column not found } @@ -653,7 +653,7 @@ export class RowNode const beans = this.beans; - const column = beans.colModel.getColOrColDefCol(colKey); + const column = beans.colModel.getCol(colKey); if (!column) { return undefined; } diff --git a/packages/ag-grid-community/src/export/baseGridSerializingSession.ts b/packages/ag-grid-community/src/export/baseGridSerializingSession.ts index 0f525b524f2..c3a49aa4d76 100644 --- a/packages/ag-grid-community/src/export/baseGridSerializingSession.ts +++ b/packages/ag-grid-community/src/export/baseGridSerializingSession.ts @@ -10,7 +10,7 @@ import type { ProcessHeaderForExportParams, ProcessRowGroupForExportParams, } from '../interfaces/exportParams'; -import type { IColsService } from '../interfaces/iColsService'; +import type { IRowGroupColsService } from '../interfaces/iColsService'; import type { CellValueResolveFrom } from '../interfaces/iEditService'; import type { ValueService } from '../valueService/valueService'; import type { @@ -24,7 +24,7 @@ import type { export abstract class BaseGridSerializingSession implements GridSerializingSession { public colModel: ColumnModel; private readonly colNames: ColumnNameService; - public rowGroupColsSvc?: IColsService; + public rowGroupColsSvc?: IRowGroupColsService; public valueSvc: ValueService; public gos: GridOptionsService; public processCellCallback?: (params: ProcessCellForExportParams) => string; diff --git a/packages/ag-grid-community/src/export/gridSerializer.ts b/packages/ag-grid-community/src/export/gridSerializer.ts index 5e100ccb345..f40462fcdd1 100644 --- a/packages/ag-grid-community/src/export/gridSerializer.ts +++ b/packages/ag-grid-community/src/export/gridSerializer.ts @@ -163,15 +163,13 @@ export class GridSerializer extends BeanStub implements NamedBean { return (gridSerializingSession) => { if (!params.skipColumnGroupHeaders) { const idCreator: GroupInstanceIdCreator = new GroupInstanceIdCreator(); - const { colGroupSvc } = this.beans; - const displayedGroups: (AgColumn | AgColumnGroup)[] = colGroupSvc - ? colGroupSvc.createColumnGroups({ - columns: columnsToExport, - idCreator, - pinned: null, - isStandaloneStructure: true, - }) - : columnsToExport; + const displayedGroups: (AgColumn | AgColumnGroup)[] = this.beans.colGroupSvc.createGroups( + columnsToExport, + idCreator, + null, + /* buildToken */ undefined, + /* isStandaloneStructure */ true + ); this.recursivelyAddHeaderGroups( displayedGroups, @@ -353,7 +351,14 @@ export class GridSerializer extends BeanStub implements NamedBean { }; if (columnKeys?.length) { - return colModel.getColsForKeys(columnKeys).filter(filterSpecialColumns); + const result: AgColumn[] = []; + for (let i = 0, len = columnKeys.length; i < len; ++i) { + const col = colModel.getCol(columnKeys[i]); + if (col && filterSpecialColumns(col)) { + result.push(col); + } + } + return result; } const isTreeData = gos.get('treeData'); @@ -361,7 +366,7 @@ export class GridSerializer extends BeanStub implements NamedBean { let columnsToExport: AgColumn[]; if (allColumns && !isPivotMode) { - columnsToExport = colModel.getCols(); + columnsToExport = colModel.colsList; } else { columnsToExport = visibleCols.allCols; } @@ -381,11 +386,10 @@ export class GridSerializer extends BeanStub implements NamedBean { ): void { const directChildrenHeaderGroups: (AgColumn | AgColumnGroup)[] = []; for (const columnGroupChild of displayedGroups) { - const columnGroup = columnGroupChild as AgColumnGroup; - if (!columnGroup.getChildren) { + if (!isColumnGroup(columnGroupChild)) { continue; } - for (const it of columnGroup.getChildren() ?? []) { + for (const it of columnGroupChild.children ?? []) { directChildrenHeaderGroups.push(it); } } diff --git a/packages/ag-grid-community/src/export/iGridSerializer.ts b/packages/ag-grid-community/src/export/iGridSerializer.ts index 9f62158ec31..5506147152f 100644 --- a/packages/ag-grid-community/src/export/iGridSerializer.ts +++ b/packages/ag-grid-community/src/export/iGridSerializer.ts @@ -9,7 +9,7 @@ import type { ProcessHeaderForExportParams, ProcessRowGroupForExportParams, } from '../interfaces/exportParams'; -import type { IColsService } from '../interfaces/iColsService'; +import type { IRowGroupColsService } from '../interfaces/iColsService'; import type { ColumnGroup } from '../interfaces/iColumn'; import type { CellValueResolveFrom } from '../interfaces/iEditService'; import type { ValueService } from '../valueService/valueService'; @@ -33,7 +33,7 @@ export interface RowSpanningAccumulator { /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface GridSerializingParams { colModel: ColumnModel; - rowGroupColsSvc?: IColsService; + rowGroupColsSvc?: IRowGroupColsService; colNames: ColumnNameService; valueSvc: ValueService; gos: GridOptionsService; diff --git a/packages/ag-grid-community/src/filter/columnFilterApi.ts b/packages/ag-grid-community/src/filter/columnFilterApi.ts index dc209c8e32a..03edc5fd9ed 100644 --- a/packages/ag-grid-community/src/filter/columnFilterApi.ts +++ b/packages/ag-grid-community/src/filter/columnFilterApi.ts @@ -19,7 +19,7 @@ export function getColumnFilterInstance( } export function destroyFilter(beans: BeanCollection, key: string | Column) { - const column = beans.colModel.getColDefColOrCol(key); + const column = beans.colModel.getCol(key); if (column) { return beans.colFilter?.destroyFilter(column, 'api'); } @@ -43,7 +43,7 @@ export function getColumnFilterModel( _warn(288); useUnapplied = false; } - const column = colModel.getColDefColOrCol(key); + const column = colModel.getCol(key); return column ? (colFilter?.getModelForColumn(column, useUnapplied) ?? null) : null; } @@ -56,7 +56,7 @@ export function setColumnFilterModel( } export function showColumnFilter(beans: BeanCollection, colKey: string | Column): void { - const column = beans.colModel.getColDefColOrCol(colKey); + const column = beans.colModel.getCol(colKey); if (!column) { // Column not found, can't show filter _error(12, { colKey }); @@ -74,7 +74,7 @@ export function hideColumnFilter(beans: BeanCollection): void { } export function getColumnFilterHandler(beans: BeanCollection, colKey: string | Column): FilterHandler | undefined { - const column = beans.colModel.getColDefColOrCol(colKey); + const column = beans.colModel.getCol(colKey); if (!column) { // Column not found, can't show filter _error(12, { colKey }); @@ -91,7 +91,7 @@ export function doFilterAction(beans: BeanCollection, params: FilterActionParams } const { colId, action } = params; if (colId) { - const column = colModel.getColById(colId); + const column = colModel.colsById[colId]; if (column) { colFilter?.updateModel(column, action); } diff --git a/packages/ag-grid-community/src/filter/columnFilterService.ts b/packages/ag-grid-community/src/filter/columnFilterService.ts index 7d68505404b..66ab5361350 100644 --- a/packages/ag-grid-community/src/filter/columnFilterService.ts +++ b/packages/ag-grid-community/src/filter/columnFilterService.ts @@ -241,7 +241,7 @@ export class ColumnFilterService // at this point, processedFields contains data for which we don't have a filter working yet modelKeys.forEach((colId) => { - const column = colModel.getColDefColOrCol(colId); + const column = colModel.colsById[colId]; if (!column) { _warn(62, { colId }); @@ -308,7 +308,7 @@ export class ColumnFilterService if (!excludeInitialState) { for (const colId of Object.keys(initialModel)) { const model = initialModel[colId]; - if (_exists(model) && !allColumnFilters.has(colId) && colModel.getCol(colId)?.isFilterAllowed()) { + if (_exists(model) && !allColumnFilters.has(colId) && colModel.colsById[colId]?.colDef.filter) { result[colId] = model; } } @@ -411,7 +411,7 @@ export class ColumnFilterService const addFilter = (column: AgColumn, filterActive: boolean, doesFilterPassWrapper: DoesFilterPassWrapper) => { if (filterActive) { - if (isAggFilter(column, colModel.isPivotMode(), colModel.isPivotActive(), groupFilterEnabled)) { + if (isAggFilter(column, colModel.pivotMode, colModel.isPivotActive(), groupFilterEnabled)) { activeAggregateFilters.push(doesFilterPassWrapper); } else { activeColumnFilters.push(doesFilterPassWrapper); @@ -639,7 +639,7 @@ export class ColumnFilterService ): IFilterParams['getValue'] { const { filterValueSvc, colModel } = this.beans; return (rowNode, column) => { - const columnToUse = column ? colModel.getColDefColOrCol(column) : filterColumn; + const columnToUse = column ? colModel.getCol(column) : filterColumn; return columnToUse ? filterValueSvc!.getValue(columnToUse, rowNode, filterValueGetterOverride) : undefined; }; } @@ -1116,11 +1116,11 @@ export class ColumnFilterService const { colModel, filterManager, groupFilter } = this.beans; this.allColumnFilters.forEach((wrapper, colId) => { - let currentColumn: AgColumn | null; - if (wrapper.column.isPrimary()) { - currentColumn = colModel.getColDefCol(colId); + let currentColumn: AgColumn | undefined; + if (wrapper.column.primary) { + currentColumn = colModel.getNonPivotColById(colId); } else { - currentColumn = colModel.getCol(colId); + currentColumn = colModel.colsById[colId]; } // group columns can be recreated with the same colId if (currentColumn && currentColumn === wrapper.column) { @@ -1516,12 +1516,12 @@ export class ColumnFilterService } public hasFloatingFilters(): boolean { - const gridColumns = this.beans.colModel.getCols(); + const gridColumns = this.beans.colModel.colsList; return gridColumns.some((col) => col.getColDef().floatingFilter); } public getFilterInstance(key: string | AgColumn): Promise { - const column = this.beans.colModel.getColDefColOrCol(key); + const column = this.beans.colModel.getCol(key); if (!column) { return Promise.resolve(undefined); @@ -1586,7 +1586,7 @@ export class ColumnFilterService } public setModelForColumnLegacy(key: string | AgColumn, model: any): AgPromise { - const column = this.beans.colModel.getColDefCol(key); + const column = this.beans.colModel.getNonPivotCol(key); const filterWrapper = column ? this.getOrCreateFilterWrapper(column, true) : null; return filterWrapper ? this.setModelOnFilterWrapper(filterWrapper, model) : AgPromise.resolve(); } @@ -1755,7 +1755,7 @@ export class ColumnFilterService public updateAllModels(action: FilterAction, additionalEventAttributes?: any): void { const promises: AgPromise[] = []; this.allColumnFilters.forEach((filter, colId) => { - const column = this.beans.colModel.getColDefCol(colId); + const column = this.beans.colModel.getNonPivotColById(colId); if (column) { _updateFilterModel({ action, @@ -1882,7 +1882,7 @@ export class ColumnFilterService const moved: DoesFilterPassWrapper[] = []; for (const filter of from) { - const column = colModel.getColById(filter.colId); + const column = colModel.colsById[filter.colId]; // Can't rely on `colModel.isPivotActive()` because this event hasn't reached to colModel yet const isPivotActive = isPivotMode && !!pivotColsSvc?.columns.length; // Our condition is isPivotMode === isAggFilter because: diff --git a/packages/ag-grid-community/src/filter/filterComp.ts b/packages/ag-grid-community/src/filter/filterComp.ts index 899a9b843e2..d226de2e65f 100644 --- a/packages/ag-grid-community/src/filter/filterComp.ts +++ b/packages/ag-grid-community/src/filter/filterComp.ts @@ -121,7 +121,7 @@ export class FilterComp extends Component { if ( (source === 'api' || source === 'paramsUpdated') && column.getId() === this.column.getId() && - this.beans.colModel.getColDefCol(this.column) + this.beans.colModel.getNonPivotCol(this.column) ) { // filter has been destroyed by the API or params changing. If the column still exists, need to recreate UI component _clearElement(this.getGui()); diff --git a/packages/ag-grid-community/src/filter/filterValueService.ts b/packages/ag-grid-community/src/filter/filterValueService.ts index 1ec56540e7a..df9d0c97257 100644 --- a/packages/ag-grid-community/src/filter/filterValueService.ts +++ b/packages/ag-grid-community/src/filter/filterValueService.ts @@ -35,7 +35,7 @@ export class FilterValueService extends BeanStub implements NamedBean { column, colDef, getValue: (field) => { - const col = colModel.getColDefColOrCol(field); + const col = colModel.getCol(field); return col ? valueSvc.getValue(col, rowNode, 'data') : null; }, }; diff --git a/packages/ag-grid-community/src/filter/quickFilterService.ts b/packages/ag-grid-community/src/filter/quickFilterService.ts index bb3385868d2..578d810693e 100644 --- a/packages/ag-grid-community/src/filter/quickFilterService.ts +++ b/packages/ag-grid-community/src/filter/quickFilterService.ts @@ -56,19 +56,18 @@ export class QuickFilterService extends BeanStub implem public refreshCols(): void { const { autoColSvc, colModel, gos, pivotResultCols } = this.beans; const pivotMode = colModel.pivotMode; - const groupAutoCols = autoColSvc?.getColumns(); - const providedCols = colModel.getColDefCols(); + const groupAutoCols = autoColSvc?.columns; + const providedCols = colModel.colDefList; let columnsForQuickFilter = - (pivotMode && !gos.get('applyQuickFilterBeforePivotOrAgg') - ? pivotResultCols?.getPivotResultCols()?.list - : providedCols) ?? []; - if (groupAutoCols) { + (pivotMode && !gos.get('applyQuickFilterBeforePivotOrAgg') ? pivotResultCols?.pivotCols : providedCols) ?? + []; + if (groupAutoCols?.length) { columnsForQuickFilter = columnsForQuickFilter.concat(groupAutoCols); } this.colsToUse = gos.get('includeHiddenColumnsInQuickFilter') ? columnsForQuickFilter - : columnsForQuickFilter.filter((col) => col.isVisible() || col.isRowGroupActive()); + : columnsForQuickFilter.filter((col) => col.visible || col.rowGroupActive); } public isFilterPresent(): boolean { diff --git a/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts b/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts index a33d82e934f..f344cbd3084 100644 --- a/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts +++ b/packages/ag-grid-community/src/gridBodyComp/gridBodyCtrl.ts @@ -8,7 +8,7 @@ import type { RowResizeEndedEvent, RowResizeStartedEvent } from '../events'; import type { FilterManager } from '../filter/filterManager'; import { _isAnimateRows } from '../gridOptionsUtils'; import { getAriaHeaderRowCount } from '../headerRendering/headerUtils'; -import type { IColsService } from '../interfaces/iColsService'; +import type { IRowGroupColsService } from '../interfaces/iColsService'; import type { VerticalSection } from '../interfaces/iGridSection'; import type { IPinnedRowModel } from '../interfaces/iPinnedRowModel'; import type { LayoutView } from '../styling/layoutFeature'; @@ -45,7 +45,7 @@ export class GridBodyCtrl extends BeanStub { private ctrlsSvc: CtrlsService; private colModel: ColumnModel; private scrollVisibleSvc: ScrollVisibleService; - private rowGroupColsSvc?: IColsService; + private rowGroupColsSvc?: IRowGroupColsService; private pinnedRowModel?: IPinnedRowModel; private filterManager?: FilterManager; @@ -160,7 +160,7 @@ export class GridBodyCtrl extends BeanStub { } private onGridColumnsChanged(): void { - const columns = this.beans.colModel.getCols(); + const columns = this.beans.colModel.colsList; this.comp.setColumnCount(columns.length); this.updateScrollableAreaWidth(); } diff --git a/packages/ag-grid-community/src/gridBodyComp/gridBodyScrollFeature.ts b/packages/ag-grid-community/src/gridBodyComp/gridBodyScrollFeature.ts index 4f96cfd564c..d2acd9ea98c 100644 --- a/packages/ag-grid-community/src/gridBodyComp/gridBodyScrollFeature.ts +++ b/packages/ag-grid-community/src/gridBodyComp/gridBodyScrollFeature.ts @@ -8,7 +8,6 @@ import { _setScrollLeft, } from 'ag-stack'; -import type { VisibleColsService } from '../columns/visibleColsService'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import type { CtrlsService } from '../ctrlsService'; @@ -49,7 +48,6 @@ interface HorizontalScrollComp extends ScrollPartner { export class GridBodyScrollFeature extends BeanStub { private ctrlsSvc: CtrlsService; private animationFrameSvc?: AnimationFrameService; - private visibleCols: VisibleColsService; // listeners for when ensureIndexVisible is waiting for SSRM data to load private clearRetryListenerFncs: (() => void)[] = []; @@ -57,7 +55,6 @@ export class GridBodyScrollFeature extends BeanStub { public wireBeans(beans: BeanCollection): void { this.ctrlsSvc = beans.ctrlsSvc; this.animationFrameSvc = beans.animationFrameSvc; - this.visibleCols = beans.visibleCols; } private enableRtl: boolean; @@ -711,14 +708,12 @@ export class GridBodyScrollFeature extends BeanStub { return; } - // calling ensureColumnVisible on a pinned column doesn't make sense if (column.isPinned()) { - return; + return; // calling ensureColumnVisible on a pinned column doesn't make sense } - // defensive - if (!this.visibleCols.isColDisplayed(column)) { - return; + if (!column.displayed) { + return; // defensive } const newHorizontalScroll: number | null = this.getPositionedHorizontalScroll(column, position); diff --git a/packages/ag-grid-community/src/headerRendering/cells/abstractCell/abstractHeaderCellCtrl.ts b/packages/ag-grid-community/src/headerRendering/cells/abstractCell/abstractHeaderCellCtrl.ts index 212efa490a9..0eb3dafef08 100644 --- a/packages/ag-grid-community/src/headerRendering/cells/abstractCell/abstractHeaderCellCtrl.ts +++ b/packages/ag-grid-community/src/headerRendering/cells/abstractCell/abstractHeaderCellCtrl.ts @@ -276,7 +276,7 @@ export abstract class AbstractHeaderCellCtrl< return; } refreshFirstAndLastStyles(comp, column, beans.visibleCols); - _setAriaColIndex(eGui, beans.visibleCols.getAriaColIndex(column)); // for react, we don't use JSX, as it slowed down column moving + _setAriaColIndex(eGui, column.ariaColIndex); // for react, we don't use JSX, as it slowed down column moving } protected addResizeAndMoveKeyboardListeners(compBean: BeanStub): void { diff --git a/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts b/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts index c6e615e34c1..f54a52ded9c 100644 --- a/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts +++ b/packages/ag-grid-community/src/headerRendering/cells/column/agColumnHeader.ts @@ -133,7 +133,7 @@ export class AgColumnHeader extends Component implements IHeaderComp { this.params = params; const { sortSvc, touchSvc, rowNumbersSvc, userCompFactory } = this.beans; - const sortComp = sortSvc?.getSortIndicatorSelector(); + const sortComp = sortSvc?.SortIndicatorSelector; this.currentTemplate = this.workOutTemplate(params, !!sortComp); this.setTemplate(this.currentTemplate, sortComp ? [sortComp] : undefined); @@ -282,7 +282,7 @@ export class AgColumnHeader extends Component implements IHeaderComp { // templates, in that case, we need to look for provided sort elements and // manually create eSortIndicator. if (!this.eSortIndicator) { - this.eSortIndicator = this.createBean(sortSvc.createSortIndicator(true)); + this.eSortIndicator = this.createBean(new sortSvc.SortIndicatorComp(true)); const { eSortIndicator, eSortOrder, diff --git a/packages/ag-grid-community/src/headerRendering/cells/column/headerCellCtrl.ts b/packages/ag-grid-community/src/headerRendering/cells/column/headerCellCtrl.ts index 1a5c38be4d5..0732ceff98d 100644 --- a/packages/ag-grid-community/src/headerRendering/cells/column/headerCellCtrl.ts +++ b/packages/ag-grid-community/src/headerRendering/cells/column/headerCellCtrl.ts @@ -7,7 +7,7 @@ import { setupCompBean } from '../../../components/emptyBean'; import { _getHeaderCompDetails } from '../../../components/framework/userCompUtils'; import type { BeanStub } from '../../../context/beanStub'; import type { AgColumn } from '../../../entities/agColumn'; -import { _getSortDefFromInput } from '../../../entities/agColumn'; +import { getSortDefFromInput } from '../../../entities/agColumn'; import type { HeaderClassParams } from '../../../entities/colDef'; import { _addGridCommonParams, _getEnableColumnSelection, _isLegacyMenuEnabled } from '../../../gridOptionsUtils'; import { ColumnHighlightPosition } from '../../../interfaces/iColumn'; @@ -221,7 +221,7 @@ export class HeaderCellCtrl extends AbstractHeaderCellCtrl { - sortSvc?.setSortForColumn(this.column, _getSortDefFromInput(sort), !!multiSort, 'uiColumnSorted'); + sortSvc?.setSortForColumn(this.column, getSortDefFromInput(sort), !!multiSort, 'uiColumnSorted'); }, eGridHeader: this.eGui, setTooltip: (value: string, shouldDisplayTooltip: () => boolean) => { @@ -572,7 +572,7 @@ export class HeaderCellCtrl extends AbstractHeaderCellCtrl { - colGroupSvc!.setColumnGroupOpened(providedColumnGroup, expanded, 'gridInitializing'); + _setColGroupOpen(this.beans, providedColumnGroup, expanded, 'gridInitializing'); }, setTooltip: (value: string, shouldDisplayTooltip: () => boolean) => { gos.assertModuleRegistered('Tooltip', 3); @@ -413,12 +414,7 @@ export class HeaderGroupCellCtrl extends AbstractHeaderCellCtrl< beans.rangeSvc?.handleColumnSelection(column, e); } else if (expandable) { const newExpandedValue = !column.isExpanded(); - - beans.colGroupSvc!.setColumnGroupOpened( - column.getProvidedColumnGroup(), - newExpandedValue, - 'uiColumnExpanded' - ); + _setColGroupOpen(beans, column.getProvidedColumnGroup(), newExpandedValue, 'uiColumnExpanded'); } } diff --git a/packages/ag-grid-community/src/headerRendering/headerUtils.ts b/packages/ag-grid-community/src/headerRendering/headerUtils.ts index 6b28d0bef0e..1d7f508d195 100644 --- a/packages/ag-grid-community/src/headerRendering/headerUtils.ts +++ b/packages/ag-grid-community/src/headerRendering/headerUtils.ts @@ -8,11 +8,11 @@ import type { HeaderRowCtrl } from './row/headerRowCtrl'; // + gridPanel -> for resizing the body and setting top margin /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function getHeaderRowCount(colModel: ColumnModel): number { - if (!colModel.cols) { + if (!colModel.ready) { return -1; } - return colModel.cols.treeDepth + 1; + return colModel.colsTreeDepth + 1; } export function getFocusHeaderRowCount(beans: BeanCollection): number { @@ -59,7 +59,7 @@ function getColumnGroupHeaderRowHeight(beans: BeanCollection, headerRowCtrl: Hea const headerRowCellCtrls = headerRowCtrl.getHeaderCellCtrls(); for (const headerCellCtrl of headerRowCellCtrls) { const { column } = headerCellCtrl; - const height = column.getAutoHeaderHeight(); + const height = column.autoHeaderHeight; if (height != null && height > maxDisplayedHeight && column.isAutoHeaderHeight()) { maxDisplayedHeight = height; } @@ -70,12 +70,14 @@ function getColumnGroupHeaderRowHeight(beans: BeanCollection, headerRowCtrl: Hea export function getColumnHeaderRowHeight(beans: BeanCollection): number { const defaultHeight = beans.colModel.pivotMode ? getPivotHeaderHeight(beans) : getHeaderHeight(beans); let maxDisplayedHeight = defaultHeight; - beans.colModel.forAllCols((col) => { - const height = col.getAutoHeaderHeight(); + const allCols = beans.colModel.getAllCols(); + for (let i = 0, len = allCols.length; i < len; ++i) { + const col = allCols[i]; + const height = col.autoHeaderHeight; if (height != null && height > maxDisplayedHeight && col.isAutoHeaderHeight()) { maxDisplayedHeight = height; } - }); + } return maxDisplayedHeight; } diff --git a/packages/ag-grid-community/src/headerRendering/row/headerRowCtrl.ts b/packages/ag-grid-community/src/headerRendering/row/headerRowCtrl.ts index 7af138bdd02..09cb14432ed 100644 --- a/packages/ag-grid-community/src/headerRendering/row/headerRowCtrl.ts +++ b/packages/ag-grid-community/src/headerRendering/row/headerRowCtrl.ts @@ -152,11 +152,7 @@ export class HeaderRowCtrl extends BeanStub { private getWidthForRow(): number { const { visibleCols } = this.beans; - const contentWidth = - visibleCols.getContainerWidth('right') + - visibleCols.getContainerWidth('left') + - visibleCols.getContainerWidth(null); - + const contentWidth = visibleCols.totalWidth; const eGridViewport = this.beans.ctrlsSvc.getGridBodyCtrl()?.eGridViewport; const viewportWidth = eGridViewport ? eGridViewport.getBoundingClientRect().width : 0; @@ -218,16 +214,9 @@ export class HeaderRowCtrl extends BeanStub { this.recycleAndCreateHeaderCtrls(child, this.ctrlsById, oldCtrls); } - // we want to keep columns that are focused, otherwise keyboard navigation breaks + // keep focused (and still-displayed) header ctrls alive, otherwise keyboard navigation breaks. const isFocusedAndDisplayed = (ctrl: HeaderCellCtrl) => { - const { focusSvc, visibleCols } = this.beans; - - const isFocused = focusSvc.isHeaderWrapperFocused(ctrl); - if (!isFocused) { - return false; - } - const isDisplayed = visibleCols.isVisible(ctrl.column); - return isDisplayed; + return ctrl.column.displayed && this.beans.focusSvc.isHeaderWrapperFocused(ctrl); }; if (oldCtrls) { diff --git a/packages/ag-grid-community/src/headerRendering/rowContainer/headerRowContainerCtrl.ts b/packages/ag-grid-community/src/headerRendering/rowContainer/headerRowContainerCtrl.ts index d6256c3cde4..09035da65c3 100644 --- a/packages/ag-grid-community/src/headerRendering/rowContainer/headerRowContainerCtrl.ts +++ b/packages/ag-grid-community/src/headerRendering/rowContainer/headerRowContainerCtrl.ts @@ -1,7 +1,6 @@ import type { ColumnMoveService } from '../../columnMove/columnMoveService'; import { BeanStub } from '../../context/beanStub'; import type { AgColumn } from '../../entities/agColumn'; -import { isColumn } from '../../entities/agColumn'; import type { AgColumnGroup } from '../../entities/agColumnGroup'; import type { FocusService } from '../../focusService'; import type { ScrollPartner } from '../../gridBodyComp/gridBodyScrollFeature'; @@ -154,7 +153,7 @@ export class HeaderRowContainerCtrl extends BeanStub implements ScrollPartner { const findCtrl = (ctrl: HeaderRowCtrl | undefined) => ctrl?.getHeaderCellCtrls().find((ctrl) => ctrl.column === column); - if (isColumn(column)) { + if (column.isColumn) { return findCtrl(this.columnsRowCtrl); } diff --git a/packages/ag-grid-community/src/infiniteRowModel/infiniteRowModel.ts b/packages/ag-grid-community/src/infiniteRowModel/infiniteRowModel.ts index a27f0b2a2a3..2f2532bd5f8 100644 --- a/packages/ag-grid-community/src/infiniteRowModel/infiniteRowModel.ts +++ b/packages/ag-grid-community/src/infiniteRowModel/infiniteRowModel.ts @@ -7,6 +7,7 @@ import { _getRowHeightAsNumber, _getRowIdCallback } from '../gridOptionsUtils'; import type { IDatasource } from '../interfaces/iDatasource'; import type { IRowModel, RowBounds, RowModelType } from '../interfaces/iRowModel'; import type { OverlayType } from '../rendering/overlays/overlayComponent'; +import { _getSortModel } from '../sort/sortService'; import type { InfiniteCacheParams } from './infiniteCache'; import { InfiniteCache } from './infiniteCache'; @@ -94,7 +95,7 @@ export class InfiniteRowModel extends BeanStub implements NamedBean, IRowModel { // for filter model, as the filter manager will fire an event when columns change that result // in the filter changing. if (this.cacheParams) { - resetRequired = !_jsonEquals(this.cacheParams.sortModel, this.beans.sortSvc?.getSortModel() ?? []); + resetRequired = !_jsonEquals(this.cacheParams.sortModel, _getSortModel(this.beans.sortSvc)); } else { // if no cacheParams, means first time creating the cache, so always create one resetRequired = true; @@ -186,7 +187,7 @@ export class InfiniteRowModel extends BeanStub implements NamedBean, IRowModel { // sort and filter model filterModel: filterManager?.getFilterModel() ?? {}, - sortModel: sortSvc?.getSortModel() ?? [], + sortModel: _getSortModel(sortSvc), rowNodeBlockLoader: rowNodeBlockLoader, diff --git a/packages/ag-grid-community/src/interfaces/formulas.ts b/packages/ag-grid-community/src/interfaces/formulas.ts index c76f155ee0e..0d63122f332 100644 --- a/packages/ag-grid-community/src/interfaces/formulas.ts +++ b/packages/ag-grid-community/src/interfaces/formulas.ts @@ -1,5 +1,4 @@ import type { ChangedRowNodes } from '../clientSideRowModel/changedRowNodes'; -import type { ColumnCollections } from '../columns/columnModel'; import type { Bean } from '../context/bean'; import type { AgColumn } from '../entities/agColumn'; import type { RowNode } from '../entities/rowNode'; @@ -77,7 +76,7 @@ export interface IFormulaService extends Bean { hasCachedRows(): boolean; isEvaluationActive(): boolean; isFormula(value: unknown): value is `=${string}`; - setFormulasActive(cols: ColumnCollections): void; + setFormulasActive(columns: AgColumn[]): void; resolveValue(col: AgColumn, row: RowNode): unknown; getDataSourceFormula(row: RowNode, col: AgColumn): string | undefined; getFormulaError(col: AgColumn, row: RowNode): Error | null; diff --git a/packages/ag-grid-community/src/interfaces/iAutoColService.ts b/packages/ag-grid-community/src/interfaces/iAutoColService.ts new file mode 100644 index 00000000000..9676471fc4f --- /dev/null +++ b/packages/ag-grid-community/src/interfaces/iAutoColService.ts @@ -0,0 +1,15 @@ +import type { AgColumn } from '../entities/agColumn'; +import type { GridOptions } from '../entities/gridOptions'; +import type { ColumnEventType } from '../events'; +import type { PropertyChangedEvent, PropertyValueChangedEvent } from '../gridOptionsService'; + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export interface IAutoColService { + /** Generated auto-group columns. Flat-array — empty when no auto-cols are active. */ + columns: AgColumn[]; + + /** Sync internal cols against current row-group state. */ + refreshCols(source: ColumnEventType): AgColumn[] | null; + + updateColumns(event: PropertyChangedEvent | PropertyValueChangedEvent): void; +} diff --git a/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts b/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts index 515776a7d70..fbef158771e 100644 --- a/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts +++ b/packages/ag-grid-community/src/interfaces/iCalculatedColumns.ts @@ -1,6 +1,8 @@ +import type { ColumnTreeBuild } from '../columns/buildColumnTree'; +import type { ColumnState } from '../columns/columnStateUtils'; import type { Bean } from '../context/bean'; import type { AgColumn } from '../entities/agColumn'; -import type { ColDef, ColGroupDef } from '../entities/colDef'; +import type { ColDef } from '../entities/colDef'; import type { ColumnEventType } from '../events'; export type CalculatedColumnExpressionPicker = 'columns' | 'functions' | 'operators'; @@ -34,13 +36,18 @@ export type CalculatedColumnUpdate = Partial void; -export type ColumnProcessors = Record; - -export type ColumnOrdering = { - enableProp: 'rowGroup' | 'pivot'; - initialEnableProp: 'initialRowGroup' | 'initialPivot'; - indexProp: 'rowGroupIndex' | 'pivotIndex'; - initialIndexProp: 'initialRowGroupIndex' | 'initialPivotIndex'; -}; - -export type ColumnExtractors = { - setFlagFunc: (col: AgColumn, flag: boolean, source: ColumnEventType) => void; - getIndexFunc: (colDef: ColDef) => number | null | undefined; - getInitialIndexFunc: (colDef: ColDef) => number | null | undefined; - getValueFunc: (colDef: ColDef) => boolean | null | undefined; - getInitialValueFunc: (colDef: ColDef) => boolean | undefined; -}; - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +/** `columnChanged`-family event dispatched by a cols service. @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export type ColumnChangedEventType = 'columnValueChanged' | 'columnPivotChanged' | 'columnRowGroupChanged'; + +/** Shared base for all three cols services; service-specific ops live on sub-interfaces. @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface IColsService { columns: AgColumn[]; - eventName: ColumnChangedEventType; - columnProcessors?: ColumnProcessors; - columnOrdering?: ColumnOrdering; - columnExtractors?: ColumnExtractors; + /** Cols staged since the last flush; non-null = awaiting dispatch. {@link ColumnModel.flushColChanges} */ + readonly pendingChanged: Set | null; + + /** Dispatch this service's staged change (if any); called by {@link ColumnModel.flushColChanges}. */ + dispatchColChange(source: ColumnEventType): void; setColumns(colKeys: ColKey[] | undefined, source: ColumnEventType): void; - addColumns(keys: Maybe[] | undefined, source: ColumnEventType): void; - removeColumns(keys: Maybe[] | undefined, source: ColumnEventType): void; - getColumnIndex(colId: string): number | undefined; - extractCols(source: ColumnEventType, oldProvidedCols: AgColumn[] | undefined): void; - syncColumnWithState( - column: AgColumn, - source: ColumnEventType, - getValue: ( - key1: U, - key2?: S - ) => { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined }, - rowIndex?: { [key: string]: number } | null - ): void; + addColumns(keys: (ColKey | null | undefined)[] | undefined, source: ColumnEventType): void; + removeColumns(keys: (ColKey | null | undefined)[] | undefined, source: ColumnEventType): void; - sortColumns(compareFn?: (a: AgColumn, b: AgColumn) => number): void; - restoreColumnOrder( - columnStateAccumulator: { [colId: string]: ColumnState }, - incomingColumnState: { [colId: string]: ColumnState } - ): { [colId: string]: ColumnState }; + /** Recompute active cols with a shared scan: call {@link extractCol} per primary col, then finalise via {@link commitExtract}. */ + extractCol(col: AgColumn, colIsNew: boolean): void; + commitExtract(source: ColumnEventType): void; - // Value - setColumnAggFunc?( - key: ColKey | undefined, - aggFunc: string | IAggFunc | null | undefined, + syncColState( + column: AgColumn, + stateItem: ColumnState | null, + defaultState: ColumnStateParams | undefined, source: ColumnEventType ): void; } + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export interface IOrderedColsService extends IColsService { + /** Assigns synthetic indexes to `incoming` and adds new entries to `accumulator`; both mutated. */ + restoreColumnOrder(incoming: { [colId: string]: ColumnState }, accumulator: { [colId: string]: ColumnState }): void; + sortByPendingState(): void; +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export interface IRowGroupColsService extends IOrderedColsService { + moveColumn(fromIndex: number, toIndex: number, source: ColumnEventType): void; +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export interface IPivotColsService extends IOrderedColsService { + isStrictColumnOrder(): boolean; +} + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export interface IValueColsService extends IColsService { + setColumnAggFunc(key: ColKey | undefined, aggFunc: ColAggFunc, source: ColumnEventType): void; +} diff --git a/packages/ag-grid-community/src/interfaces/iColumn.ts b/packages/ag-grid-community/src/interfaces/iColumn.ts index d7d5eb87c84..abe16bc1caf 100644 --- a/packages/ag-grid-community/src/interfaces/iColumn.ts +++ b/packages/ag-grid-community/src/interfaces/iColumn.ts @@ -1,7 +1,7 @@ import type { IEventEmitter } from 'ag-stack'; import type { AgProvidedColumnGroupEvent } from '../entities/agProvidedColumnGroup'; -import type { AbstractColDef, ColDef, ColGroupDef, IAggFunc } from '../entities/colDef'; +import type { AbstractColDef, ColAggFunc, ColDef, ColGroupDef } from '../entities/colDef'; import type { ColumnEvent } from '../events'; import type { BrandedType } from '../interfaces/brandedType'; import type { SortDef, SortDirection } from '../interfaces/iSort'; @@ -183,7 +183,7 @@ export interface Column getSortIndex(): number | null | undefined; /** If aggregation is set for the column, returns the aggregation function. */ - getAggFunc(): string | IAggFunc | null | undefined; + getAggFunc(): ColAggFunc; /** @deprecated v32 Use col.getLeft() + col.getActualWidth() instead. */ getRight(): number; diff --git a/packages/ag-grid-community/src/interfaces/iColumnCollectionService.ts b/packages/ag-grid-community/src/interfaces/iColumnCollectionService.ts deleted file mode 100644 index 71ceb0ee6dc..00000000000 --- a/packages/ag-grid-community/src/interfaces/iColumnCollectionService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ColumnCollections } from '../columns/columnModel'; -import type { AgColumn } from '../entities/agColumn'; -import type { ColKey } from '../entities/colDef'; -import type { GridOptions } from '../entities/gridOptions'; -import type { ColumnEventType } from '../events'; -import type { PropertyChangedEvent, PropertyValueChangedEvent } from '../gridOptionsService'; - -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export interface IColumnCollectionService { - columns: ColumnCollections | null; - - addColumns(cols: ColumnCollections): void; - - createColumns( - cols: ColumnCollections, - updateOrders: (callback: (cols: AgColumn[] | null) => AgColumn[] | null) => void, - source: ColumnEventType - ): void; - - updateColumns(event: PropertyChangedEvent | PropertyValueChangedEvent): void; - - getColumn(key: ColKey): AgColumn | null; - - getColumns(): AgColumn[] | null; -} diff --git a/packages/ag-grid-community/src/interfaces/iColumnStateUpdateStrategy.ts b/packages/ag-grid-community/src/interfaces/iColumnStateUpdateStrategy.ts index b55baebaba6..b1fe67fc3fa 100644 --- a/packages/ag-grid-community/src/interfaces/iColumnStateUpdateStrategy.ts +++ b/packages/ag-grid-community/src/interfaces/iColumnStateUpdateStrategy.ts @@ -1,6 +1,6 @@ import type { ColumnState } from '../columns/columnStateUtils'; import type { AgColumn } from '../entities/agColumn'; -import type { IAggFunc } from '../entities/colDef'; +import type { ColAggFunc } from '../entities/colDef'; import type { ColumnEventType } from '../events'; import type { SortDef } from '../interfaces/iSort'; @@ -19,13 +19,8 @@ export interface IColumnStateUpdateStrategy { hasDeferredColumnOrder(deferMode: boolean): boolean; setValueColumns(deferMode: boolean, columns: AgColumn[], eventType: ColumnEventType): void; getValueColumns(deferMode: boolean): AgColumn[]; - setColumnAggFunc( - deferMode: boolean, - column: AgColumn, - aggFunc: string | IAggFunc | null | undefined, - eventType: ColumnEventType - ): void; - getColumnAggFunc(deferMode: boolean, column: AgColumn): string | IAggFunc | null | undefined; + setColumnAggFunc(deferMode: boolean, column: AgColumn, aggFunc: ColAggFunc, eventType: ColumnEventType): void; + getColumnAggFunc(deferMode: boolean, column: AgColumn): ColAggFunc; setPivotColumns(deferMode: boolean, columns: AgColumn[], eventType: ColumnEventType): void; getPivotColumns(deferMode: boolean): AgColumn[]; setPivotMode(deferMode: boolean, pivotMode: boolean, eventType: ColumnEventType): void; diff --git a/packages/ag-grid-community/src/interfaces/iGroupHierarchyColService.ts b/packages/ag-grid-community/src/interfaces/iGroupHierarchyColService.ts index 05a8f9ec817..9a9f5056f74 100644 --- a/packages/ag-grid-community/src/interfaces/iGroupHierarchyColService.ts +++ b/packages/ag-grid-community/src/interfaces/iGroupHierarchyColService.ts @@ -1,24 +1,14 @@ +import type { ColumnTreeBuild } from '../columns/buildColumnTree'; import type { AgColumn } from '../entities/agColumn'; -import type { IColumnCollectionService } from './iColumnCollectionService'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export interface IGroupHierarchyColService extends IColumnCollectionService { - /** - * Mutates the `target` parameter, adding any virtual columns associated with the given source column, as well as the source column itself (last in the array) - */ - expandColumnInto(target: AgColumn[], col: AgColumn): void; - /** - * Mutates the `columns` parameter, adding any virtual columns associated with the given source column, _not_ including the source column itself. - * Returns the virtual columns added. - */ - insertVirtualColumnsForCol(columns: AgColumn[], col: AgColumn): AgColumn[]; - /** - * If both arguments are virtural columns with the same source column, we use the same - * order in which they are added. - * - * If one column is a virtual column and the other its source column, the virtual column is sorted first. - * - * Otherwise, we defer sorting to the caller. - */ +export interface IGroupHierarchyColService { + /** Generated hierarchy columns flat-array. Empty when no hierarchy is in use. */ + columns: AgColumn[]; + /** Recompute hierarchy cols and prepend them to the tree via the builder. No-op when none active. */ + contributeTo(build: ColumnTreeBuild): void; + /** This source col's generated virtuals, in order (seated before it); undefined if none. */ + getVirtualCols(sourceCol: AgColumn): AgColumn[] | undefined; + /** Sibling virtuals rank by insertion order; a virtual sorts before its own source; else null (caller decides). */ compareVirtualColumns(colA: AgColumn, colB: AgColumn): number | null; } diff --git a/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts b/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts index 86c208f6d3e..d949c308fba 100644 --- a/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts +++ b/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts @@ -1,21 +1,47 @@ -import type { ColumnCollections } from '../columns/columnModel'; import type { AgColumn } from '../entities/agColumn'; +import type { AgProvidedColumnGroup } from '../entities/agProvidedColumnGroup'; import type { ColDef, ColGroupDef, ColKey } from '../entities/colDef'; import type { ColumnEventType } from '../events'; -/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +/** Owns pivot result column lifecycle (create/retain/teardown). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface IPivotResultColsService { - isPivotResultColsPresent(): boolean; + /** Generated pivot result leaf columns. Null when not pivoting. */ + readonly pivotCols: AgColumn[] | null; - lookupPivotResultCol(pivotKeys: string[], valueColKey: ColKey): AgColumn | null; + /** Balanced-tree wrappers around `pivotCols`. Empty when not pivoting. */ + readonly pivotTree: (AgColumn | AgProvidedColumnGroup)[]; + + /** Tree depth of `pivotTree` (max group nesting). */ + readonly pivotTreeDepth: number; + + /** True iff any `pivotTree` group has `marryChildren`. + * Feeds `ColumnModel.hasMarryChildren` while pivoting. */ + readonly pivotHasMarryChildren: boolean; + + /** Non-padding `pivotTree` groups keyed by `groupId`. + * Feeds `ColumnModel.colsGroupsById` while pivoting. */ + readonly pivotGroupsById: Map; - getPivotResultCols(): ColumnCollections | null; + /** Flat list of all `pivotTree` groups (padding and non-padding). + * Feeds `ColumnModel.colsAllGroups` while pivoting. */ + readonly pivotAllGroups: AgProvidedColumnGroup[]; - getPivotResultCol(key: ColKey): AgColumn | null; + /** Build pivoting column-state order as parked primaries (`colsById` not `colsList`) first. + * Then append displayed cols; return `colsList` directly when none are parked. */ + buildColsInStateOrder(): AgColumn[]; + + buildAllCols(): AgColumn[]; + + lookupPivotResultCol(pivotKeys: string[], valueColKey: ColKey): AgColumn | null; setPivotResultCols(colDefs: (ColDef | ColGroupDef)[] | null, source: ColumnEventType): void; - /** Returns pivot result columns ordered for aggregation: regular columns first, total columns after. - * Cached — only recomputed when pivot result columns change. */ + /** Return aggregation order for pivot result cols (regular first, totals after). + * Cache until pivot result cols change. */ getAggregationOrderedList(): AgColumn[] | null; + + /** Rebuild `colDef`s for pivot result cols whose `pivotValueColumn` is `sourceCol` via reverse-map lookup. + * No-op when not pivoting. */ + recreateColDefsForSource(sourceCol: AgColumn, source: ColumnEventType): void; } diff --git a/packages/ag-grid-community/src/interfaces/iShowRowGroupColsService.ts b/packages/ag-grid-community/src/interfaces/iShowRowGroupColsService.ts index bf5f63c6e77..f9f0c757407 100644 --- a/packages/ag-grid-community/src/interfaces/iShowRowGroupColsService.ts +++ b/packages/ag-grid-community/src/interfaces/iShowRowGroupColsService.ts @@ -1,14 +1,14 @@ import type { AgColumn } from '../entities/agColumn'; +import type { SortDirection } from './iSort'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface IShowRowGroupColsService { readonly columns: AgColumn[]; refresh(): void; - - getShowRowGroupCol(id: string): AgColumn | undefined; - getSourceColumnsForGroupColumn(groupCol: AgColumn): AgColumn[] | null; - isRowGroupDisplayed(column: AgColumn, colId: string): boolean; + interleaveSortedColumns(sorted: AgColumn[]): AgColumn[]; + fillCoupledSortIndexMap(sortedCols: AgColumn[], map: Map): number; + isGroupSortMixed(column: AgColumn, direction: SortDirection): boolean; } diff --git a/packages/ag-grid-community/src/interfaces/rowNumbers.ts b/packages/ag-grid-community/src/interfaces/rowNumbers.ts index 8935bdf0bdd..02055dead07 100644 --- a/packages/ag-grid-community/src/interfaces/rowNumbers.ts +++ b/packages/ag-grid-community/src/interfaces/rowNumbers.ts @@ -1,8 +1,8 @@ +import type { AgColumn } from '../entities/agColumn'; import type { ColDef } from '../entities/colDef'; import type { AgColumnHeader } from '../headerRendering/cells/column/agColumnHeader'; import type { CellCtrl } from '../rendering/cell/cellCtrl'; import type { CellPosition } from './iCellPosition'; -import type { IColumnCollectionService } from './iColumnCollectionService'; export interface RowNumbersOptions extends Pick< ColDef, @@ -60,7 +60,9 @@ export interface RowNumbersOptions extends Pick< } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export interface IRowNumbersService extends IColumnCollectionService { +export interface IRowNumbersService { + column: AgColumn | null; + refreshCols(): AgColumn | null; setupForHeader(comp: AgColumnHeader): void; handleMouseDownOnCell(cell: CellPosition, mouseEvent: MouseEvent): boolean; handleKeyDownOnCell(cell: CellPosition, event: KeyboardEvent): boolean; diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 01099c86b2a..d3f2a772d93 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -35,27 +35,18 @@ export type { INoteAccess, INotesFeature, INotesDataService, INotesService } fro export { _getClientSideRowModel, _getServerSideRowModel, _getViewportRowModel } from './api/rowModelApiUtils'; export { ChangedRowNodes as _ChangedRowNodes } from './clientSideRowModel/changedRowNodes'; export { _csrmFirstLeaf, _csrmReorderAllLeafs } from './clientSideRowModel/clientSideRowModelUtils'; -export { BaseColsService } from './columns/baseColsService'; -export { - _addColumnDefaultAndTypes, - _createColumnTree, - _createColumnTreeWithIds, - _updateColumnState, -} from './columns/columnFactoryUtils'; -export { ColumnKeyCreator } from './columns/columnKeyCreator'; -export type { ColumnCollections as _ColumnCollections } from './columns/columnModel'; +export { _dispatchColumnChangedEvent, dispatchColumnVisibleEvent } from './columns/columnEventUtils'; +export { _buildColumnTree } from './columns/buildColumnTree'; +export { _addColumnDefaultAndTypes, _createUserColumn } from './columns/colDefUtils'; +export { BaseSingleColService as _BaseSingleColService } from './columns/baseSingleColService'; export type { ColumnModel } from './columns/columnModel'; export type { ColumnNameService } from './columns/columnNameService'; -export { _applyColumnState, _getColumnState, _resetColumnState } from './columns/columnStateUtils'; +export { _applyColumnState, _resetColumnState, _setColsVisible } from './columns/columnStateUtils'; export { - _areColIdsEqual, - _columnsMatch, _convertColumnEventSourceType, - _destroyColumnTree, - _getColumnsFromTree, + _destroyColumnTreeAll, + _destroyColumnTreeUnused, _getColumnStateFromColDef, - _getSortDefFromColDef, - _updateColsMap, isColumnGroupAutoCol, isColumnSelectionCol, isRowNumberCol, @@ -63,7 +54,6 @@ export { } from './columns/columnUtils'; export { DATA_TYPE_DERIVED_COL_DEF_PROPERTIES as _DATA_TYPE_DERIVED_COL_DEF_PROPERTIES } from './columns/dataTypeService'; export type { DataTypeService } from './columns/dataTypeService'; -export { GroupInstanceIdCreator } from './columns/groupInstanceIdCreator'; export type { VisibleColsService } from './columns/visibleColsService'; export { EmptyBean as _EmptyBean } from './components/emptyBean'; export { BaseComponentWrapper } from './components/framework/frameworkComponentWrapper'; @@ -95,16 +85,8 @@ export type { HorizontalResizeService } from './dragAndDrop/horizontalResizeServ export type { RowDragComp } from './dragAndDrop/rowDragComp'; export type { RowDragService } from './dragAndDrop/rowDragService'; export type { RowsDrop as _RowsDrop } from './dragAndDrop/rowDragTypes'; -export { - _areSortDefsEqual, - _getDisplaySortForColumn, - _getSortDefFromInput, - _isSortDirectionValid, - _isSortTypeValid, - _normalizeSortDirection, - _normalizeSortType, - AgColumn, -} from './entities/agColumn'; +export { _getDisplaySortForColumn, _normalizeSortType, AgColumn } from './entities/agColumn'; +export type { ColKind } from './entities/agColumn'; export { AgColumnGroup } from './entities/agColumnGroup'; export { AgProvidedColumnGroup } from './entities/agProvidedColumnGroup'; export { @@ -262,9 +244,16 @@ export type { IAggColumnNameService } from './interfaces/iAggColumnNameService'; export type { IAggFuncService } from './interfaces/iAggFuncService'; export type { IAggregatedChildrenSvc as _IAggregatedChildrenSvc } from './interfaces/iAggregatedChildrenSvc'; export type { ICellRangeFeature } from './interfaces/iCellRangeFeature'; +export type { IAutoColService } from './interfaces/iAutoColService'; export type { IClipboardService } from './interfaces/iClipboardService'; -export type { IColsService } from './interfaces/iColsService'; -export type { IColumnCollectionService } from './interfaces/iColumnCollectionService'; +export type { + ColumnChangedEventType as _ColumnChangedEventType, + IColsService, + IOrderedColsService, + IPivotColsService, + IRowGroupColsService, + IValueColsService, +} from './interfaces/iColsService'; export type { IColumnStateUpdateStrategy } from './interfaces/iColumnStateUpdateStrategy'; export type { IEventService } from './interfaces/iEventService'; export type { IExpansionService } from './interfaces/iExpansionService'; @@ -277,6 +266,7 @@ export type { IGroupFilterService } from './interfaces/iGroupFilterService'; export type { IRowGroupingEditValueSvc as _IRowGroupingEditValueSvc } from './interfaces/iRowGroupingEditValueSvc'; export type { IRowGroupPanelBuilder as _IRowGroupPanelBuilder } from './interfaces/iRowGroupPanelBuilder'; export type { IGroupHierarchyColService } from './interfaces/iGroupHierarchyColService'; +export type { ColumnTreeBuild, ColumnTreeEdit } from './columns/buildColumnTree'; export type { IMenuFactory } from './interfaces/iMenuFactory'; export type { IMultiFilterService } from './interfaces/iMultiFilterService'; @@ -347,6 +337,7 @@ export { BaseSelectionService } from './selection/baseSelectionService'; export type { RowRangeSelectionContext } from './selection/rowRangeSelectionContext'; export type { RowNodeSorter } from './sort/rowNodeSorter'; export type { SortService } from './sort/sortService'; +export { _getSortModel } from './sort/sortService'; export type { CellStyleService } from './styling/cellStyleService'; export { coreDefaults as _coreThemeDefaults } from './theming/core/core-css'; export { @@ -375,7 +366,7 @@ export { } from './utils/gridFocus'; export { _createIcon, _createIconNoSpan } from './utils/icon'; export { _consoleError, _warnOnce } from './utils/log'; -export { _mergeDeep } from './utils/mergeDeep'; +export { _mergeDeep, _mergedEqual } from './utils/mergeDeep'; export { _formatNumberCommas } from './utils/number'; export { _selectAllCells } from './utils/selection'; export { _errMsg, _error, _logPreInitWarn, _preInitErrMsg, _warn } from './validation/logging'; diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 35ceab96bd9..e8b76a1d06b 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -657,6 +657,7 @@ export type { CellStyleFunc, CheckboxSelectionCallback, CheckboxSelectionCallbackParams, + ColAggFunc, ColDef, ColDefField, ColGroupDef, diff --git a/packages/ag-grid-community/src/misc/menu/menuService.ts b/packages/ag-grid-community/src/misc/menu/menuService.ts index fc318895d67..262b035d0da 100644 --- a/packages/ag-grid-community/src/misc/menu/menuService.ts +++ b/packages/ag-grid-community/src/misc/menu/menuService.ts @@ -4,7 +4,6 @@ import type { NamedBean } from '../../context/bean'; import { BeanStub } from '../../context/beanStub'; import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; -import { isColumn } from '../../entities/agColumn'; import type { AgProvidedColumnGroup } from '../../entities/agProvidedColumnGroup'; import type { ColumnEventType } from '../../events'; import { _isLegacyMenuEnabled } from '../../gridOptionsUtils'; @@ -95,7 +94,7 @@ export class MenuService extends BeanStub implements NamedBean { } public isHeaderContextMenuEnabled(column?: AgColumn | AgProvidedColumnGroup): boolean { - const colDef = column && isColumn(column) ? column.colDef : column?.getColGroupDef(); + const colDef = column?.isColumn ? column.colDef : column?.getColGroupDef(); return !colDef?.suppressHeaderContextMenu && this.gos.get('columnMenu') === 'new'; } diff --git a/packages/ag-grid-community/src/misc/state/stateService.ts b/packages/ag-grid-community/src/misc/state/stateService.ts index 709e1144171..95215d6c9df 100644 --- a/packages/ag-grid-community/src/misc/state/stateService.ts +++ b/packages/ag-grid-community/src/misc/state/stateService.ts @@ -1,5 +1,6 @@ import { _debounce, _jsonEquals } from 'ag-stack'; +import { _getColGroupState, _setColGroupState } from '../../columns/columnGroups/columnGroupState'; import type { ColumnState, ColumnStateParams } from '../../columns/columnStateUtils'; import { _applyColumnState, _getColumnState } from '../../columns/columnStateUtils'; import type { NamedBean } from '../../context/bean'; @@ -193,7 +194,7 @@ export class StateService extends BeanStub implements NamedBean { partialColumnState: boolean, ignoreSet?: Set ): void { - this.setColumnState(state, source, partialColumnState, ignoreSet); + this.applyColumnGridState(state, source, partialColumnState, ignoreSet); this.setColumnGroupState(state, source, ignoreSet); this.updateColumnAndGroupState(); @@ -369,7 +370,7 @@ export class StateService extends BeanStub implements NamedBean { }); } - private getColumnState(): { + private getColumnGridState(): { sort?: SortState; rowGroup?: RowGroupState; aggregation?: AggregationState; @@ -383,7 +384,7 @@ export class StateService extends BeanStub implements NamedBean { return convertColumnState(_getColumnState(beans), beans.colModel.pivotMode); } - private setColumnState( + private applyColumnGridState( state: GridState, source: 'gridInitializing' | 'api', partialColumnState: boolean, @@ -406,7 +407,7 @@ export class StateService extends BeanStub implements NamedBean { forceSetState ||= shouldSet; return shouldSet; }; - const columnStateMap: { [colId: string]: ColumnState } = {}; + const columnStateMap: { [colId: string]: ColumnState } = Object.create(null); const getColumnState = (colId: string) => { let columnState = columnStateMap[colId]; if (columnState) { @@ -419,13 +420,15 @@ export class StateService extends BeanStub implements NamedBean { const defaultState: ColumnStateParams = {}; const shouldSetSortState = shouldSetState('sort', sortState); - if (shouldSetSortState) { - sortState?.sortModel.forEach(({ colId, sort, type }, sortIndex) => { + if (shouldSetSortState && sortState) { + const sortModel = sortState.sortModel; + for (let sortIndex = 0, len = sortModel.length; sortIndex < len; ++sortIndex) { + const { colId, sort, type } = sortModel[sortIndex]; const columnState = getColumnState(colId); columnState.sort = sort; columnState.sortIndex = sortIndex; columnState.sortType = type; - }); + } } if (shouldSetSortState || !partialColumnState) { defaultState.sort = null; @@ -433,12 +436,13 @@ export class StateService extends BeanStub implements NamedBean { } const shouldSetGroupState = shouldSetState('rowGroup', groupState); - if (shouldSetGroupState) { - groupState?.groupColIds.forEach((colId, rowGroupIndex) => { - const columnState = getColumnState(colId); + if (shouldSetGroupState && groupState) { + const groupColIds = groupState.groupColIds; + for (let rowGroupIndex = 0, len = groupColIds.length; rowGroupIndex < len; ++rowGroupIndex) { + const columnState = getColumnState(groupColIds[rowGroupIndex]); columnState.rowGroup = true; columnState.rowGroupIndex = rowGroupIndex; - }); + } } if (shouldSetGroupState || !partialColumnState) { defaultState.rowGroup = null; @@ -446,24 +450,27 @@ export class StateService extends BeanStub implements NamedBean { } const shouldSetAggregationState = shouldSetState('aggregation', aggregationState); - if (shouldSetAggregationState) { - aggregationState?.aggregationModel.forEach(({ colId, aggFunc }) => { + if (shouldSetAggregationState && aggregationState) { + const aggregationModel = aggregationState.aggregationModel; + for (let i = 0, len = aggregationModel.length; i < len; ++i) { + const { colId, aggFunc } = aggregationModel[i]; getColumnState(colId).aggFunc = aggFunc; - }); + } } if (shouldSetAggregationState || !partialColumnState) { defaultState.aggFunc = null; } const shouldSetPivotState = shouldSetState('pivot', pivotState); - if (shouldSetPivotState) { - pivotState?.pivotColIds.forEach((colId, pivotIndex) => { - const columnState = getColumnState(colId); + if (shouldSetPivotState && pivotState) { + const pivotColIds = pivotState.pivotColIds; + for (let pivotIndex = 0, len = pivotColIds.length; pivotIndex < len; ++pivotIndex) { + const columnState = getColumnState(pivotColIds[pivotIndex]); columnState.pivot = true; columnState.pivotIndex = pivotIndex; - }); + } this.gos.updateGridOptions({ - options: { pivotMode: !!pivotState?.pivotMode }, + options: { pivotMode: !!pivotState.pivotMode }, source: source as any, }); } @@ -532,15 +539,15 @@ export class StateService extends BeanStub implements NamedBean { this.columnGroupStates = undefined; const beans = this.beans; - const { pivotResultCols, colGroupSvc } = beans; - if (!pivotResultCols?.isPivotResultColsPresent()) { + const { pivotResultCols, colModel } = beans; + if (!pivotResultCols?.pivotCols) { return; } if (columnStates) { const secondaryColumnStates: ColumnState[] = []; for (const columnState of columnStates) { - if (pivotResultCols.getPivotResultCol(columnState.colId)) { + if (colModel.colsById[columnState.colId]?.colDef.pivotKeys != null) { secondaryColumnStates.push(columnState); } } @@ -557,16 +564,12 @@ export class StateService extends BeanStub implements NamedBean { if (columnGroupStates) { // no easy/performant way of knowing which column groups are pivot column groups - colGroupSvc?.setColumnGroupState(columnGroupStates, source); + _setColGroupState(beans, columnGroupStates, source); } } private getColumnGroupState(): ColumnGroupState | undefined { - const colGroupSvc = this.beans.colGroupSvc; - if (!colGroupSvc) { - return undefined; - } - const columnGroupState = colGroupSvc.getColumnGroupState(); + const columnGroupState = _getColGroupState(this.beans); return _convertColumnGroupState(columnGroupState); } @@ -575,9 +578,7 @@ export class StateService extends BeanStub implements NamedBean { source: 'gridInitializing' | 'api', ignoreSet?: Set ): void { - const colGroupSvc = this.beans.colGroupSvc; if ( - !colGroupSvc || ignoreSet?.has('columnGroup') || (source !== 'api' && !Object.prototype.hasOwnProperty.call(state, 'columnGroup')) ) { @@ -585,7 +586,7 @@ export class StateService extends BeanStub implements NamedBean { } const openColumnGroups = new Set(state.columnGroup?.openColumnGroupIds); - const existingColumnGroupState = colGroupSvc.getColumnGroupState(); + const existingColumnGroupState = _getColGroupState(this.beans); const stateItems = existingColumnGroupState.map(({ groupId }) => { const open = openColumnGroups.has(groupId); if (open) { @@ -606,7 +607,7 @@ export class StateService extends BeanStub implements NamedBean { if (stateItems.length) { this.columnGroupStates = stateItems; } - colGroupSvc.setColumnGroupState(stateItems, source); + _setColGroupState(this.beans, stateItems, source); } private getFilterState(): FilterState | undefined { @@ -667,7 +668,7 @@ export class StateService extends BeanStub implements NamedBean { for (const cellRange of cellSelectionState?.cellRanges ?? []) { const columns: AgColumn[] = []; for (const colId of cellRange.colIds) { - const column = colModel.getCol(colId); + const column = colModel.colsById[colId]; if (column) { columns.push(column); } @@ -675,7 +676,7 @@ export class StateService extends BeanStub implements NamedBean { if (!columns.length) { continue; } - let startColumn = colModel.getCol(cellRange.startColId); + let startColumn = colModel.colsById[cellRange.startColId]; if (!startColumn) { // find the first remaining column const allColumns = visibleCols.allCols; @@ -757,7 +758,7 @@ export class StateService extends BeanStub implements NamedBean { } const { colId, rowIndex, rowPinned } = focusedCellState; focusSvc.setFocusedCell({ - column: colModel.getCol(colId), + column: colModel.colsById[colId] ?? null, rowIndex, rowPinned, forceBrowserFocus: true, @@ -861,7 +862,7 @@ export class StateService extends BeanStub implements NamedBean { } private updateColumnState(features: (keyof GridState)[]): void { - const newColumnState = this.getColumnState(); + const newColumnState = this.getColumnGridState(); let hasChanged = false; const cachedState = this.cachedState; for (const key of Object.keys(newColumnState) as (keyof GridState)[]) { diff --git a/packages/ag-grid-community/src/navigation/headerNavigationService.ts b/packages/ag-grid-community/src/navigation/headerNavigationService.ts index e92d78360ad..1d36bcfabe0 100644 --- a/packages/ag-grid-community/src/navigation/headerNavigationService.ts +++ b/packages/ag-grid-community/src/navigation/headerNavigationService.ts @@ -4,7 +4,7 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { BeanCollection } from '../context/context'; import { AgColumn } from '../entities/agColumn'; -import { AgColumnGroup, isColumnGroup } from '../entities/agColumnGroup'; +import { AgColumnGroup, edgeLeafColumn, getColGroupAtLevel, isColumnGroup } from '../entities/agColumnGroup'; import type { GridBodyCtrl } from '../gridBodyComp/gridBodyCtrl'; import { getFocusHeaderRowCount } from '../headerRendering/headerUtils'; import type { HeaderRowType } from '../headerRendering/row/headerRowComp'; @@ -79,13 +79,10 @@ export class HeaderNavigationService extends BeanStub implements NamedBean { ): HeaderPosition | null { let column: AgColumn | AgColumnGroup | null; - const { colModel, colGroupSvc, ctrlsSvc } = this.beans; + const { colModel, ctrlsSvc } = this.beans; if (typeof colKey === 'string') { - column = colModel.getCol(colKey); - if (!column) { - column = colGroupSvc?.getColumnGroup(colKey) ?? null; - } + column = colModel.getCol(colKey) ?? colModel.colsGroupsById.get(colKey)?.displayInstances?.[0] ?? null; } else { column = colKey as AgColumn | AgColumnGroup; } @@ -301,7 +298,7 @@ export class HeaderNavigationService extends BeanStub implements NamedBean { } private findHeader(focusedHeader: HeaderPosition, direction: 'Before' | 'After'): HeaderPosition | undefined { - const { colGroupSvc, visibleCols } = this.beans; + const { visibleCols } = this.beans; let currentFocusedColumn = focusedHeader.column as AgColumn | AgColumnGroup; if (currentFocusedColumn instanceof AgColumnGroup) { @@ -324,7 +321,7 @@ export class HeaderNavigationService extends BeanStub implements NamedBean { column: nextFocusedCol, }; } - const groupAtLevel = colGroupSvc?.getColGroupAtLevel(nextFocusedCol, focusedHeader.headerRowIndex); + const groupAtLevel = getColGroupAtLevel(nextFocusedCol, focusedHeader.headerRowIndex); if (!groupAtLevel) { // spanned or filler column const isSpanningCol = nextFocusedCol instanceof AgColumn && nextFocusedCol.isSpanHeaderHeight(); @@ -420,17 +417,17 @@ function getColumnVisibleChild( // non-rendered padding groups. if (optimisticNextIndex >= columnHeaderRowIndex) { return { - column: column.getDisplayedLeafColumns()[0], + column: edgeLeafColumn(column, true, false)!, headerRowIndex: columnHeaderRowIndex, headerRowIndexWithoutSpan: optimisticNextIndex, }; } - const children = column.getDisplayedChildren(); + const children = column.displayedChildren; let firstChild = children![0]; if (firstChild instanceof AgColumnGroup && firstChild.isPadding()) { - const firstCol = firstChild.getDisplayedLeafColumns()[0]; - if (firstCol.isSpanHeaderHeight()) { + const firstCol = edgeLeafColumn(firstChild, true, false); + if (firstCol?.isSpanHeaderHeight()) { firstChild = firstCol; } } diff --git a/packages/ag-grid-community/src/pinnedColumns/pinnedColumnService.ts b/packages/ag-grid-community/src/pinnedColumns/pinnedColumnService.ts index c83c5527609..f370e397d8c 100644 --- a/packages/ag-grid-community/src/pinnedColumns/pinnedColumnService.ts +++ b/packages/ag-grid-community/src/pinnedColumns/pinnedColumnService.ts @@ -101,7 +101,7 @@ export class PinnedColumnService extends BeanStub implements NamedBean { public setColsPinned(keys: ColKey[], pinned: ColumnPinnedType, source: ColumnEventType): void { const { colModel, visibleCols, gos } = this.beans; - if (!colModel.cols) { + if (!colModel.ready) { return; } if (!keys?.length) { @@ -140,7 +140,7 @@ export class PinnedColumnService extends BeanStub implements NamedBean { } if (updatedCols.length) { - visibleCols.refresh(source); + visibleCols.refresh(source, false); dispatchColumnPinnedEvent(this.eventSvc, updatedCols, source); } } diff --git a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts index 605a2f1e086..4622019a295 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellCtrl.ts @@ -786,8 +786,7 @@ export class CellCtrl extends BeanStub { } private refreshAriaColIndex(): void { - const colIdx = this.beans.visibleCols.getAriaColIndex(this.column); - _setAriaColIndex(this.eGui, colIdx); // for react, we don't use JSX, as it slowed down column moving + _setAriaColIndex(this.eGui, this.column.ariaColIndex); // for react, we don't use JSX, as it slowed down column moving } public onWidthChanged(): void { diff --git a/packages/ag-grid-community/src/rendering/cell/cellPositionFeature.ts b/packages/ag-grid-community/src/rendering/cell/cellPositionFeature.ts index 4ae141a9063..a810619f062 100644 --- a/packages/ag-grid-community/src/rendering/cell/cellPositionFeature.ts +++ b/packages/ag-grid-community/src/rendering/cell/cellPositionFeature.ts @@ -128,8 +128,12 @@ export class CellPositionFeature extends BeanStub { if (!this.colsSpanning) { return this.column.getActualWidth(); } - - return this.colsSpanning.reduce((width, col) => width + col.getActualWidth(), 0); + const cols = this.colsSpanning; + let width = 0; + for (let i = 0, len = cols.length; i < len; ++i) { + width += cols[i].actualWidth; + } + return width; } public getColSpanningList(): AgColumn[] { diff --git a/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts b/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts index ad44fc910e6..374a09ad69f 100644 --- a/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts +++ b/packages/ag-grid-community/src/rendering/row/normalRowFeature.ts @@ -230,25 +230,26 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { if (colsFromPrev.length) { for (const [colInstanceId, cellCtrl] of colsFromPrev) { - const index = res.list.findIndex((ctrl) => ctrl.column.getLeft()! > cellCtrl.column.getLeft()!); + const index = res.list.findIndex((ctrl) => ctrl.column.left! > cellCtrl.column.left!); const normalisedIndex = index === -1 ? undefined : Math.max(index - 1, 0); addCell(colInstanceId, cellCtrl, normalisedIndex); } } - const { focusSvc, visibleCols } = this.beans; + const { focusSvc } = this.beans; const focusedCell = focusSvc.getFocusedCell(); + const focusedCol = focusedCell?.column as AgColumn | undefined; // if a cell is focused, might need to be force rendered if it belongs to this pinned section - if (focusedCell && focusedCell.column.getPinned() == pinned) { - const focusedColInstanceId = (focusedCell.column as AgColumn).getInstanceId(); + if (focusedCol && focusedCol.pinned == pinned) { + const focusedColInstanceId = focusedCol.getInstanceId(); const focusedCellCtrl = res.map[focusedColInstanceId]; // if focused col is visible, and there's no cell here for it, try to create one - if (!focusedCellCtrl && visibleCols.allCols.includes(focusedCell.column as AgColumn)) { + if (!focusedCellCtrl && focusedCol.displayed) { const cellCtrl = this.createFocusedCellCtrl(); if (cellCtrl) { - const index = res.list.findIndex((ctrl) => ctrl.column.getLeft()! > cellCtrl.column.getLeft()!); + const index = res.list.findIndex((ctrl) => ctrl.column.left! > cellCtrl.column.left!); const normalisedIndex = index === -1 ? undefined : Math.max(index - 1, 0); addCell(focusedColInstanceId, cellCtrl, normalisedIndex); } @@ -286,7 +287,7 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { // always remove the cell if it's not rendered or if it's in the wrong pinned location const { column } = cellCtrl; - if (column.getPinned() != nextContainerPinned) { + if (column.pinned != nextContainerPinned) { return REMOVE_CELL; } @@ -296,15 +297,14 @@ export class NormalRowFeature extends BeanStub implements IRowModeFeature { } // we want to try and keep editing and focused cells - const { visibleCols, editSvc } = this.beans; + const { editSvc } = this.beans; const editing = editSvc?.isEditing(cellCtrl); const focused = cellCtrl.isCellFocused(); const mightWantToKeepCell = editing || focused; if (mightWantToKeepCell) { - const cellStillDisplayed = visibleCols.allCols.indexOf(column) >= 0; - return cellStillDisplayed ? KEEP_CELL : REMOVE_CELL; + return column.displayed ? KEEP_CELL : REMOVE_CELL; } return REMOVE_CELL; diff --git a/packages/ag-grid-community/src/rendering/row/rowAutoHeightService.ts b/packages/ag-grid-community/src/rendering/row/rowAutoHeightService.ts index 2f58de18a23..b92d572d3cb 100644 --- a/packages/ag-grid-community/src/rendering/row/rowAutoHeightService.ts +++ b/packages/ag-grid-community/src/rendering/row/rowAutoHeightService.ts @@ -1,6 +1,5 @@ import { _debounce, _getDocument, _getElementSize, _observeResize } from 'ag-stack'; -import type { ColumnCollections } from '../../columns/columnModel'; import type { NamedBean } from '../../context/bean'; import { BeanStub } from '../../context/beanStub'; import type { AgColumn } from '../../entities/agColumn'; @@ -200,8 +199,8 @@ export class RowAutoHeightService extends BeanStub implements NamedBean { return true; } - public setAutoHeightActive(cols: ColumnCollections): void { - this.active = cols.list.some((col) => col.isVisible() && col.isAutoHeight()); + public setAutoHeightActive(active: boolean): void { + this.active = active; } /** diff --git a/packages/ag-grid-community/src/rendering/rowRenderer.ts b/packages/ag-grid-community/src/rendering/rowRenderer.ts index a2de7afadc4..7739af66dbb 100644 --- a/packages/ag-grid-community/src/rendering/rowRenderer.ts +++ b/packages/ag-grid-community/src/rendering/rowRenderer.ts @@ -403,7 +403,7 @@ export class RowRenderer extends BeanStub implements NamedBean { private refreshListenersToColumnsForCellComps(): void { this.removeGridColumnListeners(); - const cols = this.colModel.getCols(); + const cols = this.colModel.colsList; for (const col of cols) { const forEachCellWithThisCol = (callback: (cellCtrl: CellCtrl) => void) => { @@ -971,7 +971,7 @@ export class RowRenderer extends BeanStub implements NamedBean { if (_exists(columns)) { colIdsMap = {}; columns.forEach((colKey: string | AgColumn) => { - const column: AgColumn | null = this.colModel.getCol(colKey); + const column = this.colModel.getCol(colKey); if (_exists(column)) { colIdsMap[column.getId()] = true; } @@ -1227,8 +1227,8 @@ export class RowRenderer extends BeanStub implements NamedBean { private onDisplayedColumnsChanged(): void { const { visibleCols } = this.beans; - const pinningLeft = visibleCols.isPinningLeft(); - const pinningRight = visibleCols.isPinningRight(); + const pinningLeft = visibleCols.leftCols.length > 0; + const pinningRight = visibleCols.rightCols.length > 0; const atLeastOneChanged = this.pinningLeft !== pinningLeft || pinningRight !== this.pinningRight; if (atLeastOneChanged) { diff --git a/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts b/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts index 5a29cb24abb..0426aa30f84 100644 --- a/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts +++ b/packages/ag-grid-community/src/rendering/spanning/rowSpanCache.ts @@ -1,4 +1,4 @@ -import { BeanStub } from '../../context/beanStub'; +import type { BeanCollection } from '../../context/context'; import type { AgColumn } from '../../entities/agColumn'; import type { SpanRowsParams } from '../../entities/colDef'; import type { RowNode } from '../../entities/rowNode'; @@ -84,26 +84,25 @@ export class CellSpan { * * Only create if spanning is enabled for this column. */ -export class RowSpanCache extends BeanStub { - private centerValueNodeMap: Map; +export class RowSpanCache { + private centerValueNodeMap: Map | null = null; // pinned rows - private topValueNodeMap: Map; - private bottomValueNodeMap: Map; + private topValueNodeMap: Map | null = null; + private bottomValueNodeMap: Map | null = null; - constructor(private readonly column: AgColumn) { - super(); - } + constructor( + private readonly beans: BeanCollection, + public readonly column: AgColumn + ) {} public buildCache(pinned: 'top' | 'center' | 'bottom'): void { - const { - column, - beans: { gos, pinnedRowModel, rowModel, valueSvc, pagination }, - } = this; - const { colDef } = column; + const { gos, pinnedRowModel, rowModel, valueSvc, pagination } = this.beans; + const column = this.column; + const colDef = column.colDef; const oldMap = this.getNodeMap(pinned); - const newMap = new Map(); + const newMap = new Map(); const isFullWidthCellFunc = gos.getCallback('isFullWidthRow'); const equalsFnc = colDef.equals; @@ -199,15 +198,11 @@ export class RowSpanCache extends BeanStub { } } - public isCellSpanning(node: RowNode): boolean { - return !!this.getCellSpan(node); - } - public getCellSpan(node: RowNode): CellSpan | undefined { - return this.getNodeMap(node.rowPinned).get(node); + return this.getNodeMap(node.rowPinned)?.get(node); } - private getNodeMap(container: RowPinnedType | 'center'): Map { + private getNodeMap(container: RowPinnedType | 'center'): Map | null { switch (container) { case 'top': return this.topValueNodeMap; diff --git a/packages/ag-grid-community/src/rendering/spanning/rowSpanService.ts b/packages/ag-grid-community/src/rendering/spanning/rowSpanService.ts index 77160c842d1..81ac10418a1 100644 --- a/packages/ag-grid-community/src/rendering/spanning/rowSpanService.ts +++ b/packages/ag-grid-community/src/rendering/spanning/rowSpanService.ts @@ -14,7 +14,7 @@ export class RowSpanService extends BeanStub<'spannedCellsUpdated'> implements N /** Active only if `enableCellSpan=true` */ public active: boolean = false; - private readonly spanningColumns: Map = new Map(); + private spanningColumns: Map | null = null; public postConstruct(): void { if (!this.gos.get('enableCellSpan')) { @@ -34,148 +34,133 @@ export class RowSpanService extends BeanStub<'spannedCellsUpdated'> implements N }); } - /** - * When a new column is created with spanning (or spanning changes for a column) - * @param column column that is now spanning - */ - public register(column: AgColumn): void { - if (!this.active || this.spanningColumns.has(column)) { + /** Create and build a span cache for `column`. Idempotent. */ + private register(column: AgColumn): void { + let spanningColumns = this.spanningColumns; + if (!spanningColumns) { + spanningColumns = new Map(); + this.spanningColumns = spanningColumns; + } else if (spanningColumns.has(column)) { return; } - const cache = this.createManagedBean(new RowSpanCache(column)); - this.spanningColumns.set(column, cache); + const cache = new RowSpanCache(this.beans, column); + spanningColumns.set(column, cache); // make sure if row model already run we prep this cache this.buildCache(cache); - - this.debouncePinnedEvent(); - this.debounceModelEvent(); } - public refreshColumnSpansForCols(columns: AgColumn[]): void { + /** `spanRows` config changed: start spanning, stop spanning, or rebuild if it still spans. */ + public columnRowSpanChanged(column: AgColumn): void { if (!this.active) { return; } - - const caches: RowSpanCache[] = []; - const seenCaches = new Set(); - for (const column of columns) { - const cache = this.spanningColumns.get(column); - if (!cache || seenCaches.has(cache)) { - continue; + const cache = this.spanningColumns?.get(column); + if (column.colDef.spanRows) { + if (!cache) { + this.register(column); + return; } - - seenCaches.add(cache); - caches.push(cache); - } - - if (!caches.length) { - return; - } - - for (const cache of caches) { this.buildCache(cache); + } else if (cache) { + this.deregister(column); } + } - this.debouncePinnedEvent(); - this.debounceModelEvent(); + /** Register newly-added spanning columns. Called post-commit, when values (e.g. calc cols) resolve. */ + public refreshCols(): void { + if (this.active) { + const cols = this.beans.colModel.colsList; + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + if (col.colDef.spanRows) { + this.register(col); + } + } + } } + /** Rebuild all regions for one column's cache and signal consumers to re-render. */ private buildCache(cache: RowSpanCache): void { cache.buildCache('top'); cache.buildCache('bottom'); cache.buildCache('center'); + this.debouncePinnedEvent(); + this.debounceModelEvent(); } // debounced to allow spannedRowRenderer to run first, removing any old spanned rows private readonly debouncePinnedEvent = _debounce(this, this.dispatchCellsUpdatedEvent.bind(this, true), 0); private readonly debounceModelEvent = _debounce(this, this.dispatchCellsUpdatedEvent.bind(this, false), 0); private dispatchCellsUpdatedEvent(pinned: boolean): void { - this.dispatchLocalEvent({ type: 'spannedCellsUpdated', pinned }); + if (this.isAlive()) { + this.dispatchLocalEvent({ type: 'spannedCellsUpdated', pinned }); + } } - /** - * When a new column is destroyed with spanning (or spanning changes for a column) - * @param column column that is now spanning - */ + /** Drop `column`'s span cache (column destroyed or no longer spanning). */ public deregister(column: AgColumn): void { - this.spanningColumns.delete(column); + this.spanningColumns?.delete(column); } private pinnedTimeout: number | null = null; private modelTimeout: number | null = null; - // called when data changes, as this could be a hot path it's debounced - // it uses timeouts instead of debounce so that it can be cancelled by `modelUpdated` - // which is expected to run immediately (to exec before the rowRenderer) + // Data-change hot path: debounced via timeouts (not `_debounce`) so it can be cancelled by + // `modelUpdated`, which must run immediately (before the rowRenderer). private onRowDataUpdated({ node }: { node: IRowNode }) { - const { spannedRowRenderer } = this.beans; + const spannedRowRenderer = this.beans.spannedRowRenderer; if (node.rowPinned) { - if (this.pinnedTimeout != null) { - return; - } - this.pinnedTimeout = window.setTimeout(() => { + this.pinnedTimeout ??= window.setTimeout(() => { this.pinnedTimeout = null; this.buildPinnedCaches(); - // normally updated by the rowRenderer, but as this change is - // caused by data, need to manually update + // data-driven change: rowRenderer won't, so update manually spannedRowRenderer?.createCtrls('top'); spannedRowRenderer?.createCtrls('bottom'); }, 0); return; } - if (this.modelTimeout != null) { - return; - } - - this.modelTimeout = window.setTimeout(() => { + this.modelTimeout ??= window.setTimeout(() => { this.modelTimeout = null; this.buildModelCaches(); - // normally updated by the rowRenderer, but as this change is - // caused by data, need to manually update + // data-driven change: rowRenderer won't, so update manually spannedRowRenderer?.createCtrls('center'); }, 0); } private buildModelCaches(): void { - if (this.modelTimeout != null) { - clearTimeout(this.modelTimeout); + this.clearModelTimeout(); + const spanningColumns = this.spanningColumns; + if (spanningColumns) { + for (const cache of spanningColumns.values()) { + cache.buildCache('center'); + } } - - this.spanningColumns.forEach((cache) => cache.buildCache('center')); this.debounceModelEvent(); } private buildPinnedCaches(): void { - if (this.pinnedTimeout != null) { - clearTimeout(this.pinnedTimeout); + this.clearPinnedTimeout(); + const spanningColumns = this.spanningColumns; + if (spanningColumns) { + for (const cache of spanningColumns.values()) { + cache.buildCache('top'); + cache.buildCache('bottom'); + } } - - this.spanningColumns.forEach((cache) => { - cache.buildCache('top'); - cache.buildCache('bottom'); - }); this.debouncePinnedEvent(); } public isCellSpanning(col: AgColumn, rowNode: RowNode): boolean { - if (!this.active) { - return false; - } - const cache = this.spanningColumns.get(col); - if (!cache) { - return false; - } - - return cache.isCellSpanning(rowNode); + return !!this.spanningColumns?.get(col)?.getCellSpan(rowNode); } public getCellSpanByPosition(position: CellPosition): CellSpan | undefined { const { column, rowIndex } = position; - const cache = this.spanningColumns.get(column as AgColumn); + const cache = this.spanningColumns?.get(column as AgColumn); if (!cache) { return undefined; } @@ -194,58 +179,57 @@ export class RowSpanService extends BeanStub<'spannedCellsUpdated'> implements N node = rowModel.getRow(rowIndex); } - if (!node) { - return undefined; - } - - return cache.getCellSpan(node); + return node && cache.getCellSpan(node); } public getCellStart(position: CellPosition): CellPosition | undefined { const span = this.getCellSpanByPosition(position); - if (!span) { - return position; - } - - return { ...position, rowIndex: span.firstNode.rowIndex! }; + return span ? { ...position, rowIndex: span.firstNode.rowIndex! } : position; } public getCellEnd(position: CellPosition): CellPosition | undefined { const span = this.getCellSpanByPosition(position); - if (!span) { - return position; - } + return span ? { ...position, rowIndex: span.getLastNode().rowIndex! } : position; + } - return { ...position, rowIndex: span.getLastNode().rowIndex! }; + /** Look up the spanned cell at `column`/`rowNode`, if any. */ + public getCellSpan(column: AgColumn, rowNode: RowNode): CellSpan | undefined { + return this.spanningColumns?.get(column)?.getCellSpan(rowNode); } - /** - * Look-up a spanned cell given a col and node as position indicators - * - * @param col a column to lookup a span at this position - * @param rowNode a node that may be spanned at this position - * @returns the CellSpan object if one exists - */ - public getCellSpan(col: AgColumn, rowNode: RowNode): CellSpan | undefined { - const cache = this.spanningColumns.get(col); - if (!cache) { - return undefined; + public forEachSpannedColumn(rowNode: RowNode, callback: (column: AgColumn, span: CellSpan) => void): void { + const spanningColumns = this.spanningColumns; + if (spanningColumns) { + for (const cache of spanningColumns.values()) { + const span = cache.getCellSpan(rowNode); + if (span) { + callback(cache.column, span); + } + } } + } - return cache.getCellSpan(rowNode); + private clearModelTimeout(): void { + const modelTimeout = this.modelTimeout; + if (modelTimeout != null) { + this.modelTimeout = null; + clearTimeout(modelTimeout); + } } - public forEachSpannedColumn(rowNode: RowNode, callback: (col: AgColumn, span: CellSpan) => void): void { - for (const [col, cache] of this.spanningColumns) { - if (cache.isCellSpanning(rowNode)) { - const spanningNode = cache.getCellSpan(rowNode)!; - callback(col, spanningNode); - } + private clearPinnedTimeout(): void { + const pinnedTimeout = this.pinnedTimeout; + if (pinnedTimeout != null) { + this.pinnedTimeout = null; + clearTimeout(pinnedTimeout); } } public override destroy(): void { super.destroy(); - this.spanningColumns.clear(); + this.active = false; + this.spanningColumns = null; + this.clearPinnedTimeout(); + this.clearModelTimeout(); } } diff --git a/packages/ag-grid-community/src/selection/selectAllFeature.ts b/packages/ag-grid-community/src/selection/selectAllFeature.ts index 27376c66b0b..aa7a76563fe 100644 --- a/packages/ag-grid-community/src/selection/selectAllFeature.ts +++ b/packages/ag-grid-community/src/selection/selectAllFeature.ts @@ -242,10 +242,7 @@ export function isCheckboxSelection({ gos, selectionColSvc }: BeanCollection, co const isAutoCol = isColumnGroupAutoCol(column); // default to displaying header checkbox in the selection column const location = _getCheckboxLocation(rowSelection); - if ( - (location === 'autoGroupColumn' && isAutoCol) || - (isSelectionCol && selectionColSvc?.isSelectionColumnEnabled()) - ) { + if ((location === 'autoGroupColumn' && isAutoCol) || (isSelectionCol && selectionColSvc?.isEnabled())) { result = _getHeaderCheckbox(rowSelection); } } diff --git a/packages/ag-grid-community/src/sort/rowNodeSorter.ts b/packages/ag-grid-community/src/sort/rowNodeSorter.ts index e20c11f07ce..d1d99f7b2f1 100644 --- a/packages/ag-grid-community/src/sort/rowNodeSorter.ts +++ b/packages/ag-grid-community/src/sort/rowNodeSorter.ts @@ -122,7 +122,7 @@ export class RowNodeSorter extends BeanStub implements NamedBean { return; } - const primaryColumn = this.beans.colModel.getColDefCol(groupLeafField); + const primaryColumn = this.beans.colModel.getNonPivotCol(groupLeafField); if (!primaryColumn) { return; } @@ -173,8 +173,8 @@ export class RowNodeSorter extends BeanStub implements NamedBean { return leafChild && this.beans.valueSvc.getValue(column, leafChild, 'data'); } - const displayCol = this.beans.showRowGroupCols?.getShowRowGroupCol(column.getId()); - return displayCol ? node.groupData?.[displayCol.getId()] : undefined; + const displayCol = column.showRowGroupCol; + return displayCol ? node.groupData?.[displayCol.colId] : undefined; } } diff --git a/packages/ag-grid-community/src/sort/sortIndicatorComp.ts b/packages/ag-grid-community/src/sort/sortIndicatorComp.ts index bcc5abb7125..e009e3ab5c7 100644 --- a/packages/ag-grid-community/src/sort/sortIndicatorComp.ts +++ b/packages/ag-grid-community/src/sort/sortIndicatorComp.ts @@ -176,7 +176,7 @@ export class SortIndicatorComp extends Component { private updateMultiSortIndicator() { const { eSortMixed, beans, column } = this; if (eSortMixed) { - const isMixedSort = beans.sortSvc!.getDisplaySortForColumn(column)?.direction === 'mixed'; + const isMixedSort = beans.sortSvc!.getDisplaySort(column)?.direction === 'mixed'; _setDisplayed(eSortMixed, isMixedSort, { skipAriaHidden: true }); } } @@ -194,13 +194,8 @@ export class SortIndicatorComp extends Component { return; } - const allColumnsWithSorting = sortSvc!.getColumnsWithSortingOrdered(); - - const indexThisCol = sortSvc!.getDisplaySortIndexForColumn(column) ?? -1; - const moreThanOneColSorting = allColumnsWithSorting.some( - (col) => sortSvc!.getDisplaySortIndexForColumn(col) ?? -1 >= 1 - ); - const showIndex = indexThisCol >= 0 && moreThanOneColSorting; + const indexThisCol = sortSvc!.getDisplaySortIndex(column) ?? -1; + const showIndex = indexThisCol >= 0 && sortSvc!.isMultiSort(); _setDisplayed(eSortOrder, showIndex, { skipAriaHidden: true }); if (indexThisCol >= 0) { diff --git a/packages/ag-grid-community/src/sort/sortService.ts b/packages/ag-grid-community/src/sort/sortService.ts index 1aa8fde04a9..bb181e88d60 100644 --- a/packages/ag-grid-community/src/sort/sortService.ts +++ b/packages/ag-grid-community/src/sort/sortService.ts @@ -1,24 +1,42 @@ -import { _getSortDefFromColDef } from '../columns/columnUtils'; +import { getSortDefFromColDef } from '../columns/columnUtils'; import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; import type { AgColumn } from '../entities/agColumn'; -import { _areSortDefsEqual, _getSortDefFromInput, _normalizeSortType } from '../entities/agColumn'; +import { _normalizeSortType, getSortDefFromInput, getSortingOrder } from '../entities/agColumn'; import type { ColumnEventType, SortChangedEvent } from '../events'; import { _isColumnsSortingCoupledToGroup } from '../gridOptionsUtils'; import type { WithoutGridCommon } from '../interfaces/iCommon'; import type { DisplaySortDef, SortDef, SortDirection } from '../interfaces/iSort'; import type { SortModelItem } from '../interfaces/iSortModelItem'; import type { SortOption } from '../interfaces/iSortOption'; -import type { Component, ComponentSelector } from '../widgets/component'; +import type { Component } from '../widgets/component'; import { SortIndicatorComp, SortIndicatorSelector } from './sortIndicatorComp'; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export class SortService extends BeanStub implements NamedBean { beanName = 'sortSvc' as const; + public readonly SortIndicatorSelector = SortIndicatorSelector; + public readonly SortIndicatorComp = SortIndicatorComp; + + private cols: AgColumn[] | null = null; + private map: Map | null = null; + private opts: SortOption[] | null = null; + private multi = false; + + public override destroy(): void { + super.destroy(); + this.invalidate(); + } + + public invalidate(): void { + this.cols = null; + this.map = null; + this.opts = null; + } + public progressSort(column: AgColumn, multiSort: boolean, source: ColumnEventType): void { - const nextDirection = this.getNextSortDirection(column); - this.setSortForColumn(column, nextDirection, multiSort, source); + this.setSortForColumn(column, this.getNextSortDirection(column), multiSort, source); } public progressSortFromEvent(column: AgColumn, event: MouseEvent | KeyboardEvent): void { @@ -30,282 +48,230 @@ export class SortService extends BeanStub implements NamedBean { public setSortForColumn(column: AgColumn, sortDef: SortDef, multiSort: boolean, source: ColumnEventType): void { const { gos, showRowGroupCols } = this.beans; - const isColumnsSortingCoupledToGroup = _isColumnsSortingCoupledToGroup(gos); - let columnsToUpdate = [column]; - if (isColumnsSortingCoupledToGroup) { - if (column.colDef.showRowGroup) { - const rowGroupColumns = showRowGroupCols?.getSourceColumnsForGroupColumn?.(column); - const sortableRowGroupColumns = rowGroupColumns?.filter((col) => col.isSortable()); - - if (sortableRowGroupColumns) { - columnsToUpdate = [column, ...sortableRowGroupColumns]; + const columnsToUpdate: AgColumn[] = [column]; + if (_isColumnsSortingCoupledToGroup(gos) && column.colDef.showRowGroup) { + const rowGroupColumns = showRowGroupCols?.getSourceColumnsForGroupColumn?.(column); + for (let i = 0, len = rowGroupColumns?.length ?? 0; i < len; ++i) { + const col = rowGroupColumns![i]; + if (col.isSortable()) { + columnsToUpdate.push(col); } } } - for (const col of columnsToUpdate) { - this.setColSort(col, sortDef, source); + for (let i = 0, len = columnsToUpdate.length; i < len; ++i) { + this.setColSort(columnsToUpdate[i], sortDef, source); } const doingMultiSort = (multiSort || gos.get('alwaysMultiSort')) && !gos.get('suppressMultiSort'); + const updatedColumns = doingMultiSort ? [] : this.clearSortBarTheseColumns(columnsToUpdate, source); - // clear sort on all columns except those changed, and update the icons - const updatedColumns: AgColumn[] = []; - if (!doingMultiSort) { - const clearedColumns = this.clearSortBarTheseColumns(columnsToUpdate, source); - updatedColumns.push(...clearedColumns); - } - - // sortIndex used for knowing order of cols when multi-col sort this.updateSortIndex(column); - - updatedColumns.push(...columnsToUpdate); + for (let i = 0, len = columnsToUpdate.length; i < len; ++i) { + updatedColumns.push(columnsToUpdate[i]); + } this.dispatchSortChangedEvents(source, updatedColumns); } private updateSortIndex(lastColToChange: AgColumn) { - const { gos, colModel, showRowGroupCols } = this.beans; + const { gos, colModel } = this.beans; const isCoupled = _isColumnsSortingCoupledToGroup(gos); - const groupParent = showRowGroupCols?.getShowRowGroupCol(lastColToChange.getId()); - const lastSortIndexCol = isCoupled ? groupParent || lastColToChange : lastColToChange; - - const allSortedCols = this.getColumnsWithSortingOrdered(); - - // reset sort index on everything - colModel.forAllCols((col) => this.setColSortIndex(col, null)); + const lastSortIndexCol = isCoupled ? lastColToChange.showRowGroupCol || lastColToChange : lastColToChange; + + // Read the old-order list before mutating sortIndex below. + const sorted = this.getSortedCols(); + + // Target index per col, dropping coupled group cols and the changed col — re-appended last so it + // takes the highest index. + const targetIndex = new Map(); + let nextIndex = 0; + for (let i = 0, len = sorted.length; i < len; ++i) { + const col = sorted[i]; + if ((isCoupled && col.colDef.showRowGroup) || col === lastSortIndexCol) { + continue; + } + targetIndex.set(col, nextIndex++); + } + if (lastSortIndexCol.getSortDef()) { + targetIndex.set(lastSortIndexCol, nextIndex); + } - const allSortedColsWithoutChangesOrGroups = allSortedCols.filter((col) => { - if (isCoupled && col.colDef.showRowGroup) { - return false; + // Apply only where changed — `setColSortIndex` fires a state event per call. + const allCols = colModel.getAllCols(); + for (let i = 0, len = allCols.length; i < len; ++i) { + const col = allCols[i]; + const target = targetIndex.get(col) ?? null; + if ((col.sortIndex ?? null) !== target) { + this.setColSortIndex(col, target); } - return col !== lastSortIndexCol; - }); - const sortedColsWithIndices = lastSortIndexCol.getSortDef() - ? [...allSortedColsWithoutChangesOrGroups, lastSortIndexCol] - : allSortedColsWithoutChangesOrGroups; - sortedColsWithIndices.forEach((col, idx) => this.setColSortIndex(col, idx)); + } } - // gets called by API, so if data changes, use can call this, which will end up - // working out the sort order again of the rows. + // Called by API when data changes out-of-band; we can't know what changed, so drop everything. public onSortChanged(source: string, columns?: AgColumn[]): void { + this.invalidate(); this.dispatchSortChangedEvents(source, columns); } - public isSortActive(): boolean { - // pull out all the columns that have sorting set - let isSorting = false; - this.beans.colModel.forAllCols((col) => { - if (col.getSortDef()) { - isSorting = true; - return true; // exit loop early - } - }); - return isSorting; - } - public dispatchSortChangedEvents(source: string, columns?: AgColumn[]): void { - const event: WithoutGridCommon = { - type: 'sortChanged', - source, - }; - - if (columns) { - event.columns = columns; - } + const event: WithoutGridCommon = { type: 'sortChanged', source, columns }; this.eventSvc.dispatchEvent(event); } private clearSortBarTheseColumns(columnsToSkip: AgColumn[], source: ColumnEventType): AgColumn[] { const clearedColumns: AgColumn[] = []; - this.beans.colModel.forAllCols((columnToClear) => { - // Do not clear if either holding shift, or if column in question was clicked - if (!columnsToSkip.includes(columnToClear)) { - // add to list of cleared cols when sort direction is set - if (columnToClear.getSortDef()) { - clearedColumns.push(columnToClear); - } - // Fresh SortDef per column: `getColumnDefs()` exposes a reference to user code. - const sortDef = _getSortDefFromInput(); - this.setColSort(columnToClear, sortDef, source); + const skip = new Set(columnsToSkip); + const allCols = this.beans.colModel.getAllCols(); + for (let i = 0, len = allCols.length; i < len; ++i) { + const col = allCols[i]; + if (skip.has(col)) { + continue; } - }); - - return clearedColumns; - } - - public getNextSortDirection(column: AgColumn, currentSort?: SortDef | SortDirection | null): SortDef { - const sortingOrder = column.getSortingOrder(); - - const currentSortDef = currentSort === undefined ? column.getSortDef() : _getSortDefFromInput(currentSort); - const currentIndex = sortingOrder.findIndex((e) => _areSortDefsEqual(e, currentSortDef)); - - let nextIndex = currentIndex + 1; - if (nextIndex >= sortingOrder.length) { - nextIndex = 0; - } - return _getSortDefFromInput(sortingOrder[nextIndex]); - } - - /** - * @returns a map of sort indexes for every sorted column, if groups sort primaries then they will have equivalent indices - */ - private getIndexedSortMap(): Map { - const { gos, colModel, showRowGroupCols, rowGroupColsSvc } = this.beans; - // pull out all the columns that have sorting set - let allSortedCols: AgColumn[] = []; - colModel.forAllCols((col) => { if (col.getSortDef()) { - allSortedCols.push(col); + clearedColumns.push(col); } - }); - - if (colModel.pivotMode) { - const isSortingLinked = _isColumnsSortingCoupledToGroup(gos); - allSortedCols = allSortedCols.filter((col) => { - const isAggregated = !!col.aggFunc; - const isSecondary = !col.primary; - const isGroup = isSortingLinked - ? showRowGroupCols?.getShowRowGroupCol(col.getId()) - : col.colDef.showRowGroup; - return isAggregated || isSecondary || isGroup; - }); + // Fresh SortDef per col: `getColumnDefs()` exposes a reference to user code. + this.setColSort(col, getSortDefFromInput(), source); } + return clearedColumns; + } - const sortedRowGroupCols = rowGroupColsSvc?.columns.filter((col) => !!col.getSortDef()) ?? []; - - // when both cols are missing sortIndex, we use the position of the col in all cols list. - // this means if colDefs only have sort, but no sortIndex, we deterministically pick which - // cols is sorted by first. - const allColsIndexes: { [id: string]: number } = {}; - allSortedCols.forEach((col, index) => (allColsIndexes[col.getId()] = index)); - - // put the columns in order of which one got sorted first - allSortedCols.sort((a, b) => { - const iA = a.getSortIndex(); - const iB = b.getSortIndex(); - if (iA != null && iB != null) { - return iA - iB; // both present, normal comparison - } else if (iA == null && iB == null) { - // both missing, compare using column positions - const posA = allColsIndexes[a.getId()]; - const posB = allColsIndexes[b.getId()]; - return posA > posB ? 1 : -1; - } else if (iB == null) { - return -1; // iB missing - } else { - return 1; // iA missing - } - }); - - const isSortLinked = _isColumnsSortingCoupledToGroup(gos) && !!sortedRowGroupCols.length; - if (isSortLinked) { - allSortedCols = [ - ...new Set( - // if linked sorting, replace all columns with the display group column for index purposes, and ensure uniqueness - allSortedCols.map((col) => showRowGroupCols?.getShowRowGroupCol(col.getId()) ?? col) - ), - ]; + public getNextSortDirection(column: AgColumn, currentSort?: SortDef | SortDirection | null): SortDef { + const sortingOrder = getSortingOrder(this.gos, column); + const len = sortingOrder.length; + if (len === 0) { + return getSortDefFromInput(); } - - const indexMap: Map = new Map(); - - allSortedCols.forEach((col, idx) => indexMap.set(col, idx)); - - // add the row group cols back - if (isSortLinked) { - for (const col of sortedRowGroupCols) { - const groupDisplayCol = showRowGroupCols!.getShowRowGroupCol(col.getId())!; - indexMap.set(col, indexMap.get(groupDisplayCol)!); + const currentSortDef = currentSort === undefined ? column.getSortDef() : getSortDefFromInput(currentSort); + let next = 0; + for (let i = 0; i < len; ++i) { + if (areSortDefsEqual(sortingOrder[i], currentSortDef)) { + next = i + 1 >= len ? 0 : i + 1; + break; } } + return getSortDefFromInput(sortingOrder[next]); + } - return indexMap; + private getSortedCols(): AgColumn[] { + return this.cols ?? this.loadSortedCols(); } - public getColumnsWithSortingOrdered(): AgColumn[] { - // pull out all the columns that have sorting set - return [...this.getIndexedSortMap().entries()].sort(([, idx1], [, idx2]) => idx1 - idx2).map(([col]) => col); + private getIndexMap(): Map { + return this.map ?? this.loadIndexMap(this.getSortedCols()); } - /** - * Util method to collect sort items by going through sorted columns once. - */ - private collectSortItems(asSortModel: boolean = false): T[] { - const sortItems: T[] = []; - const columnsWithSortingOrdered = this.getColumnsWithSortingOrdered(); - for (const column of columnsWithSortingOrdered) { - const sort = column.getSortDef()?.direction; - if (!sort) { + /** Sorted cols in display order. Pivot drops primary leaves (irrelevant to the result); coupled mode + * interleaves each display group col with its source row-group cols (shared display index). */ + private loadSortedCols(): AgColumn[] { + const { colModel, showRowGroupCols } = this.beans; + const coupled = _isColumnsSortingCoupledToGroup(this.gos); + const pivotMode = colModel.pivotMode; + const allCols = colModel.getAllCols(); + const sorted: AgColumn[] = []; + for (let i = 0, len = allCols.length; i < len; ++i) { + const col = allCols[i]; + if (!col.getSortDef()) { continue; } - const type = _normalizeSortType(column.getSortDef()?.type); - const sortItem = { sort, type } as T; - if (asSortModel) { - (sortItem as SortModelItem).colId = column.getId(); - } else { - (sortItem as SortOption).column = column; + if (pivotMode) { + const isGroup = coupled ? col.showRowGroupCol : col.colDef.showRowGroup; + if (!col.aggFunc && col.primary && !isGroup) { + continue; + } } - sortItems.push(sortItem); + sorted.push(col); + } + if (sorted.length > 1) { + sorted.sort(compareBySortIndex); } - return sortItems; - } - // used by server side row models, to send sort to server - public getSortModel(): SortModelItem[] { - return this.collectSortItems(true); + // Coupled mode interleaves each display group col with its source row-group cols (enterprise). + const result = coupled && showRowGroupCols ? showRowGroupCols.interleaveSortedColumns(sorted) : sorted; + this.cols = result; + return result; } - public getSortOptions(): SortOption[] { - return this.collectSortItems(); + /** Col -> display index. Coupled mode: source row-group cols share their display col's index (which + * counts display cols only). Sets `multi`. */ + private loadIndexMap(sortedCols: AgColumn[]): Map { + const map = new Map(); + const len = sortedCols.length; + const showRowGroupCols = this.beans.showRowGroupCols; + let idx: number; + if (_isColumnsSortingCoupledToGroup(this.gos) && showRowGroupCols) { + // Coupled mode: source cols share their display group col's index (enterprise). + idx = showRowGroupCols.fillCoupledSortIndexMap(sortedCols, map); + } else { + for (let i = 0; i < len; ++i) { + map.set(sortedCols[i], i); + } + idx = len - 1; + } + this.multi = idx >= 1; + this.map = map; + return map; } - public canColumnDisplayMixedSort(column: AgColumn): boolean { - const isColumnSortCouplingActive = _isColumnsSortingCoupledToGroup(this.gos); - const isGroupDisplayColumn = !!column.colDef.showRowGroup; - return isColumnSortCouplingActive && isGroupDisplayColumn; + public getSortOptions(): SortOption[] { + let opts = this.opts; + if (opts === null) { + opts = []; + const cols = this.getSortedCols(); + for (let i = 0, len = cols.length; i < len; ++i) { + const column = cols[i]; + const sortDef = column.getSortDef(); + const sort = sortDef?.direction; + if (sort) { + opts.push({ sort, type: _normalizeSortType(sortDef.type), column }); + } + } + this.opts = opts; + } + return opts; } - public getDisplaySortForColumn(column: AgColumn): DisplaySortDef | null { + public getDisplaySort(column: AgColumn): DisplaySortDef | null { + const colSortDef = column.getSortDef(); + // Mixed sort only on a coupled group display col — check the cheap flags before the linked-col lookup. + if (!column.colDef.showRowGroup || !_isColumnsSortingCoupledToGroup(this.gos)) { + return colSortDef; + } const linkedColumns = this.beans.showRowGroupCols?.getSourceColumnsForGroupColumn(column); - if (!this.canColumnDisplayMixedSort(column) || !linkedColumns?.length) { - return column.getSortDef(); + if (!linkedColumns?.length) { + return colSortDef; } - - // if column has unique data, its sorting is independent - but can still be mixed - const columnHasUniqueData = column.colDef.field != null || !!column.colDef.valueGetter; - const sortableColumns = columnHasUniqueData ? [column, ...linkedColumns] : linkedColumns; - - const firstSort = sortableColumns[0].getSortDef(); - // the == is intentional, as null and undefined both represent no sort, which means they are equivalent - const allMatch = sortableColumns.every((col) => _areSortDefsEqual(col.getSortDef(), firstSort)); - if (!allMatch) { - return { type: _normalizeSortType(column.getSortDef()?.type), direction: 'mixed' }; + // A group col with its own field/valueGetter sorts independently, so it joins the comparison. + const colDef = column.colDef; + const ownData = colDef.field != null || !!colDef.valueGetter; + const firstSort = ownData ? colSortDef : linkedColumns[0].getSortDef(); + let allMatch = true; + for (let i = 0, len = linkedColumns.length; allMatch && i < len; ++i) { + allMatch = areSortDefsEqual(linkedColumns[i].getSortDef(), firstSort); } - return firstSort; + return allMatch ? firstSort : { type: _normalizeSortType(colSortDef?.type), direction: 'mixed' }; + } + + public getDisplaySortIndex(column: AgColumn): number | undefined { + return this.getIndexMap().get(column); } - public getDisplaySortIndexForColumn(column: AgColumn): number | null | undefined { - return this.getIndexedSortMap().get(column); + /** `true` when the indicator should show ordinal numbers (2+ distinct display indices). */ + public isMultiSort(): boolean { + this.getIndexMap(); + return this.multi; } public setupHeader(comp: Component, column: AgColumn): void { const refreshStyles = () => { - const { type, direction } = _getSortDefFromInput(column.getSortDef()); + const { type, direction } = getSortDefFromInput(column.getSortDef()); comp.toggleCss('ag-header-cell-sorted-asc', direction === 'asc'); comp.toggleCss('ag-header-cell-sorted-desc', direction === 'desc'); comp.toggleCss('ag-header-cell-sorted-abs-asc', type === 'absolute' && direction === 'asc'); comp.toggleCss('ag-header-cell-sorted-abs-desc', type === 'absolute' && direction === 'desc'); comp.toggleCss('ag-header-cell-sorted-none', !direction); - if (column.colDef.showRowGroup) { - const sourceColumns = this.beans.showRowGroupCols?.getSourceColumnsForGroupColumn(column); - // this == is intentional, as it allows null and undefined to match, which are both unsorted states - const sortDirectionsMatch = sourceColumns?.every( - (sourceCol) => direction == sourceCol.getSortDef()?.direction - ); - const isMultiSorting = !sortDirectionsMatch; - - comp.toggleCss('ag-header-cell-sorted-mixed', isMultiSorting); + const isMixed = this.beans.showRowGroupCols?.isGroupSortMixed(column, direction) ?? true; + comp.toggleCss('ag-header-cell-sorted-mixed', isMixed); } }; @@ -319,39 +285,37 @@ export class SortService extends BeanStub implements NamedBean { public initCol(column: AgColumn): void { const { sortIndex, initialSortIndex } = column.colDef; - - const sortDef = _getSortDefFromColDef(column.colDef); + const sortDef = getSortDefFromColDef(column.colDef); if (sortDef) { column.setSortDef(sortDef); } - - if (sortIndex !== undefined) { - if (sortIndex !== null) { - column.sortIndex = sortIndex; - } - } else if (initialSortIndex !== null) { - column.sortIndex = initialSortIndex; + // sortIndex wins over initialSortIndex; null/undefined leaves it unset. + const idx = sortIndex !== undefined ? sortIndex : initialSortIndex; + if (idx != null) { + column.sortIndex = idx; } } - /** - * Update a column's sort state from a sort definition. - * If `sortDefOrDirection` is `undefined`, the call is a no-op (no change). - */ + /** Update a column's sort from a sort def; `undefined` is a no-op. */ public updateColSort( column: AgColumn, sortDefOrDirection: SortDirection | SortDef | undefined, source: ColumnEventType ): void { - if (sortDefOrDirection === undefined) { - return; + if (sortDefOrDirection !== undefined) { + this.setColSort(column, getSortDefFromInput(sortDefOrDirection), source); } - - this.setColSort(column, _getSortDefFromInput(sortDefOrDirection), source); } private setColSort(column: AgColumn, sortDef: SortDef, source: ColumnEventType): void { - if (!_areSortDefsEqual(column.getSortDef(), sortDef)) { + const prevSortDef = column.getSortDef(); + if (!areSortDefsEqual(prevSortDef, sortDef)) { + // Presence flip changes membership (drop all); direction/type-only keeps order (drop opts). + if (!!prevSortDef?.direction !== !!sortDef.direction) { + this.invalidate(); + } else { + this.opts = null; + } column.setSortDef(sortDef); column.dispatchColEvent('sortChanged', source); } @@ -360,14 +324,38 @@ export class SortService extends BeanStub implements NamedBean { public setColSortIndex(column: AgColumn, sortOrder?: number | null): void { column.sortIndex = sortOrder; + this.invalidate(); column.dispatchStateUpdatedEvent('sortIndex'); } +} + +/** Order by `sortIndex` ascending; cols without one sort last (sentinel) and, being equal, keep their + * discovery order via stable sort (ES2019+). */ +const compareBySortIndex = (a: AgColumn, b: AgColumn): number => + (a.sortIndex ?? 0x7fffffff) - (b.sortIndex ?? 0x7fffffff); - public createSortIndicator(skipTemplate?: boolean): SortIndicatorComp { - return new SortIndicatorComp(skipTemplate); +/** True when two sort defs match. A falsy/absent def is treated as unsorted (direction `null`). */ +const areSortDefsEqual = (sortDef1: SortDef | null | undefined, sortDef2: SortDef | null | undefined): boolean => { + if (!sortDef1) { + return sortDef2 ? sortDef2.direction === null : true; + } + if (!sortDef2) { + return sortDef1.direction === null; } + return sortDef1.type === sortDef2.type && sortDef1.direction === sortDef2.direction; +}; - public getSortIndicatorSelector(): ComponentSelector { - return SortIndicatorSelector; +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _getSortModel(sortSvc: SortService | undefined): SortModelItem[] { + const opts = sortSvc?.getSortOptions(); + if (!opts) { + return []; + } + const len = opts.length; + const model: SortModelItem[] = new Array(len); + for (let i = 0; i < len; ++i) { + const o = opts[i]; + model[i] = { sort: o.sort, type: o.type, colId: (o.column as AgColumn).colId }; } + return model; } diff --git a/packages/ag-grid-community/src/undoRedo/undoRedoService.ts b/packages/ag-grid-community/src/undoRedo/undoRedoService.ts index a78a1a8cea7..567a679ee72 100644 --- a/packages/ag-grid-community/src/undoRedo/undoRedoService.ts +++ b/packages/ag-grid-community/src/undoRedo/undoRedoService.ts @@ -1,6 +1,5 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; -import type { AgColumn } from '../entities/agColumn'; import { _areCellsEqual, _getRowNode, _isSameRow } from '../entities/positionUtils'; import type { BatchEditingStoppedEvent, BulkEditingStoppedEvent, CellValueChangedEvent } from '../events'; import type { GridBodyCtrl } from '../gridBodyComp/gridBodyCtrl'; @@ -260,7 +259,7 @@ export class UndoRedoService extends BeanStub implements NamedBean { const { rowIndex, columnId, rowPinned } = lastFocusedCell; const { colModel, focusSvc, rangeSvc } = this.beans; - const column: AgColumn | null = colModel.getCol(columnId); + const column = colModel.getCol(columnId); if (!column) { return; diff --git a/packages/ag-grid-community/src/utils/mergeDeep.test.ts b/packages/ag-grid-community/src/utils/mergeDeep.test.ts index 79a487e054e..731116b9e72 100644 --- a/packages/ag-grid-community/src/utils/mergeDeep.test.ts +++ b/packages/ag-grid-community/src/utils/mergeDeep.test.ts @@ -1,4 +1,68 @@ -import { _mergeDeep } from './mergeDeep'; +import { _isPlainObject, _mergeDeep, _mergedEqual } from './mergeDeep'; + +describe('_isPlainObject', () => { + test('plain object literals are plain', () => { + expect(_isPlainObject({})).toBe(true); + expect(_isPlainObject({ a: 1, b: { c: 2 } })).toBe(true); + }); + + test('null-proto records (Object.create(null)) are plain', () => { + const record = Object.create(null); + record.a = 1; + expect(_isPlainObject(record)).toBe(true); + }); + + test('objects with an explicit Object.prototype proto are plain', () => { + expect(_isPlainObject(Object.create(Object.prototype))).toBe(true); + }); + + test('null and undefined are not plain (and do not throw on getPrototypeOf)', () => { + expect(_isPlainObject(null)).toBe(false); + expect(_isPlainObject(undefined)).toBe(false); + }); + + test('primitives are not plain', () => { + expect(_isPlainObject(1)).toBe(false); + expect(_isPlainObject('x')).toBe(false); + expect(_isPlainObject(true)).toBe(false); + expect(_isPlainObject(Symbol('s'))).toBe(false); + expect(_isPlainObject(10n)).toBe(false); + }); + + test('functions are not plain', () => { + expect(_isPlainObject(() => 0)).toBe(false); + expect(_isPlainObject(function named() {})).toBe(false); + }); + + test('arrays are not plain (Array.prototype proto)', () => { + expect(_isPlainObject([])).toBe(false); + expect(_isPlainObject([1, 2, 3])).toBe(false); + }); + + test('built-in object instances are not plain', () => { + expect(_isPlainObject(new Date(0))).toBe(false); + expect(_isPlainObject(/abc/g)).toBe(false); + expect(_isPlainObject(new Map())).toBe(false); + expect(_isPlainObject(new Set())).toBe(false); + }); + + test('class instances are not plain', () => { + class Ctx { + public x = 1; + } + expect(_isPlainObject(new Ctx())).toBe(false); + }); + + test('an own `constructor: Object` key does not spoof a class instance into plain', () => { + class Ctx { + public constructor() { + // own `constructor` would fool a `value.constructor === Object` check, but not the proto check. + (this as any).constructor = Object; + } + } + expect(_isPlainObject(new Ctx())).toBe(false); + }); +}); describe('_mergeDeep', () => { test('_mergeDeep does not allow prototype pollution', () => { @@ -137,3 +201,140 @@ describe('_mergeDeep', () => { expect(dest.list).toBe(arr); }); }); + +describe('_mergedEqual', () => { + test('same ref is equal', () => { + const o = { a: 1, b: { c: 2 } }; + expect(_mergedEqual(o, o)).toBe(true); + }); + + test('null vs object is not equal', () => { + expect(_mergedEqual(null, { a: 1 })).toBe(false); + expect(_mergedEqual({ a: 1 }, null)).toBe(false); + }); + + test('array vs object is not equal', () => { + expect(_mergedEqual([1, 2], { 0: 1, 1: 2 })).toBe(false); + }); + + test('shallow value equality', () => { + expect(_mergedEqual({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true); + expect(_mergedEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + + test('different key counts are not equal (no skip)', () => { + expect(_mergedEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(_mergedEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + test('same key count but different keys are not equal (no skip)', () => { + expect(_mergedEqual({ a: 1 }, { b: 1 })).toBe(false); + }); + + test('deep value equality', () => { + expect(_mergedEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true); + expect(_mergedEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } })).toBe(false); + }); + + test('arrays compare by element identity (matches _mergeDeep semantics)', () => { + expect(_mergedEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true); + expect(_mergedEqual({ a: [1, 2] }, { a: [1, 3] })).toBe(false); + }); + + test('mutation inside a shared object referenced from an array is NOT detected', () => { + // Documenting limitation: arrays do not recurse — as deepMerge was always like this + const shared = { x: 1 }; + const a = { arr: [shared] }; + const b = { arr: [shared] }; + expect(_mergedEqual(a, b)).toBe(true); + shared.x = 2; + expect(_mergedEqual(a, b)).toBe(true); + }); + + describe('non-plain object values (symmetry with _mergeDeep reference-assign)', () => { + // `_mergeDeep` only deep-merges plain objects; it assigns non-plain objects by reference. So + // `_mergedEqual` must treat them as equal only when reference-equal, never by enumerable keys. + test('different Date instances (no enumerable keys) are not equal', () => { + expect(_mergedEqual({ d: new Date(0) }, { d: new Date(0) })).toBe(false); + }); + + test('same Date reference is equal', () => { + const d = new Date(0); + expect(_mergedEqual({ d }, { d })).toBe(true); + }); + + test('different Map instances are not equal', () => { + expect(_mergedEqual({ m: new Map([['a', 1]]) }, { m: new Map([['a', 1]]) })).toBe(false); + }); + + test('different class instances with identical fields are not equal', () => { + class Ctx { + public x = 1; + } + expect(_mergedEqual({ c: new Ctx() }, { c: new Ctx() })).toBe(false); + }); + + test('Object.create(null) records are plain — deep-compared by content (like `{}`)', () => { + const makeRecord = (a: number) => { + const r: any = Object.create(null); + r.a = a; + return r; + }; + // Null-proto records are plain data objects (the grid uses them to avoid prototype pollution), + // so they deep-compare like `{}`: equal by content, not only by reference. + expect(_mergedEqual({ r: makeRecord(1) }, { r: makeRecord(1) })).toBe(true); + expect(_mergedEqual({ r: makeRecord(1) }, { r: makeRecord(2) })).toBe(false); + // A null-proto object vs a plain `{}` with the same content are also merged-equal. + expect(_mergedEqual(makeRecord(1), { a: 1 })).toBe(true); + }); + + test('constructor cannot be spoofed: own `constructor: Object` key does not make a class instance plain', () => { + class Ctx { + public x = 1; + public constructor() { + // own `constructor` would fool a `value.constructor === Object` check, but not the proto check. + (this as any).constructor = Object; + } + } + expect(_mergedEqual({ c: new Ctx() }, { c: new Ctx() })).toBe(false); + }); + + test('plain-object nested values still deep-compare', () => { + expect(_mergedEqual({ p: { a: 1 } }, { p: { a: 1 } })).toBe(true); + expect(_mergedEqual({ p: { a: 1 } }, { p: { a: 2 } })).toBe(false); + }); + }); + + describe('topLevelSkipKey', () => { + test('skip key present in both with same other keys → equal regardless of skip value', () => { + expect(_mergedEqual({ a: 1, children: [] }, { a: 1, children: [{ x: 1 }] }, 'children')).toBe(true); + }); + + test('skip key absent in both behaves like a normal compare', () => { + expect(_mergedEqual({ a: 1 }, { a: 1 }, 'children')).toBe(true); + expect(_mergedEqual({ a: 1 }, { a: 2 }, 'children')).toBe(false); + }); + + test('skip key only in a, other keys equal → still equal', () => { + expect(_mergedEqual({ a: 1, children: [] }, { a: 1 }, 'children')).toBe(true); + }); + + test('skip key only in b, other keys equal → still equal', () => { + expect(_mergedEqual({ a: 1 }, { a: 1, children: [] }, 'children')).toBe(true); + }); + + test('extra non-skip key in b is detected even with same total counts', () => { + // a: { a:1, children:[] } → non-skip keys = {a}, count=1 + // b: { a:1, b:2 } → non-skip keys = {a, b}, count=2 + expect(_mergedEqual({ a: 1, children: [] }, { a: 1, b: 2 }, 'children')).toBe(false); + }); + + test('extra non-skip key in a is detected', () => { + expect(_mergedEqual({ a: 1, b: 2, children: [] }, { a: 1, children: [] }, 'children')).toBe(false); + }); + + test('differing non-skip nested value still compares', () => { + expect(_mergedEqual({ a: { x: 1 }, children: [] }, { a: { x: 2 }, children: [] }, 'children')).toBe(false); + }); + }); +}); diff --git a/packages/ag-grid-community/src/utils/mergeDeep.ts b/packages/ag-grid-community/src/utils/mergeDeep.ts index 8cf2db875ce..dc34374ad0a 100644 --- a/packages/ag-grid-community/src/utils/mergeDeep.ts +++ b/packages/ag-grid-community/src/utils/mergeDeep.ts @@ -1,69 +1,109 @@ -import { _exists } from 'ag-stack'; +import { _areEqual } from 'ag-stack'; -// Prevents the risk of prototype pollution -export const SKIP_JS_BUILTINS = new Set(['__proto__', 'constructor', 'prototype']); +/** Returns true for JS built-in keys that must be skipped to prevent prototype pollution. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const _isProtoPollutionKey = (key: string): boolean => + key === '__proto__' || key === 'constructor' || key === 'prototype'; -function _iterateObject( - object: { [p: string]: T } | T[] | null | undefined, - callback: (key: string, value: T) => void -) { - if (object == null) { +/** Plain-prototype test only — caller must have already ensured `value` is a non-null object. */ +const isPlainProto = (value: object): boolean => { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +/** True for plain non-null objects; full guard for `unknown` input (callers with a known object use {@link isPlainProto}). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const _isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === 'object' && isPlainProto(value); + +const setKey = (out: any, key: string | number, value: any, copyUndef: boolean, simpleObjects: boolean): void => { + let destValue: any = out[key]; + if (destValue === value) { return; } - - if (Array.isArray(object)) { - for (let i = 0; i < object.length; i++) { - callback(i.toString(), object[i]); + if (value === null || typeof value !== 'object') { + if (copyUndef || value !== undefined) { + out[key] = value; } return; } - - for (const key of Object.keys(object).filter((key) => !SKIP_JS_BUILTINS.has(key))) { - callback(key, object[key]); + if (simpleObjects && destValue == null && isPlainProto(value)) { + destValue = {}; + out[key] = destValue; + } + if (destValue !== null && typeof destValue === 'object' && !Array.isArray(destValue)) { + _mergeDeep(destValue, value, copyUndef, simpleObjects); + } else { + out[key] = value; } -} +}; /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export function _mergeDeep(dest: any, source: any, copyUndefined = true, makeCopyOfSimpleObjects = false): void { - if (!_exists(source)) { +export const _mergeDeep = (dest: any, source: any, copyUndefined = true, makeCopyOfSimpleObjects = false): void => { + if (source == null || source === '') { return; } - - _iterateObject(source, (key: string, sourceValue: any) => { - let destValue: any = dest[key]; - - if (destValue === sourceValue) { - return; + if (Array.isArray(source)) { + for (let i = 0, len = source.length; i < len; ++i) { + setKey(dest, i, source[i], copyUndefined, makeCopyOfSimpleObjects); } + return; + } + for (const key of Object.keys(source)) { + if (!_isProtoPollutionKey(key)) { + setKey(dest, key, source[key], copyUndefined, makeCopyOfSimpleObjects); + } + } +}; - // when creating params, we don't want to just copy objects over. otherwise merging ColDefs (eg DefaultColDef - // and Column Types) would result in params getting shared between objects. - // by putting an empty value into destValue first, it means we end up copying over values from - // the source object, rather than just copying in the source object in it's entirety. - if (makeCopyOfSimpleObjects) { - const objectIsDueToBeCopied = destValue == null && sourceValue != null; - - if (objectIsDueToBeCopied) { - // 'simple object' means a bunch of key/value pairs, eg {filter: 'myFilter'}, as opposed - // to a Class instance (such as api instance). - const doNotCopyAsSourceIsSimpleObject = - typeof sourceValue === 'object' && sourceValue.constructor === Object; +/** Inverse of `_mergeDeep`. Note: like mergeDeep it does not recurse into arrays. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export const _mergedEqual = (a: any, b: any, topLevelSkipKey?: string): boolean => { + if (a === b) { + return true; + } + if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') { + return false; + } + const aIsArr = Array.isArray(a); + if (aIsArr !== Array.isArray(b)) { + return false; + } + if (aIsArr) { + return _areEqual(a, b); // `_mergeDeep` doesn't recurse into arrays + } + if (!isPlainProto(a) || !isPlainProto(b)) { + return false; // `_mergeDeep` only merges plain objects (a/b already non-null, non-array objects here) + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + const aLen = aKeys.length; + const bLen = bKeys.length; - if (doNotCopyAsSourceIsSimpleObject) { - destValue = {}; - dest[key] = destValue; - } + if (topLevelSkipKey === undefined) { + if (aLen !== bLen) { + return false; + } + for (let i = 0; i < aLen; ++i) { + const k = aKeys[i]; + if (!(k in b) || !_mergedEqual(a[k], b[k])) { + return false; } } + return true; + } - if (_isNonNullObject(sourceValue) && _isNonNullObject(destValue) && !Array.isArray(destValue)) { - _mergeDeep(destValue, sourceValue, copyUndefined, makeCopyOfSimpleObjects); - } else if (copyUndefined || sourceValue !== undefined) { - dest[key] = sourceValue; + let aSkip = 0; + for (let i = 0; i < aLen; ++i) { + const k = aKeys[i]; + if (aSkip === 0 && k === topLevelSkipKey) { + aSkip = 1; + continue; } - }); -} - -function _isNonNullObject(value: any): value is object { - return typeof value === 'object' && value !== null; -} + if (!(k in b) || !_mergedEqual(a[k], b[k])) { + return false; + } + } + const bSkip = topLevelSkipKey in b ? 1 : 0; + return aLen - aSkip === bLen - bSkip; +}; diff --git a/packages/ag-grid-community/src/validation/rules/colDefValidations.ts b/packages/ag-grid-community/src/validation/rules/colDefValidations.ts index 9a05bd6fb9a..94ab2b9b771 100644 --- a/packages/ag-grid-community/src/validation/rules/colDefValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/colDefValidations.ts @@ -1,5 +1,5 @@ import type { UserComponentName } from '../../context/context'; -import { _isSortDefValid, _isSortDirectionValid } from '../../entities/agColumn'; +import { _isSortDefValid, isSortDirectionValid } from '../../entities/agColumn'; import type { AbstractColDef, ColDef, ColGroupDef, ColumnMenuTab } from '../../entities/colDef'; import { _errMsg, toStringWithNullUndefined } from '../logging'; import type { Deprecations, ModuleValidation, OptionsValidator, Validations } from '../validationTypes'; @@ -209,7 +209,7 @@ const COLUMN_DEFINITION_VALIDATIONS: () => Validations = ( }, sort: { validate: (_options) => { - if (_isSortDefValid(_options.sort) || _isSortDirectionValid(_options.sort)) { + if (_isSortDefValid(_options.sort) || isSortDirectionValid(_options.sort)) { return null; } @@ -218,7 +218,7 @@ const COLUMN_DEFINITION_VALIDATIONS: () => Validations = ( }, initialSort: { validate: (_options) => { - if (_isSortDefValid(_options.initialSort) || _isSortDirectionValid(_options.initialSort)) { + if (_isSortDefValid(_options.initialSort) || isSortDirectionValid(_options.initialSort)) { return null; } @@ -231,7 +231,7 @@ const COLUMN_DEFINITION_VALIDATIONS: () => Validations = ( if (Array.isArray(sortingOrder) && sortingOrder.length > 0) { const invalidItems = sortingOrder.filter((a) => { - return !(_isSortDefValid(a) || _isSortDirectionValid(a)); + return !(_isSortDefValid(a) || isSortDirectionValid(a)); }); if (invalidItems.length > 0) { return `sortingOrder must be an array of type non-null (SortDirection | SortDef)[], incorrect items are: [${invalidItems diff --git a/packages/ag-grid-community/src/validation/rules/gridOptionsValidations.ts b/packages/ag-grid-community/src/validation/rules/gridOptionsValidations.ts index 1ba10c8fee3..b154c344831 100644 --- a/packages/ag-grid-community/src/validation/rules/gridOptionsValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/gridOptionsValidations.ts @@ -1,4 +1,4 @@ -import { _getSortDefFromInput } from '../../entities/agColumn'; +import { getSortDefFromInput } from '../../entities/agColumn'; import type { DomLayoutType, GridOptions } from '../../entities/gridOptions'; import { _BOOLEAN_GRID_OPTIONS, _GET_ALL_GRID_OPTIONS, _NUMBER_GRID_OPTIONS } from '../../propertyKeys'; import { _PUBLIC_EVENT_HANDLERS_MAP } from '../../publicEventHandlersMap'; @@ -525,7 +525,7 @@ const GRID_OPTION_VALIDATIONS: () => Validations = () => { const sortingOrder = _options.sortingOrder; if (Array.isArray(sortingOrder) && sortingOrder.length > 0) { - const invalidItems = sortingOrder.filter((a) => !_getSortDefFromInput(a)); + const invalidItems = sortingOrder.filter((a) => !getSortDefFromInput(a)); if (invalidItems.length > 0) { return `sortingOrder must be an array of type (SortDirection | SortDef)[], incorrect items are: ${invalidItems.map( (item) => diff --git a/packages/ag-grid-community/src/valueService/cellApi.ts b/packages/ag-grid-community/src/valueService/cellApi.ts index 940fd57722d..cc0d0430055 100644 --- a/packages/ag-grid-community/src/valueService/cellApi.ts +++ b/packages/ag-grid-community/src/valueService/cellApi.ts @@ -28,7 +28,7 @@ export function expireValueCache(beans: BeanCollection): void { export function getCellValue(beans: BeanCollection, params: GetCellValueParams): any { const { colKey, rowNode, useFormatter, from = 'edit' } = params; - const column = beans.colModel.getColDefColOrCol(colKey); + const column = beans.colModel.getCol(colKey); if (!column) { return null; } diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index 94c58653530..4cf131ccc7a 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -18,11 +18,14 @@ import type { import type { RowNode } from '../entities/rowNode'; import type { CellValueChangedEvent } from '../events'; import { _addGridCommonParams, _isServerSideRowModel } from '../gridOptionsUtils'; -import type { IFormulaDataService } from '../interfaces/formulas'; -import type { IColsService } from '../interfaces/iColsService'; +import type { IFormulaDataService, IFormulaService } from '../interfaces/formulas'; import type { CellValueResolveFrom } from '../interfaces/iEditService'; +import type { IFrameworkOverrides } from '../interfaces/iFrameworkOverrides'; +import type { IRowGroupingEditValueSvc } from '../interfaces/iRowGroupingEditValueSvc'; import type { IRowNode } from '../interfaces/iRowNode'; +import type { IShowRowGroupColsValueService } from '../interfaces/iShowRowGroupColsValueService'; import { _warn } from '../validation/logging'; +import type { ChangeDetectionService } from './changeDetectionService'; import type { ExpressionService } from './expressionService'; import type { ValueCache } from './valueCache'; @@ -52,11 +55,15 @@ export class ValueService extends BeanStub implements NamedBean { // exists in the same shape from construction time. private editSvc: EditService | undefined = undefined; private valueCache: ValueCache | undefined = undefined; - private rowGroupColsSvc: IColsService | undefined = undefined; private colModel!: ColumnModel; private expressionSvc: ExpressionService | undefined = undefined; private dataTypeSvc: DataTypeService | undefined = undefined; + private formula: IFormulaService | undefined = undefined; private formulaDataSvc: IFormulaDataService | undefined = undefined; + private changeDetectionSvc: ChangeDetectionService | undefined = undefined; + private showRowGroupColValueSvc: IShowRowGroupColsValueService | undefined = undefined; + private rowGroupingEditValueSvc: IRowGroupingEditValueSvc | undefined = undefined; + private frameworkOverrides: IFrameworkOverrides; public wireBeans(beans: BeanCollection): void { this.expressionSvc = beans.expressionSvc; @@ -65,7 +72,11 @@ export class ValueService extends BeanStub implements NamedBean { this.dataTypeSvc = beans.dataTypeSvc; this.editSvc = beans.editSvc; this.formulaDataSvc = beans.formulaDataSvc; - this.rowGroupColsSvc = beans.rowGroupColsSvc; + this.formula = beans.formula; + this.changeDetectionSvc = beans.changeDetectionSvc; + this.showRowGroupColValueSvc = beans.showRowGroupColValueSvc; + this.rowGroupingEditValueSvc = beans.rowGroupingEditValueSvc; + this.frameworkOverrides = beans.frameworkOverrides; this.init(); } @@ -114,10 +125,9 @@ export class ValueService extends BeanStub implements NamedBean { value: any; valueFormatted: string | null; } { - const beans = this.beans; const column = params.column; const node = params.node; - const showRowGroupColValueSvc = beans.showRowGroupColValueSvc; + const showRowGroupColValueSvc = this.showRowGroupColValueSvc; const isFullWidthGroup = !column && node.group; // Tree data auto col acts as a traditional column, with the exception of footers, so only process footers with @@ -153,7 +163,7 @@ export class ValueService extends BeanStub implements NamedBean { let value = this.getValue(column, node, params.from, this.displayIgnoresAggData(node)); let valueToFormat = value; - const formula = beans.formula; + const formula = this.formula; const colDef = column.colDef; if (colDef.allowFormula && formula?.isFormula(value)) { if (params.useRawFormula) { @@ -196,17 +206,19 @@ export class ValueService extends BeanStub implements NamedBean { } } - const editSvc = this.editSvc; - if (editSvc && from !== 'data') { - // Check for edit/pending values if not requesting committed data - const pending = editSvc.getPendingEditValue(rowNode, column, from); - if (pending !== undefined) { - return pending; + // 'data' (grouping/sort/agg hot path) never has pending edits — skip the editSvc read entirely. + if (from !== 'data') { + const editSvc = this.editSvc; + if (editSvc) { + const pending = editSvc.getPendingEditValue(rowNode, column, from); + if (pending !== undefined) { + return pending; + } } } let result = column.isCalculatedCol - ? this.beans.formula?.resolveValue(column, rowNode as RowNode) + ? this.formula?.resolveValue(column, rowNode as RowNode) : this.resolveValueWithoutCalculatedColumns(column, rowNode, ignoreAggData, isGroup); if (result === undefined) { @@ -217,8 +229,8 @@ export class ValueService extends BeanStub implements NamedBean { if (isGroup) { const rowGroupColId = colDef.showRowGroup; if (typeof rowGroupColId === 'string') { - const colRowGroupIndex = this.rowGroupColsSvc?.getColumnIndex(rowGroupColId); - if (colRowGroupIndex != null && colRowGroupIndex > rowNode.level) { + const col = this.colModel.colsById[rowGroupColId]; + if (col && col.rowGroupActive && col.rowGroupActiveIndex > rowNode.level) { return null; } } @@ -266,11 +278,10 @@ export class ValueService extends BeanStub implements NamedBean { isGroup: boolean | undefined ): any { const colDef = column.colDef; - const colId = column.colId; // Skipped for group rows — formulas + row grouping are not supported together. if (!isGroup && colDef.allowFormula) { - const formula = this.beans.formula?.getDataSourceFormula(rowNode as RowNode, column); + const formula = this.formula?.getDataSourceFormula(rowNode as RowNode, column); if (formula !== undefined) { return formula; } @@ -280,12 +291,19 @@ export class ValueService extends BeanStub implements NamedBean { const aggData = isGroup && !ignoreAggData ? rowNode.aggData : undefined; const data = rowNode.data; + const colId = column.colId; if (this.isTreeData) { - if (aggData?.[colId] !== undefined) { - return aggData[colId]; + const aggDataValue = aggData?.[colId]; + if (aggDataValue !== undefined) { + return aggDataValue; + } + const field = colDef.field; + let treeValue; + if (colDef.valueGetter) { + treeValue = this.executeValueGetter(colDef.valueGetter, data, column, rowNode); + } else if (data && field) { + treeValue = column.fieldContainsDots ? _getValueUsingDotField(data, field) : data[field]; } - - const treeValue = this.readByValueGetterOrField(column, rowNode, data); if (treeValue !== undefined) { return treeValue; } @@ -295,27 +313,14 @@ export class ValueService extends BeanStub implements NamedBean { if (groupData && colId in groupData) { return groupData[colId]; } - if (aggData?.[colId] !== undefined) { - return aggData[colId]; + const aggDataValue = aggData?.[colId]; + if (aggDataValue !== undefined) { + return aggDataValue; } return this.readUserValueForCell(column, rowNode, data, ignoreAggData, isGroup); } - private readByValueGetterOrField(column: AgColumn, rowNode: IRowNode, data: any): any { - const { valueGetter, field } = column.colDef; - - if (valueGetter) { - return this.executeValueGetter(valueGetter, data, column, rowNode); - } - - if (field && data) { - return column.fieldContainsDots ? _getValueUsingDotField(data, field) : data[field]; - } - - return undefined; - } - private readUserValueForCell( column: AgColumn, rowNode: IRowNode, @@ -335,9 +340,12 @@ export class ValueService extends BeanStub implements NamedBean { return this.executeValueGetter(colDef.valueGetter, data, column, rowNode); } - const ssrmFooterValue = this.readSsrmFooterGroupValue(column, rowNode, data, rowGroupColId); - if (ssrmFooterValue !== undefined) { - return ssrmFooterValue; + // SSRM-only footer values — skip the call entirely on client-side grids. + if (this.isSsrm) { + const ssrmFooterValue = this.readSsrmFooterGroupValue(column, rowNode, data, rowGroupColId); + if (ssrmFooterValue !== undefined) { + return ssrmFooterValue; + } } const field = colDef.field; @@ -378,7 +386,7 @@ export class ValueService extends BeanStub implements NamedBean { const colDef = column.colDef; // we do not allow parsing of formulas - if (colDef.allowFormula && this.beans.formula?.isFormula(newValue)) { + if (colDef.allowFormula && this.formula?.isFormula(newValue)) { return newValue as TValue; } @@ -528,13 +536,13 @@ export class ValueService extends BeanStub implements NamedBean { // - For leaf rows the single cellValueChanged is accumulated and flushed once at endDeferred. // - Nested callers (clipboard, fill handle) just increment/decrement the same counter; the // outermost endDeferred() performs the single aggregation + refresh pass. - const changeDetectionSvc = this.beans.changeDetectionSvc; + const changeDetectionSvc = this.changeDetectionSvc; changeDetectionSvc?.beginDeferred(); try { // Delegate groupRowValueSetter handling to the enterprise service. // Returns undefined if no groupRowValueSetter is configured. if (rowNode.group) { - const groupResult = this.beans.rowGroupingEditValueSvc?.setGroupDataValue( + const groupResult = this.rowGroupingEditValueSvc?.setGroupDataValue( rowNode as RowNode, column, newValue, @@ -618,7 +626,7 @@ export class ValueService extends BeanStub implements NamedBean { return false; } - const formulaSvc = this.beans.formula; + const formulaSvc = this.formula; const isFormulaValue = column.colDef.allowFormula && formulaSvc?.isFormula(newValue); const hasExternalFormulaData = !!this.formulaDataSvc?.hasDataSource(); @@ -648,7 +656,7 @@ export class ValueService extends BeanStub implements NamedBean { eventSource?: string; }): boolean | null { const { column, rowNode, newValue, eventSource, setterParams } = args; - const formulaSvc = this.beans.formula; + const formulaSvc = this.formula; const formulaDataSvc = this.formulaDataSvc; if (!column.colDef.allowFormula || !formulaDataSvc?.hasDataSource()) { return null; @@ -825,7 +833,7 @@ export class ValueService extends BeanStub implements NamedBean { } private getValueCallback(node: IRowNode, field: string): any { - const otherColumn = this.colModel.getColDefColOrCol(field); + const otherColumn = this.colModel.getCol(field); return otherColumn ? this.getValue(otherColumn, node, 'data') : null; } diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts index 3e964bd2e9d..cba290b1584 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts @@ -45,7 +45,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe number: (model) => _toStringOrNull(model.filter) ?? '', bigint: (model) => _toStringOrNull(model.filter) ?? '', date: (model) => { - const column = this.colModel.getColDefCol(model.colId); + const column = this.colModel.getNonPivotCol(model.colId); if (!column) { return null; } @@ -57,7 +57,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe }, dateTime: (model) => this.filterOperandGetters.date(model), dateString: (model) => { - const column = this.colModel.getColDefCol(model.colId); + const column = this.colModel.getNonPivotCol(model.colId); if (!column) { return null; } @@ -108,11 +108,12 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe this.dataTypeSvc = beans.dataTypeSvc; } - private columnNameToIdMap: { [columnNameUpperCase: string]: { colId: string; columnName: string } } = {}; + private columnNameToIdMap: { [columnNameUpperCase: string]: { colId: string; columnName: string } } = + Object.create(null); private columnAutocompleteEntries: AutocompleteEntry[] | null = null; private expressionOperators: FilterExpressionOperators; private expressionJoinOperators: { AND: string; OR: string }; - private expressionEvaluatorParams: { [colId: string]: FilterExpressionEvaluatorParams } = {}; + private expressionEvaluatorParams: { [colId: string]: FilterExpressionEvaluatorParams } = Object.create(null); public postConstruct(): void { this.expressionJoinOperators = this.generateExpressionJoinOperators(); @@ -210,7 +211,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe if (this.columnAutocompleteEntries) { return this.columnAutocompleteEntries; } - const columns = this.colModel.getColDefCols() ?? []; + const columns = this.colModel.colDefList; const entries: AutocompleteEntry[] = []; const includeHiddenColumns = this.gos.get('includeHiddenColumnsInAdvancedFilter'); for (const column of columns) { @@ -294,7 +295,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe return params; } - const column = this.colModel.getColDefCol(colId); + const column = this.colModel.getNonPivotColById(colId); if (!column) { return { valueConverter: (v: any) => v }; } @@ -344,7 +345,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe } public getColumnDetails(colId: string): { column?: AgColumn; baseCellDataType: BaseCellDataType } { - const column = this.colModel.getColDefCol(colId) ?? undefined; + const column = this.colModel.getNonPivotColById(colId); const baseCellDataType = (column ? this.dataTypeSvc?.getBaseDataType(column) : undefined) ?? 'text'; return { column, baseCellDataType }; } @@ -392,7 +393,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe public resetColumnCaches(): void { this.columnAutocompleteEntries = null; - this.columnNameToIdMap = {}; - this.expressionEvaluatorParams = {}; + this.columnNameToIdMap = Object.create(null); + this.expressionEvaluatorParams = Object.create(null); } } diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts index 13b8eec8e51..3facc2b751d 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterService.ts @@ -62,7 +62,7 @@ export class AdvancedFilterService extends BeanStub implements NamedBean, IAdvan this.expressionProxy = { getValue: (colId, node) => { - const column = this.colModel.getColDefCol(colId); + const column = this.colModel.getNonPivotColById(colId); return column ? this.filterValueSvc.getValue(column, node) : undefined; }, }; diff --git a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts index e5182a6fb0c..922b66fb54b 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts @@ -93,7 +93,7 @@ class ColumnParser implements Parser { this.colId = colValue.colId; checkAndUpdateExpression(this.params, this.colName, colValue.columnName, endPosition - 1); this.colName = colValue.columnName; - this.column = this.params.colModel.getColDefCol(this.colId); + this.column = this.params.colModel.getNonPivotCol(this.colId); if (this.column) { this.baseCellDataType = this.params.dataTypeSvc?.getBaseDataType(this.column) ?? 'text'; return true; diff --git a/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts b/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts index d2a87eee75d..63b76cf560d 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggColumnNameService.ts @@ -1,6 +1,6 @@ import { _exists } from 'ag-stack'; -import type { AgColumn, IAggColumnNameService, IAggFunc, NamedBean } from 'ag-grid-community'; +import type { AgColumn, ColAggFunc, IAggColumnNameService, NamedBean } from 'ag-grid-community'; import { BeanStub } from 'ag-grid-community'; export class AggColumnNameService extends BeanStub implements NamedBean, IAggColumnNameService { @@ -16,7 +16,7 @@ export class AggColumnNameService extends BeanStub implements NamedBean, IAggCol // only columns with aggregation active can have aggregations const pivotValueColumn = column.colDef.pivotValueColumn; const pivotActiveOnThisColumn = _exists(pivotValueColumn); - let aggFunc: string | IAggFunc | null | undefined = null; + let aggFunc: ColAggFunc = null; let aggFuncFound: boolean; // otherwise we have a measure that is active, and we are doing aggregation on it diff --git a/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts b/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts index 5a1a6812477..d6dd39517a3 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts @@ -66,7 +66,7 @@ const fireAggDataChangedEvents = ( const oldKeys = Object.keys(oldAggData); for (let i = 0, len = oldKeys.length; i < len; ++i) { const colId = oldKeys[i]; - const column = colModel.getColById(colId); + const column = colModel.colsById[colId]; if (column) { rowNode.dispatchCellChangedEvent(column, undefined, oldAggData[colId]); } @@ -82,7 +82,7 @@ const fireAggDataChangedEvents = ( if (value === oldValue) { continue; } - const column = colModel.getColById(colId); + const column = colModel.colsById[colId]; if (column) { rowNode.dispatchCellChangedEvent(column, value, oldValue); } @@ -98,7 +98,7 @@ const fireAggDataChangedEvents = ( if (colId in newAggData) { continue; } - const column = colModel.getColById(colId); + const column = colModel.colsById[colId]; if (column) { rowNode.dispatchCellChangedEvent(column, undefined, oldAggData[colId]); } diff --git a/packages/ag-grid-enterprise/src/aggregation/aggregationApi.ts b/packages/ag-grid-enterprise/src/aggregation/aggregationApi.ts index 43b82f23790..8f58bec423f 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggregationApi.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggregationApi.ts @@ -1,4 +1,4 @@ -import type { BeanCollection, ColKey, IAggFunc } from 'ag-grid-community'; +import type { BeanCollection, ColAggFunc, ColKey, IAggFunc } from 'ag-grid-community'; import type { ValueColsSvc } from './valueColsSvc'; @@ -14,10 +14,6 @@ export function clearAggFuncs(beans: BeanCollection): void { } } -export function setColumnAggFunc( - beans: BeanCollection, - key: ColKey, - aggFunc: string | IAggFunc | null | undefined -): void { +export function setColumnAggFunc(beans: BeanCollection, key: ColKey, aggFunc: ColAggFunc): void { (beans.valueColsSvc as ValueColsSvc)?.setColumnAggFunc?.(key, aggFunc, 'api'); } diff --git a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts index c9a7a5f7da0..6d53a6d20d6 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts @@ -3,6 +3,7 @@ import type { ChangedCellsPath, ChangedPath, ClientSideRowModelStage, + ColAggFunc, ColDef, ColumnModel, GridOptions, @@ -339,20 +340,16 @@ const aggregateValuesAndPivot = ( }; /** Resolves aggFunc from a string name or returns the function directly. Returns null with a warning for invalid names. */ -const resolveAggFunc = ( - aggFuncOrString: string | IAggFunc | null | undefined, - aggFuncSvc: IAggFuncService, - column: AgColumn -): IAggFunc | null => { - if (typeof aggFuncOrString === 'function') { - return aggFuncOrString; +const resolveAggFunc = (colAggFunc: ColAggFunc, aggFuncSvc: IAggFuncService, column: AgColumn): IAggFunc | null => { + if (typeof colAggFunc === 'function') { + return colAggFunc; } - if (aggFuncOrString == null) { + if (colAggFunc == null) { return null; } - const aggFunc = aggFuncSvc.getAggFunc(aggFuncOrString); + const aggFunc = aggFuncSvc.getAggFunc(colAggFunc); if (typeof aggFunc !== 'function') { - _warn(109, { inputValue: aggFuncOrString.toString(), allSuggestions: aggFuncSvc.getFuncNames(column) }); + _warn(109, { inputValue: colAggFunc.toString(), allSuggestions: aggFuncSvc.getFuncNames(column) }); return null; } return aggFunc; diff --git a/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts b/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts index eeb033f8ba4..66244803aad 100644 --- a/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts +++ b/packages/ag-grid-enterprise/src/aggregation/valueColsSvc.ts @@ -1,150 +1,118 @@ -import { _exists, _removeFromArray } from 'ag-stack'; - import type { AgColumn, - ColDef, + BeanCollection, + ColAggFunc, ColKey, ColumnEventType, + ColumnState, ColumnStateParams, - IAggFunc, - IColsService, + IAggFuncService, + IValueColsService, NamedBean, } from 'ag-grid-community'; -import { BaseColsService, _warn } from 'ag-grid-community'; +import { _warn } from 'ag-grid-community'; -export class ValueColsSvc extends BaseColsService implements NamedBean, IColsService { - beanName = 'valueColsSvc' as const; - eventName = 'columnValueChanged' as const; +import { BaseColsService } from '../columns/baseColsService'; - override columnProcessors = { - set: (column: AgColumn, added: boolean, source: ColumnEventType) => this.setValueActive(added, column, source), - add: (column: AgColumn, added: boolean, source: ColumnEventType) => this.setValueActive(true, column, source), - remove: (column: AgColumn, added: boolean, source: ColumnEventType) => - this.setValueActive(false, column, source), - } as const; +export class ValueColsSvc extends BaseColsService implements NamedBean, IValueColsService { + beanName = 'valueColsSvc' as const; + protected override eventName = 'columnValueChanged' as const; + private aggFuncSvc?: IAggFuncService; - override columnExtractors = { - setFlagFunc: (col: AgColumn, flag: boolean, source: ColumnEventType) => - this.setColValueActive(col, flag, source), - getIndexFunc: () => undefined, - getInitialIndexFunc: () => undefined, - getValueFunc: (colDef: ColDef) => { - const aggFunc = colDef.aggFunc; - // null or empty string means clear - if (aggFunc === null || aggFunc === '') { - return null; - } - if (aggFunc === undefined) { - return; - } + public override wireBeans(beans: BeanCollection): void { + super.wireBeans(beans); + this.aggFuncSvc = beans.aggFuncSvc; + } - return !!aggFunc; - }, - getInitialValueFunc: (colDef: ColDef) => { - // return false if any of the following: null, undefined, empty string - return colDef.initialAggFunc != null && colDef.initialAggFunc != ''; - }, - } as const; + /** Value cols are included from a truthy aggFunc (never indexed); `undefined` falls back to `initialAggFunc` + * (new cols) or the current flag (existing). */ + public override extractCol(col: AgColumn, colIsNew: boolean): void { + const colDef = col.colDef; + const aggFunc = colDef.aggFunc; + let include: boolean; + if (aggFunc !== undefined) { + include = aggFunc !== null && aggFunc !== ''; + } else if (colIsNew) { + const initial = colDef.initialAggFunc; + include = initial != null && initial !== ''; + } else { + // At extract time the flag still mirrors the prior active state — read it directly. + include = col.aggregationActive; + } + if (!include) { + return; + } + this.extractAddColWithValue(col); + if (aggFunc != null && aggFunc !== '') { + this.writeAggFunc(col, aggFunc); + } else if (!col.aggFunc) { + this.writeAggFunc(col, colDef.initialAggFunc); + } + } - private readonly modifyColumnsNoEventsCallbacks = { - addCol: (column: AgColumn) => this.columns.push(column), - removeCol: (column: AgColumn) => _removeFromArray(this.columns, column), - }; + // Imperative-only (the base gates on `runSideEffects`); the state/agg-func paths set the func explicitly. + protected override onColActiveChanged(column: AgColumn, active: boolean): void { + // A newly-active col with no agg-func picks up the default for its cell-data type. + const aggFuncSvc = this.aggFuncSvc; + if (active && !column.getAggFunc() && aggFuncSvc) { + this.writeAggFunc(column, aggFuncSvc.getDefaultAggFunc(column)); + } + } - public override extractCols(source: ColumnEventType, oldProvidedCols: AgColumn[] | undefined): AgColumn[] { - this.columns = super.extractCols(source, oldProvidedCols); + protected override writeColActive(col: AgColumn, active: boolean, source: ColumnEventType): boolean { + if (col.aggregationActive === active) { + return false; + } + col.aggregationActive = active; + col.dispatchColEvent(this.eventName, source); + return true; + } - // all new columns added will have aggFunc missing, so set it to what is in the colDef - for (const col of this.columns) { - const colDef = col.colDef; - // if aggFunc provided, we always override, as reactive property - if (colDef.aggFunc != null && colDef.aggFunc != '') { - this.setColAggFunc(col, colDef.aggFunc); - } - // otherwise we use initialAggFunc only if no agg func set - which happens when new column only - else if (!col.getAggFunc()) { - this.setColAggFunc(col, colDef.initialAggFunc); + public setColumnAggFunc(key: ColKey | undefined, aggFunc: ColAggFunc, source: ColumnEventType): void { + if (key) { + const column = this.colModel.getNonPivotCol(key); + if (column && this.applyAggFunc(column, aggFunc, source)) { + // aggFunc/activation only — stage + flush without a refresh; re-aggregation is event-driven. + this.stageColChange([column]); + this.colModel.flushColChanges(source, false); } } - - return this.columns; } - public setColumnAggFunc( - key: ColKey | undefined, - aggFunc: string | IAggFunc | null | undefined, + public override syncColState( + column: AgColumn, + stateItem: ColumnState | null, + defaultState: ColumnStateParams | undefined, source: ColumnEventType ): void { - if (!key) { + // Fall back to the default only when the state value is `undefined` (not `null`). + const stateAggFunc = stateItem?.aggFunc; + const aggFunc = stateAggFunc !== undefined ? stateAggFunc : defaultState?.aggFunc; + if (aggFunc === undefined) { return; } - - const column = this.colModel.getColDefCol(key); - if (!column) { + if (typeof aggFunc !== 'string' && aggFunc != null) { + _warn(33); // stateItem.aggFunc must be a string — invalid (object / function) values. return; } - - this.setColAggFunc(column, aggFunc); - - this.dispatchColumnChangedEvent(this.eventSvc, this.eventName, [column], source); + this.applyAggFunc(column, aggFunc, source); } - public override syncColumnWithState( - column: AgColumn, - source: ColumnEventType, - getValue: ( - key1: U, - key2?: S - ) => { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined } - ): void { - // noop - const aggFunc = getValue('aggFunc').value1; - if (aggFunc !== undefined) { - if (typeof aggFunc === 'string') { - this.setColAggFunc(column, aggFunc); - if (!column.isValueActive()) { - this.setColValueActive(column, true, source); - this.modifyColumnsNoEventsCallbacks.addCol(column); - } - } else { - if (_exists(aggFunc)) { - // stateItem.aggFunc must be a string - _warn(33); - } - // Note: we do not call column.setAggFunc(null), so that next time we aggregate - // by this column (eg drag the column to the agg section int he toolpanel) it will - // default to the last aggregation function. - - if (column.isValueActive()) { - this.setColValueActive(column, false, source); - this.modifyColumnsNoEventsCallbacks.removeCol(column); - } - } + private applyAggFunc(column: AgColumn, aggFunc: ColAggFunc, source: ColumnEventType): boolean { + if (aggFunc != null && aggFunc !== '') { + const aggFuncChanged = this.writeAggFunc(column, aggFunc); + const activeChanged = this.setColActive(column, true, source); + return aggFuncChanged || activeChanged; } + return this.setColActive(column, false, source); } - private setValueActive(active: boolean, column: AgColumn, source: ColumnEventType): void { - if (active === column.isValueActive()) { - return; + private writeAggFunc(column: AgColumn, aggFunc: ColAggFunc): boolean { + if (column.aggFunc === aggFunc) { + return false; } - - this.setColValueActive(column, active, source); - - if (active && !column.getAggFunc() && this.aggFuncSvc) { - const initialAggFunc = this.aggFuncSvc.getDefaultAggFunc(column); - this.setColAggFunc(column, initialAggFunc); - } - } - - private setColAggFunc(column: AgColumn, aggFunc: string | IAggFunc | null | undefined): void { column.aggFunc = aggFunc; column.dispatchStateUpdatedEvent('aggFunc'); - } - - private setColValueActive(column: AgColumn, value: boolean, source: ColumnEventType): void { - if (column.aggregationActive !== value) { - column.aggregationActive = value; - column.dispatchColEvent('columnValueChanged', source); - } + return true; } } diff --git a/packages/ag-grid-enterprise/src/aiToolkit/structuredSchema.ts b/packages/ag-grid-enterprise/src/aiToolkit/structuredSchema.ts index 903e4ba1e14..33ea39160fd 100644 --- a/packages/ag-grid-enterprise/src/aiToolkit/structuredSchema.ts +++ b/packages/ag-grid-enterprise/src/aiToolkit/structuredSchema.ts @@ -26,38 +26,32 @@ const StructuredSchemaBuilderMap: Record< } as const; export function getStructuredSchema(beans: BeanCollection, params?: StructuredSchemaParams): JSONSchema | undefined { - const allColumnIds = beans.colModel.getCols().map((col) => col.colId); - const features: Record = {}; for (const feature of STRUCTURED_SCHEMA_FEATURES) { if (params?.exclude?.includes(feature)) { continue; } - - const builder = StructuredSchemaBuilderMap[feature]; - - const schema = builder(beans, params); - + const schema = StructuredSchemaBuilderMap[feature](beans, params); if (schema) { features[feature] = schema.nullable(); } } + // Single pass over colsList — collect ids and build descriptions in one go. + const colsList = beans.colModel.colsList; const columnParams = params?.columns ?? {}; + const allColumnIds = new Array(colsList.length); + let descriptions = ''; + for (let i = 0, len = colsList.length; i < len; ++i) { + const colId = colsList[i].colId; + allColumnIds[i] = colId; + const desc = columnParams[colId]?.description; + if (i > 0) { + descriptions += '\n'; + } + descriptions += desc ? `${colId}: ${desc}` : colId; + } - const descriptions = allColumnIds - .map((colId) => { - if (columnParams[colId]?.description) { - return `${colId}: ${columnParams[colId].description}`; - } else { - return colId; - } - }) - .filter(Boolean) - .join('\n'); - - const schema = s.object(features).define('allColumnIds', s.enum(allColumnIds, descriptions)); - - return schema.toJSON(); + return s.object(features).define('allColumnIds', s.enum(allColumnIds, descriptions)).toJSON(); } diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnReferenceMapper.test.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnReferenceMapper.test.ts index 78733d0fc33..16ce02497b4 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnReferenceMapper.test.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnReferenceMapper.test.ts @@ -14,8 +14,15 @@ const createColumn = (colId: string, headerName: string, groupNames: string[] = for (let i = groupNames.length - 1; i >= 0; i--) { parent = createGroup(groupNames[i], parent); } + let colKind = 'user'; + if (colId === SELECTION_COLUMN_ID) { + colKind = 'selection'; + } else if (colId === ROW_NUMBERS_COLUMN_ID) { + colKind = 'row-number'; + } return { __headerName: headerName, + colKind, getColId: () => colId, getOriginalParent: () => parent, } as any; diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnUtils.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnUtils.ts index 76478113ba3..ec2a4388e25 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnUtils.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnUtils.ts @@ -1,58 +1,6 @@ -import type { ColDef, ColGroupDef } from 'ag-grid-community'; +import type { ColDef } from 'ag-grid-community'; import { _DATA_TYPE_DERIVED_COL_DEF_PROPERTIES } from 'ag-grid-community'; -/** Returns the index of the first item with a matching `colId`, or -1. Works for any colId-keyed list (dynamic column records, `AgColumn`s, etc.). */ -export function indexOfColId(items: T[], colId: string): number { - for (let i = 0, len = items.length; i < len; ++i) { - if (items[i].colId === colId) { - return i; - } - } - return -1; -} - -/** Returns the index of the first leaf colDef whose `colId` or `field` matches, or -1. Column groups are skipped. */ -export function indexOfColDef(columnDefs: (ColDef | ColGroupDef)[], colId: string): number { - for (let i = 0, len = columnDefs.length; i < len; ++i) { - const colDef = columnDefs[i]; - if (!('children' in colDef) && (colDef.colId === colId || colDef.field === colId)) { - return i; - } - } - return -1; -} - -/** - * Walks a (possibly nested) columnDefs tree and returns every `colId` and `field` it encounters - * on leaf columns. Used by `createUniqueColId` to scan the user-provided source of truth for id - * collisions, matching the same `colId ?? field` lookup the insert/update/remove paths use. - */ -export function collectColIdsAndFields(columnDefs: (ColDef | ColGroupDef)[]): Set { - const used = new Set(); - - const visit = (defs: (ColDef | ColGroupDef)[]) => { - for (const colDef of defs) { - if ('children' in colDef) { - visit(colDef.children); - continue; - } - - const { colId, field } = colDef; - - if (colId) { - used.add(colId); - } - - if (field) { - used.add(field); - } - } - }; - - visit(columnDefs); - return used; -} - /** * When `cellDataType` changes on a calculated column (e.g. via the Edit dialog moving Boolean → * Number), the data-type service does not re-resolve properties it implicitly set for the old diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts index 519b37905fe..a44ea0117c7 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsApi.ts @@ -1,5 +1,5 @@ import type { BeanCollection, ColKey } from 'ag-grid-community'; export function openCalculatedColumnDialog(beans: BeanCollection, column: ColKey): void { - beans.calculatedColsSvc?.openCalculatedColumnDialog(beans.colModel.getColDefColOrCol(column), 'edit', false); + beans.calculatedColsSvc?.openCalculatedColumnDialog(beans.colModel.getCol(column), 'edit', false); } diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts index f2bc11f3bf4..ff9b2e22ad8 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts @@ -2,19 +2,21 @@ import { _camelCaseToHumanText, _isStringLargerThan } from 'ag-stack'; import type { AgColumn, + CalculatedColumnDef, CalculatedColumnExpressionPicker, CalculatedColumnUpdate, CalculatedColumnValidationReason, ColDef, - ColGroupDef, ColKey, - Column, ColumnEventType, + ColumnState, + ColumnTreeBuild, ICalculatedColumnsService, NamedBean, } from 'ag-grid-community'; -import { BeanStub, _warnOnce } from 'ag-grid-community'; +import { BeanStub, _addColumnDefaultAndTypes, _createUserColumn, _mergedEqual, _warnOnce } from 'ag-grid-community'; +import { appendColumnToTree } from '../columns/columnTreeEdit'; import type { FormulaError } from '../formula/ast/utils'; import { Dialog } from '../widgets/dialog'; import { @@ -32,13 +34,7 @@ import { createCalculatedColumnReferenceMapper, translateCalculatedColumnReferenceError, } from './calculatedColumnReferenceMapper'; -import { - clearStaleDataTypeProperties, - collectColIdsAndFields, - indexOfColDef, - indexOfColId, - replaceBracketReferences, -} from './calculatedColumnUtils'; +import { clearStaleDataTypeProperties, replaceBracketReferences } from './calculatedColumnUtils'; type ValidationState = 'valid' | CalculatedColumnValidationReason; @@ -62,22 +58,12 @@ type CalcColEventCommonParams = { }; type DynamicCalculatedColumn = { - colId: string; colDef: ColDef; - anchorColId?: string; - anchorColDef?: ColDef | null; - visibleAnchorColId?: string; -}; - -type DynamicCalculatedColumnOverride = { - colId: string; - colDef: ColDef; - targetColDef: ColDef | null; -}; - -type DynamicCalculatedColumnSuppression = { - colId: string; - targetColDef: ColDef | null; + /** Source col, or `null`. Tree placement (leaf anchor); non-leaf anchors (e.g. auto-group col) + * also seat in display order via `anchoredToColId`. */ + anchorColId: string | null; + /** Owned AgColumn for stable identity across refreshes; `null` until built. The rebuild sweep destroys it. */ + instance: AgColumn | null; }; type OpenCalculatedColumnDialog = { @@ -93,22 +79,20 @@ type KnownCalculatedColumn = { export class CalculatedColumnsService extends BeanStub implements NamedBean, ICalculatedColumnsService { public readonly beanName = 'calculatedColsSvc' as const; - // calculated columns added via API/dialog, projected into the column tree (not in user `columnDefs`). - private dynamicColumns: DynamicCalculatedColumn[] = []; - // dynamic columns parked by `resetColumnState` so a later `applyColumnState` can restore them. - private inactiveDynamicColumns: DynamicCalculatedColumn[] = []; - // edits to user-declared calculated columns, applied over the original colDef during projection. - private readonly dynamicOverrides = new Map(); - // user-declared calculated columns removed by the user, suppressed from the projected tree. - private readonly dynamicSuppressions = new Map(); - // last known validation state per calculated column, to detect changes and fire validation events. + /** Dynamic calc cols (API/dialog added), keyed by colId. Insertion order = tree append order. */ + private readonly dynamicColumns = new Map(); + /** Added cols parked by `resetColumnState` so a later `applyColumnState` can restore them, by colId. */ + private readonly inactiveDynamicColumns = new Map(); + /** Build-time overrides for static (columnDefs-declared) calc cols, keyed by colId: a replacement + * colDef from `updateCalculatedColumn`, or `null` when removed. Consumed by the build via {@link overrideFor}. */ + private readonly staticColOverrides = new Map(); private validationStatesByColId = new Map(); - // guards the first validation pass so we don't emit spurious change events before the baseline exists. private validationStatesInitialised = false; - // guards the first lifecycle pass so static columnDefs do not emit spurious created events. + // Guards the first lifecycle pass so the initial column set establishes a baseline without emitting events. private lifecycleInitialised = false; private knownCalculatedColumns = new Map(); - // re-entry counter: when > 0, projection-triggered refreshes skip validation/lifecycle checks. + // Re-entry counter: while > 0 (a programmatic rebuild via {@link refreshDynamicColumns}), lifecycle/validation + // dispatch is skipped — the imperative caller emits its own events; declarative loads run with it at 0. private suppressValidationChecks = 0; private readonly openDialogsByColId = new Map(); @@ -118,30 +102,10 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa this.checkColumnLifecycle(event.source); this.checkValidationStates(event.source); }, - gridColumnsChanged: () => this.refreshCalculatedColumnSpans(), - columnMoved: (event) => this.releaseVisibleAnchors(event.columns), }); - this.addManagedPropertyListener('calculatedColumns', () => this.refreshOpenDialogHighlights()); } - private refreshCalculatedColumnSpans(): void { - const rowSpanSvc = this.beans.rowSpanSvc; - if (!rowSpanSvc?.active) { - return; - } - - const columns = this.beans.colModel.getCols() ?? []; - const calculatedColumns: AgColumn[] = []; - for (const column of columns) { - if (column.isCalculatedCol) { - calculatedColumns.push(column); - } - } - - rowSpanSvc.refreshColumnSpansForCols(calculatedColumns); - } - public isHighlightedColumn(column: AgColumn | null): boolean { return ( column != null && @@ -163,70 +127,57 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } private refreshOpenDialogHighlights(): void { - const colModel = this.beans.colModel; + const colsById = this.beans.colModel.colsById; for (const [colId, openDialog] of this.openDialogsByColId) { if (openDialog.highlight) { - this.refreshCalculatedColumnHighlight(colModel.getColById(colId)); - } - } - } - - private releaseVisibleAnchors(columns: Column[] | null | undefined): void { - if (!columns) { - return; - } - for (const column of columns) { - const colId = column.getColId(); - const dynamicColumn = this.getDynamicColumn(colId); - if (dynamicColumn) { - dynamicColumn.visibleAnchorColId = undefined; + this.refreshCalculatedColumnHighlight(colsById[colId] ?? null); } - this.releaseVisibleAnchor(colId); } } private updateCalculatedColumn(column: ColKey, colDef: CalculatedColumnUpdate): void { - const targetColumn = this.beans.colModel.getColDefColOrCol(column); + const source: ColumnEventType = 'calculatedColumn'; + const { colModel } = this.beans; + const targetColumn = colModel.getCol(column); if (targetColumn?.colDef.calculatedExpression == null) { return; } const oldExpression = targetColumn.colDef.calculatedExpression; - if (colDef.calculatedExpression !== undefined) { - if (!_isStringLargerThan(colDef.calculatedExpression, 0, true)) { + const calcExpr = colDef.calculatedExpression; + if (calcExpr !== undefined) { + if (!_isStringLargerThan(calcExpr, 0, true)) { _warnOnce('updateCalculatedColumn: calculatedExpression cannot be empty.'); return; } - if ( - !this.validateColumnReferences(colDef.calculatedExpression) || - !this.validateFormulaExpression(colDef.calculatedExpression) - ) { + if (!this.validateColumnReferences(calcExpr) || !this.validateFormulaExpression(calcExpr)) { return; } } const targetColId = targetColumn.colId; const nextColDef = this.getUpdatedCalculatedColDef(targetColumn, colDef); - const dynamicColumn = this.getDynamicColumn(targetColId); - if (dynamicColumn) { - dynamicColumn.colDef = nextColDef; - } else { - this.dynamicOverrides.set(targetColId, { - colId: targetColId, - colDef: nextColDef, - targetColDef: targetColumn.getUserProvidedColDef(), - }); - this.dynamicSuppressions.delete(targetColId); + // Skip rebuild when merged colDef is unchanged, avoiding a spurious `newColumnsLoaded`. + const merged = _addColumnDefaultAndTypes(this.beans, nextColDef, targetColId); + const changed = !_mergedEqual(merged, targetColumn.colDef); + // Skip when unchanged: a redundant override entry would needlessly re-apply on every later rebuild. + if (changed) { + const dynamicColumn = this.dynamicColumns.get(targetColId); + if (dynamicColumn) { + dynamicColumn.colDef = nextColDef; + } else { + this.staticColOverrides.set(targetColId, nextColDef); + } + this.refreshDynamicColumns(source); } - this.refreshDynamicColumns('calculatedColumn'); - const nextColumn = this.beans.colModel.getColById(targetColId) ?? targetColumn; + const nextColumn = colModel.colsById[targetColId] ?? targetColumn; const newExpression = nextColumn.colDef.calculatedExpression ?? oldExpression; - if (colDef.calculatedExpression !== undefined && oldExpression !== newExpression) { + if (calcExpr !== undefined && oldExpression !== newExpression) { this.dispatchExpressionChangedEvent( - this.getEventCommonParams(nextColumn, newExpression, 'calculatedColumn'), + this.getEventCommonParams(nextColumn, newExpression, source), oldExpression ); } - this.checkValidationStates('calculatedColumn', true); + this.checkValidationStates(source, true); this.refreshCalculatedColumn(targetColId); } @@ -245,9 +196,10 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } private getInvalidColumnReference(expression: string): string | undefined { + const colsById = this.beans.colModel.colsById; let invalidReference: string | undefined; replaceBracketReferences(expression, (ref) => { - if (invalidReference == null && !this.beans.colModel.getColById(ref)) { + if (invalidReference == null && !colsById[ref]) { invalidReference = ref; } return ref; @@ -272,7 +224,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa return true; } - public openCalculatedColumnDialog(column: AgColumn | null, mode: 'add' | 'edit', focusDialog = true): void { + public openCalculatedColumnDialog(column: AgColumn | null | undefined, mode: 'add' | 'edit', focus = true): void { if (mode === 'add') { const colId = this.createUniqueColId(); const headerName = this.getLocaleTextFunc()('calculatedColumnDefaultTitle', 'New title'); @@ -280,28 +232,21 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa this.showDialog( draft, (nextDraft) => { - const isDynamicAnchor = column != null && this.getDynamicColumn(column.colId) != null; - const anchorColDef = isDynamicAnchor ? undefined : column?.getUserProvidedColDef(); + const newColId = nextDraft.colId; 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, + this.inactiveDynamicColumns.delete(newColId); + this.dynamicColumns.set(newColId, { colDef: nextColDef, - anchorColId: column?.colId, - anchorColDef, - visibleAnchorColId: shouldUseColumnAsAnchor ? column?.colId : undefined, + anchorColId: column?.colId ?? null, + instance: null, }); this.refreshDynamicColumns('calculatedColumn'); - this.focusCalculatedColumn(nextDraft.colId); - const newColumn = this.beans.colModel.getColById(nextDraft.colId); + this.focusCalculatedColumn(newColId); + const newColumn = this.beans.colModel.colsById[newColId]; if (newColumn) { this.dispatchCreatedOrRemovedEvent( 'calculatedColumnCreated', @@ -311,7 +256,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa this.checkValidationStates('calculatedColumn', true); }, undefined, - focusDialog + focus ); return; } @@ -327,347 +272,170 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa this.updateCalculatedColumn(column.colId, update); }, column, - focusDialog + focus ); } - public removeCalculatedColumn(column: AgColumn | null): void { + public removeCalculatedColumn(column: AgColumn | undefined): void { if (column?.colDef.calculatedExpression == null) { return; } - + const source: ColumnEventType = 'calculatedColumn'; const expression = column.colDef.calculatedExpression; - const dynamicIndex = indexOfColId(this.dynamicColumns, column.colId); - if (dynamicIndex >= 0) { - this.dynamicColumns.splice(dynamicIndex, 1); - this.removeDynamicAnchors(column.colId); + const colId = column.colId; + if (this.dynamicColumns.delete(colId)) { + for (const dc of this.dynamicColumns.values()) { + if (dc.anchorColId === colId) { + dc.anchorColId = null; + } + } } else { - this.dynamicOverrides.delete(column.colId); - this.dynamicSuppressions.set(column.colId, { - colId: column.colId, - targetColDef: column.getUserProvidedColDef(), - }); + this.staticColOverrides.set(colId, null); } - this.refreshDynamicColumns('calculatedColumn'); + this.refreshDynamicColumns(source); this.dispatchCreatedOrRemovedEvent( 'calculatedColumnRemoved', - this.getEventCommonParams(column, expression, 'calculatedColumn') + this.getEventCommonParams(column, expression, source) ); - this.checkValidationStates('calculatedColumn', true); + this.checkValidationStates(source, true); } - public createProjectedColumnDefs( - columnDefs: (ColDef | ColGroupDef)[] | undefined - ): (ColDef | ColGroupDef)[] | undefined { - if (!this.hasDynamicColumnState()) { - return columnDefs; + public overrideFor(colDef: ColDef): ColDef | null | undefined { + const overrides = this.staticColOverrides; + if (overrides.size === 0) { + return undefined; } - - const insertedDynamicColIds = new Set(); - const sourceColumnDefs = columnDefs ?? []; - const result = this.projectColumnDefs(sourceColumnDefs, insertedDynamicColIds); - let projectedColumnDefs = result.columnDefs; - let visibleAnchorInsertIndex = 0; - - for (const dynamicColumn of this.dynamicColumns) { - if (insertedDynamicColIds.has(dynamicColumn.colId)) { - continue; - } - - if (projectedColumnDefs === sourceColumnDefs) { - projectedColumnDefs = sourceColumnDefs.slice(); - } - const insertIndex = - dynamicColumn.visibleAnchorColId != null ? visibleAnchorInsertIndex : projectedColumnDefs.length; - projectedColumnDefs.splice(insertIndex, 0, dynamicColumn.colDef); - insertedDynamicColIds.add(dynamicColumn.colId); - this.insertDynamicColumnsAfterDynamicColumn( - dynamicColumn.colId, - projectedColumnDefs, - insertedDynamicColIds - ); - if (dynamicColumn.visibleAnchorColId != null) { - visibleAnchorInsertIndex = indexOfColDef(projectedColumnDefs, dynamicColumn.colId) + 1; - } - } - - return projectedColumnDefs; + const key = colDef.colId ?? colDef.field; + return key != null ? overrides.get(key) : undefined; } - public orderDynamicColumns(columns: AgColumn[]): void { - for (const dynamicColumn of this.dynamicColumns) { - const visibleAnchorColId = dynamicColumn.visibleAnchorColId; - if (visibleAnchorColId != null) { - this.moveColumnAfter(columns, dynamicColumn.colId, visibleAnchorColId); - } + public contributeTo(build: ColumnTreeBuild): void { + const { dynamicColumns, staticColOverrides } = this; + // Static-col overrides/removals are handled by the build via `overrideFor`; here we only splice + // in dynamic (API/dialog-added) cols, so nothing to do without them. + if (dynamicColumns.size === 0) { + return; } - } - public shouldPreserveColumnOrderOnRefresh(): boolean { - for (const dynamicColumn of this.dynamicColumns) { - if (dynamicColumn.visibleAnchorColId != null) { - return true; + // Place each dynamic col. `appendColumnToTree(col, anchorId)` positions it in the TREE (inheriting + // a leaf anchor's group membership); `anchoredToColId` is stamped so order restoration also seats + // it after the anchor in DISPLAY order — needed for non-leaf anchors (e.g. auto-group col). A + // missing/removed anchor (or none) falls back to a plain append. + const source = build.source; + const buildToken = build.buildToken; + dynamicColumns.forEach((dc, colId) => { + const agCol = this.getOrCreateAgColumn(dc, colId, buildToken, source); + agCol.buildToken = buildToken; // So the post-build sweep keeps the col alive. + const anchorId = dc.anchorColId; + if (anchorId != null && anchorId !== colId && staticColOverrides.get(anchorId) !== null) { + agCol.anchoredToColId = anchorId; + appendColumnToTree(build, agCol, anchorId); + } else { + agCol.anchoredToColId = undefined; + appendColumnToTree(build, agCol); } - } - return false; + }); } public resetDynamicColumnDefs(preserveCreatedColumns = false): boolean { if (!preserveCreatedColumns) { - this.inactiveDynamicColumns = []; + this.inactiveDynamicColumns.clear(); } - - if (!this.hasDynamicColumnState()) { + if (!this.dynamicColumns.size && !this.staticColOverrides.size) { return false; } - + // Owned AgColumns are destroyed by the rebuild that always follows (colModel owns tree lifetime). + // `resetColumnState` parks added cols for a later `applyColumnState`, dropping the about-to-be-swept instance ref. if (preserveCreatedColumns) { - for (const dynamicColumn of this.dynamicColumns) { - this.addInactiveDynamicColumn(dynamicColumn); - } + this.dynamicColumns.forEach((dynamicColumn, colId) => { + dynamicColumn.instance = null; + this.inactiveDynamicColumns.set(colId, dynamicColumn); + }); } - - this.dynamicColumns = []; - this.dynamicOverrides.clear(); - this.dynamicSuppressions.clear(); + this.dynamicColumns.clear(); + this.staticColOverrides.clear(); return true; } - public restoreDynamicColumnDefs(colIds: string[]): boolean { - if (!this.inactiveDynamicColumns.length) { + public restoreDynamicColumnDefs(state: ColumnState[]): boolean { + const inactive = this.inactiveDynamicColumns; + if (!inactive.size) { return false; } - let restored = false; - - for (const colId of colIds) { - const inactiveIndex = indexOfColId(this.inactiveDynamicColumns, colId); - if (inactiveIndex < 0) { + for (let i = 0, len = state.length; i < len; ++i) { + const colId = state[i].colId; + const dynamicColumn = inactive.get(colId); + if (dynamicColumn === undefined) { continue; } - - const inactiveDynamicColumn = this.inactiveDynamicColumns[inactiveIndex]; - this.inactiveDynamicColumns.splice(inactiveIndex, 1); - if (this.getDynamicColumn(colId)) { - continue; + inactive.delete(colId); + if (!this.dynamicColumns.has(colId)) { + this.dynamicColumns.set(colId, dynamicColumn); + restored = true; } - - this.dynamicColumns.push(inactiveDynamicColumn); - restored = true; } return restored; } - private hasDynamicColumnState(): boolean { - return this.dynamicColumns.length > 0 || this.dynamicOverrides.size > 0 || this.dynamicSuppressions.size > 0; - } - - 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 { + private getOrCreateAgColumn( + dc: DynamicCalculatedColumn, + colId: string, + buildToken: number, + source: ColumnEventType + ): AgColumn { + const beans = this.beans; + const existing = dc.instance; + if (existing !== null) { + // Reuse the owned instance (always alive: contributed/stamped every refresh, nulled when + // parked/removed). Restamp + refresh colDef in case expression/cellDataType changed. + existing.buildToken = buildToken; + existing.reapplyColDef(dc.colDef, source); + return existing; + } + const agCol = _createUserColumn(beans, dc.colDef, colId, true, buildToken); + dc.instance = agCol; + return agCol; + } + + /** Rebuild the column tree for a calc-col mutation. Suppresses lifecycle/validation dispatch during the + * rebuild so the imperative caller (or column-state op) emits its own events, not duplicates. */ + public refreshDynamicColumns(source: ColumnEventType): void { this.suppressValidationChecks++; try { - this.beans.colModel.refreshDynamicColumns(source); + this.beans.colModel.rebuildCols(source); } finally { this.suppressValidationChecks--; } } private createUniqueColId(): string { - const usedIds = collectColIdsAndFields(this.beans.colModel.getProvidedColumnDefs() ?? []); - const currentColumns = this.beans.colModel.getCols() ?? []; - for (const currentColumn of currentColumns) { - usedIds.add(currentColumn.colId); - } - for (const dynamicColumn of this.dynamicColumns) { - usedIds.add(dynamicColumn.colId); - } - for (const inactiveDynamicColumn of this.inactiveDynamicColumns) { - usedIds.add(inactiveDynamicColumn.colId); - } - - let index = 1; - while (usedIds.has(`calculated_${index}`)) { - index++; - } - return `calculated_${index}`; + const { colModel } = this.beans; + const parked = this.inactiveDynamicColumns; + let index = 0; + let colId: string; + do { + colId = `calculated_${++index}`; + } while (colModel.getCol(colId) !== undefined || parked.has(colId)); + return colId; } private getUpdatedCalculatedColDef(column: AgColumn, colDefUpdate: CalculatedColumnUpdate): ColDef { - const dynamicColumn = this.getDynamicColumn(column.colId); - const dynamicOverride = this.dynamicOverrides.get(column.colId); - const baseColDef = - dynamicColumn?.colDef ?? dynamicOverride?.colDef ?? column.getUserProvidedColDef() ?? column.colDef; + const colId = column.colId; + const dynamicColumn = this.dynamicColumns.get(colId); + const staticOverride = this.staticColOverrides.get(colId); + const userColDef = column.getUserProvidedColDef(); + const baseColDef = dynamicColumn?.colDef ?? staticOverride ?? userColDef ?? column.colDef; const safeUpdate: ColDef = { ...colDefUpdate }; delete safeUpdate.colId; const nextColDef = { - ...clearStaleDataTypeProperties(baseColDef, column.getUserProvidedColDef(), safeUpdate), + ...clearStaleDataTypeProperties(baseColDef, userColDef, safeUpdate), ...safeUpdate, }; nextColDef.calculatedExpression ??= baseColDef.calculatedExpression; - nextColDef.colId ??= column.colId; - - return this.toCalculatedColDef(nextColDef); - } - - private projectColumnDefs( - columnDefs: (ColDef | ColGroupDef)[], - insertedDynamicColIds: Set - ): { columnDefs: (ColDef | ColGroupDef)[]; changed: boolean } { - let changed = false; - const projectedColumnDefs: (ColDef | ColGroupDef)[] = []; - - for (const colDef of columnDefs) { - if ('children' in colDef && colDef.children) { - const childResult = this.projectColumnDefs(colDef.children, insertedDynamicColIds); - if (childResult.changed) { - projectedColumnDefs.push({ ...colDef, children: childResult.columnDefs }); - changed = true; - } else { - projectedColumnDefs.push(colDef); - } - continue; - } - - if (this.isColDefSuppressed(colDef)) { - changed = true; - continue; - } - - const override = this.getColDefOverride(colDef); - const projectedColDef = override?.colDef ?? colDef; - projectedColumnDefs.push(projectedColDef); - changed ||= override != null; - - if (this.insertDynamicColumnsAfterUserColumn(colDef, projectedColumnDefs, insertedDynamicColIds)) { - changed = true; - } - } - - return { columnDefs: changed ? projectedColumnDefs : columnDefs, changed }; - } - - private insertDynamicColumnsAfterUserColumn( - colDef: ColDef, - targetColumnDefs: (ColDef | ColGroupDef)[], - insertedDynamicColIds: Set - ): boolean { - let changed = false; - const colId = colDef.colId ?? colDef.field; - - for (const dynamicColumn of this.dynamicColumns) { - if ( - insertedDynamicColIds.has(dynamicColumn.colId) || - !this.matchesColumnDef(colDef, dynamicColumn.anchorColId, dynamicColumn.anchorColDef) - ) { - continue; - } - - targetColumnDefs.push(dynamicColumn.colDef); - insertedDynamicColIds.add(dynamicColumn.colId); - this.insertDynamicColumnsAfterDynamicColumn(dynamicColumn.colId, targetColumnDefs, insertedDynamicColIds); - changed = true; - } - - if (colId != null) { - this.insertDynamicColumnsAfterDynamicColumn(colId, targetColumnDefs, insertedDynamicColIds); - } - - return changed; - } - - private getDynamicColumn(colId: string): DynamicCalculatedColumn | undefined { - const index = indexOfColId(this.dynamicColumns, colId); - return index < 0 ? undefined : this.dynamicColumns[index]; - } - private addInactiveDynamicColumn(dynamicColumn: DynamicCalculatedColumn): void { - this.removeInactiveDynamicColumn(dynamicColumn.colId); - this.inactiveDynamicColumns.push(dynamicColumn); - } - - private removeInactiveDynamicColumn(colId: string): void { - const inactiveIndex = indexOfColId(this.inactiveDynamicColumns, colId); - if (inactiveIndex >= 0) { - this.inactiveDynamicColumns.splice(inactiveIndex, 1); - } - } - - private moveColumnAfter(columns: AgColumn[], colId: string, anchorColId: string): void { - const columnIndex = indexOfColId(columns, colId); - if (columnIndex < 0 || indexOfColId(columns, anchorColId) < 0) { - return; - } - - const [column] = columns.splice(columnIndex, 1); - columns.splice(indexOfColId(columns, anchorColId) + 1, 0, column); - } - - private insertDynamicColumnsAfterDynamicColumn( - anchorColId: string, - targetColumnDefs: (ColDef | ColGroupDef)[], - insertedDynamicColIds: Set - ): void { - for (const dynamicColumn of this.dynamicColumns) { - if (insertedDynamicColIds.has(dynamicColumn.colId) || dynamicColumn.anchorColId !== anchorColId) { - continue; - } - - targetColumnDefs.push(dynamicColumn.colDef); - insertedDynamicColIds.add(dynamicColumn.colId); - this.insertDynamicColumnsAfterDynamicColumn(dynamicColumn.colId, targetColumnDefs, insertedDynamicColIds); - } - } - - private getColDefOverride(colDef: ColDef): DynamicCalculatedColumnOverride | undefined { - for (const override of this.dynamicOverrides.values()) { - if (this.matchesColumnDef(colDef, override.colId, override.targetColDef)) { - return override; - } - } - return undefined; - } - - private isColDefSuppressed(colDef: ColDef): boolean { - for (const suppression of this.dynamicSuppressions.values()) { - if (this.matchesColumnDef(colDef, suppression.colId, suppression.targetColDef)) { - return true; - } - } - return false; - } - - private matchesColumnDef(colDef: ColDef, colId: string | undefined, targetColDef?: ColDef | null): boolean { - return colDef === targetColDef || (colId != null && (colDef.colId === colId || colDef.field === colId)); - } - - private removeDynamicAnchors(colId: string): void { - for (const dynamicColumn of this.dynamicColumns) { - if (dynamicColumn.anchorColId === colId) { - dynamicColumn.anchorColId = undefined; - dynamicColumn.anchorColDef = undefined; - } - } - this.releaseVisibleAnchor(colId); - } - - private releaseVisibleAnchor(colId: string): void { - for (const dynamicColumn of this.dynamicColumns) { - if (dynamicColumn.visibleAnchorColId === colId) { - dynamicColumn.visibleAnchorColId = undefined; - } - } + return this.toCalculatedColDef(nextColDef, colId); } private showDialog( @@ -685,11 +453,8 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } const state: { close?: () => void; resolved: boolean } = { resolved: false }; - const mapper = createCalculatedColumnReferenceMapper( - this.beans, - this.beans.colModel.getCols() ?? [], - draft.colId - ); + const beans = this.beans; + const mapper = createCalculatedColumnReferenceMapper(beans, beans.colModel.colsList, draft.colId); const getValidatedExpression = ( nextDraft: CalculatedColumnDraft @@ -774,7 +539,11 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa const destroyDialogMouseListeners = this.addManagedElementListeners(dialog.getGui(), { mousedown: () => form.hideSuggestions(), }); - dialog.addDestroyFunc(() => destroyDialogMouseListeners.forEach((destroyFunc) => destroyFunc())); + dialog.addDestroyFunc(() => { + for (let i = 0, len = destroyDialogMouseListeners.length; i < len; ++i) { + destroyDialogMouseListeners[i](); + } + }); dialog.addDestroyFunc(() => { if (this.openDialogsByColId.get(draft.colId)?.dialog === dialog) { this.openDialogsByColId.delete(draft.colId); @@ -827,18 +596,19 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } private toDraft(column: AgColumn): CalculatedColumnDraft { + const beans = this.beans; const colDef = column.colDef; const colId = column.colId; const cellDataType = colDef.cellDataType; - const displayName = this.beans.colNames.getDisplayNameForColumn(column, 'header'); + const displayName = beans.colNames.getDisplayNameForColumn(column, 'header'); return { colId, headerName: colDef.headerName ?? displayName ?? colId, cellDataType: typeof cellDataType === 'string' ? cellDataType : DEFAULT_DRAFT.cellDataType, calculatedExpression: createCalculatedColumnReferenceMapper( - this.beans, - this.beans.colModel.getCols() ?? [], + beans, + beans.colModel.colsList, colId ).toDisplayExpression(colDef.calculatedExpression ?? ''), }; @@ -846,9 +616,10 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa private focusCalculatedColumn(colId: string): void { window.setTimeout(() => { - const headerPosition = this.beans.headerNavigation?.getHeaderPositionForColumn(colId, false); - if (headerPosition) { - this.beans.focusSvc.focusHeaderPosition({ headerPosition }); + const beans = this.beans; + const headerPos = this.isAlive() && beans.headerNavigation?.getHeaderPositionForColumn(colId, false); + if (headerPos) { + beans.focusSvc.focusHeaderPosition({ headerPosition: headerPos }); } }, 0); } @@ -864,22 +635,18 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa }; } - private toCalculatedColDef(colDef: ColDef): ColDef { + private toCalculatedColDef(colDef: CalculatedColumnDef | ColDef, colId: string): ColDef { // strip fields that conflict with calculatedExpression invariants (see colDefValidations.ts). - const sanitised: ColDef = { ...colDef }; - const invariantProperties: (keyof ColDef)[] = [ - 'field', - 'valueGetter', - 'valueSetter', - 'cellEditor', - 'cellEditorSelector', - ]; - invariantProperties.forEach((prop) => delete sanitised[prop]); - return { - ...sanitised, + ...colDef, + colId, editable: false, suppressPaste: true, + field: undefined, + valueGetter: undefined, + valueSetter: undefined, + cellEditor: undefined, + cellEditorSelector: undefined, }; } @@ -890,12 +657,18 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa return this.getFormulaExpressionError(expression) == null ? 'valid' : 'invalidExpression'; } + /** Fire created/expressionChanged/removed events for calc cols added, edited, or removed *declaratively* + * (via columnDefs). Imperative dialog paths rebuild through {@link refreshDynamicColumns} (counter > 0), + * so this stays silent for them and they dispatch inline — avoiding double-fire. The baseline is always + * updated (even when silent) so a later declarative load doesn't replay suppressed changes as new events. */ 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 cols = this.beans.colModel.colsList; + for (let i = 0, len = cols.length; i < len; ++i) { + const column = cols[i]; const expression = column.colDef.calculatedExpression; if (expression == null) { continue; @@ -903,7 +676,6 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa const colId = column.colId; nextColumns.set(colId, { column, expression }); - if (!shouldDispatch) { continue; } @@ -923,14 +695,14 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa } if (shouldDispatch) { - for (const [colId, previousColumn] of previousColumns) { + previousColumns.forEach((previousColumn, colId) => { if (!nextColumns.has(colId)) { this.dispatchCreatedOrRemovedEvent( 'calculatedColumnRemoved', this.getEventCommonParams(previousColumn.column, previousColumn.expression, source) ); } - } + }); } this.knownCalculatedColumns = nextColumns; @@ -946,7 +718,9 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa const previousStates = this.validationStatesByColId; const nextStates = new Map(); - for (const column of this.beans.colModel.getCols() ?? []) { + const cols = this.beans.colModel.colsList; + for (let i = 0, len = cols.length; i < len; ++i) { + const column = cols[i]; const expression = column.colDef.calculatedExpression; if (expression == null) { continue; @@ -1020,12 +794,11 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa private refreshCalculatedColumn(colId: string): void { window.setTimeout(() => { - const column = this.beans.colModel.getColById(colId); - if (!column) { - return; + const beans = this.beans; + const column = this.isAlive() && beans.colModel.colsById[colId]; + if (column) { + beans.rowRenderer.refreshCells({ columns: [column], force: true }); } - - this.beans.rowRenderer.refreshCells({ columns: [column], force: true }); }, 0); } } diff --git a/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts b/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts index 7ee7e36cabd..53accb3f999 100644 --- a/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts +++ b/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts @@ -418,7 +418,7 @@ export class ChartDatasource extends BeanStub { } private updatePivotKeysForSSRM() { - const secondaryColumns = this.pivotResultCols?.getPivotResultCols()?.list; + const secondaryColumns = this.pivotResultCols?.pivotCols; if (!secondaryColumns) { return; diff --git a/packages/ag-grid-enterprise/src/charts/chartComp/services/chartColumnService.ts b/packages/ag-grid-enterprise/src/charts/chartComp/services/chartColumnService.ts index 3475cc5dc1b..d356f4c0781 100644 --- a/packages/ag-grid-enterprise/src/charts/chartComp/services/chartColumnService.ts +++ b/packages/ag-grid-enterprise/src/charts/chartComp/services/chartColumnService.ts @@ -28,7 +28,7 @@ export class ChartColumnService extends BeanStub { } public getColumn(colId: string): AgColumn | null { - return this.colModel.getColDefColOrCol(colId); + return this.colModel.colsById[colId] ?? null; } public getAllDisplayedColumns(): AgColumn[] { @@ -60,7 +60,7 @@ export class ChartColumnService extends BeanStub { } public getChartColumns(): { dimensionCols: Set; valueCols: Set } { - const gridCols = this.colModel.getCols(); + const gridCols = this.colModel.colsList; const dimensionCols = new Set(); const valueCols = new Set(); diff --git a/packages/ag-grid-enterprise/src/charts/chartComp/services/chartCrossFilterService.ts b/packages/ag-grid-enterprise/src/charts/chartComp/services/chartCrossFilterService.ts index 095a57cd474..5f79dc64d96 100644 --- a/packages/ag-grid-enterprise/src/charts/chartComp/services/chartCrossFilterService.ts +++ b/packages/ag-grid-enterprise/src/charts/chartComp/services/chartCrossFilterService.ts @@ -79,7 +79,7 @@ export class ChartCrossFilterService extends BeanStub implements NamedBean { private convertRawValue(colId: string, rawValue: any): any { const { colModel, dataTypeSvc } = this.beans; - const column = colModel.getColById(colId); + const column = colModel.colsById[colId]; const colDef = column?.colDef; if (colDef && dataTypeSvc && colDef.chartDataType === 'time' && colDef.cellDataType === 'dateString') { // need to convert from `Date` back to `string` diff --git a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts index 56fb7102a1a..9054832b43c 100644 --- a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts +++ b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts @@ -948,15 +948,9 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS Object.assign(allCellsToFlash, cellsToFlash); } - const allColumns = this.beans.visibleCols.allCols; const exportedColumns = Array.from(columnsSet); - exportedColumns.sort((a, b) => { - const posA = allColumns.indexOf(a); - const posB = allColumns.indexOf(b); - - return posA - posB; - }); + exportedColumns.sort((a, b) => a.allColsIndex - b.allColsIndex); const data = this.buildExportParams({ columns: exportedColumns, diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts index 23d105fb3e5..d2389c2451d 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsHeader.ts @@ -107,7 +107,7 @@ export class AgPrimaryColsHeader extends Component { const showFilter = !params.suppressColumnFilter; const showSelect = !params.suppressColumnSelectAll; const showExpand = !params.suppressColumnExpandAll; - const groupsPresent = !!this.beans.colModel.colDefCols?.treeDepth; + const groupsPresent = !!this.beans.colModel.colDefTreeDepth; const translate = this.getLocaleTextFunc(); this.eFilterTextField.setInputPlaceholder(translate('searchOoo', 'Search...')); diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts index d6af5ea1258..cb13b763b54 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/agPrimaryColsList.ts @@ -92,7 +92,9 @@ export class AgPrimaryColsList extends Component { public init(params: ToolPanelColumnCompParams, allowDragging: boolean, eventType: ColumnEventType): void { this.params = params; const { suppressSyncLayoutWithGrid, contractColumnSelection, suppressColumnMove } = params; - this.allowDragging = allowDragging; + // Drag drives drag-to-zone/hide only (in-panel reorder is blocked via `isPreventMove`), so + // `suppressColumnMove` must not disable it; only deferred mode's decoupled layout suppresses it. + this.allowDragging = allowDragging && !(suppressSyncLayoutWithGrid && isDeferredMode(params)); this.eventType = eventType; if (!suppressSyncLayoutWithGrid) { @@ -195,7 +197,7 @@ export class AgPrimaryColsList extends Component { let movePadding = 0; if (isUp) { - const children = item.columnDepth > 0 ? column.getParent()?.getChildren() : null; + const children = item.columnDepth > 0 ? column.parent?.children : null; if (children?.length && column === children[0]) { movePadding = -1; } @@ -302,7 +304,7 @@ export class AgPrimaryColsList extends Component { const colGroup = item.columnGroup; if (colGroup) { // group should always exist, this is defensive - res[colGroup.getId()] = item.expanded; + res[colGroup.groupId] = item.expanded; } }); @@ -322,7 +324,7 @@ export class AgPrimaryColsList extends Component { const colGroup = item.columnGroup; if (colGroup) { // group should always exist, this is defensive - const expanded = states[colGroup.getId()]; + const expanded = states[colGroup.groupId]; const groupExistedLastTime = expanded != null; if (groupExistedLastTime || isInitialState) { item.expanded = !!expanded; @@ -363,8 +365,8 @@ export class AgPrimaryColsList extends Component { private buildTreeFromProvidedColumnDefs(): void { const colModel = this.colModel; // add column / group comps to tool panel - this.buildListModel(colModel.getColDefColTree()); - this.groupsExist = !!colModel.colDefCols?.treeDepth; + this.buildListModel(colModel.colDefTree); + this.groupsExist = !!colModel.colDefTreeDepth; } private buildListModel(columnTree: (AgColumn | AgProvidedColumnGroup)[]): void { @@ -401,8 +403,8 @@ export class AgPrimaryColsList extends Component { return; } - if (columnGroup.isPadding()) { - recursivelyBuild(columnGroup.getChildren(), depth, parentList); + if (columnGroup.padding) { + recursivelyBuild(columnGroup.children, depth, parentList); return; } @@ -418,7 +420,7 @@ export class AgPrimaryColsList extends Component { parentList.push(item); addListeners(item); - recursivelyBuild(columnGroup.getChildren(), depth + 1, item.children); + recursivelyBuild(columnGroup.children, depth + 1, item.children); }; const createColumnItem = (column: AgColumn, depth: number, parentList: ColumnModelItem[]): void => { @@ -450,12 +452,18 @@ export class AgPrimaryColsList extends Component { } this.displayedColsList.push(item); if (item.group && item.expanded) { - item.children.forEach(recursiveFunc); + const children = item.children; + for (let i = 0, len = children.length; i < len; ++i) { + recursiveFunc(children[i]); + } } }; const virtualList = this.virtualList; - this.allColsTree.forEach(recursiveFunc); + const allColsTree = this.allColsTree; + for (let i = 0, len = allColsTree.length; i < len; ++i) { + recursiveFunc(allColsTree[i]); + } virtualList.setModel(new UIColumnModel(this.displayedColsList)); let focusedRow: number | null = null; @@ -528,21 +536,22 @@ export class AgPrimaryColsList extends Component { return; } - const expandedGroupIds: string[] = []; + const targetGroupIds = new Set(groupIds); + const expandedGroupIds = new Set(); this.forEachItem((item) => { if (!item.group) { return; } - const groupId = item.columnGroup.getId(); - if (groupIds.indexOf(groupId) >= 0) { + const groupId = item.columnGroup.groupId; + if (targetGroupIds.has(groupId)) { item.expanded = expand; - expandedGroupIds.push(groupId); + expandedGroupIds.add(groupId); } }); - const unrecognisedGroupIds = groupIds.filter((groupId) => !expandedGroupIds.includes(groupId)); + const unrecognisedGroupIds = groupIds.filter((groupId) => !expandedGroupIds.has(groupId)); if (unrecognisedGroupIds.length > 0) { _warn(157, { unrecognisedGroupIds }); } @@ -703,7 +712,7 @@ export class AgPrimaryColsList extends Component { this.forEachItem((item) => { if (item.group && item.expanded) { - expandedGroupIds.push(item.columnGroup.getId()); + expandedGroupIds.push(item.columnGroup.groupId); } }); diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts index e5c26e98245..2846f9435e0 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/columnToolPanel.ts @@ -2,11 +2,11 @@ import { _areEqual, _clearElement, _last } from 'ag-stack'; import type { BeanCollection, + ColAggFunc, ColDef, ColGroupDef, ColumnToolPanelAction, ColumnToolPanelState, - IAggFunc, IColumnToolPanel, IToolPanelColumnCompParams, IToolPanelComp, @@ -35,7 +35,7 @@ interface GridStateSnapshot { columnOrder: string[]; visibleColIds: string[]; sortState: string[]; - aggFuncState: (string | IAggFunc | null | undefined)[]; + aggFuncState: ColAggFunc[]; widthState: string[]; } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelContextMenu.ts b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelContextMenu.ts index 8dcbf99bf5e..02045b9cc34 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelContextMenu.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/toolPanelContextMenu.ts @@ -1,7 +1,7 @@ import { _focusInto } from 'ag-stack'; import type { AgColumn, AgProvidedColumnGroup, IconName, MenuItemDef } from 'ag-grid-community'; -import { Component, _createIconNoSpan, isColumn, isProvidedColumnGroup } from 'ag-grid-community'; +import { Component, _createIconNoSpan, isProvidedColumnGroup } from 'ag-grid-community'; import { getGroupingLocaleText, isRowGroupColLocked } from '../rowGrouping/rowGroupingUtils'; import { MenuList } from '../widgets/menuList'; @@ -47,7 +47,7 @@ export class ToolPanelContextMenu extends Component { this.initializeProperties(column); let displayName: string | null; - if (isColumn(column)) { + if (column.isColumn) { displayName = colNames.getDisplayNameForColumn(column, 'columnToolPanel'); } else { displayName = colNames.getDisplayNameForProvidedColumnGroup(null, column, 'columnToolPanel'); @@ -203,11 +203,20 @@ export class ToolPanelContextMenu extends Component { } private addColumnsToList(columnList: AgColumn[], predicate: (col: AgColumn) => boolean): AgColumn[] { - return [...columnList].concat(this.columns.filter((col) => predicate(col) && !columnList.includes(col))); + const existing = new Set(columnList); + const additions: AgColumn[] = []; + for (let i = 0, len = this.columns.length; i < len; ++i) { + const col = this.columns[i]; + if (predicate(col) && !existing.has(col)) { + additions.push(col); + } + } + return columnList.concat(additions); } private removeColumnsFromList(columnList: AgColumn[], predicate: (col: AgColumn) => boolean): AgColumn[] { - return columnList.filter((col) => !predicate(col) || !this.columns.includes(col)); + const toRemove = new Set(this.columns); + return columnList.filter((col) => !predicate(col) || !toRemove.has(col)); } private displayContextMenu(menuItemsMapped: MenuItemDef[]): void { diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateExecutionStrategy.ts b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateExecutionStrategy.ts index 57d982ae1c4..533670a540d 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateExecutionStrategy.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateExecutionStrategy.ts @@ -3,13 +3,13 @@ import { _areEqual } from 'ag-stack'; import type { AgColumn, BeanCollection, + ColAggFunc, ColumnEventType, ColumnState, - IAggFunc, IColumnStateUpdateStrategy, SortDef, } from 'ag-grid-community'; -import { BeanStub, _applyColumnState, isColumnGroupAutoCol, isSpecialCol } from 'ag-grid-community'; +import { BeanStub, _applyColumnState, _setColsVisible, isColumnGroupAutoCol, isSpecialCol } from 'ag-grid-community'; import type { ColumnStateConcreteUpdateStrategy, @@ -73,12 +73,12 @@ export class ColumnStateUpdateExecutionStrategy extends BeanStub implements ICol public setColumnAggFunc( deferMode: boolean, column: AgColumn, - aggFunc: string | IAggFunc | null | undefined, + aggFunc: ColAggFunc, eventType: ColumnEventType ): void { this.getUpdateStrategy(deferMode).setColumnAggFunc(column, aggFunc, eventType); } - public getColumnAggFunc(deferMode: boolean, column: AgColumn): string | IAggFunc | null | undefined { + public getColumnAggFunc(deferMode: boolean, column: AgColumn): ColAggFunc { return this.getUpdateStrategy(deferMode).getColumnAggFunc(column); } public setPivotColumns(deferMode: boolean, columns: AgColumn[], eventType: ColumnEventType): void { @@ -127,11 +127,9 @@ class SynchronousColumnStateUpdateStrategy implements ColumnStateConcreteUpdateS public hasDeferredColumnOrder = () => false; public applyColumnState(state: ColumnState[], eventType: ColumnEventType): void { - if (state.length === 0) { - return; + if (state.length) { + _applyColumnState(this.beans, { state }, eventType); } - - _applyColumnState(this.beans, { state }, eventType); // apply column state } public moveColumns(columns: AgColumn[], targetIndex: number, eventType: ColumnEventType): void { @@ -140,8 +138,7 @@ class SynchronousColumnStateUpdateStrategy implements ColumnStateConcreteUpdateS } public setColumnsVisible(columns: AgColumn[], visible: boolean, eventType: ColumnEventType): void { - const allowedCols = columns.filter((column) => !column.colDef.lockVisible); - this.beans.colModel.setColsVisible(allowedCols, visible, eventType); // apply column state + _setColsVisible(this.beans, columns, visible, eventType, true); } public setRowGroupColumns(columns: AgColumn[], eventType: ColumnEventType): void { @@ -164,16 +161,12 @@ class SynchronousColumnStateUpdateStrategy implements ColumnStateConcreteUpdateS return this.beans.valueColsSvc?.columns ?? []; } - public setColumnAggFunc( - column: AgColumn, - aggFunc: string | IAggFunc | null | undefined, - eventType: ColumnEventType - ): void { - this.beans.valueColsSvc?.setColumnAggFunc?.(column, aggFunc, eventType); // dispatchEvent + public setColumnAggFunc(column: AgColumn, aggFunc: ColAggFunc, eventType: ColumnEventType): void { + this.beans.valueColsSvc?.setColumnAggFunc(column, aggFunc, eventType); // dispatchEvent } - public getColumnAggFunc(column: AgColumn): string | IAggFunc | null | undefined { - return column.getAggFunc(); + public getColumnAggFunc(column: AgColumn): ColAggFunc { + return column.aggFunc; } public setPivotColumns(columns: AgColumn[], eventType: ColumnEventType): void { @@ -186,7 +179,7 @@ class SynchronousColumnStateUpdateStrategy implements ColumnStateConcreteUpdateS } public setPivotMode(pivotMode: boolean, eventType: ColumnEventType): void { - const { colModel, gos, ctrlsSvc } = this.beans; + const { gos, colModel, ctrlsSvc } = this.beans; if (pivotMode === colModel.pivotMode) { return; } @@ -197,7 +190,7 @@ class SynchronousColumnStateUpdateStrategy implements ColumnStateConcreteUpdateS } if (!pivotMode) { - const cols = this.beans.colModel.getColDefCols() ?? []; + const cols = this.beans.colModel.colDefList; _applyColumnState( this.beans, { @@ -257,15 +250,15 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra if (columnState) { for (const [colId, patch] of columnState.patches) { - const column = beans.colModel.getColDefCol(colId); + const column = beans.colModel.getNonPivotColById(colId); if (!column) { continue; } if ( - (patch.hide !== undefined && patch.hide !== !column.isVisible()) || - (patch.rowGroup !== undefined && !!patch.rowGroup !== column.isRowGroupActive()) || - (patch.pivot !== undefined && !!patch.pivot !== column.isPivotActive()) || - (patch.aggFunc !== undefined && (patch.aggFunc ?? null) !== (column.getAggFunc() ?? null)) + (patch.hide !== undefined && patch.hide !== !column.visible) || + (patch.rowGroup !== undefined && !!patch.rowGroup !== column.rowGroupActive) || + (patch.pivot !== undefined && !!patch.pivot !== column.pivotActive) || + (patch.aggFunc !== undefined && (patch.aggFunc ?? null) !== (column.aggFunc ?? null)) ) { return true; } @@ -290,7 +283,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra if (sort) { for (const [colId, sortDef] of sort.sortDefsByColId) { - const column = beans.colModel.getColDefCol(colId); + const column = beans.colModel.getNonPivotColById(colId); if (!column) { continue; } @@ -310,11 +303,11 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra if (aggFuncs) { for (const [colId, aggFunc] of aggFuncs.values) { - const column = beans.colModel.getColDefCol(colId); + const column = beans.colModel.getNonPivotColById(colId); if (!column) { continue; } - if (aggFunc !== column.getAggFunc()) { + if (aggFunc !== column.aggFunc) { return true; } } @@ -334,129 +327,132 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra } const sortedEntries = operations.sort((a, b) => a.seq - b.seq); - for (const operation of sortedEntries) { - switch (operation.type) { - case 'columnState': { - _applyColumnState(beans, { state: [...operation.patches.values()] }, operation.eventType); - break; + + // Batch the role-column operations (rowGroup/aggregation/pivot) so consecutive ones share one + // refresh. Order-sensitive ops read live model state that a deferred role op leaves stale until + // refreshCols runs — columnOrder reads `colModel.colsList`, pivotMode reads `pivotColsSvc.columns` + // — so flush the batch before each, exactly as the unbatched path would. + // All ops here carry `eventType: 'toolPanelUi'`, so the flush source matches every dispatch. + const colModel = beans.colModel; + colModel.beginColBatch(); + try { + for (const operation of sortedEntries) { + if (!isRoleColumnOperation(operation)) { + // Close + reopen the batch to flush staged role changes, so this op reads fresh state. + colModel.endColBatch('toolPanelUi'); + colModel.beginColBatch(); } - case 'columnOrder': { - const orderedColumns = operation.colIds - .map((colId) => beans.colModel.getColDefCol(colId)) - .filter((column): column is AgColumn => !!column && isPrimaryColDefColumn(column)); - if (!beans.colModel.pivotMode) { - for (let i = 0; i < orderedColumns.length; i++) { - const column = orderedColumns[i]; - const allColumns = beans.colModel.getCols(); - const nonPrimaryPrefix = allColumns.findIndex((col) => isPrimaryColDefColumn(col)); - const targetIndex = (nonPrimaryPrefix >= 0 ? nonPrimaryPrefix : 0) + i; - if (allColumns[targetIndex] !== column) { - beans.colMoves?.moveColumns([column], targetIndex, operation.eventType, true); - } + this.applyOperation(operation); + } + } finally { + colModel.endColBatch('toolPanelUi'); + } + + this.reset(); + } + + private applyOperation(operation: CommitOperation): void { + const { beans } = this; + switch (operation.type) { + case 'columnState': { + _applyColumnState(beans, { state: [...operation.patches.values()] }, operation.eventType); + break; + } + case 'columnOrder': { + const orderedColumns = operation.colIds + .map((colId) => beans.colModel.getNonPivotColById(colId)) + .filter((column): column is AgColumn => !!column && isPrimaryColDefColumn(column)); + if (!beans.colModel.pivotMode) { + for (let i = 0; i < orderedColumns.length; i++) { + const column = orderedColumns[i]; + const allColumns = beans.colModel.colsList; + const nonPrimaryPrefix = allColumns.findIndex((col) => isPrimaryColDefColumn(col)); + const targetIndex = (nonPrimaryPrefix >= 0 ? nonPrimaryPrefix : 0) + i; + if (allColumns[targetIndex] !== column) { + beans.colMoves?.moveColumns([column], targetIndex, operation.eventType, true); } } - syncPrimaryColDefOrder(beans, orderedColumns); - break; - } - case 'rowGroup': { - beans.rowGroupColsSvc?.setColumns(operation.colIds, operation.eventType); - break; } - case 'aggregation': { - beans.valueColsSvc?.setColumns(operation.colIds, operation.eventType); - break; - } - case 'pivot': { - this.lastPivotColIds = operation.colIds; - beans.pivotColsSvc?.setColumns(operation.colIds, operation.eventType); - break; - } - case 'pivotMode': { - const { colModel, ctrlsSvc, gos, stateSvc } = beans; - if (operation.pivotMode !== colModel.pivotMode) { - const currentPivotColIds = beans.pivotColsSvc?.columns.map((col) => col.colId) ?? []; - if (currentPivotColIds.length > 0) { - this.lastPivotColIds = currentPivotColIds; - } - const previousPivotColIds = stateSvc?.getState().pivot?.pivotColIds ?? currentPivotColIds; - const pivotColIds = operation.pivotMode - ? (this.state.pivot?.colIds ?? this.lastPivotColIds) - : previousPivotColIds; - stateSvc?.setState( - { - ...stateSvc.getState(), - pivot: { - pivotMode: operation.pivotMode, - pivotColIds, - }, - }, - ['pivot'] - ); - - if (!operation.pivotMode) { - const cols = beans.colModel.getColDefCols() ?? []; - _applyColumnState( - beans, - { - state: cols.map((col) => ({ - colId: col.colId, - pivot: false, - pivotIndex: null, - })), - }, - operation.eventType - ); - } + syncPrimaryColDefOrder(beans, orderedColumns); + break; + } + case 'rowGroup': { + beans.rowGroupColsSvc?.setColumns(operation.colIds, operation.eventType); + break; + } + case 'aggregation': { + beans.valueColsSvc?.setColumns(operation.colIds, operation.eventType); + break; + } + case 'pivot': { + this.lastPivotColIds = operation.colIds; + beans.pivotColsSvc?.setColumns(operation.colIds, operation.eventType); + break; + } + case 'pivotMode': { + const { colModel, ctrlsSvc, gos } = beans; + if (operation.pivotMode !== colModel.pivotMode) { + // Remember the cols being pivoted on so re-enabling pivot mode restores them. + const currentPivotColIds = beans.pivotColsSvc?.columns.map((col) => col.colId) ?? []; + if (currentPivotColIds.length > 0) { + this.lastPivotColIds = currentPivotColIds; + } - gos.updateGridOptions({ - options: { pivotMode: operation.pivotMode }, - source: operation.eventType as any, - }); - if (operation.pivotMode && pivotColIds.length > 0) { + if (!operation.pivotMode) { + const cols = beans.colModel.colDefList; + const state = cols.map((col) => ({ colId: col.colId, pivot: false, pivotIndex: null })); + _applyColumnState(beans, { state }, operation.eventType); + } + + gos.updateGridOptions({ + options: { pivotMode: operation.pivotMode }, + source: operation.eventType as any, + }); + if (operation.pivotMode) { + const pivotColIds = this.state.pivot?.colIds ?? this.lastPivotColIds; + if (pivotColIds.length > 0) { beans.pivotColsSvc?.setColumns(pivotColIds, operation.eventType); } - ctrlsSvc.getHeaderRowContainerCtrl()?.refresh(); } - break; + ctrlsSvc.getHeaderRowContainerCtrl()?.refresh(); } - case 'sort': { - const sortState: ColumnState[] = []; - let sortIndex = 0; - for (const [colId, sortDef] of operation.sortDefsByColId) { - sortState.push({ - colId, - sort: sortDef?.direction ?? null, - sortIndex: sortDef?.direction ? sortIndex++ : null, - sortType: sortDef?.type ?? undefined, - }); - } - - _applyColumnState( - beans, - { - state: sortState, - defaultState: operation.baselineCleared - ? { sort: null, sortIndex: null, sortType: undefined } - : undefined, - }, - operation.eventType - ); - break; + break; + } + case 'sort': { + const sortState: ColumnState[] = []; + let sortIndex = 0; + for (const [colId, sortDef] of operation.sortDefsByColId) { + sortState.push({ + colId, + sort: sortDef?.direction ?? null, + sortIndex: sortDef?.direction ? sortIndex++ : null, + sortType: sortDef?.type ?? undefined, + }); } - case 'aggFuncs': { - for (const [colId, aggFunc] of operation.values) { - const column = beans.colModel.getColDefCol(colId); - if (!column) { - continue; - } - beans.valueColsSvc?.setColumnAggFunc?.(column, aggFunc, operation.eventType); + + _applyColumnState( + beans, + { + state: sortState, + defaultState: operation.baselineCleared + ? { sort: null, sortIndex: null, sortType: undefined } + : undefined, + }, + operation.eventType + ); + break; + } + case 'aggFuncs': { + for (const [colId, aggFunc] of operation.values) { + const column = beans.colModel.getNonPivotColById(colId); + if (!column) { + continue; } - break; + beans.valueColsSvc?.setColumnAggFunc(column, aggFunc, operation.eventType); } + break; } } - - this.reset(); } public applyColumnState(state: ColumnState[], eventType: ColumnEventType): void { @@ -515,7 +511,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra const aggFuncs = ensureAggFuncsDraft(this.state); for (const col of columns) { if (!liveValueColIds.has(col.colId) && !aggFuncs.values.has(col.colId)) { - const existingAggFunc = col.getAggFunc(); + const existingAggFunc = col.aggFunc; const aggFunc = existingAggFunc != null ? existingAggFunc : this.beans.aggFuncSvc?.getDefaultAggFunc(col); if (aggFunc != null) { @@ -534,11 +530,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra }; } - public setColumnAggFunc( - column: AgColumn, - aggFunc: string | IAggFunc | null | undefined, - eventType: ColumnEventType - ): void { + public setColumnAggFunc(column: AgColumn, aggFunc: ColAggFunc, eventType: ColumnEventType): void { mergeColumnStatePatch(this.state, { colId: column.colId, aggFunc }); const columnState = ensureColumnStateDraft(this.state); columnState.seq = nextSeq(this.sequence); @@ -550,12 +542,12 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra aggFuncs.values.set(column.colId, aggFunc); } - public getColumnAggFunc(column: AgColumn): string | IAggFunc | null | undefined { + public getColumnAggFunc(column: AgColumn): ColAggFunc { const colId = column.colId; if (this.state.aggFuncs?.values.has(colId)) { return this.state.aggFuncs.values.get(colId); } - return column.getAggFunc(); + return column.aggFunc; } public isColumnVisibleInToolPanel(column: AgColumn): boolean { @@ -563,7 +555,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra if (columnState?.hide !== undefined) { return !columnState.hide; } - return column.isVisible(); + return column.visible; } public isColumnSelectedInPivotModeToolPanel(column: AgColumn): boolean { @@ -576,7 +568,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra } else if (this.state.rowGroup) { rowGroupActive = this.state.rowGroup.colIds.includes(colId); } else { - rowGroupActive = column.isRowGroupActive(); + rowGroupActive = column.rowGroupActive; } let pivotActive: boolean; @@ -585,7 +577,7 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra } else if (this.state.pivot) { pivotActive = this.state.pivot.colIds.includes(colId); } else { - pivotActive = column.isPivotActive(); + pivotActive = column.pivotActive; } let valueActive: boolean; @@ -730,11 +722,20 @@ class DeferredColumnStateUpdateStrategy implements ColumnStateConcreteUpdateStra } } +/** Operations that mutate a role set via `setColumns` (deferred refresh) — safe to batch together. Excludes + * pivotMode/columnOrder/columnState/sort, which run their own refreshes and are order-sensitive. */ +function isRoleColumnOperation(operation: CommitOperation): boolean { + const type = operation.type; + return type === 'rowGroup' || type === 'aggregation' || type === 'pivot'; +} + function getDraftColumns(beans: BeanStub['beans'], colIds: string[] | undefined): AgColumn[] { if (!colIds) { return []; } - return colIds.map((colId) => beans.colModel.getColDefCol(colId)).filter((column): column is AgColumn => !!column); + return colIds + .map((colId) => beans.colModel.getNonPivotColById(colId)) + .filter((column): column is AgColumn => !!column); } function getDraftFunctionColumnIds( @@ -779,25 +780,24 @@ function getDraftFunctionColumnIds( } function syncPrimaryColDefOrderFromCurrentColumns(beans: BeanStub['beans']): void { - const orderedPrimaryColumns = beans.colModel - .getCols() + const orderedPrimaryColumns = beans.colModel.colsList .filter((column) => isPrimaryColDefColumn(column)) - .map((column) => beans.colModel.getColDefCol(column.colId)) + .map((column) => beans.colModel.getNonPivotCol(column.colId)) .filter((column): column is AgColumn => !!column); syncPrimaryColDefOrder(beans, orderedPrimaryColumns); } function syncPrimaryColDefOrder(beans: BeanStub['beans'], orderedPrimaryColumns: AgColumn[]): void { - const colDefCols = getMutablePrimaryColDefCollection(beans); - if (!colDefCols) { + const colDefList = beans.colModel.colDefList; + if (colDefList.length === 0) { return; } const orderedSet = new Set(orderedPrimaryColumns); - colDefCols.list = [ + beans.colModel.replaceColDefList([ ...orderedPrimaryColumns, - ...colDefCols.list.filter((col) => isPrimaryColDefColumn(col) && !orderedSet.has(col)), - ]; + ...colDefList.filter((col) => isPrimaryColDefColumn(col) && !orderedSet.has(col)), + ]); } function getPrimaryColumnIds(beans: BeanStub['beans']): string[] { @@ -805,20 +805,7 @@ function getPrimaryColumnIds(beans: BeanStub['beans']): string[] { } function getPrimaryColumns(beans: BeanStub['beans']): AgColumn[] { - return (beans.colModel.getColDefCols() ?? beans.colModel.getCols()).filter((column) => - isPrimaryColDefColumn(column) - ); -} - -function getMutablePrimaryColDefCollection(beans: BeanStub['beans']): { list: AgColumn[] } | undefined { - const colDefCols = beans.colModel.colDefCols; - const colDefList = colDefCols?.list; - - if (!Array.isArray(colDefList)) { - return undefined; - } - - return colDefCols as { list: AgColumn[] }; + return beans.colModel.colDefList.filter(isPrimaryColDefColumn); } function isPrimaryColDefColumn(column: AgColumn): boolean { @@ -878,7 +865,7 @@ function ensureAggFuncsDraft(state: DeferredState): NonNullable(), + values: new Map(), seq: 0, eventType: 'toolPanelUi', }; diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateStrategy.ts b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateStrategy.ts index d217b5d7c62..023a9010e67 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateStrategy.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateStrategy.ts @@ -1,8 +1,8 @@ import type { AgColumn, + ColAggFunc, ColumnEventType, ColumnState, - IAggFunc, IColumnStateUpdateStrategy, SortDef, } from 'ag-grid-community'; @@ -74,13 +74,13 @@ export class ColumnStateUpdateStrategy extends BeanStub implements IColumnStateU public setColumnAggFunc( deferMode: boolean, column: AgColumn, - aggFunc: string | IAggFunc | null | undefined, + aggFunc: ColAggFunc, eventType: ColumnEventType ): void { this.delegate('setColumnAggFunc', deferMode, column, aggFunc, eventType); } - public getColumnAggFunc(deferMode: boolean, column: AgColumn): string | IAggFunc | null | undefined { + public getColumnAggFunc(deferMode: boolean, column: AgColumn): ColAggFunc { return this.delegate('getColumnAggFunc', deferMode, column); } diff --git a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateTypes.ts b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateTypes.ts index 85877f05995..0fd86bdffc2 100644 --- a/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateTypes.ts +++ b/packages/ag-grid-enterprise/src/columnToolPanel/updates/columnStateUpdateTypes.ts @@ -1,4 +1,4 @@ -import type { AgColumn, ColumnEventType, ColumnState, IAggFunc, SortDef } from 'ag-grid-community'; +import type { AgColumn, ColAggFunc, ColumnEventType, ColumnState, SortDef } from 'ag-grid-community'; export type ColumnStateUpdateParams = { buttons?: Array<'apply' | 'cancel'> }; @@ -16,8 +16,8 @@ export interface ColumnStateConcreteUpdateStrategy { hasDeferredColumnOrder(): boolean; setValueColumns(columns: AgColumn[], eventType: ColumnEventType): void; getValueColumns(): AgColumn[]; - setColumnAggFunc(column: AgColumn, aggFunc: string | IAggFunc | null | undefined, eventType: ColumnEventType): void; - getColumnAggFunc(column: AgColumn): string | IAggFunc | null | undefined; + setColumnAggFunc(column: AgColumn, aggFunc: ColAggFunc, eventType: ColumnEventType): void; + getColumnAggFunc(column: AgColumn): ColAggFunc; setPivotColumns(columns: AgColumn[], eventType: ColumnEventType): void; getPivotColumns(): AgColumn[]; setPivotMode(pivotMode: boolean, eventType: ColumnEventType): void; @@ -32,7 +32,7 @@ type ColIdsDraft = { colIds: string[] } & Seq; type ColumnStateDraft = { patches: Map } & Seq; type PivotModeDraft = { pivotMode: boolean } & Seq; type SortDraft = { sortDefsByColId: Map; baselineCleared: boolean } & Seq; -type AggFuncsDraft = { values: Map } & Seq; +type AggFuncsDraft = { values: Map } & Seq; export type DeferredState = { columnState?: ColumnStateDraft; diff --git a/packages/ag-grid-enterprise/src/columns/baseColsService.ts b/packages/ag-grid-enterprise/src/columns/baseColsService.ts new file mode 100644 index 00000000000..903aac88873 --- /dev/null +++ b/packages/ag-grid-enterprise/src/columns/baseColsService.ts @@ -0,0 +1,322 @@ +import { _indexMap } from 'ag-stack'; + +import type { + AgColumn, + BeanCollection, + ColKey, + ColumnEventType, + ColumnModel, + ColumnState, + ColumnStateParams, + IColsService, + _ColumnChangedEventType, +} from 'ag-grid-community'; +import { BeanStub, _dispatchColumnChangedEvent } from 'ag-grid-community'; + +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export abstract class BaseColsService extends BeanStub implements IColsService { + protected colModel: ColumnModel; + + protected abstract eventName: _ColumnChangedEventType; + + /** Membership + insertion order; the single source of truth (O(1), idempotent add). The per-col flag + * (`rowGroupActive`/…) is a denormalised copy of `has(col)` for the public `Column` API. */ + protected activeColSet = new Set(); + + /** Lazy array view of {@link activeColSet}; `null` when stale. */ + private colsCache: AgColumn[] | null = null; + + /** Extract-pass buckets, opened lazily per pass and released by {@link commitExtract} (no stale carry-over). + * `…WithIndex` = cols with an order key (sorted first in commit); `…WithValue` = the rest. */ + private extractColsWithIndex: { col: AgColumn; key: number }[] | null = null; + private extractColsWithValue: Set | null = null; + + /** Bucket an indexed col, opening {@link extractColsWithIndex} lazily. */ + protected extractAddColWithIndex(col: AgColumn, key: number): void { + let bucket = this.extractColsWithIndex; + if (bucket === null) { + bucket = []; + this.extractColsWithIndex = bucket; + } + bucket.push({ col, key }); + } + + /** Bucket a non-indexed (value) col, opening {@link extractColsWithValue} lazily. */ + protected extractAddColWithValue(col: AgColumn): void { + let bucket = this.extractColsWithValue; + if (bucket === null) { + bucket = new Set(); + this.extractColsWithValue = bucket; + } + bucket.add(col); + } + + /** Cols changed since the last flush; non-null = dirty. Dispatched once by {@link dispatchColChange}. */ + public pendingChanged: Set | null = null; + + /** Active columns, in order. Ref-stable until the next edit. */ + public get columns(): AgColumn[] { + let cache = this.colsCache; + if (cache === null) { + cache = Array.from(this.activeColSet); + this.colsCache = cache; + } + return cache; + } + + public wireBeans(beans: BeanCollection): void { + this.colModel = beans.colModel; + } + + /** Replace the active cols with `cols` (must be dup-free); `cols` doubles as the cached view. */ + protected resetActiveCols(cols: AgColumn[]): void { + this.activeColSet = new Set(cols); + this.colsCache = cols; + } + + /** Activate/deactivate a col (O(1)): flag + per-col events via {@link writeColActive}, membership here, and + * the `runSideEffects`-gated {@link onColActiveChanged}. `OrderedColsService` overrides to seat virtuals. */ + protected setColActive(col: AgColumn, active: boolean, source: ColumnEventType, runSideEffects = false): boolean { + if (!this.writeColActive(col, active, source)) { + return false; + } + if (active) { + this.activeColSet.add(col); + } else { + this.activeColSet.delete(col); + } + if (runSideEffects) { + this.onColActiveChanged(col, active, source); + } + this.colsCache = null; + return true; + } + + /** Per-subclass: set the col's role flag and, if it changed, dispatch its per-col events; returns whether it + * flipped. Only this col's state — not the active set (that's {@link setColActive}). */ + protected abstract writeColActive(col: AgColumn, active: boolean, source: ColumnEventType): boolean; + + /** Auto side-effects of (de)activate (rowGroup auto-hide, value default agg-func); only on `runSideEffects` + * (imperative paths). Default no-op. */ + protected onColActiveChanged(_col: AgColumn, _active: boolean, _source: ColumnEventType): void {} + + /** Bulk diff: flip flags to match `targetSet` (insertion order = active order); `runSideEffects` runs + * {@link onColActiveChanged}. Reuses `targetSet` as the active set; `targetArr` seeds the cache, else lazy. */ + private applyActiveCols( + before: AgColumn[], + targetSet: Set, + targetArr: AgColumn[] | null, + source: ColumnEventType, + runSideEffects: boolean + ): void { + for (let i = 0, len = before.length; i < len; ++i) { + const col = before[i]; + if (!targetSet.has(col) && this.writeColActive(col, false, source) && runSideEffects) { + this.onColActiveChanged(col, false, source); + } + } + for (const col of targetSet) { + if (this.writeColActive(col, true, source) && runSideEffects) { + this.onColActiveChanged(col, true, source); + } + } + this.activeColSet = targetSet; + this.colsCache = targetArr; + } + + /** After a flush, dispatch batched per-col side-effects (rowGroup `columnVisible`). Default no-op. */ + protected onColActiveChangesComplete(_source: ColumnEventType): void {} + + /** React to a `this.columns` order/content change; `rowGroupColsSvc` stamps `rowGroupActiveIndex`. Default no-op. */ + protected onColumnsChanged(): void {} + + /** Cols differing in membership or position between `before` and `after` (the change-event payload). */ + private changedColsBetween(before: AgColumn[], after: AgColumn[]): AgColumn[] { + const afterIndex = _indexMap(after); + const changed: AgColumn[] = []; + for (let i = 0, len = before.length; i < len; ++i) { + const col = before[i]; + const newIndex = afterIndex.get(col); + if (newIndex === undefined || newIndex !== i) { + changed.push(col); // removed or moved + } + } + const beforeSet = before.length > 0 ? new Set(before) : null; + for (let i = 0, len = after.length; i < len; ++i) { + const col = after[i]; + if (!beforeSet?.has(col)) { + changed.push(col); // added + } + } + return changed; + } + + public setColumns(colKeys: ColKey[] | undefined, source: ColumnEventType): void { + const providedColKeys = colKeys ?? []; + const colModel = this.colModel; + if (colModel.colsList.length === 0) { + return; + } + + // `before` stays ref-stable: `applyActiveCols` reassigns the order array wholesale. + const before = this.columns; + + const newCols: AgColumn[] = []; + for (let i = 0, keysLen = providedColKeys.length; i < keysLen; ++i) { + const column = colModel.getNonPivotCol(providedColKeys[i]); + if (column) { + newCols.push(column); + } + } + // Provided keys, hierarchy virtuals expanded before each source. + const orderedSet = this.expandActiveCols(newCols); + const orderedArr = Array.from(orderedSet); + const changed = this.changedColsBetween(before, orderedArr); + if (changed.length === 0) { + return; // identical membership + order — nothing to refresh or dispatch + } + this.applyActiveCols(before, orderedSet, orderedArr, source, true); + this.stageColChange(changed); + colModel.flushColChanges(source, true); // membership change → refresh; defers when batched + } + + /** Seat a col into `res`; base adds just the col, `OrderedColsService` seats its virtuals first. */ + protected seatActiveCol(res: Set, col: AgColumn): void { + res.add(col); + } + + /** Expand to active order via {@link seatActiveCol}; dedupes into a fresh Set (insertion order = active order). */ + private expandActiveCols(cols: AgColumn[]): Set { + const res = new Set(); + for (let i = 0, len = cols.length; i < len; ++i) { + this.seatActiveCol(res, cols[i]); + } + return res; + } + + /** Record changed cols for the next {@link ColumnModel.flushColChanges}; a `Set` dedupes the payload. */ + protected stageColChange(changedCols: AgColumn[]): void { + this.onColumnsChanged(); + let pending = this.pendingChanged; + if (pending === null) { + pending = new Set(); + this.pendingChanged = pending; + } + for (let i = 0, len = changedCols.length; i < len; ++i) { + pending.add(changedCols[i]); + } + } + + /** Dispatch this service's staged change (if any); called by {@link ColumnModel.flushColChanges}. */ + public dispatchColChange(source: ColumnEventType): void { + // Drain batched side-effects (rowGroup visibility) unconditionally, so they're never stranded. + this.onColActiveChangesComplete(source); + const pending = this.pendingChanged; + if (pending) { + this.pendingChanged = null; + _dispatchColumnChangedEvent(this.eventSvc, this.eventName, Array.from(pending), source); + } + } + + public addColumns(keys: (ColKey | null | undefined)[] | undefined, source: ColumnEventType): void { + this.updateColList(keys, true, source); + } + + public removeColumns(keys: (ColKey | null | undefined)[] | undefined, source: ColumnEventType): void { + this.updateColList(keys, false, source); + } + + private updateColList(keys: (ColKey | null | undefined)[] | undefined, add: boolean, src: ColumnEventType): void { + if (!keys || keys.length === 0) { + return; + } + + const colModel = this.colModel; + const updatedCols = new Set(); + const before = add ? null : this.columns; // order snapshot for the removal shift below + let atLeastOne = false; + + for (let i = 0, len = keys.length; i < len; ++i) { + const key = keys[i]; + if (!key) { + continue; + } + const col = colModel.getNonPivotCol(key); + if (!col) { + continue; + } + updatedCols.add(col); + + if (this.setColActive(col, add, src, true)) { + atLeastOne = true; + if (!add) { + // Removed col: subsequent cols shift up — mark them for the event payload. + for (let j = before!.indexOf(col) + 1, blen = before!.length; j < blen; ++j) { + updatedCols.add(before![j]); + } + } + } + } + + if (!atLeastOne) { + return; + } + + this.stageColChange(Array.from(updatedCols)); + colModel.flushColChanges(src, true); // membership change → refresh; defers when batched + } + + /** Bucket one primary col for the pass; no-op if not in this role. `colIsNew` ⇒ `initial*` props apply. */ + public abstract extractCol(col: AgColumn, colIsNew: boolean): void; + + /** Finalise the pass: order the buckets, diff vs the previous active cols (flagging changes), re-seat, + * then release the buckets. */ + public commitExtract(source: ColumnEventType): AgColumn[] { + const extractColsWithIndex = this.extractColsWithIndex; + const extractColsWithValue = this.extractColsWithValue; + const activeColSet = this.activeColSet; + // Prior active cols, in prior order; flags still hold the OLD state until the diff below. + const previousCols = this.columns; + + // Order: indexed cols (sorted), then prior-order value cols, then the rest. `Set` keeps order + dedupes. + const res = new Set(); + if (extractColsWithIndex !== null) { + if (extractColsWithIndex.length > 1) { + extractColsWithIndex.sort((a, b) => a.key - b.key); + } + for (let i = 0, len = extractColsWithIndex.length; i < len; ++i) { + this.seatActiveCol(res, extractColsWithIndex[i].col); + } + } + if (extractColsWithValue !== null) { + // Existing value cols keep their prior order... + for (let i = 0, len = previousCols.length; i < len; ++i) { + const col = previousCols[i]; + if (extractColsWithValue.has(col)) { + this.seatActiveCol(res, col); + } + } + // ...then newly-included value cols in col-def order. + for (const col of extractColsWithValue) { + if (!activeColSet.has(col)) { + this.seatActiveCol(res, col); + } + } + } + + // Rebuild path (no side-effects); reuse `res` as the active set, array view materialises lazily. + this.applyActiveCols(previousCols, res, null, source, false); + this.onColumnsChanged(); + this.extractColsWithIndex = null; + this.extractColsWithValue = null; + return this.columns; + } + + /** Apply one `ColumnState` entry to this service; ordered services share the impl, `valueColsSvc` overrides. */ + public abstract syncColState( + column: AgColumn, + stateItem: ColumnState | null, + defaultState: ColumnStateParams | undefined, + source: ColumnEventType + ): void; +} diff --git a/packages/ag-grid-enterprise/src/columns/columnTreeEdit.ts b/packages/ag-grid-enterprise/src/columns/columnTreeEdit.ts new file mode 100644 index 00000000000..05e02d44f40 --- /dev/null +++ b/packages/ag-grid-enterprise/src/columns/columnTreeEdit.ts @@ -0,0 +1,131 @@ +import { _pushToMapArray } from 'ag-stack'; + +import type { AgColumn, AgProvidedColumnGroup, ColumnTreeBuild, ColumnTreeEdit } from 'ag-grid-community'; + +type ColNode = { col: AgColumn; prev: ColNode; next: ColNode }; + +/** Concrete edit session: circular leaf list for O(1) splices + deferred {@link ColumnTreeEdit.commit}. + * Community sees only the opaque {@link ColumnTreeEdit}. */ +interface ColumnTreeEditState extends ColumnTreeEdit { + /** Self-linked sentinel: `.next` = first leaf, `.prev` = last; splices skip head/tail null-checks. */ + readonly node: ColNode; + readonly byId: Map; + /** Parents a splice touched (`null` = the forest); bounds the {@link commitEdit} rebuild. */ + readonly affectedParents: Set; +} + +/** Materialise spliced `columns` from the list; at depth > 0 also rebuild touched parents' `.children`. + * The in-order walk yields each parent's children already ordered, so no sort; others keep theirs. */ +const commitEdit = (build: ColumnTreeBuild): void => { + // Enterprise is the sole writer of `build.edit`, so widening back to the concrete state is sound. + const edit = build.edit as ColumnTreeEditState; + const sentinel = edit.node; + const columns: AgColumn[] = []; + if (build.treeDepth === 0) { + for (let node = sentinel.next; node !== sentinel; node = node.next) { + columns.push(node.col); + } + build.columns = columns; + build.columnTree = columns; + return; + } + + const childrenByParent = new Map(); + const seenGroups = new Set(); + for (let node = sentinel.next; node !== sentinel; node = node.next) { + const col = node.col; + columns.push(col); + let group = col.originalParent; + while (group !== null && !seenGroups.has(group)) { + seenGroups.add(group); + _pushToMapArray(childrenByParent, group.originalParent, group); + group = group.originalParent; + } + _pushToMapArray(childrenByParent, col.originalParent, col); + } + + let tree = build.columnTree; + for (const parent of edit.affectedParents) { + const children = childrenByParent.get(parent) ?? []; + if (parent !== null) { + parent.children = children; + } else { + tree = children; + } + } + build.columns = columns; + build.columnTree = tree; +}; + +/** Open the edit session, seeding the circular list + colId index from the build's current leaves. */ +const openEdit = (build: ColumnTreeBuild): ColumnTreeEditState => { + const sentinel: ColNode = { col: null!, prev: null!, next: null! }; + const nodeById = new Map(); + const columns = build.columns; + let prev: ColNode = sentinel; + for (let i = 0, len = columns.length; i < len; ++i) { + const node: ColNode = { col: columns[i], prev, next: sentinel }; + prev.next = node; + nodeById.set(columns[i].colId, node); + prev = node; + } + prev.next = sentinel; + sentinel.prev = prev; + const edit: ColumnTreeEditState = { + node: sentinel, + byId: nodeById, + affectedParents: new Set(), + commit: commitEdit, + }; + build.edit = edit; + return edit; +}; + +/** Append `col` after `afterColId` (inheriting its group) if present, else at top-level end. O(1), lazily + * opens the session. NB: several cols sharing ONE anchor must be appended in reverse of desired order. */ +export const appendColumnToTree = (build: ColumnTreeBuild, col: AgColumn, afterColId?: string): void => { + const edit = (build.edit as ColumnTreeEditState | null) ?? openEdit(build); + const sentinel = edit.node; + const nodeById = edit.byId; + const anchor = afterColId != null ? nodeById.get(afterColId) : undefined; + const prev = anchor ?? sentinel.prev; + const next = prev.next; + const node: ColNode = { col, prev, next }; + prev.next = node; + next.prev = node; + nodeById.set(col.colId, node); + const parent = anchor ? anchor.col.originalParent : null; + col.originalParent = parent; + if (build.treeDepth > 0) { + edit.affectedParents.add(parent); + } +}; + +/** Prepend service/hierarchy `cols`, each wrapped to current depth so a bare leaf renders at leaf level. + * Back-to-front so the list ends in `cols` order; lazily opens the session, no-op when empty. */ +export const prependWrappedColumnsToTree = (build: ColumnTreeBuild, cols: AgColumn[]): void => { + const len = cols.length; + if (len === 0) { + return; + } + const edit = (build.edit as ColumnTreeEditState | null) ?? openEdit(build); + const sentinel = edit.node; + const nodeById = edit.byId; + const wrapperCache = build.wrapperCache!; + const depth = build.treeDepth; + const buildToken = build.buildToken; + for (let i = len - 1; i >= 0; --i) { + const col = cols[i]; + col.buildToken = buildToken; + wrapperCache.wrap(col, depth, buildToken); + const head = sentinel.next; + const node: ColNode = { col, prev: sentinel, next: head }; + sentinel.next = node; + head.prev = node; + nodeById.set(col.colId, node); + } + // Only root gains a front child (the top wrapper); inner padding children are self-set by `wrapOrReuse`. + if (depth > 0) { + edit.affectedParents.add(null); + } +}; diff --git a/packages/ag-grid-enterprise/src/columns/orderedColsService.ts b/packages/ag-grid-enterprise/src/columns/orderedColsService.ts new file mode 100644 index 00000000000..fb13d12e2a0 --- /dev/null +++ b/packages/ag-grid-enterprise/src/columns/orderedColsService.ts @@ -0,0 +1,277 @@ +import type { + AgColumn, + BeanCollection, + ColumnEventType, + ColumnState, + ColumnStateParams, + IGroupHierarchyColService, + IOrderedColsService, +} from 'ag-grid-community'; + +import { BaseColsService } from './baseColsService'; + +/** Index-ordered boolean-activation services (`rowGroupColsSvc`/`pivotColsSvc`); owns shared state-sync, + * ordering and hierarchy seating. (`valueColsSvc` uses `aggFunc`, so extends the base directly.) + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export abstract class OrderedColsService extends BaseColsService implements IOrderedColsService { + // ColDef/ColumnState prop names for this role; `initial*` are the new-col fallbacks. + protected abstract readonly enableProp: 'rowGroup' | 'pivot'; + protected abstract readonly indexProp: 'rowGroupIndex' | 'pivotIndex'; + protected abstract readonly initialEnableProp: 'initialRowGroup' | 'initialPivot'; + protected abstract readonly initialIndexProp: 'initialRowGroupIndex' | 'initialPivotIndex'; + + /** Writes just this col's role flag (`rowGroupActive`/`pivotActive`), returning false if already set (no-op). */ + protected abstract setActiveFlag(col: AgColumn, active: boolean): boolean; + + /** Flag write + per-col events; only the flag field (via {@link setActiveFlag}) and `enableProp` differ per service. */ + protected override writeColActive(col: AgColumn, active: boolean, source: ColumnEventType): boolean { + if (!this.setActiveFlag(col, active)) { + return false; + } + col.dispatchColEvent(this.eventName, source); + col.dispatchStateUpdatedEvent(this.enableProp); + return true; + } + + /** Hierarchy virtuals apply only to the ordered services — kept here so `BaseColsService` stays hierarchy-free. */ + private groupHierarchCols?: IGroupHierarchyColService; + + public override wireBeans(beans: BeanCollection): void { + super.wireBeans(beans); + this.groupHierarchCols = beans.groupHierarchyColSvc; + } + + private getActiveHierarchyCols(): IGroupHierarchyColService | undefined { + const svc = this.groupHierarchCols; + return svc?.columns.length ? svc : undefined; + } + + public override extractCol(col: AgColumn, colIsNew: boolean): void { + const colDef = col.colDef; + const indexProp = this.indexProp; + const initialIndexProp = this.initialIndexProp; + let include = decideExtractCol(colDef[this.enableProp], colDef[indexProp]); + if (include === undefined) { + // At extract time membership still reflects the prior state — the order set is the role-generic read. + include = colIsNew + ? decideExtractCol(colDef[this.initialEnableProp], colDef[initialIndexProp]) + : this.activeColSet.has(col); + } + if (!include) { + return; + } + const key = colDef[indexProp] ?? (colIsNew ? colDef[initialIndexProp] : null); + if (key != null) { + this.extractAddColWithIndex(col, key); + } else { + this.extractAddColWithValue(col); + } + } + + /** This col's hierarchy virtuals (date-part group levels) to seat immediately before it; undefined if none. */ + private getActiveVirtuals(column: AgColumn): AgColumn[] | undefined { + return this.groupHierarchCols?.getVirtualCols(column); + } + + /** Seat the col's hierarchy virtuals before it (bulk path; their flags are set later by the diff). */ + protected override seatActiveCol(res: Set, col: AgColumn): void { + const virtuals = this.getActiveVirtuals(col); + if (virtuals !== undefined) { + for (let i = 0, len = virtuals.length; i < len; ++i) { + res.add(virtuals[i]); + } + } + res.add(col); + } + + /** Incremental activate also seats (and flags) the col's hierarchy virtuals before it. */ + protected override setColActive( + col: AgColumn, + active: boolean, + source: ColumnEventType, + runSideEffects = false + ): boolean { + if (active) { + const virtuals = this.getActiveVirtuals(col); + if (virtuals !== undefined) { + const set = this.activeColSet; + for (let i = 0, len = virtuals.length; i < len; ++i) { + const vc = virtuals[i]; + set.add(vc); + this.writeColActive(vc, true, source); + } + } + } + return super.setColActive(col, active, source, runSideEffects); + } + + private pendingStateOrder: Map | null = null; + private pendingStateChanged = false; + + public override syncColState( + column: AgColumn, + stateItem: ColumnState | null, + defaultState: ColumnStateParams | undefined, + source: ColumnEventType + ): void { + // Enable + index read as a unit: `stateItem` wins if it mentions either, else both fall back to `defaultState`. + const enableProp = this.enableProp; + const indexProp = this.indexProp; + let enable = stateItem?.[enableProp]; + let idx = stateItem?.[indexProp]; + if (enable === undefined && idx === undefined) { + if (defaultState) { + enable = defaultState[enableProp]; + idx = defaultState[indexProp]; + } + if (enable === undefined && idx === undefined) { + return; + } + } + if (typeof idx === 'number') { + // An explicit index can reorder an already-active col, so flag regardless of whether it flipped. + this.setColActive(column, true, source); + let idxMap = this.pendingStateOrder; + if (idxMap === null) { + idxMap = new Map(); + this.pendingStateOrder = idxMap; + } + idxMap.set(column, idx); + this.pendingStateChanged = true; + } else if (this.setColActive(column, !!enable, source)) { + this.pendingStateChanged = true; + } + } + + /** Re-order + re-stamp active cols when this apply changed membership; else keep insertion order. */ + public sortByPendingState(): void { + if (!this.pendingStateChanged) { + return; + } + this.pendingStateChanged = false; + const cols = this.columns; + if (cols.length > 0) { + const hierarchy = this.getActiveHierarchyCols(); + if (hierarchy) { + cols.sort((a, b) => hierarchy.compareVirtualColumns(a, b) ?? this.compareByStateIndex(a, b)); + this.resetActiveCols(cols); + } else if (this.pendingStateOrder) { + cols.sort(this.compareByStateIndex); + this.resetActiveCols(cols); + } + } + this.onColumnsChanged(); + this.pendingStateOrder = null; + } + + private readonly compareByStateIndex = (a: AgColumn, b: AgColumn): number => { + const indexes = this.pendingStateOrder; + if (!indexes) { + return 0; + } + const aIdx = indexes.get(a); + const bIdx = indexes.get(b); + if (aIdx != null) { + return bIdx != null ? aIdx - bIdx : -1; + } + return bIdx != null ? 1 : 0; + }; + + /** Stamps synthetic `indexProp` onto `incoming`/`accumulator` so a `cellDataType`-inferred rowGroup/pivot + * flip keeps the original primary-col order (new cols slot in at their col-def position). */ + public restoreColumnOrder( + incoming: { [colId: string]: ColumnState }, + accumulator: { [colId: string]: ColumnState } + ): void { + const colList = this.columns; + if (!colList.length) { + return; + } + const isRowGroup = this.enableProp === 'rowGroup'; + const indexProp = this.indexProp; + + // `newColIds`: incoming colIds minus those already in `colList` — i.e. not-yet-known ones. + const newColIds = new Set(Object.keys(incoming)); + const allColIds = new Set(newColIds); + for (let i = 0, len = colList.length; i < len; ++i) { + const colId = colList[i].colId; + newColIds.delete(colId); + allColIds.add(colId); + } + + const colIdsInOriginalOrder: string[] = []; + const originalOrderMap: { [colId: string]: number } = Object.create(null); + let orderIndex = 0; + const primaryCols = this.colModel.colDefList; + for (let i = 0, len = primaryCols.length; i < len; ++i) { + const colId = primaryCols[i].colId; + if (allColIds.has(colId)) { + colIdsInOriginalOrder.push(colId); + originalOrderMap[colId] = orderIndex++; + } + } + + // follow approach in `resetColumnState` + let index = 1000; + let hasAddedNewCols = false; + let lastIndex = 0; + + const processPrecedingNewCols = (colId: string): void => { + const originalOrderIndex = originalOrderMap[colId]; + for (let i = lastIndex; i < originalOrderIndex; ++i) { + const newColId = colIdsInOriginalOrder[i]; + if (newColIds.has(newColId)) { + incoming[newColId][indexProp] = index++; + newColIds.delete(newColId); + } + } + lastIndex = originalOrderIndex; + }; + + for (let c = 0, cLen = colList.length; c < cLen; ++c) { + const column = colList[c]; + const colId = column.colId; + // Already in `incoming`? Its entries always carry a non-null colId, so this is the presence test. + if (incoming[colId]?.colId != null) { + // Already in incoming — place any new cols that slot before it, then assign next index. + processPrecedingNewCols(colId); + incoming[colId][indexProp] = index++; + continue; + } + const colDef = column.colDef; + const idx = isRowGroup ? colDef.rowGroupIndex : colDef.pivotIndex; + const initialIdx = isRowGroup ? colDef.initialRowGroupIndex : colDef.initialPivotIndex; + if (idx === null || (idx === undefined && initialIdx == null)) { + if (!hasAddedNewCols) { + const value = isRowGroup ? colDef.rowGroup : colDef.pivot; + const initialValue = isRowGroup ? colDef.initialRowGroup : colDef.initialPivot; + const propEnabled = value || (value === undefined && initialValue); + if (propEnabled) { + processPrecedingNewCols(colId); + } else { + // First manually added col — place all remaining new cols now, by original order index (needn't be contiguous). + for (const newColId of newColIds) { + incoming[newColId][indexProp] = index + originalOrderMap[newColId]; + } + index += colIdsInOriginalOrder.length; + hasAddedNewCols = true; + } + } + if (!accumulator[colId]) { + accumulator[colId] = { colId }; + } + accumulator[colId][indexProp] = index++; + } + } + } +} + +function decideExtractCol(value: boolean | null | undefined, index: number | null | undefined): boolean | undefined { + if (value !== undefined) { + return !!value; + } + if (index !== undefined) { + return index !== null && index >= 0; // `null` clears; a negative index excludes + } + return undefined; +} diff --git a/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts index 65684c13812..8a1f66484f4 100644 --- a/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts +++ b/packages/ag-grid-enterprise/src/filterToolPanel/agFiltersToolPanelList.ts @@ -104,7 +104,7 @@ export class AgFiltersToolPanelList extends Component 2) { const columnReference = trimmed.slice(1, -1); - const column = beans.colModel.getColById(columnReference); + const column = beans.colModel.getCol(columnReference) ?? null; if (!unsafe && !column) { throw new FormulaParseError(2, 0, trimmed.length, [trimmed]); } // Unsafe mode (e.g. paste-time parsing without grid context) stores the raw reference - // as the AST id — downstream lookups via getColById will not resolve it. + // as the AST id — downstream colsById lookups will not resolve it. return { - column: { id: column?.getColId() ?? columnReference, absolute: false }, + column: { id: column?.colId ?? columnReference, absolute: false }, row: { id: '', absolute: false, current: true }, }; } diff --git a/packages/ag-grid-enterprise/src/formula/ast/serializer.ts b/packages/ag-grid-enterprise/src/formula/ast/serializer.ts index f5556b250a0..35ed4361e01 100644 --- a/packages/ag-grid-enterprise/src/formula/ast/serializer.ts +++ b/packages/ag-grid-enterprise/src/formula/ast/serializer.ts @@ -9,7 +9,7 @@ import { FormulaError } from './utils'; const isOperationNode = (n: FormulaNode): n is FormulaOperation => n.type === 'operation'; function colLabelFromId(beans: BeanCollection, colId: string): string | null { - const col = beans.colModel.getColById(colId); + const col = beans.colModel.colsById[colId]; if (col) { return beans.formula?.getColRef(col) ?? null; } @@ -20,7 +20,7 @@ function colIdFromLabel(beans: BeanCollection, label: string): string | null { } export function colIndexFromId(colModel: ColumnModel, cols: AgColumn[], colId: string): number | null { - const col = colModel.getColById(colId); + const col = colModel.colsById[colId]; if (!col) { return null; diff --git a/packages/ag-grid-enterprise/src/formula/formulaService.ts b/packages/ag-grid-enterprise/src/formula/formulaService.ts index 36af6af9215..ff657e8fd48 100644 --- a/packages/ag-grid-enterprise/src/formula/formulaService.ts +++ b/packages/ag-grid-enterprise/src/formula/formulaService.ts @@ -9,7 +9,6 @@ import type { NamedBean, RowNode, _ChangedRowNodes, - _ColumnCollections, } from 'ag-grid-community'; import { BeanStub, _convertColumnEventSourceType, _warn } from 'ag-grid-community'; @@ -107,8 +106,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe * Recompute `active`, the `formulaColumnsPresent` cache, and trigger a full formula refresh * if the active state changed. Called by `columnModel` whenever the column set changes. */ - public setFormulasActive(cols: _ColumnCollections): void { - const columns = cols.list; + public setFormulasActive(columns: AgColumn[]): void { const calculatedColumnsEnabled = this.beans.calculatedColsSvc != null; let editableFormulaColumnsPresent = false; let calculatedColumnsPresent = false; @@ -127,9 +125,9 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe const formulaColumnsPresent = editableFormulaColumnsPresent || calculatedColumnsPresent; this.formulaColumnsPresent = formulaColumnsPresent; const editableFormulasCompatible = - editableFormulaColumnsPresent && this.checkForEditableFormulaIncompatibleServices(cols); + editableFormulaColumnsPresent && this.checkForEditableFormulaIncompatibleServices(columns); const calculatedColumnsCompatible = - calculatedColumnsPresent && this.checkForCalculatedColumnIncompatibleServices(cols); + calculatedColumnsPresent && this.checkForCalculatedColumnIncompatibleServices(columns); const editableFormulasSupported = this.beans.rowModel.getType() === 'clientSide'; const active = editableFormulasCompatible && editableFormulasSupported; const calculatedColumnsActive = calculatedColumnsCompatible; @@ -151,42 +149,35 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe _warn(295, { blockedService: 'Master Detail' }); return false; } - if (this.gos.get('enableCellExpressions')) { _warn(295, { blockedService: 'Cell Expressions' }); return false; } - return true; } - private checkForCalculatedColumnIncompatibleServices(cols: _ColumnCollections): boolean { + private checkForCalculatedColumnIncompatibleServices(columns: AgColumn[]): boolean { if (!this.checkForBaseIncompatibleServices()) { return false; } - - const columns = cols.list; - for (const col of columns) { + for (let i = 0, len = columns.length; i < len; ++i) { + const col = columns[i]; if (col.isPivotActive()) { _warn(295, { blockedService: 'Column Pivoting' }); return false; } } - return true; } - private checkForEditableFormulaIncompatibleServices(cols: _ColumnCollections): boolean { + private checkForEditableFormulaIncompatibleServices(columns: AgColumn[]): boolean { if (!this.checkForBaseIncompatibleServices()) { return false; } - if (this.gos.get('treeData')) { _warn(295, { blockedService: 'Tree Data' }); return false; } - - const columns = cols.list; for (let i = 0, len = columns.length; i < len; ++i) { const col = columns[i]; if (col.isAllowPivot() || col.isPivotActive()) { @@ -202,7 +193,6 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe return false; } } - return true; } @@ -467,7 +457,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe if (!this.isEvaluationActive()) { return; } - const list = beans.colModel.getCols(); + const list = beans.colModel.colsList; if (!list) { return; } @@ -743,7 +733,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe return; } - const referencedColumn = this.beans.colModel.getColById(reference); + const referencedColumn = this.beans.colModel.colsById[reference]; if (!referencedColumn) { canEvaluate = false; return; diff --git a/packages/ag-grid-enterprise/src/formula/functions/resolver.ts b/packages/ag-grid-enterprise/src/formula/functions/resolver.ts index 504c4e2bb49..0337c4b6357 100644 --- a/packages/ag-grid-enterprise/src/formula/functions/resolver.ts +++ b/packages/ag-grid-enterprise/src/formula/functions/resolver.ts @@ -77,7 +77,7 @@ function resolveRefToAddress( ? getFormulaRowByIndex(beans, Number(row.id) - 1) : beans.rowModel.getRowNode(row.id); - const agCol = column.absolute ? beans.formula!.getColByRef(column.id) : beans.colModel.getColById(column.id); + const agCol = column.absolute ? beans.formula!.getColByRef(column.id) : beans.colModel.colsById[column.id]; if (!rowNode || (!row.current && !isFormulaRowAvailable(rowNode)) || !agCol) { return null; @@ -281,7 +281,7 @@ function resolveCol(beans: BeanCollection, ref: CellRef): AgColumn { } return col; } - const col = beans.colModel.getColById(ref.id); + const col = beans.colModel.colsById[ref.id]; if (!col) { throw new FormulaError(31); } @@ -313,7 +313,7 @@ class RangeValuesIterator implements Iterator { return; } - this.cols = this.beans.colModel.getCols() ?? []; + this.cols = this.beans.colModel.colsList; const range = getColRangeIndices(this.beans, this.colStart, this.colEnd); if (!range) { @@ -390,7 +390,7 @@ function buildRangeArgLazy( export type Addr = { row: RowNode; column: AgColumn }; function getColRangeIndices(beans: BeanCollection, c1: AgColumn, c2: AgColumn): [number, number] | null { - const allColumns = beans.colModel.getCols() ?? []; + const allColumns = beans.colModel.colsList; let startColIndex: number | null = null; let endColIndex: number | null = null; @@ -426,7 +426,7 @@ function* rangeAddrs( startColumn: AgColumn, endColumn: AgColumn ): Generator { - const allColumns = beans.colModel.getCols() ?? []; + const allColumns = beans.colModel.colsList; const colRange = getColRangeIndices(beans, startColumn, endColumn); if (colRange == null) { return; diff --git a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts index aa8e03680c6..edf7e4f0a65 100644 --- a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts +++ b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyColService.ts @@ -1,315 +1,261 @@ -import { _removeAllFromArray } from 'ag-stack'; +import type { LocaleTextFunc } from 'ag-stack'; import type { ColDef, - ColKey, - GridOptions, + ColumnEventType, + ColumnTreeBuild, + GroupHierarchyConfig, IGroupHierarchyColService, NamedBean, - PropertyChangedEvent, - PropertyValueChangedEvent, - _ColumnCollections, -} from 'ag-grid-community'; -import { - AgColumn, - BeanStub, - GROUP_HIERARCHY_COLUMN_ID_PREFIX, - _addColumnDefaultAndTypes, - _areColIdsEqual, - _columnsMatch, - _destroyColumnTree, - _updateColsMap, } from 'ag-grid-community'; +import { AgColumn, BeanStub, GROUP_HIERARCHY_COLUMN_ID_PREFIX, _addColumnDefaultAndTypes } from 'ag-grid-community'; + +import { prependWrappedColumnsToTree } from '../columns/columnTreeEdit'; +import { getDatePartValueGetter, getHeaderValueGetter, numericalMonthToNamedMonth } from './groupHierarchyUtils'; + +/** A canonical date part: header label (+ optional distinct locale key), the date-part index its value + * getter reads, and an optional value mapper (quarter derives from month; formattedMonth localises it). */ +interface DatePartSpec { + label: string; + localeKey?: string; + index: number; + map?: (value: string, translate: LocaleTextFunc) => string; +} -import { - _getGroupHierarchy, - getDatePartValueGetter, - getHeaderValueGetter, - numericalMonthToNamedMonth, -} from './groupHierarchyUtils'; +const DATE_PART_SPECS = { + year: { label: 'Year', index: 0 }, + // `index: 1` is the 1-based month (1-12); quarter = ceil(month / 3): Q1=1-3, Q2=4-6, Q3=7-9, Q4=10-12. + quarter: { label: 'Quarter', index: 1, map: (m) => Math.ceil(Number(m) / 3).toString() }, + month: { label: 'Month', index: 1 }, + formattedMonth: { + label: 'Month', + localeKey: 'month', + index: 1, + map: (m, translate) => { + const named = numericalMonthToNamedMonth(m); + return translate(named.localeKey, named.month); + }, + }, + day: { label: 'Day', index: 2 }, + hour: { label: 'Hour', index: 3 }, + minute: { label: 'Minute', index: 4 }, + second: { label: 'Second', index: 5 }, +} satisfies Record; + +type HierarchyDatePart = keyof typeof DATE_PART_SPECS; + +/** Cheap projection of one expected hierarchy col; ColDef construction is deferred until needed. */ +interface HierarchyPlanEntry { + sourceCol: AgColumn; + part: string | ColDef; + colId: string; +} + +/** Reverse-lookup for a virtual col: source col + position in that source's bucket. The stored index + * lets `compareVirtualColumns` rank siblings in O(1) without re-scanning the bucket. */ +interface VirtualColInfo { + source: AgColumn; + index: number; +} export class GroupHierarchyColService extends BeanStub implements NamedBean, IGroupHierarchyColService { beanName = 'groupHierarchyColSvc' as const; - public columns: _ColumnCollections | null = null; - /** Map from primary column to virtual (i.e. generated) columns */ - private sourceColumnMap = new WeakMap(); - /** Map from virtual column to associated primary column. Inverse of `sourceColumnMap` */ - private inverseColumnMap = new WeakMap(); - - public addColumns(cols: _ColumnCollections): void { - const groupHierarchyCols = this.columns; - if (groupHierarchyCols == null) { + /** Generated hierarchy cols (year, quarter, month, …). `contributeTo` prepends these into the builder's + * tree; the builder owns their padding wrappers via ColumnModel's wrapper cache. */ + public columns: AgColumn[] = []; + + /** Source col → its generated virtuals. */ + private readonly sourceColumnMap = new Map(); + /** Virtual col → `{ source, index }` — the inverse of `sourceColumnMap`. */ + private readonly virtualColInfo = new Map(); + + /** Plan the expected colIds: rebuild the cols when they differ, else refresh their defs in place so a + * config / inline-part change is picked up without churning beans on a no-op refresh. */ + public contributeTo(build: ColumnTreeBuild): void { + const { plan, matches } = this.planHierarchy(build.columns); + if (plan.length === 0) { + if (this.columns.length > 0) { + this.clearColumns(); + } return; } - - cols.list = groupHierarchyCols.list - .filter((col) => !cols.list.some((c) => c.colId === col.colId)) - .concat(cols.list); - - cols.tree = groupHierarchyCols.tree - .filter((col) => !cols.tree.some((c) => c.getId() === col.getId())) - .concat(cols.tree); - - _updateColsMap(cols); + if (matches) { + this.reapplyDefs(plan, build.source); + } else { + this.rebuildColumns(plan); + } + prependWrappedColumnsToTree(build, this.columns); } - public createColumns(cols: _ColumnCollections): void { - const newSourceColumnMap = new WeakMap(); - const newInverseColumnMap = new WeakMap(); - - const list = this.createGroupHierarchyColumns(cols, newSourceColumnMap, newInverseColumnMap); - const areSame = _areColIdsEqual(list, this.columns?.list ?? []); - - if (areSame) { - return; + /** Same colIds: refresh each def in place. Reusing the live getters keeps an unchanged part a `setColDef` + * no-op, so only a real change (config / inline part / `defaultColDef`) re-applies. */ + private reapplyDefs(plan: HierarchyPlanEntry[], source: ColumnEventType): void { + const { columns, gos } = this; + for (let i = 0, len = plan.length; i < len; ++i) { + const { sourceCol, part, colId } = plan[i]; + const col = columns[i]; + const colDef = this.createColDefForPart(part, sourceCol, colId, col.colDef); + if (col.setColDef(colDef, null, source)) { + gos.validateColDef(colDef, colId, true); + } } - - _destroyColumnTree(this.beans, this.columns?.tree); - this.columns = null; - const { colGroupSvc } = this.beans; - const treeDepth = colGroupSvc?.findDepth(cols.tree) ?? 0; - const tree = colGroupSvc?.balanceTreeForAutoCols(list, treeDepth) ?? []; - this.columns = { - list, - tree, - treeDepth, - map: {}, - }; - this.sourceColumnMap = newSourceColumnMap; - this.inverseColumnMap = newInverseColumnMap; } - public updateColumns(_event: PropertyChangedEvent | PropertyValueChangedEvent): void { - // No-op + /** Allocate fresh hierarchy AgColumns from a plan whose colIds differ from current. */ + private rebuildColumns(plan: HierarchyPlanEntry[]): void { + const { sourceColumnMap, virtualColInfo, beans, gos } = this; + sourceColumnMap.clear(); + virtualColInfo.clear(); + const cols: AgColumn[] = new Array(plan.length); + for (let i = 0, len = plan.length; i < len; ++i) { + const { sourceCol, part, colId } = plan[i]; + const colDef = this.createColDefForPart(part, sourceCol, colId); + gos.validateColDef(colDef, colId, true); + const col = new AgColumn(colDef, null, colId, true, 'hierarchy'); + beans.context.createBean(col); + cols[i] = col; + const bucket = sourceColumnMap.get(sourceCol); + virtualColInfo.set(col, { source: sourceCol, index: bucket?.length ?? 0 }); + if (bucket) { + bucket.push(col); + } else { + sourceColumnMap.set(sourceCol, [col]); + } + } + this.columns = cols; } - public getColumn(key: ColKey): AgColumn | null { - return this.columns?.list.find((col) => _columnsMatch(col, key)) ?? null; + /** Project the hierarchy cols expected for `colDefList`; `matches` is true when their colIds equal the + * current `columns` (same count, same order), so the cols can be reused rather than rebuilt. */ + private planHierarchy(colDefList: AgColumn[]): { plan: HierarchyPlanEntry[]; matches: boolean } { + const config = this.gos.get('groupHierarchyConfig'); + const current = this.columns; + const plan: HierarchyPlanEntry[] = []; + let matches = true; + for (let i = 0, len = colDefList.length; i < len; ++i) { + const sourceCol = colDefList[i]; + const parts = hierarchyPartsForCol(sourceCol); + if (parts == null) { + continue; + } + for (let j = 0, m = parts.length; j < m; ++j) { + const part = parts[j]; + const colId = makeHierarchyColId(sourceCol.colId, part, config); + if (colId === null) { + continue; + } + const k = plan.length; + if (matches && (k >= current.length || current[k].colId !== colId)) { + matches = false; + } + plan.push({ sourceCol, part, colId }); + } + } + // Trailing current cols with no expected counterpart also count as a mismatch. + return { plan, matches: matches && plan.length === current.length }; } - public getColumns(): AgColumn[] | null { - return this.columns?.list ?? null; + public override destroy(): void { + this.clearColumns(); + super.destroy(); } - public expandColumnInto(target: AgColumn[], col: AgColumn): void { - const expanded = this.getVirtualColumnsForColumn(col).concat(col); - for (const expandedCol of expanded) { - if (!target.some((_c) => _columnsMatch(_c, expandedCol) || _c.colId === expandedCol.colId)) { - target.push(expandedCol); - } - } + private clearColumns(): void { + this.columns = []; + this.sourceColumnMap.clear(); + this.virtualColInfo.clear(); } public compareVirtualColumns(colA: AgColumn, colB: AgColumn): number | null { - const sourceA = this.inverseColumnMap.get(colA); - const sourceB = this.inverseColumnMap.get(colB); - if (sourceA && sourceA === sourceB) { - const hierarchyCols = this.sourceColumnMap.get(sourceA) ?? []; - return hierarchyCols?.indexOf(colA) - hierarchyCols?.indexOf(colB); + const virtualInfo = this.virtualColInfo; + const infoA = virtualInfo.get(colA); + const infoB = virtualInfo.get(colB); + // Both virtuals: same source ⇒ rank by stored bucket index; otherwise defer to caller (null). + if (infoA !== undefined && infoB !== undefined) { + return infoA.source === infoB.source ? infoA.index - infoB.index : null; } - - if (this.sourceColumnMap.get(colA)?.includes(colB)) { + // A virtual sorts BEFORE its own source col. + if (infoB?.source === colA) { return 1; } - - if (this.sourceColumnMap.get(colB)?.includes(colA)) { + if (infoA?.source === colB) { return -1; } - return null; } - public insertVirtualColumnsForCol(columns: AgColumn[], col: AgColumn): AgColumn[] { - const hierarchyCols = this.getVirtualColumnsForColumn(col); - if (!hierarchyCols) { - return []; - } - - // Index at which to insert the virtual columns - let idxCol = columns.indexOf(col); - if (idxCol < 0) { - idxCol = columns.length - 1; - } - - // For simplicity, reset the `columns` array by removing all associated - // virtual columns first - _removeAllFromArray(columns, hierarchyCols); - - // Insert the virtual columns in the given order - columns.splice(idxCol, 0, ...hierarchyCols); - - return hierarchyCols; - } - - private getVirtualColumnsForColumn(col: AgColumn): AgColumn[] { - if (this.isGroupHierarchyColsEnabledForCol(col)) { - return this.sourceColumnMap.get(col) ?? []; - } - return []; - } - - private isGroupHierarchyColsEnabled(cols: _ColumnCollections): boolean { - return cols.list.some((col) => this.isGroupHierarchyColsEnabledForCol(col)); - } - - private isGroupHierarchyColsEnabledForCol(col: AgColumn): boolean { - const def = col.colDef; - const groupHierarchy = _getGroupHierarchy(def); - return !!( - groupHierarchy && - (def.rowGroup || - def.enableRowGroup || - def.rowGroupIndex != null || - def.pivot || - def.enablePivot || - def.pivotIndex != null) - ); - } - - private createGroupHierarchyColDefs(sourceCol: AgColumn): ColDef[] { - const colDefs: ColDef[] = []; - const sourceColDef = sourceCol.colDef; - const groupHierarchy = _getGroupHierarchy(sourceColDef); - - if (!groupHierarchy) { - return colDefs; - } - - if (!this.isGroupHierarchyColsEnabledForCol(sourceCol)) { - return colDefs; - } - - for (const part of groupHierarchy) { - const colDef: ColDef | null = - typeof part === 'string' ? this.createColDefForPart(part, sourceCol, sourceColDef) : part; - if (colDef) { - colDefs.push(colDef); - } - } - - return colDefs; + /** This source col's generated virtuals, in order (seated immediately before it); undefined if none. */ + public getVirtualCols(sourceCol: AgColumn): AgColumn[] | undefined { + return this.sourceColumnMap.get(sourceCol); } - private createGroupHierarchyColumns( - cols: _ColumnCollections, - sourceColMap: WeakMap, - inverseColMap: WeakMap - ): AgColumn[] { - if (!this.isGroupHierarchyColsEnabled(cols)) { - return []; - } - - const newCols: AgColumn[] = []; + /** Build the ColDef for one part. Inline parts merge directly; configured parts overlay the config; a + * canonical date part takes its header/value getters from {@link DATE_PART_SPECS}. `reuse` (a same-col + * refresh) supplies the prior getters, so they aren't re-minted as fresh closures every refresh. */ + private createColDefForPart(part: string | ColDef, sourceCol: AgColumn, colId: string, reuse?: ColDef): ColDef { + const { beans, gos } = this; - for (const col of cols.list) { - for (const colDef of this.createGroupHierarchyColDefs(col)) { - const colId = colDef.colId!; - this.gos.validateColDef(colDef, colId, true); - const newCol = new AgColumn(colDef, null, colId, true); - this.createBean(newCol); - newCols.push(newCol); - updateMap(sourceColMap, col, newCol); - inverseColMap.set(newCol, col); - } + if (typeof part !== 'string') { + return _addColumnDefaultAndTypes(beans, part, colId, true); } - return newCols; - } - - private createColDefForPart(part: string, sourceCol: AgColumn, sourceColDef: ColDef): ColDef | null { - const { beans, gos } = this; - - const colId = `${GROUP_HIERARCHY_COLUMN_ID_PREFIX}-${sourceCol.colId}-${part}`; - const defaults: Partial = { - enableRowGroup: sourceColDef.enableRowGroup, - rowGroup: sourceColDef.rowGroup, - enablePivot: sourceColDef.enablePivot, - hide: true, - editable: false, - }; + const defaults: Partial = { hide: true, editable: false }; - const groupHierarchyConfig = gos.get('groupHierarchyConfig') ?? {}; - if (part in groupHierarchyConfig) { - const colDef = { ...defaults, ...groupHierarchyConfig[part] }; + const config = gos.get('groupHierarchyConfig') ?? {}; + if (part in config) { + const colDef = { ...defaults, ...config[part] }; colDef.colId ??= colId; return _addColumnDefaultAndTypes(beans, colDef, colDef.colId, true); } - const base: ColDef = _addColumnDefaultAndTypes(beans, { colId, ...defaults }, colId, true); + const base = _addColumnDefaultAndTypes(beans, { colId, ...defaults }, colId, true); + if (reuse?.valueGetter) { + return { ...base, headerValueGetter: reuse.headerValueGetter, valueGetter: reuse.valueGetter }; + } + // `makeHierarchyColId` only admits configured (handled above) or canonical parts, so the spec exists. + const spec: DatePartSpec = DATE_PART_SPECS[part as HierarchyDatePart]; const translate = this.getLocaleTextFunc(); - const translatePart = (part: string, fallback: string) => translate?.(part, fallback) ?? fallback; - - switch (part) { - case 'year': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Year')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 0), - }; - - case 'quarter': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Quarter')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 1, (month) => - (Math.floor(Number(month) / 4) + 1).toString() - ), - }; - - case 'month': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Month')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 1), - }; - - case 'formattedMonth': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart('month', 'Month')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 1, (month) => { - const nm = numericalMonthToNamedMonth(month); - return translatePart(nm.localeKey, nm.month); - }), - }; - - case 'day': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Day')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 2), - }; - - case 'hour': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Hour')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 3), - }; - - case 'minute': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Minute')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 4), - }; - - case 'second': - return { - ...base, - headerValueGetter: getHeaderValueGetter(beans, sourceCol, translatePart(part, 'Second')), - valueGetter: getDatePartValueGetter(beans, sourceCol, 5), - }; - - default: - return null; - } + const { map } = spec; + return { + ...base, + headerValueGetter: getHeaderValueGetter(beans, sourceCol, translate(spec.localeKey ?? part, spec.label)), + valueGetter: getDatePartValueGetter(beans, sourceCol, spec.index, map && ((v) => map(v, translate))), + }; } } -function updateMap(wm: WeakMap, key: T, value: T): void { - const existing = wm.get(key); - wm.set(key, (existing ?? []).concat(value)); -} +/** colId for `(sourceColId, part)`, or null when the part is invalid and must be skipped — an unrecognised + * string part (not configured, not canonical), or an inline ColDef without colId. */ +const makeHierarchyColId = ( + sourceColId: string, + part: string | ColDef, + config: GroupHierarchyConfig | undefined +): string | null => { + if (typeof part !== 'string') { + return part.colId || null; + } + if (config?.[part] === undefined && !(part in DATE_PART_SPECS)) { + return null; + } + return `${GROUP_HIERARCHY_COLUMN_ID_PREFIX}-${sourceColId}-${part}`; +}; + +/** The hierarchy parts iff the col is eligible for hierarchy generation, else null. */ +const hierarchyPartsForCol = (col: AgColumn): NonNullable | null | undefined => { + const def = col.colDef; + // Cheap eligibility gate first — only read hierarchy parts when the col participates in row-group / pivot. + if ( + !def.rowGroup && + !def.enableRowGroup && + def.rowGroupIndex == null && + !def.pivot && + !def.enablePivot && + def.pivotIndex == null + ) { + return null; + } + + return def.groupHierarchy ?? def.rowGroupingHierarchy; +}; diff --git a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts index 4f8afaea6aa..b9e4db296fa 100644 --- a/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts +++ b/packages/ag-grid-enterprise/src/groupHierarchy/groupHierarchyUtils.ts @@ -1,13 +1,6 @@ import { MONTHS, _getDateParts, _parseDateTimeFromString } from 'ag-stack'; -import type { - AgColumn, - BeanCollection, - ColDef, - HeaderValueGetterParams, - IRowNode, - ValueGetterParams, -} from 'ag-grid-community'; +import type { AgColumn, BeanCollection, HeaderValueGetterParams, IRowNode, ValueGetterParams } from 'ag-grid-community'; const getDate = ( { valueSvc, dataTypeSvc }: BeanCollection, @@ -55,7 +48,3 @@ export const numericalMonthToNamedMonth = (monthStr: string): { month: string; l const localeKey = MONTH_TO_LOCALE_KEY[month] ?? monthStr; return { month, localeKey }; }; - -export function _getGroupHierarchy(colDef: ColDef): ColDef['groupHierarchy'] { - return colDef.groupHierarchy ?? colDef.rowGroupingHierarchy; -} diff --git a/packages/ag-grid-enterprise/src/menu/columnChooserFactory.ts b/packages/ag-grid-enterprise/src/menu/columnChooserFactory.ts index bde5b45fdb2..ec033376ad2 100644 --- a/packages/ag-grid-enterprise/src/menu/columnChooserFactory.ts +++ b/packages/ag-grid-enterprise/src/menu/columnChooserFactory.ts @@ -75,8 +75,8 @@ export class ColumnChooserFactory extends BeanStub implements NamedBean { const columnSelectPanel = this.createColumnSelectPanel(this, column, true, chooserParams); const translate = this.getLocaleTextFunc(); const beans = this.beans; - const { visibleCols, focusSvc, menuUtils } = beans; - const columnIndex = visibleCols.allCols.indexOf(column as AgColumn); + const { focusSvc, menuUtils } = beans; + const columnIndex = column?.allColsIndex ?? -1; const headerPosition = column ? (focusSvc.focusedHeader ?? providedHeaderPosition ?? null) : null; this.activeColumnChooserDialog = this.createBean( diff --git a/packages/ag-grid-enterprise/src/menu/enterpriseMenu.ts b/packages/ag-grid-enterprise/src/menu/enterpriseMenu.ts index 9bd94852c0d..66513788fa8 100644 --- a/packages/ag-grid-enterprise/src/menu/enterpriseMenu.ts +++ b/packages/ag-grid-enterprise/src/menu/enterpriseMenu.ts @@ -28,7 +28,6 @@ import { _isLegacyMenuEnabled, _setColMenuVisible, _warn, - isColumn, } from 'ag-grid-community'; import type { AgCloseMenuEvent } from '../agStack/agMenuItemComponent'; @@ -106,7 +105,7 @@ export class EnterpriseMenuFactory extends BeanStub implements NamedBean, IMenuF column: AgColumn | undefined; columnGroup: AgProvidedColumnGroup | undefined; } { - const colIsColumn = columnOrGroup && isColumn(columnOrGroup); + const colIsColumn = columnOrGroup?.isColumn; const column = colIsColumn ? columnOrGroup : undefined; const columnGroup = colIsColumn ? undefined : columnOrGroup; return { column, columnGroup }; @@ -281,11 +280,11 @@ export class EnterpriseMenuFactory extends BeanStub implements NamedBean, IMenuF restrictToTabs?: ColumnMenuTab[], eventSource?: HTMLElement ) { - const { focusSvc, visibleCols, ctrlsSvc } = this.beans; + const { focusSvc, ctrlsSvc } = this.beans; const restoreFocusParams = { column, headerPosition: focusSvc.focusedHeader, - columnIndex: visibleCols.allCols.indexOf(column as AgColumn), + columnIndex: column?.allColsIndex ?? -1, eventSource, }; const menu = this.createMenu(column, columnGroup, restoreFocusParams, restrictToTabs, eventSource); diff --git a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts index d5878f4da73..4ecf9002028 100644 --- a/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts +++ b/packages/ag-grid-enterprise/src/menu/menuItemMapper.ts @@ -7,10 +7,10 @@ import type { DefaultMenuItem, GetNoteParams, IAggFuncService, - IColsService, IMenuActionParams, INoteAccess, INotesService, + IValueColsService, MenuItemDef, NamedBean, RowNode, @@ -296,7 +296,7 @@ export class MenuItemMapper extends BeanStub implements NamedBean { rowGroupColsSvc.setColumns(rowGroupColsSvc.columns.slice(0, lockedGroups), source); } else if (typeof showRowGroup === 'string') { // Handle multiple auto group columns - const underlyingColumn = colModel.getColDefCol(showRowGroup); + const underlyingColumn = colModel.getNonPivotCol(showRowGroup); const ungroupByName = underlyingColumn != null ? colNames.getDisplayNameForColumn(underlyingColumn, 'header') @@ -657,7 +657,7 @@ function createNoteMenuItems({ function createAggregationSubMenu( column: AgColumn, aggFuncSvc: IAggFuncService, - valueColsSvc: IColsService, + valueColsSvc: IValueColsService, localeTextFunc: LocaleTextFunc ): MenuItemDef[] { let columnToUse: AgColumn | undefined; diff --git a/packages/ag-grid-enterprise/src/pivot/pivotApi.ts b/packages/ag-grid-enterprise/src/pivot/pivotApi.ts index 3ff4ff1cbfe..972735126df 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotApi.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotApi.ts @@ -49,6 +49,5 @@ export function setPivotResultColumns(beans: BeanCollection, colDefs: (ColDef | } export function getPivotResultColumns(beans: BeanCollection): Column[] | null { - const pivotResultCols = beans.pivotResultCols?.getPivotResultCols(); - return pivotResultCols ? pivotResultCols.list : null; + return beans.pivotResultCols?.pivotCols ?? null; } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts b/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts index 26cb72345da..dd10698d6b6 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotColDefService.ts @@ -5,8 +5,9 @@ import type { ColGroupDef, ColumnModel, ColumnNameService, - IColsService, IPivotColDefService, + IPivotColsService, + IValueColsService, NamedBean, } from 'ag-grid-community'; import { BeanStub } from 'ag-grid-community'; @@ -41,8 +42,8 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol beanName = 'pivotColDefSvc' as const; private colModel: ColumnModel; - private pivotColsSvc?: IColsService; - private valueColsSvc?: IColsService; + private pivotColsSvc?: IPivotColsService; + private valueColsSvc?: IValueColsService; private colNames: ColumnNameService; public wireBeans(beans: BeanCollection) { @@ -522,7 +523,7 @@ export class PivotColDefService extends BeanStub implements NamedBean, IPivotCol } if (children.length === 0) { - const potentialAggCol = this.colModel.getColDefCol(key); + const potentialAggCol = this.colModel.getNonPivotCol(key); if (potentialAggCol) { const headerName = this.colNames.getDisplayNameForColumn(potentialAggCol, 'header') ?? key; const colDef = this.createColDef(potentialAggCol, headerName, undefined, false); diff --git a/packages/ag-grid-enterprise/src/pivot/pivotColsSvc.ts b/packages/ag-grid-enterprise/src/pivot/pivotColsSvc.ts index 269b1fbf7c7..febdb67c461 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotColsSvc.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotColsSvc.ts @@ -1,82 +1,52 @@ -import { _removeFromArray } from 'ag-stack'; +import type { AgColumn, IPivotColsService, NamedBean } from 'ag-grid-community'; -import type { AgColumn, ColDef, ColumnEventType, ColumnStateParams, IColsService, NamedBean } from 'ag-grid-community'; -import { BaseColsService } from 'ag-grid-community'; +import { OrderedColsService } from '../columns/orderedColsService'; -export class PivotColsSvc extends BaseColsService implements NamedBean, IColsService { +export class PivotColsSvc extends OrderedColsService implements NamedBean, IPivotColsService { beanName = 'pivotColsSvc' as const; - eventName = 'columnPivotChanged' as const; - override columnProcessors = { - set: (column: AgColumn, added: boolean, source: ColumnEventType) => - this.setColPivotActive(column, added, source), - add: (column: AgColumn, added: boolean, source: ColumnEventType) => - this.setColPivotActive(column, true, source), - remove: (column: AgColumn, added: boolean, source: ColumnEventType) => - this.setColPivotActive(column, false, source), - } as const; - - override columnOrdering = { - enableProp: 'pivot', - initialEnableProp: 'initialPivot', - indexProp: 'pivotIndex', - initialIndexProp: 'initialPivotIndex', - } as const; - - override columnExtractors = { - setFlagFunc: (col: AgColumn, flag: boolean, source: ColumnEventType) => - this.setColPivotActive(col, flag, source), - getIndexFunc: (colDef: ColDef) => colDef.pivotIndex, - getInitialIndexFunc: (colDef: ColDef) => colDef.initialPivotIndex, - getValueFunc: (colDef: ColDef) => colDef.pivot, - getInitialValueFunc: (colDef: ColDef) => colDef.initialPivot, - } as const; - - private readonly modifyColumnsNoEventsCallbacks = { - addCol: (column: AgColumn) => { - if (!this.columns.includes(column)) { - this.columns.push(column); - } - }, - removeCol: (column: AgColumn) => _removeFromArray(this.columns, column), - }; - - public syncColumnWithState( - column: AgColumn, - source: ColumnEventType, - getValue: ( - key1: U, - key2?: S - ) => { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined }, - rowIndex: { [key: string]: number } | null - ): void { - const { value1: pivot, value2: pivotIndex } = getValue('pivot', 'pivotIndex'); - if (pivot !== undefined || pivotIndex !== undefined) { - if (typeof pivotIndex === 'number' || pivot) { - if (!column.isPivotActive()) { - this.setColPivotActive(column, true, source); - this.modifyColumnsNoEventsCallbacks.addCol(column); - } - if (rowIndex && typeof pivotIndex === 'number') { - rowIndex[column.getId()] = pivotIndex; + protected override eventName = 'columnPivotChanged' as const; + protected override enableProp = 'pivot' as const; + protected override indexProp = 'pivotIndex' as const; + protected override initialEnableProp = 'initialPivot' as const; + protected override initialIndexProp = 'initialPivotIndex' as const; + + /** True if any active pivot col has a `pivotComparator`; cached so {@link isStrictColumnOrder} stays O(1). */ + private hasPivotComparator = false; + + public postConstruct(): void { + this.addManagedEventListeners({ + columnValueChanged: () => { + // In pivot mode the sort cache filters by value-col membership (driven by aggFunc); + // an in-place aggFunc change doesn't rebuild columns, so invalidate it. + const beans = this.beans; + if (beans.colModel.pivotMode) { + beans.sortSvc?.invalidate(); } - } else if (column.isPivotActive()) { - this.setColPivotActive(column, false, source); - this.modifyColumnsNoEventsCallbacks.removeCol(column); - } - } + }, + }); } - private setColPivotActive(column: AgColumn, pivot: boolean, source: ColumnEventType): void { - if (column.pivotActive !== pivot) { - column.pivotActive = pivot; - - if (pivot) { - const addedCols = this.beans.groupHierarchyColSvc?.insertVirtualColumnsForCol(this.columns, column); - addedCols?.forEach((c) => this.setColPivotActive(c, pivot, source)); - } + protected override setActiveFlag(col: AgColumn, active: boolean): boolean { + if (col.pivotActive === active) { + return false; + } + col.pivotActive = active; + return true; + } - column.dispatchColEvent('columnPivotChanged', source); + /** Stamps each active pivot col's position (`pivotActiveIndex`) and refreshes {@link hasPivotComparator}. */ + protected override onColumnsChanged(): void { + const cols = this.columns; + let hasPivotComparator = false; + for (let i = 0, len = cols.length; i < len; ++i) { + const col = cols[i]; + col.pivotActiveIndex = i; + hasPivotComparator ||= col.colDef.pivotComparator != null; } - column.dispatchStateUpdatedEvent('pivot'); + this.hasPivotComparator = hasPivotComparator; + } + + public isStrictColumnOrder(): boolean { + return this.hasPivotComparator && !!this.gos.get('enableStrictPivotColumnOrder'); } } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts index c8978dd2dbd..eadd3b145e3 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts @@ -1,7 +1,6 @@ -import { _areEqual, _exists } from 'ag-stack'; +import { _areEqual, _pushToMapArray } from 'ag-stack'; import type { - AbstractColDef, AgColumn, AgProvidedColumnGroup, BeanCollection, @@ -13,15 +12,14 @@ import type { IPivotResultColsService, NamedBean, VisibleColsService, - _ColumnCollections, -} from 'ag-grid-community'; -import { - BeanStub, - _createColumnTree, - _createColumnTreeWithIds, - _destroyColumnTree, - _getColumnsFromTree, } from 'ag-grid-community'; +import { BeanStub, _buildColumnTree, _destroyColumnTreeAll, _destroyColumnTreeUnused } from 'ag-grid-community'; + +type SavedPivotCols = { + tree: (AgColumn | AgProvidedColumnGroup)[]; + cols: AgColumn[] | null; + groups: AgProvidedColumnGroup[]; +}; export class PivotResultColsService extends BeanStub implements NamedBean, IPivotResultColsService { beanName = 'pivotResultCols' as const; @@ -34,174 +32,254 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo this.visibleCols = beans.visibleCols; } - // if pivoting, these are the generated columns as a result of the pivot - private pivotResultCols: _ColumnCollections | null; + public pivotCols: AgColumn[] | null = null; + public pivotTree: (AgColumn | AgProvidedColumnGroup)[] = []; + public pivotTreeDepth = 0; + /** Source value col → pivot result cols derived from it; lazily built on first `recreateColDefsForSource` + * after a rebuild (`null` ⇒ needs rebuild), so a rebuild with no value-col def change skips the O(N) walk. */ + private dependentsByValueCol: Map | null = null; + /** True iff any `pivotTree` group carries `marryChildren`; `ColumnModel` reads it while pivoting. */ + public pivotHasMarryChildren = false; + /** Non-padding groups in `pivotTree`, keyed by `groupId`. Built by `_buildColumnTree`. */ + public pivotGroupsById: Map = new Map(); + /** Cols in `pivotTree` keyed by colId / userProvidedColDef ref / field. Cached so the next + * build's reuse lookup can skip the existing-tree DFS. */ + private pivotColsByKey: Map = new Map(); + /** Every group (padding + non-padding) in `pivotTree`; the orphan sweep and `visibleColsService` + * (via `colModel.colsAllGroups`) need padding groups included when pruning/resetting `displayInstances`. */ + public pivotAllGroups: AgProvidedColumnGroup[] = []; + /** Held between clear and the next apply so generated col instances are reused. */ + private savedPivot: SavedPivotCols | null = null; - // Cached aggregation-ordered list: regular columns first, total columns after. - // Lazily computed on first access, invalidated when pivot result columns change. + /** `undefined` = uncached, `null` = cached-but-empty. */ private aggOrderedList: AgColumn[] | null | undefined; - // Saved when pivot is disabled, available to re-use when pivot is restored - private previousPivotResultCols: (AgColumn | AgProvidedColumnGroup)[] | null; - - public override destroy(): void { - _destroyColumnTree(this.beans, this.pivotResultCols?.tree); - super.destroy(); - } - - public isPivotResultColsPresent(): boolean { - return this.pivotResultCols != null; - } - public lookupPivotResultCol(pivotKeys: string[], valueColKey: ColKey): AgColumn | null { - if (this.pivotResultCols == null) { + const pivotCols = this.pivotCols; + if (pivotCols == null) { return null; } - - const valueColumnToFind = this.colModel.getColDefCol(valueColKey); - - let foundColumn: AgColumn | null = null; - - for (const column of this.pivotResultCols.list) { + const valueColumnToFind = this.colModel.getNonPivotCol(valueColKey); + for (let i = 0, len = pivotCols.length; i < len; ++i) { + const column = pivotCols[i]; const colDef = column.colDef; - const thisPivotKeys = colDef.pivotKeys; - const pivotValueColumn = colDef.pivotValueColumn; - - const pivotKeyMatches = _areEqual(thisPivotKeys, pivotKeys); - const pivotValueMatches = pivotValueColumn === valueColumnToFind; - - if (pivotKeyMatches && pivotValueMatches) { - foundColumn = column; + if (colDef.pivotValueColumn === valueColumnToFind && _areEqual(colDef.pivotKeys, pivotKeys)) { + return column; } } + return null; + } - return foundColumn; + public getAggregationOrderedList(): AgColumn[] | null { + const aggOrderedList = this.aggOrderedList; + return aggOrderedList !== undefined ? aggOrderedList : this.loadAggregationOrderedList(); } - public getPivotResultCols(): _ColumnCollections | null { - return this.pivotResultCols; + public buildAllCols(): AgColumn[] { + // Displayed cols in display order, then any parked primaries. + const parked = this.collectParkedPrimaries(); + const colsList = this.colModel.colsList; + return parked === null ? colsList : colsList.concat(parked); // no parked → colsList is the full set (no alloc) } - public getPivotResultCol(key: ColKey): AgColumn | null { - if (!this.pivotResultCols) { - return null; + public buildColsInStateOrder(): AgColumn[] { + // Parked primaries first, then the displayed cols in display order. + const parked = this.collectParkedPrimaries(); + const colsList = this.colModel.colsList; + if (parked === null) { + return colsList; // no parked → colsList is already the full order (no alloc) + } + for (let i = 0, len = colsList.length; i < len; ++i) { + parked.push(colsList[i]); } - return this.colModel.getColFromCollection(key, this.pivotResultCols); + return parked; } - public getAggregationOrderedList(): AgColumn[] | null { - let result = this.aggOrderedList; - if (result !== undefined) { - return result; + /** Primaries in `colsById` but not `colsList` (hidden/parked), in col-def order; `null` if there are none. */ + private collectParkedPrimaries(): AgColumn[] | null { + const colDefList = this.colModel.colDefList; + let parked: AgColumn[] | null = null; + for (let i = 0, len = colDefList.length; i < len; ++i) { + const col = colDefList[i]; + if (!col.inColsList) { + parked ??= []; + parked.push(col); + } } - const list = this.pivotResultCols?.list; + return parked; + } + + private loadAggregationOrderedList(): AgColumn[] | null { + const list = this.pivotCols; if (!list || list.length === 0) { this.aggOrderedList = null; return null; } - // Partition: regular columns first (no pivotTotalColumnIds), totals appended after. - // Aggregation requires this order because total columns read from already-computed regular results. - let hasAnyTotals = false; - for (let i = 0; i < list.length; ++i) { - const colDef = list[i].colDef; - if (colDef.pivotTotalColumnIds != null) { - hasAnyTotals = true; - break; - } - } - if (!hasAnyTotals) { - // No totals — the list is already in the right order. - result = list; - } else { - const regular: AgColumn[] = []; - const totals: AgColumn[] = []; - for (let i = 0; i < list.length; ++i) { - const col = list[i]; - if (col.colDef.pivotTotalColumnIds != null) { - totals.push(col); - } else { - regular.push(col); + // Regular cols first, totals after: totals read already-computed regular results during + // aggregation. Defer allocation — until a total is seen the input list is correct (returned by ref). + let regular: AgColumn[] | null = null; + let totals: AgColumn[] | null = null; + for (let i = 0, len = list.length; i < len; ++i) { + const col = list[i]; + if (col.colDef.pivotTotalColumnIds != null) { + if (totals === null) { + totals = []; + regular = list.slice(0, i); } + totals.push(col); + } else if (regular !== null) { + regular.push(col); } - result = regular.concat(totals); } + const result = totals === null ? list : regular!.concat(totals); this.aggOrderedList = result; return result; } public setPivotResultCols(colDefs: (ColDef | ColGroupDef)[] | null, source: ColumnEventType): void { - this.aggOrderedList = undefined; // Invalidate cached aggregation order - if (!this.colModel.ready) { - return; - } - - // if no cols passed, and we had no cols anyway, then do nothing - if (colDefs == null && this.pivotResultCols == null) { + this.aggOrderedList = undefined; + const colModel = this.colModel; + if (!colModel.ready) { return; } - if (colDefs) { this.processPivotResultColDef(colDefs); - // if the attempt has come from the API, can't guarantee the user has provided IDs. - const createColTreeFunc = source === 'api' ? _createColumnTree : _createColumnTreeWithIds; - const balancedTreeResult = createColTreeFunc( - this.beans, - colDefs, - false, - this.pivotResultCols?.tree || this.previousPivotResultCols || undefined, - source - ); - _destroyColumnTree(this.beans, this.pivotResultCols?.tree, balancedTreeResult.columnTree); - - const tree = balancedTreeResult.columnTree; - const treeDepth = balancedTreeResult.treeDepth; - const list = _getColumnsFromTree(tree); - const map = {}; - - this.pivotResultCols = { tree, treeDepth, list, map }; - for (const col of this.pivotResultCols.list) { - this.pivotResultCols.map[col.getId()] = col; - } - const hasPreviousCols = !!this.previousPivotResultCols; - this.previousPivotResultCols = null; - this.colModel.refreshCols(!hasPreviousCols, source); + this.applyPivotResultColDefs(colDefs, source); + } else if (this.pivotCols != null) { + this.clearPivotResultCols(source); } else { - this.previousPivotResultCols = this.pivotResultCols ? this.pivotResultCols.tree : null; - this.pivotResultCols = null; + return; + } + this.visibleCols.refresh(source, false); + } - this.colModel.refreshCols(false, source); + public override destroy(): void { + // Release pivot cols/groups including any saved set held over a clear/restore window. + if (this.pivotCols) { + _destroyColumnTreeAll(this.pivotCols, this.pivotAllGroups); + } + this.pivotTree = []; + this.pivotCols = null; + this.pivotAllGroups = []; + this.dependentsByValueCol = null; + const saved = this.savedPivot; + if (saved) { + this.savedPivot = null; + _destroyColumnTreeAll(saved.cols, saved.groups); } - this.visibleCols.refresh(source); + super.destroy(); } - private processPivotResultColDef(colDefs: (ColDef | ColGroupDef)[] | null) { - const columnCallback = this.gos.get('processPivotResultColDef'); - const groupCallback = this.gos.get('processPivotResultColGroupDef'); + /** Builds a new pivot result column tree from the supplied colDefs and refreshes display. */ + private applyPivotResultColDefs(colDefs: (ColDef | ColGroupDef)[], source: ColumnEventType): void { + const beans = this.beans; + const currentPivotCols = this.pivotCols; + const currentPivotTree = currentPivotCols ? this.pivotTree : null; + const saved = this.savedPivot; + const strictResort = beans.pivotColsSvc?.isStrictColumnOrder() ?? false; + const restoring = currentPivotTree == null && saved != null && !strictResort; + const previousTree = currentPivotTree ?? saved?.tree ?? null; + // Pick the cols/groups lists that pair with `previousTree` for the post-build sweep. + const previousCols = currentPivotTree ? currentPivotCols! : (saved?.cols ?? null); + const previousAllGroups = currentPivotTree ? this.pivotAllGroups : (saved?.groups ?? null); + const buildToken = beans.colModel.nextBuildToken(); + const balanced = _buildColumnTree( + beans, + colDefs, + false, + this.pivotGroupsById, + this.pivotColsByKey, + beans.colModel.colsById, + source, + buildToken, + null + ); + // `previousTree` (not `currentPivotTree`) covers the clear/restore window where `currentPivotTree` is + // null but saved bean refs survive. Skip the sweep when the tree is missing or unchanged. + if (previousTree && previousTree !== balanced.columnTree) { + _destroyColumnTreeUnused(previousCols ?? [], previousAllGroups ?? [], buildToken); + } - if (!columnCallback && !groupCallback) { - return undefined; - } - - const searchForColDefs = (colDefs2: (ColDef | ColGroupDef)[]): void => { - colDefs2.forEach((abstractColDef: AbstractColDef) => { - const isGroup = _exists((abstractColDef as any).children); - if (isGroup) { - const colGroupDef = abstractColDef as ColGroupDef; - if (groupCallback) { - groupCallback(colGroupDef); - } - searchForColDefs(colGroupDef.children); - } else { - const colDef = abstractColDef as ColDef; - if (columnCallback) { - columnCallback(colDef); - } + this.pivotCols = balanced.columns; + this.pivotTree = balanced.columnTree; + this.pivotTreeDepth = balanced.treeDepth; + this.pivotHasMarryChildren = balanced.marryChildren; + this.pivotGroupsById = balanced.groupsById; + this.pivotColsByKey = balanced.colsByKey; + this.pivotAllGroups = balanced.allGroups; + this.savedPivot = null; + this.dependentsByValueCol = null; + + // `newColDefs=true` resets sticky col order; suppress when restoring pivot after a clear to preserve prev order. + this.colModel.refreshCols(!restoring, source); + } + + private clearPivotResultCols(source: ColumnEventType): void { + this.savedPivot = { tree: this.pivotTree, cols: this.pivotCols, groups: this.pivotAllGroups }; + this.pivotCols = null; + this.pivotTree = []; + this.pivotTreeDepth = 0; + this.pivotHasMarryChildren = false; + this.pivotAllGroups = []; + this.dependentsByValueCol = null; + this.colModel.refreshCols(false, source); + } + + public recreateColDefsForSource(sourceCol: AgColumn, source: ColumnEventType): void { + const cols = this.pivotCols; + if (cols == null) { + return; + } + let map = this.dependentsByValueCol; + if (map === null) { + map = new Map(); + for (let i = 0, len = cols.length; i < len; ++i) { + const pivotCol = cols[i]; + const src = pivotCol.colDef.pivotValueColumn as AgColumn | null | undefined; + if (src == null) { + continue; } - }); - }; + _pushToMapArray(map, src, pivotCol); + } + this.dependentsByValueCol = map; + } + const deps = map.get(sourceCol); + if (deps === undefined) { + return; + } + const pivotColDefSvc = this.beans.pivotColDefSvc; + if (!pivotColDefSvc) { + return; + } + for (let i = 0, len = deps.length; i < len; ++i) { + const pivotCol = deps[i]; + const newColDef = pivotColDefSvc.recreateColDef(pivotCol.colDef); + pivotCol.setColDef(newColDef, newColDef, source); + } + } - if (colDefs) { - searchForColDefs(colDefs); + private processPivotResultColDef(colDefs: (ColDef | ColGroupDef)[]): void { + const columnCallback = this.gos.get('processPivotResultColDef'); + const groupCallback = this.gos.get('processPivotResultColGroupDef'); + if (columnCallback || groupCallback) { + visitColDefs(colDefs, columnCallback, groupCallback); } } } + +const visitColDefs = ( + colDefs: (ColDef | ColGroupDef)[], + columnCallback: ((colDef: ColDef) => void) | undefined, + groupCallback: ((colGroupDef: ColGroupDef) => void) | undefined +): void => { + for (let i = 0, len = colDefs.length; i < len; ++i) { + const def = colDefs[i]; + const children = (def as ColGroupDef).children; + if (children) { + groupCallback?.(def as ColGroupDef); + visitColDefs(children, columnCallback, groupCallback); + } else { + columnCallback?.(def); + } + } +}; diff --git a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts index aa7c6a0ee72..9b0a81de06b 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts @@ -68,7 +68,7 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta this.aggregationColumnsHashLastTime = null; this.pivotOrderLastTime = []; this.uniqueValues = new Map(); - if (this.pivotResultCols.isPivotResultColsPresent()) { + if (this.pivotResultCols.pivotCols) { this.pivotResultCols.setPivotResultCols(null, 'rowModelUpdated'); return true; // columns changed, deactivate changedPath } @@ -122,8 +122,7 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta this.groupColumnsHashLastTime = groupColumnsHash; const pivotColumns = pivotColsSvc?.columns ?? []; - const shouldTrackPivotOrder = - gos.get('enableStrictPivotColumnOrder') && pivotColumns.some((col) => col.colDef.pivotComparator); + const shouldTrackPivotOrder = pivotColsSvc?.isStrictColumnOrder() ?? false; const pivotOrder = shouldTrackPivotOrder ? computePivotOrder(this.uniqueValues, pivotColumns, 0) : []; const pivotOrderChanged = !_areEqual(pivotOrder, this.pivotOrderLastTime); this.pivotOrderLastTime = pivotOrder; diff --git a/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts b/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts index 22e640d8abb..b82ab4e1731 100644 --- a/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts +++ b/packages/ag-grid-enterprise/src/rangeSelection/agFillHandle.ts @@ -605,14 +605,10 @@ export class AgFillHandle extends AbstractSelectionHandle { if (initialColumn === currentColumn) { return; } - const displayedColumns = this.beans.visibleCols.allCols; - const initialIndex = displayedColumns.indexOf(initialColumn); - const currentIndex = displayedColumns.indexOf(currentColumn); + const initialIndex = initialColumn.allColsIndex; + const currentIndex = currentColumn.allColsIndex; - if ( - currentIndex <= initialIndex && - currentIndex >= displayedColumns.indexOf(this.cellRange.columns[0] as AgColumn) - ) { + if (currentIndex <= initialIndex && currentIndex >= (this.cellRange.columns[0] as AgColumn).allColsIndex) { this.reduceHorizontal(initialPosition, currentPosition); this.isReduce = true; } else { @@ -704,8 +700,8 @@ export class AgFillHandle extends AbstractSelectionHandle { const beans = this.beans; const { visibleCols } = beans; const allCols = visibleCols.allCols; - const startCol = allCols.indexOf((isMovingLeft ? endPosition.column : initialPosition.column) as AgColumn); - const endCol = allCols.indexOf((isMovingLeft ? this.cellRange.columns[0] : endPosition.column) as AgColumn); + const startCol = ((isMovingLeft ? endPosition.column : initialPosition.column) as AgColumn).allColsIndex; + const endCol = ((isMovingLeft ? this.cellRange.columns[0] : endPosition.column) as AgColumn).allColsIndex; const offset = isMovingLeft ? 0 : 1; const colsToMark = allCols.slice(startCol + offset, endCol + offset); @@ -746,8 +742,8 @@ export class AgFillHandle extends AbstractSelectionHandle { const beans = this.beans; const { visibleCols } = beans; const allCols = visibleCols.allCols; - const startCol = allCols.indexOf(endPosition.column as AgColumn); - const endCol = allCols.indexOf(initialPosition.column as AgColumn); + const startCol = (endPosition.column as AgColumn).allColsIndex; + const endCol = (initialPosition.column as AgColumn).allColsIndex; const colsToMark = allCols.slice(startCol, endCol); const { rangeStartRow, rangeEndRow } = this; diff --git a/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts b/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts index 945d137a551..1b4898bb0b0 100644 --- a/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts +++ b/packages/ag-grid-enterprise/src/rangeSelection/rangeService.ts @@ -326,16 +326,12 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, // first move start column in last cell range (i.e. series chart range) this.refreshLastRangeStart(); - const allColumns = this.visibleCols.allCols; - // check that the columns in each range still exist and are visible for (const cellRange of this.cellRanges) { const beforeCols = cellRange.columns; - // remove hidden or removed cols from cell range - cellRange.columns = cellRange.columns.filter( - (col: AgColumn) => col.isVisible() && allColumns.indexOf(col) !== -1 - ); + // remove hidden or removed cols from cell range (`displayed` ⇔ in allCols) + cellRange.columns = cellRange.columns.filter((col: AgColumn) => col.isVisible() && col.displayed); const colsInRangeChanged = !_areEqual(beforeCols, cellRange.columns); @@ -364,15 +360,26 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, public isContiguousRange(cellRange: CellRange): boolean { const rangeColumns = cellRange.columns as AgColumn[]; + const len = rangeColumns.length; - if (!rangeColumns.length) { + if (!len) { return false; } - const allColumns = this.visibleCols.allCols; - const allPositions = rangeColumns.map((c) => allColumns.indexOf(c)).sort((a, b) => a - b); + // Contiguous ⇔ displayed positions span exactly `len` slots with no gaps. Single min/max + // pass — no sort, no intermediate array (was O(n log n) + alloc). + let min = rangeColumns[0].allColsIndex; + let max = min; + for (let i = 1; i < len; ++i) { + const idx = rangeColumns[i].allColsIndex; + if (idx < min) { + min = idx; + } else if (idx > max) { + max = idx; + } + } - return _last(allPositions) - allPositions[0] + 1 === rangeColumns.length; + return max - min + 1 === len; } public getRangeStartRow(cellRange: PartialCellRange): RowPosition { @@ -772,14 +779,24 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, public getRangeEdgeColumns(cellRange: CellRange): { left: AgColumn; right: AgColumn } { const allColumns = this.visibleCols.allCols; - const allIndices = cellRange.columns - .map((c: AgColumn) => allColumns.indexOf(c)) - .filter((i) => i > -1) - .sort((a, b) => a - b); + const cols = cellRange.columns; + let minIdx = -1; + let maxIdx = -1; + for (let i = 0, len = cols.length; i < len; ++i) { + const idx = (cols[i] as AgColumn).allColsIndex; + if (idx > -1) { + if (minIdx === -1 || idx < minIdx) { + minIdx = idx; + } + if (idx > maxIdx) { + maxIdx = idx; + } + } + } return { - left: allColumns[allIndices[0]], - right: allColumns[_last(allIndices)], + left: allColumns[minIdx], + right: allColumns[maxIdx], }; } @@ -1135,12 +1152,18 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, } public isBottomRightCell(cellRange: CellRange, cell: CellPosition): boolean { - const allColumns = this.visibleCols.allCols; - const allPositions = cellRange.columns.map((c: AgColumn) => allColumns.indexOf(c)).sort((a, b) => a - b); + const cols = cellRange.columns as AgColumn[]; + let maxIdx = -1; + for (let i = 0, len = cols.length; i < len; ++i) { + const idx = cols[i].allColsIndex; + if (idx > maxIdx) { + maxIdx = idx; + } + } const { startRow, endRow } = cellRange; const lastRow = _isRowBefore(startRow!, endRow!) ? endRow : startRow; - const isRightColumn = allColumns.indexOf(cell.column as AgColumn) === _last(allPositions); + const isRightColumn = (cell.column as AgColumn).allColsIndex === maxIdx; const isLastRow = cell.rowIndex === lastRow!.rowIndex && _makeNull(cell.rowPinned) === _makeNull(lastRow!.rowPinned); @@ -1421,7 +1444,7 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, }); } - private getColumnFromModel(col: string | AgColumn): AgColumn | null { + private getColumnFromModel(col: string | AgColumn): AgColumn | undefined { return typeof col === 'string' ? this.colModel.getCol(col) : col; } @@ -1470,10 +1493,9 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, return preferredStartColumn ?? firstColumn; } - const allColumns = this.visibleCols.allCols; - const preferredStartIndex = allColumns.indexOf(preferredStartColumn); - const firstIndex = allColumns.indexOf(firstColumn); - const lastIndex = allColumns.indexOf(lastColumn); + const preferredStartIndex = preferredStartColumn.allColsIndex; + const firstIndex = firstColumn.allColsIndex; + const lastIndex = lastColumn.allColsIndex; if (preferredStartIndex < 0 || firstIndex < 0 || lastIndex < 0) { return firstColumn; @@ -1489,14 +1511,14 @@ export class RangeService extends BeanStub implements NamedBean, IRangeService, const toColumn = this.getColumnFromModel(columnB)!; const isSameColumn = fromColumn === toColumn; - const fromIndex = allColumns.indexOf(fromColumn); + const fromIndex = fromColumn.allColsIndex; if (fromIndex < 0) { _warn(178, { colId: fromColumn.getId() }); return; } - const toIndex = isSameColumn ? fromIndex : allColumns.indexOf(toColumn); + const toIndex = isSameColumn ? fromIndex : toColumn.allColsIndex; if (toIndex < 0) { _warn(178, { colId: toColumn.getId() }); diff --git a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/dropZoneColumnComp.ts b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/dropZoneColumnComp.ts index a439dd1e94b..0f8e74411e4 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/dropZoneColumnComp.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/columnDropZones/dropZoneColumnComp.ts @@ -2,10 +2,10 @@ import { RefPlaceholder } from 'ag-stack'; import type { AgColumn, + ColAggFunc, DragAndDropIcon, DragItem, DropTarget, - IAggFunc, SortDef, SortDirection, SortIndicatorComp, @@ -56,7 +56,7 @@ export class DropZoneColumnComp extends PillDragComp { ], }; if (sortSvc) { - this.agComponents = [sortSvc.getSortIndicatorSelector()]; + this.agComponents = [sortSvc.SortIndicatorSelector]; } this.displayName = colNames.getDisplayNameForColumn(this.column, 'columnDrop'); @@ -352,7 +352,7 @@ export class DropZoneColumnComp extends PillDragComp { virtualList.focusRow(rowToFocus); } - private createAggSelect(hidePopup: () => void, value: string | IAggFunc | null | undefined): Component { + private createAggSelect(hidePopup: () => void, value: ColAggFunc): Component { const itemSelected = () => { hidePopup(); this.getGui().focus(); diff --git a/packages/ag-grid-enterprise/src/rowGrouping/groupFilter/groupFilterService.ts b/packages/ag-grid-enterprise/src/rowGrouping/groupFilter/groupFilterService.ts index b2fdab3ad16..cecad890054 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/groupFilter/groupFilterService.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/groupFilter/groupFilterService.ts @@ -30,15 +30,20 @@ export class GroupFilterService extends BeanStub implements NamedBean, IGroupFil public updateFilterFlags(source: ColumnEventType, additionalEventAttributes?: any): void { const { autoColSvc, colFilter } = this.beans; - autoColSvc?.getColumns()?.forEach((groupColumn) => { + const autoColumns = autoColSvc?.columns; + if (!autoColumns || !colFilter) { + return; + } + for (let i = 0, len = autoColumns.length; i < len; ++i) { + const groupColumn = autoColumns[i]; if (this.isGroupFilter(groupColumn)) { - colFilter?.setColFilterActive( + colFilter.setColFilterActive( groupColumn, this.isFilterActive(groupColumn), source, additionalEventAttributes ); } - }); + } } } diff --git a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupColsSvc.ts b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupColsSvc.ts index 1bed00349b7..f777cf7c9e7 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupColsSvc.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupColsSvc.ts @@ -1,125 +1,70 @@ -import { _removeFromArray } from 'ag-stack'; +import type { AgColumn, ColumnEventType, IRowGroupColsService, NamedBean } from 'ag-grid-community'; +import { _shouldUpdateColVisibilityAfterGroup, dispatchColumnVisibleEvent } from 'ag-grid-community'; -import type { - AgColumn, - AllEventsWithoutGridCommon, - ColDef, - ColumnEventType, - ColumnStateParams, - IColsService, - NamedBean, -} from 'ag-grid-community'; -import { BaseColsService, _shouldUpdateColVisibilityAfterGroup } from 'ag-grid-community'; +import { OrderedColsService } from '../columns/orderedColsService'; -export class RowGroupColsSvc extends BaseColsService implements NamedBean, IColsService { +export class RowGroupColsSvc extends OrderedColsService implements NamedBean, IRowGroupColsService { beanName = 'rowGroupColsSvc' as const; - eventName = 'columnRowGroupChanged' as const; + protected override eventName = 'columnRowGroupChanged' as const; + protected override enableProp = 'rowGroup' as const; + protected override indexProp = 'rowGroupIndex' as const; + protected override initialEnableProp = 'initialRowGroup' as const; + protected override initialIndexProp = 'initialRowGroupIndex' as const; - override columnProcessors = { - set: (column: AgColumn, added: boolean, source: ColumnEventType) => this.setActive(added, column, source), - add: (column: AgColumn, added: boolean, source: ColumnEventType) => this.setActive(true, column, source), - remove: (column: AgColumn, added: boolean, source: ColumnEventType) => this.setActive(false, column, source), - } as const; - - override columnOrdering = { - enableProp: 'rowGroup', - initialEnableProp: 'initialRowGroup', - indexProp: 'rowGroupIndex', - initialIndexProp: 'initialRowGroupIndex', - } as const; - - override columnExtractors = { - setFlagFunc: (col: AgColumn, flag: boolean, source: ColumnEventType) => - this.setColRowGroupActive(col, flag, source), - getIndexFunc: (colDef: ColDef) => colDef.rowGroupIndex, - getInitialIndexFunc: (colDef: ColDef) => colDef.initialRowGroupIndex, - getValueFunc: (colDef: ColDef) => colDef.rowGroup, - getInitialValueFunc: (colDef: ColDef) => colDef.initialRowGroup, - } as const; - - private readonly modifyColumnsNoEventsCallbacks = { - addCol: (column: AgColumn) => { - if (!this.columns.includes(column)) { - this.columns.push(column); - } - }, - removeCol: (column: AgColumn) => _removeFromArray(this.columns, column), - }; + private readonly pendingVisibilityChanges = new Set(); public moveColumn(fromIndex: number, toIndex: number, source: ColumnEventType): void { - if (this.columns.length === 0) { + const columns = this.columns; + const len = columns.length; + if (len === 0 || fromIndex < 0 || fromIndex >= len) { return; } - - const column = this.columns[fromIndex]; - - const impactedColumns = this.columns.slice(fromIndex, toIndex); - this.columns.splice(fromIndex, 1); - this.columns.splice(toIndex, 0, column); - - this.updateIndexMap(); - - this.eventSvc.dispatchEvent({ - type: this.eventName, - columns: impactedColumns, - column: impactedColumns.length === 1 ? impactedColumns[0] : null, - source, - } as AllEventsWithoutGridCommon); + toIndex = Math.max(0, Math.min(toIndex, len - 1)); + if (fromIndex === toIndex) { + return; + } + const movedColumn = columns[fromIndex]; + const reordered = columns.slice(); + reordered.splice(toIndex, 0, reordered.splice(fromIndex, 1)[0]); + this.resetActiveCols(reordered); + // Reorder only (event-driven regroup): report the moved column. + this.stageColChange([movedColumn]); + this.colModel.flushColChanges(source, false); } - public syncColumnWithState( - column: AgColumn, - source: ColumnEventType, - getValue: ( - key1: U, - key2?: S - ) => { value1: ColumnStateParams[U] | undefined; value2: ColumnStateParams[S] | undefined }, - rowIndex: { [key: string]: number } | null - ): void { - const { value1: rowGroup, value2: rowGroupIndex } = getValue('rowGroup', 'rowGroupIndex'); - if (rowGroup !== undefined || rowGroupIndex !== undefined) { - if (typeof rowGroupIndex === 'number' || rowGroup) { - if (!column.isRowGroupActive()) { - this.setColRowGroupActive(column, true, source); - this.modifyColumnsNoEventsCallbacks.addCol(column); - } - if (rowIndex && typeof rowGroupIndex === 'number') { - rowIndex[column.getId()] = rowGroupIndex; - } - } else if (column.isRowGroupActive()) { - this.setColRowGroupActive(column, false, source); - this.modifyColumnsNoEventsCallbacks.removeCol(column); + protected override onColActiveChanged(column: AgColumn, active: boolean, source: ColumnEventType): void { + // Grouping auto-hides a col, ungrouping shows it again (batched `columnVisible` at flush); skip hierarchy virtuals (not user data). + if (column.colKind !== 'hierarchy' && _shouldUpdateColVisibilityAfterGroup(this.gos, active)) { + const visible = !active; + if (column.visible !== visible) { + column.setVisible(visible, source); + this.pendingVisibilityChanges.add(column); } } } - private setActive(active: boolean, column: AgColumn, source: ColumnEventType): void { - if (active === column.isRowGroupActive()) { - return; + protected override onColActiveChangesComplete(source: ColumnEventType): void { + const pending = this.pendingVisibilityChanges; + if (pending.size) { + const cols = Array.from(pending); + pending.clear(); + dispatchColumnVisibleEvent(this.eventSvc, cols, source); } + } - this.setColRowGroupActive(column, active, source); - - // If this column is a virtual column inserted by the groupHierarchyColSvc, by default we shouldn't make - // it visible when being grouped or ungrouped -- these are virtual columns, not user data columns, so they - // should only be made visible if the user explicitly wants to see them - const isGroupHierarchyCol = this.beans.groupHierarchyColSvc?.getColumn(column); - if (_shouldUpdateColVisibilityAfterGroup(this.gos, active) && !isGroupHierarchyCol) { - this.colModel.setColsVisible([column], !active, source); + protected override setActiveFlag(col: AgColumn, active: boolean): boolean { + if (col.rowGroupActive === active) { + return false; } + col.rowGroupActive = active; + return true; } - private setColRowGroupActive(column: AgColumn, rowGroup: boolean, source: ColumnEventType): void { - if (column.rowGroupActive !== rowGroup) { - column.rowGroupActive = rowGroup; - - if (rowGroup) { - const addedCols = this.beans.groupHierarchyColSvc?.insertVirtualColumnsForCol(this.columns, column); - addedCols?.forEach((c) => this.setColRowGroupActive(c, rowGroup, source)); - } - - column.dispatchColEvent('columnRowGroupChanged', source); + /** Stamps each active col's position as its row-group level (`rowGroupActiveIndex`, valid only when active). */ + protected override onColumnsChanged(): void { + const cols = this.columns; + for (let i = 0, len = cols.length; i < len; ++i) { + cols[i].rowGroupActiveIndex = i; } - column.dispatchStateUpdatedEvent('rowGroup'); } } diff --git a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingApi.ts b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingApi.ts index 84f6781c304..4cd9fa7b5ec 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingApi.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingApi.ts @@ -1,7 +1,5 @@ import type { BeanCollection, ColKey, Column } from 'ag-grid-community'; -import type { RowGroupColsSvc } from './rowGroupColsSvc'; - export function setRowGroupColumns(beans: BeanCollection, colKeys: ColKey[]): void { beans.rowGroupColsSvc?.setColumns(colKeys, 'api'); } @@ -15,7 +13,7 @@ export function addRowGroupColumns(beans: BeanCollection, colKeys: ColKey[]): vo } export function moveRowGroupColumn(beans: BeanCollection, fromIndex: number, toIndex: number): void { - (beans.rowGroupColsSvc as RowGroupColsSvc)?.moveColumn?.(fromIndex, toIndex, 'api'); + beans.rowGroupColsSvc?.moveColumn(fromIndex, toIndex, 'api'); } export function getRowGroupColumns(beans: BeanCollection): Column[] { diff --git a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingEditValueSvc.ts b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingEditValueSvc.ts index 7943fd90b68..fa5a7a3e8c2 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingEditValueSvc.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingEditValueSvc.ts @@ -55,7 +55,7 @@ export class RowGroupingEditValueSvc extends BeanStub implements NamedBean, _IRo // Resolve groupRowValueSetter: true or groupRowEditable → built-in distributeGroupValue, // false → explicitly disabled, function/object → as-is. - // colDef is already deep-merged with defaultColDef (via _mergeDeep in columnFactoryUtils), + // colDef is already deep-merged with defaultColDef (via _mergeDeep in colDefUtils), // so object-type options inherit and merge with defaultColDef automatically. // When groupRowEditable is a callback, evaluate it against the current row — only enable // implicit distribution for rows where the callback returns true. diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/autoColService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/autoColService.ts index 94de2334853..867210dc13e 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/autoColService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/autoColService.ts @@ -1,14 +1,10 @@ -import { _missing } from 'ag-stack'; - import type { ColDef, - ColKey, ColumnEventType, - IColumnCollectionService, + IAutoColService, NamedBean, PropertyValueChangedEvent, RowNode, - _ColumnCollections, } from 'ag-grid-community'; import { AgColumn, @@ -16,26 +12,27 @@ import { GROUP_AUTO_COLUMN_ID, _addColumnDefaultAndTypes, _applyColumnState, - _areColIdsEqual, - _columnsMatch, _convertColumnEventSourceType, - _destroyColumnTree, _getColumnStateFromColDef, + _isClientSideRowModel, _isColumnsSortingCoupledToGroup, _isGroupHideColumnsUntilExpanded, _isGroupMultiAutoColumn, _isGroupUseEntireRow, _mergeDeep, - _updateColsMap, _warn, - isColumnGroupAutoCol, } from 'ag-grid-community'; -export class AutoColService extends BeanStub implements NamedBean, IColumnCollectionService { +export class AutoColService extends BeanStub implements NamedBean, IAutoColService { beanName = 'autoColSvc' as const; - /** Group auto columns */ - public columns: _ColumnCollections | null; + /** Generated auto-group columns; empty when no auto-cols are active. Wrappers around these + * leaves are built/destroyed by `colGroupSvc.serviceWrapperCache`. */ + public columns: AgColumn[] = []; + + /** Flips true on the first `modelUpdated` — used to detect the CSRM-no-rowData edge case + * where the modelUpdated hook (and therefore the visibility update) never fires. */ + private modelEverUpdated = false; public postConstruct(): void { this.addManagedPropertyListener('autoGroupColumnDef', this.updateColumns.bind(this)); @@ -44,7 +41,10 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec } private setupGroupHideColumnsUntilExpanded() { - const updateGroupColumnVisibility = () => this.updateGroupColumnVisibility(); + const updateGroupColumnVisibility = () => { + this.modelEverUpdated = true; + this.updateGroupColumnVisibility(true); + }; this.addManagedEventListeners({ // modelUpdated is fired when rowGroup events are fired so we do not duplicate work by also listening to "rowGroupOpened" and "expandOrCollapseAll" modelUpdated: updateGroupColumnVisibility, @@ -56,129 +56,99 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec ); } - public addColumns(cols: _ColumnCollections): void { - const { columns } = this; - if (columns == null) { - return; - } - cols.list = columns.list.concat(cols.list); - cols.tree = columns.tree.concat(cols.tree); - _updateColsMap(cols); - } - - public createColumns( - cols: _ColumnCollections, - updateOrders: (callback: (cols: AgColumn[] | null) => AgColumn[] | null) => void, - source: ColumnEventType - ): void { + /** Generates or destroys auto-group columns based on grouping state. ColumnGroupService owns + * the balanced-tree wrappers and catches depth-only changes. */ + public refreshCols(source: ColumnEventType): AgColumn[] | null { const beans = this.beans; - const { colModel, gos, rowGroupColsSvc, colGroupSvc } = beans; + const { colModel, gos, rowGroupColsSvc } = beans; const isPivotMode = colModel.pivotMode; const groupFullWidthRow = _isGroupUseEntireRow(gos, isPivotMode); - // we need to allow suppressing auto-column separately for group and pivot as the normal situation - // is CSRM and user provides group column themselves for normal view, but when they go into pivot the - // columns are generated by the grid so no opportunity for user to provide group column. so need a way - // to suppress auto-col for grouping only, and not pivot. - // however if using Viewport RM or SSRM and user is providing the columns, the user may wish full control - // of the group column in this instance. + // Suppress auto-col separately for group vs pivot: in CSRM the user provides their own group + // column for the normal view, but pivot columns are grid-generated (no chance to provide one), + // so grouping must be suppressible independently. With Viewport/SSRM the user may also want + // full control of the group column. const suppressAutoColumn = isPivotMode ? gos.get('pivotSuppressAutoColumn') : this.isSuppressAutoCol(); + const treeData = gos.get('treeData'); const rowGroupCols = rowGroupColsSvc?.columns; - - const groupingActive = (rowGroupCols && rowGroupCols.length > 0) || gos.get('treeData'); - + const groupingActive = (rowGroupCols && rowGroupCols.length > 0) || treeData; const noAutoCols = !groupingActive || suppressAutoColumn || groupFullWidthRow; + const currentCols = this.columns; - const destroyPrevious = () => { - if (this.columns) { - _destroyColumnTree(beans, this.columns.tree); - this.columns = null; - } - }; - - // function if (noAutoCols) { - destroyPrevious(); - return; - } - - const list = this.generateAutoCols(rowGroupCols); - const autoColsSame = _areColIdsEqual(list, this.columns?.list || null); - - // the new tree depth will equal the current tree depth of cols - const newTreeDepth = cols.treeDepth; - const oldTreeDepth = this.columns ? this.columns.treeDepth : -1; - const treeDepthSame = oldTreeDepth == newTreeDepth; - - if (autoColsSame && treeDepthSame) { - // Some things like header could have changed, ensure this is captured by updating the existing cols. - const colsMap = new Map(list.map((col) => [col.getId(), col])); - for (const col of this.columns?.list ?? []) { - const newDef = colsMap.get(col.getId()); - if (newDef) { - col.setColDef(newDef.colDef, null, source); - } + if (currentCols.length > 0) { + this.destroyColumns(); } - return; + return null; } - destroyPrevious(); - const treeDepth = colGroupSvc?.findDepth(cols.tree) ?? 0; - const tree = colGroupSvc?.balanceTreeForAutoCols(list, treeDepth) ?? []; - this.columns = { - list, - tree, - treeDepth, - map: {}, - }; - - const putAutoColsFirstInList = (cols: AgColumn[] | null): AgColumn[] | null => { - if (!cols) { - return null; + // Cheap "same as before?" check — bench-critical: refreshCols runs on every pivot / + // grouping transaction. Matches colIds element-by-element with no allocation. + const doingMultiAutoColumn = !treeData && _isGroupMultiAutoColumn(gos); + if (autoColIdsMatch(currentCols, rowGroupCols, doingMultiAutoColumn)) { + // Existing col instances stay; refresh their colDefs so options changes propagate. + for (let i = 0, len = currentCols.length; i < len; ++i) { + const col = currentCols[i]; + const rowGroupCol = doingMultiAutoColumn ? rowGroupCols![i] : undefined; + const colDef = this.createAutoColDef(col.colId, rowGroupCol, i); + col.setColDef(colDef, null, source); } - // we use colId, and not instance, to remove old autoGroupCols - const colsFiltered = cols.filter((col) => !isColumnGroupAutoCol(col)); - return [...list, ...colsFiltered]; - }; + return currentCols; + } - updateOrders(putAutoColsFirstInList); + // Columns changed — destroy old, generate new. + this.destroyColumns(); + const newCols = this.generateAutoCols(rowGroupCols); + this.columns = newCols; + if (!this.modelEverUpdated && _isClientSideRowModel(this.gos)) { + // Mid-refreshCols: set visibility flags only; the enclosing refresh does the display refresh. + this.updateGroupColumnVisibility(false); + } + return newCols; } public updateColumns(event: PropertyValueChangedEvent<'autoGroupColumnDef'>) { const source = _convertColumnEventSourceType(event.source); - this.columns?.list.forEach((col, index) => this.updateOneAutoCol(col, index, source)); + const cols = this.columns; + for (let i = 0, len = cols.length; i < len; ++i) { + this.updateOneAutoCol(cols[i], i, source); + } } - public getColumn(key: ColKey): AgColumn | null { - return this.columns?.list.find((groupCol) => _columnsMatch(groupCol, key)) ?? null; + public override destroy(): void { + this.destroyColumns(); + super.destroy(); } - public getColumns(): AgColumn[] | null { - return this.columns?.list ?? null; + private destroyColumns(): void { + const list = this.columns; + this.columns = []; + for (let i = 0, len = list.length; i < len; ++i) { + const col = list[i]; + if (col.isAlive()) { + col.destroy(); + } + } } private generateAutoCols(rowGroupCols: AgColumn[] = []): AgColumn[] { const autoCols: AgColumn[] = []; - const { gos } = this; - + const gos = this.gos; const doingTreeData = gos.get('treeData'); let doingMultiAutoColumn = _isGroupMultiAutoColumn(gos); - if (doingTreeData && doingMultiAutoColumn) { _warn(182); doingMultiAutoColumn = false; } - // if doing groupDisplayType = "multipleColumns", then we call the method multiple times, once - // for each column we are grouping by + // if doing "multipleColumns", then we call the method multiple times, once for each column we are grouping by if (doingMultiAutoColumn) { - rowGroupCols.forEach((rowGroupCol, index) => { - autoCols.push(this.createOneAutoCol(rowGroupCol, index)); - }); + for (let i = 0, len = rowGroupCols.length; i < len; ++i) { + autoCols.push(this.createOneAutoCol(rowGroupCols[i], i)); + } } else { autoCols.push(this.createOneAutoCol()); } - return autoCols; } @@ -189,9 +159,7 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec if (isCustomRowGroups) { return true; } - - const treeDataDisplayType = gos.get('treeDataDisplayType'); - return treeDataDisplayType === 'custom'; + return gos.get('treeDataDisplayType') === 'custom'; } // rowGroupCol and index are missing if groupDisplayType != "multipleColumns" @@ -199,32 +167,25 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec // if doing multi, set the field let colId: string; if (rowGroupCol) { - colId = `${GROUP_AUTO_COLUMN_ID}-${rowGroupCol.getId()}`; + colId = `${GROUP_AUTO_COLUMN_ID}-${rowGroupCol.colId}`; } else { colId = GROUP_AUTO_COLUMN_ID; } - const colDef = this.createAutoColDef(colId, rowGroupCol, index); - colDef.colId = colId; - - const newCol = new AgColumn(colDef, null, colId, true); - this.createBean(newCol); + const newCol = new AgColumn(colDef, null, colId, true, 'auto-group'); + this.beans.context.createBean(newCol); return newCol; } - /** - * Refreshes an auto group col to load changes from defaultColDef or autoGroupColDef - */ + /** Refreshes an auto group col to load changes from defaultColDef or autoGroupColDef */ private updateOneAutoCol(colToUpdate: AgColumn, index: number, source: ColumnEventType) { + const beans = this.beans; const oldColDef = colToUpdate.colDef; const underlyingColId = typeof oldColDef.showRowGroup == 'string' ? oldColDef.showRowGroup : undefined; - const beans = this.beans; - const underlyingColumn = underlyingColId != null ? beans.colModel.getColDefCol(underlyingColId) : undefined; - const colId = colToUpdate.getId(); + const underlyingColumn = underlyingColId != null ? beans.colModel.getNonPivotCol(underlyingColId) : undefined; + const colId = colToUpdate.colId; const colDef = this.createAutoColDef(colId, underlyingColumn ?? undefined, index); - colToUpdate.setColDef(colDef, null, source); - _applyColumnState(beans, { state: [_getColumnStateFromColDef(colDef, colId)] }, source); } @@ -236,11 +197,11 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec _mergeDeep(res, autoGroupColumnDef); res = _addColumnDefaultAndTypes(this.beans, res, colId, true); + res.colId = colId; - // TODO: Remove this guard when we properly implement editing of auto group column. - // Auto group columns should not inherit groupRowEditable or groupRowValueSetter from - // defaultColDef — group row editing of the auto group column is not yet fully supported. - // Only honour these properties if the user explicitly set them on autoGroupColumnDef. + // TODO: Remove this guard once auto-group-column editing is properly supported. + // Don't inherit groupRowEditable / groupRowValueSetter from defaultColDef — only honour + // them when explicitly set on autoGroupColumnDef. if (autoGroupColumnDef?.groupRowEditable == null) { res.groupRowEditable = undefined; } @@ -253,10 +214,7 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec // we would only allow filter if the user has provided field or value getter. otherwise the filter // would not be able to work. const noFieldOrValueGetter = - _missing(res.field) && - _missing(res.valueGetter) && - _missing(res.filterValueGetter) && - res.filter !== 'agGroupColumnFilter'; + !res.field && !res.valueGetter && !res.filterValueGetter && res.filter !== 'agGroupColumnFilter'; if (noFieldOrValueGetter) { res.filter = false; } @@ -282,47 +240,38 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec private createBaseColDef(rowGroupCol?: AgColumn): ColDef { const userDef = this.gos.get('autoGroupColumnDef'); const localeTextFunc = this.getLocaleTextFunc(); - const res: ColDef = { headerName: localeTextFunc('group', 'Group'), showRowGroup: rowGroupCol?.colId ?? true, }; - - const userHasProvidedGroupCellRenderer = userDef && (userDef.cellRenderer || userDef.cellRendererSelector); - // only add the default group cell renderer if user hasn't provided one + const userHasProvidedGroupCellRenderer = userDef && (userDef.cellRenderer || userDef.cellRendererSelector); if (!userHasProvidedGroupCellRenderer) { res.cellRenderer = 'agGroupCellRenderer'; } - if (rowGroupCol) { res.headerName = this.beans.colNames.getDisplayNameForColumn(rowGroupCol, 'header') ?? undefined; res.headerValueGetter = rowGroupCol.colDef.headerValueGetter; } - return res; } private getDeepestExpandedLevel(nodes: RowNode[] | null | undefined, maxLevel: number): number { let deepest = -1; - if (!nodes) { return deepest; } - - for (const node of nodes) { + for (let i = 0, len = nodes.length; i < len; ++i) { + const node = nodes[i]; if (!node.group || !node.expanded) { continue; } - if (node.level > deepest) { deepest = node.level; } - if (deepest >= maxLevel) { return deepest; } - // only expanded nodes recurse into their child groups; collapsed branches are skipped. const childDeepest = this.getDeepestExpandedLevel(node.childrenAfterGroup, maxLevel); if (childDeepest > deepest) { @@ -332,59 +281,88 @@ export class AutoColService extends BeanStub implements NamedBean, IColumnCollec return deepest; } } - return deepest; } - private updateGroupColumnVisibility(): void { - const columns = this.columns?.list; - - if (!columns || columns.length === 0) { + /** Sets auto-col visibility flags for the "group hide columns until expanded" feature. When called + * mid-`refreshCols` (`canRefresh=false`), only the flags are set — the enclosing refresh assembles + * the new colsList and runs `visibleCols.refresh` itself, so refreshing here would run against the + * stale colsList and dispatch a premature `displayedColumnsChanged`. */ + private updateGroupColumnVisibility(canRefresh: boolean): void { + const columns = this.columns; + if (columns.length === 0) { return; } - const { gos, visibleCols, rowModel } = this.beans; const isFeatureEnabled = _isGroupHideColumnsUntilExpanded(gos); - let changed = false; - const setColVisible = (col: AgColumn, visible: boolean): void => { - if (visible !== col.isVisible()) { - col.setVisible(visible, 'api'); - changed = true; - } - }; - - const setAllColumnsVisible = (): void => { - for (const col of columns) { - setColVisible(col, true); - } - }; if (!isFeatureEnabled) { - setAllColumnsVisible(); + if (setAllColumnsVisible(columns)) { + changed = true; + } } else if (columns.length > 1) { - // Feature only applies when there are multiple columns to show/hide; - // the first column is always visible so a single column needs no adjustment. + // Only applies with multiple columns: the first is always visible, so a single + // column needs no adjustment. const maxLevel = columns.length - 2; const rootChildren = rowModel?.rootNode?.childrenAfterGroup; const deepestExpandedLevel = this.getDeepestExpandedLevel(rootChildren, maxLevel); if (deepestExpandedLevel >= maxLevel) { - setAllColumnsVisible(); + if (setAllColumnsVisible(columns)) { + changed = true; + } } else { for (let level = 0; level < columns.length - 1; level++) { - setColVisible(columns[level + 1], deepestExpandedLevel >= level); + if (setColumnVisible(columns[level + 1], deepestExpandedLevel >= level)) { + changed = true; + } } } } + if (changed && canRefresh) { + // skipTreeBuild=false: visibility changed, so the displayed-col partition must be rebuilt. + visibleCols.refresh('api', false); + } + } +} - if (changed) { - visibleCols.refresh('api'); +/** True when `currentCols` matches the colIds `generateAutoCols(rowGroupCols)` would produce for + * `doingMultiAutoColumn`, without allocating an intermediate string array. */ +const autoColIdsMatch = ( + currentCols: readonly AgColumn[], + rowGroupCols: readonly AgColumn[] | undefined, + doingMultiAutoColumn: boolean +): boolean => { + if (!doingMultiAutoColumn || !rowGroupCols) { + return currentCols.length === 1 && currentCols[0].colId === GROUP_AUTO_COLUMN_ID; + } + const len = rowGroupCols.length; + if (currentCols.length !== len) { + return false; + } + for (let i = 0; i < len; ++i) { + if (currentCols[i].colId !== `${GROUP_AUTO_COLUMN_ID}-${rowGroupCols[i].colId}`) { + return false; } } + return true; +}; - public override destroy(): void { - _destroyColumnTree(this.beans, this.columns?.tree); - super.destroy(); +const setColumnVisible = (col: AgColumn, visible: boolean): boolean => { + if (visible !== col.visible) { + col.setVisible(visible, 'api'); + return true; } -} + return false; +}; + +const setAllColumnsVisible = (columns: AgColumn[]): boolean => { + let changed = false; + for (let i = 0, len = columns.length; i < len; ++i) { + if (setColumnVisible(columns[i], true)) { + changed = true; + } + } + return changed; +}; diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/rendering/groupCellRendererCtrl.ts b/packages/ag-grid-enterprise/src/rowHierarchy/rendering/groupCellRendererCtrl.ts index 93d2466de5b..b10471a2a0b 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/rendering/groupCellRendererCtrl.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/rendering/groupCellRendererCtrl.ts @@ -153,13 +153,13 @@ export class GroupCellRendererCtrl extends BeanStub implements IGroupCellRendere const bodyCell = !pinnedLeftCell && !pinnedRightCell; if (this.gos.get('enableRtl')) { - if (visibleCols.isPinningLeft()) { + if (visibleCols.leftCols.length > 0) { return !pinnedRightCell; } return !bodyCell; } - if (visibleCols.isPinningLeft()) { + if (visibleCols.leftCols.length > 0) { return !pinnedLeftCell; } diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts index 98a5f283564..b894f760e33 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColValueService.ts @@ -46,10 +46,9 @@ export class ShowRowGroupColValueService extends BeanStub implements NamedBean, return { displayedNode: node, value: null }; } - // when using multiple columns, special handling if (typeof rowGroupColId === 'string') { - const colRowGroupIndex = this.beans.rowGroupColsSvc?.getColumnIndex(rowGroupColId) ?? -1; - if (colRowGroupIndex > node.level) { + const col = this.beans.colModel.colsById[rowGroupColId]; + if (col?.rowGroupActive && col.rowGroupActiveIndex > node.level) { return null; } @@ -178,7 +177,7 @@ export class ShowRowGroupColValueService extends BeanStub implements NamedBean, } let pointer: RowNode | null = node as RowNode; - while (pointer && pointer.rowGroupColumn?.getId() != showRowGroup) { + while (pointer && pointer.rowGroupColumn?.colId != showRowGroup) { const isFirstChild = pointer === pointer.parent?.getFirstChild(); if (!isShowOpenedGroupValue && !isFirstChild) { // if not first child and not showOpenedGroup then groupHideOpenParents doesn't diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts index 6f055e66235..8d686e8775b 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/showRowGroupColsService.ts @@ -1,46 +1,62 @@ -import type { AgColumn, IShowRowGroupColsService, NamedBean } from 'ag-grid-community'; +import { _pushToMapArray } from 'ag-stack'; + +import type { AgColumn, IShowRowGroupColsService, NamedBean, SortDirection } from 'ag-grid-community'; import { BeanStub } from 'ag-grid-community'; export class ShowRowGroupColsService extends BeanStub implements NamedBean, IShowRowGroupColsService { beanName = 'showRowGroupCols' as const; public readonly columns: AgColumn[] = []; + private readonly sourceCols: AgColumn[] = []; private readonly colsSet = new Set(); - private readonly colsMap = new Map(); public override destroy(): void { super.destroy(); this.columns.length = 0; this.colsSet.clear(); - this.colsMap.clear(); + this.clearStamps(); + } + + /** Reset the per-column `showRowGroupCol` back-references set on the previous build. */ + private clearStamps(): void { + const stamped = this.sourceCols; + for (let i = 0, len = stamped.length; i < len; ++i) { + stamped[i].showRowGroupCol = null; + } + stamped.length = 0; } public refresh(): void { const { colModel, rowGroupColsSvc } = this.beans; const showRowGroupCols = this.columns; - const showRowGroupColsSet = this.colsSet; - const showRowGroupColsMap = this.colsMap; - showRowGroupColsMap.clear(); + this.clearStamps(); // empties this.sourceCols before we re-fill it below + const stamped = this.sourceCols; const oldShowRowGroupColsLLen = showRowGroupCols.length; let showRowGroupColsCount = 0; let showRowGroupColsSetChanged = false; - const cols = colModel.getCols(); + const cols = colModel.colsList; for (let colIdx = 0, colsLen = cols.length; colIdx < colsLen; ++colIdx) { const col = cols[colIdx]; const colDef = col.colDef; const showRowGroup = colDef.showRowGroup; if (typeof showRowGroup === 'string') { - showRowGroupColsMap.set(showRowGroup, col); + const sourceCol = colModel.getNonPivotColById(showRowGroup); + if (sourceCol) { + sourceCol.showRowGroupCol = col; + stamped.push(sourceCol); + } } else if (showRowGroup === true) { const groupColumns = rowGroupColsSvc?.columns; if (groupColumns) { for (let grpColIdx = 0, grpColsLen = groupColumns.length; grpColIdx < grpColsLen; ++grpColIdx) { - showRowGroupColsMap.set(groupColumns[grpColIdx].getId(), col); + const sourceCol = groupColumns[grpColIdx]; + sourceCol.showRowGroupCol = col; + stamped.push(sourceCol); } } } else { @@ -63,10 +79,6 @@ export class ShowRowGroupColsService extends BeanStub implements NamedBean, ISho } } - public getShowRowGroupCol(id: string): AgColumn | undefined { - return this.colsMap.get(id); - } - public getSourceColumnsForGroupColumn(groupCol: AgColumn): AgColumn[] | null { const sourceColumnId = groupCol.colDef.showRowGroup; if (!sourceColumnId) { @@ -74,11 +86,11 @@ export class ShowRowGroupColsService extends BeanStub implements NamedBean, ISho } const { rowGroupColsSvc, colModel } = this.beans; - if (sourceColumnId === true && rowGroupColsSvc) { - return rowGroupColsSvc.columns; + if (sourceColumnId === true) { + return rowGroupColsSvc ? rowGroupColsSvc.columns : null; } - const column = colModel.getColDefCol(sourceColumnId as string); + const column = colModel.getNonPivotCol(sourceColumnId); return column ? [column] : null; } @@ -86,4 +98,62 @@ export class ShowRowGroupColsService extends BeanStub implements NamedBean, ISho const showRowGroup = column.colDef.showRowGroup; return showRowGroup === true || (showRowGroup != null && showRowGroup === colId); } + + public interleaveSortedColumns(sorted: AgColumn[]): AgColumn[] { + const rowGroupCols = this.beans.rowGroupColsSvc?.columns; + if (!rowGroupCols) { + return sorted; + } + const sourcesByGroup = new Map(); + for (let i = 0, len = rowGroupCols.length; i < len; ++i) { + const src = rowGroupCols[i]; + const groupCol = src.sortDef.direction ? src.showRowGroupCol : null; + if (groupCol) { + _pushToMapArray(sourcesByGroup, groupCol, src); + } + } + if (sourcesByGroup.size === 0) { + return sorted; + } + const seen = new Set(); + const result: AgColumn[] = []; + for (let i = 0, len = sorted.length; i < len; ++i) { + const col = sorted[i]; + const groupCol = col.showRowGroupCol ?? col; + if (seen.has(groupCol)) { + continue; + } + seen.add(groupCol); + result.push(groupCol); + const sources = sourcesByGroup.get(groupCol); + for (let j = 0, sLen = sources?.length ?? 0; j < sLen; ++j) { + result.push(sources![j]); + } + } + return result; + } + + public fillCoupledSortIndexMap(sortedCols: AgColumn[], map: Map): number { + let idx = -1; + for (let i = 0, len = sortedCols.length; i < len; ++i) { + const col = sortedCols[i]; + const reflected = col.showRowGroupCol; + map.set(col, reflected && reflected !== col ? idx : ++idx); + } + return idx; + } + + public isGroupSortMixed(column: AgColumn, direction: SortDirection): boolean { + const sourceColumns = this.getSourceColumnsForGroupColumn(column); + if (!sourceColumns) { + return true; + } + for (let i = 0, len = sourceColumns.length; i < len; ++i) { + // Direct `sortDef.direction` read — `getSortDef()` only adds a null-wrap we don't need here. + if (direction !== sourceColumns[i].sortDef.direction) { + return true; + } + } + return false; + } } diff --git a/packages/ag-grid-enterprise/src/rowNumbers/rowNumbersService.ts b/packages/ag-grid-enterprise/src/rowNumbers/rowNumbersService.ts index 3880535da33..a870aeb061d 100644 --- a/packages/ag-grid-enterprise/src/rowNumbers/rowNumbersService.ts +++ b/packages/ag-grid-enterprise/src/rowNumbers/rowNumbersService.ts @@ -1,12 +1,32 @@ +import type { IAriaAnnouncementService } from 'ag-stack'; import { _debounce, _setAriaLabel } from 'ag-stack'; +import { + KeyCode, + ROW_NUMBERS_COLUMN_ID, + _BaseSingleColService, + _addGridCommonParams, + _convertColumnEventSourceType, + _createElement, + _getFirstRow, + _getRowNode, + _interpretAsRightClick, + _isRowNumbers, + _selectAllCells, + isRowNumberCol, +} from 'ag-grid-community'; import type { + AgColumn, + BeanCollection, CellClassParams, CellCtrl, CellFocusedEvent, CellPosition, CellRange, ColDef, + ColKind, + GetContextMenuItems, + IRangeService, IRowNumbersRowResizeFeature, IRowNumbersService, NamedBean, @@ -16,29 +36,9 @@ import type { RowPosition, ValueFormatterParams, ValueGetterParams, - _ColumnCollections, + VisibleColsService, _HeaderComp, } from 'ag-grid-community'; -import { - AgColumn, - BeanStub, - KeyCode, - ROW_NUMBERS_COLUMN_ID, - _addGridCommonParams, - _applyColumnState, - _areColIdsEqual, - _convertColumnEventSourceType, - _createElement, - _destroyColumnTree, - _getColumnStateFromColDef, - _getFirstRow, - _getRowNode, - _interpretAsRightClick, - _isRowNumbers, - _selectAllCells, - _updateColsMap, - isRowNumberCol, -} from 'ag-grid-community'; import type { RangeSelectionExtension, @@ -46,17 +46,35 @@ import type { } from '../rangeSelection/rangeSelectionExtensions'; import { RowNumbersRowResizeFeature, _isRowNumbersResizerEnabled } from './rowNumbersRowResizeFeature'; -export class RowNumbersService extends BeanStub implements NamedBean, IRowNumbersService, RangeSelectionExtension { +const emptyContextMenuItems: GetContextMenuItems = () => []; + +export class RowNumbersService + extends _BaseSingleColService + implements NamedBean, IRowNumbersService, RangeSelectionExtension +{ beanName = 'rowNumbersSvc' as const; - public columns: _ColumnCollections | null; + protected readonly colKind: ColKind = 'row-number'; + + private rangeSvc?: IRangeService; + private ariaAnnounce: IAriaAnnouncementService; + private visibleCols: VisibleColsService; + + public wireBeans(beans: BeanCollection): void { + this.rangeSvc = beans.rangeSvc; + this.ariaAnnounce = beans.ariaAnnounce; + this.visibleCols = beans.visibleCols; + } private isIntegratedWithSelection: boolean = false; private isSuppressCellSelectionIntegration: boolean; - private rowNumberOverrides: RowNumbersOptions; + private rowNumberOverrides: RowNumbersOptions | null = null; private lastColumnResized: number = 0; + private readonly boundValueGetter = (params: ValueGetterParams): string => this.valueGetter(params); + private readonly boundCellClass = (params: CellClassParams): string[] => this.getCellClass(params); + public postConstruct(): void { const refreshCells_debounced = _debounce(this, this.refreshCells.bind(this), 10); this.addManagedEventListeners({ @@ -97,7 +115,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } private registerRangeSelectionExtension(): void { - const rangeSvc = this.beans.rangeSvc as RangeSelectionExtensionRegistry | undefined; + const rangeSvc = this.rangeSvc as RangeSelectionExtensionRegistry | undefined; if (!rangeSvc) { return; } @@ -105,62 +123,8 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber this.addDestroyFunc(() => rangeSvc.unregisterRangeSelectionExtension?.(this)); } - public addColumns(cols: _ColumnCollections): void { - if (this.columns == null) { - return; - } - cols.list = this.columns.list.concat(cols.list); - cols.tree = this.columns.tree.concat(cols.tree); - _updateColsMap(cols); - } - - public createColumns( - cols: _ColumnCollections, - updateOrders: (callback: (cols: AgColumn[] | null) => AgColumn[] | null) => void - ): void { - const destroyCollection = () => { - _destroyColumnTree(this.beans, this.columns?.tree); - this.columns = null; - }; - const { beans } = this; - - if (!_isRowNumbers(beans)) { - destroyCollection(); - return; - } - - const newTreeDepth = cols.treeDepth; - const oldTreeDepth = this.columns?.treeDepth ?? -1; - const treeDepthSame = oldTreeDepth == newTreeDepth; - - const list = this.generateRowNumberCols(); - const areSame = _areColIdsEqual(list, this.columns?.list ?? []); - - if (areSame && treeDepthSame) { - return; - } - - destroyCollection(); - const { colGroupSvc } = this.beans; - const treeDepth = colGroupSvc?.findDepth(cols.tree) ?? 0; - const tree = colGroupSvc?.balanceTreeForAutoCols(list, treeDepth) ?? []; - this.columns = { - list, - tree, - treeDepth, - map: {}, - }; - - const putRowNumbersColsFirstInList = (cols: AgColumn[] | null): AgColumn[] | null => { - if (!cols) { - return null; - } - // we use colId, and not instance, to remove old rowNumbersCols - const colsFiltered = cols.filter((col) => !isRowNumberCol(col)); - return [...list, ...colsFiltered]; - }; - - updateOrders(putRowNumbersColsFirstInList); + public isEnabled(): boolean { + return !!_isRowNumbers(this.beans); } public handleMouseDownOnCell(cellPosition: CellPosition, mouseEvent: MouseEvent): boolean { @@ -169,7 +133,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber !this.isIntegratedWithSelection || (mouseEvent.target as HTMLElement).classList.contains('ag-row-numbers-resizer') ) { - if (this.beans.rangeSvc) { + if (this.rangeSvc) { mouseEvent.preventDefault(); } mouseEvent.stopImmediatePropagation(); @@ -199,7 +163,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } private selectRowCells(cellPosition: CellPosition, keyboardEvent: KeyboardEvent): void { - const { rangeSvc } = this.beans; + const rangeSvc = this.rangeSvc; if (!rangeSvc) { return; @@ -211,31 +175,25 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber public updateColumns(event: PropertyValueChangedEvent): void { const source = _convertColumnEventSourceType(event.source); this.refreshSelectionIntegration(); - for (const col of this.columns?.list ?? []) { - const colDef = this.createRowNumbersColDef(); - col.setColDef(colDef, null, source); - - _applyColumnState(this.beans, { state: [_getColumnStateFromColDef(colDef, col.colId)] }, source); + const had = this.column !== null; + if (this.refreshCols()) { + this.refreshColDef(source); + } else if (had) { + this.beans.colModel.refreshAll(source); } } - public getColumn(): AgColumn | null { - return this.columns?.list.find(isRowNumberCol) ?? null; - } - - public getColumns(): AgColumn[] | null { - return this.columns?.list ?? null; + public override destroy(): void { + this.rowNumberOverrides = null; + super.destroy(); } public setupForHeader(comp: _HeaderComp): void { const { column, eGridHeader } = comp.params; - if (!isRowNumberCol(column)) { return; } - _setAriaLabel(eGridHeader, 'Row Number'); - this.addManagedElementListeners(eGridHeader, { click: this.onHeaderClick.bind(this), keydown: this.onHeaderKeyDown.bind(this), @@ -244,50 +202,39 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } private onGridCellFocused(event: CellFocusedEvent): void { - if ( - !this.isIntegratedWithSelection || - event.rowIndex == null || - !event.column || - !isRowNumberCol(event.column) - ) { + if (!this.isIntegratedWithSelection || event.rowIndex == null || !event.column) { + return; + } + const column = this.beans.colModel.getCol(event.column); + if (!column || !isRowNumberCol(column)) { return; } const translate = this.getLocaleTextFunc(); const message = translate('ariaSelectAllRowCells', 'Press Enter to select all cells on this row'); - this.beans.ariaAnnounce?.announceValue(message, 'ariaSelectAllRowCells'); + this.ariaAnnounce?.announceValue(message, 'ariaSelectAllRowCells'); } public createRowNumbersRowResizerFeature(ctrl: CellCtrl): IRowNumbersRowResizeFeature | undefined { - if (!_isRowNumbersResizerEnabled(this.beans)) { - return undefined; - } - - return new RowNumbersRowResizeFeature(this.beans, ctrl); + return _isRowNumbersResizerEnabled(this.beans) ? new RowNumbersRowResizeFeature(this.beans, ctrl) : undefined; } private refreshSelectionIntegration(): void { - const { beans } = this; - const { gos, rangeSvc } = beans; - const cellSelection = gos.get('cellSelection'); + const cellSelection = this.gos.get('cellSelection'); this.refreshRowNumberOverrides(); - - this.isIntegratedWithSelection = !!rangeSvc && !!cellSelection && !this.isSuppressCellSelectionIntegration; + this.isIntegratedWithSelection = !!this.rangeSvc && !!cellSelection && !this.isSuppressCellSelectionIntegration; } private refreshRowNumberOverrides(): void { const rowNumbers = _isRowNumbers(this.beans); this.rowNumberOverrides = {}; this.isSuppressCellSelectionIntegration = false; - if (!rowNumbers || typeof rowNumbers !== 'object') { return; } - if (rowNumbers.suppressCellSelectionIntegration) { this.isSuppressCellSelectionIntegration = true; } - const colDefValidProps: (keyof RowNumbersOptions)[] = [ 'contextMenuItems', 'context', @@ -315,7 +262,6 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber 'cellRendererSelector', 'cellRendererParams', ]; - for (const prop of colDefValidProps) { if (rowNumbers[prop] != null) { this.rowNumberOverrides[prop] = rowNumbers[prop]; @@ -327,27 +273,21 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber if (!this.isIntegratedWithSelection) { return; } - const translate = this.getLocaleTextFunc(); const message = translate('ariaSelectAllCells', 'Press Space or Enter to select all cells'); - this.beans.ariaAnnounce?.announceValue(message, 'ariaSelectAllCells'); + this.ariaAnnounce?.announceValue(message, 'ariaSelectAllCells'); } private onHeaderKeyDown(e: KeyboardEvent): void { if (!this.isIntegratedWithSelection || (e.key !== KeyCode.SPACE && e.key !== KeyCode.ENTER)) { return; } - e.preventDefault(); this.selectAllCellsFromHeader(); } private onHeaderClick(_e: MouseEvent): void { - if ( - Date.now() - this.lastColumnResized < 100 || - !this.isIntegratedWithSelection || - this.getColumn()?.resizing - ) { + if (Date.now() - this.lastColumnResized < 100 || !this.isIntegratedWithSelection || this.column?.resizing) { return; } this.focusAllCellsFromHeaderClick(); @@ -363,12 +303,10 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } private refreshCells(force?: boolean, runAutoSize?: boolean): void { - const column = this.getColumn(); - + const column = this.column; if (!column) { return; } - if (runAutoSize) { const width = this.beans.autoWidthCalc?.getPreferredWidthForElements([this.createDummyElement(column)], 2); if (width != null) { @@ -380,27 +318,23 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber ); } } - - this.beans.rowRenderer.refreshCells({ - columns: [column], - force, - }); + this.beans.rowRenderer.refreshCells({ columns: [column], force }); } private createDummyElement(column: AgColumn): HTMLDivElement { const div = _createElement({ tag: 'div', cls: 'ag-cell-value ag-cell' }); let value = String(this.beans.rowModel.getRowCount() + 1); - - if (typeof this.rowNumberOverrides.valueFormatter === 'function') { - const valueFormatterParams: ValueFormatterParams = _addGridCommonParams(this.beans.gos, { + const rowNumberOverrides = this.rowNumberOverrides; + if (typeof rowNumberOverrides?.valueFormatter === 'function') { + const valueFormatterParams: ValueFormatterParams = _addGridCommonParams(this.gos, { data: undefined, value, node: null, column, colDef: column.colDef, }); - value = this.rowNumberOverrides.valueFormatter(valueFormatterParams); + value = rowNumberOverrides.valueFormatter(valueFormatterParams); } div.textContent = value; @@ -408,17 +342,17 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber return div; } - private createRowNumbersColDef(): ColDef { - const { gos, contextMenuSvc } = this.beans; - const enableRTL = gos.get('enableRtl'); + protected createColDef(): ColDef { + const contextMenuSvc = this.beans.contextMenuSvc; + const enableRTL = this.gos.get('enableRtl'); return { // overridable properties minWidth: 60, width: 60, resizable: false, - valueGetter: this.valueGetter.bind(this), - contextMenuItems: this.isIntegratedWithSelection || !contextMenuSvc ? undefined : () => [], + valueGetter: this.boundValueGetter, + contextMenuItems: this.isIntegratedWithSelection || !contextMenuSvc ? undefined : emptyContextMenuItems, // overrides ...this.rowNumberOverrides, // non-overridable properties @@ -436,7 +370,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber suppressSizeToFit: true, suppressHeaderContextMenu: true, headerClass: this.getHeaderClass(), - cellClass: this.getCellClass.bind(this), + cellClass: this.boundCellClass, cellAriaRole: 'rowheader', }; } @@ -466,11 +400,10 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } private getCellClass(params: CellClassParams): string[] { - const { beans } = this; - const { rangeSvc, gos } = beans; + const rangeSvc = this.rangeSvc; const { node } = params; const cssClasses = ['ag-row-number-cell']; - const cellSelection = gos.get('cellSelection'); + const cellSelection = this.gos.get('cellSelection'); if (!rangeSvc || !cellSelection) { return cssClasses; @@ -487,7 +420,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } // -1 here because we shouldn't include the column added by this service - const allColsLen = this.beans.visibleCols.allCols.length - 1; + const allColsLen = this.visibleCols.allCols.length - 1; const shouldHighlight = typeof cellSelection === 'object' && cellSelection.enableHeaderHighlight; for (const range of ranges) { @@ -505,20 +438,6 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber return cssClasses; } - private generateRowNumberCols(): AgColumn[] { - const { gos, beans } = this; - if (!_isRowNumbers(beans)) { - return []; - } - - const colDef = this.createRowNumbersColDef(); - const colId = colDef.colId!; - gos.validateColDef(colDef, colId, true); - const col = new AgColumn(colDef, null, colId, false); - this.createBean(col); - return [col]; - } - private focusFirstRenderedCellAtRowPosition(rowPosition?: RowPosition | null) { const editSvc = this.beans.editSvc; @@ -534,9 +453,9 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber } } - const { beans, gos } = this; - const { visibleCols, colViewport } = beans; - const pinnedCols = gos.get('enableRtl') ? visibleCols.rightCols : visibleCols.leftCols; + const beans = this.beans; + const visibleCols = this.visibleCols; + const pinnedCols = this.gos.get('enableRtl') ? visibleCols.rightCols : visibleCols.leftCols; let columns: AgColumn[]; if (pinnedCols.length == 1) { @@ -545,7 +464,7 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber if (!rowNode) { return; } - columns = colViewport.getColsWithinViewport(rowNode); + columns = beans.colViewport.getColsWithinViewport(rowNode); } else { columns = pinnedCols; } @@ -560,19 +479,15 @@ export class RowNumbersService extends BeanStub implements NamedBean, IRowNumber // to avoid conflict with setting the range, add a setTimeout here setTimeout(() => { - beans.focusSvc.setFocusedCell({ - rowIndex, - rowPinned, - column, - forceBrowserFocus: true, - preventScrollOnBrowserFocus: true, - }); + if (this.isAlive()) { + beans.focusSvc.setFocusedCell({ + rowIndex, + rowPinned, + column, + forceBrowserFocus: true, + preventScrollOnBrowserFocus: true, + }); + } }); } - - public override destroy(): void { - _destroyColumnTree(this.beans, this.columns?.tree); - (this.rowNumberOverrides as any) = null; - super.destroy(); - } } diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/listenerUtils.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/listenerUtils.ts index 96dfbbe6691..183c4797429 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/listenerUtils.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/listenerUtils.ts @@ -1,11 +1,11 @@ -import type { BeanCollection, IColsService, IPivotResultColsService, NamedBean } from 'ag-grid-community'; +import type { BeanCollection, IPivotResultColsService, IValueColsService, NamedBean } from 'ag-grid-community'; import { BeanStub } from 'ag-grid-community'; export class ListenerUtils extends BeanStub implements NamedBean { beanName = 'ssrmListenerUtils' as const; private pivotResultCols?: IPivotResultColsService; - private valueColsSvc?: IColsService; + private valueColsSvc?: IValueColsService; public wireBeans(beans: BeanCollection) { this.pivotResultCols = beans.pivotResultCols; @@ -25,12 +25,12 @@ export class ListenerUtils extends BeanStub implements NamedBean { } public isSortingWithSecondaryColumn(changedColumnsInSort: string[]): boolean { - const pivotResultCols = this.pivotResultCols?.getPivotResultCols(); - if (!pivotResultCols) { + const pivotCols = this.pivotResultCols?.pivotCols; + if (!pivotCols) { return false; } - const secondaryColIds = pivotResultCols.list.map((col) => col.colId); + const secondaryColIds = pivotCols.map((col) => col.colId); for (let i = 0; i < changedColumnsInSort.length; i++) { if (secondaryColIds.indexOf(changedColumnsInSort[i]) > -1) { diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/sortListener.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/sortListener.ts index 5403bb40214..39eef874b0a 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/sortListener.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/sortListener.ts @@ -1,5 +1,5 @@ import type { BeanCollection, NamedBean, SortModelItem, SortService, StoreRefreshAfterParams } from 'ag-grid-community'; -import { BeanStub, _isServerSideRowModel } from 'ag-grid-community'; +import { BeanStub, _getSortModel, _isServerSideRowModel } from 'ag-grid-community'; import type { ServerSideRowModel } from '../serverSideRowModel'; import type { ListenerUtils } from './listenerUtils'; @@ -32,7 +32,7 @@ export class SortListener extends BeanStub implements NamedBean { return; } // params is undefined if no datasource set - const newSortModel = this.sortSvc.getSortModel(); + const newSortModel = _getSortModel(this.sortSvc); const oldSortModel = storeParams.sortModel; const changedColumns = this.findChangedColumnsInSort(newSortModel, oldSortModel); diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts index e7c45a1c466..6332d423ef7 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/serverSideRowModel.ts @@ -34,6 +34,7 @@ import { RowNode, _getRowHeightAsNumber, _getRowHeightForNode, + _getSortModel, _isGetRowHeightFunction, _isRowSelection, _warn, @@ -238,7 +239,7 @@ export class ServerSideRowModel extends BeanStub implements NamedBean, IServerSi return allColsUnchanged && !missingCols; }; - const sortModelDifferent = !_jsonEquals(this.storeParams.sortModel, this.sortSvc?.getSortModel() ?? []); + const sortModelDifferent = !_jsonEquals(this.storeParams.sortModel, _getSortModel(this.sortSvc)); const rowGroupDifferent = !areColsSame({ oldCols: this.storeParams.rowGroupCols, newCols: rowGroupColumnVos, @@ -399,7 +400,7 @@ export class ServerSideRowModel extends BeanStub implements NamedBean, IServerSi filterModel: this.filterManager?.isAdvFilterEnabled() ? this.filterManager?.getAdvFilterModel() : (this.filterManager?.getFilterModel() ?? {}), - sortModel: this.sortSvc?.getSortModel() ?? [], + sortModel: _getSortModel(this.sortSvc), datasource: this.datasource, lastAccessedSequence: { value: 0 }, diff --git a/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts b/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts index 5fbc0a83a57..c705f26901b 100644 --- a/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts +++ b/packages/ag-grid-enterprise/src/sideBar/common/toolPanelColDefService.ts @@ -23,13 +23,13 @@ export function toolPanelCreateColumnTree( children.push(child); } } - group.setChildren(children); + group.children = children; return group; } else { const colDef = abstractColDef as ColDef; const key = colDef.colId ? colDef.colId : colDef.field; - const column = colModel.getColDefCol(key!)!; + const column = colModel.getNonPivotCol(key!)!; if (!column) { invalidColIds.push(colDef); @@ -122,7 +122,7 @@ function getLeafPathTrees(columns: AgColumn[]): AbstractColDef[] { } function getGridPrimaryColumns(colModel: ColumnModel): AgColumn[] { - return colModel.getCols().filter((column) => { + return colModel.colsList.filter((column) => { return column.primary && !column.colDef.showRowGroup; }); } diff --git a/packages/ag-grid-enterprise/src/toolbar/providedItems/pivotPanelToolbarItem.ts b/packages/ag-grid-enterprise/src/toolbar/providedItems/pivotPanelToolbarItem.ts index 752a526fb0b..3a45c9c1f51 100644 --- a/packages/ag-grid-enterprise/src/toolbar/providedItems/pivotPanelToolbarItem.ts +++ b/packages/ag-grid-enterprise/src/toolbar/providedItems/pivotPanelToolbarItem.ts @@ -35,9 +35,9 @@ export class PivotPanelToolbarItem extends Component implements IToolbarItemComp }); // Hide the toolbar item when not in pivot mode - this.setDisplayed(this.beans.colModel.isPivotMode()); + this.setDisplayed(this.beans.colModel.pivotMode); this.addManagedEventListeners({ - columnPivotModeChanged: () => this.setDisplayed(this.beans.colModel.isPivotMode()), + columnPivotModeChanged: () => this.setDisplayed(this.beans.colModel.pivotMode), }); } diff --git a/packages/ag-stack/src/main-internal.ts b/packages/ag-stack/src/main-internal.ts index 9440f04bd71..2e94153f452 100644 --- a/packages/ag-stack/src/main-internal.ts +++ b/packages/ag-stack/src/main-internal.ts @@ -140,12 +140,14 @@ export type { AriaSortState } from './utils/aria'; export { _areEqual, _flatten, - _forAll, + _indexMap, _last, _moveInArray, + _pushToMapArray, _removeAllFromArray, _removeFromArray, _reuseArrayIfEqual, + _symmetricDiff, } from './utils/array'; export { _parseBigIntOrNull } from './utils/bigInt'; export { diff --git a/packages/ag-stack/src/utils/array.test.ts b/packages/ag-stack/src/utils/array.test.ts index a70c6440708..23f992cbabe 100644 --- a/packages/ag-stack/src/utils/array.test.ts +++ b/packages/ag-stack/src/utils/array.test.ts @@ -1,4 +1,4 @@ -import { _areEqual, _removeAllFromArray } from './array'; +import { _areEqual, _indexMap, _pushToMapArray, _removeAllFromArray, _symmetricDiff } from './array'; describe('areEqual', () => { it.each([ @@ -98,3 +98,92 @@ describe('_removeAllFromArray', () => { expect(array).toEqual([2, 3, 4]); }); }); + +describe('_symmetricDiff', () => { + it.each([ + [null, null], + [undefined, undefined], + [null, undefined], + [[], []], + [[], null], + ])('returns [] when both sides are empty or missing: a = %s, b = %s', (a, b) => { + expect(_symmetricDiff(a, b)).toEqual([]); + }); + + it('returns [] for the same array reference (fast path)', () => { + const a = [1, 2, 3]; + expect(_symmetricDiff(a, a)).toEqual([]); + }); + + it('returns a copy of the non-empty side when the other is empty', () => { + const a = [1, 2, 3]; + const result = _symmetricDiff(a, []); + expect(result).toEqual([1, 2, 3]); + expect(result).not.toBe(a); + + expect(_symmetricDiff([], [4, 5])).toEqual([4, 5]); + expect(_symmetricDiff(null, [4, 5])).toEqual([4, 5]); + }); + + it('returns [] when both sides hold the same elements', () => { + expect(_symmetricDiff([1, 2, 3], [3, 2, 1])).toEqual([]); + }); + + it('returns survivors in a-order then additions in b-order', () => { + // 1 only in a (survives), 2 & 3 in both (cancel), 4 only in b (added). + expect(_symmetricDiff([1, 2, 3], [2, 3, 4])).toEqual([1, 4]); + }); + + it('handles a single differing element on each side', () => { + expect(_symmetricDiff([1], [2])).toEqual([1, 2]); + }); + + it('compares by reference identity for objects', () => { + const shared = { id: 1 }; + const onlyA = { id: 2 }; + const onlyB = { id: 3 }; + expect(_symmetricDiff([shared, onlyA], [shared, onlyB])).toEqual([onlyA, onlyB]); + }); +}); + +describe('_indexMap', () => { + it.each([[null], [undefined], [[]]])('returns an empty map for %s', (arr) => { + expect(_indexMap(arr).size).toBe(0); + }); + + it('maps each element to its index', () => { + const map = _indexMap(['a', 'b', 'c']); + expect(map.get('a')).toBe(0); + expect(map.get('b')).toBe(1); + expect(map.get('c')).toBe(2); + }); + + it('keeps the last index for duplicate elements', () => { + const map = _indexMap(['a', 'b', 'a']); + expect(map.get('a')).toBe(2); + expect(map.get('b')).toBe(1); + }); +}); + +describe('_pushToMapArray', () => { + it('creates a new bucket on first use', () => { + const map = new Map(); + _pushToMapArray(map, 'k', 1); + expect(map.get('k')).toEqual([1]); + }); + + it('appends to an existing bucket', () => { + const map = new Map(); + _pushToMapArray(map, 'k', 1); + _pushToMapArray(map, 'k', 2); + expect(map.get('k')).toEqual([1, 2]); + }); + + it('keeps buckets separate per key', () => { + const map = new Map(); + _pushToMapArray(map, 'a', 1); + _pushToMapArray(map, 'b', 2); + expect(map.get('a')).toEqual([1]); + expect(map.get('b')).toEqual([2]); + }); +}); diff --git a/packages/ag-stack/src/utils/array.ts b/packages/ag-stack/src/utils/array.ts index b5ac8b27058..4c47c4ea337 100644 --- a/packages/ag-stack/src/utils/array.ts +++ b/packages/ag-stack/src/utils/array.ts @@ -2,11 +2,8 @@ export function _last(arr: readonly T[]): T; export function _last(arr: NodeListOf): T; export function _last(arr: any): any { - if (!arr?.length) { - return; - } - - return arr[arr.length - 1]; + const len = arr?.length; + return len ? arr[len - 1] : undefined; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ @@ -16,42 +13,42 @@ export function _areEqual( comparator?: (a: T, b: T) => boolean ): boolean { if (a === b) { - return true; // Same instance, no need to compare + return true; } if (!a || !b) { - return a == null && b == null; // True if both are null or undefined, false otherwise + return a == null && b == null; // equal only if both nullish } const len = a.length; if (len !== b.length) { - return false; // Different lengths, cannot be equal + return false; } if (comparator) { for (let i = 0; i < len; ++i) { - if (a[i] !== b[i] && !comparator(a[i], b[i])) { - return false; // Elements are not strictly equal and comparator returns false + const valueA = a[i]; + const valueB = b[i]; + if (valueA !== valueB && !comparator(valueA, valueB)) { + return false; } } - return true; // All elements are equal + return true; } for (let i = 0; i < len; ++i) { if (a[i] !== b[i]) { - return false; // Elements are not strictly equal + return false; } } - return true; // All elements are equal + return true; } /** - * Returns `prev` when its contents equal `current`; otherwise `current.slice()` (or `[]` if - * nullish). The same-reference case (`prev === current`) returns a fresh slice so callers never - * receive the readonly `current` aliased back. Mutating a returned `prev` persists into the next - * call's `prev`. + * Returns `prev` when its contents equal `current`; otherwise `current.slice()` (or `[]` if nullish). + * `prev === current` returns a fresh slice so callers never get the readonly `current` aliased back. * * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _reuseArrayIfEqual(prev: T[] | null | undefined, current: readonly T[] | null | undefined): T[] { - // Equality scan inlined (not `_areEqual`) — hot path; called per group node per sort refresh. - // Keep the loop semantics in sync with `_areEqual`'s no-comparator branch above if either changes. + // Equality scan inlined (not `_areEqual`) — hot path, per group node per sort refresh. + // Keep loop semantics in sync with `_areEqual`'s no-comparator branch. if (!current) { return []; } @@ -67,23 +64,6 @@ export function _reuseArrayIfEqual(prev: T[] | null | undefined, current: rea return current.slice(); } -/** - * Utility that uses the fastest looping approach to apply a callback to each element of the array - * https://jsperf.app/for-for-of-for-in-foreach-comparison - * If callback returns true, exit early. - * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. - */ -export function _forAll(array: T[] | undefined, callback: (value: T) => boolean | void) { - if (!array) { - return; - } - for (const value of array) { - if (callback(value)) { - return true; - } - } -} - /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _removeFromArray(array: T[], object: T): void { const index = array.indexOf(object); @@ -100,34 +80,37 @@ export function _removeFromArray(array: T[], object: T): void { * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _removeAllFromArray(array: T[], elementsToRemove: readonly T[]): void { - let i = 0; + const len = array.length; + const removeLen = elementsToRemove.length; + if (!len || !removeLen) { + return; + } let j = 0; - - for (; i < array.length; i++) { - if (!elementsToRemove.includes(array[i])) { - // elements that we want to keep are moved to the beginning of the array, maintaining original order - array[j] = array[i]; - j++; + const removeSet = new Set(elementsToRemove); + for (let i = 0; i < len; ++i) { + const value = array[i]; + if (!removeSet.has(value)) { + if (i !== j) { + array[j] = value; + } + ++j; } } - - // j marks the elements we want to keep, so pop off the remaining elements (each pop is O(1)) - while (j < array.length) { - array.pop(); + if (j < len) { + array.length = j; } } // should consider refactoring the callers to create a new array rather than mutating the original, which is expensive /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _moveInArray(array: T[], objectsToMove: T[], toIndex: number) { - // first take out items from the array - for (let i = 0; i < objectsToMove.length; i++) { + const objectsToMoveLen = objectsToMove.length; + for (let i = 0; i < objectsToMoveLen; ++i) { _removeFromArray(array, objectsToMove[i]); } - // now add the objects, in same order as provided to us, that means we start at the end - // as the objects will be pushed to the right as they are inserted - for (let i = objectsToMove.length - 1; i >= 0; i--) { + // insert from the end so each splice pushes earlier items right, preserving provided order + for (let i = objectsToMoveLen - 1; i >= 0; i--) { array.splice(toIndex, 0, objectsToMove[i]); } } @@ -137,3 +120,51 @@ export function _flatten(arrays: Array): T[] { // Currently the fastest way to flatten an array according to https://jsbench.me/adlib26t2y/2 return ([] as T[]).concat.apply([], arrays); } + +/** Elements present in exactly one of the two arrays (added or removed); nullish/empty counts as + * none. Fast paths: equal refs → empty; one side empty → copy of the other (no Set built). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _symmetricDiff(a: readonly T[] | null | undefined, b: readonly T[] | null | undefined): T[] { + if (a === b) { + return []; + } + const leftLen = a?.length; + const rightLen = b?.length; + if (!leftLen) { + return b ? b.slice() : []; + } + if (!rightLen) { + return a ? a.slice() : []; + } + const diff = new Set(a); + for (let i = 0; i < rightLen; ++i) { + const item = b![i]; + if (!diff.delete(item)) { + diff.add(item); + } + } + return Array.from(diff); +} + +/** Push `value` onto the array bucket at `key` in a `Map`, creating the bucket on first use. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _pushToMapArray(map: Map, key: K, value: V): void { + const bucket = map.get(key); + if (bucket === undefined) { + map.set(key, [value]); + return; + } + bucket.push(value); +} + +/** Build a `Map` for O(1) position lookups, beating repeated `indexOf` (O(N²) over a sort/loop). + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export function _indexMap(arr: readonly K[] | null | undefined): Map { + const map = new Map(); + if (arr) { + for (let i = 0, len = arr.length; i < len; ++i) { + map.set(arr[i], i); + } + } + return map; +} diff --git a/testing/behavioural/src/benchmarks/column-update.bench.ts b/testing/behavioural/src/benchmarks/column-update.bench.ts index d2b3151aea1..e9cb39ed984 100644 --- a/testing/behavioural/src/benchmarks/column-update.bench.ts +++ b/testing/behavioural/src/benchmarks/column-update.bench.ts @@ -63,8 +63,6 @@ const colIdsOf = (defs: (ColDef | ColGroupDef)[]): string[] => { suite('column update — applyColumnState / getColumnState paths (tiny rowData)', () => { let gridId = 0; - // `apply` runs once per iteration against a single long-lived grid (created in `setup`). - // Scenarios that need a "real change" each call alternate on `iter & 1`. const benchUpdate = (name: string, initial: GridOptions, apply: (api: GridApi, iter: number) => void) => { const id = `CU${++gridId}`; const gridsManager = new TestGridsManager({ benchmark: true, modules }); @@ -89,12 +87,10 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' const cols50 = buildFlatCols(50); const ids50 = colIdsOf(cols50); - // Pure snapshot read — allocates one ColumnState object per column. benchUpdate('getColumnState 50 flat cols', { columnDefs: cols50 }, (api) => { api.getColumnState(); }); - // Restore a saved snapshot (captured once) — idempotent state but a full refresh every call. benchUpdate( 'applyColumnState restore saved state 50 flat cols', { columnDefs: cols50 }, @@ -107,7 +103,6 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' })() ); - // Reverse vs forward order each call (applyOrder true) — exercises orderLiveColsLikeState + locked placement. const forward50: ColumnState[] = ids50.map((colId) => ({ colId })); const reversed50: ColumnState[] = ids50 .slice() @@ -117,31 +112,34 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' api.applyColumnState({ state: i & 1 ? forward50 : reversed50, applyOrder: true }); }); - // Toggle visibility on half the cols each call. const hideHalf50: ColumnState[] = ids50.map((colId, i) => ({ colId, hide: (i & 1) === 0 })); const showAll50: ColumnState[] = ids50.map((colId) => ({ colId, hide: false })); benchUpdate('applyColumnState toggle visibility half of 50 cols', { columnDefs: cols50 }, (api, i) => { api.applyColumnState({ state: i & 1 ? showAll50 : hideHalf50 }); }); - // Toggle pinning on the first 5 cols each call. const pinLeft50: ColumnState[] = ids50.map((colId, i) => ({ colId, pinned: i < 5 ? ('left' as const) : null })); const unpinned50: ColumnState[] = ids50.map((colId) => ({ colId, pinned: null })); benchUpdate('applyColumnState toggle pinned 5 of 50 cols', { columnDefs: cols50 }, (api, i) => { api.applyColumnState({ state: i & 1 ? unpinned50 : pinLeft50 }); }); - // setColumnsVisible API — narrower entry point than applyColumnState. benchUpdate('setColumnsVisible toggle 50 cols', { columnDefs: cols50 }, (api, i) => { api.setColumnsVisible(ids50, (i & 1) === 1); }); - // resetColumnState — applies state then re-applies an order pass (two internal applies). + benchUpdate( + 'setColumnsVisible toggle 50 cols (with selection col)', + { columnDefs: cols50, rowSelection: { mode: 'multiRow' } }, + (api, i) => { + api.setColumnsVisible(ids50, (i & 1) === 1); + } + ); + benchUpdate('resetColumnState 50 cols', { columnDefs: cols50 }, (api) => { api.resetColumnState(); }); - // Toggle rowGroup on the `group` col — creates/destroys the auto-group col each call. const cols20 = buildFlatCols(20); const addRowGroup: ColumnState[] = [{ colId: 'group', rowGroup: true, rowGroupIndex: 0 }]; const clearRowGroup: ColumnState[] = [{ colId: 'group', rowGroup: false, rowGroupIndex: null }]; @@ -149,7 +147,6 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' api.applyColumnState({ state: i & 1 ? clearRowGroup : addRowGroup }); }); - // Grouped layout: reverse vs forward leaf order with column groups present. const grouped = buildGroupedCols(5, 8); // 8 groups × 5 leaves const gIds = colIdsOf(grouped); const gForward: ColumnState[] = gIds.map((colId) => ({ colId })); @@ -165,7 +162,6 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' } ); - // Multi-column sort — sort + sortIndex on the first 6 cols, flipping asc/desc each call. const sortAsc6: ColumnState[] = ids50 .slice(0, 6) .map((colId, i) => ({ colId, sort: 'asc' as const, sortIndex: i })); @@ -176,28 +172,24 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' api.applyColumnState({ state: i & 1 ? sortAsc6 : sortDesc6 }); }); - // Width — set an explicit width on every col, alternating two values each call. const widthA50: ColumnState[] = ids50.map((colId) => ({ colId, width: 120 })); const widthB50: ColumnState[] = ids50.map((colId) => ({ colId, width: 180 })); benchUpdate('applyColumnState set width 50 cols', { columnDefs: cols50 }, (api, i) => { api.applyColumnState({ state: i & 1 ? widthA50 : widthB50 }); }); - // Flex — set flex on every col, alternating two values each call (triggers flex layout pass). const flexA50: ColumnState[] = ids50.map((colId) => ({ colId, flex: 1 })); const flexB50: ColumnState[] = ids50.map((colId) => ({ colId, flex: 2 })); benchUpdate('applyColumnState set flex 50 cols', { columnDefs: cols50 }, (api, i) => { api.applyColumnState({ state: i & 1 ? flexA50 : flexB50 }); }); - // aggFunc — toggle the value column's aggregation on/off (exercises valueColsSvc.syncColumnWithState). const addAgg: ColumnState[] = [{ colId: 'value', aggFunc: 'sum' }]; const clearAgg: ColumnState[] = [{ colId: 'value', aggFunc: null }]; benchUpdate('applyColumnState toggle aggFunc on value col 20 cols', { columnDefs: cols20 }, (api, i) => { api.applyColumnState({ state: i & 1 ? clearAgg : addAgg }); }); - // Pivot — toggle pivot + pivotIndex on the `group` col while pivot mode is active. const addPivot: ColumnState[] = [{ colId: 'group', pivot: true, pivotIndex: 0 }]; const clearPivot: ColumnState[] = [{ colId: 'group', pivot: false, pivotIndex: null }]; benchUpdate( @@ -208,7 +200,6 @@ suite('column update — applyColumnState / getColumnState paths (tiny rowData)' } ); - // defaultState — a partial state for one col, with defaultState applied to the other 49 cols. const partialState: ColumnState[] = [{ colId: 'c0', hide: true }]; benchUpdate('applyColumnState with defaultState (49 cols defaulted)', { columnDefs: cols50 }, (api, i) => { api.applyColumnState({ state: partialState, defaultState: { hide: (i & 1) === 0 } }); diff --git a/testing/behavioural/src/columnToolPanel/deferred-pivot-mode.test.ts b/testing/behavioural/src/columnToolPanel/deferred-pivot-mode.test.ts index 63934a1f880..ecc4fd55b07 100644 --- a/testing/behavioural/src/columnToolPanel/deferred-pivot-mode.test.ts +++ b/testing/behavioural/src/columnToolPanel/deferred-pivot-mode.test.ts @@ -801,29 +801,46 @@ describe('deferred column tool panel pivot mode', () => { }); test('commit should call exactly one state-application path', async () => { - const { toolPanel } = await createDeferredPivotModeGrid(); - const { gos, stateSvc, colModel, colMoves, rowGroupColsSvc, valueColsSvc, pivotColsSvc } = toolPanel.beans; + const { gridApi, toolPanel } = await createDeferredPivotModeGrid(); - const updateGridOptionsSpy = vi.spyOn(gos, 'updateGridOptions'); - const setStateSpy = stateSvc ? vi.spyOn(stateSvc, 'setState') : undefined; - const setPivotModeSpy = vi.spyOn(colModel as any, 'setPivotMode'); - const moveColumnsSpy = colMoves ? vi.spyOn(colMoves, 'moveColumns') : undefined; - const setRowGroupColumnsSpy = rowGroupColsSvc ? vi.spyOn(rowGroupColsSvc, 'setColumns') : undefined; - const setValueColumnsSpy = valueColsSvc ? vi.spyOn(valueColsSvc, 'setColumns') : undefined; - const setColumnAggFuncSpy = valueColsSvc ? vi.spyOn(valueColsSvc, 'setColumnAggFunc') : undefined; - const setPivotColumnsSpy = pivotColsSvc ? vi.spyOn(pivotColsSvc, 'setColumns') : undefined; + // Observe the public batch signal: a single batched state application fires exactly one + // `columnEverythingChanged`. A redundant grid-state round-trip would fire it twice; a piecemeal + // path (per-column moveColumns / setColumns / setColumnAggFunc, which emit only granular events) + // would fire it zero times. Both regressions are caught by asserting exactly one. + let everythingChangedCount = 0; + gridApi.addEventListener('columnEverythingChanged', () => { + everythingChangedCount++; + }); getUpdateStrategy(toolPanel).setPivotMode(true, false, 'toolPanelUi'); commitChanges(toolPanel); - expect(setStateSpy?.mock.calls.length ?? 0).toBe(1); - expect(updateGridOptionsSpy).toHaveBeenCalledTimes(1); - expect(setPivotModeSpy).toHaveBeenCalledTimes(1); - expect(moveColumnsSpy).not.toHaveBeenCalled(); - expect(setRowGroupColumnsSpy).not.toHaveBeenCalled(); - expect(setValueColumnsSpy).not.toHaveBeenCalled(); - expect(setColumnAggFuncSpy).not.toHaveBeenCalled(); - expect(setPivotColumnsSpy).not.toHaveBeenCalled(); + await asyncSetTimeout(1); + + // Turning pivot off applies state in a single batch — exactly one `columnEverythingChanged`. + expect(everythingChangedCount).toBe(1); + expect(gridApi.isPivotMode()).toBe(false); + }); + + test('toggling pivot mode in deferred mode persists pivot state to grid state and restores pivot columns', async () => { + const { gridApi, toolPanel } = await createDeferredPivotModeGrid(); + + expect(gridApi.getState().pivot).toEqual({ pivotMode: true, pivotColIds: ['year'] }); + + getUpdateStrategy(toolPanel).setPivotMode(true, false, 'toolPanelUi'); + commitChanges(toolPanel); + await waitForNoLoadingRows(gridApi); + + expect(gridApi.isPivotMode()).toBe(false); + expect(gridApi.getState().pivot?.pivotMode ?? false).toBe(false); + + getUpdateStrategy(toolPanel).setPivotMode(true, true, 'toolPanelUi'); + commitChanges(toolPanel); + await waitForNoLoadingRows(gridApi); + + expect(gridApi.isPivotMode()).toBe(true); + expect(gridApi.getPivotColumns().map((col) => col.getColId())).toEqual(['year']); + expect(gridApi.getState().pivot).toEqual({ pivotMode: true, pivotColIds: ['year'] }); }); test('commit should make exactly one server call', async () => { @@ -1153,8 +1170,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getValueColumnIds(gridApi)).toEqual(['silver', 'gold']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering column groups and cancelling in non-pivot mode should keep the original order', async () => { + test('reordering column groups and cancelling in non-pivot mode should keep the original order', async () => { const { gridApi, toolPanel } = await createDeferredGroupedNonPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; const age = gridApi.getColumn('age')! as AgColumn; @@ -1165,8 +1181,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel)).toEqual(['athlete', 'age', 'country', 'year']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering column groups and cancelling in pivot mode should keep the original order', async () => { + test('reordering column groups and cancelling in pivot mode should keep the original order', async () => { const { gridApi, toolPanel } = await createDeferredGroupedPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; const age = gridApi.getColumn('age')! as AgColumn; @@ -1177,8 +1192,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel)).toEqual(['athlete', 'age', 'country', 'year']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering column groups in non-pivot mode applies only after commit', async () => { + test('reordering column groups in non-pivot mode applies only after commit', async () => { const { gridApi, toolPanel } = await createDeferredGroupedNonPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; const age = gridApi.getColumn('age')! as AgColumn; @@ -1194,8 +1208,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel)).toEqual(['country', 'year', 'athlete', 'age']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering column groups in pivot mode applies only after commit', async () => { + test('reordering column groups in pivot mode applies only after commit', async () => { const { gridApi, toolPanel } = await createDeferredGroupedPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; const age = gridApi.getColumn('age')! as AgColumn; @@ -1230,8 +1243,7 @@ describe('deferred column tool panel pivot mode', () => { expect(gridApi.getPivotColumns().map((col) => col.getColId())).toEqual(['date', 'year']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering columns and cancelling in non-pivot mode should keep the original order', async () => { + test('reordering columns and cancelling in non-pivot mode should keep the original order', async () => { const { gridApi, toolPanel } = await createDeferredNonPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; @@ -1241,8 +1253,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel).slice(0, 3)).toEqual(['athlete', 'age', 'country']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering columns and cancelling in pivot mode should keep the original order', async () => { + test('reordering columns and cancelling in pivot mode should keep the original order', async () => { const { toolPanel } = await createDeferredPivotModeGrid(); const athlete = toolPanel.beans.colModel.getNonPivotCol('athlete') as AgColumn; @@ -1252,8 +1263,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel).slice(0, 3)).toEqual(['athlete', 'age', 'country']); }); - // Solved by AG-17366 when it is completed - test.skip('reordering columns in non-pivot mode applies only after commit', async () => { + test('reordering columns in non-pivot mode applies only after commit', async () => { const { gridApi, toolPanel } = await createDeferredNonPivotGrid(); const athlete = gridApi.getColumn('athlete')! as AgColumn; @@ -1268,8 +1278,7 @@ describe('deferred column tool panel pivot mode', () => { expect(getPrimaryColumnOrder(toolPanel).slice(0, 3)).toEqual(['age', 'athlete', 'country']); }); - // Solved by AG-17366 when it is completed - test.skip('dragging a column to the end in non-pivot mode should update the deferred tool panel order before commit', async () => { + test('dragging a column to the end in non-pivot mode should update the deferred tool panel order before commit', async () => { const { toolPanel } = await createDeferredNonPivotGrid(); expect(getDisplayedPrimaryColumnOrder(toolPanel)).toEqual([ @@ -1328,8 +1337,7 @@ describe('deferred column tool panel pivot mode', () => { ]); }); - // Solved by AG-17366 when it is completed - test.skip('reordering columns in pivot mode applies primary column order only after commit', async () => { + test('reordering columns in pivot mode applies primary column order only after commit', async () => { const { toolPanel } = await createDeferredPivotModeGrid(); const athlete = toolPanel.beans.colModel.getNonPivotCol('athlete') as AgColumn; @@ -1545,6 +1553,47 @@ describe('deferred column tool panel pivot mode', () => { expect(dragItem.pivotState.sport?.rowGroup).toBe(false); }); + test('getState().pivot through a deferred pivot-mode toggle off then back on', async () => { + const { gridApi, toolPanelGui } = await createDeferredPivotModeGrid(); + expect(gridApi.getState().pivot).toEqual({ pivotMode: true, pivotColIds: ['year'] }); + + getPivotModeToggle(toolPanelGui).click(); + getApplyButton(toolPanelGui).click(); + await waitForNoLoadingRows(gridApi); + expect(gridApi.isPivotMode()).toBe(false); + // Pivot off ⇒ no pivot state persisted; the pivot cols are remembered internally for re-enable. + expect(gridApi.getState().pivot).toBeUndefined(); + + getPivotModeToggle(toolPanelGui).click(); + getApplyButton(toolPanelGui).click(); + await waitForNoLoadingRows(gridApi); + expect(gridApi.isPivotMode()).toBe(true); + expect(gridApi.getState().pivot).toEqual({ pivotMode: true, pivotColIds: ['year'] }); + expect(gridApi.getPivotColumns().map((c) => c.getColId())).toEqual(['year']); + }); + + test('toggling pivot mode off preserves existing row-group, value, sort and visibility state', async () => { + const { gridApi, toolPanelGui } = await createDeferredPivotModeGrid(); + gridApi.applyColumnState({ state: [{ colId: 'athlete', sort: 'asc' }] }); + + // The removed setState round-trip used to re-apply all of this from the cache; confirm it survives + // the pivot toggle on its own. + expect(gridApi.getRowGroupColumns().map((c) => c.getColId())).toEqual(['country', 'sport']); + expect(gridApi.getValueColumns().map((c) => c.getColId())).toEqual(['silver', 'bronze']); + expect(gridApi.getColumn('athlete')!.getSort()).toBe('asc'); + expect(gridApi.getColumn('gold')!.isVisible()).toBe(false); + + getPivotModeToggle(toolPanelGui).click(); + getApplyButton(toolPanelGui).click(); + await waitForNoLoadingRows(gridApi); + + expect(gridApi.isPivotMode()).toBe(false); + expect(gridApi.getRowGroupColumns().map((c) => c.getColId())).toEqual(['country', 'sport']); + expect(gridApi.getValueColumns().map((c) => c.getColId())).toEqual(['silver', 'bronze']); + expect(gridApi.getColumn('athlete')!.getSort()).toBe('asc'); + expect(gridApi.getColumn('gold')!.isVisible()).toBe(false); + }); + test('turning pivot mode back on after disabling and applying restores the previous pivot columns', async () => { const { gridApi, toolPanelGui } = await createDeferredPivotModeGrid(); diff --git a/testing/behavioural/src/columnToolPanel/deferred-suppress-sync-layout.test.ts b/testing/behavioural/src/columnToolPanel/deferred-suppress-sync-layout.test.ts index 2ca474043dd..797e2edd61a 100644 --- a/testing/behavioural/src/columnToolPanel/deferred-suppress-sync-layout.test.ts +++ b/testing/behavioural/src/columnToolPanel/deferred-suppress-sync-layout.test.ts @@ -91,8 +91,7 @@ describe('deferred column tool panel with suppressSyncLayoutWithGrid', () => { } describe('column reordering', () => { - // Solved by AG-17366 when it is completed - test.skip('blocks column reordering in CTP when suppressSyncLayoutWithGrid is true in deferred mode', async () => { + test('blocks column reordering in CTP when suppressSyncLayoutWithGrid is true in deferred mode', async () => { const { toolPanel } = await createGrid({ suppressSyncLayoutWithGrid: true }); // No ToolPanel-type drag sources should be registered @@ -419,8 +418,7 @@ describe('deferred column tool panel with suppressSyncLayoutWithGrid', () => { .filter((ds: any) => ds.type === 0); // DragSourceType.ToolPanel = 0 } - // Solved by AG-17366 when it is completed - test.skip('no drag sources registered when suppressSyncLayoutWithGrid is true in deferred mode', async () => { + test('no drag sources registered when suppressSyncLayoutWithGrid is true in deferred mode', async () => { const { toolPanel } = await createGrid({ suppressSyncLayoutWithGrid: true }); const dragSources = getToolPanelDragSources(toolPanel); diff --git a/testing/behavioural/src/columns/cols-service-events.test.ts b/testing/behavioural/src/columns/cols-service-events.test.ts new file mode 100644 index 00000000000..15b7e4288fa --- /dev/null +++ b/testing/behavioural/src/columns/cols-service-events.test.ts @@ -0,0 +1,289 @@ +import type { Column, GridApi, GridOptions } from 'ag-grid-community'; +import { ClientSideRowModelModule } from 'ag-grid-community'; +import { AggregationModule, PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout } from '../test-utils'; + +/** + * Event coverage for every public entry point of the cols services (rowGroup / pivot / value). + * Locks the redesign's invariant: each logical mutation dispatches its grid `columnXChanged` event + * exactly once (a batch of changes collapses to one), with the changed columns as payload; and the + * per-column event fires immediately on the affected column. + */ +describe('Cols service events', () => { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, RowGroupingModule, PivotModule, AggregationModule], + }); + + afterEach(() => { + gridsManager.reset(); + }); + + const baseOptions = (): GridOptions => ({ + columnDefs: [ + { colId: 'a', field: 'a', enableRowGroup: true, enableValue: true, enablePivot: true }, + { colId: 'b', field: 'b', enableRowGroup: true, enableValue: true, enablePivot: true }, + { colId: 'c', field: 'c', enableRowGroup: true, enableValue: true, enablePivot: true }, + { colId: 'd', field: 'd' }, + ], + rowData: [{ a: 'x', b: 'p', c: 1, d: 2 }], + }); + + /** Subscribe to a grid event, returning the captured events array. */ + function capture(api: GridApi, eventName: any): any[] { + const events: any[] = []; + api.addEventListener(eventName, (e: any) => events.push(e)); + return events; + } + + const ids = (cols: Column[] | null | undefined): string[] => (cols ?? []).map((c) => c.getColId()); + + describe('rowGroup', () => { + test('setRowGroupColumns fires columnRowGroupChanged once with the new cols', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnRowGroupChanged'); + + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toEqual(['a', 'b']); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + }); + + test('setRowGroupColumns with the identical set fires no event (no spurious refresh/dispatch)', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + const events = capture(api, 'columnRowGroupChanged'); + + api.setRowGroupColumns(['a', 'b']); // identical membership + order → no-op + await asyncSetTimeout(0); + + expect(events.length).toBe(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + }); + + test('addRowGroupColumns then removeRowGroupColumns fire one event each', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnRowGroupChanged'); + + api.addRowGroupColumns(['a']); + await asyncSetTimeout(0); + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toContain('a'); + + api.removeRowGroupColumns(['a']); + await asyncSetTimeout(0); + expect(events.length).toBe(2); + expect(ids(events[1].columns)).toContain('a'); + expect(api.getRowGroupColumns().length).toBe(0); + }); + + test('moveRowGroupColumn fires columnRowGroupChanged once, reporting the moved column', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + const events = capture(api, 'columnRowGroupChanged'); + + api.moveRowGroupColumn(0, 1); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toEqual(['a']); + expect(events[0].column?.getColId()).toBe('a'); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['b', 'a']); + }); + + test('the per-column columnRowGroupChanged event fires immediately on the affected column', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const colA = api.getColumn('a')!; + const colEvents: any[] = []; + colA.addEventListener('columnRowGroupChanged', (e: any) => colEvents.push(e)); + + api.addRowGroupColumns(['a']); + + // Per-column events are immediate (not batched) — assert before any tick. + expect(colEvents.length).toBe(1); + }); + }); + + describe('value', () => { + test('setValueColumns fires columnValueChanged once', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnValueChanged'); + + api.setValueColumns(['a', 'b']); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toEqual(['a', 'b']); + expect(api.getValueColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + }); + + test('setColumnAggFunc activates a value col and fires columnValueChanged once', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnValueChanged'); + + api.setColumnAggFunc('c', 'sum'); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toEqual(['c']); + expect(api.getColumn('c')!.getAggFunc()).toBe('sum'); + }); + }); + + describe('pivot', () => { + test('setPivotColumns fires columnPivotChanged once', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnPivotChanged'); + + api.setPivotColumns(['a', 'b']); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns)).toEqual(['a', 'b']); + expect(api.getPivotColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + }); + }); + + describe('applyColumnState (batched diff dispatch)', () => { + test('a multi-col rowGroup change collapses to one columnRowGroupChanged', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const events = capture(api, 'columnRowGroupChanged'); + + api.applyColumnState({ + state: [ + { colId: 'a', rowGroupIndex: 0 }, + { colId: 'b', rowGroupIndex: 1 }, + ], + }); + await asyncSetTimeout(0); + + expect(events.length).toBe(1); + expect(ids(events[0].columns).sort()).toEqual(['a', 'b']); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + }); + + test('rowGroup + value + pivot changes in one applyColumnState fire one event per role', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const rowGroup = capture(api, 'columnRowGroupChanged'); + const value = capture(api, 'columnValueChanged'); + const pivot = capture(api, 'columnPivotChanged'); + + api.applyColumnState({ + state: [ + { colId: 'a', rowGroupIndex: 0 }, + { colId: 'b', aggFunc: 'sum' }, + { colId: 'c', pivotIndex: 0 }, + ], + }); + await asyncSetTimeout(0); + + expect(rowGroup.length).toBe(1); + expect(value.length).toBe(1); + expect(pivot.length).toBe(1); + expect(ids(rowGroup[0].columns)).toContain('a'); + expect(ids(value[0].columns)).toContain('b'); + expect(ids(pivot[0].columns)).toContain('c'); + }); + }); + + // Mutating columns from within a column-change listener re-enters the flush pipeline. The guarantee we + // verify is robustness: the change applies to state, with no hang / infinite loop / corruption. (Nested + // event *delivery* timing is governed by the async event service, so we don't assert event counts here.) + describe('re-entrancy (mutating inside a column-change listener)', () => { + test('adding a value column inside a columnRowGroupChanged listener applies cleanly', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + + let reentered = false; + api.addEventListener('columnRowGroupChanged', () => { + if (!reentered) { + reentered = true; + api.addValueColumns(['c']); // re-enter the pipeline mid-dispatch + } + }); + + api.addRowGroupColumns(['a']); + await asyncSetTimeout(0); + + expect(reentered).toBe(true); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['a']); + expect(api.getValueColumns().map((c) => c.getColId())).toEqual(['c']); + }); + + test('moveColumn inside a columnRowGroupChanged listener reorders cleanly', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + + let moved = false; + api.addEventListener('columnRowGroupChanged', () => { + if (!moved) { + moved = true; + api.moveRowGroupColumn(0, 1); // order-only (no-refresh) re-entrant change + } + }); + + api.addRowGroupColumns(['c']); + await asyncSetTimeout(0); + + expect(moved).toBe(true); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual(['b', 'a', 'c']); + }); + }); + + // Legacy `columnEverythingChanged` (unused by AG Grid, kept for external listeners): a direct role + // membership change raises it once; order-only/width changes don't; a colDef rebuild isn't doubled. + describe('columnEverythingChanged legacy event', () => { + test('a direct row-group membership change raises it exactly once per change (never doubled)', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const everything = capture(api, 'columnEverythingChanged'); + + api.addRowGroupColumns(['a']); + await asyncSetTimeout(0); + expect(everything.length).toBe(1); + + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + expect(everything.length).toBe(2); + }); + + test('order-only and width changes do not raise it', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + api.setRowGroupColumns(['a', 'b']); + await asyncSetTimeout(0); + const everything = capture(api, 'columnEverythingChanged'); + + api.moveColumns(['d'], 0); + api.moveRowGroupColumn(0, 1); + api.setColumnWidths([{ key: 'd', newWidth: 123 }]); + await asyncSetTimeout(0); + expect(everything.length).toBe(0); + }); + + test('a colDef rebuild raises it exactly once (a staged role flush must not double it)', async () => { + const api = gridsManager.createGrid('g', baseOptions()); + await asyncSetTimeout(0); + const everything = capture(api, 'columnEverythingChanged'); + + api.setGridOption('columnDefs', [ + { colId: 'a', field: 'a', rowGroup: true }, + { colId: 'b', field: 'b' }, + ]); + await asyncSetTimeout(0); + expect(everything.length).toBe(1); + }); + }); +}); diff --git a/testing/behavioural/src/columns/column-api-extended.test.ts b/testing/behavioural/src/columns/column-api-extended.test.ts index 45c1a08eecd..82a24ff729b 100644 --- a/testing/behavioural/src/columns/column-api-extended.test.ts +++ b/testing/behavioural/src/columns/column-api-extended.test.ts @@ -269,8 +269,7 @@ describe('Column API — extended coverage', () => { }); describe('setValueColumns', () => { - // Solved by AG-17366 when it is completed - test.skip('replaces the value-column set wholesale', async () => { + test('replaces the value-column set wholesale', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'gold' }, { field: 'silver' }, { field: 'bronze' }], }); @@ -383,8 +382,7 @@ describe('Column API — extended coverage', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('removeValueColumns removes the listed cols only', async () => { + test('removeValueColumns removes the listed cols only', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { field: 'country', rowGroup: true, hide: true }, @@ -434,28 +432,28 @@ describe('Column API — extended coverage', () => { expect(api.getValueColumns().map((c) => c.getColId())).toEqual(['silver']); }); - // Solved by AG-17366 when it is completed - test.skip('removeValueColumns clears runtime aggFunc; addValueColumns restores it from colDef', async () => { + test('removeValueColumns keeps the runtime aggFunc; addValueColumns restores the remembered one', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'gold', aggFunc: 'sum' }], rowData: [{ gold: 5 }], }); const gold = api.getColumn('gold')!; + api.setColumnAggFunc('gold', 'avg'); expect(gold.isValueActive()).toBe(true); - expect(gold.getAggFunc()).toBe('sum'); + expect(gold.getAggFunc()).toBe('avg'); + // Deactivating keeps the last aggFunc (legacy behaviour) so re-adding restores it. api.removeValueColumns(['gold']); expect(gold.isValueActive()).toBe(false); - expect(gold.getAggFunc()).toBeNull(); + expect(gold.getAggFunc()).toBe('avg'); api.addValueColumns(['gold']); expect(gold.isValueActive()).toBe(true); - expect(gold.getAggFunc()).toBe('sum'); + expect(gold.getAggFunc()).toBe('avg'); }); - // Solved by AG-17366 when it is completed - test.skip('applyColumnState({ aggFunc: null }) clears runtime aggFunc and deactivates', async () => { + test('applyColumnState({ aggFunc: null }) deactivates but keeps the runtime aggFunc', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'gold', aggFunc: 'sum' }], rowData: [{ gold: 5 }], @@ -467,14 +465,11 @@ describe('Column API — extended coverage', () => { api.applyColumnState({ state: [{ colId: 'gold', aggFunc: null }] }); expect(gold.isValueActive()).toBe(false); - expect(gold.getAggFunc()).toBeNull(); + expect(gold.getAggFunc()).toBe('sum'); }); - // `setColumnAggFunc` must maintain the same `isValueActive() ↔ getAggFunc() != null` - // invariant the validator now enforces — i.e. setting a non-null aggFunc activates, - // setting null deactivates. - // Solved by AG-17366 when it is completed - test.skip('setColumnAggFunc activates an inactive col when given a non-null aggFunc', async () => { + // Setting a non-null aggFunc activates the col; setting null deactivates it (keeping the last aggFunc). + test('setColumnAggFunc activates an inactive col when given a non-null aggFunc', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'gold' }], rowData: [{ gold: 5 }], @@ -490,8 +485,7 @@ describe('Column API — extended coverage', () => { expect(api.getValueColumns().map((c) => c.getColId())).toEqual(['gold']); }); - // Solved by AG-17366 when it is completed - test.skip('setColumnAggFunc(col, null) deactivates an active value col and clears aggFunc', async () => { + test('setColumnAggFunc(col, null) deactivates an active value col (keeping the last aggFunc)', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'gold', aggFunc: 'sum' }], rowData: [{ gold: 5 }], @@ -503,7 +497,7 @@ describe('Column API — extended coverage', () => { api.setColumnAggFunc('gold', null); expect(gold.isValueActive()).toBe(false); - expect(gold.getAggFunc()).toBeNull(); + expect(gold.getAggFunc()).toBe('sum'); expect(api.getValueColumns()).toEqual([]); }); @@ -673,8 +667,7 @@ describe('Column API — extended coverage', () => { }); describe('applyColumnState with aggFunc', () => { - // Solved by AG-17366 when it is completed - test.skip('non-string aggFunc in state is rejected with a warning (value col left unchanged)', async () => { + test('non-string aggFunc in state is rejected with a warning (value col left unchanged)', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { field: 'country', rowGroup: true, hide: true }, @@ -842,8 +835,7 @@ describe('Column API — extended coverage', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('toIndex past the end clamps to last slot; fromIndex out of range is a no-op', async () => { + test('toIndex past the end clamps to last slot; fromIndex out of range is a no-op', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { field: 'country', rowGroup: true }, @@ -910,8 +902,7 @@ describe('Column API — extended coverage', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('leftward move reports impacted columns', async () => { + test('leftward move reports the moved column', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { field: 'country', rowGroup: true }, @@ -919,30 +910,34 @@ describe('Column API — extended coverage', () => { { field: 'year', rowGroup: true }, ], }); - await new GridColumns(api, `leftward move reports impacted columns setup`).checkColumns(` + await new GridColumns(api, `leftward move reports the moved column setup`).checkColumns(` CENTER ├── ag-Grid-AutoColumn "Group" width:200 ├── country "Country" width:200 rowGroup ├── sport "Sport" width:200 rowGroup └── year "Year" width:200 rowGroup `); - await new GridRows(api, `leftward move reports impacted columns setup`).check(` + await new GridRows(api, `leftward move reports the moved column setup`).check(` ROOT id:ROOT_NODE_ID `); - let receivedImpacted: string[] | null = null; + let receivedColumns: string[] | null = null; + let receivedColumn: string | null = null; api.addEventListener('columnRowGroupChanged', (e) => { if (e.source === 'api') { - receivedImpacted = e.columns?.map((c: any) => c.getColId()) ?? null; + receivedColumns = e.columns?.map((c: any) => c.getColId()) ?? null; + receivedColumn = (e.column as any)?.getColId() ?? null; } }); - // Leftward move 2 → 0: every column in [0..2] shifted + // Leftward move 2 → 0 reports the moved column (matching `columnMoved`); previously a left move + // dispatched an empty payload. api.moveRowGroupColumn(2, 0); await asyncSetTimeout(0); - expect(receivedImpacted).toEqual(['country', 'sport', 'year']); - await new GridRows(api, `leftward move reports impacted columns final state`).check(` + expect(receivedColumns).toEqual(['year']); + expect(receivedColumn).toBe('year'); + await new GridRows(api, `leftward move reports the moved column final state`).check(` ROOT id:ROOT_NODE_ID `); }); @@ -1188,8 +1183,7 @@ describe('Column API — extended coverage', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('looks up by ColDef ref (object identity)', async () => { + test('looks up by ColDef ref (object identity)', async () => { const def: ColDef = { colId: 'a' }; const api = gridsManager.createGrid('myGrid', { columnDefs: [def], diff --git a/testing/behavioural/src/columns/column-api.test.ts b/testing/behavioural/src/columns/column-api.test.ts index 43978a20507..f33441ebc3d 100644 --- a/testing/behavioural/src/columns/column-api.test.ts +++ b/testing/behavioural/src/columns/column-api.test.ts @@ -1,18 +1,3 @@ -/** - * Comprehensive tests for the column API methods to ensure they remain consistent - * after internal ColumnModel refactoring (AG-17060-get-col-perf). - * - * Tests cover: - * - getAllGridColumns / getAllDisplayedColumns / getDisplayedLeft/Center/RightColumns - * - getColumn / getColumns - * - getColumnDefs (sorted and unsorted) - * - Column state API (getColumnState, applyColumnState, resetColumnState) - * - Column group state API (getColumnGroupState, setColumnGroupState) - * - Pivot columns API (isPivotMode, getPivotColumns, getValueColumns, getRowGroupColumns) - * - Column visibility and pinning API - * - Column moving API - * - Auto-generated columns (selection, auto-group, row numbers) - */ import type { ColDef, Column, ColumnState } from 'ag-grid-community'; import { ClientSideRowModelModule } from 'ag-grid-community'; import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; @@ -492,6 +477,39 @@ describe('Column API', () => { ROOT id:ROOT_NODE_ID `); }); + + test('reports rowGroupIndex/pivotIndex in active order, tracking reorders', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ colId: 'a' }, { colId: 'b' }, { colId: 'c' }], + }); + + const indexById = (prop: 'rowGroupIndex' | 'pivotIndex') => + Object.fromEntries(api.getColumnDefs()!.map((d) => [(d as ColDef).colId, (d as ColDef)[prop]])); + + // Activate row groups in a non-colDef order: c=0, a=1, b=2. + api.applyColumnState({ + state: [ + { colId: 'c', rowGroupIndex: 0 }, + { colId: 'a', rowGroupIndex: 1 }, + { colId: 'b', rowGroupIndex: 2 }, + ], + }); + expect(indexById('rowGroupIndex')).toEqual({ a: 1, b: 2, c: 0 }); + + // Reorder the active group columns and confirm getColumnDefs tracks the restamp. + api.moveRowGroupColumn(0, 2); // c (level 0) moves to the end → a=0, b=1, c=2 + expect(indexById('rowGroupIndex')).toEqual({ a: 0, b: 1, c: 2 }); + + // Same for pivot, also non-colDef order. + api.applyColumnState({ + state: [ + { colId: 'a', rowGroup: false, pivotIndex: 1 }, + { colId: 'b', rowGroup: false, pivotIndex: 0 }, + { colId: 'c', rowGroup: false }, + ], + }); + expect(indexById('pivotIndex')).toEqual({ a: 1, b: 0, c: null }); + }); }); describe('getColumnState and applyColumnState', () => { @@ -830,6 +848,41 @@ describe('Column API', () => { └── b width:200 `); }); + + // A primary column group is parked (not displayed) while pivoting. `getProvidedColumnGroup` + // returns the definition and must still resolve it by id (mirroring `getColumn` for parked + // primary columns); `getColumnGroup` returns the displayed instance, which is gone while pivoting. + test('getProvidedColumnGroup resolves a parked primary group while pivoting', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { groupId: 'locationGroup', children: [{ field: 'country' }, { field: 'athlete' }] }, + { field: 'sport', pivot: true }, + { field: 'gold', aggFunc: 'sum' }, + ], + rowData: [ + { country: 'USA', athlete: 'Phelps', sport: 'Swimming', gold: 2 }, + { country: 'Russia', athlete: 'Ivanov', sport: 'Gymnastics', gold: 3 }, + ], + }); + await asyncSetTimeout(0); + + // Normal mode: both the provided definition and the displayed instance resolve. + expect(api.getProvidedColumnGroup('locationGroup')?.getGroupId()).toBe('locationGroup'); + expect(api.getColumnGroup('locationGroup')).not.toBeNull(); + + api.setGridOption('pivotMode', true); + await asyncSetTimeout(0); + // Pivot is active (result columns generated), so the primary group is parked. + expect((api.getPivotResultColumns() ?? []).length).toBeGreaterThan(0); + + // Provided group still resolves via the parked-primary fallback; displayed instance is gone. + expect(api.getProvidedColumnGroup('locationGroup')?.getGroupId()).toBe('locationGroup'); + expect(api.getColumnGroup('locationGroup')).toBeNull(); + + // Unknown ids are null in both APIs. + expect(api.getProvidedColumnGroup('does-not-exist')).toBeNull(); + expect(api.getColumnGroup('does-not-exist')).toBeNull(); + }); }); describe('column group state API', () => { @@ -1315,6 +1368,42 @@ describe('Column API', () => { `); }); + test('getColumnDefs reflects a primary-column move made before entering pivot mode', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { colId: 'country', rowGroup: true }, + { colId: 'sport', pivot: true }, + { colId: 'gold', aggFunc: 'sum' }, + { colId: 'a' }, + { colId: 'b' }, + ], + rowData: [ + { country: 'USA', sport: 'Swimming', gold: 3 }, + { country: 'UK', sport: 'Running', gold: 2 }, + ], + }); + + api.moveColumns(['b'], 0); + + api.setGridOption('pivotMode', true); + expect(api.getPivotResultColumns()?.length).toBeGreaterThan(0); // primaries now parked + + const inPivot = api.getColumnDefs()!.map((d) => (d as ColDef).colId); + expect(inPivot).toEqual(['b', 'country', 'sport', 'gold', 'a']); + + api.setGridOption('pivotMode', false); + const afterPivot = api.getColumnDefs()!.map((d) => (d as ColDef).colId); + expect(afterPivot).toEqual(['b', 'country', 'sport', 'gold', 'a']); + expect(api.getAllDisplayedColumns().map((c: Column) => c.getColId())).toEqual([ + 'b', + 'ag-Grid-AutoColumn', + 'country', + 'sport', + 'gold', + 'a', + ]); + }); + test('adding columns to existing grid maintains prior column order', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'a' }, { colId: 'b' }, { colId: 'c' }], diff --git a/testing/behavioural/src/columns/column-autosize.test.ts b/testing/behavioural/src/columns/column-autosize.test.ts index 2e98a608077..0399095787c 100644 --- a/testing/behavioural/src/columns/column-autosize.test.ts +++ b/testing/behavioural/src/columns/column-autosize.test.ts @@ -597,8 +597,7 @@ describe('Column Autosize', () => { expect(api.getColumn('b')!.getActualWidth()).toBe(175); }); - // Solved by AG-17366 when it is completed - test.skip('selection col (colKind="selection") is excluded', async () => { + test('selection col (colKind="selection") is excluded', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { colId: 'a', width: 200, minWidth: 80 }, @@ -633,8 +632,7 @@ describe('Column Autosize', () => { expect(selectionCol!.getActualWidth()).toBe(selectionBefore); }); - // Solved by AG-17366 when it is completed - test.skip('row-number col (colKind="row-number") is excluded', async () => { + test('row-number col (colKind="row-number") is excluded', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'a', width: 200, minWidth: 80 }], rowNumbers: true, @@ -701,6 +699,53 @@ describe('Column Autosize', () => { expect(api.getColumn('a')!.getActualWidth()).toBe(80); expect(api.getColumn('b')!.getActualWidth()).toBe(80); }); + + // Autosize refreshes visible cols with skipTreeBuild=true (widths don't change liveCols). This + // exercises the multi-section + grouped (colsTreeDepth > 0) reuse path: group trees and the + // left/centre/right partition must survive the width-only refresh unchanged. + test('grouped + pinned columns keep their tree and sections after autosize', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { + headerName: 'Pinned', + children: [ + { colId: 'p1', pinned: 'left', width: 200, minWidth: 70 }, + { colId: 'p2', pinned: 'left', width: 200, minWidth: 90 }, + ], + }, + { + headerName: 'Body', + children: [ + { colId: 'c1', width: 200, minWidth: 110 }, + { colId: 'c2', width: 200, minWidth: 130 }, + ], + }, + ], + }); + await new GridColumns(api, `grouped + pinned setup`).checkColumns(` + LEFT + └─┬ "Pinned" GROUP + ├── p1 width:200 + └── p2 width:200 + CENTER + └─┬ "Body" GROUP + ├── c1 width:200 + └── c2 width:200 + `); + + api.autoSizeAllColumns(); + await new GridColumns(api, `grouped + pinned after autosize`).checkColumns(` + LEFT + └─┬ "Pinned" GROUP + ├── p1 width:70 + └── p2 width:90 + CENTER + └─┬ "Body" GROUP + ├── c1 width:110 + └── c2 width:130 + `); + await asyncSetTimeout(0); + }); }); describe('autoSizeAllColumns', () => { @@ -881,8 +926,7 @@ describe('Column Autosize', () => { expect(hiddenLeft.getActualWidth()).toBe(123); }); - // Solved by AG-17366 when it is completed - test.skip('row-number col is excluded from both passes', async () => { + test('row-number col is excluded from both passes', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'a', width: 200, minWidth: 80 }], rowNumbers: true, diff --git a/testing/behavioural/src/columns/column-destruction.test.ts b/testing/behavioural/src/columns/column-destruction.test.ts index 559fe23935c..e8487004ec1 100644 --- a/testing/behavioural/src/columns/column-destruction.test.ts +++ b/testing/behavioural/src/columns/column-destruction.test.ts @@ -1,13 +1,3 @@ -/** - * Tests that column beans are destroyed exactly once when the grid is torn down, - * and that intermediate rebuilds (pivot toggles, columnDefs replacement) don't leak. - * - * Design B ownership: ColumnModel.destroy() is the single owner of all column beans - * at teardown — it walks colsTree once and destroys everything (leaves, source-tree - * groups, and balanceTreeForAutoCols wrappers). Leaf services (auto/sel/rn/pivot) - * still own mid-life destruction in their createColumns paths, but defer teardown - * destruction to ColumnModel to prevent double-destroy. - */ import type { Column, GridApi } from 'ag-grid-community'; import { ClientSideRowModelModule, RowSelectionModule } from 'ag-grid-community'; import { PivotModule, RowGroupingModule, RowNumbersModule, TreeDataModule } from 'ag-grid-enterprise'; @@ -509,6 +499,42 @@ describe('Column destruction', () => { } expect(seen.filter((g) => g.isAlive()).length).toBe(1); }); + + test('hierarchy columns are destroyed when rebuilt or removed', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ field: 'country' }, { field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }], + rowData: [{ country: 'USA', date: new Date(2020, 0, 1) }], + }); + await asyncSetTimeout(0); + + const yearBefore = api.getColumn('ag-Grid-HierarchyColumn-date-year')!; + const monthBefore = api.getColumn('ag-Grid-HierarchyColumn-date-month')!; + expect(yearBefore).not.toBeNull(); + expect(monthBefore).not.toBeNull(); + expect((yearBefore as any).isAlive()).toBe(true); + expect((monthBefore as any).isAlive()).toBe(true); + + // (1) rebuildColumns: dropping 'month' changes the plan length → all hierarchy cols rebuilt. + api.setGridOption('columnDefs', [ + { field: 'country' }, + { field: 'date', rowGroup: true, groupHierarchy: ['year'] }, + ]); + await asyncSetTimeout(0); + + const yearAfter = api.getColumn('ag-Grid-HierarchyColumn-date-year')!; + expect(api.getColumn('ag-Grid-HierarchyColumn-date-month')).toBeNull(); + expect(yearAfter).not.toBe(yearBefore); + expect((yearBefore as any).isAlive()).toBe(false); + expect((monthBefore as any).isAlive()).toBe(false); + expect((yearAfter as any).isAlive()).toBe(true); + + // (2) clearColumns: removing groupHierarchy entirely drops the remaining hierarchy col. + api.setGridOption('columnDefs', [{ field: 'country' }, { field: 'date', rowGroup: true }]); + await asyncSetTimeout(0); + + expect(api.getColumn('ag-Grid-HierarchyColumn-date-year')).toBeNull(); + expect((yearAfter as any).isAlive()).toBe(false); + }); }); /** Walks `col.originalParent` upwards and returns the wrapper chain (excludes the leaf col). @@ -523,8 +549,7 @@ const wrapperChainOf = (col: Column): any[] => { return chain; }; -// Solved by AG-17366 when it is completed -describe.skip('ColWrapperCache lifecycle', () => { +describe('ColWrapperCache lifecycle', () => { const gridsManager = new TestGridsManager({ modules: [ ClientSideRowModelModule, diff --git a/testing/behavioural/src/columns/column-edge-cases.test.ts b/testing/behavioural/src/columns/column-edge-cases.test.ts index ce14cd32a17..8a7564b2a88 100644 --- a/testing/behavioural/src/columns/column-edge-cases.test.ts +++ b/testing/behavioural/src/columns/column-edge-cases.test.ts @@ -555,8 +555,7 @@ describe('Column Edge Cases', () => { api.removeEventListener('sortChanged', listener); }); - // Solved by AG-17366 when it is completed - test.skip('applyColumnState dispatches all 5 change events in one call when every category changes', async () => { + test('applyColumnState dispatches all 5 change events in one call when every category changes', async () => { // Each col mutates a single category so the test can attribute events to specific cols. const api = gridsManager.createGrid('myGrid', { columnDefs: [ @@ -1512,8 +1511,7 @@ describe('Column Edge Cases', () => { await new GridColumns(api, 'all hidden incl selection').checkColumns('empty'); }); - // Solved by AG-17366 when it is completed - test.skip('selection column auto-hide keeps tree and flat representations consistent', async () => { + test('selection column auto-hide keeps tree and flat representations consistent', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'a', hide: true }], rowData: [{ a: 1 }], @@ -1934,8 +1932,7 @@ describe('Column Edge Cases', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('column with empty string colId', async () => { + test('column with empty string colId', async () => { const warnSpy = vitest.spyOn(console, 'warn').mockImplementation(() => {}); const api = gridsManager.createGrid('myGrid', { diff --git a/testing/behavioural/src/columns/column-groups.test.ts b/testing/behavioural/src/columns/column-groups.test.ts index 5f10d3d4f9f..5c8a01683d6 100644 --- a/testing/behavioural/src/columns/column-groups.test.ts +++ b/testing/behavioural/src/columns/column-groups.test.ts @@ -12,6 +12,112 @@ describe('Column Groups', () => { gridsManager.reset(); }); + describe('empty groups stay findable (matches released behaviour)', () => { + test('a group declared with no children remains discoverable via the group APIs', async () => { + const api = gridsManager.createGrid('empty-declared', { + columnDefs: [{ field: 'a' }, { headerName: 'Empty', groupId: 'emptyDeclared', children: [] }] as ( + | ColDef + | ColGroupDef + )[], + rowData: [{ a: 1 }], + }); + await asyncSetTimeout(1); + + // An explicitly declared (even empty) group is not silently dropped: it stays findable. + const group = api.getProvidedColumnGroup('emptyDeclared') as unknown as { + isAlive(): boolean; + children: unknown[]; + } | null; + expect(group === null).toBe(false); + expect(group!.isAlive()).toBe(true); + expect(group!.children.length).toBe(0); + expect(api.getColumnGroupState().some((s) => s.groupId === 'emptyDeclared')).toBe(true); + await new GridColumns(api, 'declared empty group kept').checkColumns(` + CENTER + └── a "A" width:200 + `); + }); + + test('a group emptied via setColumnDefs stays findable (now empty)', async () => { + const api = gridsManager.createGrid('empty-runtime', { + columnDefs: [{ field: 'a' }, { headerName: 'G', groupId: 'g2', children: [{ field: 'b' }] }] as ( + | ColDef + | ColGroupDef + )[], + rowData: [{ a: 1, b: 2 }], + }); + await asyncSetTimeout(1); + expect(api.getProvidedColumnGroup('g2') === null).toBe(false); + + api.setGridOption('columnDefs', [{ field: 'a' }, { headerName: 'G', groupId: 'g2', children: [] }] as ( + | ColDef + | ColGroupDef + )[]); + await asyncSetTimeout(1); + + const group = api.getProvidedColumnGroup('g2') as unknown as { children: unknown[] } | null; + expect(group === null).toBe(false); + expect(group!.children.length).toBe(0); + expect(api.getColumnGroupState().some((s) => s.groupId === 'g2')).toBe(true); + await new GridColumns(api, 'group emptied via setColumnDefs kept').checkColumns(` + CENTER + └── a "A" width:200 + `); + }); + + test('getColumnGroupState surfaces synthetic padding groups (matches latest)', async () => { + const api = gridsManager.createGrid('group-state-padding', { + columnDefs: [ + { groupId: 'G', headerName: 'G', children: [{ field: 'a' }, { field: 'b' }] }, + { field: 'c' }, + ] as (ColDef | ColGroupDef)[], + rowData: [{ a: 1, b: 2, c: 3 }], + }); + await asyncSetTimeout(1); + + const state = api.getColumnGroupState(); + expect(state.some((s) => s.groupId === 'G')).toBe(true); + expect(state.some((s) => s.groupId !== 'G')).toBe(true); // the synthetic padding group for `c` + expect(state.length).toBe(2); + }); + }); + + describe('group expand state survives a rebuild', () => { + // Generated-id groups are recreated on rebuild (can't be reused), so the build must carry their expand state over. + test('a generated-id group stays expanded across a columnDefs rebuild (no calc cols)', async () => { + const makeDefs = (): (ColDef | ColGroupDef)[] => [ + { headerName: 'G', children: [{ field: 'a' }, { field: 'b', columnGroupShow: 'open' }] }, + { field: 'c' }, + ]; + const api = gridsManager.createGrid('gen-id-expand', { + columnDefs: makeDefs(), + rowData: [{ a: 1, b: 2, c: 3 }], + }); + await asyncSetTimeout(1); + + // Expand the (generated-id) expandable group. + const initial = api.getColumnGroupState(); + api.setColumnGroupState(initial.map((s) => ({ groupId: s.groupId, open: true }))); + const openedIds = api + .getColumnGroupState() + .filter((s) => s.open) + .map((s) => s.groupId); + expect(openedIds.length).toBeGreaterThan(0); + + // Rebuild from fresh (structurally identical) colDefs — recreates the generated-id group. + api.setGridOption('columnDefs', makeDefs()); + await asyncSetTimeout(1); + + // Its expand state must survive: the rebuild must not collapse it. + expect( + api + .getColumnGroupState() + .filter((s) => s.open) + .map((s) => s.groupId) + ).toEqual(openedIds); + }); + }); + describe('single-level column groups', () => { test('group with two children', async () => { const columnDefs: (ColDef | ColGroupDef)[] = [ @@ -1079,8 +1185,7 @@ describe('Column Groups', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('descendant-only change dispatches displayedChildrenChanged on ancestor (cascade)', async () => { + test('descendant-only change dispatches displayedChildrenChanged on ancestor (cascade)', async () => { const columnDefs: (ColDef | ColGroupDef)[] = [ { headerName: 'Outer', @@ -1175,9 +1280,7 @@ describe('Column Groups', () => { expect(api.getColumn('a') === colA).toBe(true); }); - // Solved by AG-17366 when it is completed: on `latest` the displayed column-group instance - // is recreated on every refresh; the rewrite's wrapper cache reuses it across a no-op refresh. - test.skip('re-setting identical columnDefs keeps the column group instance stable', async () => { + test('re-setting identical columnDefs keeps the column group instance stable', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { headerName: 'G1', groupId: 'g1', children: [{ colId: 'a' }, { colId: 'b' }] }, @@ -1357,6 +1460,25 @@ describe('Column Groups', () => { ROOT id:ROOT_NODE_ID `); }); + + test('without a partId resolves to the primary (first) display instance of a cross-section group', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { + groupId: 'g', + children: [{ colId: 'l', pinned: 'left' }, { colId: 'c' }, { colId: 'r', pinned: 'right' }], + }, + ], + }); + await asyncSetTimeout(1); + + // No partId resolves to the documented primary instance (partId 0 = the first/left section), + // not an arbitrary section, so the lookup is deterministic for multi-instance groups. + const primary = api.getColumnGroup('g'); + expect(primary).not.toBeNull(); + expect(primary).toBe(api.getColumnGroup('g', 0)); + expect(primary!.getLeafColumns().map((col) => col.getColId())).toEqual(['l']); + }); }); // Coverage for ColumnGroupService.resetColumnGroupState — resets every group to its @@ -1460,7 +1582,7 @@ describe('Column Groups', () => { }); describe('AgProvidedColumnGroup getters + leaf walk', () => { - test('getId / getInstanceId / getChildren return live values; getLeafColumns handles empty + null children', async () => { + test('getId / getInstanceId / getChildren return live values; getLeafColumns returns leaf columns', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { @@ -1491,19 +1613,8 @@ describe('Column Groups', () => { expect(typeof provided.getInstanceId()).toBe('number'); expect(provided.getChildren().map((c: any) => c.getColId?.() ?? c.getGroupId?.())).toEqual(['a', 'b']); - // Empty children — leaf walk yields nothing - (provided as any).setChildren([]); - expect(provided.getLeafColumns()).toEqual([]); - - // Null children — exercises the `!this.children` early return in `addLeafColumns` - (provided as any).children = null; - expect(provided.getLeafColumns()).toEqual([]); - await new GridRows( - api, - `getId / getInstanceId / getChildren return live values; getLeafColumns handles e final state` - ).check(` - ROOT id:ROOT_NODE_ID - `); + // getLeafColumns walks the tree to its leaf columns (public API; no internal field access) + expect(provided.getLeafColumns().map((c) => c.getColId())).toEqual(['a', 'b']); }); }); @@ -1757,8 +1868,7 @@ describe('Column Groups', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('group split across pinned sections has dense partId-indexed instances', async () => { + test('group split across pinned sections has dense partId-indexed instances', async () => { const api = gridsManager.createGrid('splitGroup', { columnDefs: [ { diff --git a/testing/behavioural/src/columns/column-lookup.test.ts b/testing/behavioural/src/columns/column-lookup.test.ts index d1e2c2c9924..b263717eded 100644 --- a/testing/behavioural/src/columns/column-lookup.test.ts +++ b/testing/behavioural/src/columns/column-lookup.test.ts @@ -129,8 +129,7 @@ describe('Column lookup', () => { expect(resolved!.getColId()).toBe('alpha'); }); - // Solved by AG-17366 when it is completed - test.skip('resolves a column by ColDef reference when colDef has no explicit colId (field fast-path)', async () => { + test('resolves a column by ColDef reference when colDef has no explicit colId (field fast-path)', async () => { const nameCol: ColDef = { field: 'name' }; const api = gridsManager.createGrid('myGrid', { columnDefs: [nameCol, { field: 'age' }], @@ -161,8 +160,7 @@ describe('Column lookup', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('resolves a column by ColDef reference when colDef has no colId or field (reference scan)', async () => { + test('resolves a column by ColDef reference when colDef has no colId or field (reference scan)', async () => { const anonCol: ColDef = { headerName: 'Anonymous' }; const api = gridsManager.createGrid('myGrid', { columnDefs: [{ field: 'name' }, anonCol], @@ -215,8 +213,7 @@ describe('Column lookup', () => { ); }); - // Solved by AG-17366 when it is completed - test.skip('resolves a column by string key matching a `field` when the field differs from colId', async () => { + test('resolves a column by string key matching a `field` when the field differs from colId', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { colId: 'X', field: 'name' }, @@ -278,8 +275,7 @@ describe('Column lookup', () => { ); }); - // Solved by AG-17366 when it is completed - test.skip('resolves a column by fresh ColDef object with only a field (no shared ref)', async () => { + test('resolves a column by fresh ColDef object with only a field (no shared ref)', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'X', field: 'name' }, { field: 'age' }], }); @@ -310,8 +306,7 @@ describe('Column lookup', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('resolves correct column when two ColDefs share the same field', async () => { + test('resolves correct column when two ColDefs share the same field', async () => { const firstCol: ColDef = { field: 'value', headerName: 'First' }; const secondCol: ColDef = { field: 'value', headerName: 'Second' }; const api = gridsManager.createGrid('myGrid', { @@ -342,8 +337,7 @@ describe('Column lookup', () => { ); }); - // Solved by AG-17366 when it is completed - test.skip('string field lookup with two cols sharing field: first registered wins', async () => { + test('string field lookup with two cols sharing field: first registered wins', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { colId: 'X', field: 'shared' }, @@ -374,8 +368,7 @@ describe('Column lookup', () => { }); describe('getColDefCol — ColDef without colId', () => { - // Solved by AG-17366 when it is completed - test.skip('setColumnsPinned resolves column by ColDef reference when colDef has no colId', async () => { + test('setColumnsPinned resolves column by ColDef reference when colDef has no colId', async () => { const nameCol: ColDef = { field: 'name' }; const api = gridsManager.createGrid('myGrid', { columnDefs: [nameCol, { field: 'age' }], @@ -572,6 +565,47 @@ describe('Column lookup', () => { }); }); + describe('numeric colId ordering', () => { + test('change-dispatch reports columns in display order with numeric-like colIds (not Object.values key order)', async () => { + const columnDefs: ColDef[] = [ + { colId: 'z', field: 'z' }, + { colId: '3', field: 'c3' }, + { colId: '1', field: 'c1' }, + { colId: '10', field: 'c10' }, + { colId: '2', field: 'c2' }, + ]; + const api = gridsManager.createGrid('numericOrder', { + columnDefs, + rowData: [{ z: 'a', c3: 'b', c1: 'c', c10: 'd', c2: 'e' }], + }); + await new GridColumns(api, 'numeric colId display order').checkColumns(` + CENTER + ├── z "Z" width:200 + ├── 3 "C3" width:200 + ├── 1 "C1" width:200 + ├── 10 "C10" width:200 + └── 2 "C2" width:200 + `); + await new GridRows(api, 'numeric colId display order').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 z:"a" 3:"b" 1:"c" 10:"d" 2:"e" + `); + + const resizedEvents: string[][] = []; + api.addEventListener('columnResized', (e) => { + resizedEvents.push(((e.columns ?? []) as Column[]).map((c) => c.getColId())); + }); + + api.applyColumnState({ state: columnDefs.map((cd) => ({ colId: cd.colId!, width: 333 })) }); + await asyncSetTimeout(10); + + expect(resizedEvents.length).toBeGreaterThan(0); + const lastEvent = resizedEvents[resizedEvents.length - 1]; + // Display order is preserved. The old `Object.values` bug would yield ['1','2','3','10','z']. + expect(lastEvent).toEqual(['z', '3', '1', '10', '2']); + }); + }); + // `getColumnState()` iterates every col known to the grid via the internal `getAllCols()`. // Asserting no duplicate entries catches silent over-inclusion of service / hierarchy cols. describe('getColumnState() — every col appears exactly once', () => { @@ -630,6 +664,46 @@ describe('Column lookup', () => { `); }); + test('pivot mode — parked primaries + pivot result + service cols appear exactly once', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { colId: 'country', field: 'country', rowGroup: true }, + { colId: 'sport', field: 'sport', pivot: true }, + { colId: 'gold', field: 'gold', aggFunc: 'sum' }, + { colId: 'silver', field: 'silver' }, + ], + pivotMode: true, + rowNumbers: true, + rowData: [ + { country: 'A', sport: 'x', gold: 1, silver: 2 }, + { country: 'B', sport: 'y', gold: 3, silver: 4 }, + ], + }); + await asyncSetTimeout(0); + await new GridColumns(api, 'pivot parked-primaries dedup').checkColumns(` + LEFT + └── ag-Grid-RowNumbersColumn width:60 !resizable !sortable suppressMovable lockPosition:left + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├─┬ "x" GROUP + │ └── pivot_sport_x_gold "Gold" width:200 columnGroupShow:open + └─┬ "y" GROUP + └── pivot_sport_y_gold "Gold" width:200 columnGroupShow:open + `); + await new GridRows(api, 'pivot parked-primaries dedup').check(` + ROOT id:ROOT_NODE_ID pivot_sport_x_gold:1 pivot_sport_y_gold:3 + ├─┬ LEAF_GROUP collapsed id:row-group-country-A row-number:"1" ag-Grid-AutoColumn:"A" pivot_sport_x_gold:1 pivot_sport_y_gold:null + │ └── LEAF hidden id:0 row-number:"1" pivot_sport_x_gold:1 pivot_sport_y_gold:1 + └─┬ LEAF_GROUP collapsed id:row-group-country-B row-number:"2" ag-Grid-AutoColumn:"B" pivot_sport_x_gold:null pivot_sport_y_gold:3 + · └── LEAF hidden id:1 row-number:"1" pivot_sport_x_gold:3 pivot_sport_y_gold:3 + `); + + const ids = api.getColumnState().map((s) => s.colId!); + expect(hasNoDuplicates(ids)).toBe(true); + // Parked primaries (country/sport/gold/silver) must still be present alongside the pivot/service cols. + expect(ids).toEqual(expect.arrayContaining(['country', 'sport', 'gold', 'silver'])); + }); + test('with row selection — selection col appears exactly once', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ colId: 'a' }, { colId: 'b' }], @@ -1068,6 +1142,45 @@ describe('Column lookup', () => { `); }); + test('reused groups re-point originalParent: leaf moved between same-id groups exports under new parent', async () => { + // Groups are reused across builds (same groupId). buildColumnTree re-sets group.originalParent + // and group.children every build, so getColumnDefs must reflect the NEW structure, not a stale + // reused-parent link. Here leaf2 moves from groupB to groupA while both groups are reused. + const api = gridsManager.createGrid('reuseRestructure', { + columnDefs: [ + { headerName: 'A', groupId: 'groupA', children: [{ colId: 'leaf1' }] }, + { headerName: 'B', groupId: 'groupB', children: [{ colId: 'leaf2' }] }, + ], + }); + await new GridColumns(api, 'reuse restructure: before').checkColumns(` + CENTER + ├─┬ "A" GROUP + │ └── leaf1 width:200 + └─┬ "B" GROUP + └── leaf2 width:200 + `); + + // Move leaf2 into groupA; both groupA and groupB are reused (ids unchanged). + api.setGridOption('columnDefs', [ + { headerName: 'A', groupId: 'groupA', children: [{ colId: 'leaf1' }, { colId: 'leaf2' }] }, + { headerName: 'B', groupId: 'groupB', children: [{ colId: 'leaf3' }] }, + ]); + await new GridColumns(api, 'reuse restructure: leaf2 moved to groupA').checkColumns(` + CENTER + ├─┬ "A" GROUP + │ ├── leaf1 width:200 + │ └── leaf2 width:200 + └─┬ "B" GROUP + └── leaf3 width:200 + `); + + const defs = api.getColumnDefs()!; + const groupA = defs.find((d) => (d as ColGroupDef).groupId === 'groupA') as ColGroupDef; + const groupB = defs.find((d) => (d as ColGroupDef).groupId === 'groupB') as ColGroupDef; + expect(groupA.children.map((c) => (c as ColDef).colId)).toEqual(['leaf1', 'leaf2']); + expect(groupB.children.map((c) => (c as ColDef).colId)).toEqual(['leaf3']); + }); + test('padding groups (from depth balancing) are skipped in exported defs', async () => { // The flat 'a' leaf has no parent group; the 'deep' leaf is nested in 'L1'. // balanceColumnTree pads 'a' with synthetic groups so the displayed tree balances, @@ -1322,8 +1435,7 @@ describe('Column lookup', () => { return new Set(ids).size === ids.length; } - // Solved by AG-17366 when it is completed - test.skip('groupHierarchy virtuals appear exactly once in getColumnState()', async () => { + test('groupHierarchy virtuals appear exactly once in getColumnState()', async () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [ { field: 'country' }, diff --git a/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts b/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts new file mode 100644 index 00000000000..79d9620d2e3 --- /dev/null +++ b/testing/behavioural/src/columns/column-model-rewrite-p2.test.ts @@ -0,0 +1,213 @@ +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { ClientSideRowModelModule } from 'ag-grid-community'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout } from '../test-utils'; + +/** + * Regression coverage for edge cases surfaced while reviewing the column-model rewrite (AG-17366). + * Each block locks one invariant that a future change could quietly break. + */ +describe('column-model rewrite edge cases', () => { + const pivotGridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, RowGroupingModule, PivotModule], + }); + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule], + }); + + afterEach(() => { + pivotGridsManager.reset(); + gridsManager.reset(); + }); + + const defColIds = (defs: (ColDef | any)[] | undefined): string[] => (defs ?? []).map((d) => d.colId ?? d.field); + const liveColIds = (api: GridApi): string[] => (api.getColumns() ?? []).map((c) => c.getColId()); + + // P2-3: a primary column added while pivoting must keep its colDef position in getColumnDefs, + // not jump to the front because its colsListIndex was never stamped (defaulted to 0). + test('getColumnDefs reports a column added during pivot in colDef order', async () => { + const options: GridOptions = { + columnDefs: [ + { colId: 'a', field: 'a' }, + { colId: 'b', field: 'b', enablePivot: true, pivot: true }, + { colId: 'c', field: 'c', enableValue: true, aggFunc: 'sum' }, + ], + pivotMode: true, + rowData: [{ a: 'x', b: 'y', c: 1 }], + }; + const api = pivotGridsManager.createGrid('g', options); + await asyncSetTimeout(0); + + // Insert 'd' between 'b' and 'c' while still in pivot mode. + api.setGridOption('columnDefs', [ + { colId: 'a', field: 'a' }, + { colId: 'b', field: 'b', enablePivot: true, pivot: true }, + { colId: 'd', field: 'd' }, + { colId: 'c', field: 'c', enableValue: true, aggFunc: 'sum' }, + ]); + await asyncSetTimeout(0); + + expect(defColIds(api.getColumnDefs())).toEqual(['a', 'b', 'd', 'c']); + }); + + // A pivot build must not reuse a user (primary) column whose colId collides with a generated pivot + // colId — reuse is scoped to the same build kind (primary vs pivot result), not just colKind 'user'. + test('pivot build does not reuse a user column that shares a generated pivot colId', async () => { + const api = pivotGridsManager.createGrid('g', { + columnDefs: [ + { field: 'country', rowGroup: true }, + { field: 'b', pivot: true }, + { field: 'total', aggFunc: 'sum' }, + // colId deliberately collides with the generated pivot result colId for b='x'. + { colId: 'pivot_b_x_total', field: 'extra' }, + ], + rowData: [ + { country: 'US', b: 'x', total: 5, extra: 'E' }, + { country: 'US', b: 'y', total: 3, extra: 'F' }, + ], + pivotMode: true, + }); + await asyncSetTimeout(0); + + // The user column keeps its colId; the colliding pivot result column is suffixed — neither is the other. + const userCol = api.getColumn('pivot_b_x_total'); + expect(userCol?.getColDef().field).toBe('extra'); + const pivotResult = api.getPivotResultColumns() ?? []; + expect(pivotResult).not.toContain(userCol); + expect(pivotResult.some((c) => c.getColId() === 'pivot_b_x_total_1')).toBe(true); + }); + + // P2-2: colId is imperative for REUSE — a column with a colId is never reused by field. Changing a + // colId therefore produces a NEW column with the new colId (whether via a fresh colDef object or by + // mutating the colId on a retained colDef reference). Lookup by field is unaffected (still supported). + test('changing a colId produces a new column with the new colId (fresh object)', async () => { + const api = gridsManager.createGrid('g', { + columnDefs: [ + { colId: 'a', field: 'a' }, + { colId: 'b', field: 'b' }, + ], + rowData: [{ a: 'x', b: 'y' }], + }); + await asyncSetTimeout(0); + expect(liveColIds(api)).toEqual(['a', 'b']); + + api.setGridOption('columnDefs', [ + { colId: 'a', field: 'a' }, + { colId: 'bRenamed', field: 'b' }, + ]); + await asyncSetTimeout(0); + + expect(liveColIds(api)).toEqual(['a', 'bRenamed']); + // Lookup by field is still supported, so the still-present field 'b' resolves the renamed column. + expect(api.getColumn('bRenamed')?.getColId()).toBe('bRenamed'); + expect(api.getColumn('b')?.getColId()).toBe('bRenamed'); + }); + + test('changing the colId on a retained colDef reference produces a new column with the new colId', async () => { + // Same object reference reused across builds, but its colId is mutated — colId is imperative for + // reuse, so this becomes a new column rather than silently keeping the old colId via colDef-ref reuse. + const sharedDef: ColDef = { colId: 'b', field: 'b' }; + const api = gridsManager.createGrid('g', { + columnDefs: [{ colId: 'a', field: 'a' }, sharedDef], + rowData: [{ a: 'x', b: 'y' }], + }); + await asyncSetTimeout(0); + expect(liveColIds(api)).toEqual(['a', 'b']); + + sharedDef.colId = 'bRenamed'; + api.setGridOption('columnDefs', [{ colId: 'a', field: 'a' }, sharedDef]); + await asyncSetTimeout(0); + + expect(liveColIds(api)).toEqual(['a', 'bRenamed']); + }); + + test('a new colId matching another column’s field does not reuse that column by field', async () => { + // Old column has an explicit colId 'x' and field 'b'. A new def with colId 'b' must NOT be reused + // from the old column just because 'b' is its field — colId is imperative for reuse. + const api = gridsManager.createGrid('g', { + columnDefs: [{ colId: 'x', field: 'b' }], + rowData: [{ b: 'y' }], + }); + await asyncSetTimeout(0); + expect(liveColIds(api)).toEqual(['x']); + + api.setGridOption('columnDefs', [{ colId: 'b', field: 'b' }]); + await asyncSetTimeout(0); + + // Reused-by-field would have kept colId 'x'; correct behaviour is the new colId 'b'. + expect(liveColIds(api)).toEqual(['b']); + }); + + // P2-7: seating a hierarchy column's generated virtuals incrementally (addPivotColumns) must match the + // bulk path (colDef `pivot: true`) — the same column structure either way. + test('incrementally pivoting a hierarchy column seats its virtuals identically to the bulk path', async () => { + const rowData = [ + { country: 'USA', date: new Date(2000, 9, 15), total: 5 }, + { country: 'USA', date: new Date(2001, 5, 20), total: 3 }, + ]; + const baseDefs = (pivot: boolean): ColDef[] => [ + { field: 'country', rowGroup: true }, + { field: 'date', enablePivot: true, pivot, groupHierarchy: ['year', 'month'] } as ColDef, + { field: 'total', aggFunc: 'sum' }, + ]; + + const bulkApi = pivotGridsManager.createGrid('bulk', { columnDefs: baseDefs(true), rowData, pivotMode: true }); + await asyncSetTimeout(0); + + const incApi = pivotGridsManager.createGrid('inc', { columnDefs: baseDefs(false), rowData, pivotMode: true }); + await asyncSetTimeout(0); + incApi.addPivotColumns(['date']); + await asyncSetTimeout(0); + + expect(liveColIds(incApi)).toEqual(liveColIds(bulkApi)); + }); + + // reapplyDefs path: a refresh that keeps the hierarchy colIds unchanged refreshes the generated + // virtual cols in place (reuses the same instances) rather than rebuilding them. + test('refreshing with unchanged hierarchy colIds reuses the hierarchy column instances', async () => { + const rowData = [ + { country: 'USA', date: new Date(2000, 9, 15), total: 5 }, + { country: 'USA', date: new Date(2001, 5, 20), total: 3 }, + ]; + const defs = (extra: boolean): ColDef[] => [ + { field: 'country', rowGroup: true }, + { field: 'date', enablePivot: true, pivot: true, groupHierarchy: ['year', 'month'] } as ColDef, + { field: 'total', aggFunc: 'sum' }, + ...(extra ? [{ field: 'extra' } as ColDef] : []), + ]; + + const api = pivotGridsManager.createGrid('g', { columnDefs: defs(false), rowData, pivotMode: true }); + await asyncSetTimeout(0); + + const yearBefore = api.getColumn('ag-Grid-HierarchyColumn-date-year'); + const monthBefore = api.getColumn('ag-Grid-HierarchyColumn-date-month'); + expect(yearBefore).toBeTruthy(); + expect(monthBefore).toBeTruthy(); + + // Adding an unrelated column leaves the date col's hierarchy colIds unchanged → reapplyDefs in place. + api.setGridOption('columnDefs', defs(true)); + await asyncSetTimeout(0); + + expect(api.getColumn('ag-Grid-HierarchyColumn-date-year')).toBe(yearBefore); + expect(api.getColumn('ag-Grid-HierarchyColumn-date-month')).toBe(monthBefore); + }); + + test('a column WITHOUT an explicit colId is reused by field across a colDef-object change', async () => { + // Field-keyed cols (auto-derived colId === field) must still reuse by field when the colDef ref changes. + const api = gridsManager.createGrid('g', { + columnDefs: [{ field: 'a' }, { field: 'b', width: 100 }], + rowData: [{ a: 'x', b: 'y' }], + }); + await asyncSetTimeout(0); + const bBefore = api.getColumn('b'); + expect(bBefore?.getColId()).toBe('b'); + + // Fresh objects (new refs), field stable, width changed → same column instance, picks up the new width. + api.setGridOption('columnDefs', [{ field: 'a' }, { field: 'b', width: 250 }]); + await asyncSetTimeout(0); + + expect(api.getColumn('b')).toBe(bBefore); + expect(api.getColumn('b')?.getActualWidth()).toBe(250); + }); +}); diff --git a/testing/behavioural/src/columns/column-mutations/apply-column-state.test.ts b/testing/behavioural/src/columns/column-mutations/apply-column-state.test.ts index 9603288ad4b..5d6e20f1618 100644 --- a/testing/behavioural/src/columns/column-mutations/apply-column-state.test.ts +++ b/testing/behavioural/src/columns/column-mutations/apply-column-state.test.ts @@ -1436,8 +1436,7 @@ describe('Column Mutations - applyColumnState', () => { }); }); - // Solved by AG-17366 when it is completed - test.skip('grouping activation + auto-col state in one call is a single pass', async () => { + test('grouping activation + auto-col state in one call is a single pass', async () => { const api = gridsManager.createGrid('evtGroupActivate', { columnDefs: [{ colId: 'a' }, { colId: 'grp' }], rowData: [{ a: 1, grp: 'x' }], @@ -1457,8 +1456,7 @@ describe('Column Mutations - applyColumnState', () => { expect(counts.columnRowGroupChanged).toBe(1); }); - // Solved by AG-17366 when it is completed - test.skip('pivot apply targeting a pivot-result col runs the leftover pass (single everythingChanged)', async () => { + test('pivot apply targeting a pivot-result col runs the leftover pass (single everythingChanged)', async () => { const api = gridsManager.createGrid('evtPivotLeftover', { columnDefs: [ { colId: 'country', rowGroup: true }, @@ -1481,8 +1479,7 @@ describe('Column Mutations - applyColumnState', () => { expect(counts.columnEverythingChanged).toBe(1); }); - // Solved by AG-17366 when it is completed - test.skip('resetColumnState on a grouped grid emits one columnsReset and one everythingChanged', async () => { + test('resetColumnState on a grouped grid emits one columnsReset and one everythingChanged', async () => { const api = gridsManager.createGrid('evtReset', { columnDefs: [{ colId: 'country', rowGroup: true }, { colId: 'a' }, { colId: 'b' }], rowData: [{ country: 'USA', a: 1, b: 2 }], @@ -1503,8 +1500,7 @@ describe('Column Mutations - applyColumnState', () => { }); describe('columnStateUtils edge cases', () => { - // Solved by AG-17366 when it is completed - test.skip('applyColumnState malformed inputs: non-array state, orphan service colIds, null/undefined colIds', async () => { + test('applyColumnState malformed inputs: non-array state, orphan service colIds, null/undefined colIds', async () => { const api = gridsManager.createGrid('malformedState', { columnDefs: [{ colId: 'a' }, { colId: 'b' }, { colId: 'c' }], }); @@ -1582,7 +1578,6 @@ describe('Column Mutations - applyColumnState', () => { ROOT id:ROOT_NODE_ID `); - // No displayed cols → `_getColumnState` takes the "iterate allCols" fast path. const state = api.getColumnState(); expect(state.map((s) => s.colId)).toEqual(['a', 'b']); await new GridRows( @@ -1664,7 +1659,6 @@ describe('Column Mutations - applyColumnState', () => { }); test('resetColumnState with no primary cols is a safe no-op', async () => { - // Grid with no columnDefs → `_resetColumnState` early-returns on `!primaryCols.length`. const api = gridsManager.createGrid('emptyReset', { columnDefs: [], }); @@ -2245,10 +2239,288 @@ describe('Column Mutations - applyColumnState', () => { `); }); }); + + describe('uncovered apply-column-state edge cases', () => { + test('duplicate colId flipping rowGroup membership in one apply: reactivated col lands at the tail', async () => { + const api = gridsManager.createGrid('dupColIdRowGroup', { + columnDefs: [{ colId: 'a', rowGroup: true }, { colId: 'b', rowGroup: true }, { colId: 'c' }], + rowData: [{ a: 1, b: 2, c: 3 }], + }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['a', 'b']); + + api.applyColumnState({ + state: [ + { colId: 'a', rowGroup: false }, + { colId: 'b', rowGroup: true }, + { colId: 'a', rowGroup: true }, + ], + }); + await asyncSetTimeout(0); + + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['b', 'a']); + await new GridColumns(api, 'duplicate colId rowGroup flip → [b, a]').checkColumns(` + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├── a width:200 rowGroup + ├── b width:200 rowGroup + └── c width:200 + `); + await new GridRows(api, 'duplicate colId rowGroup flip → grouped rows').check(` + ROOT id:ROOT_NODE_ID + └─┬ filler collapsed id:row-group-b- ag-Grid-AutoColumn:"(Blanks)" + · └─┬ LEAF_GROUP collapsed hidden id:row-group-b--a- ag-Grid-AutoColumn:"(Blanks)" + · · └── LEAF hidden id:0 + `); + }); + + test('value column aggFunc via state: change, clear via null, defaultState fallback', async () => { + const api = gridsManager.createGrid('aggFuncState', { + columnDefs: [{ colId: 'gold', aggFunc: 'sum' }, { colId: 'silver' }, { colId: 'bronze' }], + rowData: [{ gold: 1, silver: 2, bronze: 3 }], + }); + await asyncSetTimeout(0); + expect(api.getValueColumns().map((col: Column) => col.getColId())).toEqual(['gold']); + + // Change an existing value col's aggFunc. + api.applyColumnState({ state: [{ colId: 'gold', aggFunc: 'max' }] }); + await asyncSetTimeout(0); + expect(api.getColumn('gold')!.getAggFunc()).toBe('max'); + expect(api.getValueColumns().map((col: Column) => col.getColId())).toEqual(['gold']); + + // Explicit null deactivates the col but the last aggFunc is remembered (legacy behaviour). + api.applyColumnState({ state: [{ colId: 'gold', aggFunc: null }] }); + await asyncSetTimeout(0); + expect(api.getValueColumns()).toHaveLength(0); + expect(api.getColumn('gold')!.getAggFunc()).toBe('max'); + + // defaultState aggFunc fills cols with no explicit state (undefined → default), explicit wins. + api.applyColumnState({ state: [{ colId: 'silver', aggFunc: 'min' }], defaultState: { aggFunc: 'sum' } }); + await asyncSetTimeout(0); + expect(api.getColumn('silver')!.getAggFunc()).toBe('min'); + expect(api.getColumn('gold')!.getAggFunc()).toBe('sum'); + expect(api.getColumn('bronze')!.getAggFunc()).toBe('sum'); + await new GridColumns(api, 'aggFunc via state: silver min, gold/bronze sum').checkColumns(` + CENTER + ├── gold width:200 aggFunc:sum + ├── silver width:200 aggFunc:min + └── bronze width:200 aggFunc:sum + `); + }); + + test('invalid non-string aggFunc in state warns (#33) and is ignored', async () => { + const api = gridsManager.createGrid('aggFuncInvalid', { + columnDefs: [{ colId: 'gold', aggFunc: 'sum' }, { colId: 'a' }], + rowData: [{ gold: 1, a: 2 }], + }); + await asyncSetTimeout(0); + + const consoleWarnSpy = vitest.spyOn(console, 'warn').mockImplementation(() => {}); + api.applyColumnState({ state: [{ colId: 'gold', aggFunc: {} as any }] }); + await asyncSetTimeout(0); + + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleWarnSpy.mock.calls[0][0]).toContain('warning #33'); + consoleWarnSpy.mockRestore(); + // Unchanged: the invalid value is rejected, the prior aggFunc stands. + expect(api.getColumn('gold')!.getAggFunc()).toBe('sum'); + await new GridColumns(api, 'invalid aggFunc ignored → gold stays aggFunc:sum').checkColumns(` + CENTER + ├── gold width:200 aggFunc:sum + └── a width:200 + `); + }); + + test('defaultState applies field state to recreated auto-group and selection cols', async () => { + const api = gridsManager.createGrid('defaultStateServiceCols', { + columnDefs: [{ colId: 'country', rowGroup: true }, { colId: 'a' }], + rowSelection: { mode: 'multiRow', checkboxes: true }, + rowData: [{ country: 'USA', a: 1 }], + }); + await asyncSetTimeout(0); + + api.applyColumnState({ defaultState: { width: 321 } }); + await asyncSetTimeout(0); + + expect(api.getColumn('ag-Grid-AutoColumn')!.getActualWidth()).toBe(321); + expect(api.getColumn('ag-Grid-SelectionColumn')!.getActualWidth()).toBe(321); + expect(api.getColumn('a')!.getActualWidth()).toBe(321); + await new GridColumns(api, 'defaultState width:321 on auto-group + selection + primary').checkColumns(` + CENTER + ├── ag-Grid-SelectionColumn width:321 !resizable !sortable suppressMovable lockPosition:left + ├── ag-Grid-AutoColumn "Group" width:321 + ├── country width:321 rowGroup + └── a width:321 + `); + }); + + test('rowGroupIndex without an explicit rowGroup flag still activates the col', async () => { + const api = gridsManager.createGrid('indexActivates', { + columnDefs: [{ colId: 'a' }, { colId: 'b' }, { colId: 'c' }], + rowData: [{ a: 1, b: 2, c: 3 }], + }); + await asyncSetTimeout(0); + + api.applyColumnState({ state: [{ colId: 'b', rowGroupIndex: 0 }] }); + await asyncSetTimeout(0); + + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['b']); + await new GridColumns(api, 'rowGroupIndex without flag activates b').checkColumns(` + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├── a width:200 + ├── b width:200 rowGroup + └── c width:200 + `); + await new GridRows(api, 'rowGroupIndex without flag activates b → grouped rows').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP collapsed id:row-group-b- ag-Grid-AutoColumn:"(Blanks)" + · └── LEAF hidden id:0 + `); + }); + + test('one apply orders rowGroup and pivot independently by their own indexes', async () => { + const api = gridsManager.createGrid('mixedRowGroupPivot', { + columnDefs: [ + { colId: 'g1' }, + { colId: 'g2' }, + { colId: 'p1' }, + { colId: 'p2' }, + { colId: 'v', aggFunc: 'sum' }, + ], + rowData: [{ g1: 'a', g2: 'b', p1: 'c', p2: 'd', v: 1 }], + }); + await asyncSetTimeout(0); + + api.applyColumnState({ + state: [ + { colId: 'g1', rowGroup: true, rowGroupIndex: 1 }, + { colId: 'g2', rowGroup: true, rowGroupIndex: 0 }, + { colId: 'p1', pivot: true, pivotIndex: 1 }, + { colId: 'p2', pivot: true, pivotIndex: 0 }, + ], + }); + await asyncSetTimeout(0); + + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['g2', 'g1']); + expect(api.getPivotColumns().map((col: Column) => col.getColId())).toEqual(['p2', 'p1']); + await new GridColumns(api, 'independent rowGroup [g2,g1] + pivot [p2,p1] ordering').checkColumns(` + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├── g1 width:200 rowGroup + ├── g2 width:200 rowGroup + ├── p1 width:200 pivot + ├── p2 width:200 pivot + └── v width:200 aggFunc:sum + `); + }); + + test('one multi-axis apply dispatches each column event exactly once', async () => { + const api = gridsManager.createGrid('multiAxisEvents', { + columnDefs: [ + { colId: 'grp' }, + { colId: 'piv' }, + { colId: 'val' }, + { colId: 'sortMe' }, + { colId: 'pinMe' }, + { colId: 'hideMe' }, + { colId: 'wide' }, + ], + rowData: [{ grp: 'a', piv: 'b', val: 1, sortMe: 2, pinMe: 3, hideMe: 4, wide: 5 }], + }); + await asyncSetTimeout(0); + + const tracked = [ + 'columnEverythingChanged', + 'columnRowGroupChanged', + 'columnPivotChanged', + 'columnValueChanged', + 'columnMoved', + 'columnVisible', + 'columnResized', + 'columnPinned', + 'sortChanged', + ] as const; + const counts: Record = {}; + for (let i = 0; i < tracked.length; ++i) { + const name = tracked[i]; + counts[name] = 0; + api.addEventListener(name, () => { + counts[name]++; + }); + } + + api.applyColumnState({ + state: [ + { colId: 'grp', rowGroup: true }, + { colId: 'piv', pivot: true }, + { colId: 'val', aggFunc: 'sum' }, + { colId: 'sortMe', sort: 'asc' }, + { colId: 'pinMe', pinned: 'left' }, + { colId: 'hideMe', hide: true }, + { colId: 'wide', width: 333 }, + ], + applyOrder: true, + }); + await asyncSetTimeout(0); + + expect(counts.columnEverythingChanged).toBe(1); + expect(counts.columnRowGroupChanged).toBe(1); + expect(counts.columnPivotChanged).toBe(1); + expect(counts.columnValueChanged).toBe(1); + expect(counts.columnVisible).toBe(1); + expect(counts.columnResized).toBe(1); + expect(counts.columnPinned).toBe(1); + expect(counts.sortChanged).toBe(1); + await new GridColumns(api, 'multi-axis apply final column state').checkColumns(` + LEFT + └── pinMe width:200 + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├── grp width:200 rowGroup + ├── piv width:200 pivot + ├── val width:200 aggFunc:sum + ├── sortMe width:200 sort:asc + └── wide width:333 + `); + }); + + test('removing a rowGroup col re-stamps remaining indexes; a later field-only apply preserves them', async () => { + // Guards the orderByPendingState skip optimisation: the membership change must still re-stamp + // rowGroupActiveIndex (exposed as rowGroupIndex), and a subsequent width-only apply (which changes + // nothing about row-group membership/order) must not disturb that order or those indexes. + const api = gridsManager.createGrid('reStampOnRemoval', { + columnDefs: [ + { colId: 'a', rowGroup: true, rowGroupIndex: 0 }, + { colId: 'b', rowGroup: true, rowGroupIndex: 1 }, + { colId: 'c', rowGroup: true, rowGroupIndex: 2 }, + ], + rowData: [{ a: 1, b: 2, c: 3 }], + }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['a', 'b', 'c']); + + // Remove the middle group col → remaining cols' indexes must shift to 0,1. + api.applyColumnState({ state: [{ colId: 'b', rowGroup: false }] }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['a', 'c']); + const afterRemoval = api.getColumnState(); + expect(afterRemoval.find((s) => s.colId === 'a')!.rowGroupIndex).toBe(0); + expect(afterRemoval.find((s) => s.colId === 'c')!.rowGroupIndex).toBe(1); + + // Field-only apply (width) touches no row-group membership → order + indexes unchanged. + api.applyColumnState({ state: [{ colId: 'a', width: 111 }] }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((col: Column) => col.getColId())).toEqual(['a', 'c']); + const afterWidth = api.getColumnState(); + expect(afterWidth.find((s) => s.colId === 'a')!.rowGroupIndex).toBe(0); + expect(afterWidth.find((s) => s.colId === 'c')!.rowGroupIndex).toBe(1); + expect(api.getColumn('a')!.getActualWidth()).toBe(111); + }); + }); }); -// Solved by AG-17366 when it is completed -describe.skip('single displayedColumnsChanged per operation', () => { +describe('single displayedColumnsChanged per operation', () => { const gridsManager = new TestGridsManager({ modules: [ClientSideRowModelModule, RowGroupingModule, PivotModule], }); diff --git a/testing/behavioural/src/columns/column-mutations/column-identity.test.ts b/testing/behavioural/src/columns/column-mutations/column-identity.test.ts index 3b34305b118..0be44fab9a6 100644 --- a/testing/behavioural/src/columns/column-mutations/column-identity.test.ts +++ b/testing/behavioural/src/columns/column-mutations/column-identity.test.ts @@ -1,20 +1,3 @@ -/** - * Characterises AgColumn identity + colId-allocation across builds. Two distinct mechanisms, - * kept separate on purpose: - * - * 1. Reuse — does an AgColumn instance survive a colDef change? Keyed by `colId ?? field ?? - * userColDefRef` (see `_createColumnTree` / `buildColumn`). Plain colId reuse is covered in - * setColumnDefs.test.ts; this file adds field-keyed and anonymous (no colId/no field) cases, - * the latter being the React inline-`{...}` colDef scenario. - * 2. Auto-id allocation — anonymous cols receive deterministic integer ids ('0','1',…), avoiding - * collisions with explicit user colIds. - * - * Id generation itself is a fixed contract and is NOT being changed; these tests pin its current - * behaviour (including the order-dependent anonymous/explicit-colId interaction) so the upcoming - * order-maintenance rework can be verified against a stable id baseline. - * - * Tests instantiate the full grid via TestGridsManager and exercise public APIs only. - */ import { vi } from 'vitest'; import { ClientSideRowModelModule } from 'ag-grid-community'; @@ -78,8 +61,7 @@ describe('Column identity & id allocation', () => { expect(colIds(api)).toEqual(['0', 'b']); }); - // Solved by AG-17366 when it is completed - test.skip('anonymous col keeps a stable id (no drift) when its colDef object is recreated', async () => { + test('anonymous col keeps a stable id (no drift) when its colDef object is recreated', async () => { const def0: ColDef = { headerName: 'X', width: 100 }; const api = gridsManager.createGrid('myGrid', { columnDefs: [def0, { field: 'b' }] }); @@ -120,13 +102,8 @@ describe('Column identity & id allocation', () => { }); }); - describe('auto-id vs explicit colId collision', () => { - test('anonymous-first takes "0", so a later explicit colId:"0" is suffixed to "0_1"', () => { - // Auto-ids are allocated in def order: the anonymous col is FIRST so it grabs '0', and the - // later explicit `colId: '0'` then collides and is suffixed to '0_1' (with warning 273). - // Documented, order-dependent behaviour — pinned to guard the id-allocation contract while - // order-maintenance is reworked around it. - vi.spyOn(console, 'warn').mockImplementation(() => {}); // warning 273: expected colId collision + describe('explicit colId wins over anonymous integer ids (order-independent)', () => { + test('anonymous-first: explicit colId:"0" is still honoured, the anonymous col skips to "1"', () => { const api = gridsManager.createGrid('myGrid', { columnDefs: [{ headerName: 'anon' }, { colId: '0', headerName: 'explicit' }], }); @@ -134,8 +111,8 @@ describe('Column identity & id allocation', () => { const headerById = Object.fromEntries( api.getColumns()!.map((c) => [c.getColId(), c.getColDef().headerName]) ); - expect(colIds(api)).toEqual(['0', '0_1']); - expect(headerById).toEqual({ '0': 'anon', '0_1': 'explicit' }); + expect(colIds(api)).toEqual(['1', '0']); + expect(headerById).toEqual({ '1': 'anon', '0': 'explicit' }); }); test('explicit-first keeps its id; the anonymous col skips to "1"', () => { @@ -149,6 +126,17 @@ describe('Column identity & id allocation', () => { expect(colIds(api)).toEqual(['0', '1']); expect(headerById).toEqual({ '0': 'explicit', '1': 'anon' }); }); + + test('grouped: explicit numeric colId is not stolen by generated anonymous or padding-group ids', () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { groupId: 'g', children: [{ headerName: 'anon' }, { colId: '0', field: 'a' }] }, + { field: 'b' }, // top-level leaf next to a group → wrapped in a generated padding group + ], + }); + expect(api.getColumn('0')?.getColDef().field).toBe('a'); + expect(colIds(api)).toEqual(['1', '0', 'b']); + }); }); describe('duplicate keys', () => { @@ -166,6 +154,144 @@ describe('Column identity & id allocation', () => { }); expect(colIds(api)).toEqual(['x', 'x_1']); }); + + test('both duplicate-field cols keep their instances (and state) across a rebuild', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'a', width: 100 }, + { field: 'a', width: 100 }, + ], + }); + const first = api.getColumn('a')!; + const second = api.getColumn('a_1')!; // the suffixed duplicate the buggy path recreated + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + + api.applyColumnState({ state: [{ colId: 'a_1', width: 222 }] }); + api.setGridOption('columnDefs', [ + { field: 'a', headerName: 'A1' }, + { field: 'a', headerName: 'A2' }, + ]); + await asyncSetTimeout(0); + + expect(colIds(api)).toEqual(['a', 'a_1']); + expect(api.getColumn('a')).toBe(first); + expect(api.getColumn('a_1')).toBe(second); + expect(api.getColumn('a_1')!.getActualWidth()).toBe(222); + }); + + test('both duplicate-colId cols keep their instances across a rebuild', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); // warning 273: expected colId collision + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ colId: 'x' }, { colId: 'x' }], + }); + const first = api.getColumn('x')!; + const second = api.getColumn('x_1')!; + + api.setGridOption('columnDefs', [ + { colId: 'x', headerName: 'X1' }, + { colId: 'x', headerName: 'X2' }, + ]); + await asyncSetTimeout(0); + + expect(colIds(api)).toEqual(['x', 'x_1']); + expect(api.getColumn('x')).toBe(first); + expect(api.getColumn('x_1')).toBe(second); + }); + + test('the SAME colDef instance used for two columns yields two distinct cols, reused across rebuild', async () => { + const shared: ColDef = { field: 'a', width: 100 }; + const api = gridsManager.createGrid('myGrid', { + columnDefs: [shared, shared], + }); + + const first = api.getColumn('a')!; + const second = api.getColumn('a_1')!; + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first).not.toBe(second); + expect(colIds(api)).toEqual(['a', 'a_1']); + + api.setGridOption('columnDefs', [shared, shared]); + await asyncSetTimeout(0); + + expect(api.getColumns()!.length).toBe(2); + expect(colIds(api)).toEqual(['a', 'a_1']); + expect(api.getColumn('a')).toBe(first); + expect(api.getColumn('a_1')).toBe(second); + }); + + test('the SAME colDef instance with an explicit colId used twice yields two distinct cols, reused across rebuild', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); // warning 273: expected colId collision + const shared: ColDef = { colId: 'x', width: 100 }; + const api = gridsManager.createGrid('myGrid', { + columnDefs: [shared, shared], + }); + + const first = api.getColumn('x')!; + const second = api.getColumn('x_1')!; + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(first).not.toBe(second); + expect(colIds(api)).toEqual(['x', 'x_1']); + + api.setGridOption('columnDefs', [shared, shared]); + await asyncSetTimeout(0); + + expect(api.getColumns()!.length).toBe(2); + expect(colIds(api)).toEqual(['x', 'x_1']); + expect(api.getColumn('x')).toBe(first); + expect(api.getColumn('x_1')).toBe(second); + }); + + test('duplicate-field cols with stable refs follow their ref (not position) when reordered', async () => { + const defA: ColDef = { field: 'a' }; + const defB: ColDef = { field: 'a' }; + const api = gridsManager.createGrid('myGrid', { columnDefs: [defA, defB] }); + + const first = api.getColumn('a')!; // built from defA + const second = api.getColumn('a_1')!; // built from defB + api.applyColumnState({ + state: [ + { colId: 'a', width: 111 }, + { colId: 'a_1', width: 222 }, + ], + }); + + api.setGridOption('columnDefs', [defB, defA]); + await asyncSetTimeout(0); + + expect(api.getColumns()![0]).toBe(second); + expect(api.getColumns()![1]).toBe(first); + expect(api.getColumn('a_1')!.getActualWidth()).toBe(222); + expect(api.getColumn('a')!.getActualWidth()).toBe(111); + }); + + test('swapping the colId of two stable colDef refs keeps each col with its colId (not its ref)', async () => { + const defA: ColDef = { colId: 'x' }; + const defB: ColDef = { colId: 'y' }; + const api = gridsManager.createGrid('myGrid', { columnDefs: [defA, defB] }); + + const colX = api.getColumn('x')!; + const colY = api.getColumn('y')!; + api.applyColumnState({ + state: [ + { colId: 'x', width: 111 }, + { colId: 'y', width: 222 }, + ], + }); + + defA.colId = 'y'; + defB.colId = 'x'; + api.setGridOption('columnDefs', [defA, defB]); + await asyncSetTimeout(0); + + expect(api.getColumn('x')).toBe(colX); + expect(api.getColumn('y')).toBe(colY); + expect(api.getColumn('x')!.getActualWidth()).toBe(111); + expect(api.getColumn('y')!.getActualWidth()).toBe(222); + expect(colIds(api)).toEqual(['y', 'x']); // defA (now 'y') first, defB (now 'x') second + }); }); describe('deterministic allocation (master/slave grids)', () => { diff --git a/testing/behavioural/src/columns/column-mutations/services-and-trees.test.ts b/testing/behavioural/src/columns/column-mutations/services-and-trees.test.ts index 202ce0fc8cf..01c8b87c424 100644 --- a/testing/behavioural/src/columns/column-mutations/services-and-trees.test.ts +++ b/testing/behavioural/src/columns/column-mutations/services-and-trees.test.ts @@ -387,8 +387,7 @@ describe('Column Mutations', () => { }); describe('insertVirtualColumnsForCol splice position', () => { - // Solved by AG-17366 when it is completed - test.skip('hierarchy virtuals precede source col, in declared order', async () => { + test('hierarchy virtuals precede source col, in declared order', async () => { const api = gridsManager.createGrid('splicePos', { columnDefs: [ { colId: 'a' }, @@ -549,8 +548,7 @@ describe('Column Mutations', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('col removed via columnDefs change has lastLeftPinned cleared (no stale flag on destroyed bean)', async () => { + test('col removed via columnDefs change has lastLeftPinned cleared (no stale flag on destroyed bean)', async () => { const api = gridsManager.createGrid('removedAfterPin', { columnDefs: [{ colId: 'a', pinned: 'left' }, { colId: 'b', pinned: 'left' }, { colId: 'c' }], }); diff --git a/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts b/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts index 4dace78d96e..2d3e289c29a 100644 --- a/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts +++ b/testing/behavioural/src/columns/column-mutations/setColumnDefs.test.ts @@ -29,8 +29,7 @@ describe('Column Mutations', () => { }); describe('setColumnDefs: instance preservation', () => { - // Solved by AG-17366 when it is completed - test.skip('column instances are reused when colId matches', async () => { + test('column instances are reused when colId matches', async () => { const columnDefs1: ColDef[] = [ { colId: 'a', width: 100 }, { colId: 'b', width: 200 }, @@ -1356,8 +1355,7 @@ describe('Column Mutations', () => { expect(afterIds).not.toContain('b'); }); - // Solved by AG-17366 when it is completed - test.skip('group instance is reused when colGroupDef is structurally unchanged', async () => { + test('group instance is reused when colGroupDef is structurally unchanged', async () => { const api = gridsManager.createGrid('groupReuse', { columnDefs: [{ headerName: 'G', groupId: 'g', children: [{ colId: 'a' }, { colId: 'b' }] }], }); @@ -1441,8 +1439,7 @@ describe('Column Mutations', () => { expect(groupAfter.colGroupDef.headerName).toBe('Changed'); }); - // Solved by AG-17366 when it is completed - test.skip('reused group has its colGroupDef updated to reflect the latest children array', async () => { + test('reused group has its colGroupDef updated to reflect the latest children array', async () => { // Even though `children` is excluded from the structural compare (refs change per // call), the reused instance's `colGroupDef.children` MUST point to the latest // user-supplied array so consumers reading `getColGroupDef().children` don't see @@ -1497,8 +1494,7 @@ describe('Column Mutations', () => { expect(groupAfter.colGroupDef.children[1].colId).toBe('b'); }); - // Solved by AG-17366 when it is completed - test.skip('reused group picks up new children when cols are added to it', async () => { + test('reused group picks up new children when cols are added to it', async () => { const api = gridsManager.createGrid('groupChildrenAdd', { columnDefs: [{ headerName: 'G', groupId: 'g', children: [{ colId: 'a' }, { colId: 'b' }] }], }); @@ -1561,8 +1557,7 @@ describe('Column Mutations', () => { // Cascade: when AgProvidedColumnGroup is reused, the displayed AgColumnGroup wrapper // also reuses (its lookup gated on `columnGroup.providedColumnGroup === providedGroup`). // Before AgProvidedColumnGroup reuse, this cascade was always broken. - // Solved by AG-17366 when it is completed - test.skip('displayed AgColumnGroup is reused when AgProvidedColumnGroup is reused', async () => { + test('displayed AgColumnGroup is reused when AgProvidedColumnGroup is reused', async () => { const api = gridsManager.createGrid('groupCascade', { columnDefs: [{ headerName: 'G', groupId: 'g', children: [{ colId: 'a' }] }], }); @@ -1825,8 +1820,7 @@ describe('Column Mutations', () => { expect(column.getColDef()).not.toBe(initialMergedRef); }); - // Solved by AG-17366 when it is completed - test.skip('AgProvidedColumnGroup adopts new colGroupDef ref when structurally equal', async () => { + test('AgProvidedColumnGroup adopts new colGroupDef ref when structurally equal', async () => { const initialGroup: ColGroupDef = { groupId: 'g', headerName: 'G', diff --git a/testing/behavioural/src/columns/column-prototype-key-ids-enterprise.test.ts b/testing/behavioural/src/columns/column-prototype-key-ids-enterprise.test.ts index d0d72b8befd..56be3909e6e 100644 --- a/testing/behavioural/src/columns/column-prototype-key-ids-enterprise.test.ts +++ b/testing/behavioural/src/columns/column-prototype-key-ids-enterprise.test.ts @@ -24,8 +24,7 @@ describe('Enterprise: prototype-name colIds / groupIds', () => { beforeEach(() => gridsManager.reset()); afterEach(() => gridsManager.reset()); - // Solved by AG-17366 when it is completed - test.skip('advanced filter evaluates against a prototype-name colId (expressionEvaluatorParams map)', () => { + test('advanced filter evaluates against a prototype-name colId (expressionEvaluatorParams map)', () => { const api = gridsManager.createGrid('g', { columnDefs: [ { colId: 'toString', field: 'a', filter: true }, @@ -51,8 +50,7 @@ describe('Enterprise: prototype-name colIds / groupIds', () => { expect(api.getDisplayedRowAtIndex(0)!.data.a).toBe('keep'); }); - // Solved by AG-17366 when it is completed - test.skip('row grouping by prototype-name colIds keeps order (orderedColsService colId map)', () => { + test('row grouping by prototype-name colIds keeps order (orderedColsService colId map)', () => { const api = gridsManager.createGrid('g', { columnDefs: [ { colId: 'toString', field: 'a' }, diff --git a/testing/behavioural/src/columns/column-prototype-key-ids.test.ts b/testing/behavioural/src/columns/column-prototype-key-ids.test.ts index 8beb8e7c664..0b1da71122e 100644 --- a/testing/behavioural/src/columns/column-prototype-key-ids.test.ts +++ b/testing/behavioural/src/columns/column-prototype-key-ids.test.ts @@ -18,8 +18,7 @@ import { TestGridsManager } from '../test-utils'; // The grid uses `Object.create(null)` for these lookups so user-supplied prototype-name ids work. const PROTO_IDS = ['toString', 'constructor', 'valueOf', 'hasOwnProperty', '__proto__']; -// Solved by AG-17366 when it is completed -describe.skip('Columns with Object.prototype-name colIds / groupIds', () => { +describe('Columns with Object.prototype-name colIds / groupIds', () => { const gridsManager = new TestGridsManager({ modules: [ ClientSideRowModelModule, diff --git a/testing/behavioural/src/columns/order/column-move-drag.test.ts b/testing/behavioural/src/columns/order/column-move-drag.test.ts new file mode 100644 index 00000000000..c5fbf55c25d --- /dev/null +++ b/testing/behavioural/src/columns/order/column-move-drag.test.ts @@ -0,0 +1,166 @@ +import type { GridApi } from 'ag-grid-community'; +import { ClientSideRowModelModule, getGridElement } from 'ag-grid-community'; + +import { DragEventDispatcher, TestGridsManager, asyncSetTimeout } from '../../test-utils'; + +// Exercises the header-drag reorder path: getBestColumnMoveIndexFromXPosition -> getLowestFragMove +// (the `displayedIndex >= 0` displayed-subset filter) -> calculateValidMoves / notDisplayedInSection +// (the hidden-column skip). A hidden column sits between displayed ones so both the displayed filter +// and the section skip have a hidden col to act on. +describe('column header drag reorder', () => { + const gridsManager = new TestGridsManager({ modules: [ClientSideRowModelModule] }); + + beforeEach(() => gridsManager.reset()); + afterEach(() => gridsManager.reset()); + + function colOrder(api: GridApi): string[] { + return api.getAllGridColumns().map((c) => c.getColId()); + } + + function el(api: GridApi, selector: string): HTMLElement { + const found = (getGridElement(api)! as HTMLElement).querySelector(selector) as HTMLElement | null; + if (!found) { + throw new Error(`element not found: ${selector}`); + } + return found; + } + + // Drives a real header drag through DragService -> dragAndDropService -> MoveColumnFeature. + // `elementsFromPoint` is pointed at the body viewport (a BodyDropTarget container) so the in-grid + // drop target resolves; the mouse Y sits in the body so the container rect-containment check passes. + // The drop index is computed from `toClientX` (section-relative, section left is 0 in the layout mock); + // `fromClientX` only sets the horizontal drag direction (left vs right). + async function dragHeader( + api: GridApi, + sourceColId: string, + fromClientX: number, + toClientX: number + ): Promise { + return dragSelector(api, `.ag-header-cell[col-id="${sourceColId}"]`, fromClientX, toClientX); + } + + async function dragSelector( + api: GridApi, + sourceSelector: string, + fromClientX: number, + toClientX: number + ): Promise { + const source = el(api, sourceSelector); + const viewport = el(api, '.ag-grid-viewport'); + const dispatcher = new DragEventDispatcher('mouse', null, false); + const ownerDocument = source.ownerDocument; + const original = ownerDocument.elementsFromPoint?.bind(ownerDocument); + ownerDocument.elementsFromPoint = () => [viewport]; + const y = 100; // inside the body viewport rect (top = headerHeight) + try { + await dispatcher.startDrag(source, fromClientX, y); + await dispatcher.movePointer(viewport, fromClientX, y); + await dispatcher.movePointer(viewport, toClientX, y); + await dispatcher.finishDrag(viewport); + await asyncSetTimeout(50); + } finally { + ownerDocument.elementsFromPoint = original as typeof ownerDocument.elementsFromPoint; + } + } + + function createGrid(): Promise { + return gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'a', width: 100 }, + { field: 'b', width: 100 }, + { field: 'c', width: 100, hide: true }, + { field: 'd', width: 100 }, + ], + rowData: [{ a: 1, b: 2, c: 3, d: 4 }], + suppressDragLeaveHidesColumns: true, + }); + } + + test('dragging a header right places it past the displayed cols, hidden col preserved', async () => { + const api = await createGrid(); + expect(colOrder(api)).toEqual(['a', 'b', 'c', 'd']); + + // Drag 'a' rightwards to beyond the last displayed col. getLowestFragMove filters the hidden + // 'c' out of the displayed-order comparison; 'a' lands after 'd', 'c' keeps its slot. + await dragHeader(api, 'a', 5, 250); + + expect(colOrder(api)).toEqual(['b', 'c', 'd', 'a']); + }); + + test('dragging a header left to the front, hidden col preserved', async () => { + const api = await createGrid(); + expect(colOrder(api)).toEqual(['a', 'b', 'c', 'd']); + + // Drag 'd' to the far left: calculateValidMoves runs its dragging-left branch (calling + // notDisplayedInSection) and 'd' settles at the front; hidden 'c' keeps its slot. + await dragHeader(api, 'd', 400, 5); + + expect(colOrder(api)).toEqual(['d', 'a', 'b', 'c']); + }); + + test('dragging a center column to the far left pins it, alongside an existing pinned column', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'p', width: 100, pinned: 'left' }, + { field: 'a', width: 100 }, + { field: 'b', width: 100 }, + { field: 'd', width: 100 }, + ], + rowData: [{ p: 0, a: 1, b: 2, d: 4 }], + suppressDragLeaveHidesColumns: true, + }); + expect(colOrder(api)).toEqual(['p', 'a', 'b', 'd']); + + // Drag center col 'd' to the far-left (pinned) edge. The left-section move logic runs, and + // notDisplayedInSection's `section === 'left'` branch skips the center cols (pinned mismatch); + // 'd' pins to the left, ahead of the existing pinned 'p'. + await dragHeader(api, 'd', 400, 5); + + expect(colOrder(api)).toEqual(['d', 'p', 'a', 'b']); + expect(api.getColumn('d')!.getPinned()).toBe('left'); + }); + + test('a suppressMovable column cannot be dragged (move is blocked)', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'a', width: 100, suppressMovable: true }, + { field: 'b', width: 100 }, + { field: 'd', width: 100 }, + ], + rowData: [{ a: 1, b: 2, d: 4 }], + suppressDragLeaveHidesColumns: true, + }); + expect(colOrder(api)).toEqual(['a', 'b', 'd']); + + // calculateValidMoves short-circuits to [] for a non-movable col, so no valid move exists. + await dragHeader(api, 'a', 5, 400); + + expect(colOrder(api)).toEqual(['a', 'b', 'd']); + }); + + test('dragging a marryChildren group header moves all its leaves together, including hidden ones', async () => { + const api = await gridsManager.createGridAndWait('myGrid', { + columnDefs: [ + { field: 'x', width: 100 }, + { + headerName: 'G', + marryChildren: true, + children: [ + { field: 'a', width: 100 }, + { field: 'b', width: 100, columnGroupShow: 'open' }, // hidden while the group is closed + ], + }, + { field: 'd', width: 100 }, + ], + rowData: [{ x: 0, a: 1, b: 2, d: 4 }], + suppressDragLeaveHidesColumns: true, + }); + expect(colOrder(api)).toEqual(['x', 'a', 'b', 'd']); + + // Dragging the group header carries multiple moving cols (sortColsLikeCols + calculateOldIndex + // multi-col path) and getColsToMove pulls the hidden 'b' so the married group stays intact. + await dragSelector(api, '.ag-header-group-cell', 250, 5); + + expect(colOrder(api)).toEqual(['a', 'b', 'x', 'd']); + }); +}); diff --git a/testing/behavioural/src/columns/order/pivot.test.ts b/testing/behavioural/src/columns/order/pivot.test.ts index a04fdc18143..f9bf7418951 100644 --- a/testing/behavioural/src/columns/order/pivot.test.ts +++ b/testing/behavioural/src/columns/order/pivot.test.ts @@ -468,8 +468,7 @@ describe('pivotMode=true', () => { expect(getColumnOrder(gridApi, pinned)).toEqual(expected); }); - // Solved by AG-17366 when it is completed - describe.skip.each([ + describe.each([ [true, true], [true, false], [false, true], @@ -1696,8 +1695,7 @@ describe('pivotMode=true', () => { getColumnOrderFromState(api as any).filter((id): id is string => !!id?.startsWith('pivot_')); // Example 1: 3 pivot keys, two value cols per group (sport `last` + total `sum`). - // Solved by AG-17366 when it is completed - test.skip('re-sorts pivot columns on re-entry when the comparator changes (3 keys, 2 value cols)', () => { + test('re-sorts pivot columns on re-entry when the comparator changes (3 keys, 2 value cols)', () => { let order = 1; const columnDefs: (ColDef | ColGroupDef)[] = [ { @@ -1731,8 +1729,7 @@ describe('pivotMode=true', () => { }); // Example 2: 2 pivot keys, one value col per group. - // Solved by AG-17366 when it is completed - test.skip('re-sorts pivot columns on re-entry when the comparator changes (2 keys)', () => { + test('re-sorts pivot columns on re-entry when the comparator changes (2 keys)', () => { let order = 1; const columnDefs: (ColDef | ColGroupDef)[] = [ { diff --git a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts index 1c5250dd6c4..243b1568166 100644 --- a/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-ordering.test.ts @@ -157,6 +157,16 @@ describe('calculated columns - display ordering', () => { return added[0]; } + /** Removes a dynamic (dialog-added) calc col through its header menu — dynamic calc cols are not in + * `columnDefs`, so they can only be removed via the menu's "Remove Calculated Column" action. */ + async function removeViaMenu(api: GridApi, colId: string): Promise { + enableOffsetParentPolyfill(); + api.showColumnMenu(colId); + await asyncSetTimeout(10); + await clickColumnMenuItem('Remove Calculated Column'); + await asyncSetTimeout(1); + } + // === Rule 6: static calc cols keep their declared columnDefs position ======================== test('static calc col keeps its declared position among regular columns', async () => { @@ -314,8 +324,31 @@ describe('calculated columns - display ordering', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn appends after a manual reorder, preserving the reorder', async () => { + test('an unanchored calc col (its calc-col anchor removed) re-appends at the top level, not into a group', async () => { + const api = createGrid('calc-anchor-calc-removed', { + rowData: [{ id: 'r1', a: 1, b: 2 }], + columnDefs: [{ groupId: 'G', headerName: 'G', children: [{ field: 'a' }, { field: 'b' }] }], + }); + // c1 anchored to 'b' (sits inside group G); c2 anchored to c1. + const c1 = await addViaDialog(api, 'b', '[a]'); + const c2 = await addViaDialog(api, c1, '[a]'); + + // Removing c1 nulls c2's anchor → c2 becomes truly unanchored (no order-restoration anchor). The last + // displayed leaf is inside group 'G', so the unanchored re-append must NOT smuggle c2 into 'G'. + await removeViaMenu(api, c1); + await asyncSetTimeout(1); + + expect(c2).toBe('calculated_2'); + await new GridColumns(api, 'unanchored calc col stays top-level, not in group').checkColumns(` + CENTER + ├── calculated_2 "New title" width:200 + └─┬ "G" GROUP + ├── a "A" width:200 + └── b "B" width:200 + `); + }); + + test('addCalculatedColumn appends after a manual reorder, preserving the reorder', async () => { const api = createGrid('api-append-after-move', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], @@ -355,6 +388,21 @@ describe('calculated columns - display ordering', () => { `); }); + test('two calc cols from the same anchor stack newest-first (tree + display agree)', async () => { + const api = createGrid('dialog-same-anchor', { + rowData: [{ id: 'r1', revenue: 10, cost: 3 }], + columnDefs: [ + { field: 'revenue', headerName: 'Revenue' }, + { field: 'cost', headerName: 'Cost' }, + ], + }); + const first = await addViaDialog(api, 'revenue', '[Revenue] - [Cost]'); + const second = await addViaDialog(api, 'revenue', '[Revenue] * [Cost]'); + + // Each insert lands immediately after the anchor, so the newest sits closest to it. + expect(order(api)).toEqual(['revenue', second, first, 'cost']); + }); + test('dialog add lands after the anchor leaf column when maintainColumnOrder is enabled', async () => { const api = createGrid('dialog-after-anchor-maintain-order', { maintainColumnOrder: true, @@ -488,8 +536,7 @@ describe('calculated columns - display ordering', () => { expect(closedCalculatedColDef?.columnGroupShow).toBe('closed'); }); - // Solved by AG-17366 when it is completed - test.skip('removing an anchor preserves the user reorder and keeps the dependent in place', async () => { + test('removing an anchor preserves the user reorder and keeps the dependent in place', async () => { const api = createGrid('reorder-then-remove-anchor', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [ @@ -505,7 +552,7 @@ describe('calculated columns - display ordering', () => { api.moveColumns(['c'], 0); expect(order(api)).toEqual(['c', 'a', first, second, 'b']); - removeColumnDef(api, first); + await removeViaMenu(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') @@ -567,8 +614,7 @@ describe('calculated columns - display ordering', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('two dialog adds on the same anchor: later add sits between the anchor and the earlier add', async () => { + test('two dialog adds on the same anchor: later add sits between the anchor and the earlier add', async () => { const api = createGrid('dialog-same-anchor', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -832,8 +878,7 @@ describe('calculated columns - display ordering', () => { // === Rule 4: anchor removed — orphaned dependent keeps its displayed position ================ - // Solved by AG-17366 when it is completed - test.skip('removing the anchor calc col keeps its orphaned dependent in place', async () => { + test('removing the anchor calc col keeps its orphaned dependent in place', async () => { const api = createGrid('dialog-anchor-removed', { rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [ @@ -845,7 +890,7 @@ describe('calculated columns - display ordering', () => { const second = await addViaDialog(api, first, '[Revenue] - [Cost]'); expect(order(api)).toEqual(['revenue', first, second, 'cost']); - removeColumnDef(api, first); + await removeViaMenu(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. @@ -858,8 +903,7 @@ describe('calculated columns - display ordering', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('removing the anchor calc col keeps a mid-list dependent between its neighbours', async () => { + test('removing the anchor calc col keeps a mid-list dependent between its neighbours', async () => { const api = createGrid('dialog-anchor-removed-mid', { rowData: [{ id: 'r1', revenue: 10, cost: 3, tax: 1 }], columnDefs: [ @@ -872,7 +916,7 @@ describe('calculated columns - display ordering', () => { const second = await addViaDialog(api, first, '[Revenue] - [Cost]'); expect(order(api)).toEqual(['revenue', first, second, 'cost', 'tax']); - removeColumnDef(api, first); + await removeViaMenu(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. diff --git a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts index 204679eee82..e743e1ccaa7 100644 --- a/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns-pivot.test.ts @@ -122,8 +122,7 @@ describe('calculated columns - pivot mode', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn while pivot active keeps the pivot result intact', async () => { + test('addCalculatedColumn while pivot active keeps the pivot result intact', async () => { const api = createGrid('pivot-add-calc', { rowData, columnDefs: pivotColumnDefs, diff --git a/testing/behavioural/src/formulas/calculated-columns.test.ts b/testing/behavioural/src/formulas/calculated-columns.test.ts index e2a00b8f4d4..0458658bfb6 100644 --- a/testing/behavioural/src/formulas/calculated-columns.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns.test.ts @@ -504,6 +504,41 @@ describe('ag-grid calculated columns', () => { expect(coveredCell).toBeNull(); }); + test('editing a calculated column expression re-groups its row spans (and dependents)', async () => { + const api = createGrid('calculated-span-rows-expression-edit', { + enableCellSpan: true, + rowData: [ + { id: 'r1', a: 'X', b: 'P' }, + { id: 'r2', a: 'X', b: 'Q' }, + { id: 'r3', a: 'Y', b: 'Q' }, + ], + columnDefs: [ + { field: 'a' }, + { field: 'b' }, + { colId: 'calc', calculatedExpression: '[a]', spanRows: true }, + { colId: 'dep', calculatedExpression: '[calc]', spanRows: true }, + ], + }); + await asyncSetTimeout(1); + + await new GridRows(api, 'calc spans by [a]', gridRowsOpts).check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:r1 a:"X" b:"P" calc:"X"↧2 dep:"X"↧2 + ├── LEAF id:r2 a:"X" b:"Q" calc:"X"↥ dep:"X"↥ + └── LEAF id:r3 a:"Y" b:"Q" calc:"Y" dep:"Y" + `); + + updateCalculatedColumnDef(api, 'calc', { calculatedExpression: '[b]' }); + await asyncSetTimeout(1); + + await new GridRows(api, 'calc re-spans by [b] after expression edit', gridRowsOpts).check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:r1 a:"X" b:"P" calc:"P" dep:"P" + ├── LEAF id:r2 a:"X" b:"Q" calc:"Q"↧2 dep:"Q"↧2 + └── LEAF id:r3 a:"Y" b:"Q" calc:"Q"↥ dep:"Q"↥ + `); + }); + test('sorting, filtering and value formatters use evaluated values', async () => { const api = createGrid('calculated-sort-filter', { rowData: [ @@ -591,6 +626,44 @@ describe('ag-grid calculated columns', () => { `); }); + test('removing the sole calc column of a group destroys the column but keeps the (now-empty) group', async () => { + const api = createGrid('calc-empty-group', { + rowData: [{ id: 'r1', revenue: 10, cost: 3 }], + columnDefs: [ + { field: 'revenue' }, + { field: 'cost' }, + { + groupId: 'derived', + headerName: 'Derived', + children: [{ colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number' }], + }, + ] as (ColDef | ColGroupDef)[], + }); + await asyncSetTimeout(1); + + const profitBefore = api.getColumn('profit'); + expect(api.getProvidedColumnGroup('derived') === null).toBe(false); + expect(profitBefore === null).toBe(false); + + removeColumnDef(api, 'profit'); + await asyncSetTimeout(1); + + // The removed COLUMN is gone and destroyed, but the user-declared GROUP stays findable (now + // empty) — it must not be silently dropped. Compare booleans (not objects) so failures print + // cleanly. + expect(api.getColumn('profit') === null).toBe(true); + expect((profitBefore as unknown as { isAlive(): boolean }).isAlive()).toBe(false); + const derivedAfter = api.getProvidedColumnGroup('derived') as unknown as { children: unknown[] } | null; + expect(derivedAfter === null).toBe(false); + expect(derivedAfter!.children.length).toBe(0); + + await new GridColumns(api, 'column removed, empty group kept').checkColumns(` + CENTER + ├── revenue "Revenue" width:200 + └── cost "Cost" width:200 + `); + }); + test('grid api calculated column mutations do not mutate provided column definitions', async () => { const revenueColDef: ColDef = { field: 'revenue' }; const costColDef: ColDef = { field: 'cost' }; @@ -1678,8 +1751,7 @@ describe('ag-grid calculated columns', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('calc col anchored to the first of two auto-group cols after a grouping toggle', async () => { + test('calc col anchored to the first of two auto-group cols after a grouping toggle', async () => { const api = createGrid('calculated-autogroup-toggle-multi', { groupDisplayType: 'multipleColumns', rowData: [{ id: 'r1', productType: 'A', country: 'UK', revenue: 10, cost: 3 }], @@ -1973,8 +2045,7 @@ describe('ag-grid calculated columns', () => { expect(newColumnsLoaded).toHaveBeenCalledTimes(1); }); - // Solved by AG-17366 when it is completed - test.skip('removeCalculatedColumn then re-adding the same colId yields a working live column', async () => { + test('removeCalculatedColumn then re-adding the same colId yields a working live column', async () => { const api = createGrid('calc-col-readd-same-id', { rowData: [ { id: 'r1', revenue: 10, cost: 3 }, @@ -2012,29 +2083,6 @@ describe('ag-grid calculated columns', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('updateCalculatedColumn with an unchanged expression does NOT dispatch newColumnsLoaded', async () => { - const newColumnsLoaded = vi.fn(); - const api = createGrid('calc-col-noop-update', { - rowData: [{ id: 'r1', revenue: 10, cost: 3 }], - columnDefs: [{ field: 'revenue' }, { field: 'cost' }], - onNewColumnsLoaded: newColumnsLoaded, - }); - addCalculatedColumnDef(api, { colId: 'profit', calculatedExpression: '[revenue] - [cost]' }); - await asyncSetTimeout(1); - newColumnsLoaded.mockClear(); - - // Same expression — should be a no-op. - updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] - [cost]' }); - await asyncSetTimeout(1); - expect(newColumnsLoaded).not.toHaveBeenCalled(); - - // Different expression — should fire. - updateCalculatedColumnDef(api, 'profit', { calculatedExpression: '[revenue] * [cost]' }); - await asyncSetTimeout(1); - expect(newColumnsLoaded).toHaveBeenCalledTimes(1); - }); - 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 }], @@ -2466,7 +2514,7 @@ describe('ag-grid calculated columns', () => { async (_label, expressionPickers) => { const api = createGrid(`calculated-dialog-helper-lists-${_label.replace(' ', '-')}`, { calculatedColumns: { - expressionPickers, + expressionPickers: expressionPickers as any, }, rowData: [{ id: 'r1', revenue: 10, cost: 3 }], columnDefs: [{ field: 'revenue' }, { field: 'cost' }], @@ -3037,8 +3085,7 @@ describe('ag-grid calculated columns', () => { // update / remove a calc col. Without preserving the live display order in the colDefs it // passes through, runtime reorders (drag-drop / moveColumns / applyColumnState) reset to the // original setGridOption order on every calc-col mutation. - // Solved by AG-17366 when it is completed - test.skip('adding a calculated column preserves the current display order after moveColumns', async () => { + test('adding a calculated column preserves the current display order after moveColumns', async () => { const api = createGrid('calculated-cols-preserve-order', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], @@ -3078,8 +3125,7 @@ describe('ag-grid calculated columns', () => { // Same invariant with a column group: the group structure must survive the calc-col round-trip // and the runtime reorder must be preserved. - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn preserves group structure and reorder when columns are grouped', async () => { + test('addCalculatedColumn preserves group structure and reorder when columns are grouped', async () => { const api = createGrid('calc-cols-with-groups', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ groupId: 'G', headerName: 'G', children: [{ field: 'a' }, { field: 'b' }] }, { field: 'c' }], @@ -3115,8 +3161,7 @@ describe('ag-grid calculated columns', () => { // Same order-preservation invariant, but via `applyColumnState({ applyOrder: true })` instead // of `moveColumns`. Drives the same `colsList` mutation through a different code path — // guards that the lean variant's display-order sort sees the applied order. - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn preserves order set via applyColumnState({ applyOrder: true })', async () => { + test('addCalculatedColumn preserves order set via applyColumnState({ applyOrder: true })', async () => { const api = createGrid('calc-cols-preserve-applyOrder', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], @@ -3151,14 +3196,7 @@ describe('ag-grid calculated columns', () => { `); }); - // A column with `groupHierarchy` generates synthetic virtual columns alongside the source. - // Adding a calculated column triggers a `updateGridOptions({ columnDefs })` round-trip through - // the lean variant, which reads `col.userProvidedColDef ?? col.colDef` — for the virtuals - // (no user-provided def), this falls back to the synthetic merged def. After the round-trip - // the hierarchy service must still have a valid set of virtual columns AND the calc col must - // evaluate. - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn round-trip preserves groupHierarchy virtual columns', async () => { + test('addCalculatedColumn round-trip preserves groupHierarchy virtual columns', async () => { const api = createGrid('calc-cols-with-hierarchy', { rowData: [ { id: 'r1', country: 'USA', date: new Date(2020, 0, 1), amount: 10 }, @@ -3190,11 +3228,6 @@ describe('ag-grid calculated columns', () => { expect((yearVirtualAfter as any)!.isAlive()).toBe(true); expect((monthVirtualAfter as any)!.isAlive()).toBe(true); - // EXACTLY ONE set of hierarchy virtuals must exist. `latest` keeps virtuals in a - // separate `groupHierarchyColSvc.columns` collection so they never round-trip through - // `api.getColumnDefs()`. My branch's column-model rewrite merged them into `colDefList`, - // so without filtering they'd appear in factory output → fed back through - // `updateGridOptions({ columnDefs })` → `_1`-suffixed duplicates from `getUniqueKey`. const hierarchyCols = api .getAllGridColumns()! .filter((col) => col.getColId().startsWith('ag-Grid-HierarchyColumn-')); @@ -3214,10 +3247,19 @@ describe('ag-grid calculated columns', () => { ├── amount "Amount" width:200 └── doubled width:200 `); - await new GridRows(api, 'hierarchy + addCalculatedColumn rows', gridRowsOpts).check(` + await new GridRows(api, 'hierarchy + addCalculatedColumn rows', { + ...gridRowsOpts, + forcedColumns: [ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + 'country', + 'amount', + 'doubled', + ], + }).check(` ROOT id:ROOT_NODE_ID ag-Grid-HierarchyColumn-date-year:null ag-Grid-HierarchyColumn-date-month:null - ├── LEAF id:r1 ag-Grid-HierarchyColumn-date-year:"2020" ag-Grid-HierarchyColumn-date-month:"1" country:"USA" date:"2020-01-01T00:00:00.000Z" amount:10 doubled:20 - └── LEAF id:r2 ag-Grid-HierarchyColumn-date-year:"2021" ag-Grid-HierarchyColumn-date-month:"6" country:"UK" date:"2021-06-14T23:00:00.000Z" amount:20 doubled:40 + ├── LEAF id:r1 ag-Grid-HierarchyColumn-date-year:"2020" ag-Grid-HierarchyColumn-date-month:"1" country:"USA" amount:10 doubled:20 + └── LEAF id:r2 ag-Grid-HierarchyColumn-date-year:"2021" ag-Grid-HierarchyColumn-date-month:"6" country:"UK" amount:20 doubled:40 `); }); @@ -3226,8 +3268,7 @@ describe('ag-grid calculated columns', () => { // references via `colModel.getCol(ref)` (which falls back to field-name lookup), so the AST // parser must use the same lookup or validation accepts a reference that evaluation can't // resolve. Locks in parser/validator consistency. - // Solved by AG-17366 when it is completed - test.skip('calculated expression bracket-reference resolves a column by field when colId differs', async () => { + test('calculated expression bracket-reference resolves a column by field when colId differs', async () => { const api = createGrid('calc-bracket-field-ref', { rowData: [{ id: 'r1', revenue: 10 }], columnDefs: [ @@ -3278,8 +3319,7 @@ describe('ag-grid calculated columns', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn after moveColumns with maintainColumnOrder=false preserves reorder', async () => { + test('addCalculatedColumn after moveColumns with maintainColumnOrder=false preserves reorder', async () => { const api = createGrid('calc-maintain-false-move', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], @@ -3400,8 +3440,7 @@ describe('ag-grid calculated columns', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('addCalculatedColumn while pivot is active references primary columns', async () => { + test('addCalculatedColumn while pivot is active references primary columns', async () => { const api = createGrid('calc-with-pivot', { rowData: [ { id: 'r1', country: 'US', year: 2020, revenue: 10, cost: 3 }, @@ -3494,8 +3533,7 @@ describe('ag-grid calculated columns', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('moveColumns on a previously-added dynamic calc col preserves the move across subsequent adds', async () => { + test('moveColumns on a previously-added dynamic calc col preserves the move across subsequent adds', async () => { const api = createGrid('calc-move-then-add', { rowData: [{ id: 'r1', a: 1, b: 2, c: 3 }], columnDefs: [{ field: 'a' }, { field: 'b' }, { field: 'c' }], diff --git a/testing/behavioural/src/grid-state/grid-state.test.ts b/testing/behavioural/src/grid-state/grid-state.test.ts index eb057fc584b..f57a8af3627 100644 --- a/testing/behavioural/src/grid-state/grid-state.test.ts +++ b/testing/behavioural/src/grid-state/grid-state.test.ts @@ -503,6 +503,36 @@ describe('StateService - Grid State Management', () => { ); expect(api.getState().columnGroup).toEqual(undefined); }); + + test('should restore an open generated-id group from saved state', async () => { + // A generated-id (positional) group gets the same id when rebuilt from identical colDefs, + // so an open one round-trips through saved state like an explicit-id group would. + const columnDefs = [ + { headerName: 'G', children: [{ field: 'athlete' }, { field: 'country', columnGroupShow: 'open' }] }, + ]; + const api = gridsManager.createGrid('myGrid', { + columnDefs, + rowData: defaultRowData, + }); + + // The sole group carries a generated (numeric) id — no groupId was provided. + const groupId = api.getColumnGroupState()[0].groupId; + expect(/^\d+$/.test(groupId)).toBe(true); + + api.setColumnGroupOpened(groupId, true); + const savedState = api.getState(); + expect(savedState.columnGroup).toEqual({ openColumnGroupIds: [groupId] }); + + // Restore into a fresh grid from identical colDefs: the same positional id is regenerated. + const api2 = gridsManager.createGrid('target', { + columnDefs, + rowData: defaultRowData, + initialState: savedState, + }); + + expect(api2.getState().columnGroup).toEqual({ openColumnGroupIds: [groupId] }); + expect(api2.getColumnGroupState()[0].open).toBe(true); + }); }); // ===== ROW STATE TESTS ===== diff --git a/testing/behavioural/src/grouping-data/grouping-column-visibility-events.test.ts b/testing/behavioural/src/grouping-data/grouping-column-visibility-events.test.ts index b1ad53bdc10..66041032180 100644 --- a/testing/behavioural/src/grouping-data/grouping-column-visibility-events.test.ts +++ b/testing/behavioural/src/grouping-data/grouping-column-visibility-events.test.ts @@ -25,8 +25,7 @@ describe('grouping column visibility events', () => { return events; }; - // Solved by AG-17366 when it is completed - test.skip('grouping multiple columns fires a single columnVisible hiding them all', async () => { + test('grouping multiple columns fires a single columnVisible hiding them all', async () => { const api = gridsManager.createGrid('groupHideMany', { columnDefs: [{ field: 'country' }, { field: 'sport' }, { field: 'gold' }], rowData: [{ country: 'USA', sport: 'Swim', gold: 5 }], @@ -51,8 +50,7 @@ describe('grouping column visibility events', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('ungrouping fires a single columnVisible showing the cols', async () => { + test('ungrouping fires a single columnVisible showing the cols', async () => { const api = gridsManager.createGrid('ungroupShowMany', { columnDefs: [{ field: 'country' }, { field: 'sport' }, { field: 'gold' }], rowData: [{ country: 'USA', sport: 'Swim', gold: 5 }], @@ -91,6 +89,27 @@ describe('grouping column visibility events', () => { expect(events).toEqual([]); }); + test('sequential grouping never re-fires columnVisible for already-hidden cols', async () => { + const api = gridsManager.createGrid('groupSequential', { + columnDefs: [{ field: 'country' }, { field: 'sport' }, { field: 'gold' }], + rowData: [{ country: 'USA', sport: 'Swim', gold: 5 }], + }); + await asyncSetTimeout(0); + + const events = captureVisible(api); + api.addRowGroupColumns(['country']); + api.addRowGroupColumns(['sport']); + api.addRowGroupColumns(['gold']); + await asyncSetTimeout(0); + + // Each grouping auto-hides only the newly-grouped col; previously-hidden cols must not re-fire. + expect(events).toEqual([ + { visible: false, cols: ['country'] }, + { visible: false, cols: ['sport'] }, + { visible: false, cols: ['gold'] }, + ]); + }); + test('grouping a hierarchy column hides only the source col', async () => { const api = gridsManager.createGrid('groupHideHierarchy', { columnDefs: [ diff --git a/testing/behavioural/src/grouping-data/grouping-show-columns-when-expanded.test.ts b/testing/behavioural/src/grouping-data/grouping-show-columns-when-expanded.test.ts index 9e565b7562a..165d9e8a07f 100644 --- a/testing/behavioural/src/grouping-data/grouping-show-columns-when-expanded.test.ts +++ b/testing/behavioural/src/grouping-data/grouping-show-columns-when-expanded.test.ts @@ -939,7 +939,10 @@ describe('ag-grid groupHideColumnsUntilExpanded', () => { · · └── LEAF hidden id:4 country:"France" year:"2021" athlete:"Jean Dupont" gold:1 `); - // Enable feature + // Enable feature — `groupHideColumnsUntilExpanded` is owned by autoColSvc (visibility only), so + // columnModel no longer also refreshes: the toggle must be a single displayed-cols refresh. + let displayedRefreshes = 0; + api.addEventListener('displayedColumnsChanged', () => displayedRefreshes++); api.updateGridOptions({ groupHideColumnsUntilExpanded: true }); // Now only level 0 should be visible @@ -958,6 +961,10 @@ describe('ag-grid groupHideColumnsUntilExpanded', () => { · └─┬ LEAF_GROUP collapsed hidden id:row-group-country-France-year-2021 ag-Grid-AutoColumn-year:"2021" · · └── LEAF hidden id:4 country:"France" year:"2021" athlete:"Jean Dupont" gold:1 `); + + // Single refresh for the toggle (events are async — the awaited check above flushed them). + await asyncSetTimeout(0); + expect(displayedRefreshes).toBe(1); }); test('runtime toggle - turning option off restores all columns', async () => { @@ -1511,7 +1518,6 @@ describe('ag-grid groupHideColumnsUntilExpanded', () => { test.each([ ['rowData=[]', { rowData: [] }], - // Solved by AG-17366 when it is completed // ['rowData unspecified', {}], ])('groupHideColumnsUntilExpanded with %s — only level-0 auto col visible', async (_label, extraOpts) => { const api = gridsManager.createGrid(`hide-${_label}`, { diff --git a/testing/behavioural/src/grouping-data/pivot-column-defs-update.test.ts b/testing/behavioural/src/grouping-data/pivot-column-defs-update.test.ts index 4dd111c9fb6..3382746ecbf 100644 --- a/testing/behavioural/src/grouping-data/pivot-column-defs-update.test.ts +++ b/testing/behavioural/src/grouping-data/pivot-column-defs-update.test.ts @@ -474,8 +474,7 @@ describe('pivot column identity across columnDefs updates', () => { await checkDefaultCols(api, 'cols after callback recheck'); }); - // Solved by AG-17366 when it is completed - test.skip('custom properties on a pivot result colDef survive a no-op refresh and are wiped on actual change', async () => { + test('custom properties on a pivot result colDef survive a no-op refresh and are wiped on actual change', async () => { type ColDefWithCustom = ColDef & { myCustomProp?: string }; const liveDefs: ColDef[] = [ @@ -711,8 +710,7 @@ describe('pivot column identity across columnDefs updates', () => { expect(afterIds).not.toContain('year'); }); - // Solved by AG-17366 when it is completed - test.skip('pivot result cols dropped across a clear/restore window are destroyed (no bean leak)', async () => { + test('pivot result cols dropped across a clear/restore window are destroyed (no bean leak)', async () => { const api = gridsManager.createGrid('clearRestoreLeak', { columnDefs: baseColumnDefs, pivotMode: true, @@ -840,8 +838,7 @@ describe('pivot column identity across columnDefs updates', () => { expect((api.getPivotResultColumns() ?? []).length).toBeGreaterThan(0); }); - // Solved by AG-17366 when it is completed - test.skip('api.getColumn by field in pivot mode resolves primary col via lazy colsByDef map', async () => { + test('api.getColumn by field in pivot mode resolves primary col via lazy colsByDef map', async () => { const api = gridsManager.createGrid('pivotFieldLookup', { columnDefs: [ { colId: 'countryCol', field: 'country', rowGroup: true, hide: true }, diff --git a/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts b/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts index 96d3df0bd4a..3ed6c4bc111 100644 --- a/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts +++ b/testing/behavioural/src/grouping-data/pivot-group-hierarchy.test.ts @@ -97,6 +97,40 @@ describe('pivot with groupHierarchy (date-time)', () => { }; }; + test('quarter date-part groups each month into the correct quarter', async () => { + const api = gridsManager.createGrid('quarterHierarchy', { + columnDefs: [ + { field: 'date', rowGroup: true, hide: true, groupHierarchy: ['quarter'] }, + { field: 'v', aggFunc: 'sum' }, + ], + groupDefaultExpanded: -1, + getRowId: ({ data }) => data.id, + }); + applyTransactionChecked(api, { + add: [ + { id: 'mar', date: new Date(2020, 2, 1), v: 1 }, // month 3 → Q1 + { id: 'jul', date: new Date(2020, 6, 1), v: 1 }, // month 7 → Q3 + { id: 'oct', date: new Date(2020, 9, 1), v: 1 }, // month 10 → Q4 + ], + }); + await asyncSetTimeout(1); + + // Quarter is derived from the 1-based month: Q1=1-3, Q2=4-6, Q3=7-9, Q4=10-12. + // March → Q1, July → Q3, October → Q4 (the months that the old `/4` math mis-bucketed). + await new GridRows(api, 'quarter groups', { forcedColumns: ['ag-Grid-AutoColumn'] }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-ag-Grid-HierarchyColumn-date-quarter-1 ag-Grid-AutoColumn:"1" + │ └─┬ LEAF_GROUP id:row-group-ag-Grid-HierarchyColumn-date-quarter-1-date-2020-03-01 ag-Grid-AutoColumn:"2020-03-01" + │ · └── LEAF id:mar + ├─┬ filler id:row-group-ag-Grid-HierarchyColumn-date-quarter-3 ag-Grid-AutoColumn:"3" + │ └─┬ LEAF_GROUP id:row-group-ag-Grid-HierarchyColumn-date-quarter-3-date-2020-07-01 ag-Grid-AutoColumn:"2020-07-01" + │ · └── LEAF id:jul + └─┬ filler id:row-group-ag-Grid-HierarchyColumn-date-quarter-4 ag-Grid-AutoColumn:"4" + · └─┬ LEAF_GROUP id:row-group-ag-Grid-HierarchyColumn-date-quarter-4-date-2020-10-01 ag-Grid-AutoColumn:"2020-10-01" + · · └── LEAF id:oct + `); + }); + test('pivot by date column creates hierarchy columns (year -> month)', async () => { const api = createPivotDateTimeGrid(); @@ -154,6 +188,43 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); + test('removing the pivot source col leaves year/month as pivot dimensions (result regroups to year -> month)', async () => { + const api = createPivotDateTimeGrid(); + api.setPivotColumns(['date']); + await asyncSetTimeout(0); + + // [year, month, date] are the pivot dimensions. + expect(api.getPivotColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + 'date', + ]); + + api.removePivotColumns(['date']); + await asyncSetTimeout(0); + expect(api.getPivotColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + ]); + + await new GridColumns(api, 'pivot result after removing the date source dimension').checkColumns(` + CENTER + ├── ag-Grid-AutoColumn "Group" width:200 + ├─┬ "2000" GROUP closed + │ ├─┬ "10" GROUP hidden + │ │ └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2000-10_total "Total" width:200 columnGroupShow:open hidden + │ ├─┬ "11" GROUP hidden + │ │ └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2000-11_total "Total" width:200 columnGroupShow:open hidden + │ └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2000_total "Total" width:200 columnGroupShow:closed + └─┬ "2001" GROUP closed + ├─┬ "1" GROUP hidden + │ └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2001-1_total "Total" width:200 columnGroupShow:open hidden + ├─┬ "6" GROUP hidden + │ └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2001-6_total "Total" width:200 columnGroupShow:open hidden + └── pivot_ag-Grid-HierarchyColumn-date-year-ag-Grid-HierarchyColumn-date-month_2001_total "Total" width:200 columnGroupShow:closed + `); + }); + test('setPivotColumns toggles pivot result columns', async () => { const api = createPivotDateTimeGrid(); @@ -298,8 +369,7 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('re-setting identical columnDefs does not leave destroyed hierarchy columns', async () => { + test('re-setting identical columnDefs does not leave destroyed hierarchy columns', async () => { const api = createPivotDateTimeGrid(); api.setPivotColumns(['date']); await asyncSetTimeout(0); @@ -345,17 +415,7 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); - /** - * Locks in the sort behaviour of `GroupHierarchyColService.compareVirtualColumns` when both - * a source col and one of its virtual cols are simultaneously row-grouped. The virtual cols - * must sort BEFORE the source col, and virtual cols from the same source must keep their - * insertion-order within that source's bucket. - */ test('virtual cols sort before their source col when both are row-grouped', async () => { - // A date col with `groupHierarchy: ['year', 'month']` AND `rowGroup: true` makes the - // source col plus both virtual cols (year, month) eligible to be row-group cols. The - // sort comparator in BaseColsService.sortColumns delegates to - // GroupHierarchyColService.compareVirtualColumns for these pairs. const api = gridsManager.createGrid('hierarchyRowGroup', { columnDefs: [{ field: 'country' }, { field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }], rowData: [ @@ -366,11 +426,6 @@ describe('pivot with groupHierarchy (date-time)', () => { }); await asyncSetTimeout(0); - // Expected order in row-group cols list: [year-virtual, month-virtual, date-source]. - // The compareVirtualColumns: - // - returns -1 for (year, date) since year is virtual-of date → year before date - // - returns -1 for (month, date) since month is virtual-of date → month before date - // - returns insertion-order for (year, month) within date's bucket → year before month const rowGroupCols = api.getRowGroupColumns().map((c) => c.getColId()); const yearIdx = rowGroupCols.findIndex((id) => id.includes('-date-year')); const monthIdx = rowGroupCols.findIndex((id) => id.includes('-date-month')); @@ -381,7 +436,6 @@ describe('pivot with groupHierarchy (date-time)', () => { expect(yearIdx).toBeLessThan(monthIdx); expect(monthIdx).toBeLessThan(dateIdx); - // Sanity: GridColumns snapshot of the displayed structure. await new GridColumns(api, 'date hierarchy as row groups').checkColumns(` CENTER ├── ag-Grid-AutoColumn-ag-Grid-HierarchyColumn-date-year "Date (Year)" width:200 @@ -392,8 +446,155 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('two independent hierarchy sources keep each virtual run ordered before its own source', async () => { + test('removing a hierarchy source col leaves its virtual cols grouped (virtuals are independent columns)', async () => { + const api = gridsManager.createGrid('hierarchyDeactivate', { + columnDefs: [{ field: 'country' }, { field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }], + rowData: [ + { country: 'USA', date: new Date(2020, 0, 1) }, + { country: 'UK', date: new Date(2021, 5, 15) }, + ], + groupDisplayType: 'multipleColumns', + }); + await asyncSetTimeout(0); + + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + 'date', + ]); + + api.removeRowGroupColumns(['date']); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + ]); + + api.addRowGroupColumns(['date']); + await asyncSetTimeout(0); + api.applyColumnState({ state: [{ colId: 'date', rowGroup: false }] }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + ]); + }); + + test('clearing all row-group columns also clears the hierarchy virtuals (setRowGroupColumns([]))', async () => { + const api = gridsManager.createGrid('hierarchyClearAll', { + columnDefs: [{ field: 'country' }, { field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }], + rowData: [ + { country: 'USA', date: new Date(2020, 0, 1) }, + { country: 'UK', date: new Date(2021, 5, 15) }, + ], + groupDisplayType: 'multipleColumns', + }); + await asyncSetTimeout(0); + + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + 'date', + ]); + + api.setRowGroupColumns([]); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([]); + }); + + test('changing an inline hierarchy part def refreshes the hierarchy column in place (same colId)', async () => { + const api = gridsManager.createGrid('hierarchyInlineRefresh', { + columnDefs: [{ field: 'date', rowGroup: true, groupHierarchy: [{ colId: 'y', headerName: 'Year A' }] }], + rowData: [{ date: new Date(2020, 0, 1) }], + }); + await asyncSetTimeout(0); + const before = api.getColumn('y')!; + expect(before.getColDef().headerName).toBe('Year A'); + + // Same colId 'y', changed header: the existing hierarchy col's def must be reapplied (not left stale, + // and not rebuilt — same instance). + api.setGridOption('columnDefs', [ + { field: 'date', rowGroup: true, groupHierarchy: [{ colId: 'y', headerName: 'Year B' }] }, + ]); + await asyncSetTimeout(0); + expect(api.getColumn('y')).toBe(before); + expect(api.getColumn('y')!.getColDef().headerName).toBe('Year B'); + }); + + test('every canonical date part extracts the expected value', async () => { + const api = gridsManager.createGrid('hierarchyAllParts', { + columnDefs: [ + { + field: 'date', + enableRowGroup: true, + groupHierarchy: ['year', 'quarter', 'month', 'formattedMonth', 'day', 'hour', 'minute', 'second'], + }, + ], + rowData: [{ date: new Date(2021, 6, 15, 14, 30, 45) }], // 15 July 2021, 14:30:45 + }); + await asyncSetTimeout(0); + const node = api.getDisplayedRowAtIndex(0)!; + const val = (part: string) => + api.getCellValue({ rowNode: node, colKey: `ag-Grid-HierarchyColumn-date-${part}` }); + expect(val('year')).toBe('2021'); + expect(val('quarter')).toBe('3'); // July is in Q3 (months 7-9) + expect(val('month')).toBe('7'); + expect(val('formattedMonth')).toBe('July'); + expect(val('day')).toBe('15'); + expect(val('hour')).toBe('14'); + expect(val('minute')).toBe(':30'); // `_getDateParts` formats minute/second with a leading colon + expect(val('second')).toBe(':45'); + + const parts = ['year', 'quarter', 'month', 'formattedMonth', 'day', 'hour', 'minute', 'second']; + const forcedColumns = parts.map((p) => `ag-Grid-HierarchyColumn-date-${p}`); + await new GridColumns(api, 'all date-part hierarchy columns').checkColumns(` + CENTER + └── date "Date" width:200 + `); + await new GridRows(api, 'all date-part values', { forcedColumns }).check(` + ROOT id:ROOT_NODE_ID ag-Grid-HierarchyColumn-date-year:null ag-Grid-HierarchyColumn-date-quarter:null ag-Grid-HierarchyColumn-date-month:null ag-Grid-HierarchyColumn-date-formattedMonth:null ag-Grid-HierarchyColumn-date-day:null ag-Grid-HierarchyColumn-date-hour:null ag-Grid-HierarchyColumn-date-minute:null ag-Grid-HierarchyColumn-date-second:null + └── LEAF id:0 ag-Grid-HierarchyColumn-date-year:"2021" ag-Grid-HierarchyColumn-date-quarter:"3" ag-Grid-HierarchyColumn-date-month:"7" ag-Grid-HierarchyColumn-date-formattedMonth:"July" ag-Grid-HierarchyColumn-date-day:"15" ag-Grid-HierarchyColumn-date-hour:"14" ag-Grid-HierarchyColumn-date-minute:":30" ag-Grid-HierarchyColumn-date-second:":45" + `); + }); + + test('changing defaultColDef refreshes the hierarchy column defs in place', async () => { + const api = gridsManager.createGrid('hierarchyDefaultColDef', { + columnDefs: [{ field: 'date', rowGroup: true, groupHierarchy: ['year'] }], + defaultColDef: { width: 150 }, + rowData: [{ date: new Date(2020, 0, 1) }], + }); + await asyncSetTimeout(0); + const col = api.getColumn('ag-Grid-HierarchyColumn-date-year')!; + expect(col.getColDef().width).toBe(150); + + api.setGridOption('defaultColDef', { width: 250 }); + await asyncSetTimeout(0); + expect(api.getColumn('ag-Grid-HierarchyColumn-date-year')).toBe(col); // reused, not rebuilt + expect(api.getColumn('ag-Grid-HierarchyColumn-date-year')!.getColDef().width).toBe(250); + }); + + test('adding a hierarchy part at runtime creates the new column (plan grows)', async () => { + const api = gridsManager.createGrid('hierarchyPlanGrow', { + columnDefs: [{ field: 'date', rowGroup: true, groupHierarchy: ['year'] }], + rowData: [{ date: new Date(2020, 0, 1) }], + groupDisplayType: 'multipleColumns', + }); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'date', + ]); + + api.setGridOption('columnDefs', [{ field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }]); + await asyncSetTimeout(0); + expect(api.getRowGroupColumns().map((c) => c.getColId())).toEqual([ + 'ag-Grid-HierarchyColumn-date-year', + 'ag-Grid-HierarchyColumn-date-month', + 'date', + ]); + }); + + test('two independent hierarchy sources keep each virtual run ordered before its own source', async () => { const api = gridsManager.createGrid('twoHierarchies', { columnDefs: [ { field: 'country' }, @@ -476,8 +677,7 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('applyColumnState row-group ordering sorts mixed hierarchy + plain cols correctly', async () => { + test('applyColumnState row-group ordering sorts mixed hierarchy + plain cols correctly', async () => { const api = gridsManager.createGrid('stateOrder', { columnDefs: [ { field: 'country' }, @@ -930,8 +1130,7 @@ describe('pivot with groupHierarchy (date-time)', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('inline ColDef hierarchy part materialises only when it has an explicit colId', async () => { + test('inline ColDef hierarchy part materialises only when it has an explicit colId', async () => { const api = gridsManager.createGrid('hierarchyInlineColDef', { columnDefs: [ { @@ -1170,4 +1369,47 @@ describe('pivot with groupHierarchy (date-time)', () => { └── date "Date" width:200 `); }); + + test('hierarchy columns coexist with grouped column headers (depth > 0)', async () => { + const api = gridsManager.createGrid('hierarchyWithColGroups', { + columnDefs: [ + { + headerName: 'Group A', + children: [{ field: 'country' }, { field: 'sport' }], + }, + { + headerName: 'Group B', + children: [ + { field: 'date', rowGroup: true, groupHierarchy: ['year', 'month'] }, + { field: 'total' }, + ], + }, + ], + rowData: [ + { country: 'USA', sport: 'Swimming', date: new Date(2020, 0, 1), total: 1 }, + { country: 'UK', sport: 'Running', date: new Date(2021, 5, 15), total: 2 }, + ], + groupDisplayType: 'multipleColumns', + }); + await asyncSetTimeout(0); + + const hierarchyIds = api + .getAllGridColumns() + .map((c) => c.getColId()) + .filter((id) => id.startsWith('ag-Grid-HierarchyColumn-date')); + expect(hierarchyIds).toEqual(['ag-Grid-HierarchyColumn-date-year', 'ag-Grid-HierarchyColumn-date-month']); + + await new GridColumns(api, 'hierarchy cols with grouped column headers').checkColumns(` + CENTER + ├── ag-Grid-AutoColumn-ag-Grid-HierarchyColumn-date-year "Date (Year)" width:200 + ├── ag-Grid-AutoColumn-ag-Grid-HierarchyColumn-date-month "Date (Month)" width:200 + ├── ag-Grid-AutoColumn-date "Date" width:200 + ├─┬ "Group A" GROUP + │ ├── country "Country" width:200 + │ └── sport "Sport" width:200 + └─┬ "Group B" GROUP + ├── date "Date" width:200 rowGroup + └── total "Total" width:200 + `); + }); }); diff --git a/testing/behavioural/src/selection/row-numbers-selection.test.ts b/testing/behavioural/src/selection/row-numbers-selection.test.ts index ae356af5410..4becf99180d 100644 --- a/testing/behavioural/src/selection/row-numbers-selection.test.ts +++ b/testing/behavioural/src/selection/row-numbers-selection.test.ts @@ -1602,8 +1602,7 @@ describe('Row Numbers Keyboard Navigation', () => { `); }); - // Solved by AG-17366 when it is completed - test.skip('rowNumbers options mutated at runtime propagate via updateColumns', async () => { + test('rowNumbers options mutated at runtime propagate via updateColumns', async () => { const api = await createGrid({ columnDefs, rowData, @@ -1680,3 +1679,82 @@ describe('Row Numbers Keyboard Navigation', () => { expect(api.getDisplayedRightColumns().map((c) => c.getColId())).toContain(ROW_NUMBERS_COLUMN_ID); }); }); + +describe('Row Numbers refresh coalescing', () => { + const gridMgr = new TestGridsManager({ modules: [ClientSideRowModelModule, RowNumbersModule] }); + + afterEach(() => gridMgr.reset()); + + function countRefreshes(api: GridApi): { displayed: number } { + const counts = { displayed: 0 }; + api.addEventListener('displayedColumnsChanged', () => counts.displayed++); + return counts; + } + + test('changing a rowNumbers def prop refreshes once, not twice', async () => { + const api = gridMgr.createGrid('myGrid', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ a: 1, b: 2 }], + rowNumbers: { width: 60 }, + }); + await new GridColumns(api, 'rowNumbers width 60').checkColumns(` + LEFT + └── ag-Grid-RowNumbersColumn width:60 !resizable !sortable suppressMovable lockPosition:left + CENTER + ├── a "A" width:200 + └── b "B" width:200 + `); + await new GridRows(api, 'rowNumbers width 60').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:0 row-number:"1" a:1 b:2 + `); + const counts = countRefreshes(api); + + api.setGridOption('rowNumbers', { width: 90 }); + await asyncSetTimeout(20); + await new GridColumns(api, 'rowNumbers width 90').checkColumns(` + LEFT + └── ag-Grid-RowNumbersColumn width:90 !resizable !sortable suppressMovable lockPosition:left + CENTER + ├── a "A" width:200 + └── b "B" width:200 + `); + + // Single pass: rowNumbersSvc owns the refresh (columnModel no longer also refreshes on `rowNumbers`). + expect(counts.displayed).toBe(1); + expect(api.getColumn(ROW_NUMBERS_COLUMN_ID)!.getActualWidth()).toBe(90); + }); + + test('toggling rowNumbers on then off adds/removes the column in a single refresh each', async () => { + const api = gridMgr.createGrid('myGrid', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ a: 1, b: 2 }], + }); + expect(api.getColumn(ROW_NUMBERS_COLUMN_ID)).toBeNull(); + + const counts = countRefreshes(api); + api.setGridOption('rowNumbers', true); + await asyncSetTimeout(20); + expect(api.getColumn(ROW_NUMBERS_COLUMN_ID)).not.toBeNull(); + expect(counts.displayed).toBe(1); + await new GridColumns(api, 'rowNumbers toggled on').checkColumns(` + LEFT + └── ag-Grid-RowNumbersColumn width:60 !resizable !sortable suppressMovable lockPosition:left + CENTER + ├── a "A" width:200 + └── b "B" width:200 + `); + + api.setGridOption('rowNumbers', false); + await asyncSetTimeout(20); + expect(api.getColumn(ROW_NUMBERS_COLUMN_ID)).toBeNull(); + // Exactly one displayed-cols refresh per toggle (2 total). + expect(counts.displayed).toBe(2); + expect(api.getAllGridColumns().map((c) => c.getColId())).toEqual(['a', 'b']); + await new GridColumns(api, 'rowNumbers toggled off').checkColumns(` + CENTER + ├── a "A" width:200 + └── b "B" width:200 + `); + }); +}); diff --git a/testing/behavioural/src/selection/selection-column-autohide.test.ts b/testing/behavioural/src/selection/selection-column-autohide.test.ts new file mode 100644 index 00000000000..7b14a49833a --- /dev/null +++ b/testing/behavioural/src/selection/selection-column-autohide.test.ts @@ -0,0 +1,110 @@ +import type { GridApi } from 'ag-grid-community'; +import { ClientSideRowModelModule, RowSelectionModule } from 'ag-grid-community'; +import { RowNumbersModule } from 'ag-grid-enterprise'; + +import { TestGridsManager, asyncSetTimeout } from '../test-utils'; + +const SELECTION_COL = 'ag-Grid-SelectionColumn'; +const ROW_NUMBERS_COL = 'ag-Grid-RowNumbersColumn'; + +// A lone selection (checkbox) column adds nothing, so when the only displayed column would be the +// selection col (plus optional row-numbers) it auto-hides. This must be consistent across BOTH the +// body (displayed leaf cols) and the header (displayed column-group tree) — a regression had the body +// drop it but the header keep a phantom padding-wrapped entry at depth>0. +describe('selection column auto-hide', () => { + const mgr = new TestGridsManager({ modules: [RowSelectionModule, RowNumbersModule, ClientSideRowModelModule] }); + afterEach(() => mgr.reset()); + + const bodyColIds = (api: GridApi): string[] => api.getAllDisplayedColumns().map((c) => c.getColId()); + + // Flatten the displayed header tree (groups + padding) to its leaf colIds. + const headerColIds = (api: GridApi): string[] => { + const ids: string[] = []; + const visit = (item: any): void => { + if (typeof item.getDisplayedChildren === 'function') { + item.getDisplayedChildren().forEach(visit); + } else { + ids.push(item.getColId()); + } + }; + [ + ...(api.getLeftDisplayedColumnGroups?.() ?? []), + ...(api.getCenterDisplayedColumnGroups?.() ?? []), + ...(api.getRightDisplayedColumnGroups?.() ?? []), + ].forEach(visit); + return ids; + }; + + test('hiding all grouped data cols removes the selection col from body AND header (depth > 0)', async () => { + const api = mgr.createGrid('g', { + columnDefs: [{ headerName: 'G', children: [{ field: 'a' }, { field: 'b' }] }], + rowData: [{ a: 1, b: 2 }], + rowSelection: { mode: 'multiRow' }, + }); + await asyncSetTimeout(1); + + expect(bodyColIds(api)).toContain(SELECTION_COL); + expect(headerColIds(api)).toContain(SELECTION_COL); + + api.setColumnsVisible(['a', 'b'], false); + await asyncSetTimeout(1); + + // Body and header agree: the lone selection col is gone from both. + expect(bodyColIds(api)).toHaveLength(0); + expect(headerColIds(api)).not.toContain(SELECTION_COL); + + api.setColumnsVisible(['a', 'b'], true); + await asyncSetTimeout(1); + + // Restored to both representations. + expect(bodyColIds(api)).toEqual(expect.arrayContaining([SELECTION_COL, 'a', 'b'])); + expect(headerColIds(api)).toContain(SELECTION_COL); + }); + + test('hiding all flat data cols removes the selection col from body AND header (depth 0)', async () => { + const api = mgr.createGrid('g', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ a: 1, b: 2 }], + rowSelection: { mode: 'multiRow' }, + }); + await asyncSetTimeout(1); + + expect(bodyColIds(api)).toContain(SELECTION_COL); + + api.setColumnsVisible(['a', 'b'], false); + await asyncSetTimeout(1); + + expect(bodyColIds(api)).toHaveLength(0); + expect(headerColIds(api)).not.toContain(SELECTION_COL); + + api.setColumnsVisible(['a', 'b'], true); + await asyncSetTimeout(1); + + expect(bodyColIds(api)).toContain(SELECTION_COL); + }); + + test('selection col stays when at least one data col remains (with row-numbers present)', async () => { + const api = mgr.createGrid('g', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ a: 1, b: 2 }], + rowSelection: { mode: 'multiRow' }, + rowNumbers: true, + }); + await asyncSetTimeout(1); + + expect(bodyColIds(api)).toContain(ROW_NUMBERS_COL); + + // Hide only one data col — a real col still shows, so the selection col stays. + api.setColumnsVisible(['a'], false); + await asyncSetTimeout(1); + expect(bodyColIds(api)).toContain(SELECTION_COL); + expect(bodyColIds(api)).toContain('b'); + + // Hide the last data col — now only selection + row-numbers would remain → selection auto-hides + // while the row-numbers col stays. + api.setColumnsVisible(['b'], false); + await asyncSetTimeout(1); + expect(bodyColIds(api)).not.toContain(SELECTION_COL); + expect(bodyColIds(api)).toContain(ROW_NUMBERS_COL); + }); +}); diff --git a/testing/behavioural/src/sorting/sort-service.test.ts b/testing/behavioural/src/sorting/sort-service.test.ts index ca628d7225b..3a53c4a8a57 100644 --- a/testing/behavioural/src/sorting/sort-service.test.ts +++ b/testing/behavioural/src/sorting/sort-service.test.ts @@ -180,6 +180,34 @@ describe('SortService', () => { `); }); + test('multi-sort with sort but no sortIndex falls back to colDef order', async () => { + const api = gridMgr.createGrid('g', { + columnDefs: [ + { colId: 'a', field: 'a', sort: 'asc' }, + { colId: 'b', field: 'b', sort: 'asc' }, + ], + rowData: [ + { id: '1', a: 'x', b: 'b' }, + { id: '2', a: 'x', b: 'a' }, + { id: '3', a: 'a', b: 'z' }, + ], + getRowId: (p) => p.data.id, + }); + await asyncSetTimeout(0); + + expect(getSortModel(api)).toEqual([ + { colId: 'a', sort: 'asc' }, + { colId: 'b', sort: 'asc' }, + ]); + + await new GridRows(api, 'multi-sort asc/asc no sortIndex').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:3 a:"a" b:"z" + ├── LEAF id:2 a:"x" b:"a" + └── LEAF id:1 a:"x" b:"b" + `); + }); + test('changing sort direction on secondary column reorders rows', async () => { const api = gridMgr.createGrid('g', { columnDefs: [ @@ -228,6 +256,46 @@ describe('SortService', () => { └── b "B" width:200 sort:desc sortIndex:1 `); }); + + test('swapping only sortIndex reorders rows', async () => { + const api = gridMgr.createGrid('g', { + columnDefs: [ + { colId: 'a', field: 'a' }, + { colId: 'b', field: 'b' }, + { colId: 'c', field: 'c' }, + ], + rowData, + getRowId: (p) => p.data.id, + }); + + // a primary: a-asc orders 2 -> 3 -> 1. + api.applyColumnState({ + state: [ + { colId: 'a', sort: 'asc', sortIndex: 0 }, + { colId: 'b', sort: 'asc', sortIndex: 1 }, + ], + }); + await new GridRows(api, 'a primary (asc)').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:2 a:"a" b:"x" c:1 + ├── LEAF id:3 a:"m" b:"a" c:9 + └── LEAF id:1 a:"z" b:"m" c:5 + `); + + // Swap indices only — b primary now: b-asc orders 3 -> 1 -> 2. + api.applyColumnState({ + state: [ + { colId: 'a', sort: 'asc', sortIndex: 1 }, + { colId: 'b', sort: 'asc', sortIndex: 0 }, + ], + }); + await new GridRows(api, 'b primary (asc) after sortIndex swap').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:3 a:"m" b:"a" c:9 + ├── LEAF id:1 a:"z" b:"m" c:5 + └── LEAF id:2 a:"a" b:"x" c:1 + `); + }); }); describe('cache invalidation on column changes', () => { diff --git a/testing/behavioural/src/sorting/sorting.test.ts b/testing/behavioural/src/sorting/sorting.test.ts index b7d3590d172..77d36822d97 100644 --- a/testing/behavioural/src/sorting/sorting.test.ts +++ b/testing/behavioural/src/sorting/sorting.test.ts @@ -60,6 +60,34 @@ describe('Sorting', () => { gridMgr.reset(); }); + test('the sort-order index badge shows only in a multi-column sort', async () => { + const api = await gridMgr.createGridAndWait('sort-order-badge', { + columnDefs: [{ field: 'a' }, { field: 'b' }], + rowData: [{ a: 1, b: 2 }], + }); + await asyncSetTimeout(0); + const gridDiv = getGridElement(api)! as HTMLElement; + const sortOrderEl = (colId: string) => + getByTestId(gridDiv, agTestIdFor.headerCell(colId)).querySelector('.ag-sort-order'); + + // Single-column sort: no ordinal badge (the lone sorted column needs no priority number). + api.applyColumnState({ state: [{ colId: 'a', sort: 'asc' }], defaultState: { sort: null } }); + await asyncSetTimeout(1); + expect(sortOrderEl('a')?.classList.contains('ag-hidden')).toBe(true); + + // Multi-column sort: every sorted column shows its 1-based priority. + api.applyColumnState({ + state: [ + { colId: 'a', sort: 'asc', sortIndex: 0 }, + { colId: 'b', sort: 'asc', sortIndex: 1 }, + ], + }); + await asyncSetTimeout(1); + expect(sortOrderEl('a')?.classList.contains('ag-hidden')).toBe(false); + expect(sortOrderEl('a')?.textContent).toBe('1'); + expect(sortOrderEl('b')?.textContent).toBe('2'); + }); + const columnDefs = [{ field: 'sport', sortable: false }, { field: 'year' }, { field: 'amount' }, { field: 'day' }]; const rowData = [ { sport: 'football', year: 2021, amount: 43, day: 'monday' }, diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts b/testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts index 4e5ab87fa21..eae21c08595 100644 --- a/testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts +++ b/testing/behavioural/src/test-utils/gridColumns/columns-diagram/formatting.ts @@ -62,8 +62,7 @@ export function columnDiagram(col: Column, api: GridApi, isHidden: boolean): str } } - // Aggregation - const aggFunc = col.getAggFunc(); + const aggFunc = col.isValueActive() ? col.getAggFunc() : null; if (aggFunc != null) { parts.push('aggFunc:' + (typeof aggFunc === 'string' ? aggFunc : 'custom')); } diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts b/testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts index 4c9f86558ab..75f2b1c5ebf 100644 --- a/testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts +++ b/testing/behavioural/src/test-utils/gridColumns/columns-validation-dom/gridColumnsDomValidator.ts @@ -497,8 +497,8 @@ export class GridColumnsDomValidator { } // ── aria-colindex attribute ────────────────────────────────────── - // For groups, expected aria-colindex is the FIRST leaf col's index (matches - // `VisibleColsService.getAriaColIndex` for groups). + // For groups, expected aria-colindex is the FIRST leaf col's index — a group header + // spans its leaves, so the leftmost leaf's index applies. const ariaColIndex = headerCell.getAttribute('aria-colindex'); const firstLeaf = group.getLeafColumns()[0]; const expectedIndex = firstLeaf ? expectedAriaColIndex.get(firstLeaf) : undefined; @@ -680,8 +680,8 @@ function isCoupledSortMode(api: GridApi): boolean { /** Compute the expected `aria-colindex` (1-based) for every column in `colsList`, partitioning * by pinned section: `[left-pinned, center, right-pinned]`. Includes hidden cols (they still * occupy aria-colindex slots — keeps aria-colcount and aria-colindex consistent for screen - * readers). Mirrors `VisibleColsService.stampHeaderIndexes` so the DOM validator catches any - * drift between the rendered attribute and the canonical ordering. */ + * readers). This is the black-box expected ordering; the validator asserts the rendered DOM + * attribute matches it, catching drift independently of how the grid computes it. */ function buildExpectedAriaColIndex(api: GridApi): Map { const cols = api.getAllGridColumns() ?? []; const expected = new Map(); diff --git a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts b/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts index a886e84f675..6e7da97957f 100644 --- a/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts +++ b/testing/behavioural/src/test-utils/gridColumns/columns-validation/gridColumnsValidator.ts @@ -371,7 +371,7 @@ export class GridColumnsValidator { const check = (tree: (Column | ColumnGroup)[], flat: Column[], label: 'left' | 'center' | 'right'): void => { const leaves: Column[] = []; this.collectLeaves(tree, leaves); - if (leaves.length !== flat.length && this.bugs.treeLeavesMatchFlatArray) { + if (leaves.length !== flat.length) { this.errors.default.add( `${label} tree has ${leaves.length} leaf columns but ${label}Cols flat array has ${flat.length}.` ); @@ -716,7 +716,7 @@ export class GridColumnsValidator { for (let i = 0, len = stateArr.length; i < len; ++i) { const colId = stateArr[i].colId; const found = api.getColumn(colId); - if (!found && this.bugs.columnStateColsMustExist) { + if (!found) { // Auto-group columns and other generated columns may appear in state without being looked up // by id from `getColumn` in some legacy modes — only flag if the column id looks like a regular // user-defined column (not ag-Grid-auto- prefix). @@ -975,16 +975,9 @@ export class GridColumnsValidator { } // ── Aggregation function consistency ──────────────────────────────── - // `getAggFunc()` reflects runtime state; it should be cleared when the column leaves the - // active value list. `colDef.aggFunc` (the initial config) is allowed to persist separately. - const aggFunc = col.getAggFunc(); - const isValueActive = col.isValueActive(); - if (isValueActive && aggFunc == null) { + if (col.isValueActive() && col.getAggFunc() == null) { colErrors.add('isValueActive() is true but getAggFunc() is null.'); } - if (!isValueActive && aggFunc != null) { - colErrors.add(`isValueActive() is false but getAggFunc() is "${String(aggFunc)}".`); - } // ── Filter consistency ────────────────────────────────────────────── if (col.isFilterActive() && col.isFilterAllowed?.() === false) { diff --git a/testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts b/testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts index 0e32c6ca165..73f49d640bb 100644 --- a/testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts +++ b/testing/behavioural/src/test-utils/gridColumns/gridColumnsOptions.ts @@ -48,29 +48,7 @@ export interface GridColumnsOptions { * Tests can override individual flags via `GridColumnsOptions.bugs` to enable or disable * validations on a per-test basis. */ -export const gridColumnsBugs = { - /** - * BUG: an auto-hidden selection / row-number column is left in the column tree after every user - * column is hidden, so the section tree has more leaves than the flat column array. - * Gates `validateTreeMatchesFlat`. - * Solved by AG-17366 when it is completed. - */ - treeLeavesMatchFlatArray: false, - - /** - * BUG: `getColumnState()` retains entries for columns that no longer exist (e.g. pivot result - * colIds dropped when leaving pivot mode), so a state colId resolves to null via `api.getColumn()`. - * Gates the column-state ↔ `getColumn` consistency check. - * Solved by AG-17366 when it is completed. - */ - columnStateEntriesExist: false, - - /** - * Additional check for column state entries that verifies the colId exists in the grid. - * Solved by AG-17366 when it is completed. - */ - columnStateColsMustExist: false, -} as const; +export const gridColumnsBugs = {} as const; /** The type of the known bugs configuration object. */ export type GridColumnsBugs = { -readonly [K in keyof typeof gridColumnsBugs]: boolean }; diff --git a/testing/behavioural/src/test-utils/utils.ts b/testing/behavioural/src/test-utils/utils.ts index d1478981661..8983ed7ab34 100644 --- a/testing/behavioural/src/test-utils/utils.ts +++ b/testing/behavioural/src/test-utils/utils.ts @@ -110,19 +110,18 @@ export function unindentText(text: TemplateStringsArray | string | string[] | nu return lines.join('\n'); } -let consoleLicenseKeyErrorInitialized = false; +/** Tags the patched `console.error` so we re-install (not double-wrap) — `mockRestore` in other tests can wipe it. */ +const LICENSE_FILTER_TAG = '__agIgnoresLicenseKeyError'; export function ignoreConsoleLicenseKeyError() { - if (consoleLicenseKeyErrorInitialized) { - return; + const current = console.error as { [LICENSE_FILTER_TAG]?: boolean }; + if (current[LICENSE_FILTER_TAG]) { + return; // filter already installed on the current console.error } - consoleLicenseKeyErrorInitialized = true; - - const originalConsoleError = console.error; - - // We want to ignore the missing license error message during tests. - function consoleErrorImpl(...args: unknown[]) { + // Re-wrap whatever console.error is now (a prior `spyOn(...).mockRestore()` may have removed our patch). + const wrapped = console.error; + const consoleErrorImpl = (...args: unknown[]): void => { if ( args.length === 1 && typeof args[0] === 'string' && @@ -130,12 +129,10 @@ export function ignoreConsoleLicenseKeyError() { args[0].endsWith('*') && args[0].length === 124 ) { - return; // This is a license error message + return; // AG Grid license box line } - return originalConsoleError.apply(console, args); - } - - consoleErrorImpl.original = originalConsoleError; - + wrapped.apply(console, args); + }; + (consoleErrorImpl as { [LICENSE_FILTER_TAG]?: boolean })[LICENSE_FILTER_TAG] = true; console.error = consoleErrorImpl; } From 5ca14e73188f6f52fd7d545d595c067bf1b72168 Mon Sep 17 00:00:00 2001 From: Guilherme Lopes Date: Mon, 8 Jun 2026 08:16:11 -0300 Subject: [PATCH 2/2] [Calculated Columns] - Fixes for Group Rows, UI and UX (#13972) Co-authored-by: Salvatore Previti --- .../base/parts/_calculated-columns.scss | 17 +- .../example.spec.ts | 7 +- .../docs/calculated-columns/index.mdoc | 21 +-- .../src/validation/errorMessages/errorText.ts | 2 + .../calculatedColumns/calculatedColumnForm.ts | 5 + .../calculatedColumns/calculatedColumns.css | 20 +++ .../calculatedColumnsService.ts | 32 +++- .../src/formula/formulaService.ts | 39 +--- .../src/formula/refUtils.ts | 27 --- .../src/formula/rowAccess.ts | 14 -- .../src/formulas/calculated-columns.test.ts | 166 ++++-------------- 11 files changed, 114 insertions(+), 236 deletions(-) diff --git a/community-modules/styles/src/internal/base/parts/_calculated-columns.scss b/community-modules/styles/src/internal/base/parts/_calculated-columns.scss index 5a875bb859d..731d5eb9095 100644 --- a/community-modules/styles/src/internal/base/parts/_calculated-columns.scss +++ b/community-modules/styles/src/internal/base/parts/_calculated-columns.scss @@ -8,10 +8,25 @@ flex-direction: column; gap: var(--ag-grid-size); min-width: 260px; + flex: 1 1 auto; + min-height: 0; } .ag-calculated-column-expression-wrap { - position: relative; + display: flex; + flex: 1 1 auto; + .ag-text-area { + flex: 1 1 auto; + } + + .ag-text-area-input-wrapper { + height: 100%; + } + + .ag-text-area-input { + height: 100%; + resize: none; + } } .ag-calculated-column-expression-error { diff --git a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/example.spec.ts index 21921ba4f1c..5098edf8e0b 100644 --- a/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/example.spec.ts +++ b/documentation/ag-grid-docs/src/content/docs/calculated-columns/_examples/calculated-columns-row-groups/example.spec.ts @@ -1,14 +1,15 @@ import { expect, test } from '@utils/grid/test-utils'; test.agExample(import.meta, () => { - test.eachFramework('calculated columns evaluate from aggregated group values', async ({ agIdFor }) => { + test.eachFramework('calculated columns are blank on group rows and evaluate on leaf rows', async ({ agIdFor }) => { const solarGroupId = 'row-group-productType-Solar'; await expect(agIdFor.autoGroupCell(solarGroupId)).toContainText('Solar (2)', { useInnerText: true }); await expect(agIdFor.cell(solarGroupId, 'revenue')).toContainText('$220,000'); await expect(agIdFor.cell(solarGroupId, 'cost')).toContainText('$148,000'); - await expect(agIdFor.cell(solarGroupId, 'profit')).toContainText('$72,000'); - await expect(agIdFor.cell(solarGroupId, 'margin')).toContainText('33%'); + // Calculated columns show no value on group rows. + await expect(agIdFor.cell(solarGroupId, 'profit')).toHaveText(''); + await expect(agIdFor.cell(solarGroupId, 'margin')).toHaveText(''); await expect(agIdFor.cell('0', 'profit')).toContainText('$46,000'); await expect(agIdFor.cell('1', 'profit')).toContainText('$26,000'); 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 e5be609b040..1715e789771 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 @@ -37,29 +37,14 @@ Calculated columns are always read-only. They cannot be edited through cell edit ## Row Groups and Tree Data -Calculated columns are full columns, so [Row Grouping](./grouping/), [Tree Data](./tree-data/), [Aggregation](./aggregation/), sorting and filtering apply to them the same way they apply to any other column. +Calculated columns are full columns, so [Row Grouping](./grouping/), [Tree Data](./tree-data/), [Aggregation](./aggregation/), sorting and filtering apply to them the same way as any other column. -On a group row, the expression evaluates against the **aggregated** values of the columns it references. With the setup below, the `profit` cell on a region group shows `sum(revenue) - sum(cost)` for that region: +Calculated values appear on leaf rows only. Row group rows, Tree Data parents (including parents that carry their own data), group footers and the grand-total row show no calculated value. -```{% frameworkTransform=true %} -const gridOptions = { - columnDefs: [ - { field: 'region', rowGroup: true, hide: true }, - { field: 'revenue', aggFunc: 'sum' }, - { field: 'cost', aggFunc: 'sum' }, - { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number' }, - ], -}; -``` - -Because the inputs are aggregated before the expression runs, `[revenue] / [cost]` on a group is `sum(revenue) / sum(cost)`, not the average of each leaf row's ratio. Choose expressions whose group-level result is meaningful under this rule. +This avoids a misleading result. Evaluating the expression against aggregated inputs does not match the aggregate of the per-row results for non-linear expressions: `[revenue] / [cost]` on a group would be `sum(revenue) / sum(cost)`, not the average of each leaf row's ratio. {% gridExampleRunner title="Row Groups with Calculated Columns" name="calculated-columns-row-groups" exampleHeight=420 /%} -A group cell stays blank unless every referenced column produces an aggregated value. Reference a column with no `aggFunc`, and group rows for that calculated column are blank while the leaf rows still evaluate. Filler group rows in Tree Data, which have no aggregates of their own, stay blank for the same reason. - -Group and grand-total footer rows show the same calculated value as the group they total. Tree Data parent rows evaluate against their aggregated children in the same way as Row Group rows. - ## References with Column Groups When the Column Menu is registered, users can add a calculated column from the header menu using **Add Calculated Column**. Opening the header menu on a calculated column shows **Edit Calculated Column** for changing its title, type and expression, and **Remove Calculated Column** for removing it. Right-clicking cells in a calculated column also shows **Remove Calculated Column**. diff --git a/packages/ag-grid-community/src/validation/errorMessages/errorText.ts b/packages/ag-grid-community/src/validation/errorMessages/errorText.ts index 20654ae933c..e4fbf5fd9a0 100644 --- a/packages/ag-grid-community/src/validation/errorMessages/errorText.ts +++ b/packages/ag-grid-community/src/validation/errorMessages/errorText.ts @@ -800,6 +800,8 @@ export const AG_GRID_ERRORS = { }), 303: ({ key }: { key: string }) => `Multiple toolbar items share the explicit key '${key}'. Only the first item is rendered.` as const, + 304: ({ dataType }: { dataType: string }) => + `Invalid calculatedColumns.dataTypes entry "${dataType}" - it must be a built-in data type or registered via dataTypeDefinitions. It has been ignored.` as const, }; export type ErrorMap = typeof AG_GRID_ERRORS; diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts index c81cf0a94a7..023f06ea35a 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnForm.ts @@ -417,6 +417,11 @@ export class CalculatedColumnForm extends Component { return; } + const editorWidth = this.eExpression.getInputElement().offsetWidth; + if (editorWidth > 0) { + list.getGui().style.width = `${editorWidth}px`; + } + popupSvc.positionPopupByComponent({ ePopup: list.getGui(), type: 'calculatedColumnAutocomplete', diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css index 47d6ffa7deb..796ae1ba0b4 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumns.css @@ -7,6 +7,26 @@ flex-direction: column; gap: var(--ag-spacing); min-width: 260px; + flex: 1 1 auto; + min-height: 0; +} + +.ag-calculated-column-expression-wrap { + display: flex; + flex: 1 1 auto; +} + +.ag-calculated-column-expression-wrap :where(.ag-text-area) { + flex: 1 1 auto; +} + +.ag-calculated-column-expression-wrap :where(.ag-text-area-input-wrapper) { + height: 100%; +} + +.ag-calculated-column-expression-wrap :where(.ag-text-area-input) { + height: 100%; + resize: none; } .ag-calculated-column-expression-tools { diff --git a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts index ff9b2e22ad8..016c2d32309 100644 --- a/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts +++ b/packages/ag-grid-enterprise/src/calculatedColumns/calculatedColumnsService.ts @@ -14,7 +14,14 @@ import type { ICalculatedColumnsService, NamedBean, } from 'ag-grid-community'; -import { BeanStub, _addColumnDefaultAndTypes, _createUserColumn, _mergedEqual, _warnOnce } from 'ag-grid-community'; +import { + BeanStub, + _addColumnDefaultAndTypes, + _createUserColumn, + _mergedEqual, + _warn, + _warnOnce, +} from 'ag-grid-community'; import { appendColumnToTree } from '../columns/columnTreeEdit'; import type { FormulaError } from '../formula/ast/utils'; @@ -521,7 +528,7 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa component: form, width: 300, height: 320, - minWidth: 260, + minWidth: 300, minHeight: 280, centered: true, movable: true, @@ -563,7 +570,9 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa private getDataTypeOptions(currentDataType?: string): CalculatedColumnDataTypeOption[] { const configuredDataTypes = this.gos.get('calculatedColumns')?.dataTypes; - const dataTypes = [...(configuredDataTypes ?? DEFAULT_CALCULATED_COLUMN_DATA_TYPES)]; + const dataTypes = configuredDataTypes + ? this.getValidConfiguredDataTypes(configuredDataTypes) + : [...DEFAULT_CALCULATED_COLUMN_DATA_TYPES]; if (currentDataType != null && dataTypes.indexOf(currentDataType) < 0) { dataTypes.push(currentDataType); @@ -575,6 +584,23 @@ export class CalculatedColumnsService extends BeanStub implements NamedBean, ICa })); } + private getValidConfiguredDataTypes(dataTypes: string[]): string[] { + const dataTypeSvc = this.beans.dataTypeSvc; + if (!dataTypeSvc) { + return [...dataTypes]; + } + + const validDataTypes: string[] = []; + for (const dataType of dataTypes) { + if (dataTypeSvc.isDataTypeRegistered(dataType)) { + validDataTypes.push(dataType); + } else { + _warn(304, { dataType }); + } + } + return validDataTypes; + } + private getDataTypeDisplayName(dataType: string): string { const localeKey = BASE_DATA_TYPE_LOCALE_KEYS[dataType]; if (localeKey != null) { diff --git a/packages/ag-grid-enterprise/src/formula/formulaService.ts b/packages/ag-grid-enterprise/src/formula/formulaService.ts index ff657e8fd48..6012133c85c 100644 --- a/packages/ag-grid-enterprise/src/formula/formulaService.ts +++ b/packages/ag-grid-enterprise/src/formula/formulaService.ts @@ -22,8 +22,8 @@ import { evalAst, formulaVisitorSetVisited, formulaVisitorSetVisiting, unresolve import SUPPORTED_FUNCTIONS from './functions/supportedFuncs'; import { shiftNode } from './functions/utils'; import type { FormulaErrorId, FormulaErrorType } from './i18n'; -import { isValidFunctionName, visitCalculatedColumnReferences } from './refUtils'; -import { isCalculatedColumnRowAvailable } from './rowAccess'; +import { isValidFunctionName } from './refUtils'; +import { isFormulaRowAvailable } from './rowAccess'; /** Shared params object for `rowRenderer.refreshCells`, hoisted to avoid per-call allocation. */ const REFRESH_CELLS_PARAMS = { suppressFlash: true, force: true } as const; @@ -680,7 +680,7 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe } private ensureCalculatedCellFormula(row: RowNode, col: AgColumn, calculatedExpression: string): CellFormula | null { - if (!isCalculatedColumnRowAvailable(row)) { + if (!isFormulaRowAvailable(row) || row.group) { return null; } @@ -698,10 +698,6 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe rowMap.set(col, null); try { - if (row.group && !this.canEvaluateCalculatedColumnForRow(row, col, new Set())) { - return null; - } - const trimmedExpression = calculatedExpression.trim(); if (!trimmedExpression) { return null; @@ -716,35 +712,6 @@ export class FormulaService extends BeanStub implements IFormulaService, NamedBe } } - private canEvaluateCalculatedColumnForRow(row: RowNode, col: AgColumn, visiting: Set): boolean { - const calculatedExpression = col.colDef.calculatedExpression; - if (calculatedExpression == null) { - return this.fetchRawValue(col, row) !== undefined; - } - - if (visiting.has(col.colId)) { - return true; - } - - visiting.add(col.colId); - let canEvaluate = true; - visitCalculatedColumnReferences(calculatedExpression, (reference) => { - if (!canEvaluate) { - return; - } - - const referencedColumn = this.beans.colModel.colsById[reference]; - if (!referencedColumn) { - canEvaluate = false; - return; - } - - canEvaluate = this.canEvaluateCalculatedColumnForRow(row, referencedColumn, visiting); - }); - visiting.delete(col.colId); - return canEvaluate; - } - private coerceFormulaValue(cell: CellFormula, value: unknown): unknown { let baseDataType = cell.baseDataType; if (baseDataType === undefined) { diff --git a/packages/ag-grid-enterprise/src/formula/refUtils.ts b/packages/ag-grid-enterprise/src/formula/refUtils.ts index a6594f81fa7..af2905ea006 100644 --- a/packages/ag-grid-enterprise/src/formula/refUtils.ts +++ b/packages/ag-grid-enterprise/src/formula/refUtils.ts @@ -101,30 +101,3 @@ export const getRefTokenMatches = (text: string): RefTokenMatch[] => { } return matches; }; - -export function visitCalculatedColumnReferences(expression: string, callback: (reference: string) => void): void { - let inString = false; - for (let i = 0, len = expression.length; i < len; ++i) { - const char = expression[i]; - if (char === '"') { - if (expression[i + 1] === '"') { - i++; - } else { - inString = !inString; - } - continue; - } - - if (inString || char !== '[') { - continue; - } - - const end = expression.indexOf(']', i + 1); - if (end < 0) { - return; - } - - callback(expression.slice(i + 1, end)); - i = end; - } -} diff --git a/packages/ag-grid-enterprise/src/formula/rowAccess.ts b/packages/ag-grid-enterprise/src/formula/rowAccess.ts index 8298917d557..2a06abcb8c9 100644 --- a/packages/ag-grid-enterprise/src/formula/rowAccess.ts +++ b/packages/ag-grid-enterprise/src/formula/rowAccess.ts @@ -22,17 +22,3 @@ export function getFormulaRowIndex(row: RowNode): number | null { export function isFormulaRowAvailable(row: RowNode): boolean { return !row.stub && !row.failedLoad && row.data != null; } - -/** - * Like {@link isFormulaRowAvailable} but also accepts loaded aggregate group rows. Group nodes - * have `data == null` because their values come from aggregation, not from the source row data, - * so the standard formula-row predicate would reject them. Used by calculated columns, which - * can legitimately evaluate against aggregated values; editable formulas should keep using the - * stricter {@link isFormulaRowAvailable}. - */ -export function isCalculatedColumnRowAvailable(row: RowNode): boolean { - return ( - isFormulaRowAvailable(row) || - (!row.stub && !row.failedLoad && row.group === true && (row.level !== -1 || row.footer === true)) - ); -} diff --git a/testing/behavioural/src/formulas/calculated-columns.test.ts b/testing/behavioural/src/formulas/calculated-columns.test.ts index 0458658bfb6..3b4dc1a5bf3 100644 --- a/testing/behavioural/src/formulas/calculated-columns.test.ts +++ b/testing/behavioural/src/formulas/calculated-columns.test.ts @@ -906,7 +906,7 @@ describe('ag-grid calculated columns', () => { expect(sourceCell).toHaveClass(flashCssClass); }); - test('calculated columns evaluate on row group aggregate values', async () => { + test('calculated columns stay blank on row group rows while leaf rows evaluate', async () => { const api = createGrid('calculated-row-groups', { rowData: [ { id: 'r1', region: 'EMEA', revenue: 10, cost: 3 }, @@ -922,7 +922,7 @@ describe('ag-grid calculated columns', () => { ], groupDefaultExpanded: -1, }); - await new GridColumns(api, `calculated columns evaluate on row group aggregate values setup`).checkColumns(` + await new GridColumns(api, `calculated columns stay blank on row group rows setup`).checkColumns(` CENTER ├── ag-Grid-AutoColumn "Group" width:200 ├── revenue "Revenue" width:200 aggFunc:sum @@ -930,12 +930,13 @@ describe('ag-grid calculated columns', () => { ├── profit width:200 └── doubleProfit width:200 `); - await new GridRows(api, `calculated columns evaluate on row group aggregate values setup`).check(` + // Group rows show no calculated value; leaf rows still evaluate. + await new GridRows(api, `calculated columns stay blank on row group rows`).check(` ROOT id:ROOT_NODE_ID - ├─┬ LEAF_GROUP id:row-group-region-EMEA ag-Grid-AutoColumn:"EMEA" revenue:30 cost:11 profit:19 doubleProfit:38 + ├─┬ LEAF_GROUP id:row-group-region-EMEA ag-Grid-AutoColumn:"EMEA" revenue:30 cost:11 │ ├── LEAF id:r1 region:"EMEA" revenue:10 cost:3 profit:7 doubleProfit:14 │ └── LEAF id:r2 region:"EMEA" revenue:20 cost:8 profit:12 doubleProfit:24 - └─┬ LEAF_GROUP id:row-group-region-APAC ag-Grid-AutoColumn:"APAC" revenue:15 cost:5 profit:10 doubleProfit:20 + └─┬ LEAF_GROUP id:row-group-region-APAC ag-Grid-AutoColumn:"APAC" revenue:15 cost:5 · └── LEAF id:r3 region:"APAC" revenue:15 cost:5 profit:10 doubleProfit:20 `); await asyncSetTimeout(1); @@ -952,19 +953,14 @@ describe('ag-grid calculated columns', () => { }); expect(emeaGroup.group).toBe(true); - expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'profit', useFormatter: false })).toBe(19); - expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'doubleProfit', useFormatter: false })).toBe(38); + expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'profit', useFormatter: false })).toBeUndefined(); + expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'doubleProfit', useFormatter: false })).toBeUndefined(); expect(apacGroup.group).toBe(true); - expect(api.getCellValue({ rowNode: apacGroup, colKey: 'profit', useFormatter: false })).toBe(10); - expect(api.getCellValue({ rowNode: apacGroup, colKey: 'doubleProfit', useFormatter: false })).toBe(20); - await new GridRows(api, `calculated columns evaluate on row group aggregate values final state`).check(` - ROOT id:ROOT_NODE_ID - ├─┬ LEAF_GROUP id:row-group-region-EMEA ag-Grid-AutoColumn:"EMEA" revenue:30 cost:11 profit:19 doubleProfit:38 - │ ├── LEAF id:r1 region:"EMEA" revenue:10 cost:3 profit:7 doubleProfit:14 - │ └── LEAF id:r2 region:"EMEA" revenue:20 cost:8 profit:12 doubleProfit:24 - └─┬ LEAF_GROUP id:row-group-region-APAC ag-Grid-AutoColumn:"APAC" revenue:15 cost:5 profit:10 doubleProfit:20 - · └── LEAF id:r3 region:"APAC" revenue:15 cost:5 profit:10 doubleProfit:20 - `); + expect(api.getCellValue({ rowNode: apacGroup, colKey: 'profit', useFormatter: false })).toBeUndefined(); + expect(api.getCellValue({ rowNode: api.getRowNode('r1')!, colKey: 'profit', useFormatter: false })).toBe(7); + expect(api.getCellValue({ rowNode: api.getRowNode('r1')!, colKey: 'doubleProfit', useFormatter: false })).toBe( + 14 + ); }); test('calculated columns stay blank on row groups without aggregate source values while leaf rows still evaluate', async () => { @@ -997,7 +993,7 @@ describe('ag-grid calculated columns', () => { expect(api.getCellValue({ rowNode: api.getRowNode('r2')!, colKey: 'profit', useFormatter: false })).toBe(26000); }); - test('calculated columns evaluate on tree data rows and stay blank on filler groups', async () => { + test('calculated columns evaluate on tree data leaves and stay blank on parent and filler groups', async () => { const parentApi = createGrid('calculated-tree-data-parent', { treeData: true, treeDataChildrenField: 'children', @@ -1020,9 +1016,10 @@ describe('ag-grid calculated columns', () => { }); await asyncSetTimeout(1); + // A tree parent is a non-leaf row, so it shows no calculated value even though it carries its own data. expect( parentApi.getCellValue({ rowNode: parentApi.getRowNode('parent')!, colKey: 'profit', useFormatter: false }) - ).toBe(60); + ).toBeUndefined(); expect( parentApi.getCellValue({ rowNode: parentApi.getRowNode('child')!, colKey: 'profit', useFormatter: false }) ).toBe(20); @@ -1054,7 +1051,7 @@ describe('ag-grid calculated columns', () => { ).toBe(20); }); - test('calculated columns evaluate on group and grand total footer rows', async () => { + test('calculated columns stay blank on group and grand total footer rows', async () => { const api = createGrid('calculated-row-group-footers', { rowData: [ { id: 'r1', region: 'EMEA', revenue: 10, cost: 3 }, @@ -1078,85 +1075,14 @@ describe('ag-grid calculated columns', () => { const grandTotal = api.getRowNode('rowGroupFooter_ROOT_NODE_ID')!; expect(emeaFooter).toBeTruthy(); - expect(api.getCellValue({ rowNode: emeaFooter, colKey: 'profit', useFormatter: false })).toBe(19); + expect(api.getCellValue({ rowNode: emeaFooter, colKey: 'profit', useFormatter: false })).toBeUndefined(); expect(apacFooter).toBeTruthy(); - expect(api.getCellValue({ rowNode: apacFooter, colKey: 'profit', useFormatter: false })).toBe(10); + expect(api.getCellValue({ rowNode: apacFooter, colKey: 'profit', useFormatter: false })).toBeUndefined(); expect(grandTotal).toBeTruthy(); - expect(api.getCellValue({ rowNode: grandTotal, colKey: 'profit', useFormatter: false })).toBe(29); + expect(api.getCellValue({ rowNode: grandTotal, colKey: 'profit', useFormatter: false })).toBeUndefined(); }); - test('calculated columns aggregate across multiple group levels', async () => { - const api = createGrid('calculated-multi-level-groups', { - rowData: [ - { id: 'r1', region: 'EMEA', country: 'UK', revenue: 10, cost: 3 }, - { id: 'r2', region: 'EMEA', country: 'DE', revenue: 20, cost: 8 }, - { id: 'r3', region: 'EMEA', country: 'DE', revenue: 5, cost: 1 }, - ], - columnDefs: [ - { field: 'region', rowGroup: true, hide: true }, - { field: 'country', rowGroup: true, hide: true }, - { field: 'revenue', aggFunc: 'sum' }, - { field: 'cost', aggFunc: 'sum' }, - { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number' }, - ], - groupDefaultExpanded: -1, - }); - await asyncSetTimeout(1); - - const emeaGroup = api.getRowNode('row-group-region-EMEA')!; - const ukGroup = api.getRowNode('row-group-region-EMEA-country-UK')!; - const deGroup = api.getRowNode('row-group-region-EMEA-country-DE')!; - - expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'profit', useFormatter: false })).toBe(23); - expect(api.getCellValue({ rowNode: ukGroup, colKey: 'profit', useFormatter: false })).toBe(7); - expect(api.getCellValue({ rowNode: deGroup, colKey: 'profit', useFormatter: false })).toBe(16); - }); - - test('sorting on a calculated column orders group rows by aggregate result', async () => { - const api = createGrid('calculated-sort-grouped', { - rowData: [ - { id: 'r1', region: 'EMEA', revenue: 10, cost: 3 }, - { id: 'r2', region: 'EMEA', revenue: 20, cost: 8 }, - { id: 'r3', region: 'APAC', revenue: 15, cost: 5 }, - ], - columnDefs: [ - { field: 'region', rowGroup: true, hide: true }, - { field: 'revenue', aggFunc: 'sum' }, - { field: 'cost', aggFunc: 'sum' }, - { - colId: 'profit', - calculatedExpression: '[revenue] - [cost]', - cellDataType: 'number', - sortable: true, - }, - ], - }); - await asyncSetTimeout(1); - - api.applyColumnState({ state: [{ colId: 'profit', sort: 'asc' }], defaultState: { sort: null } }); - await asyncSetTimeout(1); - - const ascOrder: string[] = []; - api.forEachNodeAfterFilterAndSort((node) => { - if (node.group && node.key) { - ascOrder.push(node.key); - } - }); - expect(ascOrder).toEqual(['APAC', 'EMEA']); - - api.applyColumnState({ state: [{ colId: 'profit', sort: 'desc' }], defaultState: { sort: null } }); - await asyncSetTimeout(1); - - const descOrder: string[] = []; - api.forEachNodeAfterFilterAndSort((node) => { - if (node.group && node.key) { - descOrder.push(node.key); - } - }); - expect(descOrder).toEqual(['EMEA', 'APAC']); - }); - - test('grid api adds a calculated column while grouped and it evaluates on group rows', async () => { + test('grid api adds a calculated column while grouped and it evaluates on leaf rows', async () => { const api = createGrid('calculated-api-while-grouped', { rowData: [ { id: 'r1', region: 'EMEA', revenue: 10, cost: 3 }, @@ -1182,43 +1108,10 @@ describe('ag-grid calculated columns', () => { await asyncSetTimeout(1); const emeaGroup = api.getRowNode('row-group-region-EMEA')!; - const apacGroup = api.getRowNode('row-group-region-APAC')!; - expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'profit', useFormatter: false })).toBe(19); - expect(api.getCellValue({ rowNode: apacGroup, colKey: 'profit', useFormatter: false })).toBe(10); - }); - - test('calculated columns aggregate on tree data parents with aggregated inputs', async () => { - const api = createGrid('calculated-tree-data-aggregated', { - treeData: true, - getDataPath: (data) => data.path, - rowData: [ - { id: 'l1', path: ['Dept', 'Team A', 'Leaf 1'], revenue: 30, cost: 10 }, - { id: 'l2', path: ['Dept', 'Team A', 'Leaf 2'], revenue: 20, cost: 5 }, - { id: 'l3', path: ['Dept', 'Team B', 'Leaf 3'], revenue: 40, cost: 25 }, - ], - columnDefs: [ - { field: 'revenue', aggFunc: 'sum' }, - { field: 'cost', aggFunc: 'sum' }, - { colId: 'profit', calculatedExpression: '[revenue] - [cost]', cellDataType: 'number' }, - ], - groupDefaultExpanded: -1, - }); - await asyncSetTimeout(1); - - let deptGroup: any; - let teamAGroup: any; - api.forEachNode((node) => { - if (node.key === 'Dept') { - deptGroup = node; - } - if (node.key === 'Team A') { - teamAGroup = node; - } - }); - - expect(api.getCellValue({ rowNode: teamAGroup, colKey: 'profit', useFormatter: false })).toBe(35); - expect(api.getCellValue({ rowNode: deptGroup, colKey: 'profit', useFormatter: false })).toBe(50); - expect(api.getCellValue({ rowNode: api.getRowNode('l3')!, colKey: 'profit', useFormatter: false })).toBe(15); + // Group rows stay blank; the leaf rows under them evaluate the newly added column. + expect(api.getCellValue({ rowNode: emeaGroup, colKey: 'profit', useFormatter: false })).toBeUndefined(); + expect(api.getCellValue({ rowNode: api.getRowNode('r1')!, colKey: 'profit', useFormatter: false })).toBe(7); + expect(api.getCellValue({ rowNode: api.getRowNode('r3')!, colKey: 'profit', useFormatter: false })).toBe(10); }); test.each([ @@ -2446,9 +2339,11 @@ describe('ag-grid calculated columns', () => { `); }); - test('dialog type list uses configured data types', async () => { + test('dialog type list uses configured data types and ignores unregistered ones', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const api = createGrid('calculated-dialog-configured-types', { calculatedColumns: { + // `customStatus` is registered below; `missingType` has no definition and must be ignored. dataTypes: ['number', 'customStatus', 'missingType', 'boolean'], }, dataTypeDefinitions: { @@ -2474,7 +2369,10 @@ describe('ag-grid calculated columns', () => { const typeOptions = Array.from(document.querySelectorAll('.ag-list-item')).map((element) => element.textContent?.trim() ); - expect(typeOptions).toEqual(['Number', 'Custom Status', 'Missing Type', 'Boolean']); + expect(typeOptions).toEqual(['Number', 'Custom Status', 'Boolean']); + expect(warn.mock.calls.flat().join(' ')).toContain('missingType'); + + warn.mockRestore(); }); test('dialog expression picker config hides picker buttons without disabling inline autocomplete', async () => {