From 41c35c57acde3e91a4ef948159ea8bd2ab14fda5 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 12 Mar 2026 12:35:01 +0400 Subject: [PATCH 1/5] build column lookup map on each _processItems to avoid higher complexity --- .../grids/data_grid/summary/m_summary.ts | 98 ++++++++++++++++--- 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts index 8dbc6081f7e6..26c9f03a93a3 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts @@ -18,6 +18,7 @@ import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_da import type { EditingControllerRequired, ModuleType } from '@ts/grids/grid_core/m_types'; import { ColumnsView } from '@ts/grids/grid_core/views/m_columns_view'; +import type { Column } from '../../grid_core/columns_controller/types'; import type { EditingController } from '../../grid_core/editing/m_editing'; import type { RowsView } from '../../grid_core/views/m_rows_view'; import AggregateCalculator from '../m_aggregate_calculator'; @@ -471,9 +472,9 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx private _processGroupItem(groupItem, options) { const that = this; - if (!options.summaryGroupItems) { - options.summaryGroupItems = that.option('summary.groupItems') || []; - } + options.summaryGroupItems ??= that.option('summary.groupItems') || []; + options.summaryColumnMap ??= this._buildColumnLookupMap(); + if (groupItem.rowType === 'group') { let groupColumnIndex = -1; let afterGroupColumnIndex = -1; @@ -490,32 +491,97 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx } }); - groupItem.summaryCells = this._calculateSummaryCells(options.summaryGroupItems, getGroupAggregates(groupItem.data), options.visibleColumns, (summaryItem, column) => { - if (summaryItem.showInGroupFooter) { - return -1; - } + groupItem.summaryCells = this._calculateSummaryCells( + options.summaryGroupItems, + getGroupAggregates(groupItem.data), + options.visibleColumns, + (summaryItem, column) => { + if (summaryItem.showInGroupFooter) { + return -1; + } - if (summaryItem.alignByColumn && column && !isDefined(column.groupIndex) && (column.index !== afterGroupColumnIndex)) { - return column.index; - } - return groupColumnIndex; - }, true); + if (summaryItem.alignByColumn + && column + && !isDefined(column.groupIndex) + && (column.index !== afterGroupColumnIndex) + ) { + return column.index; + } + + return groupColumnIndex; + }, + true, + options.summaryColumnMap, + ); } + if (groupItem.rowType === DATAGRID_GROUP_FOOTER_ROW_TYPE) { - groupItem.summaryCells = this._calculateSummaryCells(options.summaryGroupItems, getGroupAggregates(groupItem.data), options.visibleColumns, (summaryItem, column) => (summaryItem.showInGroupFooter && that._isDataColumn(column) ? column.index : -1)); + groupItem.summaryCells = this._calculateSummaryCells( + options.summaryGroupItems, + getGroupAggregates(groupItem.data), + options.visibleColumns, + (summaryItem, column) => ( + summaryItem.showInGroupFooter && that._isDataColumn(column) ? column.index : -1 + ), + false, + options.summaryColumnMap, + ); } return groupItem; } - private _calculateSummaryCells(summaryItems, aggregates, visibleColumns, calculateTargetColumnIndex, isGroupRow?) { + // The map is built once per _processItems cycle (via options) and discarded after. + private _buildColumnLookupMap(): Map { + const columnMap = new Map(); + const { _columnsController: ctrl } = this; + const allColumns = ctrl.getColumns() + .concat(ctrl._commandColumns ?? []); + + for (const column of allColumns) { + const copy = extend({}, column) as Column; + const keys = [ + column.index, column.name, + column.dataField, column.caption, + ]; + for (const key of keys) { + if (key !== undefined && !columnMap.has(key)) { + columnMap.set(key, copy); + } + } + } + + return columnMap; + } + + private static _getColumnFromMap( + identifier: string | number | undefined, + columnMap: Map, + ): Column | undefined { + return identifier !== undefined ? columnMap.get(identifier) : undefined; + } + + private _calculateSummaryCells( + summaryItems, + aggregates, + visibleColumns, + calculateTargetColumnIndex, + isGroupRow?, + columnMap?: Map, + ) { const that = this; const summaryCells: any = []; const summaryCellsByColumns = {}; each(summaryItems, (summaryIndex, summaryItem) => { - const column = that._columnsController.columnOption(summaryItem.column); - const showInColumn = summaryItem.showInColumn && that._columnsController.columnOption(summaryItem.showInColumn) || column; + const column = columnMap + ? SummaryDataControllerExtender._getColumnFromMap(summaryItem.column, columnMap) + : that._columnsController.columnOption(summaryItem.column); + const showInColumn = (summaryItem.showInColumn + && (columnMap + ? SummaryDataControllerExtender._getColumnFromMap(summaryItem.showInColumn, columnMap) + : that._columnsController.columnOption(summaryItem.showInColumn))) + || column; const columnIndex = calculateTargetColumnIndex(summaryItem, showInColumn); if (columnIndex >= 0) { From 2044d9454ac03f9f3141f696c03f211a22169fbe Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 12 Mar 2026 12:41:19 +0400 Subject: [PATCH 2/5] move static method to utils --- .../__internal/grids/data_grid/summary/m_summary.ts | 13 +++---------- .../js/__internal/grids/data_grid/summary/utils.ts | 7 +++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts index 26c9f03a93a3..83c151aca44d 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts @@ -24,7 +24,7 @@ import type { RowsView } from '../../grid_core/views/m_rows_view'; import AggregateCalculator from '../m_aggregate_calculator'; import gridCore from '../m_core'; import dataSourceAdapterProvider from '../m_data_source_adapter'; -import { getSummaryCellIndex } from './utils'; +import { getColumnFromMap, getSummaryCellIndex } from './utils'; const DATAGRID_TOTAL_FOOTER_CLASS = 'dx-datagrid-total-footer'; const DATAGRID_SUMMARY_ITEM_CLASS = 'dx-datagrid-summary-item'; @@ -554,13 +554,6 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx return columnMap; } - private static _getColumnFromMap( - identifier: string | number | undefined, - columnMap: Map, - ): Column | undefined { - return identifier !== undefined ? columnMap.get(identifier) : undefined; - } - private _calculateSummaryCells( summaryItems, aggregates, @@ -575,11 +568,11 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx each(summaryItems, (summaryIndex, summaryItem) => { const column = columnMap - ? SummaryDataControllerExtender._getColumnFromMap(summaryItem.column, columnMap) + ? getColumnFromMap(summaryItem.column, columnMap) : that._columnsController.columnOption(summaryItem.column); const showInColumn = (summaryItem.showInColumn && (columnMap - ? SummaryDataControllerExtender._getColumnFromMap(summaryItem.showInColumn, columnMap) + ? getColumnFromMap(summaryItem.showInColumn, columnMap) : that._columnsController.columnOption(summaryItem.showInColumn))) || column; const columnIndex = calculateTargetColumnIndex(summaryItem, showInColumn); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts index a6065e3b565e..bcdb802be97e 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts @@ -18,3 +18,10 @@ export function getSummaryCellIndex( return !isDefined(column.groupIndex) ? cellIndex : -1; } + +export function getColumnFromMap( + identifier: string | number | undefined, + columnMap: Map, +): Column | undefined { + return identifier !== undefined ? columnMap.get(identifier) : undefined; +} From 89aed9463c598455ee8a495d09e421688351d196 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Thu, 12 Mar 2026 12:57:02 +0400 Subject: [PATCH 3/5] add tests --- .../__tests__/__mock__/model/data_grid.ts | 18 ++ .../__tests__/m_summary.integration.test.ts | 237 ++++++++++++++++++ .../data_grid/summary/__tests__/utils.test.ts | 139 ++++++++++ 3 files changed, 394 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts index 4c6cf1853d55..e6a24a86fb33 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts @@ -2,6 +2,12 @@ import type { Column } from '@js/ui/data_grid'; import DataGrid from '@js/ui/data_grid'; import { DataGridBaseModel } from '@ts/grids/grid_core/__tests__/__mock__/model/data_grid_base'; +const SELECTORS = { + summaryItem: 'dx-datagrid-summary-item', + groupFooter: 'dx-datagrid-group-footer', + footerRow: 'dx-footer-row', +}; + export class DataGridModel extends DataGridBaseModel { protected NAME = 'dxDataGrid'; @@ -35,4 +41,16 @@ export class DataGridModel extends DataGridBaseModel { instance.columnOption(columnName, optionName, optionValue); }); } + + public getFooterRow(): HTMLElement | null { + return this.root.querySelector(`.${SELECTORS.footerRow}`); + } + + public getGroupFooterRows(): NodeListOf { + return this.root.querySelectorAll(`.${SELECTORS.groupFooter}`); + } + + public getSummaryItems(row: HTMLElement): NodeListOf { + return row.querySelectorAll(`.${SELECTORS.summaryItem}`); + } } diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts new file mode 100644 index 000000000000..fc92e8ea455c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts @@ -0,0 +1,237 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { + afterTest, + beforeTest, + createDataGrid, +} from '../../../grid_core/__tests__/__mock__/helpers/utils'; + +describe('Summary', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('column lookup map performance optimization', () => { + const dataSource = [ + { + id: 1, name: 'Alice', value: 10, category: 'A', + }, + { + id: 2, name: 'Bob', value: 20, category: 'A', + }, + { + id: 3, name: 'Carol', value: 30, category: 'B', + }, + { + id: 4, name: 'Dave', value: 40, category: 'B', + }, + ]; + + it('should correctly calculate total summary', async () => { + const { instance } = await createDataGrid({ + dataSource, + columns: ['id', 'name', 'value', 'category'], + summary: { + totalItems: [ + { column: 'value', summaryType: 'sum' }, + { column: 'value', summaryType: 'avg' }, + { column: 'id', summaryType: 'count' }, + ], + }, + }); + + jest.runAllTimers(); + + expect(instance.getTotalSummaryValue('sum_value')).toBe(100); + expect(instance.getTotalSummaryValue('avg_value')).toBe(25); + expect(instance.getTotalSummaryValue('count_id')).toBe(4); + }); + + it('should render total footer with summary items', async () => { + const { component } = await createDataGrid({ + dataSource, + columns: ['id', 'name', 'value', 'category'], + summary: { + totalItems: [ + { column: 'value', summaryType: 'sum' }, + ], + }, + }); + + jest.runAllTimers(); + + const footerRow = component.getFooterRow() as HTMLElement; + + expect(footerRow).not.toBeNull(); + + const summaryItems = component.getSummaryItems(footerRow); + const summary = dataSource.reduce((acc, item) => acc + item.value, 0); + + expect(summaryItems.length).toBe(1); + expect(summaryItems[0].textContent).toContain(summary.toString()); + }); + + it('should calculate group summary with many groupItems', async () => { + const { component } = await createDataGrid({ + dataSource, + columns: [ + { dataField: 'id' }, + { dataField: 'name' }, + { dataField: 'value' }, + { dataField: 'category', groupIndex: 0 }, + ], + summary: { + groupItems: [ + { column: 'value', summaryType: 'sum', showInGroupFooter: false }, + { column: 'value', summaryType: 'avg', showInGroupFooter: false }, + { column: 'id', summaryType: 'count', showInGroupFooter: false }, + ], + }, + }); + + jest.runAllTimers(); + + const groupRows = component.getGroupRows(); + + expect(groupRows.length).toBe(2); + + // Group summary items with showInGroupFooter: false + // are rendered inline in the group row cell text + const firstGroupRowText = groupRows[0].textContent ?? ''; + + expect(firstGroupRowText).toContain('Sum'); + expect(firstGroupRowText).toContain('Avg'); + expect(firstGroupRowText).toContain('Count'); + }); + + it('should render group footer summary', async () => { + const { component } = await createDataGrid({ + dataSource, + columns: [ + { dataField: 'id' }, + { dataField: 'name' }, + { dataField: 'value' }, + { dataField: 'category', groupIndex: 0 }, + ], + summary: { + groupItems: [ + { + column: 'value', + summaryType: 'sum', + showInGroupFooter: true, + }, + ], + }, + }); + + jest.runAllTimers(); + + const groupFooterRows = component.getGroupFooterRows(); + + expect(groupFooterRows.length).toBe(2); + + const summaryItems = component.getSummaryItems( + groupFooterRows[0], + ); + + expect(summaryItems.length).toBe(1); + expect(summaryItems[0].textContent).toContain('30'); + }); + + it('should correctly calculate summary with showInColumn option', async () => { + const { component } = await createDataGrid({ + dataSource, + columns: [ + { dataField: 'id' }, + { dataField: 'name' }, + { dataField: 'value' }, + { dataField: 'category', groupIndex: 0 }, + ], + summary: { + groupItems: [ + { + column: 'value', + summaryType: 'sum', + showInColumn: 'name', + showInGroupFooter: false, + }, + ], + }, + }); + + jest.runAllTimers(); + + const groupRows = component.getGroupRows(); + + expect(groupRows.length).toBe(2); + }); + + it('should handle many summary items without errors', async () => { + const groupItems = Array.from( + { length: 50 }, + (_, i) => ({ + column: i % 2 === 0 ? 'value' : 'id', + summaryType: 'sum' as const, + showInGroupFooter: false, + name: `summary_${i}`, + }), + ); + + const { component } = await createDataGrid({ + dataSource, + columns: [ + { dataField: 'id' }, + { dataField: 'name' }, + { dataField: 'value' }, + { dataField: 'category', groupIndex: 0 }, + ], + summary: { + groupItems, + }, + }); + + jest.runAllTimers(); + + const groupRows = component.getGroupRows(); + + expect(groupRows.length).toBe(2); + }); + + it('should handle combined total and group summary', async () => { + const { instance, component } = await createDataGrid({ + dataSource, + columns: [ + { dataField: 'id' }, + { dataField: 'name' }, + { dataField: 'value' }, + { dataField: 'category', groupIndex: 0 }, + ], + summary: { + totalItems: [ + { column: 'value', summaryType: 'sum' }, + ], + groupItems: [ + { + column: 'value', + summaryType: 'sum', + showInGroupFooter: false, + }, + ], + }, + }); + + jest.runAllTimers(); + + expect(instance.getTotalSummaryValue('sum_value')).toBe(100); + + const footerRow = component.getFooterRow(); + + expect(footerRow).not.toBeNull(); + + const groupRows = component.getGroupRows(); + + expect(groupRows.length).toBe(2); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts new file mode 100644 index 000000000000..b8ae2fb9b85d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts @@ -0,0 +1,139 @@ +import { + describe, expect, it, +} from '@jest/globals'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; + +import { getColumnFromMap, getSummaryCellIndex } from '../utils'; + +const makeColumn = (overrides: Partial = {}): Column => ({ + ...overrides, +}); + +describe('getSummaryCellIndex', () => { + describe('when isGroupRow is false (default)', () => { + it('should return column.index', () => { + const column = makeColumn({ index: 5 }); + + expect(getSummaryCellIndex(column)).toBe(5); + }); + + it('should return -1 when column.index is undefined', () => { + const column = makeColumn(); + + expect(getSummaryCellIndex(column)).toBe(-1); + }); + + it('should ignore prevColumn when isGroupRow is false', () => { + const column = makeColumn({ index: 3 }); + const prevColumn = makeColumn({ index: 10, type: 'groupExpand' }); + + expect(getSummaryCellIndex(column, prevColumn, false)).toBe(3); + }); + }); + + describe('when isGroupRow is true', () => { + describe('groupExpand handling', () => { + it('should return prevColumn.index when prevColumn.type is groupExpand', () => { + const column = makeColumn({ index: 5 }); + const prevColumn = makeColumn({ index: 2, type: 'groupExpand' }); + + expect(getSummaryCellIndex(column, prevColumn, true)).toBe(2); + }); + + it('should return prevColumn.index when column.type is groupExpand', () => { + const column = makeColumn({ index: 5, type: 'groupExpand' }); + const prevColumn = makeColumn({ index: 7 }); + + expect(getSummaryCellIndex(column, prevColumn, true)).toBe(7); + }); + + it('should return -1 when column.type is groupExpand and prevColumn is undefined', () => { + const column = makeColumn({ index: 5, type: 'groupExpand' }); + + expect(getSummaryCellIndex(column, undefined, true)).toBe(-1); + }); + + it('should return -1 when prevColumn.type is groupExpand and prevColumn.index is undefined', () => { + const column = makeColumn({ index: 5 }); + const prevColumn = makeColumn({ type: 'groupExpand' }); + + expect(getSummaryCellIndex(column, prevColumn, true)).toBe(-1); + }); + }); + + describe('groupIndex handling', () => { + it('should return column.index when groupIndex is not defined', () => { + const column = makeColumn({ index: 4 }); + + expect(getSummaryCellIndex(column, undefined, true)).toBe(4); + }); + + it('should return -1 when groupIndex is defined', () => { + const column = makeColumn({ index: 4, groupIndex: 0 }); + + expect(getSummaryCellIndex(column, undefined, true)).toBe(-1); + }); + + it('should return -1 when groupIndex is 0 (falsy but defined)', () => { + const column = makeColumn({ index: 8, groupIndex: 0 }); + + expect(getSummaryCellIndex(column, undefined, true)).toBe(-1); + }); + + it('should return column.index when groupIndex is undefined and prevColumn has no groupExpand type', () => { + const column = makeColumn({ index: 6 }); + const prevColumn = makeColumn({ index: 3 }); + + expect(getSummaryCellIndex(column, prevColumn, true)).toBe(6); + }); + }); + }); +}); + +describe('getColumnFromMap', () => { + const colA = makeColumn({ index: 0, dataField: 'fieldA' }); + const colB = makeColumn({ index: 1, dataField: 'fieldB' }); + const getColumnMap = (): Map => ( + new Map([ + [0, colA], + ['fieldA', colA], + [1, colB], + ['fieldB', colB], + ]) + ); + + it('should return column by numeric index', () => { + const columnMap = getColumnMap(); + + expect(getColumnFromMap(0, columnMap)).toBe(colA); + expect(getColumnFromMap(1, columnMap)).toBe(colB); + }); + + it('should return column by string dataField', () => { + const columnMap = getColumnMap(); + + expect(getColumnFromMap('fieldA', columnMap)).toBe(colA); + expect(getColumnFromMap('fieldB', columnMap)).toBe(colB); + }); + + it('should return undefined for undefined identifier', () => { + const columnMap = getColumnMap(); + + expect(getColumnFromMap(undefined, columnMap)).toBeUndefined(); + }); + + it('should return undefined for identifier not in the map', () => { + const columnMap = getColumnMap(); + + expect(getColumnFromMap(999, columnMap)).toBeUndefined(); + expect(getColumnFromMap('nonExistent', columnMap)).toBeUndefined(); + }); + + it('should work with an empty map', () => { + const emptyMap = new Map(); + + expect(getColumnFromMap(0, emptyMap)).toBeUndefined(); + expect(getColumnFromMap('fieldA', emptyMap)).toBeUndefined(); + expect(getColumnFromMap(undefined, emptyMap)).toBeUndefined(); + }); +}); From 32f9b5d7cc7096dee1c5d9e978d3cbc0e32a340b Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Mon, 16 Mar 2026 12:46:50 +0400 Subject: [PATCH 4/5] fix test and refactor code a bit --- .../__tests__/__mock__/model/data_grid.ts | 18 -- .../__tests__/m_summary.integration.test.ts | 224 +++--------------- .../grids/data_grid/summary/m_summary.ts | 29 ++- 3 files changed, 46 insertions(+), 225 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts index e6a24a86fb33..4c6cf1853d55 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/__tests__/__mock__/model/data_grid.ts @@ -2,12 +2,6 @@ import type { Column } from '@js/ui/data_grid'; import DataGrid from '@js/ui/data_grid'; import { DataGridBaseModel } from '@ts/grids/grid_core/__tests__/__mock__/model/data_grid_base'; -const SELECTORS = { - summaryItem: 'dx-datagrid-summary-item', - groupFooter: 'dx-datagrid-group-footer', - footerRow: 'dx-footer-row', -}; - export class DataGridModel extends DataGridBaseModel { protected NAME = 'dxDataGrid'; @@ -41,16 +35,4 @@ export class DataGridModel extends DataGridBaseModel { instance.columnOption(columnName, optionName, optionValue); }); } - - public getFooterRow(): HTMLElement | null { - return this.root.querySelector(`.${SELECTORS.footerRow}`); - } - - public getGroupFooterRows(): NodeListOf { - return this.root.querySelectorAll(`.${SELECTORS.groupFooter}`); - } - - public getSummaryItems(row: HTMLElement): NodeListOf { - return row.querySelectorAll(`.${SELECTORS.summaryItem}`); - } } diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts index fc92e8ea455c..0b7028be6b98 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts @@ -6,232 +6,68 @@ import { afterTest, beforeTest, createDataGrid, + flushAsync, } from '../../../grid_core/__tests__/__mock__/helpers/utils'; describe('Summary', () => { beforeEach(beforeTest); afterEach(afterTest); - describe('column lookup map performance optimization', () => { + describe('column lookup map performance optimization (T1316562)', () => { + const SUMMARY_COUNT = 100; + const GROUP_COUNT = 4; + const dataSource = [ { - id: 1, name: 'Alice', value: 10, category: 'A', + id: 1, name: 'Alice', value: 10, category: 'A', region: 'X', }, { - id: 2, name: 'Bob', value: 20, category: 'A', + id: 2, name: 'Bob', value: 20, category: 'A', region: 'Y', }, { - id: 3, name: 'Carol', value: 30, category: 'B', + id: 3, name: 'Carol', value: 30, category: 'B', region: 'X', }, { - id: 4, name: 'Dave', value: 40, category: 'B', + id: 4, name: 'Dave', value: 40, category: 'B', region: 'Y', }, ]; - it('should correctly calculate total summary', async () => { + const groupSummaryItems = Array.from( + { length: SUMMARY_COUNT }, + (_, i) => ({ + column: i % 2 === 0 ? 'value' : 'id', + summaryType: 'sum' as const, + showInGroupFooter: false, + name: `summary_${i}`, + }), + ); + + it('should use columnMap optimization and avoid O(n*m) columnOption calls on refresh', async () => { const { instance } = await createDataGrid({ - dataSource, - columns: ['id', 'name', 'value', 'category'], - summary: { - totalItems: [ - { column: 'value', summaryType: 'sum' }, - { column: 'value', summaryType: 'avg' }, - { column: 'id', summaryType: 'count' }, - ], - }, - }); - - jest.runAllTimers(); - - expect(instance.getTotalSummaryValue('sum_value')).toBe(100); - expect(instance.getTotalSummaryValue('avg_value')).toBe(25); - expect(instance.getTotalSummaryValue('count_id')).toBe(4); - }); - - it('should render total footer with summary items', async () => { - const { component } = await createDataGrid({ - dataSource, - columns: ['id', 'name', 'value', 'category'], - summary: { - totalItems: [ - { column: 'value', summaryType: 'sum' }, - ], - }, - }); - - jest.runAllTimers(); - - const footerRow = component.getFooterRow() as HTMLElement; - - expect(footerRow).not.toBeNull(); - - const summaryItems = component.getSummaryItems(footerRow); - const summary = dataSource.reduce((acc, item) => acc + item.value, 0); - - expect(summaryItems.length).toBe(1); - expect(summaryItems[0].textContent).toContain(summary.toString()); - }); - - it('should calculate group summary with many groupItems', async () => { - const { component } = await createDataGrid({ - dataSource, - columns: [ - { dataField: 'id' }, - { dataField: 'name' }, - { dataField: 'value' }, - { dataField: 'category', groupIndex: 0 }, - ], - summary: { - groupItems: [ - { column: 'value', summaryType: 'sum', showInGroupFooter: false }, - { column: 'value', summaryType: 'avg', showInGroupFooter: false }, - { column: 'id', summaryType: 'count', showInGroupFooter: false }, - ], - }, - }); - - jest.runAllTimers(); - - const groupRows = component.getGroupRows(); - - expect(groupRows.length).toBe(2); - - // Group summary items with showInGroupFooter: false - // are rendered inline in the group row cell text - const firstGroupRowText = groupRows[0].textContent ?? ''; - - expect(firstGroupRowText).toContain('Sum'); - expect(firstGroupRowText).toContain('Avg'); - expect(firstGroupRowText).toContain('Count'); - }); - - it('should render group footer summary', async () => { - const { component } = await createDataGrid({ - dataSource, - columns: [ - { dataField: 'id' }, - { dataField: 'name' }, - { dataField: 'value' }, - { dataField: 'category', groupIndex: 0 }, - ], - summary: { - groupItems: [ - { - column: 'value', - summaryType: 'sum', - showInGroupFooter: true, - }, - ], - }, - }); - - jest.runAllTimers(); - - const groupFooterRows = component.getGroupFooterRows(); - - expect(groupFooterRows.length).toBe(2); - - const summaryItems = component.getSummaryItems( - groupFooterRows[0], - ); - - expect(summaryItems.length).toBe(1); - expect(summaryItems[0].textContent).toContain('30'); - }); - - it('should correctly calculate summary with showInColumn option', async () => { - const { component } = await createDataGrid({ - dataSource, - columns: [ - { dataField: 'id' }, - { dataField: 'name' }, - { dataField: 'value' }, - { dataField: 'category', groupIndex: 0 }, - ], - summary: { - groupItems: [ - { - column: 'value', - summaryType: 'sum', - showInColumn: 'name', - showInGroupFooter: false, - }, - ], - }, - }); - - jest.runAllTimers(); - - const groupRows = component.getGroupRows(); - - expect(groupRows.length).toBe(2); - }); - - it('should handle many summary items without errors', async () => { - const groupItems = Array.from( - { length: 50 }, - (_, i) => ({ - column: i % 2 === 0 ? 'value' : 'id', - summaryType: 'sum' as const, - showInGroupFooter: false, - name: `summary_${i}`, - }), - ); - - const { component } = await createDataGrid({ - dataSource, - columns: [ - { dataField: 'id' }, - { dataField: 'name' }, - { dataField: 'value' }, - { dataField: 'category', groupIndex: 0 }, - ], - summary: { - groupItems, - }, - }); - - jest.runAllTimers(); - - const groupRows = component.getGroupRows(); - - expect(groupRows.length).toBe(2); - }); - - it('should handle combined total and group summary', async () => { - const { instance, component } = await createDataGrid({ dataSource, columns: [ { dataField: 'id' }, { dataField: 'name' }, { dataField: 'value' }, { dataField: 'category', groupIndex: 0 }, + { dataField: 'region', groupIndex: 1 }, ], - summary: { - totalItems: [ - { column: 'value', summaryType: 'sum' }, - ], - groupItems: [ - { - column: 'value', - summaryType: 'sum', - showInGroupFooter: false, - }, - ], - }, + summary: { groupItems: groupSummaryItems }, }); - jest.runAllTimers(); + await flushAsync(); - expect(instance.getTotalSummaryValue('sum_value')).toBe(100); + const columnsController = instance.getController('columns'); + const spy = jest.spyOn(columnsController, 'columnOption'); - const footerRow = component.getFooterRow(); + instance.refresh().catch(() => {}); + await flushAsync(); - expect(footerRow).not.toBeNull(); + const worstCaseMinCalls = SUMMARY_COUNT * GROUP_COUNT; - const groupRows = component.getGroupRows(); + expect(spy.mock.calls.length).toBeLessThan(worstCaseMinCalls); - expect(groupRows.length).toBe(2); + spy.mockRestore(); }); }); }); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts index 83c151aca44d..b01fa46861bb 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts @@ -534,20 +534,24 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx // The map is built once per _processItems cycle (via options) and discarded after. private _buildColumnLookupMap(): Map { const columnMap = new Map(); - const { _columnsController: ctrl } = this; - const allColumns = ctrl.getColumns() - .concat(ctrl._commandColumns ?? []); + const allColumns = [ + ...this._columnsController.getColumns(), + ...(this._columnsController._commandColumns ?? []), + ]; for (const column of allColumns) { - const copy = extend({}, column) as Column; + const copiedColumn = { ...column }; const keys = [ - column.index, column.name, - column.dataField, column.caption, - ]; + column.index, + column.name, + column.dataField, + column.caption, + ].filter((key) => ( + key !== undefined && !columnMap.has(key) + )); + for (const key of keys) { - if (key !== undefined && !columnMap.has(key)) { - columnMap.set(key, copy); - } + columnMap.set(key, copiedColumn); } } @@ -562,18 +566,17 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx isGroupRow?, columnMap?: Map, ) { - const that = this; const summaryCells: any = []; const summaryCellsByColumns = {}; each(summaryItems, (summaryIndex, summaryItem) => { const column = columnMap ? getColumnFromMap(summaryItem.column, columnMap) - : that._columnsController.columnOption(summaryItem.column); + : this._columnsController.columnOption(summaryItem.column); const showInColumn = (summaryItem.showInColumn && (columnMap ? getColumnFromMap(summaryItem.showInColumn, columnMap) - : that._columnsController.columnOption(summaryItem.showInColumn))) + : this._columnsController.columnOption(summaryItem.showInColumn))) || column; const columnIndex = calculateTargetColumnIndex(summaryItem, showInColumn); From dc1d23a51c856303aedc8b8edbc93319dfa7c158 Mon Sep 17 00:00:00 2001 From: Danil Mirgaev Date: Mon, 16 Mar 2026 13:46:42 +0400 Subject: [PATCH 5/5] add appropriate type definitions --- .../grids/data_grid/summary/m_summary.ts | 13 +++++++----- .../grids/data_grid/summary/types.ts | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/summary/types.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts index b01fa46861bb..a49cab1a3a11 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts @@ -18,12 +18,12 @@ import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_da import type { EditingControllerRequired, ModuleType } from '@ts/grids/grid_core/m_types'; import { ColumnsView } from '@ts/grids/grid_core/views/m_columns_view'; -import type { Column } from '../../grid_core/columns_controller/types'; import type { EditingController } from '../../grid_core/editing/m_editing'; import type { RowsView } from '../../grid_core/views/m_rows_view'; import AggregateCalculator from '../m_aggregate_calculator'; import gridCore from '../m_core'; import dataSourceAdapterProvider from '../m_data_source_adapter'; +import type { ColumnMap, SummaryItem } from './types'; import { getColumnFromMap, getSummaryCellIndex } from './utils'; const DATAGRID_TOTAL_FOOTER_CLASS = 'dx-datagrid-total-footer'; @@ -532,8 +532,8 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx } // The map is built once per _processItems cycle (via options) and discarded after. - private _buildColumnLookupMap(): Map { - const columnMap = new Map(); + private _buildColumnLookupMap(): ColumnMap { + const columnMap: ColumnMap = new Map(); const allColumns = [ ...this._columnsController.getColumns(), ...(this._columnsController._commandColumns ?? []), @@ -541,6 +541,9 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx for (const column of allColumns) { const copiedColumn = { ...column }; + // The method registers each column under a few keys: index, name, dataField, and caption. + // This is because the developer can specify summaryItem.column (and summaryItem.showInColumn) + // in any of these forms — number for column index and string for all the rest. const keys = [ column.index, column.name, @@ -559,12 +562,12 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx } private _calculateSummaryCells( - summaryItems, + summaryItems: SummaryItem[], aggregates, visibleColumns, calculateTargetColumnIndex, isGroupRow?, - columnMap?: Map, + columnMap?: ColumnMap, ) { const summaryCells: any = []; const summaryCellsByColumns = {}; diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts new file mode 100644 index 000000000000..97eb4331d699 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts @@ -0,0 +1,20 @@ +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; + +export interface SummaryItem { + /** Number as a column index or string as a column name, dataField, or caption. */ + column?: string | number | undefined; + /** Number as a column index or string as a column name, dataField, or caption. */ + showInColumn?: string | number | undefined; + showInGroupFooter?: boolean; + alignByColumn?: boolean; + summaryType?: string | undefined; + valueFormat?: unknown; + name?: string | undefined; + skipEmptyValues?: boolean; + alignment?: string | undefined; + cssClass?: string | undefined; + customizeText?: ((itemInfo: { value?: string | number | Date; valueText: string }) => string); + displayFormat?: string | undefined; +} + +export type ColumnMap = Map;