Skip to content

Commit b320bba

Browse files
committed
Support lookup/master_detail object grouping with proper label display
1 parent 80f8c06 commit b320bba

2 files changed

Lines changed: 107 additions & 3 deletions

File tree

packages/plugin-grid/src/__tests__/useGroupedData.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,61 @@ describe('useGroupedData – collapsed state management', () => {
252252
expect(result.current.groups[0].label).toBe('Alpha, Beta');
253253
});
254254
});
255+
256+
describe('lookup / master_detail object values', () => {
257+
it('groups by lookup object using id and shows display name', () => {
258+
const data = [
259+
{ id: 1, account: { id: 'a1', name: 'Acme Corporation' } },
260+
{ id: 2, account: { id: 'a1', name: 'Acme Corporation' } },
261+
{ id: 3, account: { id: 'a2', name: 'Beta Industries' } },
262+
];
263+
const config = { fields: [{ field: 'account', order: 'asc' as const, collapsed: false }] };
264+
265+
const { result } = renderHook(() => useGroupedData(config, data));
266+
267+
expect(result.current.groups).toHaveLength(2);
268+
expect(result.current.groups.map((g) => g.label)).toEqual([
269+
'Acme Corporation',
270+
'Beta Industries',
271+
]);
272+
expect(result.current.groups[0].rows).toHaveLength(2);
273+
expect(result.current.groups[1].rows).toHaveLength(1);
274+
// No "[object Object]" anywhere in labels
275+
for (const g of result.current.groups) {
276+
expect(g.label).not.toContain('[object Object]');
277+
}
278+
});
279+
280+
it('keeps distinct groups for same display name but different ids', () => {
281+
const data = [
282+
{ account: { id: 'a1', name: 'Same Name' } },
283+
{ account: { id: 'a2', name: 'Same Name' } },
284+
];
285+
const config = { fields: [{ field: 'account', order: 'asc' as const, collapsed: false }] };
286+
287+
const { result } = renderHook(() => useGroupedData(config, data));
288+
289+
expect(result.current.groups).toHaveLength(2);
290+
});
291+
292+
it('falls back to id when no display name field is present', () => {
293+
const data = [{ owner: { id: 'u1' } }];
294+
const config = { fields: [{ field: 'owner', order: 'asc' as const, collapsed: false }] };
295+
296+
const { result } = renderHook(() => useGroupedData(config, data));
297+
298+
expect(result.current.groups[0].label).toBe('u1');
299+
});
300+
301+
it('handles null lookup values as (empty)', () => {
302+
const data = [{ account: null }, { account: { id: 'a1', name: 'Acme' } }];
303+
const config = { fields: [{ field: 'account', order: 'asc' as const, collapsed: false }] };
304+
305+
const { result } = renderHook(() => useGroupedData(config, data));
306+
307+
const labels = result.current.groups.map((g) => g.label);
308+
expect(labels).toContain('(empty)');
309+
expect(labels).toContain('Acme');
310+
});
311+
});
255312
});

packages/plugin-grid/src/useGroupedData.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,38 @@ export interface UseGroupedDataResult {
6464
toggleGroup: (key: string) => void;
6565
}
6666

67+
/**
68+
* Extract a stable identity key from a value. For lookup / master_detail
69+
* fields the cell contains an expanded object (e.g. `{ id, name, ... }`); we
70+
* key off `id` so different referenced records produce distinct groups even
71+
* when they happen to share the same display name. Plain primitives are
72+
* stringified directly.
73+
*/
74+
function extractValueKey(value: any): string {
75+
if (value === undefined || value === null || value === '') return '';
76+
if (Array.isArray(value)) {
77+
return value.map((v) => extractValueKey(v)).join('|');
78+
}
79+
if (typeof value === 'object') {
80+
const id = (value as any).id ?? (value as any)._id ?? (value as any).pk ?? (value as any).value;
81+
if (id !== undefined && id !== null && id !== '') return String(id);
82+
const label = (value as any).name ?? (value as any).label ?? (value as any).title;
83+
if (label !== undefined && label !== null && label !== '') return String(label);
84+
try {
85+
return JSON.stringify(value);
86+
} catch {
87+
return '';
88+
}
89+
}
90+
return String(value);
91+
}
92+
6793
/**
6894
* Build a value-only key segment for a single grouping field. Used to compose
6995
* stable composite keys across nesting levels.
7096
*/
7197
function buildSegmentKey(row: Record<string, any>, field: string): string {
72-
return String(row[field] ?? '');
98+
return extractValueKey(row[field]);
7399
}
74100

75101
/**
@@ -102,11 +128,28 @@ function buildSegmentLabel(
102128
const f2 = formatValue(field, v);
103129
if (f2 !== undefined && f2 !== '') return f2;
104130
}
105-
return String(v);
131+
return buildSegmentLabel(v, field);
106132
})
107133
.join(', ');
108134
return joined || '(empty)';
109135
}
136+
// Lookup / master_detail fields: the cell is an expanded object such as
137+
// `{ id, name, ... }`. Stringifying directly produces "[object Object]",
138+
// so prefer common display fields, falling back to the id.
139+
if (typeof value === 'object') {
140+
const label =
141+
(value as any).name ??
142+
(value as any).label ??
143+
(value as any).title ??
144+
(value as any).display_name ??
145+
(value as any).displayName ??
146+
(value as any).fullName ??
147+
(value as any).full_name;
148+
if (label !== undefined && label !== null && label !== '') return String(label);
149+
const id = (value as any).id ?? (value as any)._id ?? (value as any).pk;
150+
if (id !== undefined && id !== null && id !== '') return String(id);
151+
return '(empty)';
152+
}
110153
return String(value);
111154
}
112155

@@ -214,7 +257,11 @@ export function useGroupedData(
214257
}
215258

216259
const order = f.order ?? 'asc';
217-
keyOrder.sort((a, b) => compareGroups(a, b, order));
260+
keyOrder.sort((a, b) => {
261+
const labelA = map.get(a)?.label ?? a;
262+
const labelB = map.get(b)?.label ?? b;
263+
return compareGroups(labelA, labelB, order);
264+
});
218265

219266
return keyOrder.map((segment) => {
220267
const entry = map.get(segment)!;

0 commit comments

Comments
 (0)