diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/extenders/summary_data_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/extenders/summary_data_controller.ts index 1360ed40de0a..9c8c894a9967 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/extenders/summary_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/extenders/summary_data_controller.ts @@ -1,6 +1,5 @@ -/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable consistent-return */ -/* eslint-disable no-param-reassign */ + /* eslint-disable @typescript-eslint/no-this-alias */ /* eslint-disable @typescript-eslint/init-declarations */ /* eslint-disable no-self-compare */ @@ -13,35 +12,30 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -import { compileGetter } from '@js/core/utils/data'; import { extend } from '@js/core/utils/extend'; -import { each, map } from '@js/core/utils/iterator'; +import { each } from '@js/core/utils/iterator'; import { isDefined, isEmptyObject, isString, } from '@js/core/utils/type'; -import errors from '@js/ui/widget/ui.errors'; -import type { DataSource } from '@ts/data/data_source/types'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; -import type { EditingController } from '@ts/grids/grid_core/editing/m_editing'; import type { ModuleType } from '@ts/grids/grid_core/m_types'; import gridCore from '../../m_core'; import { DATAGRID_GROUP_FOOTER_ROW_TYPE, DATAGRID_TOTAL_FOOTER_ROW_TYPE } from '../const'; -import type { CalculateSummaryCellsArgs, ColumnMap, SummaryCellItem } from '../types'; +import type { + CalculateSummaryCellsArgs, ColumnMap, SummaryCellItem, +} from '../types'; import { getColumnFromMap, getSummaryCellIndex } from '../utils'; - -const getGroupAggregates = (data) => data.summary || data.aggregates || []; +import { getGroupAggregates } from '../utils/get_group_aggregates'; +import { getSummaryItemIndex } from '../utils/get_summary_item_index'; export const summaryDataControllerExtender = ( Base: ModuleType, ): ModuleType => class SummaryDataControllerExtender extends Base { private _footerItems!: any[]; - protected _editingController!: EditingController; - public init(): void { this._footerItems = []; - this._editingController = this.getController('editing'); super.init(); } @@ -277,205 +271,12 @@ export const summaryDataControllerExtender = ( super._updateItemsCore(change); } - private _prepareUnsavedDataSelector(selector) { - if (this.option('summary.recalculateWhileEditing')) { - const editingController = this._editingController; - if (editingController) { - return function (data) { - data = editingController.getUpdatedData(data); - return selector(data); - }; - } - } - - return selector; - } - - private _prepareAggregateSelector(selector, aggregator) { - selector = this._prepareUnsavedDataSelector(selector); - - if (aggregator === 'avg' || aggregator === 'sum') { - return function (data) { - const value = selector(data); - return isDefined(value) ? Number(value) : value; - }; - } - - return selector; - } - - private _getAggregates(summaryItems, remoteOperations) { - const that = this; - let calculateCustomSummary: any = that.option('summary.calculateCustomSummary'); - const commonSkipEmptyValues = that.option('summary.skipEmptyValues'); - - return map(summaryItems || [], (summaryItem) => { - const column = this._columnsController.columnOption(summaryItem.column); - const calculateCellValue = column?.calculateCellValue ? column.calculateCellValue.bind(column) : compileGetter(column ? column.dataField : summaryItem.column); - let aggregator = summaryItem.summaryType || 'count'; - const skipEmptyValues = isDefined(summaryItem.skipEmptyValues) ? summaryItem.skipEmptyValues : commonSkipEmptyValues; - - if (remoteOperations) { - return { - selector: summaryItem.column, - summaryType: aggregator, - }; - } - const selector = that._prepareAggregateSelector(calculateCellValue, aggregator); - - if (aggregator === 'custom') { - if (!calculateCustomSummary) { - errors.log('E1026'); - calculateCustomSummary = function () { }; - } - const options: any = { - component: that.component, - name: summaryItem.name, - }; - calculateCustomSummary(options); - options.summaryProcess = 'calculate'; - aggregator = { - seed(groupIndex) { - options.summaryProcess = 'start'; - options.totalValue = undefined; - options.groupIndex = groupIndex; - delete options.value; - calculateCustomSummary(options); - return options.totalValue; - }, - step(totalValue, value) { - options.summaryProcess = 'calculate'; - options.totalValue = totalValue; - options.value = value; - calculateCustomSummary(options); - return options.totalValue; - }, - finalize(totalValue) { - options.summaryProcess = 'finalize'; - options.totalValue = totalValue; - delete options.value; - calculateCustomSummary(options); - return options.totalValue; - }, - }; - } - return { - selector, - aggregator, - skipEmptyValues, - }; - }); - } - - private _addSortInfo(sortByGroups, groupColumn, selector, sortOrder) { - if (groupColumn) { - const { groupIndex } = groupColumn; - sortOrder = sortOrder || groupColumn.sortOrder; - if (isDefined(groupIndex)) { - sortByGroups[groupIndex] = sortByGroups[groupIndex] || []; - sortByGroups[groupIndex].push({ - selector, - desc: sortOrder === 'desc', - }); - } - } - } - - private _findSummaryItem(summaryItems, name) { - let summaryItemIndex: any = -1; - - const getFullName = function (summaryItem) { - const { summaryType } = summaryItem; - const { column } = summaryItem; - - return summaryType && column && `${summaryType}_${column}`; - }; - - if (isDefined(name)) { - // @ts-expect-error - each(summaryItems || [], function (index) { - if (this.name === name || index === name || this.summaryType === name || this.column === name || getFullName(this) === name) { - summaryItemIndex = index; - return false; - } - }); - } - return summaryItemIndex; - } - - private _getSummarySortByGroups(sortByGroupSummaryInfo, groupSummaryItems) { - const that = this; - const columnsController = that._columnsController; - const groupColumns = columnsController.getGroupColumns(); - const sortByGroups = []; - - if (!groupSummaryItems?.length) return; - - each(sortByGroupSummaryInfo || [], function () { - const { sortOrder } = this; - let { groupColumn } = this; - const summaryItemIndex = that._findSummaryItem(groupSummaryItems, this.summaryItem); - - if (summaryItemIndex < 0) return; - - const selector = function (data) { - return getGroupAggregates(data)[summaryItemIndex]; - }; - - if (isDefined(groupColumn)) { - groupColumn = columnsController.columnOption(groupColumn); - that._addSortInfo(sortByGroups, groupColumn, selector, sortOrder); - } else { - each(groupColumns, (groupIndex, groupColumn) => { - that._addSortInfo(sortByGroups, groupColumn, selector, sortOrder); - }); - } - }); - return sortByGroups; - } - - protected _createDataSourceAdapter(dataSource: DataSource) { - const dataSourceAdapter = super._createDataSourceAdapter(dataSource); - - // @ts-expect-error summaryGetter is defined in summary DataSourceAdapterExtender - dataSourceAdapter.summaryGetter((currentRemoteOperations) => { - const result = this._getSummaryOptions( - currentRemoteOperations ?? dataSourceAdapter.remoteOperations(), - ); - return result; - }); - - return dataSourceAdapter; - } - - private _getSummaryOptions(remoteOperations) { - const that = this; - const groupSummaryItems = that.option('summary.groupItems'); - const totalSummaryItems = that.option('summary.totalItems'); - const sortByGroupSummaryInfo = that.option('sortByGroupSummaryInfo'); - const groupAggregates = that._getAggregates(groupSummaryItems, remoteOperations?.grouping && remoteOperations.summary); - const totalAggregates = that._getAggregates(totalSummaryItems, remoteOperations?.summary); - const sortByGroups = function () { - return that._getSummarySortByGroups(sortByGroupSummaryInfo, groupSummaryItems); - }; - - if (groupAggregates.length || totalAggregates.length) { - return { - groupAggregates, - totalAggregates, - sortByGroups, - }; - } - - return undefined; - } - public publicMethods(): string[] { return [...super.publicMethods(), 'getTotalSummaryValue']; } private getTotalSummaryValue(summaryItemName) { - const summaryItemIndex = this._findSummaryItem(this.option('summary.totalItems'), summaryItemName); + const summaryItemIndex = getSummaryItemIndex(this.option('summary.totalItems'), summaryItemName); const aggregates = this._dataSource.totalAggregates(); if (aggregates.length && summaryItemIndex > -1) { 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 96b1de02cd84..0a769125b82f 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 @@ -3,11 +3,13 @@ import dataQuery from '@js/common/data/query'; import storeHelper from '@js/common/data/store_helper'; import { normalizeSortingInfo } from '@js/common/data/utils'; import $ from '@js/core/renderer'; -import { noop } from '@js/core/utils/common'; import { extend } from '@js/core/utils/extend'; import { each } from '@js/core/utils/iterator'; -import { isDefined, isFunction, isPlainObject } from '@js/core/utils/type'; +import { isDefined, isPlainObject } from '@js/core/utils/type'; +import type { DataSource } from '@ts/data/data_source/types'; +import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; +import type { RemoteOperationsOptions } from '@ts/grids/grid_core/data_source_adapter/types'; import type { EditingControllerRequired, ModuleType } from '@ts/grids/grid_core/m_types'; import { ColumnsView } from '@ts/grids/grid_core/views/m_columns_view'; @@ -27,6 +29,8 @@ import { DATAGRID_TOTAL_FOOTER_CLASS, DATAGRID_TOTAL_FOOTER_ROW_TYPE, } from './const'; +import type { SummaryOptions } from './types'; +import { getSummaryOptions } from './utils/get_summary_options'; export const renderSummaryCell = function (cell, options, setAria) { const $cell = $(cell); @@ -149,7 +153,7 @@ const calculateAggregates = function (that: EditingControllerRequired, summary, let calculator; if ((that as any).option('summary.recalculateWhileEditing')) { - const editingController = that._editingController; + const { editingController } = that; if (editingController) { const insertedData = editingController.getInsertedData(); if (insertedData.length) { @@ -284,34 +288,36 @@ export class FooterView extends ColumnsView { export const dataSourceAdapterExtender = (Base: ModuleType) => class SummaryDataSourceAdapterExtender extends Base implements EditingControllerRequired { private _totalAggregates: any; - private _summaryGetter: any; + private columnsController!: ColumnsController; - public _editingController!: EditingController; + public editingController!: EditingController; - public init() { - super.init.apply(this, arguments as any); + public init(dataSource?: DataSource): void { + super.init(dataSource); - this._editingController = this.getController('editing'); + this.columnsController = this.getController('columns'); + this.editingController = this.getController('editing'); this._totalAggregates = []; - this._summaryGetter = noop; } - private summaryGetter(summaryGetter?) { - if (!arguments.length) { - return this._summaryGetter; - } + private getSummary(remoteOperations?: RemoteOperationsOptions): SummaryOptions | undefined { + const summary = this.option('summary'); - if (isFunction(summaryGetter)) { - this._summaryGetter = summaryGetter; + if (!summary) { + return undefined; } - } - private summary(summary?) { - if (!arguments.length) { - return this._summaryGetter(); - } + const result = getSummaryOptions({ + summary, + sortByGroupSummaryInfo: this.option('sortByGroupSummaryInfo'), + remoteOperations: remoteOperations ?? this.remoteOperations(), + getUpdatedItemData: (data) => this.editingController.getUpdatedData(data), + columnOption: (id) => this.columnsController.columnOption(id), + groupColumns: this.columnsController.getGroupColumns(), + component: this.component as any, + }); - this._summaryGetter = function () { return summary; }; + return result; } private totalAggregates() { @@ -319,7 +325,7 @@ export const dataSourceAdapterExtender = (Base: ModuleType) = } private isLastLevelGroupItemsPagingLocal() { - const summary = this.summary(); + const summary = this.getSummary(); const sortByGroupsInfo = summary?.sortByGroups(); return sortByGroupsInfo?.length; @@ -342,7 +348,7 @@ export const dataSourceAdapterExtender = (Base: ModuleType) = } protected _customizeRemoteOperations(options) { - const summary = this.summary(); + const summary = this.getSummary(); if (summary) { if (options.remoteOperations.summary) { @@ -372,7 +378,7 @@ export const dataSourceAdapterExtender = (Base: ModuleType) = protected customizeLoadResultHandlerCore(options) { const groups = normalizeSortingInfo(options.storeLoadOptions.group || options.loadOptions.group || []); const remoteOperations = options.remoteOperations || {}; - const summary = this.summaryGetter()(remoteOperations); + const summary = this.getSummary(remoteOperations); if (!options.isCustomLoading || options.storeLoadOptions.isLoadingAll) { if (remoteOperations.summary) { diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/summary_module.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/summary_module.ts index 82449674fb2f..27c6a1dc3be5 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/summary_module.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/summary_module.ts @@ -1,4 +1,5 @@ import messageLocalization from '@js/common/core/localization/message'; +import type { SortByGroupSummaryInfoItem, Summary } from '@js/ui/data_grid'; import gridCore from '../m_core'; import dataSourceAdapterProvider from '../m_data_source_adapter'; @@ -11,7 +12,10 @@ import { dataSourceAdapterProvider.extend(dataSourceAdapterExtender); gridCore.registerModule('summary', { - defaultOptions() { + defaultOptions(): { + summary: Summary, + sortByGroupSummaryInfo: SortByGroupSummaryInfoItem[] | undefined + } { return { summary: { groupItems: undefined, diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts index 6c2518449a56..af0076da8c65 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts @@ -1,21 +1,11 @@ +import type { + SummaryGroupItem, + SummaryTotalItem, SummaryType, +} from '@js/ui/data_grid'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/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 interface SummaryItem extends SummaryGroupItem, SummaryTotalItem { } export interface SummaryCellItem extends SummaryItem { value?: unknown; @@ -32,3 +22,30 @@ export interface CalculateSummaryCellsArgs { isGroupRow?: boolean; columnMap?: ColumnMap; } + +export interface SortInfo { + selector: (data: RawItemData) => unknown; + desc: boolean; +} + +// NOTE: indexed by groupIndex, so levels without sort info are left empty +export type SortByGroups = (SortInfo[] | undefined)[]; + +export interface CustomAggregator { + seed: (groupIndex: number) => unknown; + step: (totalValue: unknown, value: unknown) => unknown; + finalize: (totalValue: unknown) => unknown; +} + +export interface Aggregate { + selector?: string | ((data: RawItemData) => unknown); + aggregator?: string | Exclude | CustomAggregator; + summaryType?: SummaryType | string | undefined; + skipEmptyValues?: boolean; +} + +export interface SummaryOptions { + groupAggregates: Aggregate[]; + totalAggregates: Aggregate[]; + sortByGroups: () => SortByGroups | undefined; +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_item_index.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_item_index.test.ts new file mode 100644 index 000000000000..e5c07243df48 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_item_index.test.ts @@ -0,0 +1,137 @@ +import { + describe, expect, it, +} from '@jest/globals'; + +import type { SummaryItem } from '../../types'; +import { getSummaryItemIndex } from '../get_summary_item_index'; + +describe('findSummaryItem', () => { + describe('when there is nothing to search in or for', () => { + const summaryItems: SummaryItem[] = [{ summaryType: 'count' }]; + + it('should return -1 when summaryItems is not defined', () => { + expect(getSummaryItemIndex(undefined, 'count')).toBe(-1); + }); + + it('should return -1 when summaryItems is empty', () => { + expect(getSummaryItemIndex([], 'count')).toBe(-1); + }); + + it('should return -1 when name is not defined', () => { + expect(getSummaryItemIndex(summaryItems, undefined)).toBe(-1); + }); + + it('should return -1 when name is null', () => { + expect(getSummaryItemIndex(summaryItems, null)).toBe(-1); + }); + }); + + describe('search by custom name', () => { + const summaryItems: SummaryItem[] = [{ + name: 'testCount1', + summaryType: 'count', + }, { + name: 'testCount2', + summaryType: 'count', + }, { + name: 'testMin', + summaryType: 'min', + column: 'testField', + }]; + + it('should find by summaryType', () => { + expect(getSummaryItemIndex(summaryItems, 'count')).toBe(0); + }); + + it('should find by column name', () => { + expect(getSummaryItemIndex(summaryItems, 'testField')).toBe(2); + }); + + it('should find by summaryType + column name', () => { + expect(getSummaryItemIndex(summaryItems, 'min_testField')).toBe(2); + }); + + it('should find by name', () => { + expect(getSummaryItemIndex(summaryItems, 'testCount2')).toBe(1); + }); + + it('should find by summary item index', () => { + expect(getSummaryItemIndex(summaryItems, 1)).toBe(1); + }); + + it('should return -1 for a wrong name', () => { + expect(getSummaryItemIndex(summaryItems, 'test3')).toBe(-1); + }); + }); + + describe('search by index', () => { + const summaryItems: SummaryItem[] = [ + { summaryType: 'count' }, + { summaryType: 'min', column: 'field' }, + ]; + + it('should find the first item by index 0', () => { + expect(getSummaryItemIndex(summaryItems, 0)).toBe(0); + }); + + it('should return -1 when index is out of range', () => { + expect(getSummaryItemIndex(summaryItems, 2)).toBe(-1); + expect(getSummaryItemIndex(summaryItems, -1)).toBe(-1); + }); + + it('should not match an index passed as a string', () => { + expect(getSummaryItemIndex(summaryItems, '1')).toBe(-1); + }); + }); + + describe('full name', () => { + it('should use summaryType only when column is not defined', () => { + const summaryItems: SummaryItem[] = [{ summaryType: 'sum' }]; + + expect(getSummaryItemIndex(summaryItems, 'sum')).toBe(0); + expect(getSummaryItemIndex(summaryItems, 'sum_undefined')).toBe(-1); + }); + + it('should use column only when summaryType is not defined', () => { + const summaryItems: SummaryItem[] = [{ column: 'field' }]; + + expect(getSummaryItemIndex(summaryItems, 'field')).toBe(0); + expect(getSummaryItemIndex(summaryItems, 'undefined_field')).toBe(-1); + }); + + it('should not match items without summaryType, column and name', () => { + const summaryItems: SummaryItem[] = [{}, { summaryType: 'sum' }]; + + expect(getSummaryItemIndex(summaryItems, 'sum')).toBe(1); + }); + }); + + describe('several matching items', () => { + it('should return the first matching item', () => { + const summaryItems: SummaryItem[] = [ + { summaryType: 'count' }, + { summaryType: 'count' }, + ]; + + expect(getSummaryItemIndex(summaryItems, 'count')).toBe(0); + }); + + it('should prefer an earlier item matched by name over a later index match', () => { + const summaryItems: SummaryItem[] = [ + { name: '1', summaryType: 'count' }, + { summaryType: 'min', column: 'field' }, + ]; + + expect(getSummaryItemIndex(summaryItems, '1')).toBe(0); + }); + + it('should not match a numeric name against an item name of the same digits', () => { + const summaryItems: SummaryItem[] = [ + { name: '1', summaryType: 'count' }, + { name: 'other', summaryType: 'min' }, + ]; + + expect(getSummaryItemIndex(summaryItems, 1)).toBe(1); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_options.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_options.test.ts new file mode 100644 index 000000000000..fef9e122c7cf --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/__tests__/get_summary_options.test.ts @@ -0,0 +1,847 @@ +import { + afterEach, describe, expect, it, jest, +} from '@jest/globals'; +import type { CustomSummaryInfo } from '@js/ui/data_grid'; +import type dxDataGrid from '@js/ui/data_grid'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; +import errors from '@ts/ui/errors'; + +import type { + Aggregate, CustomAggregator, SummaryOptions, +} from '../../types'; +import type { GetSummaryOptionsArgs } from '../get_summary_options'; +import { getSummaryOptions as getSummaryOptionsUtil } from '../get_summary_options'; + +const component = {} as dxDataGrid; + +const makeColumn = (overrides: Partial = {}): Column => ({ ...overrides }); + +const createArgs = (overrides: Partial = {}): GetSummaryOptionsArgs => ({ + summary: {}, + sortByGroupSummaryInfo: [], + remoteOperations: {}, + getUpdatedItemData: (data) => data, + columnOption: () => undefined, + groupColumns: [], + component, + ...overrides, +}); + +const getSummaryOptions = (overrides: Partial = {}): SummaryOptions => { + const result = getSummaryOptionsUtil(createArgs(overrides)); + + if (!result) { + throw new Error('summary options are expected to be defined'); + } + + return result; +}; + +const callSelector = (aggregate: Aggregate, data: RawItemData): unknown => { + const { selector } = aggregate; + + if (typeof selector !== 'function') { + throw new Error('selector is expected to be a function'); + } + + return selector(data); +}; + +const getCustomAggregator = (aggregate: Aggregate): CustomAggregator => { + const { aggregator } = aggregate; + + if (!aggregator || typeof aggregator === 'string') { + throw new Error('aggregator is expected to be a custom aggregator'); + } + + return aggregator; +}; + +interface CustomSummaryCall { + component: unknown; + name?: string; + summaryProcess?: string; + groupIndex?: number; + totalValue?: unknown; + value?: unknown; + hasValue: boolean; +} + +interface CustomSummaryRecorder { + calculateCustomSummary: (options: CustomSummaryInfo) => void; + calls: CustomSummaryCall[]; +} + +const createCustomSummaryRecorder = ( + handler?: (options: CustomSummaryInfo) => void, +): CustomSummaryRecorder => { + const calls: CustomSummaryCall[] = []; + + const calculateCustomSummary = (options: CustomSummaryInfo): void => { + calls.push({ + component: options.component, + name: options.name, + summaryProcess: options.summaryProcess, + groupIndex: options.groupIndex, + totalValue: options.totalValue, + value: options.value, + hasValue: 'value' in options, + }); + + handler?.(options); + }; + + return { calculateCustomSummary, calls }; +}; + +describe('getSummaryOptions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('result', () => { + it('should return undefined when there are no summary items', () => { + expect(getSummaryOptionsUtil(createArgs())).toBeUndefined(); + }); + + it('should return undefined when both item arrays are empty', () => { + const args = createArgs({ summary: { groupItems: [], totalItems: [] } }); + + expect(getSummaryOptionsUtil(args)).toBeUndefined(); + }); + + it('should return options when there are only group items', () => { + const options = getSummaryOptions({ summary: { groupItems: [{ summaryType: 'count' }] } }); + + expect(options.groupAggregates).toHaveLength(1); + expect(options.totalAggregates).toHaveLength(0); + expect(typeof options.sortByGroups).toBe('function'); + }); + + it('should return options when there are only total items', () => { + const options = getSummaryOptions({ summary: { totalItems: [{ summaryType: 'count' }] } }); + + expect(options.groupAggregates).toHaveLength(0); + expect(options.totalAggregates).toHaveLength(1); + }); + + it('should keep the group and total item order', () => { + const options = getSummaryOptions({ + summary: { + groupItems: [ + { summaryType: 'min', column: 'a' }, + { summaryType: 'max', column: 'b' }, + ], + totalItems: [ + { summaryType: 'sum', column: 'c' }, + { summaryType: 'count' }, + ], + }, + }); + + expect(options.groupAggregates.map((item) => item.aggregator)).toEqual(['min', 'max']); + expect(options.totalAggregates.map((item) => item.aggregator)).toEqual(['sum', 'count']); + }); + }); + + describe('remote aggregates', () => { + it('should use remote group aggregates when both grouping and summary are remote', () => { + const options = getSummaryOptions({ + summary: { groupItems: [{ summaryType: 'sum', column: 'field' }] }, + remoteOperations: { grouping: true, summary: true }, + }); + + expect(options.groupAggregates).toStrictEqual([{ + selector: 'field', + summaryType: 'sum', + }]); + }); + + it('should use local group aggregates when only summary is remote', () => { + const options = getSummaryOptions({ + summary: { groupItems: [{ summaryType: 'sum', column: 'field' }] }, + remoteOperations: { summary: true }, + }); + + expect(options.groupAggregates[0].aggregator).toBe('sum'); + expect(typeof options.groupAggregates[0].selector).toBe('function'); + }); + + it('should use local group aggregates when only grouping is remote', () => { + const options = getSummaryOptions({ + summary: { groupItems: [{ summaryType: 'sum', column: 'field' }] }, + remoteOperations: { grouping: true }, + }); + + expect(options.groupAggregates[0].aggregator).toBe('sum'); + expect(typeof options.groupAggregates[0].selector).toBe('function'); + }); + + it('should use remote total aggregates when summary is remote', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'avg', column: 'field' }] }, + remoteOperations: { summary: true }, + }); + + expect(options.totalAggregates).toStrictEqual([{ + selector: 'field', + summaryType: 'avg', + }]); + }); + + it('should not use remote total aggregates when only grouping is remote', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'avg', column: 'field' }] }, + remoteOperations: { grouping: true }, + }); + + expect(options.totalAggregates[0].aggregator).toBe('avg'); + expect(typeof options.totalAggregates[0].selector).toBe('function'); + }); + + it('should use the count summary type by default', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{}] }, + remoteOperations: { summary: true }, + }); + + expect(options.totalAggregates).toStrictEqual([{ + selector: undefined, + summaryType: 'count', + }]); + }); + }); + + describe('aggregator', () => { + it('should use the count aggregator by default', () => { + const options = getSummaryOptions({ summary: { totalItems: [{}] } }); + + expect(options.totalAggregates[0].aggregator).toBe('count'); + }); + + it('should use the specified summary type as an aggregator', () => { + const options = getSummaryOptions({ summary: { totalItems: [{ summaryType: 'min' }] } }); + + expect(options.totalAggregates[0].aggregator).toBe('min'); + }); + }); + + describe('selector', () => { + it('should use calculateCellValue of the column bound to the column', () => { + const column = makeColumn({ + dataField: 'field', + calculateCellValue(this: Column, data: RawItemData): unknown { + return [this.dataField, data.field]; + }, + }); + + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'min', column: 'field' }] }, + columnOption: () => column, + }); + + expect(callSelector(options.totalAggregates[0], { field: 1 })).toEqual(['field', 1]); + }); + + it('should use the column dataField when the column has no calculateCellValue', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'min', column: 'alias' }] }, + columnOption: () => makeColumn({ dataField: 'nested.value' }), + }); + + expect(callSelector(options.totalAggregates[0], { nested: { value: 5 } })).toBe(5); + }); + + it('should use the summary item column when the column is not found', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'min', column: 'nested.value' }] }, + columnOption: () => undefined, + }); + + expect(callSelector(options.totalAggregates[0], { nested: { value: 7 } })).toBe(7); + }); + + it('should return the row itself when there is neither a column nor a dataField', () => { + const options = getSummaryOptions({ summary: { totalItems: [{ summaryType: 'count' }] } }); + const data = { field: 1 }; + + expect(callSelector(options.totalAggregates[0], data)).toBe(data); + }); + + describe('recalculateWhileEditing', () => { + const createGetUpdatedItemData = (): jest.Mock => jest.fn( + (data: RawItemData) => ({ ...data, field: 100 }), + ); + + it('should use updated data when recalculateWhileEditing is enabled', () => { + const getUpdatedItemData = createGetUpdatedItemData(); + + const options = getSummaryOptions({ + summary: { + recalculateWhileEditing: true, + totalItems: [{ summaryType: 'min', column: 'field' }], + }, + getUpdatedItemData, + }); + + expect(callSelector(options.totalAggregates[0], { field: 1 })).toBe(100); + expect(getUpdatedItemData).toHaveBeenCalledWith({ field: 1 }); + }); + + it('should not use updated data when recalculateWhileEditing is disabled', () => { + const getUpdatedItemData = createGetUpdatedItemData(); + + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'min', column: 'field' }] }, + getUpdatedItemData, + }); + + expect(callSelector(options.totalAggregates[0], { field: 1 })).toBe(1); + expect(getUpdatedItemData).not.toHaveBeenCalled(); + }); + }); + + describe('numeric conversion', () => { + const getConvertingSelectorValue = ( + summaryType: 'sum' | 'avg' | 'min', + value: unknown, + ): unknown => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType, column: 'field' }] }, + }); + + return callSelector(options.totalAggregates[0], { field: value }); + }; + + it('should convert values to numbers for the sum summary type', () => { + expect(getConvertingSelectorValue('sum', '10')).toBe(10); + }); + + it('should convert values to numbers for the avg summary type', () => { + expect(getConvertingSelectorValue('avg', '10')).toBe(10); + }); + + it('should not convert values for other summary types', () => { + expect(getConvertingSelectorValue('min', '10')).toBe('10'); + }); + + it('should keep undefined and null values as is', () => { + expect(getConvertingSelectorValue('sum', undefined)).toBeUndefined(); + expect(getConvertingSelectorValue('sum', null)).toBeNull(); + }); + + it('should convert not numeric values to NaN', () => { + expect(getConvertingSelectorValue('sum', 'text')).toBeNaN(); + }); + + it('should convert updated data values when recalculateWhileEditing is enabled', () => { + const options = getSummaryOptions({ + summary: { + recalculateWhileEditing: true, + totalItems: [{ summaryType: 'sum', column: 'field' }], + }, + getUpdatedItemData: () => ({ field: '20' }), + }); + + expect(callSelector(options.totalAggregates[0], { field: '1' })).toBe(20); + }); + }); + }); + + describe('skipEmptyValues', () => { + it('should use the summary item value', () => { + const options = getSummaryOptions({ + summary: { + skipEmptyValues: true, + totalItems: [{ summaryType: 'min', skipEmptyValues: false }], + }, + }); + + expect(options.totalAggregates[0].skipEmptyValues).toBe(false); + }); + + it('should fall back to the common value', () => { + const options = getSummaryOptions({ + summary: { + skipEmptyValues: true, + totalItems: [{ summaryType: 'min' }], + }, + }); + + expect(options.totalAggregates[0].skipEmptyValues).toBe(true); + }); + + it('should be undefined when neither value is specified', () => { + const options = getSummaryOptions({ summary: { totalItems: [{ summaryType: 'min' }] } }); + + expect(options.totalAggregates[0].skipEmptyValues).toBeUndefined(); + }); + }); + + describe('custom aggregator', () => { + it('should log E1026 and use a noop aggregator when calculateCustomSummary is not defined', () => { + const logSpy = jest.spyOn(errors, 'log').mockImplementation(() => undefined); + + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'custom', name: 'test' }] }, + }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('E1026'); + + const aggregator = getCustomAggregator(options.totalAggregates[0]); + + // NOTE: a noop calculation leaves the total value as it is + expect(aggregator.seed(0)).toBeUndefined(); + expect(aggregator.step(1, 2)).toBe(1); + expect(aggregator.finalize(3)).toBe(3); + }); + + it('should not log E1026 when calculateCustomSummary is defined', () => { + const logSpy = jest.spyOn(errors, 'log').mockImplementation(() => undefined); + const { calculateCustomSummary } = createCustomSummaryRecorder(); + + getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom' }], + }, + }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('should call calculateCustomSummary once on creation without a summary process', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder(); + + getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom', name: 'test' }], + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + component, + name: 'test', + summaryProcess: undefined, + hasValue: false, + }); + }); + + it('should pass the summary item name to calculateCustomSummary', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder(); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [ + { summaryType: 'custom', name: 'first' }, + { summaryType: 'custom', name: 'second' }, + ], + }, + }); + + getCustomAggregator(options.totalAggregates[0]).seed(0); + getCustomAggregator(options.totalAggregates[1]).seed(0); + + expect(calls.map((call) => call.name)).toEqual(['first', 'second', 'first', 'second']); + }); + + it('should start the summary process on seed', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder((options) => { + options.totalValue = 'seeded'; + }); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + groupItems: [{ summaryType: 'custom' }], + }, + }); + + const result = getCustomAggregator(options.groupAggregates[0]).seed(2); + + expect(result).toBe('seeded'); + expect(calls[1]).toMatchObject({ + summaryProcess: 'start', + groupIndex: 2, + totalValue: undefined, + hasValue: false, + }); + }); + + it('should calculate the summary process on step', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder((options) => { + options.totalValue = Number(options.totalValue) + Number(options.value); + }); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom' }], + }, + }); + + const result = getCustomAggregator(options.totalAggregates[0]).step(1, 2); + + expect(result).toBe(3); + expect(calls[1]).toMatchObject({ + summaryProcess: 'calculate', + totalValue: 1, + value: 2, + hasValue: true, + }); + }); + + it('should finalize the summary process on finalize', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder((options) => { + options.totalValue = `${String(options.totalValue)} finalized`; + }); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom' }], + }, + }); + + const result = getCustomAggregator(options.totalAggregates[0]).finalize('total'); + + expect(result).toBe('total finalized'); + expect(calls[1]).toMatchObject({ + summaryProcess: 'finalize', + totalValue: 'total', + hasValue: false, + }); + }); + + it('should remove the value passed to step from the next seed and finalize calls', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder(); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom' }], + }, + }); + + const aggregator = getCustomAggregator(options.totalAggregates[0]); + + aggregator.step(1, 2); + aggregator.finalize(1); + aggregator.step(1, 2); + aggregator.seed(0); + + expect(calls.map((call) => call.hasValue)).toEqual([false, true, false, true, false]); + }); + + it('should keep the group index from seed during the calculation', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder(); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + groupItems: [{ summaryType: 'custom' }], + }, + }); + + const aggregator = getCustomAggregator(options.groupAggregates[0]); + + aggregator.seed(1); + aggregator.step(undefined, 1); + aggregator.finalize(1); + + expect(calls.map((call) => call.groupIndex)).toEqual([undefined, 1, 1, 1]); + }); + + it('should calculate a custom summary through the whole aggregation cycle', () => { + const calculateCustomSummary = (options: CustomSummaryInfo): void => { + switch (options.summaryProcess) { + case 'start': + options.totalValue = 0; + break; + case 'calculate': + options.totalValue = Number(options.totalValue) + Number(options.value); + break; + case 'finalize': + options.totalValue = `total: ${String(options.totalValue)}`; + break; + default: + break; + } + }; + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + groupItems: [{ summaryType: 'custom' }], + }, + }); + + const aggregator = getCustomAggregator(options.groupAggregates[0]); + + let totalValue = aggregator.seed(0); + + expect(totalValue).toBe(0); + + totalValue = aggregator.step(totalValue, 1); + totalValue = aggregator.step(totalValue, 2); + + expect(totalValue).toBe(3); + expect(aggregator.finalize(totalValue)).toBe('total: 3'); + }); + + it('should use independent options for group and total items', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder((options) => { + options.totalValue = options.name; + }); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + groupItems: [{ summaryType: 'custom', name: 'group' }], + totalItems: [{ summaryType: 'custom', name: 'total' }], + }, + }); + + const groupResult = getCustomAggregator(options.groupAggregates[0]).seed(0); + const totalResult = getCustomAggregator(options.totalAggregates[0]).seed(0); + + expect(groupResult).toBe('group'); + expect(totalResult).toBe('total'); + expect(calls.map((call) => call.name)).toEqual(['group', 'total', 'group', 'total']); + }); + + it('should use a selector and skipEmptyValues for a custom aggregate', () => { + const { calculateCustomSummary } = createCustomSummaryRecorder(); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + skipEmptyValues: true, + totalItems: [{ summaryType: 'custom', column: 'field' }], + }, + }); + + expect(options.totalAggregates[0].skipEmptyValues).toBe(true); + expect(callSelector(options.totalAggregates[0], { field: 1 })).toBe(1); + }); + + it('should not create a custom aggregator for remote aggregates', () => { + const { calculateCustomSummary, calls } = createCustomSummaryRecorder(); + + const options = getSummaryOptions({ + summary: { + calculateCustomSummary, + totalItems: [{ summaryType: 'custom', column: 'field' }], + }, + remoteOperations: { summary: true }, + }); + + expect(calls).toHaveLength(0); + expect(options.totalAggregates).toStrictEqual([{ + selector: 'field', + summaryType: 'custom', + }]); + }); + }); + + describe('sortByGroups', () => { + const groupItems = [ + { summaryType: 'count' as const }, + { summaryType: 'sum' as const, column: 'value' }, + ]; + + it('should return undefined when there are no group items', () => { + const options = getSummaryOptions({ + summary: { totalItems: [{ summaryType: 'count' }] }, + sortByGroupSummaryInfo: [{ summaryItem: 'count' }], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + expect(options.sortByGroups()).toBeUndefined(); + }); + + it('should return an empty array when sortByGroupSummaryInfo is empty', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + expect(options.sortByGroups()).toEqual([]); + }); + + it('should return an empty array when sortByGroupSummaryInfo is not defined', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: undefined as unknown as GetSummaryOptionsArgs['sortByGroupSummaryInfo'], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + expect(options.sortByGroups()).toEqual([]); + }); + + it('should skip info items with an unknown summary item', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'unknown' }, { summaryItem: undefined }], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + expect(options.sortByGroups()).toEqual([]); + }); + + it('should use the group column specified in the info item', () => { + const columnOption = jest.fn( + () => makeColumn({ dataField: 'group', groupIndex: 1 }), + ); + + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'count', groupColumn: 'group' }], + groupColumns: [makeColumn({ dataField: 'other', groupIndex: 0 })], + columnOption, + }); + + const sortByGroups = options.sortByGroups(); + + expect(columnOption).toHaveBeenCalledWith('group'); + expect(sortByGroups).toHaveLength(2); + expect(sortByGroups?.[0]).toBeUndefined(); + expect(sortByGroups?.[1]).toHaveLength(1); + }); + + it('should use all group columns when the info item has no group column', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'count' }], + groupColumns: [ + makeColumn({ dataField: 'group1', groupIndex: 0 }), + makeColumn({ dataField: 'group2', groupIndex: 1 }), + ], + }); + + const sortByGroups = options.sortByGroups(); + + expect(sortByGroups).toHaveLength(2); + expect(sortByGroups?.[0]).toHaveLength(1); + expect(sortByGroups?.[1]).toHaveLength(1); + }); + + it('should skip columns without a group index', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'count' }], + groupColumns: [ + makeColumn({ dataField: 'notGrouped' }), + makeColumn({ dataField: 'group', groupIndex: 0 }), + ], + }); + + expect(options.sortByGroups()).toHaveLength(1); + }); + + it('should skip an info item whose group column is not found', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'count', groupColumn: 'unknown' }], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + columnOption: () => undefined, + }); + + expect(options.sortByGroups()).toEqual([]); + }); + + it('should collect several info items for the same group index', () => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [ + { summaryItem: 'count', sortOrder: 'desc' }, + { summaryItem: 'sum_value' }, + ], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + const sortByGroups = options.sortByGroups(); + + expect(sortByGroups).toHaveLength(1); + expect(sortByGroups?.[0]?.map((info) => info.desc)).toEqual([true, false]); + }); + + describe('desc', () => { + const getSortInfoDesc = ( + sortOrder: 'asc' | 'desc' | undefined, + columnSortOrder: 'asc' | 'desc' | undefined, + ): boolean | undefined => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem: 'count', sortOrder }], + groupColumns: [makeColumn({ + dataField: 'group', + groupIndex: 0, + sortOrder: columnSortOrder, + })], + }); + + return options.sortByGroups()?.[0]?.[0].desc; + }; + + it('should be true for the desc sort order', () => { + expect(getSortInfoDesc('desc', 'asc')).toBe(true); + }); + + it('should be false for the asc sort order', () => { + expect(getSortInfoDesc('asc', 'desc')).toBe(false); + }); + + it('should use the column sort order when the info item has none', () => { + expect(getSortInfoDesc(undefined, 'desc')).toBe(true); + expect(getSortInfoDesc(undefined, 'asc')).toBe(false); + }); + + it('should be false when neither sort order is defined', () => { + expect(getSortInfoDesc(undefined, undefined)).toBe(false); + }); + }); + + describe('selector', () => { + const getSortInfoSelector = ( + summaryItem: string | number, + ): ((data: RawItemData) => unknown) => { + const options = getSummaryOptions({ + summary: { groupItems }, + sortByGroupSummaryInfo: [{ summaryItem }], + groupColumns: [makeColumn({ dataField: 'group', groupIndex: 0 })], + }); + + const selector = options.sortByGroups()?.[0]?.[0].selector; + + if (!selector) { + throw new Error('sort info selector is expected to be defined'); + } + + return selector; + }; + + it('should return the group aggregate found by the summary item name', () => { + expect(getSortInfoSelector('sum_value')({ summary: [1, 2] })).toBe(2); + expect(getSortInfoSelector('count')({ summary: [1, 2] })).toBe(1); + }); + + it('should return the group aggregate found by the summary item index', () => { + expect(getSortInfoSelector(1)({ summary: [1, 2] })).toBe(2); + }); + + it('should fall back to the aggregates field', () => { + expect(getSortInfoSelector('sum_value')({ aggregates: [1, 2] })).toBe(2); + }); + + it('should return undefined when there are no aggregates', () => { + expect(getSortInfoSelector('sum_value')({})).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_group_aggregates.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_group_aggregates.ts new file mode 100644 index 000000000000..75f1ef775868 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_group_aggregates.ts @@ -0,0 +1,6 @@ +import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; + +export const getGroupAggregates = (data: RawItemData): unknown[] => { + const result = data.summary ?? data.aggregates ?? []; + return result as unknown[]; +}; diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_item_index.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_item_index.ts new file mode 100644 index 000000000000..0ce88556b30f --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_item_index.ts @@ -0,0 +1,37 @@ +import { isDefined } from '@ts/core/utils/m_type'; + +import type { SummaryItem } from '../types'; + +const getFullName = (summaryItem: SummaryItem): string | undefined => { + const { summaryType, column } = summaryItem; + + if (summaryType && column) { + return `${summaryType}_${column}`; + } + + return summaryType ?? column; +}; + +export const getSummaryItemIndex = ( + summaryItems?: SummaryItem[], + name?: string | number | null, +): number => { + if (!summaryItems?.length || !isDefined(name)) { + return -1; + } + + for (let index = 0; index < summaryItems.length; index += 1) { + const summaryItem = summaryItems[index]; + const isNameMatch = summaryItem.name === name + || index === name + || summaryItem.summaryType === name + || summaryItem.column === name + || getFullName(summaryItem) === name; + + if (isNameMatch) { + return index; + } + } + + return -1; +}; diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_options.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_options.ts new file mode 100644 index 000000000000..61f75bb62814 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils/get_summary_options.ts @@ -0,0 +1,236 @@ +import type { + CustomSummaryInfo, + SortByGroupSummaryInfoItem, + Summary, +} from '@js/ui/data_grid'; +import type dxDataGrid from '@js/ui/data_grid'; +import { noop } from '@ts/core/utils/m_common'; +import { compileGetter } from '@ts/core/utils/m_data'; +import { isDefined } from '@ts/core/utils/m_type'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { RawItemData, RemoteOperationsOptions } from '@ts/grids/grid_core/data_source_adapter/types'; +import errors from '@ts/ui/errors'; + +import type { + Aggregate, CustomAggregator, SortByGroups, SortInfo, SummaryItem, SummaryOptions, +} from '../types'; +import { getGroupAggregates } from './get_group_aggregates'; +import { getSummaryItemIndex } from './get_summary_item_index'; + +export interface GetSummaryOptionsArgs { + summary: Summary; + sortByGroupSummaryInfo?: SortByGroupSummaryInfoItem[]; + remoteOperations: RemoteOperationsOptions; + getUpdatedItemData: GetUpdatedItemData; + columnOption: GetColumnOption; + groupColumns: Column[]; + component: dxDataGrid; +} + +type MutableCustomSummaryInfo = { + -readonly [K in keyof CustomSummaryInfo]: CustomSummaryInfo[K]; +}; + +type Selector = (data: RawItemData) => unknown; + +type GetColumnOption = (id: string | number) => Column | undefined; + +type GetUpdatedItemData = (data: RawItemData) => RawItemData; + +const getSelector = ( + column: Column | undefined, + summaryItem: SummaryItem, + recalculateWhileEditing: boolean, + getUpdatedItemData: GetUpdatedItemData, +): Selector => { + const getCellValue: Selector = column?.calculateCellValue + ? column.calculateCellValue.bind(column) + : compileGetter(column ? column.dataField : summaryItem.column); + + const getCellValueWithEditing = recalculateWhileEditing + ? (data: RawItemData): unknown => getCellValue(getUpdatedItemData(data)) + : getCellValue; + + const getCellValueWithConversion = summaryItem.summaryType === 'avg' || summaryItem.summaryType === 'sum' + ? (data: RawItemData): unknown => { + const value = getCellValueWithEditing(data); + return isDefined(value) ? Number(value) : value; + } + : getCellValueWithEditing; + + return getCellValueWithConversion; +}; + +const createCustomAggregator = ( + calculateCustomSummary: NonNullable, + component: dxDataGrid, + name: string | undefined, +): CustomAggregator => { + // NOTE: the first call is intentionally made before summaryProcess is set + const options = { + component, + name, + } as MutableCustomSummaryInfo; + + calculateCustomSummary(options); + options.summaryProcess = 'calculate'; + + return { + seed: (groupIndex): unknown => { + options.summaryProcess = 'start'; + options.totalValue = undefined; + options.groupIndex = groupIndex; + delete options.value; + calculateCustomSummary(options); + return options.totalValue; + }, + step: (totalValue, value): unknown => { + options.summaryProcess = 'calculate'; + options.totalValue = totalValue; + options.value = value; + calculateCustomSummary(options); + return options.totalValue; + }, + finalize: (totalValue): unknown => { + options.summaryProcess = 'finalize'; + options.totalValue = totalValue; + delete options.value; + calculateCustomSummary(options); + return options.totalValue; + }, + }; +}; + +const createLocalAggregate = (summaryItem: SummaryItem, args: GetSummaryOptionsArgs): Aggregate => { + const { + recalculateWhileEditing, + skipEmptyValues: commonSkipEmptyValues, + } = args.summary; + + const aggregator = summaryItem.summaryType ?? 'count'; + + const column = isDefined(summaryItem.column) + ? args.columnOption(summaryItem.column) + : undefined; + + const selector = getSelector( + column, + summaryItem, + Boolean(recalculateWhileEditing), + args.getUpdatedItemData, + ); + + const skipEmptyValues = summaryItem.skipEmptyValues ?? commonSkipEmptyValues; + + if (aggregator === 'custom') { + let { calculateCustomSummary } = args.summary; + + if (!calculateCustomSummary) { + errors.log('E1026'); + calculateCustomSummary = noop; + } + + const customAggregator = createCustomAggregator( + calculateCustomSummary, + args.component, + summaryItem.name, + ); + + return { + selector, + aggregator: customAggregator, + skipEmptyValues, + }; + } + + return { + selector, + aggregator, + skipEmptyValues, + }; +}; + +const getAggregates = ( + summaryItems: SummaryItem[], + remoteOperations: boolean, + args: GetSummaryOptionsArgs, +): Aggregate[] => { + if (remoteOperations) { + return summaryItems.map((summaryItem) => ({ + selector: summaryItem.column, + summaryType: summaryItem.summaryType ?? 'count', + })); + } + + return summaryItems.map((summaryItem) => createLocalAggregate(summaryItem, args)); +}; + +const getSummarySortByGroups = (args: GetSummaryOptionsArgs): SortByGroups | undefined => { + const { + summary, + groupColumns, + columnOption, + sortByGroupSummaryInfo, + } = args; + + if (!summary.groupItems?.length) { + return undefined; + } + + const sortByGroups: SortByGroups = []; + + (sortByGroupSummaryInfo ?? []).forEach(({ groupColumn, sortOrder, summaryItem }) => { + const summaryItemIndex = getSummaryItemIndex(summary.groupItems, summaryItem); + + if (summaryItemIndex < 0) { + return; + } + + const selector: SortInfo['selector'] = (data) => getGroupAggregates(data)[summaryItemIndex]; + const normalizedGroupColumns = isDefined(groupColumn) + ? [columnOption(groupColumn)] + : groupColumns; + + normalizedGroupColumns + .forEach((column) => { + const groupIndex = column?.groupIndex; + + if (!isDefined(groupIndex)) { + return; + } + + sortByGroups[groupIndex] ??= []; + sortByGroups[groupIndex]?.push({ + selector, + desc: (sortOrder ?? column?.sortOrder) === 'desc', + }); + }); + }); + + return sortByGroups; +}; + +export const getSummaryOptions = (args: GetSummaryOptionsArgs): SummaryOptions | undefined => { + const { summary, remoteOperations } = args; + + const groupAggregates = getAggregates( + summary.groupItems ?? [], + Boolean(remoteOperations.grouping && remoteOperations.summary), + args, + ); + const totalAggregates = getAggregates( + summary.totalItems ?? [], + Boolean(remoteOperations.summary), + args, + ); + + if (groupAggregates.length || totalAggregates.length) { + return { + groupAggregates, + totalAggregates, + sortByGroups: (): SortByGroups | undefined => getSummarySortByGroups(args), + }; + } + + return undefined; +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts index ef001c801ff5..a5bbd4dcbabf 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -244,7 +244,7 @@ export interface Views { } export interface EditingControllerRequired { - _editingController: EditingController; + editingController: EditingController; } type ViewTypes = { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js index 239995a2ec2a..050b80f43d7b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js @@ -11449,32 +11449,6 @@ QUnit.module('Summary', { assert.deepEqual(headerFilterItems.map(function(item) { return item.value; }), [15, 18, 19, 20, 25], 'items are sorted'); }); - QUnit.test('findSummaryItem by custom name', function(assert) { - const summaryItems = [{ - name: 'testCount1', - summaryType: 'count' - }, { - name: 'testCount2', - summaryType: 'count' - }, { - name: 'testMin', - summaryType: 'min', - column: 'testField' - }]; - - // act - this.setupDataGridModules(); - this.clock.tick(10); - - // assert - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 'count'), 0, 'find by summaryType'); - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 'testField'), 2, 'find by column name'); - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 'min_testField'), 2, 'find by summary type+column name'); - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 'testCount2'), 1, 'find by name'); - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 1), 1, 'find by summary item index'); - assert.strictEqual(this.dataController._findSummaryItem(summaryItems, 'test3'), -1, 'find by wrong name'); - }); - QUnit.test('group sorting by summary sortOrder desc', function(assert) { this.options = { columns: ['name', 'age'], diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js index 14c0307d7d31..4a78843428ef 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js @@ -58,10 +58,13 @@ const createDataSource = function(options) { return items; }; + dataSourceAdapter.setSummary = (summary) => { + dataSourceAdapter.getSummary = () => summary; + }; + return dataSourceAdapter; }; - QUnit.module('Grid DataSource', { beforeEach: function() { this.clock = sinon.useFakeTimers(); @@ -3019,7 +3022,7 @@ QUnit.module('Remote group paging', { }); const loadingChanged = sinon.stub(); - dataSource.summary({ + dataSource.setSummary({ groupAggregates: [{ summaryType: 'count' }], @@ -6019,7 +6022,7 @@ QUnit.module('Summary', { QUnit.test('Total summary without grouping', function(assert) { const dataSource = this.createDataSource({}); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6040,7 +6043,7 @@ QUnit.module('Summary', { group: 'this' }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6068,7 +6071,7 @@ QUnit.module('Summary', { } }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6105,7 +6108,7 @@ QUnit.module('Summary', { } }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6137,7 +6140,7 @@ QUnit.module('Summary', { remoteOperations: false }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6166,7 +6169,7 @@ QUnit.module('Summary', { }) }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: 'count' }, { @@ -6303,7 +6306,7 @@ QUnit.module('Cache', { remoteOperations: true }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ selector: 'this', aggregator: 'sum' @@ -6470,7 +6473,7 @@ QUnit.module('Cache', { } }); - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ selector: 'this', aggregator: 'count' @@ -6592,7 +6595,7 @@ QUnit.module('Cache', { let stepCount = 0; - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: { seed: 0, @@ -6629,7 +6632,7 @@ QUnit.module('Cache', { let stepCount = 0; - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: { seed: 0, @@ -6669,7 +6672,7 @@ QUnit.module('Cache', { let stepCount = 0; - dataSource.summary({ + dataSource.setSummary({ totalAggregates: [{ aggregator: { seed: 0,