diff --git a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts index f20cdfc31070..8c70878e74b9 100644 --- a/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts +++ b/e2e/testcafe-devextreme/tests/dataGrid/common/scrolling.ts @@ -1495,6 +1495,54 @@ test.meta({ runInTheme: Themes.genericLight })('New virtual mode. Navigation to }); }); +// T1284002 +test('Last group should not disappear after collapsing another subgroup with virtual scrolling, local grouping and remote operations (T1284002)', async (t) => { + const dataGrid = new DataGrid('#container'); + await t.expect(dataGrid.isReady()).ok(); + + await dataGrid.apiCollapseRow(['Cell phones', 'Touch Screen Phones']); + + const visibleRows = await dataGrid.apiGetVisibleRows(); + const dataRows = visibleRows.filter((r) => r.rowType === 'data'); + + await t.expect(dataRows.length).eql(2, 'Computers category should still show 2 data rows'); +}).before(async () => createWidget('dxDataGrid', { + height: 500, + dataSource: [ + { + Id: 1, Category: 'Cell phones', Subcategory: 'Touch Screen Phones', Store: 'Europe Online Store', Date: '2024-01-10', + }, + { + Id: 2, Category: 'Cell phones', Subcategory: 'Touch Screen Phones', Store: 'Europe Online Store', Date: '2024-02-15', + }, + { + Id: 3, Category: 'Computers', Subcategory: 'Computers Accessories', Store: 'North America Reseller', Date: '2024-03-20', + }, + { + Id: 4, Category: 'Computers', Subcategory: 'Computers Accessories', Store: 'North America Online Store', Date: '2024-04-25', + }, + ], + keyExpr: 'Id', + remoteOperations: { + filtering: true, + sorting: true, + paging: true, + }, + scrolling: { + mode: 'virtual', + }, + grouping: { + autoExpandAll: true, + }, + columns: [ + { dataField: 'Id', dataType: 'number' }, + { dataField: 'Category', dataType: 'string', groupIndex: 0 }, + { dataField: 'Subcategory', dataType: 'string', groupIndex: 1 }, + { dataField: 'Store', dataType: 'string', groupIndex: 2 }, + { dataField: 'Date', dataType: 'date', format: 'yyyy-MM-dd' }, + ], +})); + // T1152498 // TODO: fix unstable tests // ['infinite', 'virtual'].forEach((scrollingMode) => { diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.helpers.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.helpers.ts new file mode 100644 index 000000000000..53aef1747266 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.helpers.ts @@ -0,0 +1,101 @@ +import { GroupingHelper, updateGroupOffsets } from '../m_grouping_expanded'; +import type { DataItem, GroupInfoData, GroupItemData } from '../types'; + +export interface GroupConfig { + key: string; + count: number; + serverCount?: number; +} + +/** + * Test helper that simulates the grid's expand/collapse flow for grouped data. + * + * Constructed from a configuration array — each entry generates a group + * with `count` leaf items (ids like `"A0"`, `"A1"`, …). + * When `serverCount` is provided it is used as the group's total instead of + * the visible leaf count, mirroring the real server-side paging scenario. + * + * - `collapse(path)` — nullifies `group.items` and registers the group as collapsed. + * - `expand(path)` — restores the saved children and marks the group as expanded. + * + * After each operation all group offsets are recalculated via `updateGroupOffsets`, + * exactly matching the `changeRowExpand` flow in the production code. + */ + +export class GroupingTestHelper { + public readonly grouping: GroupingHelper; + + public readonly items: DataItem[]; + + private readonly groupsByKey: Map; + + private readonly leafCounts: Map; + + /** Saved children snapshots so expand can restore them. */ + private readonly savedChildren = new Map(); + + constructor(groups: GroupConfig[]) { + this.grouping = new GroupingHelper({ option: (): undefined => undefined }); + this.groupsByKey = new Map(); + this.leafCounts = new Map(); + + // Initialize group items + this.items = groups.map(({ key, count, serverCount }) => { + const items = Array.from({ length: count }, (_, i) => ({ id: `${key}${i}` })); + const group = { key, items } as unknown as GroupItemData; + this.groupsByKey.set(key, group); + this.leafCounts.set(key, serverCount ?? count); + + return group; + }); + } + + public collapse(path: string[]): void { + const key = path[0]; + const group = this.groupsByKey.get(key); + const count = this.leafCounts.get(key) ?? 0; + + this.simulateChangeRowExpand(path, count); + + if (group) { + this.savedChildren.set(key, group.items); + group.items = null; + } + } + + public expand(path: string[]): void { + const groupInfo = this.grouping.findGroupInfo(path); + const count = groupInfo ? groupInfo.count : 0; + + this.simulateChangeRowExpand(path, count); + + const key = path[0]; + const group = this.groupsByKey.get(key); + if (group) { + group.items = this.savedChildren.get(key) ?? []; + } + } + + private simulateChangeRowExpand( + path: unknown[], + count: number, + ): void { + const groupInfo = this.grouping.findGroupInfo(path); + + const pendingGroupInfo: GroupInfoData = { + offset: groupInfo ? groupInfo.offset : -1, + path: groupInfo ? groupInfo.path : path, + isExpanded: groupInfo ? !groupInfo.isExpanded : false, + count, + }; + + updateGroupOffsets(this.grouping, this.items, [], 0, pendingGroupInfo); + + if (groupInfo) { + groupInfo.isExpanded = !groupInfo.isExpanded; + groupInfo.count = count; + } else if (pendingGroupInfo.offset >= 0) { + this.grouping.addGroupInfo(pendingGroupInfo); + } + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.mock.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.mock.ts new file mode 100644 index 000000000000..58df5a76d866 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.mock.ts @@ -0,0 +1,8 @@ +import { GroupingHelper } from '../m_grouping_expanded'; + +/** Subclass that exposes the protected handleDataLoading method for testing. */ +export class GroupingHelperMock extends GroupingHelper { + public testHandleDataLoading(options: unknown): void { + this.handleDataLoading(options); + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.test.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.test.ts new file mode 100644 index 000000000000..607021b6216a --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/__tests__/m_grouping_expanded.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { GroupConfig } from './m_grouping_expanded.helpers'; +import { GroupingTestHelper } from './m_grouping_expanded.helpers'; +import { GroupingHelperMock } from './m_grouping_expanded.mock'; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- +const LOCAL_GROUPS: GroupConfig[] = [ + { key: 'A', count: 3 }, + { key: 'B', count: 2 }, + { key: 'C', count: 1 }, +]; + +const SERVER_GROUPS: GroupConfig[] = [ + { key: 'A', count: 3, serverCount: 100 }, + { key: 'B', count: 2, serverCount: 50 }, + { key: 'C', count: 1, serverCount: 30 }, +]; + +// --------------------------------------------------------------------------- +// Tests — local data (count matches visible items) +// --------------------------------------------------------------------------- + +describe('groupInfo state after changeRowExpand flow', () => { + describe('local data: collapsing a single new group', () => { + it('should register group A with offset 0 and isExpanded false', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['A']); + + expect(helper.grouping.findGroupInfo(['A'])).toEqual({ + offset: 0, count: 3, path: ['A'], isExpanded: false, + }); + }); + + it('should register group C with offset 5', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['C']); + + expect(helper.grouping.findGroupInfo(['C'])).toEqual({ + offset: 5, count: 1, path: ['C'], isExpanded: false, + }); + }); + }); + + describe('local data: collapsing new groups top-to-bottom (A → B → C)', () => { + it('should assign correct offsets at each step', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false }); + + helper.collapse(['B']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 2, isExpanded: false }); + + helper.collapse(['C']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 2, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 1, isExpanded: false }); + }); + }); + + describe('local data: collapsing new groups bottom-to-top (C → B → A)', () => { + it('should assign correct offsets at each step', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['C']); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 1, isExpanded: false }); + + helper.collapse(['B']); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 2, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 1, isExpanded: false }); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 2, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 1, isExpanded: false }); + }); + }); + + describe('local data: expanding and re-collapsing', () => { + it('should toggle isExpanded to true on expand', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ isExpanded: false }); + + helper.expand(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ isExpanded: true }); + }); + + it('should preserve count when expanding', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['B']); + helper.expand(['B']); + + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ count: 2, isExpanded: true }); + }); + + it('should toggle isExpanded back to false (collapse → expand → collapse)', () => { + const helper = new GroupingTestHelper(LOCAL_GROUPS); + + helper.collapse(['A']); + helper.expand(['A']); + helper.collapse(['A']); + + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ + offset: 0, count: 3, isExpanded: false, + }); + }); + }); + + describe('server-side data: collapsing a single group uses server count', () => { + it('should store server count, not visible item count', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + + expect(helper.grouping.findGroupInfo(['A'])).toEqual({ + offset: 0, count: 100, path: ['A'], isExpanded: false, + }); + }); + }); + + describe('server-side data: collapsing top-to-bottom (A → B → C)', () => { + it('should assign offsets based on server counts', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false }); + + helper.collapse(['B']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false }); + + helper.collapse(['C']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 150, count: 30, isExpanded: false }); + }); + }); + + describe('server-side data: collapsing bottom-to-top (C → B → A)', () => { + it('should assign offsets based on server counts', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['C']); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 30, isExpanded: false }); + + helper.collapse(['B']); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 50, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 53, count: 30, isExpanded: false }); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 150, count: 30, isExpanded: false }); + }); + }); + + describe('server-side data: expand and re-collapse', () => { + it('should preserve server count through expand/collapse cycle', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: false }); + + helper.expand(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: true }); + + helper.collapse(['A']); + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: false }); + }); + }); + + describe('server-side data: skip a middle group', () => { + it('should correctly offset C past collapsed A (B stays expanded)', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + helper.collapse(['C']); + + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100 }); + // B is expanded, its 2 visible items sit between A and C + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 102, count: 30 }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Tests — isPending logic +// --------------------------------------------------------------------------- + +describe('isPending logic', () => { + describe('updateGroupOffsets: expanding a known collapsed group', () => { + it('should keep subsequent collapsed group at correct offset when expanding the first group', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + helper.collapse(['B']); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 100 }); + + helper.expand(['A']); + + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ isExpanded: true }); + // B.offset must stay at 100 (A.count), not drop to 1 + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false }); + }); + + it('should keep surrounding offsets correct when expanding a middle group', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['A']); + helper.collapse(['B']); + helper.collapse(['C']); + + helper.expand(['B']); + + expect(helper.grouping.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false }); + expect(helper.grouping.findGroupInfo(['B'])).toMatchObject({ isExpanded: true }); + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 150, count: 30, isExpanded: false }); + }); + + it('should recalculate C offset as groups are expanded one by one (collapse C→B→A, expand B→A)', () => { + const helper = new GroupingTestHelper(SERVER_GROUPS); + + helper.collapse(['C']); + helper.collapse(['B']); + helper.collapse(['A']); + + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 150 }); + + helper.expand(['B']); + // A still collapsed (100) + B being expanded still counts as 50 + C + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 150 }); + + helper.expand(['A']); + // A still uses count (100) + B expanded with 2 visible items → C at 102 + expect(helper.grouping.findGroupInfo(['C'])).toMatchObject({ offset: 102 }); + }); + }); + + describe('handleDataLoading: expandCorrection', () => { + it('should widen the load window for collapsed groups after the expanding one', () => { + const grouping = new GroupingHelperMock({ option: (): undefined => undefined }); + + // Group A was just expanded — isPending + isExpanded, count=100 + grouping.addGroupInfo({ + offset: 0, count: 100, isExpanded: true, isPending: true, path: ['A'], + }); + // Group B is collapsed right after A: B.offset = A.count + grouping.addGroupInfo({ + offset: 100, count: 30, isExpanded: false, path: ['B'], + }); + + const options: Record = { + storeLoadOptions: { skip: 0, take: 50 }, + loadOptions: { group: [{ selector: 'category' }] }, + }; + grouping.testHandleDataLoading(options); + + // expandCorrection = A.count (100) shifts boundary from 51 to 151, + // so B (offset = A.count = 100) falls inside the load window + expect(options.collapsedGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['B'], count: 30 }), + ]), + ); + expect(options.collapsedItemsCount).toBe(30); + }); + + it('should NOT widen the load window for previously expanded groups (not pending)', () => { + const grouping = new GroupingHelperMock({ option: (): undefined => undefined }); + + // Group A is expanded but NOT pending (normal steady state) + grouping.addGroupInfo({ + offset: 0, count: 100, isExpanded: true, path: ['A'], + }); + // Group B is collapsed right after A: B.offset = A.count + grouping.addGroupInfo({ + offset: 100, count: 30, isExpanded: false, path: ['B'], + }); + + const options: Record = { + storeLoadOptions: { skip: 0, take: 50 }, + loadOptions: { group: [{ selector: 'category' }] }, + }; + grouping.testHandleDataLoading(options); + + // Without expandCorrection boundary stays at 51; + // B (offset = A.count = 100) is past it and therefore excluded + expect(options.collapsedGroups).toEqual([]); + expect(options.collapsedItemsCount).toBe(0); + }); + }); + + describe('handleDataLoading: isPending cleanup', () => { + it('should delete isPending from an expanded group after processing', () => { + const grouping = new GroupingHelperMock({ option: (): undefined => undefined }); + + grouping.addGroupInfo({ + offset: 0, count: 100, isExpanded: true, isPending: true, path: ['A'], + }); + + const options: Record = { + storeLoadOptions: { skip: 0, take: 50 }, + loadOptions: { group: [{ selector: 'category' }] }, + }; + grouping.testHandleDataLoading(options); + + expect(grouping.findGroupInfo(['A'])?.isPending).toBeUndefined(); + }); + + it('should delete isPending from a collapsed group after processing', () => { + const grouping = new GroupingHelperMock({ option: (): undefined => undefined }); + + grouping.addGroupInfo({ + offset: 0, count: 10, isExpanded: false, isPending: true, path: ['A'], + }); + + const options: Record = { + storeLoadOptions: { skip: 0, take: 50 }, + loadOptions: { group: [{ selector: 'category' }] }, + }; + grouping.testHandleDataLoading(options); + + expect(grouping.findGroupInfo(['A'])?.isPending).toBeUndefined(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_core.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_core.ts index e9fa4e039c53..477ac16c7332 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_core.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_core.ts @@ -4,6 +4,7 @@ import { when } from '@js/core/utils/deferred'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import gridCore from '../m_core'; +import type { GroupInfoData } from './types'; export function createOffsetFilter(path, storeLoadOptions, lastLevelOnly?) { const groups = normalizeSortingInfo(storeLoadOptions.group); @@ -206,7 +207,7 @@ export class GroupingHelper { groupsInfo.sort((a, b) => a.offset - b.offset); } - public findGroupInfo(path) { + public findGroupInfo(path): GroupInfoData | undefined { const that = this; let groupInfo; let groupsInfo = that._groupsInfo; diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_expanded.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_expanded.ts index 86133e053ac0..49b4dbf835c2 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_expanded.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_expanded.ts @@ -9,6 +9,7 @@ import { each } from '@js/core/utils/iterator'; import dataGridCore from '../m_core'; import { createGroupFilter } from '../m_utils'; import { createOffsetFilter, GroupingHelper as GroupingHelperCore } from './m_grouping_core'; +import type { DataItem, GroupInfoData, GroupItemData } from './types'; const loadTotalCount = function (dataSource, options) { // @ts-expect-error @@ -21,11 +22,6 @@ const loadTotalCount = function (dataSource, options) { return d; }; -/// #DEBUG -export { loadTotalCount }; - -/// #ENDDEBUG - const foreachCollapsedGroups = function (that, callback, updateOffsets?) { return that.foreachGroups((groupInfo) => { if (!groupInfo.isExpanded) { @@ -126,33 +122,75 @@ const pathEquals = function (path1, path2) { return true; }; -const updateGroupOffsets = function (that, items, path, offset, additionalGroupInfo?) { - if (!items) return; +const isGroupItem = (item: DataItem): item is GroupItemData => ( + 'key' in item && (item as GroupItemData).items !== undefined +); + +/** + * Recalculates flat-data offsets for all known groups after an expand/collapse operation. + * Walks the item tree recursively, updating stored offsets so that subsequent + * paging and rendering use correct row positions. + * The optional pendingGroupInfo carries offset data for a group whose expand state + * is about to change but hasn't been persisted yet. + */ +const updateGroupOffsets = ( + that: GroupingHelperCore, + items: DataItem[] | null, + path: unknown[], + offset: number, + pendingGroupInfo?: GroupInfoData, +): number => { + if (!items) { + return offset; + } - for (let i = 0; i < items.length; i++) { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < items.length; i += 1) { const item = items[i]; - if ('key' in item && item.items !== undefined) { + if (isGroupItem(item)) { path.push(item.key); - if (additionalGroupInfo && pathEquals(additionalGroupInfo.path, path) && !item.isContinuation) { - additionalGroupInfo.offset = offset; - } + + const isPendingGroup = pendingGroupInfo && pathEquals(pendingGroupInfo.path, path); const groupInfo = that.findGroupInfo(path); + + // Only update offset for the first occurrence; continuation parts keep the original offset + if (isPendingGroup && !item.isContinuation) { + pendingGroupInfo.offset = offset; + } + if (groupInfo && !item.isContinuation) { groupInfo.offset = offset; } + if (groupInfo && !groupInfo.isExpanded) { + // Collapsed group: skip over all its children in the flat index + // eslint-disable-next-line no-param-reassign offset += groupInfo.count; - } else { - offset = updateGroupOffsets(that, item.items, path, offset, additionalGroupInfo); + } else if (isPendingGroup && !pendingGroupInfo.isExpanded) { + // Pending collapse: the group isn't persisted yet, but we already need its count + // eslint-disable-next-line no-param-reassign + offset += pendingGroupInfo.count; + } else if (item.items) { + // Expanded group: recurse into children to accumulate their offsets + // eslint-disable-next-line no-param-reassign + offset = updateGroupOffsets(that, item.items, path, offset, pendingGroupInfo); } + path.pop(); } else { - offset++; + // eslint-disable-next-line no-param-reassign + offset += 1; } } + return offset; }; +/// #DEBUG +export { loadTotalCount, updateGroupOffsets }; + +/// #ENDDEBUG + const removeGroupLoadOption = function (storeLoadOptions, loadOptions) { if (loadOptions.group) { const groups = dataGridCore.normalizeSortingInfo(loadOptions.group); @@ -225,16 +263,32 @@ export class GroupingHelper extends GroupingHelperCore { loadOptions.take++; } - // @ts-expect-error - foreachCollapsedGroups(that, (groupInfo) => { - if (groupInfo.offset >= loadOptions.skip + loadOptions.take + skipCorrection) { - return false; - } if (groupInfo.offset >= loadOptions.skip + skipCorrection && groupInfo.count) { - skipCorrection += groupInfo.count - 1; - collapsedGroups.push(groupInfo); - collapsedItemsCount += groupInfo.count; + let loadEndOffset: number = loadOptions.skip + loadOptions.take; + + this.foreachGroups((groupInfo: GroupInfoData) => { + // loadEndOffset should be corrected for expanding group to properly calculate group filter + if (groupInfo.isPending && groupInfo.isExpanded && groupInfo.count) { + loadEndOffset += groupInfo.count; } - }); + + if (!groupInfo.isExpanded) { + if (groupInfo.offset >= loadEndOffset + skipCorrection) { + return false; + } + + if (groupInfo.offset >= loadOptions.skip + skipCorrection && groupInfo.count) { + skipCorrection += groupInfo.count - 1; + collapsedGroups.push(groupInfo); + collapsedItemsCount += groupInfo.count; + } + } + + if (groupInfo.isPending) { + delete groupInfo.isPending; + } + + return undefined; + }, false, false, undefined, true); each(collapsedGroups, function () { loadOptions.filter = createNotGroupFilter(this.path, loadOptions, group); @@ -322,10 +376,12 @@ export class GroupingHelper extends GroupingHelperCore { private changeRowExpand(path) { const that = this; const dataSource = that._dataSource; - const beginPageIndex = dataSource.beginPageIndex ? dataSource.beginPageIndex() : dataSource.pageIndex(); + const beginPageIndex = dataSource.beginPageIndex + ? dataSource.beginPageIndex() + : dataSource.pageIndex(); const dataSourceItems = dataSource.items(); const offset = correctSkipLoadOption(that, beginPageIndex * dataSource.pageSize()); - let groupInfo = that.findGroupInfo(path); + const groupInfo = that.findGroupInfo(path); let groupCountQuery; if (groupInfo && !groupInfo.isExpanded) { @@ -341,23 +397,23 @@ export class GroupingHelper extends GroupingHelperCore { } return when(groupCountQuery).done((count) => { - // eslint-disable-next-line radix - count = parseInt(count.length ? count[0] : count); + const normalizedCount = parseInt(count.length ? count[0] : count, 10); + const pendingGroupInfo: GroupInfoData = { + offset: groupInfo ? groupInfo.offset : -1, + path: groupInfo ? groupInfo.path : path, + isExpanded: groupInfo ? !groupInfo.isExpanded : false, + count: normalizedCount, + isPending: true, + }; + + updateGroupOffsets(that, dataSourceItems, [], offset, pendingGroupInfo); + if (groupInfo) { - updateGroupOffsets(that, dataSourceItems, [], offset); groupInfo.isExpanded = !groupInfo.isExpanded; - groupInfo.count = count; - } else { - groupInfo = { - offset: -1, - count, - path, - isExpanded: false, - }; - updateGroupOffsets(that, dataSourceItems, [], offset, groupInfo); - if (groupInfo.offset >= 0) { - that.addGroupInfo(groupInfo); - } + groupInfo.count = normalizedCount; + groupInfo.isPending = true; + } else if (pendingGroupInfo.offset >= 0) { + that.addGroupInfo(pendingGroupInfo); } that.updateTotalItemsCount(); }).fail(function () { diff --git a/packages/devextreme/js/__internal/grids/data_grid/grouping/types.ts b/packages/devextreme/js/__internal/grids/data_grid/grouping/types.ts new file mode 100644 index 000000000000..9764f07915e3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/grouping/types.ts @@ -0,0 +1,16 @@ +export interface GroupItemData { + key: unknown; + items: GroupItemData[] | null; + isContinuation?: boolean; + count?: number; +} + +export type DataItem = GroupItemData | Record; + +export interface GroupInfoData { + offset: number; + count: number; + path: unknown[]; + isExpanded: boolean; + isPending?: boolean; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts index d41c314c4986..c2717eba9ca3 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/virtual_scrolling/m_virtual_scrolling.ts @@ -762,7 +762,7 @@ export const data = (Base: ModuleType) => class VirtualScrolling const { rowType } = item; const itemCountable = isItemCountableByDataSource(item, dataSource); - const isNextGroupItem = rowType === 'group' && (prevCountable || itemCountable || (prevRowType !== 'group' && currentIndex > 0)); + const isNextGroupItem = rowType === 'group' && (prevCountable || (prevRowType !== 'group' && currentIndex > 0)); const isNextDataItem = rowType === 'data' && itemCountable && (prevCountable || prevRowType !== 'group'); if (!item.isNewRow && isDefined(prevCountable)) {