diff --git a/.env b/.env index 510df4ef819..27f768e81e9 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ # Production Build -BUILD_GRID_VERSION=35.1.0-beta.20260309.1858 -BUILD_CHARTS_VERSION=13.1.0-beta.20260309 +BUILD_GRID_VERSION=35.1.0-beta.20260312.1528 +BUILD_CHARTS_VERSION=13.1.0-beta.20260312 ENV=local NX_BATCH_MODE=true NX_ADD_PLUGINS=false diff --git a/community-modules/locale/package.json b/community-modules/locale/package.json index b3649fa4a97..56aa882b6ab 100644 --- a/community-modules/locale/package.json +++ b/community-modules/locale/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/locale", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "Localisation Module for AG Grid, providing translations in 31 languages.", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", diff --git a/community-modules/styles/package.json b/community-modules/styles/package.json index 231000eb7e2..9f11d00cd15 100644 --- a/community-modules/styles/package.json +++ b/community-modules/styles/package.json @@ -1,6 +1,6 @@ { "name": "@ag-grid-community/styles", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "AG Grid Styles and Themes", "main": "_index.scss", "files": [ diff --git a/documentation/ag-grid-docs/package.json b/documentation/ag-grid-docs/package.json index d2f7bf4a73c..0f68237a124 100644 --- a/documentation/ag-grid-docs/package.json +++ b/documentation/ag-grid-docs/package.json @@ -2,7 +2,7 @@ "name": "ag-grid-docs", "description": "Documentation for AG Grid", "type": "module", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "repository": { "type": "git", "url": "https://github.com/ag-grid/ag-grid.git" @@ -53,17 +53,17 @@ "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", "@pqina/flip": "^1.8.4", - "ag-charts-angular": "13.1.0-beta.20260309", - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309", - "ag-charts-types": "13.1.0-beta.20260309", - "ag-charts-react": "13.1.0-beta.20260309", - "ag-charts-vue3": "13.1.0-beta.20260309", - "ag-grid-angular": "35.1.0-beta.20260309.1858", - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-grid-react": "35.1.0-beta.20260309.1858", - "ag-grid-vue3": "35.1.0-beta.20260309.1858", + "ag-charts-angular": "13.1.0-beta.20260312", + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312", + "ag-charts-types": "13.1.0-beta.20260312", + "ag-charts-react": "13.1.0-beta.20260312", + "ag-charts-vue3": "13.1.0-beta.20260312", + "ag-grid-angular": "35.1.0-beta.20260312.1528", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-grid-react": "35.1.0-beta.20260312.1528", + "ag-grid-vue3": "35.1.0-beta.20260312.1528", "algoliasearch": "^4.18.0", "astro": "5.16.6", "cheerio": "^1.0.0", diff --git a/documentation/update-algolia-indices/package.json b/documentation/update-algolia-indices/package.json index 4c980cff6f1..f36be6b1c5d 100644 --- a/documentation/update-algolia-indices/package.json +++ b/documentation/update-algolia-indices/package.json @@ -1,6 +1,6 @@ { "name": "update-algolia-indices", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "Update algolia indices", "main": "src/index.ts", "type": "module", diff --git a/package.json b/package.json index 36963aabc47..8ebb3236cbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "license": "MIT", "scripts": { "compressVideo": "tsx external/ag-website-shared/scripts/compress-video", diff --git a/packages/ag-grid-angular/package.json b/packages/ag-grid-angular/package.json index 0fba671c294..2d3ee522b8a 100644 --- a/packages/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "AG Grid Angular Component", "scripts": { "clean": "rimraf dist", @@ -15,7 +15,7 @@ "module": "./dist/ag-grid-angular/fesm2022/ag-grid-angular.mjs", "typings": "./dist/ag-grid-angular/index.d.ts", "dependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "@angular/animations": "^18.0.7", "@angular/common": "^18.0.7", "@angular/compiler": "^18.0.7", @@ -27,7 +27,7 @@ "zone.js": "~0.15.1" }, "devDependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "@angular-devkit/build-angular": "^18.0.7", "@angular/cli": "^18.0.7", "@angular/forms": "^18.0.7", diff --git a/packages/ag-grid-angular/projects/ag-grid-angular/package.json b/packages/ag-grid-angular/projects/ag-grid-angular/package.json index 5127f35cffd..0d99ece1dc8 100644 --- a/packages/ag-grid-angular/projects/ag-grid-angular/package.json +++ b/packages/ag-grid-angular/projects/ag-grid-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "AG Grid Angular Component", "license": "MIT", "peerDependencies": { @@ -8,7 +8,7 @@ "@angular/core": ">= 18.0.0" }, "dependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "tslib": "^2.3.0" }, "repository": { diff --git a/packages/ag-grid-community/package.json b/packages/ag-grid-community/package.json index 6423b962619..60fd857bf61 100644 --- a/packages/ag-grid-community/package.json +++ b/packages/ag-grid-community/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-community", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -119,7 +119,7 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-charts-types": "13.1.0-beta.20260309" + "ag-charts-types": "13.1.0-beta.20260312" }, "devDependencies": { "web-streams-polyfill": "^3.3.2", diff --git a/packages/ag-grid-community/src/agStack/utils/array.ts b/packages/ag-grid-community/src/agStack/utils/array.ts index 2e1f99d8b48..b31c09d3dc5 100644 --- a/packages/ag-grid-community/src/agStack/utils/array.ts +++ b/packages/ag-grid-community/src/agStack/utils/array.ts @@ -1,9 +1,3 @@ -/** - * An array that is always empty and that cannot be modified - * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. - */ -export const _EmptyArray = Object.freeze([]) as unknown as any[]; - /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export function _last(arr: readonly T[]): T; export function _last(arr: NodeListOf): T; diff --git a/packages/ag-grid-community/src/clientSideRowModel/clientSideRowModel.ts b/packages/ag-grid-community/src/clientSideRowModel/clientSideRowModel.ts index 1972547f2bd..915c1e734d8 100644 --- a/packages/ag-grid-community/src/clientSideRowModel/clientSideRowModel.ts +++ b/packages/ag-grid-community/src/clientSideRowModel/clientSideRowModel.ts @@ -15,7 +15,8 @@ import type { IRowNodeStage } from '../interfaces/iRowNodeStage'; import type { RowDataTransaction } from '../interfaces/rowDataTransaction'; import type { RowNodeTransaction } from '../interfaces/rowNodeTransaction'; import type { OverlayType } from '../rendering/overlays/overlayComponent'; -import { ChangedPath } from '../utils/changedPath'; +import type { ChangedPath } from '../utils/changedPath'; +import { ChangedRowsPath, _forEachChangedGroupDepthFirst } from '../utils/changedPath'; import { _warn } from '../validation/logging'; import { ChangedRowNodes } from './changedRowNodes'; import { ClientSideNodeManager } from './clientSideNodeManager'; @@ -336,9 +337,7 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, } } - private clearRowTopAndRowIndex(changedPath: ChangedPath, displayedRowsMapped: Set): void { - const changedPathActive = changedPath.active; - + private clearRowTopAndRowIndex(changedPath: ChangedPath | undefined, displayedRowsMapped: Set): void { const clearIfNotDisplayed = (rowNode?: RowNode) => { if (rowNode?.id != null && !displayedRowsMapped.has(rowNode.id)) { rowNode.clearRowTopAndRowIndex(); @@ -355,15 +354,11 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, return; } - // if a changedPath is active, it means we are here because of a transaction update or - // a change detection. neither of these impacts the open/closed state of groups. so if - // a group is not open this time, it was not open last time. so we know all closed groups - // already have their top positions cleared. so there is no need to traverse all the way - // when changedPath is active and the rowNode is not expanded. - const isRootNode = rowNode.level == -1; // we need to give special consideration for root node, - // as expanded=undefined for root node - const skipChildren = changedPathActive && !isRootNode && !rowNode.expanded; - if (skipChildren) { + // When changedPath is provided, we are here because of a transaction update or + // a change detection. Neither of these impacts the open/closed state of groups. So if + // a group is not open this time, it was not open last time. So we know all closed groups + // already have their top positions cleared — no need to traverse further. + if (changedPath && rowNode.level !== -1 && !rowNode.expanded) { return; } for (let i = 0, len = childrenAfterGroup.length; i < len; ++i) { @@ -501,19 +496,6 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, this.refreshModel({ step: this.beans.colModel.isPivotActive() ? 'pivot' : 'aggregate' }); } - private createChangePath(enabled: boolean): ChangedPath { - // for updates, if the row is updated at all, then we re-calc all the values - // in that row. we could compare each value to each old value, however if we - // did this, we would be calling the valueSvc twice, once on the old value - // and once on the new value. so it's less valueGetter calls if we just assume - // each column is different. that way the changedPath is used so that only - // the impacted parent rows are recalculated, parents who's children have - // not changed are not impacted. - const changedPath = new ChangedPath(false, this.rootNode!); - changedPath.active = enabled; - return changedPath; - } - private isSuppressModelUpdateAfterUpdateTransaction(params: RefreshModelParams): boolean { if (!this.gos.get('suppressModelUpdateAfterUpdateTransaction')) { return false; // Not suppressed @@ -556,7 +538,7 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, } const rowDataUpdated = !!params.rowDataUpdated; - const changedPath = (params.changedPath ??= this.createChangePath(!params.newData && rowDataUpdated)); + params.changedPath ??= !params.newData && rowDataUpdated ? new ChangedRowsPath() : undefined; if (started && rowDataUpdated) { eventSvc.dispatchEvent({ type: 'rowDataUpdated' }); @@ -581,7 +563,7 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, let succeeded = false; this.refreshingModel = true; // Prevent nested refreshModel calls try { - this.executeRefresh(params, changedPath, rowDataUpdated); + this.executeRefresh(params, rowDataUpdated); succeeded = true; } finally { // Reset lock flags even on failure to prevent the grid from being stuck @@ -609,7 +591,7 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, } /** Executes the refresh pipeline stages and updates row positions. */ - private executeRefresh(params: RefreshModelParams, changedPath: ChangedPath, rowDataUpdated: boolean): void { + private executeRefresh(params: RefreshModelParams, rowDataUpdated: boolean): void { const { beans } = this; beans.masterDetailSvc?.refreshModel(params); @@ -617,11 +599,12 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, beans.colFilter?.refreshModel(); } - // this goes through the pipeline of stages. what's in my head is similar to the diagram on this page: - // http://commons.apache.org/sandbox/commons-pipeline/pipeline_basics.html - // however we want to keep the results of each stage, hence we manually call each step - // rather than have them chain each other. - // fallthrough in below switch is on purpose, eg if STEP_FILTER, then all steps after runs too + let changedPath = params.changedPath; + + // Ensure the root node is always visited by pipeline stages even for empty transactions. + changedPath?.addRow(this.rootNode); + + // Pipeline of stages — fallthrough is on purpose, e.g. if 'filter', then all steps after run too. /* eslint-disable no-fallthrough */ switch (params.step) { case 'group': @@ -629,7 +612,11 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, case 'filter': this.doFilter(changedPath); case 'pivot': - this.doPivot(changedPath); + // Pivot may signal that columns changed, requiring full traversal for subsequent stages. + if (this.doPivot(changedPath)) { + changedPath = undefined; + params.changedPath = undefined; + } case 'aggregate': // depends on agg fields this.doAggregate(changedPath); case 'filter_aggregates': @@ -940,14 +927,14 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, } // it's possible to recompute the aggregate without doing the other parts + api.refreshClientSideRowModel('aggregate') - public doAggregate(changedPath: ChangedPath): void { + public doAggregate(changedPath: ChangedPath | undefined): void { const rootNode = this.rootNode; if (rootNode) { this.beans.aggStage?.execute(changedPath); } } - private doFilterAggregates(changedPath: ChangedPath): void { + private doFilterAggregates(changedPath: ChangedPath | undefined): void { const rootNode = this.rootNode!; const filterAggStage = this.beans.filterAggStage; if (filterAggStage) { @@ -958,13 +945,13 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, rootNode.childrenAfterAggFilter = rootNode.childrenAfterFilter; } - private doSort(changedPath: ChangedPath, changedRowNodes: ChangedRowNodes | undefined): void { + private doSort(changedPath: ChangedPath | undefined, changedRowNodes: ChangedRowNodes | undefined): void { const sortStage = this.beans.sortStage; if (sortStage) { sortStage.execute(changedPath, changedRowNodes); return; } - changedPath.forEachChangedNodeDepthFirst((rowNode) => { + _forEachChangedGroupDepthFirst(this.rootNode, changedPath, (rowNode) => { rowNode.childrenAfterSort = rowNode.childrenAfterAggFilter!.slice(0); updateRowNodeAfterSort(rowNode); }); @@ -995,20 +982,21 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, } } - private doFilter(changedPath: ChangedPath): void { + private doFilter(changedPath: ChangedPath | undefined): void { const filterStage = this.beans.filterStage; if (filterStage) { filterStage.execute(changedPath); return; } - changedPath.forEachChangedNodeDepthFirst((rowNode) => { + _forEachChangedGroupDepthFirst(this.rootNode, changedPath, (rowNode) => { rowNode.childrenAfterFilter = rowNode.childrenAfterGroup; updateRowNodeAfterFilter(rowNode); - }, true); + }); } - private doPivot(changedPath: ChangedPath) { - this.beans.pivotStage?.execute(changedPath); + /** Returns `true` if pivot columns changed and changedPath should be deactivated. */ + private doPivot(changedPath: ChangedPath | undefined): boolean { + return this.beans.pivotStage?.execute(changedPath) ?? false; } public getRowNode(id: string): RowNode | undefined { @@ -1112,7 +1100,7 @@ export class ClientSideRowModel extends BeanStub implements IClientSideRowModel, keepRenderedRows: true, animate, changedRowNodes, - changedPath: this.createChangePath(true), + changedPath: new ChangedRowsPath(), }); } diff --git a/packages/ag-grid-community/src/clientSideRowModel/deltaSort.ts b/packages/ag-grid-community/src/clientSideRowModel/deltaSort.ts index f77af40c765..3fe5db99287 100644 --- a/packages/ag-grid-community/src/clientSideRowModel/deltaSort.ts +++ b/packages/ag-grid-community/src/clientSideRowModel/deltaSort.ts @@ -30,7 +30,7 @@ export const doDeltaSort = ( rowNodeSorter: RowNodeSorter, rowNode: RowNode, changedRowNodes: ChangedRowNodes, - changedPath: ChangedPath, + changedPath: ChangedPath | undefined, sortOptions: SortOption[] ): RowNode[] => { const oldSortedRows = rowNode.childrenAfterSort; @@ -51,7 +51,6 @@ export const doDeltaSort = ( } if (!oldSortedRows || unsortedRowsLen <= MIN_DELTA_SORT_ROWS) { - // No previous sort, or just too few elements, do full sort return rowNodeSorter.doFullSortInPlace(unsortedRows.slice(), sortOptions); } @@ -63,7 +62,7 @@ export const doDeltaSort = ( const touchedRows: RowNode[] = []; for (let i = 0; i < unsortedRowsLen; ++i) { const node = unsortedRows[i]; - if (updates.has(node) || adds.has(node) || !changedPath.canSkip(node)) { + if (updates.has(node) || adds.has(node) || !changedPath || changedPath.hasRow(node)) { indexByNode.set(node, ~i); // Bitwise NOT for touched (negative) touchedRows.push(node); } else { diff --git a/packages/ag-grid-community/src/clientSideRowModel/filterStage.ts b/packages/ag-grid-community/src/clientSideRowModel/filterStage.ts index b5f50326ca1..b9b4132e943 100644 --- a/packages/ag-grid-community/src/clientSideRowModel/filterStage.ts +++ b/packages/ag-grid-community/src/clientSideRowModel/filterStage.ts @@ -7,6 +7,7 @@ import type { FilterManager } from '../filter/filterManager'; import type { ClientSideRowModelStage } from '../interfaces/iClientSideRowModel'; import type { IRowNodeFilterStage } from '../interfaces/iRowNodeStage'; import type { ChangedPath } from '../utils/changedPath'; +import { _forEachChangedGroupDepthFirst } from '../utils/changedPath'; export function updateRowNodeAfterFilter(rowNode: RowNode): void { const sibling = rowNode.sibling; @@ -27,7 +28,7 @@ export class FilterStage extends BeanStub implements IRowNodeFilterStage, NamedB this.filterManager = beans.filterManager; } - public execute(changedPath: ChangedPath): void { + public execute(changedPath: ChangedPath | undefined): void { const filterActive = !!this.filterManager?.isChildFilterPresent(); if (this.beans.formula?.active) { this.softFilter(filterActive, changedPath); @@ -36,7 +37,7 @@ export class FilterStage extends BeanStub implements IRowNodeFilterStage, NamedB } } - private filterNodes(filterActive: boolean, changedPath: ChangedPath): void { + private filterNodes(filterActive: boolean, changedPath: ChangedPath | undefined): void { const filterCallback = (rowNode: RowNode, includeChildNodes: boolean) => { // recursively get all children that are groups to also filter if (rowNode.hasChildren()) { @@ -90,15 +91,14 @@ export class FilterStage extends BeanStub implements IRowNodeFilterStage, NamedB filterCallback(rowNode, alreadyFoundInParent); }; - const treeDataFilterCallback = (rowNode: RowNode) => treeDataDepthFirstFilter(rowNode, false); - changedPath.executeFromRootNode(treeDataFilterCallback); + treeDataDepthFirstFilter(this.beans.rowModel.rootNode!, false); } else { const defaultFilterCallback = (rowNode: RowNode) => filterCallback(rowNode, false); - changedPath.forEachChangedNodeDepthFirst(defaultFilterCallback, true); + _forEachChangedGroupDepthFirst(this.beans.rowModel.rootNode, changedPath, defaultFilterCallback); } } - private softFilter(filterActive: boolean, changedPath: ChangedPath): void { + private softFilter(filterActive: boolean, changedPath: ChangedPath | undefined): void { const filterCallback = (rowNode: RowNode) => { rowNode.childrenAfterFilter = rowNode.childrenAfterGroup; if (rowNode.hasChildren()) { @@ -112,11 +112,11 @@ export class FilterStage extends BeanStub implements IRowNodeFilterStage, NamedB updateRowNodeAfterFilter(rowNode); }; - changedPath.forEachChangedNodeDepthFirst(filterCallback, true); + _forEachChangedGroupDepthFirst(this.beans.rowModel.rootNode, changedPath, filterCallback); } private doingTreeDataFiltering() { const { gos } = this; - return gos.get('treeData') && !gos.get('excludeChildrenWhenTreeDataFiltering'); + return !!this.beans.groupStage?.treeData && !gos.get('excludeChildrenWhenTreeDataFiltering'); } } diff --git a/packages/ag-grid-community/src/clientSideRowModel/sortStage.ts b/packages/ag-grid-community/src/clientSideRowModel/sortStage.ts index 66ae30ec030..97606b4eac1 100644 --- a/packages/ag-grid-community/src/clientSideRowModel/sortStage.ts +++ b/packages/ag-grid-community/src/clientSideRowModel/sortStage.ts @@ -9,6 +9,7 @@ import type { WithoutGridCommon } from '../interfaces/iCommon'; import type { IRowNodeSortStage } from '../interfaces/iRowNodeStage'; import type { SortOption } from '../interfaces/iSortOption'; import type { ChangedPath } from '../utils/changedPath'; +import { _forEachChangedGroupDepthFirst } from '../utils/changedPath'; import type { ChangedRowNodes } from './changedRowNodes'; import { doDeltaSort } from './deltaSort'; @@ -97,7 +98,7 @@ export class SortStage extends BeanStub implements NamedBean, IRowNodeSortStage } else if (!sortOptions.length || skipSortingPivotLeafs) { // if there's no sort to make, skip this step } else if (useDeltaSort && changedRowNodes) { - newChildrenAfterSort = doDeltaSort(rowNodeSorter!, rowNode, changedRowNodes, changedPath!, sortOptions); + newChildrenAfterSort = doDeltaSort(rowNodeSorter!, rowNode, changedRowNodes, changedPath, sortOptions); } else { newChildrenAfterSort = rowNodeSorter!.doFullSortInPlace( rowNode.childrenAfterAggFilter!.slice(), @@ -119,7 +120,7 @@ export class SortStage extends BeanStub implements NamedBean, IRowNodeSortStage } }; - changedPath?.forEachChangedNodeDepthFirst(callback); + _forEachChangedGroupDepthFirst(this.beans.rowModel.rootNode, changedPath, callback); // if using group hide open parents and a sort has happened, refresh the group cells as the first child // displays the parent grouping - it's cheaper here to refresh all cells in col rather than fire events for every potential diff --git a/packages/ag-grid-community/src/dragAndDrop/rowDragFeature.ts b/packages/ag-grid-community/src/dragAndDrop/rowDragFeature.ts index 7fa25574197..cc307eba1e8 100644 --- a/packages/ag-grid-community/src/dragAndDrop/rowDragFeature.ts +++ b/packages/ag-grid-community/src/dragAndDrop/rowDragFeature.ts @@ -12,7 +12,7 @@ import { _getRowIdCallback, _isClientSideRowModel } from '../gridOptionsUtils'; import type { IClientSideRowModel } from '../interfaces/iClientSideRowModel'; import type { IRowModel } from '../interfaces/iRowModel'; import type { IRowNode } from '../interfaces/iRowNode'; -import { ChangedPath } from '../utils/changedPath'; +import { ChangedRowsPath } from '../utils/changedPath'; import { _warn } from '../validation/logging'; import type { DragAndDropIcon, DropTarget } from './dragAndDropService'; import { DragSourceType } from './dragAndDropService'; @@ -676,7 +676,7 @@ export class RowDragFeature extends BeanStub implements DropTarget { step: 'group', keepRenderedRows: true, animate: !this.gos.get('suppressAnimationFrame'), - changedPath: new ChangedPath(false, rootNode as RowNode), + changedPath: new ChangedRowsPath(), changedRowNodes, }); diff --git a/packages/ag-grid-community/src/entities/rowNode.ts b/packages/ag-grid-community/src/entities/rowNode.ts index c71f5570800..1f8036ff402 100644 --- a/packages/ag-grid-community/src/entities/rowNode.ts +++ b/packages/ag-grid-community/src/entities/rowNode.ts @@ -72,7 +72,7 @@ export class RowNode /** When using group rows, contains the value without casting to string */ public groupValue: any; - /** If using row grouping and aggregation, contains the aggregation data. */ + /** If using row grouping and aggregation, contains the aggregation data. Created via `Object.create(null)` to avoid prototype conflicts. */ public aggData: any; /** @@ -197,19 +197,19 @@ export class RowNode * Children of this group. If multi levels of grouping, shows only immediate children. * Do not modify this array directly. The grouping module relies on mutable references to the array. */ - public childrenAfterGroup: RowNode[] | null; + public childrenAfterGroup: RowNode[] | null = null; /** Filtered children of this group. */ - public childrenAfterFilter: RowNode[] | null; + public childrenAfterFilter: RowNode[] | null = null; /** Aggregated and re-filtered children of this group. */ - public childrenAfterAggFilter: RowNode[] | null; + public childrenAfterAggFilter: RowNode[] | null = null; /** Sorted children of this group. */ - public childrenAfterSort: RowNode[] | null; + public childrenAfterSort: RowNode[] | null = null; /** Number of children and grand children. */ - public allChildrenCount: number | null; + public allChildrenCount: number | null = null; /** Children mapped by the pivot columns or group key */ public childrenMapped: { [key: string]: any } | null = null; diff --git a/packages/ag-grid-community/src/interfaces/iClientSideRowModel.ts b/packages/ag-grid-community/src/interfaces/iClientSideRowModel.ts index e180f29dce6..f377f22ac07 100644 --- a/packages/ag-grid-community/src/interfaces/iClientSideRowModel.ts +++ b/packages/ag-grid-community/src/interfaces/iClientSideRowModel.ts @@ -53,7 +53,7 @@ export interface IClientSideRowModel extends IRowModel { callback?: (res: RowNodeTransaction) => void ): void; flushAsyncTransactions(): void; - doAggregate(changedPath: ChangedPath): void; + doAggregate(changedPath: ChangedPath | undefined): void; getTopLevelNodes(): RowNode[] | null; getFormulaRow(index: number): RowNode; diff --git a/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts b/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts index 6fcc0a849fb..86c208f6d3e 100644 --- a/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts +++ b/packages/ag-grid-community/src/interfaces/iPivotResultColsService.ts @@ -14,4 +14,8 @@ export interface IPivotResultColsService { getPivotResultCol(key: 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. */ + getAggregationOrderedList(): AgColumn[] | null; } diff --git a/packages/ag-grid-community/src/interfaces/iRowNode.ts b/packages/ag-grid-community/src/interfaces/iRowNode.ts index b369115b22d..b1784dc9b1d 100644 --- a/packages/ag-grid-community/src/interfaces/iRowNode.ts +++ b/packages/ag-grid-community/src/interfaces/iRowNode.ts @@ -201,7 +201,7 @@ interface GroupRowNode { /** If using row grouping, contains the group values for this group. */ groupData: { [key: string]: any | null } | null; - /** If using row grouping and aggregation, contains the aggregation data. */ + /** If using row grouping and aggregation, contains the aggregation data. Created via `Object.create(null)` to avoid prototype conflicts. */ aggData: any; /** The row group column used for this group. */ @@ -226,11 +226,11 @@ interface GroupRowNode { allLeafChildren: IRowNode[] | null; /** Number of children and grand children. */ allChildrenCount: number | null; - /** Children of this group. If multi levels of grouping, shows only immediate children. */ + /** Children of this group. `null` for leaf nodes, non-empty array for groups. Never an empty array. */ childrenAfterGroup: IRowNode[] | null; - /** Sorted children of this group. */ + /** Sorted children of this group after aggregation filtering. `null` for leaf nodes. */ childrenAfterSort: IRowNode[] | null; - /** Filtered children of this group. */ + /** Filtered children of this group. `null` for leaf nodes. */ childrenAfterFilter: IRowNode[] | null; /** `true` if row is a footer. Footers have `group = true` and `footer = true`. */ diff --git a/packages/ag-grid-community/src/interfaces/iRowNodeStage.ts b/packages/ag-grid-community/src/interfaces/iRowNodeStage.ts index e20ef67125f..788e0eba0ae 100644 --- a/packages/ag-grid-community/src/interfaces/iRowNodeStage.ts +++ b/packages/ag-grid-community/src/interfaces/iRowNodeStage.ts @@ -11,21 +11,22 @@ export interface IRowNodeStage { } export interface IRowNodeSortStage extends IRowNodeStage { - execute(changedPath: ChangedPath, changedRowNodes: ChangedRowNodes | undefined): void; + execute(changedPath: ChangedPath | undefined, changedRowNodes: ChangedRowNodes | undefined): void; } export interface IRowNodeFilterStage extends IRowNodeStage { - execute(changedPath: ChangedPath): void; + execute(changedPath: ChangedPath | undefined): void; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface IRowNodePivotStage extends IRowNodeStage { - execute(changedPath: ChangedPath): void; + /** Returns `true` if the changedPath should be deactivated (e.g. pivot columns changed). */ + execute(changedPath: ChangedPath | undefined): boolean; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ export interface IRowNodeAggregationStage extends IRowNodeStage { - execute(changedPath: ChangedPath): void; + execute(changedPath: ChangedPath | undefined): void; /** * Returns the immediate children that contribute to the aggregation of a group RowNode. @@ -39,7 +40,7 @@ export interface IRowNodeAggregationStage extends IRowNodeStage extends IRowNodeStage { - execute(changedPath: ChangedPath): void; + execute(changedPath: ChangedPath | undefined): void; } /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ diff --git a/packages/ag-grid-community/src/main-internal.test.ts b/packages/ag-grid-community/src/main-internal.test.ts index 6bee4b612f0..6cdcf229969 100644 --- a/packages/ag-grid-community/src/main-internal.test.ts +++ b/packages/ag-grid-community/src/main-internal.test.ts @@ -110,6 +110,15 @@ function findDeclarationNode(sourceFile: ts.SourceFile, symbolName: string): ts. } } } + + // Named re-exports: export { Foo } from '...' or export { Bar as Foo } from '...' + if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { + for (const element of stmt.exportClause.elements) { + if (element.name.text === symbolName) { + return stmt; + } + } + } } return null; diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 2a79824e260..c9e802f4523 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -122,7 +122,7 @@ export { _setAriaSort, } from './agStack/utils/aria'; export type { AriaSortState } from './agStack/utils/aria'; -export { _areEqual, _EmptyArray, _flatten, _last, _removeAllFromArray, _removeFromArray } from './agStack/utils/array'; +export { _areEqual, _flatten, _last, _removeAllFromArray, _removeFromArray } from './agStack/utils/array'; export { _parseBigIntOrNull } from './agStack/utils/bigInt'; export { _isBrowserFirefox, _isBrowserSafari, _isIOSUserAgent } from './agStack/utils/browser'; export { _getDateParts, MONTHS as _MONTHS, _parseDateTimeFromString, _serialiseDate } from './agStack/utils/date'; @@ -493,7 +493,8 @@ export { } from './theming/parts/theme/themes'; export { _getShouldDisplayTooltip, _isShowTooltipWhenTruncated } from './tooltip/tooltipFeature'; export type { ITooltipCtrl, ITooltipCtrlParams, TooltipFeature } from './tooltip/tooltipFeature'; -export { ChangedPath } from './utils/changedPath'; +export type { ChangedPath } from './utils/changedPath'; +export { ChangedCellsPath, ChangedRowsPath, _forEachChangedGroupDepthFirst } from './utils/changedPath'; export { _createElement } from './utils/element'; export type { ElementParams } from './utils/element'; export { _isStopPropagationForAgGrid, _stopPropagationForAgGrid } from './utils/gridEvent'; diff --git a/packages/ag-grid-community/src/selection/selectionService.ts b/packages/ag-grid-community/src/selection/selectionService.ts index fd0e5ae8dba..6fcb5cef641 100644 --- a/packages/ag-grid-community/src/selection/selectionService.ts +++ b/packages/ag-grid-community/src/selection/selectionService.ts @@ -18,7 +18,8 @@ import type { IRowNode } from '../interfaces/iRowNode'; import type { ISelectionService, ISetNodesSelectedParams } from '../interfaces/iSelectionService'; import type { ServerSideRowGroupSelectionState, ServerSideRowSelectionState } from '../interfaces/selectionState'; import { _isManualPinnedRow } from '../pinnedRowModel/pinnedRowUtils'; -import { ChangedPath } from '../utils/changedPath'; +import type { ChangedPath } from '../utils/changedPath'; +import { _forEachChangedGroupDepthFirst } from '../utils/changedPath'; import { _error, _warn } from '../validation/logging'; import { BaseSelectionService } from './baseSelectionService'; @@ -283,21 +284,18 @@ export class SelectionService extends BaseSelectionService implements NamedBean, return false; } - if (!changedPath) { - changedPath = new ChangedPath(true, rootNode); - changedPath.active = false; - } - let selectionChanged = false; - changedPath.forEachChangedNodeDepthFirst((rowNode) => { + const nodeCallback = (rowNode: RowNode): void => { if (rowNode !== rootNode) { const selected = this.calculateSelectedFromChildren(rowNode); selectionChanged = this.selectRowNode(rowNode, selected === null ? false : selected, undefined, source) || selectionChanged; } - }); + }; + + _forEachChangedGroupDepthFirst(rootNode, changedPath, nodeCallback); return selectionChanged; } @@ -550,7 +548,12 @@ export class SelectionService extends BaseSelectionService implements NamedBean, // are considered as the current page const recursivelyAddChildren = (child: RowNode) => { addToResult(child); - child.childrenAfterFilter?.forEach(recursivelyAddChildren); + const children = child.childrenAfterFilter; + if (children) { + for (let i = 0, len = children.length; i < len; ++i) { + recursivelyAddChildren(children[i]); + } + } }; recursivelyAddChildren(node); return; @@ -675,40 +678,32 @@ export class SelectionService extends BaseSelectionService implements NamedBean, } const source: SelectionEventSourceType = 'selectableChanged'; - const skipLeafNodes = changedPath !== undefined; const isCSRMGroupSelectsDescendants = _isClientSideRowModel(gos) && this.groupSelectsDescendants; const nodesToDeselect: RowNode[] = []; - const nodeCallback = (node: RowNode): void => { - if (skipLeafNodes && !node.group) { - return; - } - - // Only in the CSRM, we allow group node selection if a child has a selectable=true when using groupSelectsChildren - if (isCSRMGroupSelectsDescendants && node.group) { - const hasSelectableChild = node.childrenAfterGroup?.some((rowNode) => rowNode.selectable) ?? false; - this.setRowSelectable(node, hasSelectableChild, true); - return; - } - - const rowSelectable = this.updateRowSelectable(node, true); - - if (!rowSelectable && node.isSelected()) { - nodesToDeselect.push(node); - } - }; - - // Needs to be depth first in this case, so that parents can be updated based on child. + // Post-order depth-first: child groups are processed before parents, so selectable state propagates up. if (isCSRMGroupSelectsDescendants) { - if (changedPath === undefined) { - const rootNode = (rowModel as IClientSideRowModel).rootNode; - changedPath = rootNode ? new ChangedPath(false, rootNode) : undefined; + const rootNode = (rowModel as IClientSideRowModel).rootNode; + if (rootNode) { + // isRowSelectable changed: update leaf children before checking group. + _forEachChangedGroupDepthFirst(rootNode, changedPath, (node) => { + let childSelectable = false; + for (const child of node.childrenAfterGroup!) { + childSelectable ||= child.selectable; + if (!child.group && !this.updateRowSelectable(child, true) && child.isSelected()) { + nodesToDeselect.push(child); + } + } + this.setRowSelectable(node, childSelectable, true); + }); } - changedPath?.forEachChangedNodeDepthFirst(nodeCallback, !skipLeafNodes, !skipLeafNodes); } else { - // Normal case, update all rows - rowModel.forEachNode(nodeCallback); + rowModel.forEachNode((node) => { + if (!this.updateRowSelectable(node, true) && node.isSelected()) { + nodesToDeselect.push(node); + } + }); } if (nodesToDeselect.length) { @@ -720,7 +715,7 @@ export class SelectionService extends BaseSelectionService implements NamedBean, } // if csrm and group selects children, update the groups after deselecting leaf nodes. - if (!skipLeafNodes && isCSRMGroupSelectsDescendants) { + if (!changedPath && isCSRMGroupSelectsDescendants) { this.updateGroupsFromChildrenSelections?.(source); } } diff --git a/packages/ag-grid-community/src/utils/changedPath.ts b/packages/ag-grid-community/src/utils/changedPath.ts index b74ed341d90..0c89444c041 100644 --- a/packages/ag-grid-community/src/utils/changedPath.ts +++ b/packages/ag-grid-community/src/utils/changedPath.ts @@ -1,203 +1,66 @@ -import type { AgColumn } from '../entities/agColumn'; import type { RowNode } from '../entities/rowNode'; +import type { ChangedCellsPath } from './changedPathImpl/changedCellsPath'; +import type { ChangedRowsPath } from './changedPathImpl/changedRowsPath'; -// the class below contains a tree of row nodes. each node is -// represented by a PathItem -interface PathItem { - rowNode: RowNode; // the node this item points to - children: PathItem[] | null; // children of this node - will be a subset of all the nodes children -} - -// when doing transactions, or change detection, and grouping is present -// in the data, there is no need for the ClientSideRowModel to update each -// group after an update, ony parts that were impacted by the change. -// this class keeps track of all groups that were impacted by a transaction. -// the the different CSRM operations (filter, sort etc) use the forEach method -// to visit each group that was changed. /** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ -export class ChangedPath { - // we keep columns when doing changed detection after user edits. - // when a user edits, we only need to re-aggregate the column - // that was edited. - private readonly keepingColumns: boolean; - - // the root path always points to RootNode, and RootNode - // is always in the changed path. over time, we add items to - // the path, but this stays as the root. when the changed path - // is ready, this will be the root of the tree of RowNodes that - // need to be refreshed (all the row nodes that were impacted by - // the transaction). - private readonly pathRoot: PathItem; - - // whether changed path is active of not. it is active when a) doing - // a transaction update or b) doing change detection. if we are doing - // a CSRM refresh for other reasons (after sort or filter, or user calling - // setRowData() without delta mode) then we are not active. we are also - // marked as not active if secondary columns change in pivot (as this impacts - // aggregations). - // can be set inactive by: - // a) ClientSideRowModel, if no transactions or - // b) PivotService, if secondary columns changed - public active = true; - - // for each node in the change path, we also store which columns need - // to be re-aggregated. - private nodeIdsToColumns: { [nodeId: string]: { [colId: string]: boolean } } = {}; - - // for quick lookup, all items in the change path are mapped by nodeId - private mapToItems: { [id: string]: PathItem } = {}; - - public constructor(keepingColumns: boolean, rootNode: RowNode) { - this.keepingColumns = keepingColumns; - - this.pathRoot = { - rowNode: rootNode, - children: null, - }; - this.mapToItems[rootNode.id!] = this.pathRoot; - } - - private depthFirstSearchChangedPath(pathItem: PathItem, callback: (rowNode: RowNode) => void): void { - const { rowNode, children } = pathItem; - if (children) { - for (let i = 0; i < children.length; ++i) { - this.depthFirstSearchChangedPath(children[i], callback); - } - } - callback(rowNode); - } - - private depthFirstSearchEverything( - rowNode: RowNode, - callback: (rowNode: RowNode) => void, - traverseEverything: boolean - ): void { - const childrenAfterGroup = rowNode.childrenAfterGroup; - if (childrenAfterGroup) { - for (let i = 0, len = childrenAfterGroup.length; i < len; ++i) { - const childNode = childrenAfterGroup[i]; - if (childNode.childrenAfterGroup) { - this.depthFirstSearchEverything(childNode, callback, traverseEverything); - } else if (traverseEverything) { - callback(childNode); - } - } - } - callback(rowNode); - } - - // traverseLeafNodes -> used when NOT doing changed path, ie traversing everything. the callback - // will be called for child nodes in addition to parent nodes. - public forEachChangedNodeDepthFirst( - callback: (rowNode: RowNode) => void, - traverseLeafNodes = false, - includeUnchangedNodes = false - ): void { - if (this.active && !includeUnchangedNodes) { - // if we are active, then use the change path to callback - // only for updated groups - this.depthFirstSearchChangedPath(this.pathRoot, callback); - } else { - // we are not active, so callback for everything, walk the entire path - this.depthFirstSearchEverything(this.pathRoot.rowNode, callback, traverseLeafNodes); - } - } - - public executeFromRootNode(callback: (rowNode: RowNode) => void) { - callback(this.pathRoot.rowNode); - } - - private createPathItems(rowNode: RowNode): number { - let pointer = rowNode; - let newEntryCount = 0; - while (!this.mapToItems[pointer.id!]) { - const newEntry: PathItem = { - rowNode: pointer, - children: null, - }; - this.mapToItems[pointer.id!] = newEntry; - newEntryCount++; - pointer = pointer.parent!; - } - return newEntryCount; - } - - private populateColumnsMap(rowNode: RowNode, columns: AgColumn[]): void { - if (!this.keepingColumns || !columns) { - return; - } - - let pointer = rowNode; - while (pointer) { - // if columns, add the columns in all the way to parent, merging - // in any other columns that might be there already - if (!this.nodeIdsToColumns[pointer.id!]) { - this.nodeIdsToColumns[pointer.id!] = {}; - } - for (const col of columns) { - this.nodeIdsToColumns[pointer.id!][col.getId()] = true; - } - pointer = pointer.parent!; +export { ChangedCellsPath } from './changedPathImpl/changedCellsPath'; +/** @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. */ +export { ChangedRowsPath } from './changedPathImpl/changedRowsPath'; + +/** + * Discriminated union of `ChangedRowsPath | ChangedCellsPath`. + * Both share `addRow`, `addCell`, `hasRow`, and `getSortedRows`. + * + * Narrow on `kind` to access cell-specific methods on `ChangedCellsPath`. + * + * ```ts + * changedPath.addCell(rowNode, colId); // works on both — ChangedRowsPath ignores colId + * if (changedPath.kind === 'cells') { + * changedPath.hasCellBySlot(rowSlot, colSlot); // cell-specific + * } + * ``` + * + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export type ChangedPath = ChangedRowsPath | ChangedCellsPath; + +const forEachGroupDepthFirst = (children: RowNode[], callback: (rowNode: RowNode) => void): void => { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; + const grandChildren = child.childrenAfterGroup; + if (grandChildren !== null) { + forEachGroupDepthFirst(grandChildren, callback); + callback(child); } } - - private linkPathItems(rowNode: RowNode, newEntryCount: number): void { - let pointer = rowNode; - for (let i = 0; i < newEntryCount; i++) { - const thisItem = this.mapToItems[pointer.id!]; - const parentItem = this.mapToItems[pointer.parent!.id!]; - if (!parentItem.children) { - parentItem.children = []; +}; + +/** + * Visits group nodes in post-order (deepest-first), skipping leaf nodes. + * When `changedPath` is provided, visits only changed nodes with `childrenAfterGroup` set. + * When `changedPath` is `null`/`undefined`, performs a full post-order traversal of nodes with `childrenAfterGroup`. + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export const _forEachChangedGroupDepthFirst = ( + rootNode: RowNode | null | undefined, + changedPath: ChangedPath | null | undefined, + callback: (rowNode: RowNode) => void +): void => { + if (changedPath != null) { + const rows = changedPath.getSortedRows(); + for (let i = 0, len = rows.length; i < len; ++i) { + const row = rows[i]; + if (row.childrenAfterGroup !== null && !row.destroyed) { + callback(row); } - parentItem.children.push(thisItem); - pointer = pointer.parent!; } + return; } - - // called by - // 1) change detection (provides cols) and - // 2) groupStage if doing transaction update (doesn't provide cols) - public addParentNode(rowNode: RowNode | null, columns?: AgColumn[]): void { - if (!rowNode || rowNode.isRowPinned()) { - return; + if (rootNode != null) { + const children = rootNode.childrenAfterGroup; + if (children !== null) { + forEachGroupDepthFirst(children, callback); + callback(rootNode); } - - // we cannot do both steps below in the same loop as - // the second loop has a dependency on the first loop. - // ie the hierarchy cannot be stitched up yet because - // we don't have it built yet - - // create the new PathItem objects. - const newEntryCount = this.createPathItems(rowNode); - - // link in the node items - this.linkPathItems(rowNode, newEntryCount); - - // update columns - this.populateColumnsMap(rowNode, columns!); - } - - public canSkip(rowNode: RowNode): boolean { - return this.active && !this.mapToItems[rowNode.id!]; - } - - public getValueColumnsForNode(rowNode: RowNode, valueColumns: AgColumn[]): AgColumn[] { - if (!this.keepingColumns) { - return valueColumns; - } - - const colsForThisNode = this.nodeIdsToColumns[rowNode.id!]; - const result = valueColumns.filter((col) => colsForThisNode[col.getId()]); - return result; - } - - public getNotValueColumnsForNode(rowNode: RowNode, valueColumns: AgColumn[]): AgColumn[] | null { - if (!this.keepingColumns) { - return null; - } - - const colsForThisNode = this.nodeIdsToColumns[rowNode.id!]; - const result = valueColumns.filter((col) => !colsForThisNode[col.getId()]); - return result; } -} +}; diff --git a/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.test.ts b/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.test.ts new file mode 100644 index 00000000000..c629cff8496 --- /dev/null +++ b/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.test.ts @@ -0,0 +1,535 @@ +import type { RowNode } from '../../entities/rowNode'; +import { ChangedCellsPath } from './changedCellsPath'; + +// ─── Minimal stubs ──────────────────────────────────────────────────────────── + +function makeNode(id: string, parent: RowNode | null = null): RowNode { + return { + id, + parent, + level: parent ? (parent as any).level + 1 : -1, + rowPinned: null, + childrenAfterGroup: null, + destroyed: false, + isRowPinned: () => false, + } as unknown as RowNode; +} + +function collectRows(path: ChangedCellsPath): RowNode[] { + return [...path.getSortedRows()]; +} + +function hasCol(path: ChangedCellsPath, node: RowNode, colId: string): boolean { + return path.hasCellBySlot(path.getSlot(node), path.getSlot(colId)); +} + +function makeChain(depth: number): RowNode[] { + const chain: RowNode[] = [makeNode('root')]; + for (let i = 1; i <= depth; ++i) { + chain.push(makeNode(`n${i}`, chain[i - 1])); + } + return chain; +} + +// ─── ChangedCellsPath-specific tests ───────────────────────────────────────── + +describe('ChangedCellsPath', () => { + describe('addRow — all-columns semantics', () => { + test('getSlot returns -1 for addRow node (all columns changed)', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addRow(leaf); + expect(path.getSlot(leaf)).toBe(-1); + expect(path.getSlot(root)).toBe(-1); + expect(hasCol(path, leaf, 'any')).toBe(true); + }); + }); + + describe('addCell — column tracking', () => { + test('column is registered on the leaf and all ancestors up to root', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'value'); + + expect(hasCol(path, leaf, 'value')).toBe(true); + expect(hasCol(path, leaf, 'other')).toBe(false); + expect(hasCol(path, group, 'value')).toBe(true); + expect(hasCol(path, root, 'value')).toBe(true); + }); + + test('two different columns on same leaf are both tracked', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + path.addCell(leaf, 'B'); + + expect(hasCol(path, leaf, 'A')).toBe(true); + expect(hasCol(path, leaf, 'B')).toBe(true); + }); + + test('column on one sibling is not visible on the other sibling', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf1 = makeNode('leaf1', group); + const leaf2 = makeNode('leaf2', group); + const path = new ChangedCellsPath(); + path.addCell(leaf1, 'A'); + path.addCell(leaf2, 'B'); + + expect(hasCol(path, leaf1, 'A')).toBe(true); + expect(hasCol(path, leaf1, 'B')).toBe(false); + expect(hasCol(path, leaf2, 'B')).toBe(true); + expect(hasCol(path, leaf2, 'A')).toBe(false); + expect(hasCol(path, group, 'A')).toBe(true); + expect(hasCol(path, group, 'B')).toBe(true); + expect(hasCol(path, root, 'A')).toBe(true); + expect(hasCol(path, root, 'B')).toBe(true); + }); + + test('adding same column twice on same node is idempotent', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'value'); + path.addCell(leaf, 'value'); + expect(hasCol(path, leaf, 'value')).toBe(true); + }); + + test('addCell on root registers column on root', () => { + const root = makeNode('root'); + const path = new ChangedCellsPath(); + path.addCell(root, 'A'); + + expect(hasCol(path, root, 'A')).toBe(true); + expect(collectRows(path)).toEqual([root]); + }); + + test('early-stop: second addCell with same column on cousin skips shared ancestors', () => { + const root = makeNode('root'); + const groupA = makeNode('groupA', root); + const groupB = makeNode('groupB', root); + const leafA = makeNode('leafA', groupA); + const leafB = makeNode('leafB', groupB); + const path = new ChangedCellsPath(); + path.addCell(leafA, 'X'); + path.addCell(leafB, 'X'); + + expect(hasCol(path, leafA, 'X')).toBe(true); + expect(hasCol(path, groupA, 'X')).toBe(true); + expect(hasCol(path, leafB, 'X')).toBe(true); + expect(hasCol(path, groupB, 'X')).toBe(true); + expect(hasCol(path, root, 'X')).toBe(true); + }); + + test('column propagated through all intermediate nodes in a deep chain', () => { + const chain = makeChain(10); + const path = new ChangedCellsPath(); + path.addCell(chain[10], 'deep'); + + for (let i = 0; i <= 10; i++) { + expect(hasCol(path, chain[i], 'deep')).toBe(true); + } + expect(hasCol(path, chain[5], 'other')).toBe(false); + }); + }); + + describe('getSlot + hasCellBySlot', () => { + test('getSlot returns -1 for node not in path', () => { + const root = makeNode('root'); + const other = makeNode('other', root); + const path = new ChangedCellsPath(); + path.addRow(root); + expect(path.getSlot(other)).toBe(-1); + expect(path.hasCellBySlot(-1, path.getSlot('any'))).toBe(true); + }); + + test('getSlot returns >= 0 for cell-tracked node', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + expect(path.getSlot(leaf)).toBeGreaterThanOrEqual(0); + }); + + test('hasCellBySlot returns true for tracked column, false for untracked', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + path.addCell(leaf, 'B'); + const idx = path.getSlot(leaf); + expect(idx).toBeGreaterThanOrEqual(0); + expect(path.hasCellBySlot(idx, path.getSlot('A'))).toBe(true); + expect(path.hasCellBySlot(idx, path.getSlot('B'))).toBe(true); + expect(path.hasCellBySlot(idx, path.getSlot('C'))).toBe(false); + }); + }); + + describe('mixed addRow/addCell usage', () => { + test('addRow then addCell on same node — all columns still changed', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const path = new ChangedCellsPath(); + path.addRow(leaf); + path.addCell(leaf, 'A'); + + expect(hasCol(path, leaf, 'A')).toBe(true); + expect(hasCol(path, leaf, 'anything')).toBe(true); + expect(hasCol(path, group, 'A')).toBe(true); + expect(hasCol(path, root, 'A')).toBe(true); + }); + + test('addCell then addRow on same node — upgrades to all columns', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + expect(path.getSlot(leaf)).toBeGreaterThanOrEqual(0); + path.addRow(leaf); + expect(path.getSlot(leaf)).toBe(-1); + expect(hasCol(path, leaf, 'anything')).toBe(true); + }); + + test('addCell with colId then addCell with null colId — upgrades to all columns', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + expect(path.getSlot(leaf)).toBeGreaterThanOrEqual(0); + path.addCell(leaf, null); + expect(path.getSlot(leaf)).toBe(-1); + expect(hasCol(path, leaf, 'anything')).toBe(true); + }); + + test('addCell then addRow on different node in same path', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf1 = makeNode('leaf1', group); + const leaf2 = makeNode('leaf2', group); + const path = new ChangedCellsPath(); + path.addCell(leaf1, 'A'); + path.addRow(leaf2); + + expect(hasCol(path, leaf1, 'A')).toBe(true); + expect(hasCol(path, group, 'A')).toBe(true); + expect(hasCol(path, root, 'A')).toBe(true); + expect(path.getSlot(leaf2)).toBe(-1); + expect(hasCol(path, leaf2, 'A')).toBe(true); + expect(hasCol(path, leaf2, 'anything')).toBe(true); + }); + + test('addRow on ancestor does not affect cell-tracked descendant', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const path = new ChangedCellsPath(); + path.addCell(leaf, 'A'); + path.addRow(group); + + // leaf still cell-tracked + expect(path.getSlot(leaf)).toBeGreaterThanOrEqual(0); + expect(hasCol(path, leaf, 'A')).toBe(true); + expect(hasCol(path, leaf, 'B')).toBe(false); + // group upgraded to all-columns + expect(path.getSlot(group)).toBe(-1); + expect(hasCol(path, group, 'anything')).toBe(true); + }); + + test('addCell after addRow on sibling — both coexist', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const path = new ChangedCellsPath(); + path.addRow(leaf1); + path.addCell(leaf2, 'X'); + + expect(path.getSlot(leaf1)).toBe(-1); + expect(hasCol(path, leaf1, 'anything')).toBe(true); + const leaf2Id = path.getSlot(leaf2); + expect(leaf2Id).toBeGreaterThanOrEqual(0); + expect(path.hasCellBySlot(leaf2Id, path.getSlot('X'))).toBe(true); + expect(path.hasCellBySlot(leaf2Id, path.getSlot('Y'))).toBe(false); + }); + + test('interleaved addRow and addCell on many nodes', () => { + const root = makeNode('root'); + const nodes: RowNode[] = []; + for (let i = 0; i < 20; i++) { + nodes.push(makeNode(`n${i}`, root)); + } + const path = new ChangedCellsPath(); + for (let i = 0; i < 20; i++) { + if (i % 2 === 0) { + path.addRow(nodes[i]); + } else { + path.addCell(nodes[i], `col${i}`); + } + } + for (let i = 0; i < 20; i++) { + expect(path.hasRow(nodes[i])).toBe(true); + if (i % 2 === 0) { + expect(path.getSlot(nodes[i])).toBe(-1); + } else { + expect(path.getSlot(nodes[i])).toBeGreaterThanOrEqual(0); + expect(hasCol(path, nodes[i], `col${i}`)).toBe(true); + } + } + }); + }); + + describe('many columns (word growth)', () => { + test('handles exactly 32 columns without word growth', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 32; i++) { + path.addCell(leaf, `col${i}`); + } + const idx = path.getSlot(leaf); + expect(idx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 32; i++) { + expect(path.hasCellBySlot(idx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(idx, path.getSlot('col32'))).toBe(false); + }); + + test('handles 33 columns (first word growth: 1→2 words)', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 33; i++) { + path.addCell(leaf, `col${i}`); + } + const idx = path.getSlot(leaf); + expect(idx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 33; i++) { + expect(path.hasCellBySlot(idx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(idx, path.getSlot('col33'))).toBe(false); + }); + + test('handles 64 columns (fills 2 words exactly)', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 64; i++) { + path.addCell(leaf, `col${i}`); + } + const idx = path.getSlot(leaf); + expect(idx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 64; i++) { + expect(path.hasCellBySlot(idx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(idx, path.getSlot('col64'))).toBe(false); + }); + + test('handles 65 columns (word growth: 2→3 words)', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 65; i++) { + path.addCell(leaf, `col${i}`); + } + const idx = path.getSlot(leaf); + expect(idx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 65; i++) { + expect(path.hasCellBySlot(idx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(idx, path.getSlot('col65'))).toBe(false); + }); + + test('handles 100 columns (multiple word growths)', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const path = new ChangedCellsPath(); + for (let i = 0; i < 100; i++) { + path.addCell(leaf, `col${i}`); + } + const leafIdx = path.getSlot(leaf); + const groupIdx = path.getSlot(group); + const rootIdx = path.getSlot(root); + expect(leafIdx).toBeGreaterThanOrEqual(0); + expect(groupIdx).toBeGreaterThanOrEqual(0); + expect(rootIdx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 100; i++) { + expect(path.hasCellBySlot(leafIdx, path.getSlot(`col${i}`))).toBe(true); + expect(path.hasCellBySlot(groupIdx, path.getSlot(`col${i}`))).toBe(true); + expect(path.hasCellBySlot(rootIdx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(leafIdx, path.getSlot('col100'))).toBe(false); + }); + + test('handles 129 columns (5 words)', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 129; i++) { + path.addCell(leaf, `col${i}`); + } + const leafIdx = path.getSlot(leaf); + const rootIdx = path.getSlot(root); + expect(leafIdx).toBeGreaterThanOrEqual(0); + expect(rootIdx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 129; i++) { + expect(path.hasCellBySlot(leafIdx, path.getSlot(`col${i}`))).toBe(true); + expect(path.hasCellBySlot(rootIdx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(leafIdx, path.getSlot('col129'))).toBe(false); + }); + + test('word growth preserves all-columns sentinel (-1)', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const path = new ChangedCellsPath(); + path.addRow(leaf1); + // Add 40 columns on leaf2, triggering word growth + for (let i = 0; i < 40; i++) { + path.addCell(leaf2, `col${i}`); + } + expect(path.getSlot(leaf1)).toBe(-1); + expect(hasCol(path, leaf1, 'anything')).toBe(true); + const leaf2Idx = path.getSlot(leaf2); + expect(leaf2Idx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 40; i++) { + expect(path.hasCellBySlot(leaf2Idx, path.getSlot(`col${i}`))).toBe(true); + } + }); + + test('word growth with multiple rows preserves all data', () => { + const root = makeNode('root'); + const groupA = makeNode('groupA', root); + const groupB = makeNode('groupB', root); + const leafA = makeNode('leafA', groupA); + const leafB = makeNode('leafB', groupB); + const path = new ChangedCellsPath(); + path.addCell(leafA, 'colA'); + path.addCell(leafB, 'colB'); + // Force word growth + for (let i = 0; i < 40; i++) { + path.addCell(leafA, `extra${i}`); + } + expect(hasCol(path, leafA, 'colA')).toBe(true); + expect(hasCol(path, leafA, 'colB')).toBe(false); + expect(hasCol(path, leafB, 'colB')).toBe(true); + expect(hasCol(path, leafB, 'colA')).toBe(false); + expect(hasCol(path, root, 'colA')).toBe(true); + expect(hasCol(path, root, 'colB')).toBe(true); + }); + + test('word growth with many rows and many columns', () => { + const root = makeNode('root'); + const leaves: RowNode[] = []; + for (let i = 0; i < 10; i++) { + leaves.push(makeNode(`leaf${i}`, root)); + } + const path = new ChangedCellsPath(); + for (let i = 0; i < 10; i++) { + for (let c = 0; c < 40; c++) { + path.addCell(leaves[i], `col_${i}_${c}`); + } + } + for (let i = 0; i < 10; i++) { + const idx = path.getSlot(leaves[i]); + expect(idx).toBeGreaterThanOrEqual(0); + for (let c = 0; c < 40; c++) { + expect(path.hasCellBySlot(idx, path.getSlot(`col_${i}_${c}`))).toBe(true); + } + const otherLeaf = (i + 1) % 10; + expect(path.hasCellBySlot(idx, path.getSlot(`col_${otherLeaf}_0`))).toBe(false); + } + for (let i = 0; i < 10; i++) { + for (let c = 0; c < 40; c++) { + expect(hasCol(path, root, `col_${i}_${c}`)).toBe(true); + } + } + }); + + test('word growth interleaved with addRow', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const leaf3 = makeNode('leaf3', root); + const path = new ChangedCellsPath(); + + for (let i = 0; i < 20; i++) { + path.addCell(leaf1, `col${i}`); + } + path.addRow(leaf2); + for (let i = 20; i < 40; i++) { + path.addCell(leaf1, `col${i}`); + } + for (let i = 0; i < 10; i++) { + path.addCell(leaf3, `other${i}`); + } + + const leaf1Id = path.getSlot(leaf1); + expect(leaf1Id).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 40; i++) { + expect(path.hasCellBySlot(leaf1Id, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.getSlot(leaf2)).toBe(-1); + expect(hasCol(path, leaf2, 'anything')).toBe(true); + const leaf3Id = path.getSlot(leaf3); + expect(leaf3Id).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 10; i++) { + expect(path.hasCellBySlot(leaf3Id, path.getSlot(`other${i}`))).toBe(true); + } + }); + + test('no false positives at 32-column boundary', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedCellsPath(); + for (let i = 0; i < 32; i++) { + path.addCell(leaf, `col${i}`); + } + const leafIdx = path.getSlot(leaf); + const rootIdx = path.getSlot(root); + expect(leafIdx).toBeGreaterThanOrEqual(0); + expect(rootIdx).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 32; i++) { + expect(path.hasCellBySlot(leafIdx, path.getSlot(`col${i}`))).toBe(true); + } + expect(path.hasCellBySlot(leafIdx, path.getSlot('col32'))).toBe(false); + }); + + test('200 columns across a deep chain (7 words)', () => { + const chain = makeChain(5); + const path = new ChangedCellsPath(); + for (let i = 0; i < 200; i++) { + path.addCell(chain[5], `c${i}`); + } + for (let n = 0; n <= 5; n++) { + const id = path.getSlot(chain[n]); + expect(id).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 200; i++) { + expect(path.hasCellBySlot(id, path.getSlot(`c${i}`))).toBe(true); + } + } + }); + }); + + describe('cache invalidation', () => { + test('addCell after getSortedRows — new node appears in next traversal', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const path = new ChangedCellsPath(); + + path.addCell(leaf1, 'value'); + collectRows(path); // triggers sort + + path.addCell(leaf2, 'value'); + const visited = collectRows(path); + expect(visited).toContain(leaf2); + expect(visited).toContain(leaf1); + }); + }); +}); diff --git a/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.ts b/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.ts new file mode 100644 index 00000000000..5d9e8f373c3 --- /dev/null +++ b/packages/ag-grid-community/src/utils/changedPathImpl/changedCellsPath.ts @@ -0,0 +1,247 @@ +import type { RowNode } from '../../entities/rowNode'; +import { _sortNodesByDepthFirst } from '../sortNodesByDepthFirst'; + +/** + * Tracks changed rows and which columns changed on each, using bitmasks for fast lookups. + * + * Used by the CSRM pipeline to skip unchanged rows/columns during aggregation. + * Rows added via `addRow` are marked as all-columns-changed. + * Rows added via `addCell` track specific columns. + * + * A single Map stores both row and column mappings — RowNode keys hold the row's bitmask + * index (or -1 for all-columns), string keys hold the column slot number. Object and string + * keys never collide in a Map. Changed columns are stored as bitmask arrays of 32-bit integers, + * one array per group of 32 columns. + * + * Total space: O(R × ⌈C/32⌉ + C), where R = tracked rows (including ancestors), C = tracked columns. + * + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export class ChangedCellsPath { + readonly kind = 'cells' as const; + + /** + * All tracked rows, lazily sorted by depth-first when `getSortedRows` is called. + * Space: O(R) where R = number of tracked rows (including ancestors). + */ + private rows: RowNode[] = []; + + /** + * True when `rows` needs resorting, set after new tracked rows are added. + * Space: O(1). + */ + private unsorted: boolean = false; + + /** + * Maps RowNode to row slot (or -1 for all-columns) and colId string to column slot. + * Used also to check if a row is tracked (has a slot) in O(1). + * RowNode keys never collide with string keys in a Map. + * Space: O(R + C) where R = number of tracked rows (including ancestors), C = number of tracked columns. + */ + private readonly slots: Map = new Map(); + + /** + * Bitmask array for the first 32 columns. Always present. + * Space: O(R) where R = number of tracked rows (including ancestors). + */ + private readonly bits: number[] = []; + + /** + * Extra bitmask arrays for columns 32+. `null` when C ≤ 32; `extraBits[0]` covers columns 32–63, etc. + * Space: O(R × max(0, ⌈C/32⌉ − 1)) where R = number of tracked rows (including ancestors), C = number of tracked columns. + */ + private extraBits: number[][] | null = null; + + /** + * Number of distinct column IDs added via `addCell`, used to assign column slots. + * Does not include columns tracked via `addRow` (all-columns). Time: O(1). + * Space: O(1). + */ + private colCount: number = 0; + + /** + * Adds `rowNode` and all its ancestors. All columns are considered changed. No-op if null/undefined. + * Time: O(D), D = depth. + * Space: O(D) for new ancestors. + */ + public addRow(rowNode: RowNode | null | undefined): void { + if (rowNode == null) { + return; + } + const slots = this.slots; + let node: RowNode | null = rowNode; + if (slots.get(node) !== undefined) { + // Upgrade cell-tracked ancestors to all-columns until we hit one already at -1. + while (node != null && slots.get(node)! >= 0) { + slots.set(node, -1); + node = node.parent; + } + return; + } + const rows = this.rows; + do { + slots.set(node, -1); + rows.push(node); + node = node.parent; + } while (node != null && !slots.has(node)); + this.unsorted = true; + } + + /** + * Adds `rowNode` and its ancestors with a specific column marked as changed. + * When `colId` is `null`/`undefined`, delegates to `addRow` (all columns changed). + * Time: O(D × ⌈C/32⌉), D = depth, C = number of tracked columns. + * Space: O(D × ⌈C/32⌉) for new ancestors. + */ + public addCell(rowNode: RowNode | null | undefined, colId: string | null | undefined): void { + if (colId == null) { + this.addRow(rowNode); + return; + } + if (rowNode == null) { + return; + } + const slots = this.slots; + const bits = this.bits; + const colSlot = slots.get(colId) ?? this.ensureCol(colId); + let rowSlot = slots.get(rowNode); + if (rowSlot === undefined) { + rowSlot = this.ensureRow(rowNode); + } else if (rowSlot < 0) { + return; // already all-columns-changed + } + // extraBits is guaranteed non-null when colSlot >= 32 because ensureCol populates it, + // and colSlot can only come from the slots Map which was set by ensureCol. + const word = colSlot < 32 ? bits : this.extraBits![(colSlot >>> 5) - 1]; + const bit = 1 << (colSlot & 31); + const rowBits = word[rowSlot]; + if ((rowBits & bit) !== 0) { + return; // already marked + } + word[rowSlot] = rowBits | bit; + // Propagate bit up the ancestor chain. All ancestors are registered by ensureRow. + let p = rowNode.parent; + while (p != null) { + const pSlot = slots.get(p)!; + if (pSlot < 0) { + break; + } + const pBits = word[pSlot]; + if ((pBits & bit) !== 0) { + break; + } + word[pSlot] = pBits | bit; + p = p.parent; + } + } + + /** + * Returns true if `rowNode` is tracked (added via `addRow` or `addCell`, or as an ancestor of either). + * Time: O(1). + */ + public hasRow(rowNode: RowNode): boolean { + return this.slots.has(rowNode); + } + + /** + * Returns the changed rows sorted deepest-first. Cached — do not modify the returned array. + * Time: O(1) cached, O(R) if sort was invalidated. + * Space: O(1) best case if sort happens in place, O(R) where R = number of tracked rows (including ancestors) worst case. + */ + public getSortedRows(): RowNode[] { + if (!this.unsorted) { + return this.rows; + } + this.unsorted = false; + const rows = _sortNodesByDepthFirst(this.rows); + this.rows = rows; + return rows; + } + + /** + * Returns the slot index for a row or column, or -1 if not tracked. + * For RowNode keys, -1 also means all-columns-changed (via `addRow`). + * Read-only — does not allocate slots. + * Time: O(1). + */ + public getSlot(key: RowNode | string): number { + return this.slots.get(key) ?? -1; + } + + /** + * Returns true if the column is changed for the row. Always true when `rowSlot < 0`. + * Time: O(1). + * + * ```ts + * const rowSlot = path.getSlot(rowNode); + * if (path.hasCellBySlot(rowSlot, colSlot)) { … } + * ``` + */ + public hasCellBySlot(rowSlot: number, colSlot: number): boolean { + if (rowSlot < 0) { + return true; + } + if (colSlot < 32) { + return colSlot >= 0 && (this.bits[rowSlot] & (1 << colSlot)) !== 0; + } + return (this.extraBits![(colSlot >>> 5) - 1][rowSlot] & (1 << (colSlot & 31))) !== 0; + } + + /** Registers a new row and all its unregistered ancestors. Returns the row's bitmask index. + * Time: O(D × ⌈C/32⌉) where D = depth, C = number of tracked columns. In practice O(D) because + * C < 32 is the common case (single bitmask word per row, no extraBits loop). + * Space: O(D × ⌈C/32⌉). + */ + private ensureRow(rowNode: RowNode): number { + const slots = this.slots; + const rows = this.rows; + const bits = this.bits; + const extraBits = this.extraBits; + // bits.push(0) returns the new length; originSlot is one less (the index just pushed). + let nextSlot = bits.push(0); + const originSlot = nextSlot - 1; + if (extraBits !== null) { + for (let w = 0, len = extraBits.length; w < len; ++w) { + extraBits[w].push(0); + } + } + slots.set(rowNode, originSlot); + rows.push(rowNode); + this.unsorted = true; + let p = rowNode.parent; + while (p != null && !slots.has(p)) { + slots.set(p, nextSlot); + rows.push(p); + nextSlot = bits.push(0); + if (extraBits !== null) { + for (let w = 0, len = extraBits.length; w < len; ++w) { + extraBits[w].push(0); + } + } + p = p.parent; + } + return originSlot; + } + + /** + * Assigns a new column slot. Appends a bitmask array when crossing a 32-column boundary. + * Time: O(1) amortised, O(R) when crossing a 32-column boundary, where R = number of tracked rows (including ancestors) due to array extension and copying. + * Space: O(R) when crossing a 32-column boundary. + */ + private ensureCol(colId: string): number { + const colSlot = this.colCount++; + this.slots.set(colId, colSlot); + if (colSlot >= 32) { + const extraBitsIndex = (colSlot >>> 5) - 1; + let extraBits = this.extraBits; + if (extraBits === null) { + extraBits = []; + this.extraBits = extraBits; + } + if (extraBitsIndex >= extraBits.length) { + extraBits.push(new Array(this.bits.length).fill(0)); + } + } + return colSlot; + } +} diff --git a/packages/ag-grid-community/src/utils/changedPathImpl/changedPath.test.ts b/packages/ag-grid-community/src/utils/changedPathImpl/changedPath.test.ts new file mode 100644 index 00000000000..bae90bb69cc --- /dev/null +++ b/packages/ag-grid-community/src/utils/changedPathImpl/changedPath.test.ts @@ -0,0 +1,294 @@ +import type { RowNode } from '../../entities/rowNode'; +import type { ChangedPath } from '../changedPath'; +import { ChangedCellsPath } from './changedCellsPath'; +import { ChangedRowsPath } from './changedRowsPath'; + +// ─── Shared stubs ──────────────────────────────────────────────────────────── + +function makeNode(id: string, parent: RowNode | null = null): RowNode { + return { + id, + parent, + level: parent ? (parent as any).level + 1 : -1, + rowPinned: null, + childrenAfterGroup: null, + destroyed: false, + isRowPinned: () => false, + } as unknown as RowNode; +} + +function collectRows(path: ChangedPath): RowNode[] { + return [...path.getSortedRows()]; +} + +function makeChain(depth: number): RowNode[] { + const chain: RowNode[] = [makeNode('root')]; + for (let i = 1; i <= depth; ++i) { + chain.push(makeNode(`n${i}`, chain[i - 1])); + } + return chain; +} + +function makeFlatTree(leafCount: number): { root: RowNode; leaves: RowNode[] } { + const root = makeNode('root'); + const leaves: RowNode[] = []; + for (let i = 0; i < leafCount; i++) { + leaves.push(makeNode(`leaf${i}`, root)); + } + return { root, leaves }; +} + +function makeWideBranchTree(branches: number, depth: number): { root: RowNode; leaves: RowNode[] } { + const root = makeNode('root'); + const leaves: RowNode[] = []; + for (let b = 0; b < branches; b++) { + let parent = root; + for (let d = 1; d <= depth; d++) { + parent = makeNode(`b${b}_d${d}`, parent); + } + leaves.push(parent); + } + return { root, leaves }; +} + +// ─── Shared interface tests ────────────────────────────────────────────────── + +describe.each([ + { label: 'ChangedRowsPath', create: () => new ChangedRowsPath() }, + { label: 'ChangedCellsPath', create: () => new ChangedCellsPath() }, +])('$label — shared ChangedPath interface', ({ create }) => { + describe('addRow', () => { + test('null or undefined rowNode is a no-op', () => { + const path = create(); + path.addRow(null); + path.addRow(undefined); + expect(collectRows(path)).toEqual([]); + }); + + test('adds direct child of root', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = create(); + path.addRow(leaf); + expect(collectRows(path)).toEqual([leaf, root]); + }); + + test('adds leaf and its ancestry up to root', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const path = create(); + path.addRow(leaf); + expect(collectRows(path)).toEqual([leaf, group, root]); + }); + + test('adding same node twice does not duplicate', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = create(); + path.addRow(leaf); + path.addRow(leaf); + expect(collectRows(path)).toEqual([leaf, root]); + }); + + test('two siblings share the same parent — parent visited once', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf1 = makeNode('leaf1', group); + const leaf2 = makeNode('leaf2', group); + const path = create(); + path.addRow(leaf1); + path.addRow(leaf2); + + const visited = collectRows(path); + expect(visited.filter((n) => n === group)).toHaveLength(1); + expect(visited.indexOf(leaf1)).toBeLessThan(visited.indexOf(group)); + expect(visited.indexOf(leaf2)).toBeLessThan(visited.indexOf(group)); + expect(visited.indexOf(group)).toBeLessThan(visited.indexOf(root)); + }); + + test('two separate subtrees both visited', () => { + const root = makeNode('root'); + const groupA = makeNode('groupA', root); + const groupB = makeNode('groupB', root); + const leafA = makeNode('leafA', groupA); + const leafB = makeNode('leafB', groupB); + const path = create(); + path.addRow(leafA); + path.addRow(leafB); + + const visited = collectRows(path); + expect(visited).toContain(leafA); + expect(visited).toContain(groupA); + expect(visited).toContain(leafB); + expect(visited).toContain(groupB); + expect(visited).toContain(root); + expect(visited.indexOf(leafA)).toBeLessThan(visited.indexOf(groupA)); + expect(visited.indexOf(leafB)).toBeLessThan(visited.indexOf(groupB)); + }); + }); + + describe('addCell', () => { + test('null or undefined rowNode is a no-op', () => { + const path = create(); + path.addCell(null, 'col'); + path.addCell(undefined, 'col'); + path.addCell(null, null); + path.addCell(undefined, undefined); + expect(collectRows(path)).toEqual([]); + }); + + test('null or undefined colId delegates to addRow', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const path = create(); + path.addCell(leaf1, null); + path.addCell(leaf2, undefined); + expect(path.hasRow(leaf1)).toBe(true); + expect(path.hasRow(leaf2)).toBe(true); + expect(path.hasRow(root)).toBe(true); + }); + }); + + describe('hasRow', () => { + test('returns false for a node not in the path', () => { + const root = makeNode('root'); + const other = makeNode('other', root); + const path = create(); + expect(path.hasRow(other)).toBe(false); + }); + + test('returns true for a node that was added', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = create(); + path.addRow(leaf); + expect(path.hasRow(leaf)).toBe(true); + }); + }); + + describe('getSortedRows', () => { + test('empty path returns empty array', () => { + const path = create(); + expect(collectRows(path)).toEqual([]); + }); + + describe.each([ + { label: 'linear chain depth=1 (2 nodes)', depth: 1 }, + { label: 'linear chain depth=2 (3 nodes)', depth: 2 }, + { label: 'linear chain depth=5 (6 nodes)', depth: 5 }, + { label: 'linear chain depth=15 (16 nodes, insertion sort boundary)', depth: 15 }, + { label: 'linear chain depth=16 (17 nodes, counting sort)', depth: 16 }, + { label: 'linear chain depth=31 (32 nodes)', depth: 31 }, + { label: 'linear chain depth=99 (100 nodes)', depth: 99 }, + { label: 'linear chain depth=500 (501 nodes, triggers buffer grow)', depth: 500 }, + ])('$label', ({ depth }) => { + test('sorts deepest-first', () => { + const chain = makeChain(depth); + const root = chain[0]; + const leaf = chain[depth]; + const path = create(); + path.addRow(leaf); + + const nodes = collectRows(path); + expect(nodes).toHaveLength(depth + 1); + expect(nodes[0]).toBe(leaf); + expect(nodes.at(-1)).toBe(root); + for (let i = 0; i < nodes.length - 1; i++) { + expect(nodes[i].level).toBeGreaterThanOrEqual(nodes[i + 1].level); + } + }); + }); + + describe.each([ + { label: 'flat 2 leaves (3 nodes, 2 levels)', leafCount: 2 }, + { label: 'flat 15 leaves (16 nodes, insertion sort boundary, 2 levels)', leafCount: 15 }, + { label: 'flat 16 leaves (17 nodes, sortRotate)', leafCount: 16 }, + { label: 'flat 31 leaves (32 nodes, sortRotate)', leafCount: 31 }, + { label: 'flat 99 leaves (100 nodes, sortRotate)', leafCount: 99 }, + ])('$label', ({ leafCount }) => { + test('sorts deepest-first — all leaves before root', () => { + const { root, leaves } = makeFlatTree(leafCount); + const path = create(); + for (const leaf of leaves) { + path.addRow(leaf); + } + + const nodes = collectRows(path); + expect(nodes).toHaveLength(leafCount + 1); + expect(nodes.at(-1)).toBe(root); + for (let i = 0; i < leafCount; i++) { + expect(nodes[i].level).toBeGreaterThan(root.level); + } + }); + }); + + test('sort is stable — same-level nodes preserve input order', () => { + const { leaves } = makeFlatTree(30); + const path = create(); + for (const leaf of leaves) { + path.addRow(leaf); + } + + const nodes = collectRows(path); + for (let i = 0; i < leaves.length; i++) { + expect(nodes[i]).toBe(leaves[i]); + } + }); + + describe.each([ + { label: '5 branches depth=3 (16 nodes, insertion sort boundary)', branches: 5, depth: 3 }, + { label: '4 branches depth=4 (17 nodes, counting sort)', branches: 4, depth: 4 }, + { label: '6 branches depth=5 (31 nodes, multi-level)', branches: 6, depth: 5 }, + { label: '10 branches depth=10 (101 nodes, multi-level)', branches: 10, depth: 10 }, + ])('wide tree: $label', ({ branches, depth }) => { + test('sorts deepest-first with multiple levels', () => { + const { root, leaves } = makeWideBranchTree(branches, depth); + const path = create(); + for (const leaf of leaves) { + path.addRow(leaf); + } + + const nodes = collectRows(path); + for (let i = 0; i < nodes.length - 1; i++) { + expect(nodes[i].level).toBeGreaterThanOrEqual(nodes[i + 1].level); + } + expect(nodes.at(-1)).toBe(root); + for (const leaf of leaves) { + expect(nodes).toContain(leaf); + } + }); + }); + }); + + describe('cache invalidation', () => { + test('addRow after getSortedRows — new node appears in next traversal', () => { + const root = makeNode('root'); + const leaf1 = makeNode('leaf1', root); + const leaf2 = makeNode('leaf2', root); + const path = create(); + + path.addRow(leaf1); + const firstVisit = collectRows(path); + expect(firstVisit).toEqual([leaf1, root]); + + path.addRow(leaf2); + const secondVisit = collectRows(path); + expect(secondVisit).toContain(leaf2); + expect(secondVisit).toContain(leaf1); + expect(secondVisit).toContain(root); + }); + + test('multiple reads without mutation reuse cached order', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = create(); + path.addRow(leaf); + + const first = collectRows(path); + const second = collectRows(path); + expect(first).toEqual(second); + }); + }); +}); diff --git a/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.test.ts b/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.test.ts new file mode 100644 index 00000000000..0bfb6cbd0b3 --- /dev/null +++ b/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.test.ts @@ -0,0 +1,207 @@ +import type { RowNode } from '../../entities/rowNode'; +import type { ChangedPath } from '../changedPath'; +import { _forEachChangedGroupDepthFirst } from '../changedPath'; +import { ChangedRowsPath } from './changedRowsPath'; + +// ─── Minimal stubs ──────────────────────────────────────────────────────────── + +function makeNode(id: string, parent: RowNode | null = null, opts: { children?: RowNode[] } = {}): RowNode { + return { + id, + parent, + level: parent ? (parent as any).level + 1 : -1, + rowPinned: null, + childrenAfterGroup: opts.children ?? null, + destroyed: false, + isRowPinned: () => false, + } as unknown as RowNode; +} + +function collectGroups(root: RowNode, path: ChangedPath | undefined): RowNode[] { + const nodes: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, path, (n) => nodes.push(n)); + return nodes; +} + +function collectRows(path: ChangedPath): RowNode[] { + return [...path.getSortedRows()]; +} + +// ─── ChangedRowsPath-specific tests ────────────────────────────────────────── + +describe('ChangedRowsPath', () => { + describe('addCell delegates to addRow — colId is ignored', () => { + test('addCell with colId tracks the row, not the column', () => { + const root = makeNode('root'); + const leaf = makeNode('leaf', root); + const path = new ChangedRowsPath(); + path.addCell(leaf, 'someColumn'); + expect(path.hasRow(leaf)).toBe(true); + expect(path.hasRow(root)).toBe(true); + expect(collectRows(path)).toEqual([leaf, root]); + }); + }); + + describe('traversal', () => { + test('visits only changed nodes depth-first', () => { + const root = makeNode('root'); + const group = makeNode('group', root); + const leaf = makeNode('leaf', group); + const unrelated = makeNode('unrelated', root); + const path = new ChangedRowsPath(); + path.addRow(leaf); + + const visited = collectRows(path); + expect(visited).not.toContain(unrelated); + expect(visited).toEqual([leaf, group, root]); + }); + + test('undefined _forEachChangedGroupDepthFirst: visits all group nodes, skipping leaves', () => { + const leaf1 = makeNode('leaf1'); + const leaf2 = makeNode('leaf2'); + const root = makeNode('root', null, { children: [leaf1, leaf2] }); + + const visited = collectGroups(root, undefined); + expect(visited).toContain(root); + expect(visited).not.toContain(leaf1); + expect(visited).not.toContain(leaf2); + }); + + test('undefined _forEachChangedGroupDepthFirst: child with childrenAfterGroup=null is skipped', () => { + const nonGroupChild = makeNode('nonGroup'); + const root = makeNode('root', null, { children: [nonGroupChild] }); + + const visited = collectGroups(root, undefined); + expect(visited).toContain(root); + expect(visited).not.toContain(nonGroupChild); + }); + + test('_forEachChangedGroupDepthFirst with null changedPath always does full group traversal', () => { + const root = makeNode('root', null, { children: [makeNode('leaf1')] }); + + const visitedAll: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visitedAll.push(n)); + expect(visitedAll).toContain(root); + }); + }); + + describe('_forEachChangedGroupDepthFirst with changedPath', () => { + test('only visits nodes with childrenAfterGroup set', () => { + const root = makeNode('root', null, { children: [] }); + const group = makeNode('group', root, { children: [] }); + const leaf = makeNode('leaf', group); + const path = new ChangedRowsPath(); + path.addRow(leaf); + + // All three are in getSortedRows + const sorted = collectRows(path); + expect(sorted).toContain(leaf); + expect(sorted).toContain(group); + expect(sorted).toContain(root); + + // _forEachChangedGroupDepthFirst visits only nodes with childrenAfterGroup + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, path, (n) => visited.push(n)); + expect(visited).toContain(root); + expect(visited).toContain(group); + expect(visited).not.toContain(leaf); // leaf has no childrenAfterGroup + + // Clearing childrenAfterGroup causes a node to be skipped + (group as any).childrenAfterGroup = null; + const visited2: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, path, (n) => visited2.push(n)); + expect(visited2).toContain(root); + expect(visited2).not.toContain(group); + }); + }); + + describe('level changes after addRow', () => { + test('traversal uses current levels, not levels at addRow time', () => { + const root = makeNode('root'); + const groupA = makeNode('groupA', root); + const groupB = makeNode('groupB', root); + const leaf = makeNode('leaf', groupA); + const path = new ChangedRowsPath(); + path.addRow(leaf); + + (leaf as any).parent = groupB; + (leaf as any).level = 3; + + path.addRow(groupB); + + const visited = collectRows(path); + expect(visited.indexOf(leaf)).toBeLessThan(visited.indexOf(groupB)); + expect(visited.indexOf(groupB)).toBeLessThan(visited.indexOf(root)); + }); + }); + + describe('getSortedRows — deep chain with branch', () => { + test('two branches at different depths — both sorted correctly', () => { + const chain: RowNode[] = [makeNode('root')]; + for (let i = 1; i <= 500; ++i) { + chain.push(makeNode(`n${i}`, chain[i - 1])); + } + const root = chain[0]; + const shallowLeaf = makeNode('shallow', chain[3]); + const path = new ChangedRowsPath(); + path.addRow(chain[500]); + path.addRow(shallowLeaf); + + const visited = collectRows(path); + expect(visited.indexOf(shallowLeaf)).toBeLessThan(visited.indexOf(chain[3])); + expect(visited[0]).toBe(chain[500]); + expect(visited.at(-1)).toBe(root); + }); + }); +}); + +// ─── Standalone traversal function tests ───────────────────────────────────── + +describe('_forEachChangedGroupDepthFirst', () => { + test('root with no childrenAfterGroup (leaf root) is not visited', () => { + const root = makeNode('root'); + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visited.push(n)); + expect(visited).toEqual([]); + }); + + test('root with empty children (group root) visits only root', () => { + const root = makeNode('root', null, { children: [] }); + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visited.push(n)); + expect(visited).toEqual([root]); + }); + + test('leaf children (no childrenAfterGroup) are skipped', () => { + const leaf1 = makeNode('leaf1'); + const leaf2 = makeNode('leaf2'); + const root = makeNode('root', null, { children: [leaf1, leaf2] }); + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visited.push(n)); + expect(visited).toEqual([root]); + }); + + test('group children are recursed depth-first and each group visited exactly once', () => { + const leaf = makeNode('leaf'); + const group = makeNode('group', null, { children: [leaf] }); + const root = makeNode('root', null, { children: [group] }); + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visited.push(n)); + expect(visited).toHaveLength(2); + expect(visited).not.toContain(leaf); + expect(visited.indexOf(group)).toBeLessThan(visited.indexOf(root)); + }); + + test('mixed tree visits only group nodes', () => { + const leaf1 = makeNode('leaf1'); + const leaf2 = makeNode('leaf2'); + const groupA = makeNode('groupA', null, { children: [leaf1] }); + const root = makeNode('root', null, { children: [groupA, leaf2] }); + const visited: RowNode[] = []; + _forEachChangedGroupDepthFirst(root, null, (n) => visited.push(n)); + expect(visited).toContain(groupA); + expect(visited).toContain(root); + expect(visited).not.toContain(leaf1); + expect(visited).not.toContain(leaf2); + }); +}); diff --git a/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.ts b/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.ts new file mode 100644 index 00000000000..b5890df4cb1 --- /dev/null +++ b/packages/ag-grid-community/src/utils/changedPathImpl/changedRowsPath.ts @@ -0,0 +1,80 @@ +import type { RowNode } from '../../entities/rowNode'; +import { _sortNodesByDepthFirst } from '../sortNodesByDepthFirst'; + +/** + * Set-based ChangedPath — no column tracking. + * All columns are considered changed for every node in the path. + * + * Total space: O(R), where R = number of tracked rows (including ancestors). + * + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export class ChangedRowsPath { + readonly kind = 'rows' as const; + + /** + * All tracked rows, lazily sorted by depth-first when `getSortedRows` is called. + * Space: O(R) where R = number of tracked rows (including ancestors). + */ + private rows: RowNode[] = []; + + /** + * True when `rows` needs resorting, set after new tracked rows are added. + * Space: O(1). + */ + private unsorted: boolean = false; + + /** + * Hash set that keeps track of which rows are in `rows` for O(1) lookup. + * Space: O(R) where R = number of tracked rows (including ancestors). + */ + private readonly rowSet: Set = new Set(); + + /** + * Adds `rowNode` and all its ancestors. No-op if null/undefined or already present. + * Time: O(D), D = depth. + * Space: O(D) for new ancestors + */ + public addRow(rowNode: RowNode | null | undefined): void { + if (rowNode == null) { + return; + } + const rowSet = this.rowSet; + if (rowSet.has(rowNode)) { + return; + } + const rows = this.rows; + let node: RowNode | null = rowNode; + do { + rowSet.add(node); + rows.push(node); + node = node.parent; + } while (node != null && !rowSet.has(node)); + this.unsorted = true; + } + + /** Delegates to `addRow` — column tracking is ignored for `ChangedRowsPath`. */ + public addCell(rowNode: RowNode | null | undefined, _colId: string | null | undefined): void { + this.addRow(rowNode); + } + + /** Time: O(1). */ + public hasRow(rowNode: RowNode): boolean { + return this.rowSet.has(rowNode); + } + + /** + * Returns the changed rows sorted deepest-first. Cached — do not modify the returned array. + * Time: O(1) cached, O(R) if sort was invalidated. + * Space: O(1) best case if sort happens in place, O(R) where R = number of tracked rows (including ancestors) worst case. + */ + public getSortedRows(): RowNode[] { + if (!this.unsorted) { + return this.rows; + } + this.unsorted = false; + const rows = _sortNodesByDepthFirst(this.rows); + this.rows = rows; + return rows; + } +} diff --git a/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.test.ts b/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.test.ts new file mode 100644 index 00000000000..a4acc053b25 --- /dev/null +++ b/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.test.ts @@ -0,0 +1,250 @@ +import type { RowNode } from '../entities/rowNode'; +import { _sortNodesByDepthFirst } from './sortNodesByDepthFirst'; + +function makeNode(id: string, level: number): RowNode { + return { id, level } as unknown as RowNode; +} + +/** Asserts the result is in deepest-first (non-increasing level) order. */ +function expectDeepestFirst(nodes: RowNode[]): void { + for (let i = 1; i < nodes.length; ++i) { + expect(nodes[i].level).toBeLessThanOrEqual(nodes[i - 1].level); + } +} + +describe('_sortNodesByDepthFirst', () => { + test('empty array returns same array', () => { + const input: RowNode[] = []; + expect(_sortNodesByDepthFirst(input)).toBe(input); + }); + + test('single element returns same array', () => { + const input = [makeNode('a', 3)]; + expect(_sortNodesByDepthFirst(input)).toBe(input); + }); + + test('two elements already sorted returns same array', () => { + const input = [makeNode('a', 5), makeNode('b', 2)]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // already deepest-first → no copy + }); + + test('two elements reversed swaps in-place', () => { + const a = makeNode('a', 2); + const b = makeNode('b', 5); + const input = [a, b]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // swapped in-place + expect(result).toEqual([b, a]); // deepest first + }); + + test('all same level returns same array', () => { + const nodes = [makeNode('a', 3), makeNode('b', 3), makeNode('c', 3)]; + expect(_sortNodesByDepthFirst(nodes)).toBe(nodes); + }); + + // ── Basic sorting ───────────────────────────────────────────────────────── + + test('sorts mixed levels deepest-first', () => { + const n0 = makeNode('n0', 0); + const n1 = makeNode('n1', 1); + const n2 = makeNode('n2', 2); + const n3 = makeNode('n3', 3); + const input = [n1, n3, n0, n2]; + const result = _sortNodesByDepthFirst(input); + expect(result).toEqual([n3, n2, n1, n0]); + }); + + test('preserves relative order of nodes at the same level (stable within bucket)', () => { + const a = makeNode('a', 2); + const b = makeNode('b', 2); + const c = makeNode('c', 2); + const d = makeNode('d', 0); + const input = [a, d, b, c]; + const result = _sortNodesByDepthFirst(input); + // a, b, c are all level 2 — should appear before d (level 0) + // within level 2, order should be preserved (a, b, c) + expect(result).toEqual([a, b, c, d]); + }); + + test('handles root level -1', () => { + const root = makeNode('root', -1); + const child = makeNode('child', 0); + const grandchild = makeNode('grandchild', 1); + const input = [child, root, grandchild]; + const result = _sortNodesByDepthFirst(input); + expect(result).toEqual([grandchild, child, root]); + }); + + test('already sorted deepest-first returns same array (early exit)', () => { + const input = [makeNode('a', 5), makeNode('b', 3), makeNode('c', 1), makeNode('d', 0)]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); + }); + + test('already sorted with equal adjacent levels returns same array', () => { + const input = [makeNode('a', 5), makeNode('b', 5), makeNode('c', 3), makeNode('d', 3)]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); + }); + + test('small unsorted array sorts in-place', () => { + const n0 = makeNode('n0', 0); + const n1 = makeNode('n1', 1); + const n2 = makeNode('n2', 2); + const input = [n0, n1, n2]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // sorted in-place + expect(result).toEqual([n2, n1, n0]); + }); + + test('20 nodes across 5 levels', () => { + const levels = [0, 1, 2, 3, 4, 4, 3, 2, 1, 0, 2, 4, 1, 3, 0, 4, 2, 1, 3, 0]; + const nodes = levels.map((l, i) => makeNode(`n${i}`, l)); + const result = _sortNodesByDepthFirst(nodes); + expect(result).toHaveLength(20); + expectDeepestFirst(result); + // All original nodes present + expect(new Set(result)).toEqual(new Set(nodes)); + }); + + test('100 nodes with random levels', () => { + const nodes: RowNode[] = []; + for (let i = 0; i < 100; ++i) { + nodes.push(makeNode(`n${i}`, ((i * 7 + 3) % 10) - 1)); + } + const result = _sortNodesByDepthFirst(nodes); + expect(result).toHaveLength(100); + expectDeepestFirst(result); + expect(new Set(result)).toEqual(new Set(nodes)); + }); + + test('200 levels deep triggers buffer grow', () => { + const nodes: RowNode[] = []; + for (let i = 0; i <= 200; ++i) { + nodes.push(makeNode(`n${i}`, i)); + } + const result = _sortNodesByDepthFirst(nodes); + expect(result).toHaveLength(201); + expectDeepestFirst(result); + expect(result[0].level).toBe(200); + expect(result[200].level).toBe(0); + }); + + test('500 levels deep sorts correctly', () => { + const nodes: RowNode[] = []; + for (let i = 0; i <= 500; ++i) { + nodes.push(makeNode(`n${i}`, i - 1)); // levels -1 to 499 + } + const result = _sortNodesByDepthFirst(nodes); + expect(result).toHaveLength(501); + expectDeepestFirst(result); + expect(result[0].level).toBe(499); + expect(result[500].level).toBe(-1); + }); + + test('multiple sorts reuse cleaned buckets correctly', () => { + for (let round = 0; round < 5; ++round) { + const nodes = [makeNode('a', round), makeNode('b', round + 2), makeNode('c', round + 1)]; + const result = _sortNodesByDepthFirst(nodes); + expectDeepestFirst(result); + expect(result[0].level).toBe(round + 2); + expect(result[2].level).toBe(round); + } + }); + + test('alternating sorted and unsorted inputs', () => { + // Sorted input (early exit) + const sorted = [makeNode('a', 5), makeNode('b', 3), makeNode('c', 1)]; + expect(_sortNodesByDepthFirst(sorted)).toBe(sorted); + + // Unsorted input (small array, sorted in-place) + const unsorted = [makeNode('d', 1), makeNode('e', 5), makeNode('f', 3)]; + const result = _sortNodesByDepthFirst(unsorted); + expect(result).toBe(unsorted); // small array sorted in-place + expectDeepestFirst(result); + + // Another sorted input (early exit, buckets should be clean) + const sorted2 = [makeNode('g', 4), makeNode('h', 2), makeNode('i', 0)]; + expect(_sortNodesByDepthFirst(sorted2)).toBe(sorted2); + }); + + test('root at index 0 + all same level sorts in-place', () => { + const root = makeNode('root', -1); + const a = makeNode('a', 0); + const b = makeNode('b', 0); + const c = makeNode('c', 0); + const input = [root, a, b, c]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // sorted in-place + expect(result).toEqual([a, b, c, root]); + }); + + test('root at index 0 + all same level preserves order of non-root nodes', () => { + const root = makeNode('root', -1); + const nodes = Array.from({ length: 20 }, (_, i) => makeNode(`n${i}`, 0)); + const input = [root, ...nodes]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // sorted in-place + expect(result).toEqual([...nodes, root]); + }); + + test('root at index 0 + single non-adjacent level sorts in-place', () => { + const root = makeNode('root', -1); + const a = makeNode('a', 5); + const b = makeNode('b', 5); + const input = [root, a, b]; + const result = _sortNodesByDepthFirst(input); + expect(result).toBe(input); // sorted in-place + expect(result).toEqual([a, b, root]); + }); + + test('root NOT at index 0 sorts correctly', () => { + const root = makeNode('root', -1); + const a = makeNode('a', 0); + const b = makeNode('b', 0); + const input = [a, root, b]; + const result = _sortNodesByDepthFirst(input); + expectDeepestFirst(result); + expect(result).toBe(input); // small array, sorted in-place + expect(result).toEqual([a, b, root]); + }); + + test('counting sort is stable: nodes at same level preserve input order', () => { + const nodes = [ + makeNode('first-L2', 2), + makeNode('L0', 0), + makeNode('second-L2', 2), + makeNode('L1', 1), + makeNode('third-L2', 2), + ]; + const result = _sortNodesByDepthFirst(nodes); + // Level-2 nodes should appear in original order: first, second, third + const level2 = result.filter((n) => n.level === 2); + expect(level2.map((n) => n.id)).toEqual(['first-L2', 'second-L2', 'third-L2']); + }); + + test('1000 levels triggers multiple buffer reallocs and sorts correctly', () => { + const nodes: RowNode[] = []; + // Interleave deep and shallow nodes to prevent sorted early-exit + for (let i = 0; i <= 1000; ++i) { + nodes.push(makeNode(`n${i}`, i % 2 === 0 ? i : 1000 - i)); + } + const result = _sortNodesByDepthFirst(nodes); + expect(result).toHaveLength(1001); + expectDeepestFirst(result); + expect(result[0].level).toBe(1000); + expect(result.at(-1)!.level).toBe(0); + // All original nodes present + expect(new Set(result)).toEqual(new Set(nodes)); + }); + + test('sort after realloc still works for small arrays', () => { + // After the 1000-level test grew the buffer, verify small sorts still work + const a = makeNode('a', 0); + const b = makeNode('b', 2); + const c = makeNode('c', 1); + const result = _sortNodesByDepthFirst([a, b, c]); + expect(result).toEqual([b, c, a]); + }); +}); diff --git a/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.ts b/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.ts new file mode 100644 index 00000000000..c62dab4ba49 --- /dev/null +++ b/packages/ag-grid-community/src/utils/sortNodesByDepthFirst.ts @@ -0,0 +1,182 @@ +import type { RowNode } from '../entities/rowNode'; +import type { IRowNode } from '../interfaces/iRowNode'; + +/** + * Reusable counting-sort bucket buffer, one entry per tree level. + * Lazily allocated on first sort. Self-cleaning: only used entries are zeroed after each sort. + * Grows by power of two (never shrunk); initial size 64 entries (256 bytes). + */ +let _sortBuckets: Uint32Array | null = null; + +/** Cold path: allocates or grows the bucket buffer. */ +const sortBucketsAlloc = (minBucket: number): Uint32Array => { + const old = _sortBuckets; + const newBuckets = new Uint32Array(1 << (32 - Math.clz32(minBucket | 63))); + if (old) { + newBuckets.set(old); // preserve counts accumulated so far + } + _sortBuckets = newBuckets; + return newBuckets; +}; + +/** + * In-place stable two-level partition: deep nodes first, then shallow. + * Single-element minorities use copyWithin (zero allocation). + * General case buffers the shallow minority into a short-lived local array. + */ +const sortTwoLevels = (nodes: IRowNode[], nodeCount: number, deepest: number, deepCount: number): IRowNode[] => { + const shallowCount = nodeCount - deepCount; + const deepLevel = deepest - 1; // precomputed to avoid repeated + 1 in tight loops + + // Single shallow node — find it and rotate to end. Zero allocation. + if (shallowCount === 1) { + let si = 0; + while (nodes[si].level === deepLevel) { + ++si; + } + if (si < nodeCount - 1) { + const shallow = nodes[si]; + nodes.copyWithin(si, si + 1); + nodes[nodeCount - 1] = shallow; + } + return nodes; + } + + // Single deep node — find it and rotate to front. Zero allocation. + if (deepCount === 1) { + let di = 0; + while (nodes[di].level !== deepLevel) { + ++di; + } + if (di > 0) { + const deep = nodes[di]; + nodes.copyWithin(1, 0, di); + nodes[0] = deep; + } + return nodes; + } + + // General case: buffer shallow nodes, compact deep nodes left, append shallow. + const shallow = new Array(shallowCount); + let di = 0; + let si = 0; + for (let i = 0; i < nodeCount; ++i) { + const node = nodes[i]; + if (node.level === deepLevel) { + nodes[di++] = node; + } else { + shallow[si++] = node; + } + } + for (let i = 0; i < shallowCount; ++i) { + nodes[deepCount + i] = shallow[i]; + } + return nodes; +}; + +const countingSort = (nodes: IRowNode[], nodesLen: number): RowNode[] => { + // Single-pass: find level range, check sorted order, and count per-level. + // `unsorted` accumulates sign bits: `prevB - b` is negative when a shallower node + // precedes a deeper one. `unsorted >= 0` => already sorted. + let deepest = nodes[0].level + 1; + let shallowest = deepest; + let unsorted = 0; + let prevB = deepest; + + // Bucket index = level + 1 (because root is -1) + let buckets = _sortBuckets; + if (!buckets || deepest >= buckets.length) { + buckets = sortBucketsAlloc(deepest); + } + ++buckets[deepest]; + + for (let i = 1; i < nodesLen; ++i) { + const b = nodes[i].level + 1; + if (b > deepest) { + deepest = b; + if (deepest >= buckets.length) { + buckets = sortBucketsAlloc(deepest); + } + } else if (b < shallowest) { + shallowest = b; + } + ++buckets[b]; + unsorted |= prevB - b; + prevB = b; + } + + if (unsorted >= 0) { + buckets.fill(0, shallowest, deepest + 1); // clean up counts + return nodes as RowNode[]; + } + + const sCount = buckets[shallowest]; + const dCount = buckets[deepest]; + + // Fast path: exactly 2 distinct levels. + if (sCount + dCount === nodesLen) { + buckets[shallowest] = 0; + buckets[deepest] = 0; + return sortTwoLevels(nodes, nodesLen, deepest, dCount) as RowNode[]; + } + + // Prefix-sum — convert counts to write cursors, deepest bucket at position 0. + let pos = 0; + for (let b = deepest; b >= shallowest; --b) { + const count = buckets[b]; + buckets[b] = pos; + pos += count; + } + + // Scatter — sequential writes per bucket for cache-friendly output. + const output = new Array(nodesLen); + for (let i = 0; i < nodesLen; ++i) { + const node = nodes[i]; + output[buckets[node.level + 1]++] = node; + } + + buckets.fill(0, shallowest, deepest + 1); // self-clean for next call + return output as RowNode[]; +}; + +/** + * Sorts `nodes` by `RowNode.level` descending (deepest first). + * Returns the input array (mutated in-place) or a new sorted array. + * The sort is stable: nodes at the same level preserve their input order. + * + * @internal AG_GRID_INTERNAL - Not for public use. Can change / be removed at any time. + */ +export const _sortNodesByDepthFirst = (nodes: IRowNode[], nodesLen = nodes.length): RowNode[] => { + // Just two nodes - swap them if we need to, O(1). + if (nodesLen === 2) { + if (nodes[0].level < nodes[1].level) { + const tmp = nodes[0]; + nodes[0] = nodes[1]; + nodes[1] = tmp; + } + return nodes as RowNode[]; + } + + if (nodesLen > 16) { + // More than 16 nodes, use counting sort, which is O(n) + return countingSort(nodes, nodesLen); + } + + // Insertion sort for tiny arrays, where overhead of counting sort isn't worth it. + // O(n*n) but very efficient for data sets that are already substantially sorted. + // https://en.wikipedia.org/wiki/Insertion_sort + for (let i = 1; i < nodesLen; i++) { + const value = nodes[i]; + const valueLevel = value.level; + let j = i - 1; + if (nodes[j].level < valueLevel) { + let k = i; + do { + nodes[k] = nodes[j]; + k = j--; + } while (j >= 0 && nodes[j].level < valueLevel); + nodes[k] = value; + } + } + return nodes as RowNode[]; +}; diff --git a/packages/ag-grid-community/src/valueService/changeDetectionService.ts b/packages/ag-grid-community/src/valueService/changeDetectionService.ts index fddf4d0f967..522085fc719 100644 --- a/packages/ag-grid-community/src/valueService/changeDetectionService.ts +++ b/packages/ag-grid-community/src/valueService/changeDetectionService.ts @@ -1,11 +1,10 @@ import type { NamedBean } from '../context/bean'; import { BeanStub } from '../context/beanStub'; -import type { AgColumn } from '../entities/agColumn'; import type { RowNode } from '../entities/rowNode'; import type { CellValueChangedEvent } from '../events'; import { _isClientSideRowModel } from '../gridOptionsUtils'; import type { IClientSideRowModel } from '../interfaces/iClientSideRowModel'; -import { ChangedPath } from '../utils/changedPath'; +import { ChangedCellsPath, ChangedRowsPath, _forEachChangedGroupDepthFirst } from '../utils/changedPath'; // Matches value in clipboard module const SOURCE_PASTE = 'paste'; @@ -44,13 +43,12 @@ export class ChangeDetectionService extends BeanStub implements NamedBean { // step 1 of change detection is to update the aggregated values if (rootNode && !rowNode.isRowPinned()) { - const onlyChangedColumns = gos.get('aggregateOnlyChangedColumns'); - const changedPath = new ChangedPath(onlyChangedColumns, rootNode); - changedPath.addParentNode(rowNode.parent, [event.column as AgColumn]); + const changedPath = gos.get('aggregateOnlyChangedColumns') ? new ChangedCellsPath() : new ChangedRowsPath(); + changedPath.addCell(rowNode.parent, event.column?.getId()); clientSideRowModel.doAggregate(changedPath); // add all nodes impacted by aggregation, as they need refreshed also. - changedPath.forEachChangedNodeDepthFirst((rowNode) => { + _forEachChangedGroupDepthFirst(rootNode, changedPath, (rowNode) => { nodesToRefresh.push(rowNode); if (rowNode.sibling) { nodesToRefresh.push(rowNode.sibling); diff --git a/packages/ag-grid-community/src/version.ts b/packages/ag-grid-community/src/version.ts index 52fd5df6f2e..212ad79624e 100644 --- a/packages/ag-grid-community/src/version.ts +++ b/packages/ag-grid-community/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '35.1.0-beta.20260309.1858'; +export const VERSION = '35.1.0-beta.20260312.1528'; diff --git a/packages/ag-grid-enterprise/package.json b/packages/ag-grid-enterprise/package.json index 891047a6f95..4370e255419 100644 --- a/packages/ag-grid-enterprise/package.json +++ b/packages/ag-grid-enterprise/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-enterprise", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "Advanced Data Grid / Data Table supporting Javascript / Typescript / React / Angular / Vue", "main": "./dist/package/main.cjs.js", "types": "./dist/types/src/main.d.ts", @@ -113,15 +113,15 @@ ], "homepage": "https://www.ag-grid.com/", "dependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858" + "ag-grid-community": "35.1.0-beta.20260312.1528" }, "optionalDependencies": { - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309" + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312" }, "devDependencies": { - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309", + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312", "@types/jest": "^29.5.0", "jest": "^29.5.0", "jest-environment-jsdom": "^29.7.0", diff --git a/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts b/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts new file mode 100644 index 00000000000..51e3dd08dac --- /dev/null +++ b/packages/ag-grid-enterprise/src/aggregation/aggDataUtils.ts @@ -0,0 +1,90 @@ +import type { ColumnModel, RowNode } from 'ag-grid-community'; + +/** Sets aggData and fires cell-changed events if listeners are registered. */ +export const setAggData = (rowNode: RowNode, newAggData: Record | null, colModel: ColumnModel): void => { + const oldAggData = rowNode.aggData; + if (oldAggData === newAggData) { + return; + } + rowNode.aggData = newAggData; + if (rowNode.__localEventService) { + fireAggDataChangedEvents(rowNode, oldAggData, newAggData, colModel); + } +}; + +/** Sets aggData on a row node and all its siblings (footer + pinned). */ +export const setAggDataWithSiblings = ( + rowNode: RowNode, + newAggData: Record | null, + colModel: ColumnModel +): void => { + setAggData(rowNode, newAggData, colModel); + + const pinnedSibling = rowNode.pinnedSibling; + if (pinnedSibling) { + setAggData(pinnedSibling, newAggData, colModel); + } + + const sibling = rowNode.sibling; + if (sibling) { + setAggData(sibling, newAggData, colModel); + + const siblingPinnedSibling = sibling.pinnedSibling; + if (siblingPinnedSibling) { + setAggData(siblingPinnedSibling, newAggData, colModel); + } + } +}; + +/** Cold path: dispatches cell-changed events for changed/added/removed agg values. */ +const fireAggDataChangedEvents = ( + rowNode: RowNode, + oldAggData: Record | null | undefined, + newAggData: Record | null, + colModel: ColumnModel +): void => { + if (!newAggData) { + if (!oldAggData) { + return; + } + const oldKeys = Object.keys(oldAggData); + for (let i = 0, len = oldKeys.length; i < len; ++i) { + const colId = oldKeys[i]; + const column = colModel.getColById(colId); + if (column) { + rowNode.dispatchCellChangedEvent(column, undefined, oldAggData[colId]); + } + } + return; + } + + const newKeys = Object.keys(newAggData); + for (let i = 0, len = newKeys.length; i < len; ++i) { + const colId = newKeys[i]; + const value = newAggData[colId]; + const oldValue = oldAggData ? oldAggData[colId] : undefined; + if (value === oldValue) { + continue; + } + const column = colModel.getColById(colId); + if (column) { + rowNode.dispatchCellChangedEvent(column, value, oldValue); + } + } + + // Detect removed columns (old key not present in new aggData). + if (!oldAggData) { + return; + } + const oldKeys = Object.keys(oldAggData); + for (let i = 0, len = oldKeys.length; i < len; ++i) { + const colId = oldKeys[i]; + if (colId in newAggData) { + continue; + } + const column = colModel.getColById(colId); + if (column) { + rowNode.dispatchCellChangedEvent(column, undefined, oldAggData[colId]); + } + } +}; diff --git a/packages/ag-grid-enterprise/src/aggregation/aggUtils.ts b/packages/ag-grid-enterprise/src/aggregation/aggUtils.ts deleted file mode 100644 index 0e0edd02267..00000000000 --- a/packages/ag-grid-enterprise/src/aggregation/aggUtils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { AgColumn, BeanCollection, IAggFunc, IAggFuncParams, RowNode } from 'ag-grid-community'; -import { _addGridCommonParams, _warn } from 'ag-grid-community'; - -interface AggregateValuesParams { - beans: BeanCollection; - values: any[]; - aggFuncOrString: string | IAggFunc; - column: AgColumn; - /** The row node being aggregated. Required for CSRM aggregation, undefined for integrated charts. */ - rowNode: RowNode | undefined; - /** The pivot result column when aggregating pivot data. */ - pivotResultColumn: AgColumn | undefined; - /** The children nodes contributing to this aggregation. Required for CSRM aggregation, empty array for integrated charts. */ - aggregatedChildren: RowNode[]; -} - -export function _aggregateValues({ - beans, - values, - aggFuncOrString, - column, - rowNode, - pivotResultColumn, - aggregatedChildren, -}: AggregateValuesParams): any { - const aggFunc = - typeof aggFuncOrString === 'string' ? beans.aggFuncSvc!.getAggFunc(aggFuncOrString) : aggFuncOrString; - - if (typeof aggFunc !== 'function') { - _warn(109, { inputValue: aggFuncOrString.toString(), allSuggestions: beans.aggFuncSvc!.getFuncNames(column) }); - return null; - } - - const params: IAggFuncParams = _addGridCommonParams(beans.gos, { - values, - column, - colDef: column.colDef, - pivotResultColumn, - rowNode: rowNode!, // this is typed incorrectly. Within CSRM, this will always be defined. When called from integrated charts, this will never be defined. - data: rowNode?.data, - aggregatedChildren, - }); - - return aggFunc(params); -} diff --git a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts index c2dedc99f23..f47bfdc7f5c 100644 --- a/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts +++ b/packages/ag-grid-enterprise/src/aggregation/aggregationStage.ts @@ -1,33 +1,56 @@ import type { AgColumn, - BeanCollection, + ChangedCellsPath, ChangedPath, ClientSideRowModelStage, + ColDef, ColumnModel, - GetGroupRowAggParams, GridOptions, - IColsService, + IAggFunc, + IAggFuncService, IPivotResultColsService, NamedBean, RowNode, ValueService, - WithoutGridCommon, _IRowNodeAggregationStage, } from 'ag-grid-community'; -import { BeanStub, _getGrandTotalRow, _getGroupAggFiltering, _isClientSideRowModel } from 'ag-grid-community'; - -import { _aggregateValues } from './aggUtils'; - -interface AggregationDetails { - alwaysAggregateAtRootLevel: boolean; - groupIncludeTotalFooter: boolean; - changedPath: ChangedPath; - valueColumns: AgColumn[]; - pivotColumns: AgColumn[]; - filteredOnly: boolean; - userAggFunc: ((params: WithoutGridCommon>) => any) | undefined; +import { + BeanStub, + _forEachChangedGroupDepthFirst, + _getGrandTotalRow, + _getGroupAggFiltering, + _isClientSideRowModel, + _warn, +} from 'ag-grid-community'; + +import { setAggData, setAggDataWithSiblings } from './aggDataUtils'; + +/** Pre-resolved value column metadata for the per-group aggregation loop. */ +interface ResolvedValueColumn { + column: AgColumn; + colId: string; + colDef: ColDef; + aggFunc: IAggFunc | null; + /** Bitmask slot for ChangedCellsPath column tracking. -1 when inactive. */ + colSlot: number; } +/** Pre-resolved pivot result column for the pivot aggregation loop. */ +interface ResolvedPivotColumn { + column: AgColumn; + colId: string; + aggFunc: IAggFunc | null; + /** The secondary (pivot result) column produced by this aggregation. */ + pivotResultCol: AgColumn; + /** Pivot key path for leaf-group child lookup via childrenMapped. */ + pivotKeys: string[] | null | undefined; + /** Column IDs whose results are aggregated into this total. Defined only for total columns. */ + totalColIds: string[] | undefined; +} + +/** Resolved pivot columns: regular columns come first, totals appended after. */ +type ResolvedPivotData = ResolvedPivotColumn[]; + export class AggregationStage extends BeanStub implements NamedBean, _IRowNodeAggregationStage { beanName = 'aggStage' as const; @@ -39,392 +62,394 @@ export class AggregationStage extends BeanStub implements NamedBean, _IRowNodeAg 'grandTotalRow', ]; - private clientSide: boolean = false; - private colModel: ColumnModel; - private valueSvc: ValueService; - private pivotColsSvc?: IColsService; - private valueColsSvc?: IColsService; - private pivotResultCols?: IPivotResultColsService; - - public wireBeans(beans: BeanCollection) { - this.colModel = beans.colModel; - this.pivotColsSvc = beans.pivotColsSvc; - this.valueColsSvc = beans.valueColsSvc; - this.pivotResultCols = beans.pivotResultCols; - this.valueSvc = beans.valueSvc; - this.clientSide = _isClientSideRowModel(beans.gos); - } - - // it's possible to recompute the aggregate without doing the other parts - // + api.refreshClientSideRowModel('aggregate') - public execute(changedPath: ChangedPath): any { - // if changed path is active, it means we came from a) change detection or b) transaction update. - // for both of these, if no value columns are present, it means there is nothing to aggregate now - // and there is no cleanup to be done (as value columns don't change between transactions or change - // detections). if no value columns and no changed path, means we have to go through all nodes in - // case we need to clean up agg data from before. - const noValueColumns = !this.valueColsSvc?.columns?.length; - const noUserAgg = !this.gos.getCallback('getGroupRowAgg'); - if (noValueColumns && noUserAgg && changedPath?.active) { - return; - } + /** Tracks whether the previous execute() call produced aggData, so we only clear once on transition. */ + private hadAgg = false; - const aggDetails = this.createAggDetails(changedPath); + /** Cached once — row model type never changes after init. */ + private csrm = false; - this.recursivelyCreateAggData(aggDetails); + public postConstruct(): void { + this.csrm = _isClientSideRowModel(this.gos); } - private createAggDetails(changedPath: ChangedPath): AggregationDetails { - const pivotActive = this.colModel.isPivotActive(); - - const measureColumns = this.valueColsSvc?.columns; - const pivotColumns = pivotActive && this.pivotColsSvc ? this.pivotColsSvc.columns : []; - - const aggDetails: AggregationDetails = { - alwaysAggregateAtRootLevel: this.gos.get('alwaysAggregateAtRootLevel'), - groupIncludeTotalFooter: !!_getGrandTotalRow(this.gos), - changedPath, - valueColumns: measureColumns ?? [], - pivotColumns: pivotColumns, - filteredOnly: !this.isSuppressAggFilteredOnly(), - userAggFunc: this.gos.getCallback('getGroupRowAgg') as any, - }; - - return aggDetails; - } - - private isSuppressAggFilteredOnly() { - const isGroupAggFiltering = _getGroupAggFiltering(this.gos) !== undefined; - return isGroupAggFiltering || this.gos.get('suppressAggFilteredOnly'); - } - - private recursivelyCreateAggData(aggDetails: AggregationDetails) { - const callback = (rowNode: RowNode) => { - const hasNoChildren = !rowNode.hasChildren(); - if (hasNoChildren) { - // this check is needed for TreeData, in case the node is no longer a child, - // but it was a child previously. - if (rowNode.aggData) { - this.setAggDataWithSiblings(rowNode, null); - } - // never agg data for leaf nodes - return; + // Stale aggData on demoted nodes is cleared by the group stage (setRowNodeGroup), not here. + public execute(changedPath: ChangedPath | undefined): void { + const { gos, beans } = this; + const userAggFunc = gos.getCallback('getGroupRowAgg'); + const valueColumns = beans.valueColsSvc?.columns; + + if (!valueColumns?.length && !userAggFunc) { + if (this.hadAgg && !changedPath) { + // Full refresh with no value columns: clear stale aggData from all groups. + // Skip during transaction updates (changedPath defined) — the config-change + // full refresh will handle it. + this.hadAgg = false; + const colModel = beans.colModel; + _forEachChangedGroupDepthFirst(beans.rowModel.rootNode, undefined, (rowNode) => { + setAggDataWithSiblings(rowNode, null, colModel); + }); } + return; + } - //Optionally enable the aggregation at the root Node - const isRootNode = rowNode.level === -1; - // if total footer is displayed, the value is in use - if (isRootNode && !aggDetails.groupIncludeTotalFooter) { - const notPivoting = !this.colModel.isPivotMode(); - if (!aggDetails.alwaysAggregateAtRootLevel && notPivoting) { - // Root node has no siblings here: no footer (groupIncludeTotalFooter is false) - // and root cannot be manually pinned, so just clear the root node's aggData. - this.setAggData(rowNode, null); - return; - } + this.hadAgg = true; + + const colModel = beans.colModel; + const aggFuncSvc = beans.aggFuncSvc; + const aggregateRoot = + gos.get('alwaysAggregateAtRootLevel') || !!_getGrandTotalRow(gos) || colModel.isPivotMode(); + const filteredOnly = !_getGroupAggFiltering(gos) && !gos.get('suppressAggFilteredOnly'); + + // Hoist service lookups once — they are accessed per-group inside the traversal callback. + const valueSvc = beans.valueSvc; + const api = beans.gridApi; + const context = beans.gridOptions.context; + + // Pre-resolve value column metadata so the per-group hot loop avoids + // repeated property access on AgColumn (colId, colDef, getAggFunc). + // The ?? [] fallback is for TS narrowing only — valueColumns is non-empty when userAggFunc is falsy. + const resolvedValueColumns = valueColumns ?? []; + const colCount = resolvedValueColumns.length; + + const narrowedCellsPath = changedPath?.kind === 'cells' ? changedPath : undefined; + let cellsChangedPath: ChangedCellsPath | undefined; + const valueCols = new Array(colCount); + for (let i = 0; i < colCount; ++i) { + const col = resolvedValueColumns[i]; + const colSlot = narrowedCellsPath ? narrowedCellsPath.getSlot(col.colId) : -1; + if (colSlot >= 0) { + cellsChangedPath = narrowedCellsPath; } - - this.aggregateRowNode(rowNode, aggDetails); - }; - - aggDetails.changedPath.forEachChangedNodeDepthFirst(callback, true); - } - - private aggregateRowNode(rowNode: RowNode, aggDetails: AggregationDetails): void { - const measureColumnsMissing = aggDetails.valueColumns.length === 0; - const pivotColumnsMissing = aggDetails.pivotColumns.length === 0; - - let aggResult: any; - if (aggDetails.userAggFunc) { - aggResult = aggDetails.userAggFunc({ nodes: rowNode.childrenAfterFilter! }); - } else if (measureColumnsMissing) { - aggResult = null; - } else if (pivotColumnsMissing) { - aggResult = this.aggregateRowNodeUsingValuesOnly(rowNode, aggDetails); - } else { - aggResult = this.aggregateRowNodeUsingValuesAndPivot(rowNode); + valueCols[i] = { + column: col, + colId: col.colId, + colDef: col.colDef, + aggFunc: resolveAggFunc(col.getAggFunc(), aggFuncSvc!, col), + colSlot, + }; } - this.setAggDataWithSiblings(rowNode, aggResult); - } - - private aggregateRowNodeUsingValuesAndPivot(rowNode: RowNode): any { - const result: any = {}; - - const secondaryColumns = this.pivotResultCols?.getPivotResultCols()?.list ?? []; - let canSkipTotalColumns = true; - const beans = this.beans; - const valueSvc = this.valueSvc; + // Resolve pivot columns — null when pivot is inactive or has no result columns. + const pivotData = resolvePivotColumns(colModel, beans.pivotResultCols, aggFuncSvc!); - for (let i = 0; i < secondaryColumns.length; i++) { - const secondaryCol = secondaryColumns[i]; - const colDef = secondaryCol.getColDef(); + // Pre-allocate reusable values2d outer array — reused across groups to avoid + // per-group allocation. Inner arrays are still fresh per group (user-facing via aggFunc params). + const values2d = colCount > 0 ? new Array(colCount) : null; - if (colDef.pivotTotalColumnIds != null) { - canSkipTotalColumns = false; - continue; + _forEachChangedGroupDepthFirst(beans.rowModel.rootNode, changedPath, (rowNode) => { + if (rowNode.level === -1 && !aggregateRoot) { + setAggData(rowNode, null, colModel); + return; } - let values: any[]; - let aggregatedChildren: RowNode[] | null | undefined; - - const pivotValueColumn = colDef.pivotValueColumn as AgColumn; - - if (rowNode.leafGroup) { - // lowest level group, get the values from the mapped set - aggregatedChildren = getNodesFromMappedSet(rowNode.childrenMapped, colDef.pivotKeys); - values = getValuesFromNodes(valueSvc, aggregatedChildren, pivotValueColumn); + let aggResult: Record | null; + if (userAggFunc) { + aggResult = userAggFunc({ nodes: rowNode.childrenAfterFilter! }); + } else if (!values2d) { + aggResult = null; + } else if (pivotData) { + aggResult = aggregateValuesAndPivot(rowNode, pivotData, valueSvc, api, context); } else { - // value columns and pivot columns, non-leaf group - aggregatedChildren = rowNode.childrenAfterFilter; - values = getAggDataFromNodes(aggregatedChildren, secondaryCol.getId()); - } - - // bit of a memory drain storing null/undefined, but seems to speed up performance. - result[colDef.colId!] = _aggregateValues({ - beans, - values, - aggFuncOrString: pivotValueColumn.getAggFunc()!, - column: pivotValueColumn, - rowNode, - pivotResultColumn: secondaryCol, - aggregatedChildren: aggregatedChildren ?? [], - }); - } - - if (!canSkipTotalColumns) { - for (let i = 0; i < secondaryColumns.length; i++) { - const secondaryCol = secondaryColumns[i]; - const colDef = secondaryCol.getColDef(); - - if (!colDef.pivotTotalColumnIds?.length) { - continue; - } - - const aggResults: any[] = colDef.pivotTotalColumnIds.map( - (currentColId: string) => result[currentColId] - ); - // bit of a memory drain storing null/undefined, but seems to speed up performance. - // For total columns, aggregatedChildren is the same as the parent node's children - result[colDef.colId!] = _aggregateValues({ - beans, - values: aggResults, - aggFuncOrString: colDef.pivotValueColumn!.getAggFunc()!, - column: colDef.pivotValueColumn as AgColumn, + aggResult = aggregateValuesOnly( rowNode, - pivotResultColumn: secondaryCol, - aggregatedChildren: rowNode.childrenAfterFilter ?? [], - }); + valueCols, + colCount, + values2d, + cellsChangedPath, + filteredOnly, + valueSvc, + api, + context + ); } - } - - return result; - } - - private aggregateRowNodeUsingValuesOnly(rowNode: RowNode, aggDetails: AggregationDetails): any { - const result: any = {}; - - const { changedPath, valueColumns, filteredOnly } = aggDetails; - - const changedValueColumns = changedPath.active - ? changedPath.getValueColumnsForNode(rowNode, valueColumns) - : valueColumns; - const notChangedValueColumns = changedPath.active - ? changedPath.getNotValueColumnsForNode(rowNode, valueColumns) - : null; - - // Get aggregated children once and reuse for all columns - const aggregatedChildren = (filteredOnly ? rowNode.childrenAfterFilter : rowNode.childrenAfterGroup) ?? []; - const values2d = getValuesFromNodesMultiColumn(this.valueSvc, aggregatedChildren, changedValueColumns); - const oldValues = rowNode.aggData; - - const beans = this.beans; - - changedValueColumns.forEach((valueColumn, index) => { - result[valueColumn.getId()] = _aggregateValues({ - beans, - values: values2d[index], - aggFuncOrString: valueColumn.getAggFunc()!, - column: valueColumn, - rowNode, - pivotResultColumn: undefined, - aggregatedChildren, - }); + setAggDataWithSiblings(rowNode, aggResult, colModel); }); - - if (notChangedValueColumns && oldValues) { - for (const valueColumn of notChangedValueColumns) { - result[valueColumn.getId()] = oldValues[valueColumn.getId()]; - } - } - - return result; } public getAggregatedChildren(rowNode: RowNode | null | undefined, col: AgColumn | null | undefined): RowNode[] { - if (!rowNode?.group || !this.clientSide) { - return []; // only group nodes have aggregated children, and only supported in CSRM + const { gos } = this; + if (!rowNode?.group || !this.csrm) { + return []; } // For pinned siblings, delegate to the source row which has the actual children. - // Pinned siblings copy children references at creation time, but those references become stale - // when filtering/sorting updates the source row's children arrays. if (rowNode.rowPinned) { - const sourceRow = rowNode.pinnedSibling; - if (!sourceRow) { + rowNode = rowNode.pinnedSibling; + if (!rowNode) { return []; } - rowNode = sourceRow; } - const colDef = col?.getColDef(); - const pivotKeys = colDef?.pivotKeys; // undefined for non-pivot columns + const colDef = col?.colDef; + const pivotKeys = colDef?.pivotKeys; if (pivotKeys) { - // For regular pivot columns on leaf groups with specific pivot keys, use childrenMapped to filter by pivot keys. - // For pivot total columns (pivotColumnGroupTotals), aggregation uses childrenAfterFilter instead. if (rowNode.leafGroup && pivotKeys.length && !colDef.pivotTotalColumnIds) { - return getNodesFromMappedSet(rowNode.childrenMapped, pivotKeys) ?? []; + return getNodesFromMappedSet(rowNode.childrenMapped, pivotKeys); } - - // For pivot columns on non-leaf groups, total columns, or pivot total columns with empty pivotKeys, - // aggregation always uses childrenAfterFilter (see aggregateRowNodeUsingValuesAndPivot), - // regardless of suppressAggFilteredOnly. return rowNode.childrenAfterFilter ?? rowNode.childrenAfterGroup ?? []; } - // For non-pivot columns, return the children that aggregation uses: filtered children by default, - // or all children when suppressAggFilteredOnly is true or groupAggFiltering is defined. - if (this.isSuppressAggFilteredOnly()) { + if (_getGroupAggFiltering(gos) || gos.get('suppressAggFilteredOnly')) { return rowNode.childrenAfterGroup ?? []; } return rowNode.childrenAfterFilter ?? rowNode.childrenAfterGroup ?? []; } +} - /** - * Sets aggData on a row node and all its siblings (footer sibling and pinned siblings). - * This ensures all related nodes stay in sync when aggregation data changes. - */ - private setAggDataWithSiblings(rowNode: RowNode, newAggData: any): void { - this.setAggData(rowNode, newAggData); - - // Update pinnedSibling of the group row (for manually pinned group rows) - const pinnedSibling = rowNode.pinnedSibling; - if (pinnedSibling) { - this.setAggData(pinnedSibling, newAggData); - } - - // if we are grouping, then it's possible there is a sibling footer - // to the group, so update the data here also if there is one - const sibling = rowNode.sibling; - if (sibling) { - this.setAggData(sibling, newAggData); - - // Similarly for pinned siblings. A pinned grand total row is a `pinnedSibling` of - // the `sibling` of the root node. - const siblingPinnedSibling = sibling.pinnedSibling; - if (siblingPinnedSibling) { - this.setAggData(siblingPinnedSibling, newAggData); +// ── Module-level aggregation functions ──────────────────────────────────── + +/** Aggregates value columns for a single group node (non-pivot path). */ +const aggregateValuesOnly = ( + rowNode: RowNode, + valueCols: ResolvedValueColumn[], + colCount: number, + values2d: (any[] | null)[], + cellsChangedPath: ChangedCellsPath | undefined, + filteredOnly: boolean, + valueSvc: ValueService, + api: any, + context: any +): Record => { + const aggregatedChildren = (filteredOnly ? rowNode.childrenAfterFilter : rowNode.childrenAfterGroup) ?? []; + const childCount = aggregatedChildren.length; + const data = rowNode.data; + const result: Record = Object.create(null); + + // When column tracking is active, only re-aggregate changed columns; copy the rest. + // rowSlot >= 0 means this group is tracked by cellsChangedPath; -1 means re-aggregate all. + const rowSlot = cellsChangedPath ? cellsChangedPath.getSlot(rowNode) : -1; + const oldAggData = rowSlot >= 0 ? rowNode.aggData : undefined; + + // Pre-allocate per-column value arrays only for changed columns; copy unchanged ones. + // The outer values2d array is reused across groups (passed in from execute). + let changedCount = 0; + for (let j = 0; j < colCount; ++j) { + const vc = valueCols[j]; + if (rowSlot >= 0 && !cellsChangedPath!.hasCellBySlot(rowSlot, vc.colSlot)) { + values2d[j] = null; + if (oldAggData) { + result[vc.colId] = oldAggData[vc.colId]; } + } else { + values2d[j] = new Array(childCount); + ++changedCount; } } - private setAggData(rowNode: RowNode, newAggData: any): void { - const oldAggData = rowNode.aggData; - rowNode.aggData = newAggData; - - // if no event service, nobody has registered for events, so no need fire event - if (rowNode.__localEventService) { - const eventFunc = (colId: string) => { - const value = rowNode.aggData ? rowNode.aggData[colId] : undefined; - const oldValue = oldAggData ? oldAggData[colId] : undefined; - - if (value === oldValue) { - return; - } - - // do a quick lookup - despite the event it's possible the column no longer exists - const column = this.colModel.getColById(colId); - if (!column) { - return; - } - - rowNode.dispatchCellChangedEvent(column, value, oldValue); - }; + if (changedCount === 0) { + return result; + } - if (oldAggData) { - for (const key of Object.keys(oldAggData)) { - eventFunc(key); // raise for old keys + // Collect values row-major: children outer, columns inner (single pass over children). + // For group children, read aggData[colId] directly — depth-first traversal guarantees + // child aggData is already computed, and getValue() would resolve to the same value. + // Falls back to getValue() when aggData[colId] is undefined (custom aggFunc edge case). + for (let c = 0; c < childCount; ++c) { + const child = aggregatedChildren[c]; + const childAggData = child.aggData; + if (childAggData) { + for (let j = 0; j < colCount; ++j) { + const colValues = values2d[j]; + if (colValues !== null) { + const vc = valueCols[j]; + const v = childAggData[vc.colId]; + colValues[c] = v !== undefined ? v : valueSvc.getValue(vc.column, child, 'data'); } } - if (newAggData) { - for (const key of Object.keys(newAggData)) { - if (!oldAggData || !(key in oldAggData)) { - eventFunc(key); // new key, event not yet raised - } + } else { + for (let j = 0; j < colCount; ++j) { + const colValues = values2d[j]; + if (colValues !== null) { + colValues[c] = valueSvc.getValue(valueCols[j].column, child, 'data'); } } } } -} -/** Extracts values from nodes for a single column. */ -const getValuesFromNodes = (valueSvc: ValueService, nodes: RowNode[] | null | undefined, column: AgColumn): any[] => { - if (!nodes) { - return []; + for (let j = 0; j < colCount; ++j) { + const colValues = values2d[j]; + if (colValues === null) { + continue; + } + const rc = valueCols[j]; + const aggFunc = rc.aggFunc; + result[rc.colId] = aggFunc + ? aggFunc({ + values: colValues, + column: rc.column, + colDef: rc.colDef, + rowNode, + data, + aggregatedChildren, + api, + context, + }) + : null; } - const len = nodes.length; - const result = new Array(len); - for (let i = 0; i < len; ++i) { - result[i] = valueSvc.getValue(column, nodes[i], 'data'); + + return result; +}; + +/** Aggregates pivot result columns for a single group node. */ +const aggregateValuesAndPivot = ( + rowNode: RowNode, + pivotData: ResolvedPivotData, + valueSvc: ValueService, + api: any, + context: any +): Record => { + const pivotColCount = pivotData.length; + const isLeafGroup = rowNode.leafGroup; + const data = rowNode.data; + const childrenMapped = rowNode.childrenMapped; + const childrenAfterFilter = rowNode.childrenAfterFilter ?? []; + const result: Record = Object.create(null); + + // Memoize getNodesFromMappedSet — consecutive pivot columns that share the same + // pivotKeys reference (identity check) reuse the previously resolved children array. + let prevPivotKeys: string[] | null | undefined; + let prevPivotChildren: RowNode[] | undefined; + + // Single loop over sorted pivot columns: regular cols first, then totals. + // Regular columns populate `result`; total columns read from it. + for (let i = 0; i < pivotColCount; ++i) { + const rc = pivotData[i]; + const column = rc.column; + const colId = rc.colId; + const totalColIds = rc.totalColIds; + let values: any[]; + let aggregatedChildren: RowNode[]; + + if (totalColIds != null) { + // Total column — aggregate from already-computed regular column results. + const tLen = totalColIds.length; + values = new Array(tLen); + for (let t = 0; t < tLen; ++t) { + values[t] = result[totalColIds[t]]; + } + aggregatedChildren = childrenAfterFilter; + } else if (isLeafGroup) { + // Regular column on leaf group — resolve children via pivot keys. + const pivotKeys = rc.pivotKeys; + if (!prevPivotChildren || pivotKeys !== prevPivotKeys) { + prevPivotKeys = pivotKeys; + prevPivotChildren = getNodesFromMappedSet(childrenMapped, pivotKeys); + } + aggregatedChildren = prevPivotChildren; + const nodeCount = aggregatedChildren.length; + values = new Array(nodeCount); + for (let n = 0; n < nodeCount; ++n) { + values[n] = valueSvc.getValue(column, aggregatedChildren[n], 'data'); + } + } else { + // Regular column on non-leaf group — read aggData from children directly. + // Same optimization as the non-pivot path: bypasses getValue() for group children. + // Falls back to getValue() if aggData[colId] is undefined (consistency with non-pivot). + aggregatedChildren = childrenAfterFilter; + const nodeCount = aggregatedChildren.length; + values = new Array(nodeCount); + for (let n = 0; n < nodeCount; ++n) { + const childNode = aggregatedChildren[n]; + const childAggData = childNode.aggData; + const v = childAggData ? childAggData[colId] : undefined; + values[n] = v !== undefined ? v : valueSvc.getValue(column, childNode, 'data'); + } + } + + const aggFunc = rc.aggFunc; + result[colId] = aggFunc + ? aggFunc({ + values, + column, + colDef: column.colDef, + pivotResultColumn: rc.pivotResultCol, + rowNode, + data, + aggregatedChildren, + api, + context, + }) + : null; } + return result; }; -/** Extracts values from nodes for multiple columns (returns 2D array). */ -const getValuesFromNodesMultiColumn = (valueSvc: ValueService, nodes: RowNode[], columns: AgColumn[]): any[][] => { - const columnCount = columns.length; - const values: any[][] = new Array(columnCount); - for (let j = 0; j < columnCount; j++) { - values[j] = []; +// ── Module-level helpers ─────────────────────────────────────────────────── + +/** 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 rowCount = nodes.length; - for (let i = 0; i < rowCount; i++) { - const childNode = nodes[i]; - for (let j = 0; j < columnCount; j++) { - values[j].push(valueSvc.getValue(columns[j], childNode, 'data')); - } + if (aggFuncOrString == null) { + return null; + } + const aggFunc = aggFuncSvc.getAggFunc(aggFuncOrString); + if (typeof aggFunc !== 'function') { + _warn(109, { inputValue: aggFuncOrString.toString(), allSuggestions: aggFuncSvc.getFuncNames(column) }); + return null; } - return values; + return aggFunc; }; -/** Extracts aggData values from nodes for a specific column ID. */ -const getAggDataFromNodes = (nodes: RowNode[] | null | undefined, columnId: string): any[] => { - if (!nodes) { - return []; +/** Resolves pivot result columns. Returns null when pivot is inactive or has no result columns. + * Uses getAggregationOrderedList() which is cached by the pivot service — avoids + * re-partitioning regular vs total columns on every aggregation refresh. */ +const resolvePivotColumns = ( + colModel: ColumnModel, + pivotResultCols: IPivotResultColsService | undefined, + aggFuncSvc: IAggFuncService +): ResolvedPivotData | null => { + if (!colModel.isPivotActive()) { + return null; } - const len = nodes.length; - const result = new Array(len); - for (let i = 0; i < len; i++) { - result[i] = nodes[i].aggData?.[columnId]; + // getAggregationOrderedList() returns columns pre-sorted: regular first, totals after. + // The list is cached and only recomputed when pivot result columns change. + const orderedList = pivotResultCols?.getAggregationOrderedList(); + if (!orderedList || orderedList.length === 0) { + return null; } - return result; + const len = orderedList.length; + const resolved = new Array(len); + let count = 0; + for (let i = 0; i < len; ++i) { + const pivotResultCol = orderedList[i]; + const resultColDef = pivotResultCol.colDef; + const valueCol = resultColDef.pivotValueColumn as AgColumn | null | undefined; + if (!valueCol) { + continue; + } + resolved[count++] = { + column: valueCol, + colId: resultColDef.colId!, + aggFunc: resolveAggFunc(valueCol.getAggFunc(), aggFuncSvc, valueCol), + pivotResultCol: pivotResultCol, + pivotKeys: resultColDef.pivotKeys, + totalColIds: resultColDef.pivotTotalColumnIds, + }; + } + if (count === 0) { + return null; + } + resolved.length = count; + return resolved; }; /** Traverses childrenMapped using pivot keys to get the matching RowNode array. */ -const getNodesFromMappedSet = (mappedSet: any, keys: string[] | null | undefined): RowNode[] | undefined => { +const getNodesFromMappedSet = (mappedSet: any, keys: string[] | null | undefined): RowNode[] => { if (!keys) { - return undefined; + return []; } let mapPointer = mappedSet; - for (let i = 0; i < keys.length && mapPointer; i++) { + for (let i = 0, len = keys.length; i < len && mapPointer; ++i) { mapPointer = mapPointer[keys[i]]; } - // Only return if we reached an array of RowNodes. If keys is empty or traversal - // ends at a non-array (e.g., intermediate map object), return undefined. - if (Array.isArray(mapPointer)) { - return mapPointer; - } - return undefined; + return Array.isArray(mapPointer) ? mapPointer : []; }; diff --git a/packages/ag-grid-enterprise/src/aggregation/filterAggregatesStage.ts b/packages/ag-grid-enterprise/src/aggregation/filterAggregatesStage.ts index 96323e3e191..f4df7469867 100644 --- a/packages/ag-grid-enterprise/src/aggregation/filterAggregatesStage.ts +++ b/packages/ag-grid-enterprise/src/aggregation/filterAggregatesStage.ts @@ -8,7 +8,7 @@ import type { RowNode, _IRowNodeFilterAggregateStage, } from 'ag-grid-community'; -import { BeanStub, _getGroupAggFiltering } from 'ag-grid-community'; +import { BeanStub, _forEachChangedGroupDepthFirst, _getGroupAggFiltering } from 'ag-grid-community'; export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowNodeFilterAggregateStage { beanName = 'filterAggStage' as const; @@ -22,7 +22,7 @@ export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowN this.filterManager = beans.filterManager; } - public execute(changedPath: ChangedPath): void { + public execute(changedPath: ChangedPath | undefined): void { const isPivotMode = this.beans.colModel.isPivotMode(); const isAggFilterActive = this.filterManager?.isAggregateFilterPresent() || this.filterManager?.isAggregateQuickFilterPresent(); @@ -44,8 +44,9 @@ export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowN if (node.childrenAfterFilter) { node.childrenAfterAggFilter = node.childrenAfterFilter; if (recursive) { - for (const child of node.childrenAfterAggFilter) { - preserveChildren(child, recursive); + const children = node.childrenAfterAggFilter; + for (let i = 0, len = children.length; i < len; ++i) { + preserveChildren(children[i], recursive); } } this.setAllChildrenCount(node); @@ -78,7 +79,11 @@ export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowN } }; - changedPath.forEachChangedNodeDepthFirst(isAggFilterActive ? filterChildren : preserveChildren, true); + _forEachChangedGroupDepthFirst( + this.beans.rowModel.rootNode, + changedPath, + isAggFilterActive ? filterChildren : preserveChildren + ); } /** for tree data, we include all children, groups and leafs */ @@ -102,14 +107,16 @@ export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowN /* for grid data, we only count the leafs */ private setAllChildrenCountGridGrouping(rowNode: RowNode) { + const children = rowNode.childrenAfterAggFilter!; let allChildrenCount = 0; - rowNode.childrenAfterAggFilter!.forEach((child: RowNode) => { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; if (child.group) { allChildrenCount += child.allChildrenCount as any; } else { allChildrenCount++; } - }); + } rowNode.setAllChildrenCount(allChildrenCount); } @@ -119,7 +126,7 @@ export class FilterAggregatesStage extends BeanStub implements NamedBean, _IRowN return; } - if (this.gos.get('treeData')) { + if (this.beans.groupStage?.treeData) { this.setAllChildrenCountTreeData(rowNode); } else { this.setAllChildrenCountGridGrouping(rowNode); 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 5247b3b9876..7fee943d7d6 100644 --- a/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts +++ b/packages/ag-grid-enterprise/src/charts/chartComp/datasource/chartDatasource.ts @@ -23,7 +23,6 @@ import { _warn, } from 'ag-grid-community'; -import { _aggregateValues } from '../../../aggregation/aggUtils'; import type { ColState } from '../model/chartDataModel'; import { DEFAULT_CHART_CATEGORY } from '../model/chartDataModel'; @@ -342,23 +341,43 @@ export class ChartDatasource extends BeanStub { } if (this.gos.assertModuleRegistered('SharedAggregation', 1)) { + // Resolve the aggFunc once for all columns/groups. + const aggFuncOrString = params.aggFunc; + const aggFunc: IAggFunc | null = + typeof aggFuncOrString === 'function' + ? aggFuncOrString + : typeof aggFuncOrString === 'string' + ? this.beans.aggFuncSvc!.getAggFunc(aggFuncOrString) + : null; + + if (typeof aggFunc !== 'function') { + _warn(109, { inputValue: String(aggFuncOrString), allSuggestions: [] }); + return dataAggregated; + } + + const api = this.beans.gridApi; + const context = this.gos.get('context'); + for (const groupItem of dataAggregated) { for (const col of params.valueCols) { const colId = col.getColId(); + if (params.crossFiltering) { // filtered data const dataToAgg = groupItem.__children .filter((child: any) => typeof child[colId] !== 'undefined') .map((child: any) => child[colId]); - const aggResult: any = _aggregateValues({ - beans: this.beans, + const aggResult: any = aggFunc({ values: dataToAgg, - aggFuncOrString: params.aggFunc, column: col, - rowNode: undefined, - pivotResultColumn: undefined, + colDef: col.colDef, + pivotResultColumn: undefined as any, + rowNode: undefined!, + data: undefined, aggregatedChildren: [], + api, + context, }); groupItem[colId] = aggResult && typeof aggResult.value !== 'undefined' ? aggResult.value : aggResult; @@ -369,14 +388,16 @@ export class ChartDatasource extends BeanStub { .filter((child: any) => typeof child[filteredOutColId] !== 'undefined') .map((child: any) => child[filteredOutColId]); - const aggResultFiltered: any = _aggregateValues({ - beans: this.beans, + const aggResultFiltered: any = aggFunc({ values: dataToAggFiltered, - aggFuncOrString: params.aggFunc, column: col, - rowNode: undefined, - pivotResultColumn: undefined, + colDef: col.colDef, + pivotResultColumn: undefined as any, + rowNode: undefined!, + data: undefined, aggregatedChildren: [], + api, + context, }); groupItem[filteredOutColId] = aggResultFiltered && typeof aggResultFiltered.value !== 'undefined' @@ -384,14 +405,16 @@ export class ChartDatasource extends BeanStub { : aggResultFiltered; } else { const dataToAgg = groupItem.__children.map((child: any) => child[colId]); - const aggResult = _aggregateValues({ - beans: this.beans, + const aggResult = aggFunc({ values: dataToAgg, - aggFuncOrString: params.aggFunc, column: col, - rowNode: undefined, - pivotResultColumn: undefined, + colDef: col.colDef, + pivotResultColumn: undefined as any, + rowNode: undefined!, + data: undefined, aggregatedChildren: [], + api, + context, }); groupItem[colId] = diff --git a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts index 74f74807e7c..79614422c80 100644 --- a/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts +++ b/packages/ag-grid-enterprise/src/clipboard/clipboardService.ts @@ -2,6 +2,7 @@ import type { AgColumn, CellPosition, CellRange, + ChangedPath, CsvExportParams, GridCtrl, GridOptions, @@ -19,9 +20,11 @@ import type { } from 'ag-grid-community'; import { BeanStub, - ChangedPath, + ChangedCellsPath, + ChangedRowsPath, _createCellId, _exists, + _forEachChangedGroupDepthFirst, _getActiveDomElement, _getDocument, _getRowBelow, @@ -290,7 +293,8 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS const { clientSideRowModel } = this; const rootNode = clientSideRowModel?.rootNode; - const changedPath = rootNode && new ChangedPath(gos.get('aggregateOnlyChangedColumns'), rootNode); + const changedPath = + rootNode && (gos.get('aggregateOnlyChangedColumns') ? new ChangedCellsPath() : new ChangedRowsPath()); const cellsToFlash: Record = {}; const updatedRowNodes: RowNode[] = []; @@ -298,12 +302,12 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS pasteOperationFunc(cellsToFlash, updatedRowNodes, focusedCell, changedPath); - const nodesToRefresh: RowNode[] = [...updatedRowNodes]; + const nodesToRefresh: RowNode[] = updatedRowNodes.slice(); if (changedPath) { clientSideRowModel.doAggregate(changedPath); // add all nodes impacted by aggregation, as they need refreshed also. - changedPath.forEachChangedNodeDepthFirst((rowNode) => { + _forEachChangedGroupDepthFirst(rootNode, changedPath, (rowNode) => { nodesToRefresh.push(rowNode); }); } @@ -430,7 +434,7 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS ); rowNode.setDataValue(column, newValue, SOURCE_PASTE); - changedPath?.addParentNode(rowNode.parent, [column]); + changedPath?.addCell(rowNode.parent, column.getId()); const { rowIndex, rowPinned } = currentRow; const cellId = _createCellId({ rowIndex, column, rowPinned }); @@ -581,10 +585,7 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS ); rowNode.setDataValue(column, firstRowValue, SOURCE_PASTE); - - if (changedPath) { - changedPath.addParentNode(rowNode.parent, [column]); - } + changedPath?.addCell(rowNode.parent, column.getId()); const { rowIndex, rowPinned } = currentRow; const cellId = _createCellId({ rowIndex, column, rowPinned }); @@ -716,10 +717,7 @@ export class ClipboardService extends BeanStub implements NamedBean, IClipboardS const { rowIndex, rowPinned } = rowNode; const cellId = _createCellId({ rowIndex: rowIndex!, column, rowPinned }); cellsToFlash[cellId] = true; - - if (changedPath) { - changedPath.addParentNode(rowNode.parent, [column]); - } + changedPath?.addCell(rowNode.parent, column.getId()); } public copyToClipboard(params: IClipboardCopyParams = {}): void { diff --git a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts index c55983cea07..b3d0c204342 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotResultColsService.ts @@ -37,6 +37,10 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo // if pivoting, these are the generated columns as a result of the pivot private pivotResultCols: _ColumnCollections | null; + // Cached aggregation-ordered list: regular columns first, total columns after. + // Lazily computed on first access, invalidated when pivot result columns change. + private aggOrderedList: AgColumn[] | null | undefined; + // Saved when pivot is disabled, available to re-use when pivot is restored private previousPivotResultCols: (AgColumn | AgProvidedColumnGroup)[] | null; @@ -84,7 +88,47 @@ export class PivotResultColsService extends BeanStub implements NamedBean, IPivo return this.colModel.getColFromCollection(key, this.pivotResultCols); } + public getAggregationOrderedList(): AgColumn[] | null { + let result = this.aggOrderedList; + if (result !== undefined) { + return result; + } + const list = this.pivotResultCols?.list; + 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) { + if (list[i].getColDef().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.getColDef().pivotTotalColumnIds != null) { + totals.push(col); + } else { + regular.push(col); + } + } + result = 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; } diff --git a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts index 35edc29bb54..22676e8bfa9 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts @@ -12,7 +12,7 @@ import type { ValueService, _IRowNodePivotStage, } from 'ag-grid-community'; -import { BeanStub, _jsonEquals, _missing } from 'ag-grid-community'; +import { BeanStub, _forEachChangedGroupDepthFirst, _jsonEquals, _missing } from 'ag-grid-community'; import type { PivotColDefService } from './pivotColDefService'; @@ -69,26 +69,26 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta private maxUniqueValues: number = -1; - public execute(changedPath: ChangedPath): void { + /** Returns `true` if the changedPath should be deactivated (e.g. pivot columns changed). */ + public execute(changedPath: ChangedPath | undefined): boolean { if (this.colModel.isPivotActive()) { - this.executePivotOn(changedPath!); + return this.executePivotOn(changedPath); } else { - this.executePivotOff(changedPath!); + return this.executePivotOff(); } } - private executePivotOff(changedPath: ChangedPath): void { + private executePivotOff(): boolean { this.aggregationColumnsHashLastTime = null; this.uniqueValues = new Map(); if (this.pivotResultCols.isPivotResultColsPresent()) { this.pivotResultCols.setPivotResultCols(null, 'rowModelUpdated'); - if (changedPath) { - changedPath.active = false; - } + return true; // columns changed, deactivate changedPath } + return false; } - private executePivotOn(changedPath: ChangedPath): void { + private executePivotOn(changedPath: ChangedPath | undefined): boolean { const numberOfAggregationColumns = this.valueColsSvc?.columns.length ?? 1; // As unique values creates one column per aggregation column, divide max columns by number of aggregation columns @@ -108,7 +108,7 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta message: e.message, }); this.lastTimeFailed = true; - return; + return false; } throw e; } @@ -156,13 +156,13 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta ) { const pivotColumnGroupDefs = this.pivotColDefSvc.createPivotColumnDefs(this.uniqueValues); this.pivotResultCols.setPivotResultCols(pivotColumnGroupDefs, 'rowModelUpdated'); - // because the secondary columns have changed, then the aggregation needs to visit the whole - // tree again, so we make the changedPath not active, to force aggregation to visit all paths. - if (changedPath) { - changedPath.active = false; - } + // Because the secondary columns have changed, the aggregation needs to visit the whole + // tree again, so signal the caller to deactivate the changedPath. + this.lastTimeFailed = false; + return true; } this.lastTimeFailed = false; + return false; } private setUniqueValues(newValues: Map): boolean { @@ -177,13 +177,13 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta } private currentUniqueCount = 0; - private bucketUpRowNodes(changedPath: ChangedPath): Map { + private bucketUpRowNodes(changedPath: ChangedPath | undefined): Map { this.currentUniqueCount = 0; // accessed from inside inner function const uniqueValues: Map = new Map(); // ensure childrenMapped is cleared, as if a node has been filtered out it should not have mapped children. - changedPath.forEachChangedNodeDepthFirst((node) => { + _forEachChangedGroupDepthFirst(this.beans.rowModel.rootNode, changedPath, (node) => { if (node.leafGroup) { node.childrenMapped = null; } @@ -193,11 +193,16 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta if (node.leafGroup) { this.bucketRowNode(node, uniqueValues); } else { - node.childrenAfterFilter?.forEach(recursivelyBucketFilteredChildren); + const children = node.childrenAfterFilter; + if (children) { + for (let i = 0, len = children.length; i < len; ++i) { + recursivelyBucketFilteredChildren(children[i]); + } + } } }; - changedPath.executeFromRootNode(recursivelyBucketFilteredChildren); + recursivelyBucketFilteredChildren(this.beans.rowModel.rootNode!); return uniqueValues; } @@ -229,7 +234,8 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta const doesGeneratedColMaxExist = this.maxUniqueValues !== -1; // map the children out based on the pivot column - children.forEach((child: RowNode) => { + for (let i = 0, len = children.length; i < len; ++i) { + const child = children[i]; let key: string | null | undefined = this.valueSvc.getKeyForNode(pivotColumn, child); if (_missing(key)) { @@ -251,7 +257,7 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta mappedChildren.set(key, []); } mappedChildren.get(key)!.push(child); - }); + } // if it's the last pivot column, return as is, otherwise go one level further in the map if (pivotIndex === pivotColumns.length - 1) { diff --git a/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts b/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts index 10337c6b65e..748d812e57e 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/groupStrategy/groupStrategy.ts @@ -6,7 +6,7 @@ import type { RefreshModelParams, _ChangedRowNodes, } from 'ag-grid-community'; -import { BeanStub, RowNode, _csrmFirstLeaf, _warn } from 'ag-grid-community'; +import { BeanStub, RowNode, _csrmFirstLeaf, _forEachChangedGroupDepthFirst, _warn } from 'ag-grid-community'; import type { IRowGroupingStrategy } from '../../rowHierarchy/rowHierarchyUtils'; import { _getRowDefaultExpanded } from '../../rowHierarchy/rowHierarchyUtils'; @@ -82,7 +82,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } public execute(rootNode: RowNode, params: RefreshModelParams): void { - const changedPath = params.changedPath!; + const changedPath = params.changedPath; if (this.initRefresh(params)) { const changedRowNodes = params.changedRowNodes; if (changedRowNodes) { @@ -92,14 +92,14 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } } - this.positionLeafsAndGroups(changedPath); + this.positionLeafsAndGroups(rootNode, changedPath); this.orderGroups(rootNode); this.beans.selectionSvc?.updateSelectableAfterGrouping(changedPath); } - private positionLeafsAndGroups(changedPath: ChangedPath) { - changedPath.forEachChangedNodeDepthFirst((group: RowNode) => { + private positionLeafsAndGroups(rootNode: RowNode, changedPath: ChangedPath | undefined) { + _forEachChangedGroupDepthFirst(rootNode, changedPath, (group: RowNode) => { const children = group.childrenAfterGroup; const childrenLen = children?.length; if (!childrenLen) { @@ -134,7 +134,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { sibling.childrenAfterGroup = newChildren; } } - }, false); + }); } private initRefresh(params: RefreshModelParams): boolean { @@ -163,35 +163,30 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { private handleDeltaUpdate( rootNode: RowNode, - changedPath: ChangedPath, + changedPath: ChangedPath | undefined, { removals, updates, adds, reordered }: _ChangedRowNodes, animate: boolean ): void { const parentsWithRemovals = new Set(); - let activeChangedPath: ChangedPath | null = changedPath; - if (!activeChangedPath.active) { - activeChangedPath = null; - } - for (let i = 0, len = removals.length; i < len; ++i) { const rowNode = removals[i]; const oldParent = this.removeFromParent(rowNode); if (!parentsWithRemovals.has(oldParent)) { parentsWithRemovals.add(oldParent); - activeChangedPath?.addParentNode(oldParent); + changedPath?.addRow(oldParent); } } for (const rowNode of updates) { const oldParent = rowNode.parent; // we add even if parent has not changed, as the data could have changed, or aggregations will be wrong - activeChangedPath?.addParentNode(oldParent); + changedPath?.addRow(oldParent); if (this.moveNodeInWrongPath(rootNode, rowNode)) { parentsWithRemovals.add(oldParent); const newParent = rowNode.parent; - activeChangedPath?.addParentNode(newParent); + changedPath?.addRow(newParent); reordered ||= (newParent?.childrenAfterGroup?.length ?? 0) > 1; // Order may be wrong after move } @@ -201,7 +196,7 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { for (const rowNode of adds) { this.insertOneNode(rootNode, rowNode); const newParent = rowNode.parent; - activeChangedPath?.addParentNode(newParent); + changedPath?.addRow(newParent); reordered ||= (newParent?.childrenAfterGroup?.length ?? 0) > 1; // Order may be wrong after add } @@ -213,22 +208,18 @@ export class GroupStrategy extends BeanStub implements IRowGroupingStrategy { } if (reordered) { - this.sortChildren(changedPath); + this.sortChildren(rootNode, changedPath); } } // this is used when doing delta updates, eg Redux, keeps nodes in right order - private sortChildren(changedPath: ChangedPath): void { - changedPath.forEachChangedNodeDepthFirst( - (node) => { - const didSort = sortGroupChildren(node.childrenAfterGroup); - if (didSort && changedPath.active) { - changedPath.addParentNode(node); - } - }, - false, - true - ); + private sortChildren(rootNode: RowNode, changedPath: ChangedPath | undefined): void { + _forEachChangedGroupDepthFirst(rootNode, undefined, (node) => { + const didSort = sortGroupChildren(node.childrenAfterGroup); + if (didSort) { + changedPath?.addRow(node); + } + }); } private orderGroups(rootNode: RowNode): void { diff --git a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingUtils.ts b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingUtils.ts index b793442eb5d..c3f726fa68b 100644 --- a/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingUtils.ts +++ b/packages/ag-grid-enterprise/src/rowGrouping/rowGroupingUtils.ts @@ -1,5 +1,7 @@ import type { AgColumn, BeanCollection, ColumnModel, LocaleTextFunc, RowNode } from 'ag-grid-community'; +import { setAggData } from '../aggregation/aggDataUtils'; + export function setRowNodeGroupValue( rowNode: RowNode, colModel: ColumnModel, @@ -33,6 +35,13 @@ export function setRowNodeGroup(rowNode: RowNode, beans: BeanCollection, group: // if we used to be a group, and no longer, then close the node if (rowNode.group && !group) { rowNode.expanded = false; + // Clear stale aggData when demoting from group to leaf. + const colModel = beans.colModel; + setAggData(rowNode, null, colModel); + const pinnedSibling = rowNode.pinnedSibling; + if (pinnedSibling) { + setAggData(pinnedSibling, null, colModel); + } } rowNode.group = group; diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts index 6c3437cfc5b..1a89202e93a 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts @@ -9,7 +9,7 @@ import type { } from 'ag-grid-community'; import { BeanStub, - ChangedPath, + ChangedRowsPath, _ChangedRowNodes, _csrmFirstLeaf, _csrmReorderAllLeafs, @@ -432,7 +432,7 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { step: 'group', keepRenderedRows: true, animate: !this.gos.get('suppressAnimationFrame'), - changedPath: new ChangedPath(false, rootNode), + changedPath: new ChangedRowsPath(), changedRowNodes, }); } diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/groupStage.ts b/packages/ag-grid-enterprise/src/rowHierarchy/groupStage.ts index 2feaa0542b5..ebb29ed1285 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/groupStage.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/groupStage.ts @@ -1,4 +1,5 @@ import type { + BeanCollection, ClientSideRowModelStage, GridOptions, IClientSideRowModel, @@ -10,6 +11,7 @@ import type { } from 'ag-grid-community'; import { BeanStub } from 'ag-grid-community'; +import { setRowNodeGroup } from '../rowGrouping/rowGroupingUtils'; import type { IRowGroupingStrategy } from './rowHierarchyUtils'; export class GroupStage extends BeanStub implements NamedBean, _IRowNodeGroupStage { @@ -110,7 +112,7 @@ export class GroupStage extends BeanStub implements NamedBean, _IRowNodeG this.needReset = false; beans.rowDragSvc?.cancelRowDrag(); params.animate = false; // resetting grouping / treeData, so no animation - resetGrouping(rootNode, !nested); + resetGrouping(rootNode, !nested, beans); } return strategy ? strategy.execute(rootNode, params) || needReset : undefined; } @@ -197,13 +199,14 @@ const loadRealLeafs = (node: RowNode): RowNode[] | null => { return leafs; }; -const resetGrouping = (rootNode: RowNode, canResetTreeNode: boolean): void => { +const resetGrouping = (rootNode: RowNode, canResetTreeNode: boolean, beans: BeanCollection): void => { const allLeafs = rootNode._leafs!; const rootSibling = rootNode.sibling; rootNode.treeNodeFlags = 0; rootNode.childrenAfterGroup = allLeafs; rootNode.childrenMapped = null; rootNode._groupData = undefined; + rootNode.aggData = null; if (rootSibling) { rootSibling.childrenAfterGroup = rootNode.childrenAfterGroup; rootSibling.childrenAfterAggFilter = rootNode.childrenAfterAggFilter; @@ -211,6 +214,7 @@ const resetGrouping = (rootNode: RowNode, canResetTreeNode: boolea rootSibling.childrenAfterSort = rootNode.childrenAfterSort; rootSibling.childrenMapped = null; rootSibling._groupData = undefined; + rootSibling.aggData = null; } for (let i = 0, allLeafsLen = allLeafs.length ?? 0; i < allLeafsLen; ++i) { const row = allLeafs[i]; @@ -224,8 +228,7 @@ const resetGrouping = (rootNode: RowNode, canResetTreeNode: boolea if (canResetTreeNode) { row.treeParent = null; } - row.group = false; - row.updateHasChildren(); + setRowNodeGroup(row, beans, false); } rootNode.updateHasChildren(); }; diff --git a/packages/ag-grid-enterprise/src/treeData/treeGroupStrategy.ts b/packages/ag-grid-enterprise/src/treeData/treeGroupStrategy.ts index 9918598616f..238cec67dab 100644 --- a/packages/ag-grid-enterprise/src/treeData/treeGroupStrategy.ts +++ b/packages/ag-grid-enterprise/src/treeData/treeGroupStrategy.ts @@ -5,7 +5,7 @@ import type { RefreshModelParams, _ChangedRowNodes, } from 'ag-grid-community'; -import { BeanStub, RowNode, _EmptyArray, _removeFromArray, _warn } from 'ag-grid-community'; +import { BeanStub, RowNode, _removeFromArray, _warn } from 'ag-grid-community'; import { setRowNodeGroup } from '../rowGrouping/rowGroupingUtils'; import type { IRowGroupingStrategy } from '../rowHierarchy/rowHierarchyUtils'; @@ -128,8 +128,7 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou const { changedRowNodes, changedPath } = params; - const activeChangedPath = changedPath?.active ? changedPath : undefined; - const fullReload = this.fullReload || (!changedRowNodes && !activeChangedPath); + const fullReload = this.fullReload || (!changedRowNodes && !changedPath); const hasUpdates = !!changedRowNodes && this.flagUpdatedNodes(changedRowNodes); if (fullReload || hasUpdates) { @@ -153,10 +152,10 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou const treeChanged = parentsChanged || (preprocessedCount & FLAG_CHILDREN_CHANGED) !== 0; preprocessedCount &= ~FLAG_CHILDREN_CHANGED; - const traverseCount = this.traverseRoot(rootNode, activeChangedPath); + const traverseCount = this.traverseRoot(rootNode, changedPath); if (preprocessedCount > 0 && preprocessedCount !== traverseCount) { this.handleCycles(rootNode); // We have unprocessed nodes, this means we have at least one cycle to fix - this.traverseRoot(rootNode, activeChangedPath); // Re-traverse the root + this.traverseRoot(rootNode, changedPath); // Re-traverse the root } rootNode.treeNodeFlags = 0; @@ -276,15 +275,21 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou const len = treeNodeFlags & MASK_CHILDREN_LEN; row.treeNodeFlags = (treeNodeFlags & ~MASK_CHILDREN_LEN) | ((oldLen || 0) === len ? 0 : FLAG_CHILDREN_CHANGED); if (len === 0 && row.level >= 0) { - if (childrenAfterGroup !== _EmptyArray) { - row.childrenAfterGroup = _EmptyArray; + if (childrenAfterGroup !== null) { + row.childrenAfterGroup = null; + row.childrenAfterFilter = null; + row.childrenAfterAggFilter = null; + row.childrenAfterSort = null; const sibling = row.sibling; if (sibling) { - sibling.childrenAfterGroup = _EmptyArray; + sibling.childrenAfterGroup = null; + sibling.childrenAfterFilter = null; + sibling.childrenAfterAggFilter = null; + sibling.childrenAfterSort = null; } } } else if (oldLen !== len || childrenAfterGroup === rowLeafs) { - if (!childrenAfterGroup || childrenAfterGroup === _EmptyArray || childrenAfterGroup === rowLeafs) { + if (!childrenAfterGroup || childrenAfterGroup === rowLeafs) { row.childrenAfterGroup = childrenAfterGroup = new Array(len); const sibling = row.sibling; if (sibling) { @@ -351,8 +356,8 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou collapsed: boolean, activeChangedPath: ChangedPath | undefined ): number { - const children = row.childrenAfterGroup!; - const len = children.length; + const children = row.childrenAfterGroup; + const len = children?.length ?? 0; let flags = row.treeNodeFlags; row.treeNodeFlags = flags & FLAG_EXPANDED_INITIALIZED; @@ -367,9 +372,7 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou flags |= FLAG_CHANGED; } - if ((flags & (FLAG_CHANGED | FLAG_CHILDREN_CHANGED)) !== 0) { - activeChangedPath?.addParentNode(row); - } + const selfChanged = (flags & (FLAG_CHANGED | FLAG_CHILDREN_CHANGED)) !== 0; const canBeExpanded = len !== 0 || row.master; if (!canBeExpanded) { @@ -392,13 +395,19 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou ++level; // Increment level as it is passed down to children flags &= FLAG_CHILDREN_CHANGED; for (let i = 0; i < len; ++i) { - const child = children[i]; + const child = children![i]; const childFlags = this.traverse(child, level, collapsed, activeChangedPath); // Accumulates traversed nodes count and propagates children changed flag flags = (flags + (childFlags & ~FLAG_CHILDREN_CHANGED)) | (childFlags & FLAG_CHILDREN_CHANGED); } + if (selfChanged) { + flags |= FLAG_CHILDREN_CHANGED; // Propagate own changes upward + } if (flags & FLAG_CHILDREN_CHANGED) { + if (len > 0) { + activeChangedPath?.addRow(row); // Only add group nodes to changedPath + } row._leafs = undefined; // Invalidate allLeafChildren cache when children changed and propagate up. } return flags + 1; @@ -412,7 +421,7 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou return false; } marked.add(row); - for (const child of row.childrenAfterGroup!) { + for (const child of row.childrenAfterGroup ?? []) { mark(child); } return true; @@ -427,7 +436,11 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou if (parent && mark(row)) { parent.treeNodeFlags |= FLAG_CHILDREN_CHANGED | FLAG_CHANGED; row.parent = rootNode; // Move the row to the root node - _removeFromArray(parent.childrenAfterGroup!, row); // Remove the row from the root children + const parentChildren = parent.childrenAfterGroup!; + _removeFromArray(parentChildren, row); // Remove the row from the parent children + if (parentChildren.length === 0) { + parent.childrenAfterGroup = null; + } rootChildrenAfterGroup.push(row); _warn(270, { id: row.id!, parentId: parent?.id ?? '' }); } else if (parent === rootNode) { @@ -802,12 +815,19 @@ export class TreeGroupStrategy extends BeanStub implements IRowGrou row.group = false; row.treeParent = null; row.treeNodeFlags = 0; - row.childrenAfterGroup = _EmptyArray; + row.childrenAfterGroup = null; + row.childrenAfterFilter = null; + row.childrenAfterAggFilter = null; + row.childrenAfterSort = null; row._leafs = undefined; row._groupData = null; + row.aggData = null; const sibling = row.sibling; if (sibling) { - sibling.childrenAfterGroup = _EmptyArray; + sibling.childrenAfterGroup = null; + sibling.childrenAfterFilter = null; + sibling.childrenAfterAggFilter = null; + sibling.childrenAfterSort = null; } row.updateHasChildren(); if (row.rowIndex !== null) { diff --git a/packages/ag-grid-enterprise/src/version.ts b/packages/ag-grid-enterprise/src/version.ts index 52fd5df6f2e..212ad79624e 100644 --- a/packages/ag-grid-enterprise/src/version.ts +++ b/packages/ag-grid-enterprise/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '35.1.0-beta.20260309.1858'; +export const VERSION = '35.1.0-beta.20260312.1528'; diff --git a/packages/ag-grid-react/package.json b/packages/ag-grid-react/package.json index 102436e417f..ae3c01a84f0 100644 --- a/packages/ag-grid-react/package.json +++ b/packages/ag-grid-react/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-react", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "AG Grid React Component", "main": "./dist/package/index.cjs.js", "types": "./dist/types/src/index.d.ts", @@ -31,7 +31,7 @@ "devDependencies": { "@babel/runtime": "^7.27.1", "prop-types": "^15.6.2", - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "@babel/plugin-proposal-throw-expressions": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@types/react": "~18.3.26", @@ -44,7 +44,7 @@ }, "dependencies": { "prop-types": "^15.8.1", - "ag-grid-community": "35.1.0-beta.20260309.1858" + "ag-grid-community": "35.1.0-beta.20260312.1528" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", diff --git a/packages/ag-grid-vue3/package.json b/packages/ag-grid-vue3/package.json index 40d894504f9..74a80a8340c 100644 --- a/packages/ag-grid-vue3/package.json +++ b/packages/ag-grid-vue3/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3", "description": "AG Grid Vue 3 Component", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "author": "Sean Landsman ", "license": "MIT", "files": [ @@ -44,7 +44,7 @@ "build-only:watch": "vite build --watch" }, "dependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858" + "ag-grid-community": "35.1.0-beta.20260312.1528" }, "devDependencies": { "vue": "^3.5.0", diff --git a/plugins/ag-grid-generate-code-reference-files/package.json b/plugins/ag-grid-generate-code-reference-files/package.json index 905eb17ab2b..66614c90862 100644 --- a/plugins/ag-grid-generate-code-reference-files/package.json +++ b/plugins/ag-grid-generate-code-reference-files/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-generate-code-reference-files", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "private": true, "dependencies": { "ag-shared": "0.0.1", diff --git a/plugins/ag-grid-generate-example-files/package.json b/plugins/ag-grid-generate-example-files/package.json index cc093c9b29f..4a85554c1f5 100644 --- a/plugins/ag-grid-generate-example-files/package.json +++ b/plugins/ag-grid-generate-example-files/package.json @@ -1,10 +1,10 @@ { "name": "ag-grid-generate-example-files", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "private": true, "dependencies": { "ag-shared": "0.0.1", - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "glob": "8.0.3", "typescript": "~5.4.5", "cheerio": "^1.0.0", diff --git a/plugins/ag-grid-task-autogen/package.json b/plugins/ag-grid-task-autogen/package.json index 8a880ccb0df..3f4082ef9f5 100644 --- a/plugins/ag-grid-task-autogen/package.json +++ b/plugins/ag-grid-task-autogen/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-task-autogen", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "private": true, "dependencies": { "@nx/devkit": "20.3.1", diff --git a/testing/accessibility/package.json b/testing/accessibility/package.json index f81daf07b10..ab86d46a478 100644 --- a/testing/accessibility/package.json +++ b/testing/accessibility/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-accessibility", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "scripts": { "download-examples": "curl --retry 5 -retry-all-errors https://grid-staging.ag-grid.com/debug/all-examples.json > ./all-examples.json", "download-examples-local": "curl https://localhost:4610/debug/all-examples.json > ./all-examples.json", @@ -18,11 +18,11 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "ag-grid-angular": "35.1.0-beta.20260309.1858", - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309", + "ag-grid-angular": "35.1.0-beta.20260312.1528", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/testing/angular-tests/package.json b/testing/angular-tests/package.json index aef9772446f..d44959290a3 100644 --- a/testing/angular-tests/package.json +++ b/testing/angular-tests/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-angular-tests", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "private": true, "scripts": { "test:e2e": "jest --no-cache" @@ -11,8 +11,8 @@ "@angular/core": "^21.0.0", "@angular/platform-browser": "^21.0.0", "@angular/platform-browser-dynamic": "^21.0.0", - "ag-grid-angular": "35.1.0-beta.20260309.1858", - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-angular": "35.1.0-beta.20260312.1528", + "ag-grid-community": "35.1.0-beta.20260312.1528", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/testing/behavioural/package.json b/testing/behavioural/package.json index a0a58063024..bec2c996951 100644 --- a/testing/behavioural/package.json +++ b/testing/behavioural/package.json @@ -1,6 +1,6 @@ { "name": "ag-behavioural-testing", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "private": true, "description": "Behavioural unit testing for ag-Grid", "dependencies": { @@ -8,9 +8,9 @@ }, "type": "module", "devDependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-grid-react": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-grid-react": "35.1.0-beta.20260312.1528", "@vitejs/plugin-react-swc": "^4.2.3", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", diff --git a/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation-changed-columns.test.ts b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation-changed-columns.test.ts new file mode 100644 index 00000000000..acc4b2fb40d --- /dev/null +++ b/testing/behavioural/src/cell-editing/group-edit/group-row-editable-aggregation-changed-columns.test.ts @@ -0,0 +1,284 @@ +import { GridRows } from '../../test-utils'; +import { + EDIT_MODES, + asyncSetTimeout, + cascadeGroupRowValueSetter, + editCell, + gridsManager, +} from './group-edit-test-utils'; + +afterEach(() => { + gridsManager.reset(); +}); + +// Tests that cell editing propagates aggregation correctly through ChangeDetectionService, +// exercising both ChangedCellsPath (aggregateOnlyChangedColumns=true) and ChangedRowsPath (false). + +describe.each([true, false])( + 'cell edit aggregation with aggregateOnlyChangedColumns=%s', + (aggregateOnlyChangedColumns) => { + describe.each(EDIT_MODES)('edit mode: %s', (editMode) => { + const baselineSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe ag-Grid-AutoColumn:"Europe" amount:180 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France ag-Grid-AutoColumn:"France" amount:60 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany ag-Grid-AutoColumn:"Germany" amount:120 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:50 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas ag-Grid-AutoColumn:"Americas" amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA ag-Grid-AutoColumn:"USA" amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `; + + function createGridOptions() { + return { + defaultColDef: { cellEditor: 'agTextCellEditor' }, + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + }, + { + colId: 'score', + field: 'score', + aggFunc: 'sum', + editable: true, + }, + ], + rowData: createMultiColumnRowData(), + groupDefaultExpanded: -1, + getRowId: (params: { data: { id: string } }) => params.data.id, + aggregateOnlyChangedColumns, + }; + } + + test('single leaf cell edit updates parent aggregations', async () => { + const api = await gridsManager.createGridAndWait('agg-changed-cols-leaf', createGridOptions()); + await new GridRows(api, 'baseline').check(baselineSnapshot); + + const parisNode = api.getRowNode('fr-paris')!; + + if (editMode === 'ui') { + await editCell(api, parisNode, 'amount', '50'); + } else { + parisNode.setDataValue('amount', 50, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // amount changed: 30 -> 50, so France = 80, Europe = 200 + // score should remain unchanged + await new GridRows(api, 'after leaf edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe ag-Grid-AutoColumn:"Europe" amount:200 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France ag-Grid-AutoColumn:"France" amount:80 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:50 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany ag-Grid-AutoColumn:"Germany" amount:120 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:50 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas ag-Grid-AutoColumn:"Americas" amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA ag-Grid-AutoColumn:"USA" amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `); + }); + + test('sequential edits on different columns propagate correctly', async () => { + const api = await gridsManager.createGridAndWait('agg-changed-cols-multi', createGridOptions()); + await new GridRows(api, 'baseline').check(baselineSnapshot); + + const berlinNode = api.getRowNode('de-berlin')!; + + // Edit amount: 50 -> 10 + if (editMode === 'ui') { + await editCell(api, berlinNode, 'amount', '10'); + } else { + berlinNode.setDataValue('amount', 10, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // Germany amount: 10 + 70 = 80, Europe amount: 60 + 80 = 140 + await new GridRows(api, 'after amount edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe ag-Grid-AutoColumn:"Europe" amount:140 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France ag-Grid-AutoColumn:"France" amount:60 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany ag-Grid-AutoColumn:"Germany" amount:80 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:10 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas ag-Grid-AutoColumn:"Americas" amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA ag-Grid-AutoColumn:"USA" amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `); + + // Now edit score on same row: 100 -> 200 + if (editMode === 'ui') { + await editCell(api, berlinNode, 'score', '200'); + } else { + berlinNode.setDataValue('score', 200, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // Germany score: 200 + 140 = 340, Europe score: 120 + 340 = 460 + await new GridRows(api, 'after score edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe ag-Grid-AutoColumn:"Europe" amount:140 score:460 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France ag-Grid-AutoColumn:"France" amount:60 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany ag-Grid-AutoColumn:"Germany" amount:80 score:340 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:10 score:200 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas ag-Grid-AutoColumn:"Americas" amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA ag-Grid-AutoColumn:"USA" amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `); + }); + + test('editing different rows in sequence updates aggregations independently', async () => { + const api = await gridsManager.createGridAndWait('agg-changed-cols-diff-rows', createGridOptions()); + await new GridRows(api, 'baseline').check(baselineSnapshot); + + // Edit paris amount: 30 -> 100 + const parisNode = api.getRowNode('fr-paris')!; + if (editMode === 'ui') { + await editCell(api, parisNode, 'amount', '100'); + } else { + parisNode.setDataValue('amount', 100, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // Edit nyc amount: 60 -> 200 + const nycNode = api.getRowNode('us-nyc')!; + if (editMode === 'ui') { + await editCell(api, nycNode, 'amount', '200'); + } else { + nycNode.setDataValue('amount', 200, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // France amount: 100 + 30 = 130, Europe: 130 + 120 = 250 + // USA amount: 200 + 40 = 240, Americas: 240 + // Scores unchanged + await new GridRows(api, 'after edits to different groups').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe ag-Grid-AutoColumn:"Europe" amount:250 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France ag-Grid-AutoColumn:"France" amount:130 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:100 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany ag-Grid-AutoColumn:"Germany" amount:120 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:50 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas ag-Grid-AutoColumn:"Americas" amount:240 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA ag-Grid-AutoColumn:"USA" amount:240 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:200 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `); + }); + }); + } +); + +describe.each(EDIT_MODES)('cascading group edit with aggregateOnlyChangedColumns (%s)', (editMode) => { + test.each([true, false])('aggregateOnlyChangedColumns=%s', async (aggregateOnlyChangedColumns) => { + const api = await gridsManager.createGridAndWait('agg-changed-cols-cascade', { + defaultColDef: { cellEditor: 'agTextCellEditor' }, + groupDisplayType: 'custom', + columnDefs: [ + { colId: 'group', headerName: 'Group', cellRenderer: 'agGroupCellRenderer' }, + { field: 'region', rowGroup: true, hide: true }, + { field: 'country', rowGroup: true, hide: true }, + { + colId: 'amount', + field: 'amount', + aggFunc: 'sum', + editable: true, + groupRowEditable: true, + groupRowValueSetter: cascadeGroupRowValueSetter, + }, + { + colId: 'score', + field: 'score', + aggFunc: 'sum', + editable: true, + }, + ], + rowData: createMultiColumnRowData(), + groupDefaultExpanded: -1, + getRowId: (params: { data: { id: string } }) => params.data.id, + aggregateOnlyChangedColumns, + }); + + const baselineSnapshot = ` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:180 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:60 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:30 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:30 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:120 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:50 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:70 score:140 + └─┬ filler id:row-group-region-Americas amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `; + + await new GridRows(api, 'baseline').check(baselineSnapshot); + + // Cascade edit on the Europe filler group: amount 180 -> 360 + // Europe has France(2 leaves) + Germany(2 leaves) + // cascadeGroupRowValueSetter distributes: 360/2 = 180 per country, 180/2 = 90 per leaf + const europeNode = api.getRowNode('row-group-region-Europe')!; + if (editMode === 'ui') { + await editCell(api, europeNode, 'amount', '360'); + } else { + europeNode.setDataValue('amount', 360, 'ui'); + await asyncSetTimeout(0); + } + await asyncSetTimeout(0); + + // Europe amount: 360, scores unchanged + await new GridRows(api, 'after cascade edit').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-region-Europe amount:360 score:360 + │ ├─┬ LEAF_GROUP id:row-group-region-Europe-country-France amount:180 score:120 + │ │ ├── LEAF id:fr-paris region:"Europe" country:"France" amount:90 score:60 + │ │ └── LEAF id:fr-lyon region:"Europe" country:"France" amount:90 score:60 + │ └─┬ LEAF_GROUP id:row-group-region-Europe-country-Germany amount:180 score:240 + │ · ├── LEAF id:de-berlin region:"Europe" country:"Germany" amount:90 score:100 + │ · └── LEAF id:de-hamburg region:"Europe" country:"Germany" amount:90 score:140 + └─┬ filler id:row-group-region-Americas amount:100 score:200 + · └─┬ LEAF_GROUP id:row-group-region-Americas-country-USA amount:100 score:200 + · · ├── LEAF id:us-nyc region:"Americas" country:"USA" amount:60 score:120 + · · └── LEAF id:us-la region:"Americas" country:"USA" amount:40 score:80 + `); + }); +}); + +/** Row data with two value columns for testing column-level change tracking */ +function createMultiColumnRowData() { + return [ + { id: 'fr-paris', region: 'Europe', country: 'France', amount: 30, score: 60 }, + { id: 'fr-lyon', region: 'Europe', country: 'France', amount: 30, score: 60 }, + { id: 'de-berlin', region: 'Europe', country: 'Germany', amount: 50, score: 100 }, + { id: 'de-hamburg', region: 'Europe', country: 'Germany', amount: 70, score: 140 }, + { id: 'us-nyc', region: 'Americas', country: 'USA', amount: 60, score: 120 }, + { id: 'us-la', region: 'Americas', country: 'USA', amount: 40, score: 80 }, + ]; +} diff --git a/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-validation.test.ts b/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-validation.test.ts index e933f5dd392..93bde0ae393 100644 --- a/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-validation.test.ts +++ b/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-validation.test.ts @@ -52,54 +52,55 @@ describe.each([false, true])('tree row dragging validation (suppress move %s)', return gridsManager.createGrid(id, gridOptions); }; - // test('unmanaged tree data drag leaves hierarchy unchanged', async () => { - // const rowData = [ - // { - // id: 'root', - // name: 'Root', - // type: 'folder', - // children: [{ id: 'drafts', name: 'Drafts', type: 'file', children: [] }], - // }, - // { id: 'archive', name: 'Archive', type: 'folder', children: [] }, - // ]; - // - // const api = createGrid('tree-unmanaged', rowData, { - // rowDragManaged: false, - // }); - // - // const initialRows = new GridRows(api, 'unmanaged initial'); - // await initialRows.check(` - // ROOT id:ROOT_NODE_ID - // ├─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" - // │ └── drafts LEAF id:drafts ag-Grid-AutoColumn:"Drafts" type:"file" - // └── archive LEAF id:archive ag-Grid-AutoColumn:"Archive" type:"folder" - // `); - // - // const sourceRowId = 'drafts'; - // const targetRowId = 'archive'; - // expect(getRowHtmlElement(api, sourceRowId)).toBeTruthy(); - // expect(getRowHtmlElement(api, targetRowId)).toBeTruthy(); - // - // const dispatcher = new RowDragDispatcher({ api }); - // await dispatcher.start(sourceRowId); - // await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Drafts')); - // await dispatcher.move(targetRowId, { yOffsetPercent: 0.6 }); - // await dispatcher.move(targetRowId, { center: true }); - // assertDropIndicatorVisible(api); - // await dispatcher.finish(); - // await asyncSetTimeout(0); - // - // const finalRows = new GridRows(api, 'unmanaged final'); - // await finalRows.check(` - // ROOT id:ROOT_NODE_ID - // ├─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" - // │ └── drafts LEAF id:drafts ag-Grid-AutoColumn:"Drafts" type:"file" - // └── archive LEAF id:archive ag-Grid-AutoColumn:"Archive" type:"folder" - // `); - // - // const endEvent = dispatcher.rowDragEndEvents[0]; - // expect(endEvent?.rowsDrop?.newParent?.id).toBe('ROOT_NODE_ID'); - // }); + test('unmanaged tree data drag leaves hierarchy unchanged', async () => { + const rowData = [ + { + id: 'root', + name: 'Root', + type: 'folder', + children: [{ id: 'drafts', name: 'Drafts', type: 'file', children: [] }], + }, + { id: 'archive', name: 'Archive', type: 'folder', children: [] }, + ]; + + const api = createGrid('tree-unmanaged', rowData, { + rowDragManaged: false, + }); + + const initialRows = new GridRows(api, 'unmanaged initial'); + await initialRows.check(` + ROOT id:ROOT_NODE_ID + ├─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" + │ └── drafts LEAF id:drafts ag-Grid-AutoColumn:"Drafts" type:"file" + └── archive LEAF id:archive ag-Grid-AutoColumn:"Archive" type:"folder" + `); + + const sourceRowId = 'drafts'; + const targetRowId = 'archive'; + expect(getRowHtmlElement(api, sourceRowId)).toBeTruthy(); + expect(getRowHtmlElement(api, targetRowId)).toBeTruthy(); + + const dispatcher = new RowDragDispatcher({ api }); + await dispatcher.start(sourceRowId); + await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Drafts')); + await asyncSetTimeout(1); + await dispatcher.move(targetRowId, { yOffsetPercent: 0.9 }); + await asyncSetTimeout(1); + assertDropIndicatorVisible(api); + await dispatcher.finish(); + await asyncSetTimeout(1); + + const finalRows = new GridRows(api, 'unmanaged final'); + await finalRows.check(` + ROOT id:ROOT_NODE_ID + ├─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" + │ └── drafts LEAF id:drafts ag-Grid-AutoColumn:"Drafts" type:"file" + └── archive LEAF id:archive ag-Grid-AutoColumn:"Archive" type:"folder" + `); + + const endEvent = dispatcher.rowDragEndEvents[0]; + expect(endEvent?.rowsDrop?.newParent?.id).toBe('ROOT_NODE_ID'); + }); test('isRowValidDropPosition can veto dropping into specific parents', async () => { const validatorParents: Array = []; @@ -143,10 +144,12 @@ describe.each([false, true])('tree row dragging validation (suppress move %s)', await dispatcher.start('draft'); await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Draft')); await dispatcher.move('protected', { yOffsetPercent: 0.35 }); + await asyncSetTimeout(1); await dispatcher.move('protected', { center: true }); + await asyncSetTimeout(1); assertDropIndicatorVisible(api); await dispatcher.finish(); - await asyncSetTimeout(0); + await asyncSetTimeout(1); const finalRows = new GridRows(api, 'validator final'); await finalRows.check(` @@ -217,8 +220,7 @@ describe.each([false, true])('tree row dragging validation (suppress move %s)', const dispatcher = new RowDragDispatcher({ api }); await dispatcher.start(sourceRowId); await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Team')); - await dispatcher.move(targetRowId, { yOffsetPercent: 0.6 }); - await dispatcher.move(targetRowId, { center: true }); + await dispatcher.move(targetRowId, { yOffsetPercent: 0.9 }); assertDropIndicatorVisible(api); await dispatcher.finish(); await asyncSetTimeout(0); diff --git a/testing/behavioural/src/grouping-data/benchmarks/grouping-aggregation.bench.ts b/testing/behavioural/src/grouping-data/benchmarks/grouping-aggregation.bench.ts new file mode 100644 index 00000000000..c5185bf5acd --- /dev/null +++ b/testing/behavioural/src/grouping-data/benchmarks/grouping-aggregation.bench.ts @@ -0,0 +1,243 @@ +/** + * Grouping aggregation benchmark — measures aggregation performance with real grid instances. + * + * Every iteration does real work by alternating between two distinct data sets (A/B), + * ensuring the immutable-data path always detects changes and triggers a full pipeline run. + * + * Uses 50 value columns with 3-level grouping (5 x 6 x 4 = 120 groups) to ensure + * aggregation cost dominates pipeline overhead. + * + * Run with: + * npx vitest bench --root testing/behavioural "grouping-aggregation.bench" + */ +import { bench, suite } from 'vitest'; + +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { ClientSideRowModelApiModule, ClientSideRowModelModule } from 'ag-grid-community'; +import { RowGroupingModule } from 'ag-grid-enterprise'; + +import { SimplePRNG, TestGridsManager } from '../../test-utils'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const ROW_COUNT = 5_000; +const VALUE_COL_COUNT = 50; +const UPDATE_FRACTION = 0.05; // 5% of rows changed per update +const CHANGED_COL_COUNT = 5; // columns changed in "partial col" benchmarks + +// Bench tuning: more warmup + longer measurement = more samples, less noise +const BENCH_TIME = 3000; // ms to measure (default 1000) +const BENCH_WARMUP_ITERATIONS = 10; // warmup iterations (default 5) + +// ── Data generation ───────────────────────────────────────────────────────── + +interface AggRow { + id: string; + group1: string; + group2: string; + group3: string; + [key: string]: string | number; +} + +function buildAggData(count: number, valueCols: number, prng = new SimplePRNG(0xa7c3d1e5)): AggRow[] { + const g1 = ['Dept A', 'Dept B', 'Dept C', 'Dept D', 'Dept E']; + const g2 = ['Team 1', 'Team 2', 'Team 3', 'Team 4', 'Team 5', 'Team 6']; + const g3 = ['Region W', 'Region X', 'Region Y', 'Region Z']; + const rows: AggRow[] = []; + for (let i = 0; i < count; ++i) { + const row: AggRow = { + id: i.toString(), + group1: prng.pick(g1)!, + group2: prng.pick(g2)!, + group3: prng.pick(g3)!, + }; + for (let v = 0; v < valueCols; ++v) { + row[`v${v}`] = prng.nextFloat(1, 1000); + } + rows.push(row); + } + return rows; +} + +/** Creates a partial update: UPDATE_FRACTION of rows get new values in `colCount` columns. */ +function buildPartialUpdate(rows: AggRow[], colCount: number, prng = new SimplePRNG(0x2a3b4c5d)): AggRow[] { + const updated = rows.slice(); + const changeCount = Math.floor(rows.length * UPDATE_FRACTION); + const indices = new Set(); + while (indices.size < changeCount) { + indices.add(prng.nextInt(0, updated.length - 1)); + } + for (const idx of indices) { + const row = { ...updated[idx] }; + for (let v = 0; v < colCount; ++v) { + row[`v${v}`] = prng.nextFloat(1, 1000); + } + updated[idx] = row; + } + return updated; +} + +/** Creates transaction edit arrays: forward (modified) and reverse (original). */ +function buildEdits(rows: AggRow[], colCount: number, prng = new SimplePRNG(0x5e6f7a8b)) { + const forward: AggRow[] = []; + const reverse: AggRow[] = []; + const changeCount = Math.floor(rows.length * UPDATE_FRACTION); + const indices = new Set(); + while (indices.size < changeCount) { + indices.add(prng.nextInt(0, rows.length - 1)); + } + for (const idx of indices) { + const original = rows[idx]; + const modified = { ...original }; + for (let v = 0; v < colCount; ++v) { + modified[`v${v}`] = (modified[`v${v}`] as number) * prng.nextFloat(0.5, 1.5); + } + forward.push(modified); + reverse.push(original); + } + return { forward, reverse }; +} + +function buildAggColumnDefs(): ColDef[] { + const defs: ColDef[] = [ + { field: 'group1', rowGroup: true, hide: true }, + { field: 'group2', rowGroup: true, hide: true }, + { field: 'group3', rowGroup: true, hide: true }, + ]; + for (let v = 0; v < VALUE_COL_COUNT; ++v) { + defs.push({ field: `v${v}`, aggFunc: 'sum' }); + } + return defs; +} + +// ── Pre-built data ─────────────────────────────────────────────────────────── + +const dataA = buildAggData(ROW_COUNT, VALUE_COL_COUNT); + +// Immutable data: all columns changed +const immAllA = dataA; +const immAllB = buildPartialUpdate(dataA, VALUE_COL_COUNT, new SimplePRNG(0x3c4d5e6f)); + +// Immutable data: only CHANGED_COL_COUNT columns changed +const immPartialA = dataA; +const immPartialB = buildPartialUpdate(dataA, CHANGED_COL_COUNT, new SimplePRNG(0x4d5e6f70)); + +// Transaction edits: all columns changed +const editsAll = buildEdits(dataA, VALUE_COL_COUNT); + +// Transaction edits: only CHANGED_COL_COUNT columns changed +const editsPartial = buildEdits(dataA, CHANGED_COL_COUNT, new SimplePRNG(0x6f7a8b9c)); + +// ── Scenario definitions ───────────────────────────────────────────────────── + +const modules = [ClientSideRowModelModule, ClientSideRowModelApiModule, RowGroupingModule]; + +const commonGridOptions: GridOptions = { + columnDefs: buildAggColumnDefs(), + autoGroupColumnDef: { headerName: 'Group' }, + groupDefaultExpanded: -1, + suppressAggFuncInHeader: true, + getRowId: ({ data }: { data: { id: string } }) => data.id, +}; + +function makeGridOptions(aggregateOnlyChangedColumns: boolean): GridOptions { + return { ...commonGridOptions, aggregateOnlyChangedColumns }; +} + +// ── Benchmarks ─────────────────────────────────────────────────────────────── +// Each bench iteration performs TWO operations (a round-trip) so that every call +// does real work and the measurements are comparable across benchmarks. + +const updateCount = Math.floor(ROW_COUNT * UPDATE_FRACTION); +const desc = `grouping aggregation — ${ROW_COUNT} rows, ${VALUE_COL_COUNT} value cols, ${updateCount} updated rows`; + +suite(desc, () => { + let gridId = 0; + + // Helper to create a bench for a given mode + const benchMode = (name: string, cellsPath: boolean, fn: (api: GridApi) => void, initialData: AggRow[]) => { + const id = `G${++gridId}`; + const gridsManager = new TestGridsManager({ benchmark: true, modules }); + let api!: GridApi; + + bench(name, () => fn(api), { + throws: true, + time: BENCH_TIME, + warmupIterations: BENCH_WARMUP_ITERATIONS, + setup: () => { + gridsManager.reset(); + api = gridsManager.createGrid(id, { ...makeGridOptions(cellsPath), rowData: initialData }); + }, + }); + }; + + // Full refresh: clear then reload + for (const cellsPath of [false, true]) { + const tag = cellsPath ? 'CellsPath' : 'RowsPath'; + benchMode( + `full refresh — ${tag}`, + cellsPath, + (api) => { + api.setGridOption('rowData', []); + api.setGridOption('rowData', dataA); + }, + [] + ); + } + + // Immutable update: all value columns changed + for (const cellsPath of [false, true]) { + const tag = cellsPath ? 'CellsPath' : 'RowsPath'; + benchMode( + `immutable (all ${VALUE_COL_COUNT} cols) — ${tag}`, + cellsPath, + (api) => { + api.setGridOption('rowData', immAllB); + api.setGridOption('rowData', immAllA); + }, + immAllA + ); + } + + // Immutable update: only CHANGED_COL_COUNT columns changed + for (const cellsPath of [false, true]) { + const tag = cellsPath ? 'CellsPath' : 'RowsPath'; + benchMode( + `immutable (${CHANGED_COL_COUNT}/${VALUE_COL_COUNT} cols) — ${tag}`, + cellsPath, + (api) => { + api.setGridOption('rowData', immPartialB); + api.setGridOption('rowData', immPartialA); + }, + immPartialA + ); + } + + // Transaction update: all value columns changed + for (const cellsPath of [false, true]) { + const tag = cellsPath ? 'CellsPath' : 'RowsPath'; + benchMode( + `transaction (all ${VALUE_COL_COUNT} cols) — ${tag}`, + cellsPath, + (api) => { + api.applyTransaction({ update: editsAll.forward }); + api.applyTransaction({ update: editsAll.reverse }); + }, + dataA + ); + } + + // Transaction update: only CHANGED_COL_COUNT columns changed + for (const cellsPath of [false, true]) { + const tag = cellsPath ? 'CellsPath' : 'RowsPath'; + benchMode( + `transaction (${CHANGED_COL_COUNT}/${VALUE_COL_COUNT} cols) — ${tag}`, + cellsPath, + (api) => { + api.applyTransaction({ update: editsPartial.forward }); + api.applyTransaction({ update: editsPartial.reverse }); + }, + dataA + ); + } +}); diff --git a/testing/behavioural/src/grouping-data/benchmarks/grouping-sorting.bench.ts b/testing/behavioural/src/grouping-data/benchmarks/grouping-sorting.bench.ts index 908dee4be0c..e8d044bc258 100644 --- a/testing/behavioural/src/grouping-data/benchmarks/grouping-sorting.bench.ts +++ b/testing/behavioural/src/grouping-data/benchmarks/grouping-sorting.bench.ts @@ -12,6 +12,7 @@ const COLUMN_COUNTS = [5]; suite('sorting', () => { const gridsManager = new TestGridsManager({ + benchmark: true, modules: [ClientSideRowModelModule, ClientSideRowModelApiModule, RowGroupingModule], }); diff --git a/testing/behavioural/src/grouping-data/benchmarks/grouping.bench.ts b/testing/behavioural/src/grouping-data/benchmarks/grouping.bench.ts index afe1e14ea97..6796e9e9e41 100644 --- a/testing/behavioural/src/grouping-data/benchmarks/grouping.bench.ts +++ b/testing/behavioural/src/grouping-data/benchmarks/grouping.bench.ts @@ -9,8 +9,7 @@ import { SimplePRNG, TestGridsManager } from '../../test-utils'; suite('row grouping', () => { const gridsManager = new TestGridsManager({ - includeDefaultModules: false, - mockGridLayout: false, + benchmark: true, modules: [ClientSideRowModelModule, ClientSideRowModelApiModule, RowGroupingModule], }); @@ -34,9 +33,6 @@ suite('row grouping', () => { rowData: [], groupDefaultExpanded: -1, suppressAggFuncInHeader: true, - ensureDomOrder: false, - suppressRowVirtualisation: false, - suppressColumnVirtualisation: false, getRowId: ({ data }: { data: { id: string } }) => data.id, }); }, diff --git a/testing/behavioural/src/grouping-data/benchmarks/pivot-aggregation.bench.ts b/testing/behavioural/src/grouping-data/benchmarks/pivot-aggregation.bench.ts new file mode 100644 index 00000000000..f1281c9024c --- /dev/null +++ b/testing/behavioural/src/grouping-data/benchmarks/pivot-aggregation.bench.ts @@ -0,0 +1,183 @@ +/** + * Pivot aggregation benchmark — measures pivot aggregation performance with real grid instances. + * + * Every iteration does real work by alternating between two distinct data sets (A/B), + * ensuring the immutable-data path always detects changes and triggers a full pipeline run. + * + * Uses the same 2-level group structure (5 x 6 = 30 groups) as the grouping benchmark. + * 3 value columns x 4 pivot values = 12 result columns. + * + * Run with: + * npx vitest bench --root testing/behavioural "pivot-aggregation.bench" + */ +import { bench, suite } from 'vitest'; + +import type { ColDef, GridApi, GridOptions } from 'ag-grid-community'; +import { ClientSideRowModelApiModule, ClientSideRowModelModule } from 'ag-grid-community'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; + +import { SimplePRNG, TestGridsManager } from '../../test-utils'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const ROW_COUNT = 10_000; +const VALUE_COL_COUNT = 3; +const PIVOT_VALUES = 4; // years: 2020–2023 +const UPDATE_FRACTION = 0.05; // 5% of rows changed per update + +// ── Data generation ───────────────────────────────────────────────────────── + +interface PivotRow { + id: string; + group1: string; + group2: string; + year: string; + [key: string]: string | number; +} + +function buildPivotData(count: number, prng = new SimplePRNG(0xb4d5e6f7)): PivotRow[] { + const g1 = ['Dept A', 'Dept B', 'Dept C', 'Dept D', 'Dept E']; + const g2 = ['Team 1', 'Team 2', 'Team 3', 'Team 4', 'Team 5', 'Team 6']; + const years = ['2020', '2021', '2022', '2023']; + const rows: PivotRow[] = []; + for (let i = 0; i < count; ++i) { + const row: PivotRow = { + id: `p${i}`, + group1: prng.pick(g1)!, + group2: prng.pick(g2)!, + year: prng.pick(years)!, + }; + for (let v = 0; v < VALUE_COL_COUNT; ++v) { + row[`v${v}`] = prng.nextFloat(1, 1000); + } + rows.push(row); + } + return rows; +} + +/** Creates a partial update: UPDATE_FRACTION of rows get new value columns. */ +function buildPartialUpdate(rows: PivotRow[], prng = new SimplePRNG(0xc5e6f7a8)): PivotRow[] { + const updated = rows.slice(); + const changeCount = Math.floor(rows.length * UPDATE_FRACTION); + const indices = new Set(); + while (indices.size < changeCount) { + indices.add(prng.nextInt(0, updated.length - 1)); + } + for (const idx of indices) { + const row = { ...updated[idx] }; + for (let v = 0; v < VALUE_COL_COUNT; ++v) { + row[`v${v}`] = prng.nextFloat(1, 1000); + } + updated[idx] = row; + } + return updated; +} + +/** Creates transaction edit arrays: forward (modified) and reverse (original). */ +function buildEdits(rows: PivotRow[], prng = new SimplePRNG(0xd6f7a8b9)) { + const forward: PivotRow[] = []; + const reverse: PivotRow[] = []; + const changeCount = Math.floor(rows.length * UPDATE_FRACTION); + const indices = new Set(); + while (indices.size < changeCount) { + indices.add(prng.nextInt(0, rows.length - 1)); + } + for (const idx of indices) { + const original = rows[idx]; + const modified = { ...original }; + for (let v = 0; v < VALUE_COL_COUNT; ++v) { + modified[`v${v}`] = (modified[`v${v}`] as number) * prng.nextFloat(0.5, 1.5); + } + forward.push(modified); + reverse.push(original); + } + return { forward, reverse }; +} + +function buildPivotColumnDefs(): ColDef[] { + const defs: ColDef[] = [ + { field: 'group1', rowGroup: true, hide: true }, + { field: 'group2', rowGroup: true, hide: true }, + { field: 'year', pivot: true, hide: true }, + ]; + for (let v = 0; v < VALUE_COL_COUNT; ++v) { + defs.push({ field: `v${v}`, aggFunc: 'sum', hide: true }); + } + return defs; +} + +// ── Pre-built data ─────────────────────────────────────────────────────────── + +const dataA = buildPivotData(ROW_COUNT); + +// Immutable: alternating A → B → A → B ensures real changes every iteration +const immutableA = dataA; +const immutableB = buildPartialUpdate(dataA, new SimplePRNG(0x3c4d5e6f)); + +// Transaction edits: forward then reverse +const edits = buildEdits(dataA); + +// ── Benchmarks ─────────────────────────────────────────────────────────────── + +const modules = [ClientSideRowModelModule, ClientSideRowModelApiModule, RowGroupingModule, PivotModule]; +const resultCols = VALUE_COL_COUNT * PIVOT_VALUES; +const updateCount = Math.floor(ROW_COUNT * UPDATE_FRACTION); + +const gridOptions: GridOptions = { + columnDefs: buildPivotColumnDefs(), + pivotMode: true, + autoGroupColumnDef: { headerName: 'Group' }, + groupDefaultExpanded: -1, + suppressAggFuncInHeader: true, + getRowId: ({ data }: { data: { id: string } }) => data.id, +}; + +const desc = `pivot aggregation — ${ROW_COUNT} rows, ${resultCols} result cols, ${updateCount} updated rows`; + +suite(desc, () => { + let gridId = 0; + + const benchMode = (name: string, fn: (api: GridApi) => void, initialData: PivotRow[]) => { + const id = `P${++gridId}`; + const gridsManager = new TestGridsManager({ benchmark: true, modules }); + let api!: GridApi; + + bench(name, () => fn(api), { + throws: true, + setup: () => { + gridsManager.reset(); + api = gridsManager.createGrid(id, { ...gridOptions, rowData: initialData }); + }, + }); + }; + + // Full refresh: clear then reload + benchMode( + 'full refresh', + (api) => { + api.setGridOption('rowData', []); + api.setGridOption('rowData', dataA); + }, + [] + ); + + // Immutable update: 5% of rows changed, alternating A → B → A → B + benchMode( + `immutable update (${updateCount} rows)`, + (api) => { + api.setGridOption('rowData', immutableB); + api.setGridOption('rowData', immutableA); + }, + immutableA + ); + + // Transaction update: same 5% forward then reverse + benchMode( + `transaction update (${updateCount} rows)`, + (api) => { + api.applyTransaction({ update: edits.forward }); + api.applyTransaction({ update: edits.reverse }); + }, + dataA + ); +}); diff --git a/testing/behavioural/src/grouping-data/grouping-aggregation-changed-columns.test.ts b/testing/behavioural/src/grouping-data/grouping-aggregation-changed-columns.test.ts new file mode 100644 index 00000000000..036a6ffff4e --- /dev/null +++ b/testing/behavioural/src/grouping-data/grouping-aggregation-changed-columns.test.ts @@ -0,0 +1,337 @@ +import { ClientSideRowModelModule } from 'ag-grid-community'; +import { RowGroupingModule } from 'ag-grid-enterprise'; + +import { + GridRows, + TestGridsManager, + applyTransactionChecked, + cachedJSONObjects, + executeTransactionsAsync, +} from '../test-utils'; + +describe('ag-grid grouping aggregation with aggregateOnlyChangedColumns', () => { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, RowGroupingModule], + }); + + beforeEach(() => { + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test.each([false, true] as const)( + 'single-level grouping with aggregateOnlyChangedColumns=%s, transaction update', + async (aggregateOnlyChangedColumns) => { + const rowData = cachedJSONObjects.array([ + { id: '1', country: 'Ireland', gold: 1, silver: 2 }, + { id: '2', country: 'Ireland', gold: 2, silver: 1 }, + { id: '3', country: 'Italy', gold: 3, silver: 3 }, + { id: '4', country: 'Italy', gold: 4, silver: 4 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum' }, + { field: 'silver', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + animateRows: false, + groupDefaultExpanded: -1, + aggregateOnlyChangedColumns, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 silver:3 + │ ├── LEAF id:1 country:"Ireland" gold:1 silver:2 + │ └── LEAF id:2 country:"Ireland" gold:2 silver:1 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:7 silver:7 + · ├── LEAF id:3 country:"Italy" gold:3 silver:3 + · └── LEAF id:4 country:"Italy" gold:4 silver:4 + `); + + // Update only 'gold' for one row — silver should remain unchanged + applyTransactionChecked(api, { + update: [{ id: '1', country: 'Ireland', gold: 10, silver: 2 }], + }); + + await new GridRows(api, 'after gold update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:12 silver:3 + │ ├── LEAF id:1 country:"Ireland" gold:10 silver:2 + │ └── LEAF id:2 country:"Ireland" gold:2 silver:1 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:7 silver:7 + · ├── LEAF id:3 country:"Italy" gold:3 silver:3 + · └── LEAF id:4 country:"Italy" gold:4 silver:4 + `); + + // Update both columns for another row + applyTransactionChecked(api, { + update: [{ id: '3', country: 'Italy', gold: 30, silver: 30 }], + }); + + await new GridRows(api, 'after both columns update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:12 silver:3 + │ ├── LEAF id:1 country:"Ireland" gold:10 silver:2 + │ └── LEAF id:2 country:"Ireland" gold:2 silver:1 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:34 silver:34 + · ├── LEAF id:3 country:"Italy" gold:30 silver:30 + · └── LEAF id:4 country:"Italy" gold:4 silver:4 + `); + } + ); + + test.each([false, true] as const)( + 'multi-level grouping with aggregateOnlyChangedColumns=%s, transactions', + async (aggregateOnlyChangedColumns) => { + const rowData = cachedJSONObjects.array([ + { id: '1', country: 'Ireland', sport: 'Sailing', gold: 1, silver: 10 }, + { id: '2', country: 'Ireland', sport: 'Soccer', gold: 2, silver: 20 }, + { id: '3', country: 'Ireland', sport: 'Soccer', gold: 3, silver: 30 }, + { id: '4', country: 'Italy', sport: 'Football', gold: 4, silver: 40 }, + { id: '5', country: 'Italy', sport: 'Tennis', gold: 5, silver: 50 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'sport', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum' }, + { field: 'silver', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + aggregateOnlyChangedColumns, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:6 silver:60 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-sport-Sailing ag-Grid-AutoColumn:"Sailing" gold:1 silver:10 + │ │ └── LEAF id:1 country:"Ireland" sport:"Sailing" gold:1 silver:10 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-sport-Soccer ag-Grid-AutoColumn:"Soccer" gold:5 silver:50 + │ · ├── LEAF id:2 country:"Ireland" sport:"Soccer" gold:2 silver:20 + │ · └── LEAF id:3 country:"Ireland" sport:"Soccer" gold:3 silver:30 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 silver:90 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-sport-Football ag-Grid-AutoColumn:"Football" gold:4 silver:40 + · │ └── LEAF id:4 country:"Italy" sport:"Football" gold:4 silver:40 + · └─┬ LEAF_GROUP id:row-group-country-Italy-sport-Tennis ag-Grid-AutoColumn:"Tennis" gold:5 silver:50 + · · └── LEAF id:5 country:"Italy" sport:"Tennis" gold:5 silver:50 + `); + + // Update gold only for a deep leaf + applyTransactionChecked(api, { + update: [{ id: '2', country: 'Ireland', sport: 'Soccer', gold: 20, silver: 20 }], + }); + + await new GridRows(api, 'after update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:24 silver:60 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-sport-Sailing ag-Grid-AutoColumn:"Sailing" gold:1 silver:10 + │ │ └── LEAF id:1 country:"Ireland" sport:"Sailing" gold:1 silver:10 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-sport-Soccer ag-Grid-AutoColumn:"Soccer" gold:23 silver:50 + │ · ├── LEAF id:2 country:"Ireland" sport:"Soccer" gold:20 silver:20 + │ · └── LEAF id:3 country:"Ireland" sport:"Soccer" gold:3 silver:30 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 silver:90 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-sport-Football ag-Grid-AutoColumn:"Football" gold:4 silver:40 + · │ └── LEAF id:4 country:"Italy" sport:"Football" gold:4 silver:40 + · └─┬ LEAF_GROUP id:row-group-country-Italy-sport-Tennis ag-Grid-AutoColumn:"Tennis" gold:5 silver:50 + · · └── LEAF id:5 country:"Italy" sport:"Tennis" gold:5 silver:50 + `); + + // Add and remove rows + applyTransactionChecked(api, { + add: [{ id: '6', country: 'Italy', sport: 'Football', gold: 6, silver: 60 }], + remove: [rowData[0]], + }); + + await new GridRows(api, 'after add+remove', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:23 silver:50 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-sport-Soccer ag-Grid-AutoColumn:"Soccer" gold:23 silver:50 + │ · ├── LEAF id:2 country:"Ireland" sport:"Soccer" gold:20 silver:20 + │ · └── LEAF id:3 country:"Ireland" sport:"Soccer" gold:3 silver:30 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:15 silver:150 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-sport-Football ag-Grid-AutoColumn:"Football" gold:10 silver:100 + · │ ├── LEAF id:4 country:"Italy" sport:"Football" gold:4 silver:40 + · │ └── LEAF id:6 country:"Italy" sport:"Football" gold:6 silver:60 + · └─┬ LEAF_GROUP id:row-group-country-Italy-sport-Tennis ag-Grid-AutoColumn:"Tennis" gold:5 silver:50 + · · └── LEAF id:5 country:"Italy" sport:"Tennis" gold:5 silver:50 + `); + } + ); + + test.each([false, true] as const)( + 'aggregateOnlyChangedColumns=%s with async transactions', + async (aggregateOnlyChangedColumns) => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', x: 1, y: 10 }, + { id: '2', group: 'A', x: 2, y: 20 }, + { id: '3', group: 'B', x: 3, y: 30 }, + { id: '4', group: 'B', x: 4, y: 40 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'x', aggFunc: 'sum' }, + { field: 'y', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + aggregateOnlyChangedColumns, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" x:3 y:30 + │ ├── LEAF id:1 group:"A" x:1 y:10 + │ └── LEAF id:2 group:"A" x:2 y:20 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" x:7 y:70 + · ├── LEAF id:3 group:"B" x:3 y:30 + · └── LEAF id:4 group:"B" x:4 y:40 + `); + + // Multiple async transactions that update different columns in different groups + await executeTransactionsAsync( + [ + { update: [{ id: '1', group: 'A', x: 100, y: 10 }] }, + { update: [{ id: '3', group: 'B', x: 3, y: 300 }] }, + { add: [{ id: '5', group: 'A', x: 5, y: 50 }] }, + ], + api + ); + + await new GridRows(api, 'after async transactions', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" x:107 y:80 + │ ├── LEAF id:1 group:"A" x:100 y:10 + │ ├── LEAF id:2 group:"A" x:2 y:20 + │ └── LEAF id:5 group:"A" x:5 y:50 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" x:7 y:340 + · ├── LEAF id:3 group:"B" x:3 y:300 + · └── LEAF id:4 group:"B" x:4 y:40 + `); + } + ); + + test.each([false, true] as const)( + 'aggregateOnlyChangedColumns=%s with immutable data', + async (aggregateOnlyChangedColumns) => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', gold: 1, silver: 10 }, + { id: '2', group: 'A', gold: 2, silver: 20 }, + { id: '3', group: 'B', gold: 3, silver: 30 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum' }, + { field: 'silver', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + aggregateOnlyChangedColumns, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" gold:3 silver:30 + │ ├── LEAF id:1 group:"A" gold:1 silver:10 + │ └── LEAF id:2 group:"A" gold:2 silver:20 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" gold:3 silver:30 + · └── LEAF id:3 group:"B" gold:3 silver:30 + `); + + // Immutable update: change gold for id:1, add new row + api.setGridOption( + 'rowData', + cachedJSONObjects.array([ + { id: '1', group: 'A', gold: 100, silver: 10 }, + { id: '2', group: 'A', gold: 2, silver: 20 }, + { id: '3', group: 'B', gold: 3, silver: 30 }, + { id: '4', group: 'B', gold: 4, silver: 40 }, + ]) + ); + + await new GridRows(api, 'after immutable update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" gold:102 silver:30 + │ ├── LEAF id:1 group:"A" gold:100 silver:10 + │ └── LEAF id:2 group:"A" gold:2 silver:20 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" gold:7 silver:70 + · ├── LEAF id:3 group:"B" gold:3 silver:30 + · └── LEAF id:4 group:"B" gold:4 silver:40 + `); + } + ); + + test('aggregateOnlyChangedColumns with grand total and group footers', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', value: 10 }, + { id: '2', group: 'A', value: 20 }, + { id: '3', group: 'B', value: 30 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'value', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + aggregateOnlyChangedColumns: true, + alwaysAggregateAtRootLevel: true, + grandTotalRow: 'top', + groupTotalRow: 'bottom', + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID value:60 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:null value:60 + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" + │ ├── LEAF id:1 group:"A" value:10 + │ ├── LEAF id:2 group:"A" value:20 + │ └─ footer id:rowGroupFooter_row-group-group-A ag-Grid-AutoColumn:"A" value:30 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" + · ├── LEAF id:3 group:"B" value:30 + · └─ footer id:rowGroupFooter_row-group-group-B ag-Grid-AutoColumn:"B" value:30 + `); + + applyTransactionChecked(api, { + update: [{ id: '1', group: 'A', value: 100 }], + }); + + await new GridRows(api, 'after update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID value:150 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:null value:150 + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" + │ ├── LEAF id:1 group:"A" value:100 + │ ├── LEAF id:2 group:"A" value:20 + │ └─ footer id:rowGroupFooter_row-group-group-A ag-Grid-AutoColumn:"A" value:120 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" + · ├── LEAF id:3 group:"B" value:30 + · └─ footer id:rowGroupFooter_row-group-group-B ag-Grid-AutoColumn:"B" value:30 + `); + }); +}); diff --git a/testing/behavioural/src/grouping-data/grouping-aggregation-options.test.ts b/testing/behavioural/src/grouping-data/grouping-aggregation-options.test.ts new file mode 100644 index 00000000000..7b7ad1de791 --- /dev/null +++ b/testing/behavioural/src/grouping-data/grouping-aggregation-options.test.ts @@ -0,0 +1,244 @@ +import { ClientSideRowModelModule, NumberFilterModule, TextFilterModule } from 'ag-grid-community'; +import { RowGroupingModule } from 'ag-grid-enterprise'; + +import { GridRows, TestGridsManager, applyTransactionChecked, cachedJSONObjects } from '../test-utils'; + +describe('ag-grid grouping aggregation options', () => { + const gridsManager = new TestGridsManager({ + modules: [ClientSideRowModelModule, NumberFilterModule, TextFilterModule, RowGroupingModule], + }); + + beforeEach(() => { + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('suppressAggFilteredOnly: aggregates all children regardless of filter', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', value: 10, year: 2020 }, + { id: '2', group: 'A', value: 20, year: 2021 }, + { id: '3', group: 'A', value: 30, year: 2022 }, + { id: '4', group: 'B', value: 40, year: 2020 }, + { id: '5', group: 'B', value: 50, year: 2021 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'value', aggFunc: 'sum' }, + { field: 'year', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + suppressAggFilteredOnly: true, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:60 + │ ├── LEAF id:1 group:"A" value:10 year:2020 + │ ├── LEAF id:2 group:"A" value:20 year:2021 + │ └── LEAF id:3 group:"A" value:30 year:2022 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:90 + · ├── LEAF id:4 group:"B" value:40 year:2020 + · └── LEAF id:5 group:"B" value:50 year:2021 + `); + + // Filter to year >= 2021 — with suppressAggFilteredOnly, aggregation should still use ALL rows + api.setFilterModel({ + year: { filterType: 'number', type: 'greaterThanOrEqual', filter: 2021 }, + }); + + await new GridRows(api, 'filtered, agg uses all children', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:60 + │ ├── LEAF id:2 group:"A" value:20 year:2021 + │ └── LEAF id:3 group:"A" value:30 year:2022 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:90 + · └── LEAF id:5 group:"B" value:50 year:2021 + `); + }); + + test('default (no suppressAggFilteredOnly): aggregates only filtered children', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', value: 10, year: 2020 }, + { id: '2', group: 'A', value: 20, year: 2021 }, + { id: '3', group: 'A', value: 30, year: 2022 }, + { id: '4', group: 'B', value: 40, year: 2020 }, + { id: '5', group: 'B', value: 50, year: 2021 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'value', aggFunc: 'sum' }, + { field: 'year', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + rowData, + getRowId: (params) => params.data.id, + }); + + // Filter to year >= 2021 — without suppressAggFilteredOnly, aggregation uses only filtered rows + api.setFilterModel({ + year: { filterType: 'number', type: 'greaterThanOrEqual', filter: 2021 }, + }); + + await new GridRows(api, 'filtered, agg uses filtered children only', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:50 + │ ├── LEAF id:2 group:"A" value:20 year:2021 + │ └── LEAF id:3 group:"A" value:30 year:2022 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:50 + · └── LEAF id:5 group:"B" value:50 year:2021 + `); + }); + + test('getGroupRowAgg: custom whole-row aggregation', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', x: 10, y: 100 }, + { id: '2', group: 'A', x: 20, y: 200 }, + { id: '3', group: 'B', x: 30, y: 300 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ field: 'group', rowGroup: true, hide: true }, { field: 'x' }, { field: 'y' }], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + getGroupRowAgg: (params) => { + let sumX = 0; + let sumY = 0; + for (const node of params.nodes) { + sumX += node.data?.x ?? 0; + sumY += node.data?.y ?? 0; + } + return { x: sumX, y: sumY * 2 }; + }, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" x:30 y:600 + │ ├── LEAF id:1 group:"A" x:10 y:100 + │ └── LEAF id:2 group:"A" x:20 y:200 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" x:30 y:600 + · └── LEAF id:3 group:"B" x:30 y:300 + `); + + applyTransactionChecked(api, { + update: [{ id: '1', group: 'A', x: 100, y: 100 }], + }); + + await new GridRows(api, 'after update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" x:120 y:600 + │ ├── LEAF id:1 group:"A" x:100 y:100 + │ └── LEAF id:2 group:"A" x:20 y:200 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" x:30 y:600 + · └── LEAF id:3 group:"B" x:30 y:300 + `); + }); + + test('getGroupRowAgg with grand total row', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', value: 10 }, + { id: '2', group: 'A', value: 20 }, + { id: '3', group: 'B', value: 30 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ field: 'group', rowGroup: true, hide: true }, { field: 'value' }], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + grandTotalRow: 'top', + alwaysAggregateAtRootLevel: true, + getGroupRowAgg: (params) => { + let sum = 0; + for (const node of params.nodes) { + const val = node.group ? node.aggData?.value : node.data?.value; + sum += val ?? 0; + } + return { value: sum }; + }, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID value:60 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:null value:60 + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" value:30 + │ ├── LEAF id:1 group:"A" value:10 + │ └── LEAF id:2 group:"A" value:20 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" value:30 + · └── LEAF id:3 group:"B" value:30 + `); + }); + + test('multiple aggFunc types: sum, avg, count, min, max, first, last', async () => { + const rowData = cachedJSONObjects.array([ + { id: '1', group: 'A', v: 10 }, + { id: '2', group: 'A', v: 20 }, + { id: '3', group: 'A', v: 30 }, + { id: '4', group: 'B', v: 5 }, + { id: '5', group: 'B', v: 15 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { colId: 'sum', field: 'v', aggFunc: 'sum' }, + { colId: 'avg', field: 'v', aggFunc: 'avg' }, + { colId: 'count', field: 'v', aggFunc: 'count' }, + { colId: 'min', field: 'v', aggFunc: 'min' }, + { colId: 'max', field: 'v', aggFunc: 'max' }, + { colId: 'first', field: 'v', aggFunc: 'first' }, + { colId: 'last', field: 'v', aggFunc: 'last' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + animateRows: false, + groupDefaultExpanded: -1, + rowData, + getRowId: (params) => params.data.id, + }); + + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" sum:60 avg:{"count":3,"value":20} count:{"value":3} min:10 max:30 first:10 last:30 + │ ├── LEAF id:1 group:"A" sum:10 avg:10 count:10 min:10 max:10 first:10 last:10 + │ ├── LEAF id:2 group:"A" sum:20 avg:20 count:20 min:20 max:20 first:20 last:20 + │ └── LEAF id:3 group:"A" sum:30 avg:30 count:30 min:30 max:30 first:30 last:30 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" sum:20 avg:{"count":2,"value":10} count:{"value":2} min:5 max:15 first:5 last:15 + · ├── LEAF id:4 group:"B" sum:5 avg:5 count:5 min:5 max:5 first:5 last:5 + · └── LEAF id:5 group:"B" sum:15 avg:15 count:15 min:15 max:15 first:15 last:15 + `); + + // Update and verify all agg functions recalculate correctly + applyTransactionChecked(api, { + update: [{ id: '1', group: 'A', v: 50 }], + }); + + await new GridRows(api, 'after update', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-group-A ag-Grid-AutoColumn:"A" sum:100 avg:{"count":3,"value":33.333333333333336} count:{"value":3} min:20 max:50 first:50 last:30 + │ ├── LEAF id:1 group:"A" sum:50 avg:50 count:50 min:50 max:50 first:50 last:50 + │ ├── LEAF id:2 group:"A" sum:20 avg:20 count:20 min:20 max:20 first:20 last:20 + │ └── LEAF id:3 group:"A" sum:30 avg:30 count:30 min:30 max:30 first:30 last:30 + └─┬ LEAF_GROUP id:row-group-group-B ag-Grid-AutoColumn:"B" sum:20 avg:{"count":2,"value":10} count:{"value":2} min:5 max:15 first:5 last:15 + · ├── LEAF id:4 group:"B" sum:5 avg:5 count:5 min:5 max:5 first:5 last:5 + · └── LEAF id:5 group:"B" sum:15 avg:15 count:15 min:15 max:15 first:15 last:15 + `); + }); +}); diff --git a/testing/behavioural/src/grouping-data/grouping-aggregation.test.ts b/testing/behavioural/src/grouping-data/grouping-aggregation.test.ts index cd719148e4d..d2321b4025e 100644 --- a/testing/behavioural/src/grouping-data/grouping-aggregation.test.ts +++ b/testing/behavioural/src/grouping-data/grouping-aggregation.test.ts @@ -1,3 +1,4 @@ +import type { IAggFuncParams } from 'ag-grid-community'; import { ClientSideRowModelModule, RowSelectionModule } from 'ag-grid-community'; import { RowGroupingModule } from 'ag-grid-enterprise'; @@ -273,6 +274,44 @@ describe('ag-grid grouping aggregation', () => { `); }); + test('custom aggFunc receives api and context in params', async () => { + const myContext = { myKey: 'myContextValue' }; + const capturedParams: IAggFuncParams[] = []; + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'category', rowGroup: true, hide: true }, + { + field: 'value', + aggFunc: (params: IAggFuncParams) => { + capturedParams.push({ ...params }); + return params.values.reduce((a: number, b: number) => a + b, 0); + }, + }, + ], + autoGroupColumnDef: { headerName: 'Category' }, + groupDefaultExpanded: -1, + context: myContext, + rowData: [ + { id: '1', category: 'A', value: 10 }, + { id: '2', category: 'A', value: 20 }, + ], + getRowId: (params) => params.data.id, + }); + + expect(capturedParams.length).toBeGreaterThan(0); + + for (const params of capturedParams) { + expect(params.api).toBe(api); + expect(params.context).toBe(myContext); + expect(params.rowNode).toBeDefined(); + expect(params.column).toBeDefined(); + expect(params.colDef).toBeDefined(); + expect(params.values).toBeDefined(); + expect(Array.isArray(params.values)).toBe(true); + } + }); + test('grouping with mixed data types and null values in aggregation', async () => { const rowData = cachedJSONObjects.array([ { id: '1', category: 'A', amount: 100, quantity: null, active: true }, @@ -328,4 +367,79 @@ describe('ag-grid grouping aggregation', () => { └─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " amount:450 quantity:{"count":3,"value":20} active:{"value":5} `); }); + + test('cross-column valueGetter dependencies during aggregation', async () => { + // Column 'total' uses a valueGetter that reads 'price' and 'qty' via params.getValue(). + // This verifies that aggregation correctly handles valueGetters with cross-column + // dependencies — getValue must resolve the other column's value for the same row, + // including when intermediate group nodes propagate aggregated values upward. + const rowData = cachedJSONObjects.array([ + { id: '1', region: 'EU', category: 'A', price: 10, qty: 3 }, + { id: '2', region: 'EU', category: 'A', price: 20, qty: 2 }, + { id: '3', region: 'EU', category: 'B', price: 15, qty: 4 }, + { id: '4', region: 'US', category: 'A', price: 25, qty: 1 }, + { id: '5', region: 'US', category: 'B', price: 30, qty: 2 }, + ]); + + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'category', rowGroup: true, hide: true }, + { field: 'price', aggFunc: 'sum' }, + { field: 'qty', aggFunc: 'sum' }, + { + colId: 'total', + headerName: 'Total', + valueGetter: (params) => { + const price = params.getValue('price'); + const qty = params.getValue('qty'); + return (price ?? 0) * (qty ?? 0); + }, + aggFunc: 'sum', + }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + groupDefaultExpanded: -1, + rowData, + getRowId: (params) => params.data.id, + }); + + // Leaf totals: 10*3=30, 20*2=40, 15*4=60, 25*1=25, 30*2=60 + // EU/A: sum(30,40)=70. EU/B: sum(60)=60. EU: sum(70,60)=130 + // US/A: sum(25)=25. US/B: sum(60)=60. US: sum(25,60)=85 + await new GridRows(api, 'initial', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID total:0 + ├─┬ filler id:row-group-region-EU ag-Grid-AutoColumn:"EU" price:45 qty:9 total:130 + │ ├─┬ LEAF_GROUP id:row-group-region-EU-category-A ag-Grid-AutoColumn:"A" price:30 qty:5 total:70 + │ │ ├── LEAF id:1 region:"EU" category:"A" price:10 qty:3 total:30 + │ │ └── LEAF id:2 region:"EU" category:"A" price:20 qty:2 total:40 + │ └─┬ LEAF_GROUP id:row-group-region-EU-category-B ag-Grid-AutoColumn:"B" price:15 qty:4 total:60 + │ · └── LEAF id:3 region:"EU" category:"B" price:15 qty:4 total:60 + └─┬ filler id:row-group-region-US ag-Grid-AutoColumn:"US" price:55 qty:3 total:85 + · ├─┬ LEAF_GROUP id:row-group-region-US-category-A ag-Grid-AutoColumn:"A" price:25 qty:1 total:25 + · │ └── LEAF id:4 region:"US" category:"A" price:25 qty:1 total:25 + · └─┬ LEAF_GROUP id:row-group-region-US-category-B ag-Grid-AutoColumn:"B" price:30 qty:2 total:60 + · · └── LEAF id:5 region:"US" category:"B" price:30 qty:2 total:60 + `); + + applyTransactionChecked(api, { + update: [{ id: '1', region: 'EU', category: 'A', price: 100, qty: 5 }], + }); + + // EU/A: sum(100*5=500, 40)=540. EU: sum(540,60)=600 + await new GridRows(api, 'after transaction', { useFormatter: false }).check(` + ROOT id:ROOT_NODE_ID total:0 + ├─┬ filler id:row-group-region-EU ag-Grid-AutoColumn:"EU" price:135 qty:11 total:600 + │ ├─┬ LEAF_GROUP id:row-group-region-EU-category-A ag-Grid-AutoColumn:"A" price:120 qty:7 total:540 + │ │ ├── LEAF id:1 region:"EU" category:"A" price:100 qty:5 total:500 + │ │ └── LEAF id:2 region:"EU" category:"A" price:20 qty:2 total:40 + │ └─┬ LEAF_GROUP id:row-group-region-EU-category-B ag-Grid-AutoColumn:"B" price:15 qty:4 total:60 + │ · └── LEAF id:3 region:"EU" category:"B" price:15 qty:4 total:60 + └─┬ filler id:row-group-region-US ag-Grid-AutoColumn:"US" price:55 qty:3 total:85 + · ├─┬ LEAF_GROUP id:row-group-region-US-category-A ag-Grid-AutoColumn:"A" price:25 qty:1 total:25 + · │ └── LEAF id:4 region:"US" category:"A" price:25 qty:1 total:25 + · └─┬ LEAF_GROUP id:row-group-region-US-category-B ag-Grid-AutoColumn:"B" price:30 qty:2 total:60 + · · └── LEAF id:5 region:"US" category:"B" price:30 qty:2 total:60 + `); + }); }); diff --git a/testing/behavioural/src/grouping-data/grouping-filter-aggregates-stage.test.ts b/testing/behavioural/src/grouping-data/grouping-filter-aggregates-stage.test.ts new file mode 100644 index 00000000000..fea6ec198f1 --- /dev/null +++ b/testing/behavioural/src/grouping-data/grouping-filter-aggregates-stage.test.ts @@ -0,0 +1,714 @@ +import { ClientSideRowModelModule, NumberFilterModule, QuickFilterModule } from 'ag-grid-community'; +import { PivotModule, RowGroupingModule, TreeDataModule } from 'ag-grid-enterprise'; + +import { GridRows, TestGridsManager, cachedJSONObjects } from '../test-utils'; + +describe('ag-grid filterAggregatesStage', () => { + const gridsManager = new TestGridsManager({ + modules: [NumberFilterModule, ClientSideRowModelModule, RowGroupingModule], + }); + + beforeEach(() => { + vitest.useRealTimers(); + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('groupAggFiltering=true filters groups by aggregate values', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Ireland', gold: 2 }, + { country: 'Italy', gold: 4 }, + { country: 'Italy', gold: 5 }, + { country: 'France', gold: 1 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + ├─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + │ ├── LEAF id:2 country:"Italy" gold:4 + │ └── LEAF id:3 country:"Italy" gold:5 + └─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" gold:1 + · └── LEAF id:4 country:"France" gold:1 + `); + + // Filter: gold > 5 — only Italy (sum=9) passes + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 5 }, + }); + + await new GridRows(api, 'gold > 5').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + · ├── LEAF id:2 country:"Italy" gold:4 + · └── LEAF id:3 country:"Italy" gold:5 + `); + + // Filter: gold >= 3 — Ireland (sum=3) and Italy (sum=9) pass + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThanOrEqual', filter: 3 }, + }); + + await new GridRows(api, 'gold >= 3').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + · ├── LEAF id:2 country:"Italy" gold:4 + · └── LEAF id:3 country:"Italy" gold:5 + `); + + // Clear filter + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + ├─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + │ ├── LEAF id:2 country:"Italy" gold:4 + │ └── LEAF id:3 country:"Italy" gold:5 + └─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" gold:1 + · └── LEAF id:4 country:"France" gold:1 + `); + }); + + test('groupAggFiltering callback controls which rows are tested by the filter', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + // Only apply aggregate filter to group rows (not leaves) + groupAggFiltering: (params) => params.node.group === true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Ireland', gold: 2 }, + { country: 'Italy', gold: 4 }, + { country: 'Italy', gold: 5 }, + { country: 'France', gold: 1 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + ├─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + │ ├── LEAF id:2 country:"Italy" gold:4 + │ └── LEAF id:3 country:"Italy" gold:5 + └─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" gold:1 + · └── LEAF id:4 country:"France" gold:1 + `); + + // Filter: gold > 5 — only group rows are tested, so Italy (sum=9) passes. + // Leaves are NOT tested by the aggregate filter, so they're preserved. + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 5 }, + }); + + await new GridRows(api, 'gold > 5, callback filters groups only').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + · ├── LEAF id:2 country:"Italy" gold:4 + · └── LEAF id:3 country:"Italy" gold:5 + `); + + // Change callback: apply filter to all rows (groups and leaves) + api.setGridOption('groupAggFiltering', () => true); + + await new GridRows(api, 'gold > 5, callback filters all rows').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + · ├── LEAF id:2 country:"Italy" gold:4 + · └── LEAF id:3 country:"Italy" gold:5 + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + ├─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:9 + │ ├── LEAF id:2 country:"Italy" gold:4 + │ └── LEAF id:3 country:"Italy" gold:5 + └─┬ LEAF_GROUP id:row-group-country-France ag-Grid-AutoColumn:"France" gold:1 + · └── LEAF id:4 country:"France" gold:1 + `); + }); + + test('multi-level grouping: parent kept because child passes, parent filtered when no children pass', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'year', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + groupDefaultExpanded: -1, + // Only apply filter to leaf groups — filler groups are kept/removed based on whether children pass + groupAggFiltering: (params) => params.node.leafGroup === true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', year: 2020, gold: 1 }, + { country: 'Ireland', year: 2021, gold: 10 }, + { country: 'Italy', year: 2020, gold: 2 }, + { country: 'Italy', year: 2021, gold: 3 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-year-2020 ag-Grid-AutoColumn:2020 gold:1 + │ │ └── LEAF id:0 country:"Ireland" year:2020 gold:1 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + │ · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:5 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-year-2020 ag-Grid-AutoColumn:2020 gold:2 + · │ └── LEAF id:2 country:"Italy" year:2020 gold:2 + · └─┬ LEAF_GROUP id:row-group-country-Italy-year-2021 ag-Grid-AutoColumn:2021 gold:3 + · · └── LEAF id:3 country:"Italy" year:2021 gold:3 + `); + + // Filter: gold > 5 — only leaf groups are tested. + // Ireland/2020 (1) fails, Ireland/2021 (10) passes → Ireland filler kept (has passing child). + // Italy/2020 (2) fails, Italy/2021 (3) fails → Italy filler removed (no passing children). + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 5 }, + }); + + await new GridRows(api, 'gold > 5').check(` + ROOT id:ROOT_NODE_ID + └─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + · └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + · · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + `); + + // Filter: gold >= 2 — Ireland (2021=10 passes), Italy (2020=2 passes, 2021=3 passes) + // Both filler groups kept because at least one leaf group passes. + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThanOrEqual', filter: 2 }, + }); + + await new GridRows(api, 'gold >= 2').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + │ · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:5 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-year-2020 ag-Grid-AutoColumn:2020 gold:2 + · │ └── LEAF id:2 country:"Italy" year:2020 gold:2 + · └─┬ LEAF_GROUP id:row-group-country-Italy-year-2021 ag-Grid-AutoColumn:2021 gold:3 + · · └── LEAF id:3 country:"Italy" year:2021 gold:3 + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-year-2020 ag-Grid-AutoColumn:2020 gold:1 + │ │ └── LEAF id:0 country:"Ireland" year:2020 gold:1 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + │ · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:5 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-year-2020 ag-Grid-AutoColumn:2020 gold:2 + · │ └── LEAF id:2 country:"Italy" year:2020 gold:2 + · └─┬ LEAF_GROUP id:row-group-country-Italy-year-2021 ag-Grid-AutoColumn:2021 gold:3 + · · └── LEAF id:3 country:"Italy" year:2021 gold:3 + `); + }); + + test('all children filtered out by aggregate filter removes the group', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Italy', gold: 2 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:1 + │ └── LEAF id:0 country:"Ireland" gold:1 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:2 + · └── LEAF id:1 country:"Italy" gold:2 + `); + + // Filter: gold > 100 — no group passes, all removed + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 100 }, + }); + + await new GridRows(api, 'gold > 100, all filtered out').check(` + ROOT id:ROOT_NODE_ID + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:1 + │ └── LEAF id:0 country:"Ireland" gold:1 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:2 + · └── LEAF id:1 country:"Italy" gold:2 + `); + }); + + test('groupAggFiltering=true with multi-level: passing parent preserves all descendants', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'year', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + groupDefaultExpanded: -1, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', year: 2020, gold: 1 }, + { country: 'Ireland', year: 2021, gold: 10 }, + { country: 'Italy', year: 2020, gold: 2 }, + { country: 'Italy', year: 2021, gold: 3 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-year-2020 ag-Grid-AutoColumn:2020 gold:1 + │ │ └── LEAF id:0 country:"Ireland" year:2020 gold:1 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + │ · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:5 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-year-2020 ag-Grid-AutoColumn:2020 gold:2 + · │ └── LEAF id:2 country:"Italy" year:2020 gold:2 + · └─┬ LEAF_GROUP id:row-group-country-Italy-year-2021 ag-Grid-AutoColumn:2021 gold:3 + · · └── LEAF id:3 country:"Italy" year:2021 gold:3 + `); + + // Filter: gold > 8 — Ireland filler (11) passes → all descendants recursively preserved. + // Italy filler (5) fails, and no child passes either → removed entirely. + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 8 }, + }); + + await new GridRows(api, 'gold > 8').check(` + ROOT id:ROOT_NODE_ID + └─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + · ├─┬ LEAF_GROUP id:row-group-country-Ireland-year-2020 ag-Grid-AutoColumn:2020 gold:1 + · │ └── LEAF id:0 country:"Ireland" year:2020 gold:1 + · └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + · · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ filler id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:11 + │ ├─┬ LEAF_GROUP id:row-group-country-Ireland-year-2020 ag-Grid-AutoColumn:2020 gold:1 + │ │ └── LEAF id:0 country:"Ireland" year:2020 gold:1 + │ └─┬ LEAF_GROUP id:row-group-country-Ireland-year-2021 ag-Grid-AutoColumn:2021 gold:10 + │ · └── LEAF id:1 country:"Ireland" year:2021 gold:10 + └─┬ filler id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:5 + · ├─┬ LEAF_GROUP id:row-group-country-Italy-year-2020 ag-Grid-AutoColumn:2020 gold:2 + · │ └── LEAF id:2 country:"Italy" year:2020 gold:2 + · └─┬ LEAF_GROUP id:row-group-country-Italy-year-2021 ag-Grid-AutoColumn:2021 gold:3 + · · └── LEAF id:3 country:"Italy" year:2021 gold:3 + `); + }); + + test('groupAggFiltering=true with grandTotalRow and suppressAggFilteredOnly', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + grandTotalRow: 'top', + alwaysAggregateAtRootLevel: true, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Ireland', gold: 2 }, + { country: 'Italy', gold: 10 }, + ]), + }); + + // Filter: gold > 5 — only Italy passes + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 5 }, + }); + + // Aggregation runs before filterAggregatesStage, so ROOT aggregates all children + await new GridRows(api, 'gold > 5, default aggregation').check(` + ROOT id:ROOT_NODE_ID gold:13 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " gold:13 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + // With suppressAggFilteredOnly, aggregation always uses ALL children — same result here + api.setGridOption('suppressAggFilteredOnly', true); + + await new GridRows(api, 'gold > 5, suppressAggFilteredOnly=true').check(` + ROOT id:ROOT_NODE_ID gold:13 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " gold:13 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + }); + + test('enabling groupAggFiltering at runtime triggers re-filter', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Ireland', gold: 2 }, + { country: 'Italy', gold: 10 }, + ]), + }); + + // Without groupAggFiltering, filter applies to leaf rows (default predicate: !node.group) + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 5 }, + }); + + await new GridRows(api, 'gold > 5, no groupAggFiltering').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + // Enable groupAggFiltering at runtime — now filter applies to groups by aggregate + api.setGridOption('groupAggFiltering', true); + + await new GridRows(api, 'gold > 5, groupAggFiltering=true').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + // Filter: gold >= 3 — Ireland (sum=3) now passes because filter tests the aggregate + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThanOrEqual', filter: 3 }, + }); + + await new GridRows(api, 'gold >= 3, groupAggFiltering=true').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + // Disable groupAggFiltering — revert to default leaf-only filtering + api.setGridOption('groupAggFiltering', false); + + // gold >= 3: now filters leaf rows, so only Italy/gold=10 passes + await new GridRows(api, 'gold >= 3, groupAggFiltering=false').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + }); +}); + +describe('ag-grid filterAggregatesStage pivot mode', () => { + const gridsManager = new TestGridsManager({ + modules: [NumberFilterModule, ClientSideRowModelModule, RowGroupingModule, PivotModule], + }); + + beforeEach(() => { + vitest.useRealTimers(); + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('pivot mode uses leafGroup predicate for aggregate filtering', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'athlete', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Athlete' }, + groupDefaultExpanded: -1, + pivotMode: true, + rowData: cachedJSONObjects.array([ + { athlete: 'Michael Phelps', gold: 8 }, + { athlete: 'Michael Phelps', gold: 6 }, + { athlete: 'Natalie Coughlin', gold: 1 }, + { athlete: 'Natalie Coughlin', gold: 2 }, + ]), + }); + + await new GridRows(api, 'initial pivot').check(` + ROOT id:ROOT_NODE_ID gold:17 + ├─┬ LEAF_GROUP collapsed id:"row-group-athlete-Michael Phelps" ag-Grid-AutoColumn:"Michael Phelps" gold:14 + │ ├── LEAF hidden id:0 athlete:"Michael Phelps" gold:8 + │ └── LEAF hidden id:1 athlete:"Michael Phelps" gold:6 + └─┬ LEAF_GROUP collapsed id:"row-group-athlete-Natalie Coughlin" ag-Grid-AutoColumn:"Natalie Coughlin" gold:3 + · ├── LEAF hidden id:2 athlete:"Natalie Coughlin" gold:1 + · └── LEAF hidden id:3 athlete:"Natalie Coughlin" gold:2 + `); + + // In pivot mode without groupAggFiltering, default predicate filters leafGroup nodes + api.setFilterModel({ + gold: { filterType: 'number', type: 'greaterThan', filter: 7 }, + }); + + await new GridRows(api, 'gold > 7 pivot mode').check(` + ROOT id:ROOT_NODE_ID gold:17 + └─┬ LEAF_GROUP collapsed id:"row-group-athlete-Michael Phelps" ag-Grid-AutoColumn:"Michael Phelps" gold:14 + · ├── LEAF hidden id:0 athlete:"Michael Phelps" gold:8 + · └── LEAF hidden id:1 athlete:"Michael Phelps" gold:6 + `); + + // Disable pivot mode — default predicate switches to !node.group (leaf rows only) + api.setGridOption('pivotMode', false); + + await new GridRows(api, 'gold > 7 non-pivot').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:"row-group-athlete-Michael Phelps" ag-Grid-AutoColumn:"Michael Phelps" gold:8 + · └── LEAF id:0 athlete:"Michael Phelps" gold:8 + `); + + // Re-enable pivot mode — filter applies to leaf groups again + api.setGridOption('pivotMode', true); + + await new GridRows(api, 'gold > 7 pivot mode re-enabled').check(` + ROOT id:ROOT_NODE_ID gold:17 + └─┬ LEAF_GROUP collapsed id:"row-group-athlete-Michael Phelps" ag-Grid-AutoColumn:"Michael Phelps" gold:14 + · ├── LEAF hidden id:0 athlete:"Michael Phelps" gold:8 + · └── LEAF hidden id:1 athlete:"Michael Phelps" gold:6 + `); + }); +}); + +describe('ag-grid filterAggregatesStage quick filter', () => { + const gridsManager = new TestGridsManager({ + modules: [NumberFilterModule, QuickFilterModule, ClientSideRowModelModule, RowGroupingModule], + }); + + beforeEach(() => { + vitest.useRealTimers(); + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('quick filter on aggregate values with groupAggFiltering', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'country', rowGroup: true, hide: true }, + { field: 'gold', aggFunc: 'sum' }, + ], + autoGroupColumnDef: { headerName: 'Country' }, + groupDefaultExpanded: -1, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { country: 'Ireland', gold: 1 }, + { country: 'Ireland', gold: 2 }, + { country: 'Italy', gold: 10 }, + ]), + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + // Quick filter "10" — matches Italy leaf (gold=10) via quick filter on leaf data + api.setGridOption('quickFilterText', '10'); + + await new GridRows(api, 'quick filter "10"').check(` + ROOT id:ROOT_NODE_ID + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + + api.setGridOption('quickFilterText', ''); + + await new GridRows(api, 'quick filter cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ LEAF_GROUP id:row-group-country-Ireland ag-Grid-AutoColumn:"Ireland" gold:3 + │ ├── LEAF id:0 country:"Ireland" gold:1 + │ └── LEAF id:1 country:"Ireland" gold:2 + └─┬ LEAF_GROUP id:row-group-country-Italy ag-Grid-AutoColumn:"Italy" gold:10 + · └── LEAF id:2 country:"Italy" gold:10 + `); + }); +}); + +describe('ag-grid filterAggregatesStage tree data', () => { + const gridsManager = new TestGridsManager({ + modules: [NumberFilterModule, ClientSideRowModelModule, RowGroupingModule, TreeDataModule], + }); + + beforeEach(() => { + vitest.useRealTimers(); + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('tree data allChildrenCount includes groups and leaves', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [ + { field: 'n' }, + { field: 'x', aggFunc: 'sum', filter: 'agNumberColumnFilter' }, + { field: 'y', filter: 'agNumberColumnFilter' }, + ], + autoGroupColumnDef: { headerName: 'Tree' }, + treeData: true, + groupDefaultExpanded: -1, + grandTotalRow: 'top', + alwaysAggregateAtRootLevel: true, + rowData: cachedJSONObjects.array([ + { id: 'A', n: 'A', y: 1 }, + { id: 'B', parentId: 'A', n: 'B', x: 10, y: 5 }, + { id: 'C', parentId: 'A', n: 'C', x: 20, y: 6 }, + { id: 'D', n: 'D', y: 2 }, + { id: 'E', parentId: 'D', n: 'E', x: 30, y: 3 }, + ]), + getRowId: (params) => params.data.id, + treeDataParentIdField: 'parentId', + groupSuppressBlankHeader: true, + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID x:60 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " x:60 + ├─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 y:1 + │ ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 y:5 + │ └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 y:6 + └─┬ D GROUP id:D ag-Grid-AutoColumn:"D" n:"D" x:30 y:2 + · └── E LEAF id:E ag-Grid-AutoColumn:"E" n:"E" x:30 y:3 + `); + + // Filter by y > 4 — only B (y=5) and C (y=6) pass + api.setFilterModel({ + y: { filterType: 'number', type: 'greaterThan', filter: 4 }, + }); + + await new GridRows(api, 'y > 4').check(` + ROOT id:ROOT_NODE_ID x:30 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " x:30 + └─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 y:1 + · ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 y:5 + · └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 y:6 + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID x:60 + ├─ footer id:rowGroupFooter_ROOT_NODE_ID ag-Grid-AutoColumn:"Total " x:60 + ├─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 y:1 + │ ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 y:5 + │ └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 y:6 + └─┬ D GROUP id:D ag-Grid-AutoColumn:"D" n:"D" x:30 y:2 + · └── E LEAF id:E ag-Grid-AutoColumn:"E" n:"E" x:30 y:3 + `); + }); + + test('tree data groupAggFiltering filters on aggregate values', async () => { + const api = gridsManager.createGrid('myGrid', { + columnDefs: [{ field: 'n' }, { field: 'x', aggFunc: 'sum', filter: 'agNumberColumnFilter' }], + autoGroupColumnDef: { headerName: 'Tree' }, + treeData: true, + groupDefaultExpanded: -1, + groupAggFiltering: true, + rowData: cachedJSONObjects.array([ + { id: 'A', n: 'A' }, + { id: 'B', parentId: 'A', n: 'B', x: 10 }, + { id: 'C', parentId: 'A', n: 'C', x: 20 }, + { id: 'D', n: 'D' }, + { id: 'E', parentId: 'D', n: 'E', x: 5 }, + ]), + getRowId: (params) => params.data.id, + treeDataParentIdField: 'parentId', + groupSuppressBlankHeader: true, + }); + + await new GridRows(api, 'initial').check(` + ROOT id:ROOT_NODE_ID + ├─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 + │ ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 + │ └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 + └─┬ D GROUP id:D ag-Grid-AutoColumn:"D" n:"D" x:5 + · └── E LEAF id:E ag-Grid-AutoColumn:"E" n:"E" x:5 + `); + + // Filter x > 8 — A (sum=30) passes, D (sum=5) fails. All of A's children preserved. + api.setFilterModel({ + x: { filterType: 'number', type: 'greaterThan', filter: 8 }, + }); + + await new GridRows(api, 'x > 8').check(` + ROOT id:ROOT_NODE_ID + └─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 + · ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 + · └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 + `); + + api.setFilterModel(null); + + await new GridRows(api, 'cleared').check(` + ROOT id:ROOT_NODE_ID + ├─┬ A GROUP id:A ag-Grid-AutoColumn:"A" n:"A" x:30 + │ ├── B LEAF id:B ag-Grid-AutoColumn:"B" n:"B" x:10 + │ └── C LEAF id:C ag-Grid-AutoColumn:"C" n:"C" x:20 + └─┬ D GROUP id:D ag-Grid-AutoColumn:"D" n:"D" x:5 + · └── E LEAF id:E ag-Grid-AutoColumn:"E" n:"E" x:5 + `); + }); +}); diff --git a/testing/behavioural/src/grouping-data/grouping-simple-data.test.ts b/testing/behavioural/src/grouping-data/grouping-simple-data.test.ts index 7b7aedb1ad3..e6180fa8e3d 100644 --- a/testing/behavioural/src/grouping-data/grouping-simple-data.test.ts +++ b/testing/behavioural/src/grouping-data/grouping-simple-data.test.ts @@ -138,12 +138,12 @@ describe('ag-grid grouping simple data', () => { rowIndex: 1, }, { - allChildrenCount: undefined, + allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: undefined, - childrenAfterGroup: undefined, - childrenAfterSort: undefined, + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -166,12 +166,12 @@ describe('ag-grid grouping simple data', () => { rowIndex: 2, }, { - allChildrenCount: undefined, + allChildrenCount: null, allLeafChildren: null, childIndex: 1, - childrenAfterFilter: undefined, - childrenAfterGroup: undefined, - childrenAfterSort: undefined, + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -222,12 +222,12 @@ describe('ag-grid grouping simple data', () => { rowIndex: 4, }, { - allChildrenCount: undefined, + allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: undefined, - childrenAfterGroup: undefined, - childrenAfterSort: undefined, + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -306,12 +306,12 @@ describe('ag-grid grouping simple data', () => { rowIndex: 7, }, { - allChildrenCount: undefined, + allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: undefined, - childrenAfterGroup: undefined, - childrenAfterSort: undefined, + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -362,12 +362,12 @@ describe('ag-grid grouping simple data', () => { rowIndex: 9, }, { - allChildrenCount: undefined, + allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: undefined, - childrenAfterGroup: undefined, - childrenAfterSort: undefined, + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, diff --git a/testing/behavioural/src/modules/benchmarks/modules.bench.ts b/testing/behavioural/src/modules/benchmarks/modules.bench.ts index 52153b78ba9..8adb3ce331b 100644 --- a/testing/behavioural/src/modules/benchmarks/modules.bench.ts +++ b/testing/behavioural/src/modules/benchmarks/modules.bench.ts @@ -13,10 +13,7 @@ suite('render cells with different module sets', () => { const element = document.createElement('div'); let params: Params | undefined; - const gridsManager = new TestGridsManager({ - includeDefaultModules: false, - mockGridLayout: false, - }); + const gridsManager = new TestGridsManager({ benchmark: true }); const makeBenchOptions = (modules: Module[] = []): BenchOptions => { return { diff --git a/testing/behavioural/src/services/benchmarks/changedPath.bench.ts b/testing/behavioural/src/services/benchmarks/changedPath.bench.ts new file mode 100644 index 00000000000..bff37c09466 --- /dev/null +++ b/testing/behavioural/src/services/benchmarks/changedPath.bench.ts @@ -0,0 +1,164 @@ +/** + * ChangedPath CSRM pipeline benchmark — mirrors real-life scenarios. + * + * Scenario A (changeDetectionService): single cell edit triggers addCell once, then full pipeline. + * Scenario B (clipboardService paste): paste 500 cells across different rows/columns, then pipeline. + * Scenario C (ChangedRowsPath): addRow per changed row (no column tracking), then pipeline. + * + * Run with: + * ./behave.sh "changedPath.bench" --bench + */ +import { bench, suite } from 'vitest'; + +import type { RowNode } from 'ag-grid-community'; +import { + ChangedCellsPath, + ChangedRowsPath, + _forEachChangedGroupDepthFirst, + _forEachChangedNodeDepthFirst, +} from 'ag-grid-community'; + +import { SimplePRNG } from '../../test-utils'; + +// ── Stubs ───────────────────────────────────────────────────────────────────── + +let nodeCounter = 0; +function makeNode(id: string, parent: RowNode | null = null): RowNode { + return { + id: `${id}_${nodeCounter++}`, + parent, + level: parent ? parent.level + 1 : -1, + childrenAfterGroup: null, + destroyed: false, + } as unknown as RowNode; +} + +// ── Tree builder ────────────────────────────────────────────────────────────── + +function buildTree(groupsPerLevel: number, levels: number, leavesPerLeafGroup: number) { + const root = makeNode('root', null); + (root as any).childrenAfterGroup = []; + const allNodes: RowNode[] = [root]; + const leaves: RowNode[] = []; + + function build(parent: RowNode, level: number, prefix: string): void { + for (let g = 0; g < groupsPerLevel; g++) { + const id = `${prefix}-${g}`; + if (level < levels) { + const node = makeNode(id, parent); + (node as any).childrenAfterGroup = []; + allNodes.push(node); + (parent.childrenAfterGroup as RowNode[]).push(node); + build(node, level + 1, id); + } else { + for (let l = 0; l < leavesPerLeafGroup; l++) { + const leaf = makeNode(`${id}-l${l}`, parent); + allNodes.push(leaf); + leaves.push(leaf); + (parent.childrenAfterGroup as RowNode[]).push(leaf); + } + } + } + } + + build(root, 1, 'n'); + return { root, allNodes, leaves }; +} + +// ── Pipeline helpers ────────────────────────────────────────────────────────── + +function runPipeline(path: ChangedRowsPath | ChangedCellsPath, root: RowNode, allNodes: RowNode[]): void { + // Filter stage + _forEachChangedNodeDepthFirst(root, path, () => {}); + // Sort stage: membership checks on all rows + let n = 0; + for (let i = 0; i < allNodes.length; i++) { + if (path.hasRow(allNodes[i])) { + n++; + } + } + // Aggregation stage + _forEachChangedGroupDepthFirst(root, path, () => { + n++; + }); + // Pivot stage + _forEachChangedGroupDepthFirst(root, path, () => {}); + void n; +} + +function runPipelineWithColumnChecks( + path: ChangedCellsPath, + root: RowNode, + allNodes: RowNode[], + colIds: string[] +): void { + // Filter stage + _forEachChangedNodeDepthFirst(root, path, () => {}); + // Sort stage: membership checks on all rows + let n = 0; + for (let i = 0; i < allNodes.length; i++) { + if (path.hasRow(allNodes[i])) { + n++; + } + } + // Aggregation stage: check per-column changes (real aggregation skips unchanged columns) + const colSlots = colIds.map((id) => path.getSlot(id)); + _forEachChangedGroupDepthFirst(root, path, (rowNode) => { + const rowSlot = path.getSlot(rowNode); + for (let c = 0; c < colSlots.length; c++) { + if (path.hasCellBySlot(rowSlot, colSlots[c])) { + n++; + } + } + }); + // Pivot stage + _forEachChangedGroupDepthFirst(root, path, () => {}); + void n; +} + +// ── Fixture: 500 changed, 10 levels ────────────────────────────────────────── + +const tree = buildTree(2, 10, 4); +const prng = new SimplePRNG(0xc4a3b1d9); +const shuffled = tree.leaves.slice(); +prng.shuffle(shuffled); +const changed = shuffled.slice(0, Math.min(500, shuffled.length)); + +const colIds3 = ['value', 'count', 'sum']; +const colIds25 = Array.from({ length: 25 }, (_, i) => `col${i}`); +const allNodes = tree.allNodes; +const tag = `500/${allNodes.length} nodes, 10 levels`; + +// ── Benchmarks ─────────────────────────────────────────────────────────────── + +suite(`CSRM pipeline — ${tag}`, () => { + bench('ChangedRowsPath: addRow (no column tracking)', () => { + const path = new ChangedRowsPath(); + for (let i = 0; i < changed.length; i++) { + path.addRow(changed[i]); + } + runPipeline(path, tree.root, allNodes); + }); + + bench('ChangedCellsPath: paste 500 cells, 3 columns', () => { + const path = new ChangedCellsPath(); + for (let i = 0; i < changed.length; i++) { + path.addCell(changed[i], colIds3[i % colIds3.length]); + } + runPipelineWithColumnChecks(path, tree.root, allNodes, colIds3); + }); + + bench('ChangedCellsPath: paste 500 cells, 25 columns', () => { + const path = new ChangedCellsPath(); + for (let i = 0; i < changed.length; i++) { + path.addCell(changed[i], colIds25[i % colIds25.length]); + } + runPipelineWithColumnChecks(path, tree.root, allNodes, colIds25); + }); + + bench('ChangedCellsPath: single cell edit', () => { + const path = new ChangedCellsPath(); + path.addCell(changed[0], colIds3[0]); + runPipelineWithColumnChecks(path, tree.root, allNodes, colIds3); + }); +}); diff --git a/testing/behavioural/src/sorting/benchmarks/flat-sorting.bench.ts b/testing/behavioural/src/sorting/benchmarks/flat-sorting.bench.ts index f1e376e483b..07a8f0c304f 100644 --- a/testing/behavioural/src/sorting/benchmarks/flat-sorting.bench.ts +++ b/testing/behavioural/src/sorting/benchmarks/flat-sorting.bench.ts @@ -13,8 +13,7 @@ interface IData { suite('flat grid sorting', () => { const gridsManager = new TestGridsManager({ - includeDefaultModules: false, - mockGridLayout: false, + benchmark: true, modules: [ClientSideRowModelModule, ColumnApiModule], }); @@ -36,9 +35,6 @@ suite('flat grid sorting', () => { deltaSort: true, rowData: baseRowData, getRowId: ({ data }) => data.id, - ensureDomOrder: false, - suppressRowVirtualisation: false, - suppressColumnVirtualisation: false, }); apiNoDelta ??= gridsManager.createGrid('G-no-delta', { @@ -46,9 +42,6 @@ suite('flat grid sorting', () => { deltaSort: false, rowData: baseRowData, getRowId: ({ data }) => data.id, - ensureDomOrder: false, - suppressRowVirtualisation: false, - suppressColumnVirtualisation: false, }); }, teardown: () => { diff --git a/testing/behavioural/src/sorting/delta-sorting.test.ts b/testing/behavioural/src/sorting/delta-sorting.test.ts index ceeee502ffa..9c6db126ce1 100644 --- a/testing/behavioural/src/sorting/delta-sorting.test.ts +++ b/testing/behavioural/src/sorting/delta-sorting.test.ts @@ -1,4 +1,5 @@ import { ClientSideRowModelModule } from 'ag-grid-community'; +import { PivotModule, RowGroupingModule } from 'ag-grid-enterprise'; import { GridRows, TestGridsManager, applyTransactionChecked } from '../test-utils'; @@ -668,3 +669,44 @@ describe('Delta Sorting', () => { `); }); }); + +describe('Delta Sorting — pivot-triggered changedPath deactivation', () => { + const gridMgr = new TestGridsManager({ + modules: [ClientSideRowModelModule, RowGroupingModule, PivotModule], + }); + + test('falls back to full sort when pivotStage nullifies the changedPath', () => { + // When a transaction introduces a new unique pivot column value, pivotStage returns true + // because the set of generated pivot columns changed (uniqueValuesChanged=true in + // executePivotOn). CSRM then sets changedPath to undefined, so sortStage receives + // undefined and doDeltaSort takes the direct full-sort path. + // + // Initial data has year 2020 only. Adding a row with year 2021 triggers the nullification. + // After the transaction, groups must still be in correct sorted order (A < B < C). + const api = gridMgr.createGrid('deltaSort-pivot-deactivate', { + columnDefs: [ + { field: 'region', rowGroup: true, hide: true }, + { field: 'year', pivot: true, hide: true }, + { field: 'sales', aggFunc: 'sum', hide: true }, + ], + autoGroupColumnDef: { sort: 'asc' }, + pivotMode: true, + deltaSort: true, + rowData: [ + { id: '1', region: 'B', year: 2020, sales: 100 }, + { id: '2', region: 'A', year: 2020, sales: 200 }, + ], + getRowId: ({ data }) => data.id, + }); + + // Adding year 2021 → uniqueValuesChanged → pivotStage returns true → + // CSRM sets changedPath=undefined → doDeltaSort receives undefined → full sort. + applyTransactionChecked(api, { add: [{ id: '3', region: 'C', year: 2021, sales: 50 }] }); + + // Groups must be sorted A < B < C despite the nullified changedPath. + expect(api.getDisplayedRowCount()).toBe(3); + expect(api.getDisplayedRowAtIndex(0)?.key).toBe('A'); + expect(api.getDisplayedRowAtIndex(1)?.key).toBe('B'); + expect(api.getDisplayedRowAtIndex(2)?.key).toBe('C'); + }); +}); diff --git a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts b/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts index e88c56bb4ad..61e95617f07 100644 --- a/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts +++ b/testing/behavioural/src/test-utils/gridRows/rows-validation/gridRowsValidator.ts @@ -46,6 +46,7 @@ export class GridRowsValidator { this.validatePinnedRows(state); this.validateSelectedRows(state); this.validateDisplayedRowCounts(state); + this.validateNoAggDataWithoutGrouping(state); return this; } @@ -183,6 +184,9 @@ export class GridRowsValidator { // Group and detail are mutually exclusive rowErrors.add(!!row.group && !!row.detail && 'Row is both group and detail'); + // Non-group rows should not have aggData — it must be cleared during group demotion + rowErrors.add(!row.group && row.aggData != null && 'Non-group row should not have aggData'); + // Master/detail bidirectional consistency const detailNode = row.detailNode; if (row.master && detailNode) { @@ -247,7 +251,7 @@ export class GridRowsValidator { ); } verifyLeafs(this.errors, this.#allLeafsMap, gridRows, row); - if (row.allChildrenCount !== undefined) { + if (row.allChildrenCount != null) { validateAllChildrenCount(state, rowErrors, row); } } @@ -348,14 +352,15 @@ export class GridRowsValidator { children = []; } - if (!children) { - if (gridRows.treeData) { - if (!gridRows.isDuplicateIdRow(parentRow) && name !== 'allLeafChildren') { - if (!parentRow.detail) { - this.errors.add(parentRow, `${name} is missing`); - } - } - } else if (parentRow.group && (name === 'childrenAfterGroup' || name === 'allLeafChildren')) { + if (children) { + if (name === 'childrenAfterGroup' && children.length === 0) { + this.errors.add(parentRow, `${name} is an empty array`); + } + } else if (parentRow.group && !parentRow.detail && !gridRows.isDuplicateIdRow(parentRow)) { + const required = gridRows.treeData + ? name !== 'allLeafChildren' + : name === 'childrenAfterGroup' || name === 'allLeafChildren'; + if (required) { this.errors.add(parentRow, `${name} is missing`); } } @@ -524,4 +529,37 @@ export class GridRowsValidator { 'Leaf data row displayed in pivot mode with active grouping/pivoting' ); } + + /** When grouping/treeData/pivot are not active, no node should have aggData. */ + private validateNoAggDataWithoutGrouping(state: GridRowsValidationState): void { + if (!state.csrm || state.pivotMode) { + return; + } + const { api, treeData } = state.gridRows; + if (treeData) { + return; + } + // Check if any grouping is active + if (api.isModuleRegistered('RowGroupingModule') && api.getRowGroupColumns().length > 0) { + return; + } + if ( + api.getGridOption('getGroupRowAgg') || + api.getGridOption('alwaysAggregateAtRootLevel') || + api.getGridOption('grandTotalRow') + ) { + return; + } + // No grouping/treeData/pivot — no node should have aggData + for (const row of state.gridRows.rowNodes) { + this.errors.add(row, row.aggData != null && 'Row has aggData but grouping/treeData/pivot are not active'); + } + const root = state.gridRows.rootRowNode; + if (root) { + this.errors.add( + root, + root.aggData != null && 'Root node has aggData but grouping/treeData/pivot are not active' + ); + } + } } diff --git a/testing/behavioural/src/test-utils/rows-snapshot.ts b/testing/behavioural/src/test-utils/rows-snapshot.ts index afe11820325..c313357f8bc 100644 --- a/testing/behavioural/src/test-utils/rows-snapshot.ts +++ b/testing/behavioural/src/test-utils/rows-snapshot.ts @@ -38,7 +38,7 @@ export function getRowSnapshot(row: IRowNode) { } = row; return { - allChildrenCount: allChildrenCount as typeof allChildrenCount | undefined, + allChildrenCount, allLeafChildren: mapArray(allLeafChildren, getRowKey), childIndex, childrenAfterFilter: mapArray(childrenAfterFilter, getRowKey), diff --git a/testing/behavioural/src/test-utils/testGridsManager.ts b/testing/behavioural/src/test-utils/testGridsManager.ts index 5c5da82598f..ee147704c29 100644 --- a/testing/behavioural/src/test-utils/testGridsManager.ts +++ b/testing/behavioural/src/test-utils/testGridsManager.ts @@ -23,6 +23,9 @@ export interface TestGridManagerOptions { includeDefaultModules?: boolean; mockGridLayout?: boolean; + + /** When true, uses production-like grid defaults (virtualization on, ensureDomOrder off). Implies mockGridLayout: false. */ + benchmark?: boolean; } const gridApiHtmlElementsMap = new WeakMap(); @@ -47,17 +50,28 @@ export class TestGridsManager { ensureDomOrder: true, }; + /** Production-like defaults for benchmarks: virtualization enabled, DOM order not maintained. */ + public static benchmarkGridOptions: GridOptions = { + animateRows: false, + suppressRowVirtualisation: false, + suppressColumnVirtualisation: false, + ensureDomOrder: false, + debug: false, + }; + private gridsMap = new Map(); private includeDefaultModules: boolean = true; private modulesToRegister: Module[] | null | undefined; + private benchmark: boolean = false; public constructor(options: TestGridManagerOptions = {}) { this.modulesToRegister = options.modules; + this.benchmark = options.benchmark === true; - if (options.mockGridLayout !== false) { + if (this.benchmark ? options.mockGridLayout === true : options.mockGridLayout !== false) { mockGridLayout.init(); } - if (options.includeDefaultModules === false) { + if (this.benchmark || options.includeDefaultModules === false) { this.includeDefaultModules = false; } } @@ -129,11 +143,10 @@ export class TestGridsManager { ValidationModule ); } - const api = createGrid( - element, - { ...TestGridsManager.defaultGridOptions, ...gridOptions }, - { ...params, modules } - ); + const baseOptions = this.benchmark + ? { ...TestGridsManager.defaultGridOptions, ...TestGridsManager.benchmarkGridOptions } + : TestGridsManager.defaultGridOptions; + const api = createGrid(element, { ...baseOptions, ...gridOptions }, { ...params, modules }); this.gridsMap.set(element, api); gridApiHtmlElementsMap.set(api, element); diff --git a/testing/behavioural/src/tree-data/datapath/benchmarks/tree-data-path.bench.ts b/testing/behavioural/src/tree-data/datapath/benchmarks/tree-data-path.bench.ts index 7e66e73a677..4af39daaf64 100644 --- a/testing/behavioural/src/tree-data/datapath/benchmarks/tree-data-path.bench.ts +++ b/testing/behavioural/src/tree-data/datapath/benchmarks/tree-data-path.bench.ts @@ -9,8 +9,7 @@ import { SimplePRNG, TestGridsManager } from '../../../test-utils'; suite('treeData with getDataPath', () => { const gridsManager = new TestGridsManager({ - includeDefaultModules: false, - mockGridLayout: false, + benchmark: true, modules: [ClientSideRowModelModule, ClientSideRowModelApiModule, TreeDataModule], }); @@ -30,9 +29,6 @@ suite('treeData with getDataPath', () => { groupDefaultExpanded: -1, getDataPath: (data: { path: string[] }) => data.path, getRowId: ({ data }: { data: { id: string } }) => data.id, - ensureDomOrder: false, - suppressRowVirtualisation: false, - suppressColumnVirtualisation: false, }); }, teardown: () => { diff --git a/testing/behavioural/src/tree-data/datapath/grouping-tree-data-reactive.test.ts b/testing/behavioural/src/tree-data/datapath/grouping-tree-data-reactive.test.ts index 7b60052c32d..d3c65cda920 100644 --- a/testing/behavioural/src/tree-data/datapath/grouping-tree-data-reactive.test.ts +++ b/testing/behavioural/src/tree-data/datapath/grouping-tree-data-reactive.test.ts @@ -157,9 +157,9 @@ describe('ag-grid grouping treeData is reactive', () => { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -213,9 +213,9 @@ describe('ag-grid grouping treeData is reactive', () => { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, diff --git a/testing/behavioural/src/tree-data/datapath/simpleHierarchyRowsSnapshot.ts b/testing/behavioural/src/tree-data/datapath/simpleHierarchyRowsSnapshot.ts index 09dea075b85..60d2e5cee32 100644 --- a/testing/behavioural/src/tree-data/datapath/simpleHierarchyRowsSnapshot.ts +++ b/testing/behavioural/src/tree-data/datapath/simpleHierarchyRowsSnapshot.ts @@ -34,9 +34,9 @@ export function simpleHierarchyRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -90,9 +90,9 @@ export function simpleHierarchyRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -202,9 +202,9 @@ export function simpleHierarchyRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, diff --git a/testing/behavioural/src/tree-data/datapath/tree-data.test.ts b/testing/behavioural/src/tree-data/datapath/tree-data.test.ts index 4cd4d13c08d..6b2b02f5bf1 100644 --- a/testing/behavioural/src/tree-data/datapath/tree-data.test.ts +++ b/testing/behavioural/src/tree-data/datapath/tree-data.test.ts @@ -454,9 +454,9 @@ function hierarchyWithInvertedOrderRowSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -538,9 +538,9 @@ function hierarchyWithInvertedOrderRowSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, diff --git a/testing/behavioural/src/tree-data/parentid/benchmarks/tree-data-parent-id.bench.ts b/testing/behavioural/src/tree-data/parentid/benchmarks/tree-data-parent-id.bench.ts index 2aea358118a..363faaf39c0 100644 --- a/testing/behavioural/src/tree-data/parentid/benchmarks/tree-data-parent-id.bench.ts +++ b/testing/behavioural/src/tree-data/parentid/benchmarks/tree-data-parent-id.bench.ts @@ -9,8 +9,7 @@ import { SimplePRNG, TestGridsManager } from '../../../test-utils'; suite('treeData with parentId', () => { const gridsManager = new TestGridsManager({ - includeDefaultModules: false, - mockGridLayout: false, + benchmark: true, modules: [ValidationModule, ClientSideRowModelModule, ClientSideRowModelApiModule, TreeDataModule], }); @@ -30,9 +29,6 @@ suite('treeData with parentId', () => { groupDefaultExpanded: -1, treeDataParentIdField: 'parentId', getRowId: ({ data }: { data: TreeDataParentIdRow }) => data.id, - ensureDomOrder: false, - suppressRowVirtualisation: false, - suppressColumnVirtualisation: false, }); }, teardown: () => { diff --git a/testing/behavioural/src/tree-data/parentid/simpleParentIdRowsSnapshot.ts b/testing/behavioural/src/tree-data/parentid/simpleParentIdRowsSnapshot.ts index 4a00c36f17f..b71ad45a4f8 100644 --- a/testing/behavioural/src/tree-data/parentid/simpleParentIdRowsSnapshot.ts +++ b/testing/behavioural/src/tree-data/parentid/simpleParentIdRowsSnapshot.ts @@ -34,9 +34,9 @@ export function simpleParentIdRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -90,9 +90,9 @@ export function simpleParentIdRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, @@ -202,9 +202,9 @@ export function simpleParentIdRowsSnapshot(): RowSnapshot[] { allChildrenCount: null, allLeafChildren: null, childIndex: 0, - childrenAfterFilter: [], - childrenAfterGroup: [], - childrenAfterSort: [], + childrenAfterFilter: null, + childrenAfterGroup: null, + childrenAfterSort: null, detail: undefined, displayed: true, expanded: false, diff --git a/testing/behavioural/src/version.ts b/testing/behavioural/src/version.ts index 52fd5df6f2e..212ad79624e 100644 --- a/testing/behavioural/src/version.ts +++ b/testing/behavioural/src/version.ts @@ -1,2 +1,2 @@ // DO NOT UPDATE MANUALLY: Generated from script during build time -export const VERSION = '35.1.0-beta.20260309.1858'; +export const VERSION = '35.1.0-beta.20260312.1528'; diff --git a/testing/csp/package.json b/testing/csp/package.json index 55e9405a2fd..731f2305efa 100644 --- a/testing/csp/package.json +++ b/testing/csp/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-csp", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "CSP testing for AG Grid", "main": "index.js", "scripts": {}, diff --git a/testing/module-size-angular/package.json b/testing/module-size-angular/package.json index c184515a622..7cea3675561 100644 --- a/testing/module-size-angular/package.json +++ b/testing/module-size-angular/package.json @@ -1,6 +1,6 @@ { "name": "ag-grid-module-size-angular", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "scripts": { "ng": "ng", "start": "ng serve", @@ -20,11 +20,11 @@ "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@angular/router": "^19.0.0", - "ag-grid-angular": "35.1.0-beta.20260309.1858", - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309", + "ag-grid-angular": "35.1.0-beta.20260312.1528", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/testing/module-size/package.json b/testing/module-size/package.json index 50adb12edcd..2cb4b819b7f 100644 --- a/testing/module-size/package.json +++ b/testing/module-size/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-module-size", "private": true, - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "scripts": { "dev": "vite", "cp-app": "cp ./src/App_Src.tsx ./src/App_AUTO.tsx", @@ -14,11 +14,11 @@ "test:e2e": "run-s \"module-combinations -- {1}\" module-validate --" }, "dependencies": { - "ag-grid-react": "35.1.0-beta.20260309.1858", - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-charts-community": "13.1.0-beta.20260309", - "ag-charts-enterprise": "13.1.0-beta.20260309", + "ag-grid-react": "35.1.0-beta.20260312.1528", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-charts-community": "13.1.0-beta.20260312", + "ag-charts-enterprise": "13.1.0-beta.20260312", "ag-shared": "0.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/testing/public-recipes/e2e/package.json b/testing/public-recipes/e2e/package.json index c86dc0ff2dd..ca139ed955a 100644 --- a/testing/public-recipes/e2e/package.json +++ b/testing/public-recipes/e2e/package.json @@ -1,12 +1,12 @@ { "name": "ag-grid-public-e2e-testing-recipes", - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "description": "Public E2E testing recipes for AG Grid", "main": "index.js", "scripts": {}, "license": "MIT", "devDependencies": { - "ag-grid-community": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", "playwright": "^1.56.0", "@playwright/test": "^1.56.0", "@types/node": "^24.0.3" diff --git a/testing/vue3-tests/package.json b/testing/vue3-tests/package.json index 6dcfe585bc5..a9c376b4367 100644 --- a/testing/vue3-tests/package.json +++ b/testing/vue3-tests/package.json @@ -1,7 +1,7 @@ { "name": "ag-grid-vue3-tests", "private": true, - "version": "35.1.0-beta.20260309.1858", + "version": "35.1.0-beta.20260312.1528", "type": "module", "scripts": { "dev": "vite", @@ -15,9 +15,9 @@ "dependencies": { "vue": "^3.5.0", "vue-router": "^4.5.0", - "ag-grid-community": "35.1.0-beta.20260309.1858", - "ag-grid-enterprise": "35.1.0-beta.20260309.1858", - "ag-grid-vue3": "35.1.0-beta.20260309.1858", + "ag-grid-community": "35.1.0-beta.20260312.1528", + "ag-grid-enterprise": "35.1.0-beta.20260312.1528", + "ag-grid-vue3": "35.1.0-beta.20260312.1528", "decimal.js": "^10.4.3" }, "devDependencies": { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000000..729f313d8e9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + watch: false, + }, +}); diff --git a/yarn.lock b/yarn.lock index 3906b0d29f2..a58cc8f511f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9275,61 +9275,61 @@ adm-zip@^0.5.10: resolved "http://52.50.158.57:4873/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909" integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== -ag-charts-angular@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-angular/-/ag-charts-angular-13.1.0-beta.20260309.tgz#3bdcf47e9d71f831c48f03d933aaec2ce065c310" - integrity sha512-ONDsYiw3RgLDbvo5X3+fCDU9He8T77ZAzfLU3Ax4KP2qIFlZVLOqmYTSnx5lasROZ1h4Li3I7m7eiXn5yivMqA== +ag-charts-angular@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-angular/-/ag-charts-angular-13.1.0-beta.20260312.tgz#012b1302b6c82dc9cea769bfc4c9bcdec2fad258" + integrity sha512-knp4aLbXcV2S19CI1VkAY10EHZlwoSwtcwSiDMUUuY9C05Dot9PPkcv7UNuuK9t2HjxYw4iU5Z6a/YWWILpAmA== dependencies: - ag-charts-community "13.1.0-beta.20260309" + ag-charts-community "13.1.0-beta.20260312" tslib "^2.3.0" -ag-charts-community@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-community/-/ag-charts-community-13.1.0-beta.20260309.tgz#a72db86f65d6b46cc4b2f99ee90067b7878821e1" - integrity sha512-nxGhKPX3mtRGc9hLOlg91IRQCpnRnTiemrpHvmn5cWazg/FfbiMmESVcmz0PziSwTuuvhPW0C0M7HbVX2O3bXw== +ag-charts-community@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-community/-/ag-charts-community-13.1.0-beta.20260312.tgz#d3417fee68bbe0393931013ae356a009575e2a9c" + integrity sha512-zFhe58K8Fae2DAPMUqvzDjiCHN7dfLeibVzswwMNhqFVnot4NHuWs5XshVHJH+h7fq091cuHilHgXw5X4eqe4g== dependencies: - ag-charts-core "13.1.0-beta.20260309" - ag-charts-locale "13.1.0-beta.20260309" - ag-charts-types "13.1.0-beta.20260309" + ag-charts-core "13.1.0-beta.20260312" + ag-charts-locale "13.1.0-beta.20260312" + ag-charts-types "13.1.0-beta.20260312" -ag-charts-core@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-core/-/ag-charts-core-13.1.0-beta.20260309.tgz#22ec40c9daafca68a08e154914f033f320352fc3" - integrity sha512-VqJzgcDx2+9jigAxXQA9vNbw5Ykk+f8pbPoa2FxbsVtZ1Q9Cv7W+X092m65ipgeUqjbIyf6i2nS9hK5JCJf0CA== +ag-charts-core@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-core/-/ag-charts-core-13.1.0-beta.20260312.tgz#e0678a7038598d32024dfcb59d82f39c9b545b88" + integrity sha512-VQb2rA9EEgK4mlT9iB2oss/lZEQYLgaMRrLHj+ntzbP6fD03dGtdfVi+lziIZJTxssEJ7V7hU6wZoSEw7qoEiw== dependencies: - ag-charts-types "13.1.0-beta.20260309" + ag-charts-types "13.1.0-beta.20260312" -ag-charts-enterprise@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-enterprise/-/ag-charts-enterprise-13.1.0-beta.20260309.tgz#bc6eb48a164d781f93242b6449d237a788aba92c" - integrity sha512-+szhu2Yd/mSNx6gn0lpYepyvkDI9+vD2MqahcXT6tqba8CZnXawlzlg27SQmQ0nQpfCIj0iqRhtyzSupyTHACw== +ag-charts-enterprise@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-enterprise/-/ag-charts-enterprise-13.1.0-beta.20260312.tgz#41ff75ecda8bad86d674ab620b7f42e2b392ba5c" + integrity sha512-LIgmNG6A/RdvSubaoY1x+HoeQGaNFXeWgols9kBJmYU2dF9LYQF1rvIiIwTgVlF8SYtIQhCqjF/sNDqu4Sx1xQ== dependencies: - ag-charts-community "13.1.0-beta.20260309" - ag-charts-core "13.1.0-beta.20260309" + ag-charts-community "13.1.0-beta.20260312" + ag-charts-core "13.1.0-beta.20260312" -ag-charts-locale@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-locale/-/ag-charts-locale-13.1.0-beta.20260309.tgz#58919733cd17a18ffa7c9bb3a1396e5370a7aaff" - integrity sha512-XWnrYC+2Bp67wvkapoeMXxUEPclsIp+4se9aTl1UituNYdaekLE9ZyDjaJM7KC7eJRO3mL8GfXQZTO3mYCalKQ== +ag-charts-locale@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-locale/-/ag-charts-locale-13.1.0-beta.20260312.tgz#b55bb328bb1455e3c2407da33c15a57134f6240c" + integrity sha512-TFZCxDNuvsNen/wW0Rqh/d0HocsB/6IWZuDLrAd5lCv52MKyWcsxabWTHPexJo1ZELNGx64ufM1dfFwPMI4Hgw== -ag-charts-react@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-react/-/ag-charts-react-13.1.0-beta.20260309.tgz#1f1bfdd975b55ba29bef501e2adfc8971325b5e5" - integrity sha512-AOVcHysL2cKGnBV6uP6dzf9P9ZEVfMvGJps2zX+jpx9HZwGK6VyaSORUg/w+b43lAOET/PhU9Sp5+L7jySYlLQ== +ag-charts-react@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-react/-/ag-charts-react-13.1.0-beta.20260312.tgz#9717f16f3432fb1d8cdddc12d1d849a32533c37e" + integrity sha512-Sqkuwoj1HunhKF0y2Lalna87g49f1x/v8eOyhAvnafT9LqEmeRVeU/QP93at1Ou9l0xXHN5w4aV9vQ7/NWkOIQ== dependencies: - ag-charts-community "13.1.0-beta.20260309" + ag-charts-community "13.1.0-beta.20260312" -ag-charts-types@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-types/-/ag-charts-types-13.1.0-beta.20260309.tgz#ae8051d72df2fe4ef1fd280cff556255f30b6d79" - integrity sha512-w1GXjVGzi/MzOHFmAL3OhO+RgPw6TTvnk+MrHDjIYc+23+iPq+UBVHSUiPmJnxTB/+EGYUfWRScfJXQN2a9jew== +ag-charts-types@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-types/-/ag-charts-types-13.1.0-beta.20260312.tgz#52b6f614f24a58843f5ea983d6e824d25d1e48f2" + integrity sha512-RFly6bGE9Q9IoSktNcGNmEwMcj8XAtw+RZRRSiVch2bUopf3obSYgoYyya9C4ziFQS3LLhga9qfc7fySK6GVHQ== -ag-charts-vue3@13.1.0-beta.20260309: - version "13.1.0-beta.20260309" - resolved "https://registry.ag-grid.com/ag-charts-vue3/-/ag-charts-vue3-13.1.0-beta.20260309.tgz#e1417ef087149adf9d5a3de99f3ff5c75a368088" - integrity sha512-amqQ4POBgRqZzgEka6L13PODsR01kXsapxn2ABwZxAd26pYm8jGGVFHjKyG4Bo3/BJDMnJv7i+KXU0ZWY3OzAQ== +ag-charts-vue3@13.1.0-beta.20260312: + version "13.1.0-beta.20260312" + resolved "https://registry.ag-grid.com/ag-charts-vue3/-/ag-charts-vue3-13.1.0-beta.20260312.tgz#df47cb6ac6c05cc3191515bd0f4ee156fba76fa3" + integrity sha512-YgZmugdqlnUcLXjy2PUGMwiAYBj9A2BjZVscb4zwY6zpoEd+TXNi8xquyHxsbtnB5W6cWV/ga+NUNv+N6ZbQbg== dependencies: - ag-charts-community "13.1.0-beta.20260309" + ag-charts-community "13.1.0-beta.20260312" agent-base@6, agent-base@^6.0.2: version "6.0.2" @@ -23118,16 +23118,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "http://52.50.158.57:4873/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "http://52.50.158.57:4873/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23232,7 +23223,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "http://52.50.158.57:4873/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -23246,13 +23237,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "http://52.50.158.57:4873/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.2" resolved "http://52.50.158.57:4873/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -25722,7 +25706,7 @@ wordwrap@^1.0.0: resolved "http://52.50.158.57:4873/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "http://52.50.158.57:4873/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -25758,15 +25742,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "http://52.50.158.57:4873/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^9.0.0: version "9.0.2" resolved "http://52.50.158.57:4873/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"