Skip to content

Commit 70ad332

Browse files
committed
DataGrid: fix calculate group offsets on collapsing a new group (T1325181)
1 parent 3c1eada commit 70ad332

4 files changed

Lines changed: 429 additions & 34 deletions

File tree

packages/devextreme/js/__internal/grids/data_grid/grouping/m_grouping_core.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { when } from '@js/core/utils/deferred';
44
import gridCoreUtils from '@ts/grids/grid_core/m_utils';
55

66
import gridCore from '../m_core';
7+
import type { GroupInfoData } from './types';
78

89
export function createOffsetFilter(path, storeLoadOptions, lastLevelOnly?) {
910
const groups = normalizeSortingInfo(storeLoadOptions.group);
@@ -206,7 +207,7 @@ export class GroupingHelper {
206207
groupsInfo.sort((a, b) => a.offset - b.offset);
207208
}
208209

209-
public findGroupInfo(path) {
210+
public findGroupInfo(path): GroupInfoData | undefined {
210211
const that = this;
211212
let groupInfo;
212213
let groupsInfo = that._groupsInfo;
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
3+
import { GroupingHelper as GroupingHelperCore } from './m_grouping_core';
4+
import { updateGroupOffsets } from './m_grouping_expanded';
5+
import type { DataItem, GroupInfoData, GroupItemData } from './types';
6+
7+
// ---------------------------------------------------------------------------
8+
// Helpers
9+
// ---------------------------------------------------------------------------
10+
11+
function findGroup(
12+
items: DataItem[],
13+
key: unknown,
14+
): GroupItemData | undefined {
15+
for (const item of items) {
16+
if ('key' in item && (item as GroupItemData).key === key) {
17+
return item as GroupItemData;
18+
}
19+
}
20+
return undefined;
21+
}
22+
23+
function countLeafItems(items: DataItem[] | null): number {
24+
if (!items) return 0;
25+
let count = 0;
26+
for (const item of items) {
27+
if ('key' in item && (item as GroupItemData).items !== undefined) {
28+
count += countLeafItems((item as GroupItemData).items);
29+
} else {
30+
count += 1;
31+
}
32+
}
33+
return count;
34+
}
35+
36+
// ---------------------------------------------------------------------------
37+
// Scenario helper — holds a single mutable items array and a
38+
// GroupingHelperCore. After each collapse / expand the items
39+
// array is mutated the same way the real grid does it:
40+
// collapse → group.items becomes null
41+
// expand → group.items is restored from the saved snapshot
42+
//
43+
// When `serverCounts` is provided, collapse uses the server-side
44+
// total count instead of counting visible leaf items. This mirrors
45+
// the real flow where `loadTotalCount` queries the server.
46+
// ---------------------------------------------------------------------------
47+
48+
interface Scenario {
49+
readonly helper: GroupingHelperCore;
50+
readonly items: DataItem[];
51+
collapse: (path: unknown[]) => void;
52+
expand: (path: unknown[]) => void;
53+
}
54+
55+
function createScenario(
56+
initialItems: DataItem[],
57+
serverCounts?: Record<string, number>,
58+
): Scenario {
59+
const items = initialItems;
60+
const helper = new GroupingHelperCore({ option: (): undefined => undefined });
61+
const savedChildren = new Map<string, GroupItemData[] | null>();
62+
63+
function simulateChangeRowExpand(
64+
path: unknown[],
65+
count: number,
66+
): void {
67+
const groupInfo = helper.findGroupInfo(path);
68+
69+
const pendingGroupInfo: GroupInfoData = {
70+
offset: groupInfo ? groupInfo.offset : -1,
71+
path: groupInfo ? groupInfo.path : path,
72+
isExpanded: groupInfo ? !groupInfo.isExpanded : false,
73+
count,
74+
};
75+
76+
updateGroupOffsets(helper, items, [], 0, pendingGroupInfo);
77+
78+
if (groupInfo) {
79+
groupInfo.isExpanded = !groupInfo.isExpanded;
80+
groupInfo.count = count;
81+
} else if (pendingGroupInfo.offset >= 0) {
82+
helper.addGroupInfo(pendingGroupInfo);
83+
}
84+
}
85+
86+
return {
87+
helper,
88+
items,
89+
90+
collapse(path): void {
91+
const key = String(path[path.length - 1]);
92+
const group = findGroup(items, key);
93+
const count = serverCounts?.[key] ?? countLeafItems(group?.items ?? null);
94+
95+
simulateChangeRowExpand(path, count);
96+
97+
if (group) {
98+
savedChildren.set(JSON.stringify(path), group.items);
99+
group.items = null;
100+
}
101+
},
102+
103+
expand(path): void {
104+
const groupInfo = helper.findGroupInfo(path);
105+
const count = groupInfo ? groupInfo.count : 0;
106+
107+
simulateChangeRowExpand(path, count);
108+
109+
const group = findGroup(items, path[path.length - 1]);
110+
if (group) {
111+
group.items = savedChildren.get(JSON.stringify(path)) ?? null;
112+
}
113+
},
114+
};
115+
}
116+
117+
// ---------------------------------------------------------------------------
118+
// Test data
119+
// ---------------------------------------------------------------------------
120+
121+
function createItems(): DataItem[] {
122+
return [
123+
{ key: 'A', items: [{ id: 1 }, { id: 2 }, { id: 3 }] },
124+
{ key: 'B', items: [{ id: 4 }, { id: 5 }] },
125+
{ key: 'C', items: [{ id: 6 }] },
126+
];
127+
}
128+
129+
const SERVER_COUNTS = { A: 100, B: 50, C: 30 };
130+
131+
// ---------------------------------------------------------------------------
132+
// Tests — local data (count matches visible items)
133+
// ---------------------------------------------------------------------------
134+
135+
describe('groupInfo state after changeRowExpand flow', () => {
136+
describe('local data: collapsing a single new group', () => {
137+
it('should register group A with offset 0 and isExpanded false', () => {
138+
const { helper, collapse } = createScenario(createItems());
139+
140+
collapse(['A']);
141+
142+
expect(helper.findGroupInfo(['A'])).toEqual({
143+
offset: 0, count: 3, path: ['A'], isExpanded: false,
144+
});
145+
});
146+
147+
it('should register group C with offset 5', () => {
148+
const { helper, collapse } = createScenario(createItems());
149+
150+
collapse(['C']);
151+
152+
expect(helper.findGroupInfo(['C'])).toEqual({
153+
offset: 5, count: 1, path: ['C'], isExpanded: false,
154+
});
155+
});
156+
});
157+
158+
describe('local data: collapsing new groups top-to-bottom (A → B → C)', () => {
159+
it('should assign correct offsets at each step', () => {
160+
const { helper, collapse } = createScenario(createItems());
161+
162+
collapse(['A']);
163+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, isExpanded: false });
164+
165+
collapse(['B']);
166+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false });
167+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 3, isExpanded: false });
168+
169+
collapse(['C']);
170+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 3, isExpanded: false });
171+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 2, isExpanded: false });
172+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 1, isExpanded: false });
173+
});
174+
});
175+
176+
describe('local data: collapsing new groups bottom-to-top (C → B → A)', () => {
177+
it('should assign correct offsets at each step', () => {
178+
const { helper, collapse } = createScenario(createItems());
179+
180+
collapse(['C']);
181+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 5, isExpanded: false });
182+
183+
collapse(['B']);
184+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 3, isExpanded: false });
185+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 5, isExpanded: false });
186+
187+
collapse(['A']);
188+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, isExpanded: false });
189+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 3, isExpanded: false });
190+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 5, isExpanded: false });
191+
});
192+
});
193+
194+
describe('local data: collapse order independence', () => {
195+
it('should produce identical final offsets regardless of collapse order', () => {
196+
const topDown = createScenario(createItems());
197+
topDown.collapse(['A']);
198+
topDown.collapse(['B']);
199+
topDown.collapse(['C']);
200+
201+
const bottomUp = createScenario(createItems());
202+
bottomUp.collapse(['C']);
203+
bottomUp.collapse(['B']);
204+
bottomUp.collapse(['A']);
205+
206+
for (const key of ['A', 'B', 'C']) {
207+
expect(topDown.helper.findGroupInfo([key]))
208+
.toEqual(bottomUp.helper.findGroupInfo([key]));
209+
}
210+
});
211+
});
212+
213+
describe('local data: expanding and re-collapsing', () => {
214+
it('should toggle isExpanded to true on expand', () => {
215+
const { helper, collapse, expand } = createScenario(createItems());
216+
217+
collapse(['A']);
218+
expect(helper.findGroupInfo(['A'])).toMatchObject({ isExpanded: false });
219+
220+
expand(['A']);
221+
expect(helper.findGroupInfo(['A'])).toMatchObject({ isExpanded: true });
222+
});
223+
224+
it('should preserve count when expanding', () => {
225+
const { helper, collapse, expand } = createScenario(createItems());
226+
227+
collapse(['B']);
228+
expand(['B']);
229+
230+
expect(helper.findGroupInfo(['B'])).toMatchObject({ count: 2, isExpanded: true });
231+
});
232+
233+
it('should toggle isExpanded back to false (collapse → expand → collapse)', () => {
234+
const { helper, collapse, expand } = createScenario(createItems());
235+
236+
collapse(['A']);
237+
expand(['A']);
238+
collapse(['A']);
239+
240+
expect(helper.findGroupInfo(['A'])).toMatchObject({
241+
offset: 0, count: 3, isExpanded: false,
242+
});
243+
});
244+
});
245+
246+
// -------------------------------------------------------------------------
247+
// Server-side filtered data: visible items are a subset,
248+
// but server reports the real total count per group.
249+
//
250+
// Group A — 3 visible items, 100 on server
251+
// Group B — 2 visible items, 50 on server
252+
// Group C — 1 visible item, 30 on server
253+
//
254+
// Expected flat offsets: A=0, B=100, C=150
255+
// -------------------------------------------------------------------------
256+
257+
describe('server-side data: collapsing a single group uses server count', () => {
258+
it('should store server count, not visible item count', () => {
259+
const { helper, collapse } = createScenario(createItems(), SERVER_COUNTS);
260+
261+
collapse(['A']);
262+
263+
expect(helper.findGroupInfo(['A'])).toEqual({
264+
offset: 0, count: 100, path: ['A'], isExpanded: false,
265+
});
266+
});
267+
});
268+
269+
describe('server-side data: collapsing top-to-bottom (A → B → C)', () => {
270+
it('should assign offsets based on server counts', () => {
271+
const { helper, collapse } = createScenario(createItems(), SERVER_COUNTS);
272+
273+
collapse(['A']);
274+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false });
275+
276+
collapse(['B']);
277+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false });
278+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false });
279+
280+
collapse(['C']);
281+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false });
282+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false });
283+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 150, count: 30, isExpanded: false });
284+
});
285+
});
286+
287+
describe('server-side data: collapsing bottom-to-top (C → B → A)', () => {
288+
it('should assign offsets based on server counts', () => {
289+
const { helper, collapse } = createScenario(createItems(), SERVER_COUNTS);
290+
291+
collapse(['C']);
292+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 5, count: 30, isExpanded: false });
293+
294+
collapse(['B']);
295+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 3, count: 50, isExpanded: false });
296+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 53, count: 30, isExpanded: false });
297+
298+
collapse(['A']);
299+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100, isExpanded: false });
300+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 100, count: 50, isExpanded: false });
301+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 150, count: 30, isExpanded: false });
302+
});
303+
});
304+
305+
describe('server-side data: expand and re-collapse', () => {
306+
it('should preserve server count through expand/collapse cycle', () => {
307+
const { helper, collapse, expand } = createScenario(createItems(), SERVER_COUNTS);
308+
309+
collapse(['A']);
310+
expect(helper.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: false });
311+
312+
expand(['A']);
313+
expect(helper.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: true });
314+
315+
collapse(['A']);
316+
expect(helper.findGroupInfo(['A'])).toMatchObject({ count: 100, isExpanded: false });
317+
});
318+
319+
it('should adjust other group offsets after expand', () => {
320+
const { helper, collapse, expand } = createScenario(createItems(), SERVER_COUNTS);
321+
322+
collapse(['A']);
323+
collapse(['B']);
324+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 100 });
325+
326+
// Expanding A — A is still collapsed in the view (items: null)
327+
// so updateGroupOffsets sees collapsed A with isPendingGroup
328+
// and adds 1 instead of count
329+
expand(['A']);
330+
331+
expect(helper.findGroupInfo(['A'])).toMatchObject({ isExpanded: true });
332+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 1 });
333+
334+
collapse(['A']);
335+
expect(helper.findGroupInfo(['A'])).toMatchObject({ isExpanded: false });
336+
expect(helper.findGroupInfo(['B'])).toMatchObject({ offset: 100 });
337+
});
338+
});
339+
340+
describe('server-side data: skip a middle group', () => {
341+
it('should correctly offset C past collapsed A (B stays expanded)', () => {
342+
const { helper, collapse } = createScenario(createItems(), SERVER_COUNTS);
343+
344+
collapse(['A']);
345+
collapse(['C']);
346+
347+
expect(helper.findGroupInfo(['A'])).toMatchObject({ offset: 0, count: 100 });
348+
// B is expanded, its 2 visible items sit between A and C
349+
expect(helper.findGroupInfo(['C'])).toMatchObject({ offset: 102, count: 30 });
350+
});
351+
});
352+
});

0 commit comments

Comments
 (0)