Skip to content

Commit f68303b

Browse files
Copilothotlong
andcommitted
merge: resolve conflicts with main branch
Combined action provider handlers (this PR) with child relation discovery, highlight fields, section groups (from main). Kept main's workspaceAliases variable in vite.config.ts. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent ebac3ca commit f68303b

20 files changed

Lines changed: 423 additions & 77 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,8 @@ All 313 `@object-ui/fields` tests pass.
14931493
- [x] Related list pagination, sorting, filtering
14941494
- [x] Collapsible section groups
14951495
- [x] Header highlight area with key fields
1496+
- [x] Console `RecordDetailView` integration: `autoTabs`, `autoDiscoverRelated`, `highlightFields`, `sectionGroups` wired into `detailSchema` for end-to-end availability
1497+
- [x] Console reverse-reference discovery: child objects (e.g., `order_item``order`) auto-discovered and rendered with filtered data
14961498

14971499
---
14981500

apps/console/src/__tests__/RecordDetailEdit.test.tsx

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,214 @@ function renderDetailView(
7676

7777
// ─── Tests ───────────────────────────────────────────────────────────────────
7878

79+
describe('RecordDetailView — detail schema features', () => {
80+
it('renders auto tabs (Details tab) when autoTabs is enabled', async () => {
81+
const ds = createMockDataSource();
82+
renderDetailView('contact-1', 'contact', vi.fn(), ds);
83+
84+
await waitFor(() => {
85+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice');
86+
});
87+
88+
// autoTabs: true should produce a "Details" tab trigger
89+
expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument();
90+
});
91+
92+
it('auto-discovers related lists from objectSchema reference fields', async () => {
93+
const dsWithRefs: DataSource = {
94+
async getObjectSchema() {
95+
return {
96+
name: 'order',
97+
label: 'Order',
98+
fields: {
99+
name: { name: 'name', label: 'Name', type: 'text' },
100+
account: { name: 'account', label: 'Account', type: 'lookup', reference: 'account' },
101+
},
102+
};
103+
},
104+
findOne: vi.fn().mockResolvedValue({ id: 'order-1', name: 'Order #1' }),
105+
find: vi.fn().mockResolvedValue({ data: [] }),
106+
create: vi.fn().mockResolvedValue({ id: '1' }),
107+
update: vi.fn().mockResolvedValue({ id: '1' }),
108+
delete: vi.fn().mockResolvedValue(true),
109+
} as any;
110+
111+
const objectsWithRefs = [
112+
{
113+
name: 'order',
114+
label: 'Order',
115+
fields: {
116+
name: { name: 'name', label: 'Name', type: 'text' },
117+
account: { name: 'account', label: 'Account', type: 'lookup', reference: 'account' },
118+
},
119+
},
120+
];
121+
122+
render(
123+
<MemoryRouter initialEntries={['/order/record/order-1']}>
124+
<Routes>
125+
<Route
126+
path="/:objectName/record/:recordId"
127+
element={
128+
<RecordDetailView
129+
dataSource={dsWithRefs}
130+
objects={objectsWithRefs}
131+
onEdit={vi.fn()}
132+
/>
133+
}
134+
/>
135+
</Routes>
136+
</MemoryRouter>,
137+
);
138+
139+
await waitFor(() => {
140+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Order #1');
141+
});
142+
143+
// autoDiscoverRelated: true + lookup field should produce a "Related" tab
144+
await waitFor(() => {
145+
expect(screen.getByRole('tab', { name: /Related/ })).toBeInTheDocument();
146+
});
147+
});
148+
149+
it('renders highlight fields for key field types', async () => {
150+
const objectsWithStatus = [
151+
{
152+
name: 'contact',
153+
label: 'Contact',
154+
fields: {
155+
name: { name: 'name', label: 'Name', type: 'text' },
156+
email: { name: 'email', label: 'Email', type: 'email' },
157+
status: { name: 'status', label: 'Status', type: 'select' },
158+
},
159+
},
160+
];
161+
162+
const ds: DataSource = {
163+
...createMockDataSource(),
164+
findOne: vi.fn().mockResolvedValue({ id: 'c-1', name: 'Alice', status: 'Active' }),
165+
} as any;
166+
167+
render(
168+
<MemoryRouter initialEntries={['/contact/record/c-1']}>
169+
<Routes>
170+
<Route
171+
path="/:objectName/record/:recordId"
172+
element={
173+
<RecordDetailView
174+
dataSource={ds}
175+
objects={objectsWithStatus}
176+
onEdit={vi.fn()}
177+
/>
178+
}
179+
/>
180+
</Routes>
181+
</MemoryRouter>,
182+
);
183+
184+
await waitFor(() => {
185+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice');
186+
});
187+
188+
// The highlightFields should render "Status" label in the highlight banner
189+
// (also appears in the detail section, so multiple matches expected)
190+
const statusElements = screen.getAllByText('Status');
191+
expect(statusElements.length).toBeGreaterThanOrEqual(2); // highlight banner + detail section
192+
// The value "Active" should appear in the highlight area and/or detail section
193+
const activeElements = screen.getAllByText('Active');
194+
expect(activeElements.length).toBeGreaterThanOrEqual(1);
195+
});
196+
it('discovers and renders reverse-reference child objects (e.g., order_item → order)', async () => {
197+
const orderItemData = [
198+
{ id: 'item-1', name: 'Widget A', quantity: 2 },
199+
{ id: 'item-2', name: 'Widget B', quantity: 5 },
200+
];
201+
202+
const ds: DataSource = {
203+
async getObjectSchema() {
204+
return {
205+
name: 'order',
206+
label: 'Order',
207+
fields: {
208+
name: { name: 'name', label: 'Name', type: 'text' },
209+
},
210+
};
211+
},
212+
findOne: vi.fn().mockResolvedValue({ id: 'order-1', name: 'Order #1' }),
213+
find: vi.fn().mockImplementation((objectName: string) => {
214+
if (objectName === 'order_item') {
215+
return Promise.resolve({ data: orderItemData });
216+
}
217+
return Promise.resolve({ data: [] });
218+
}),
219+
create: vi.fn().mockResolvedValue({ id: '1' }),
220+
update: vi.fn().mockResolvedValue({ id: '1' }),
221+
delete: vi.fn().mockResolvedValue(true),
222+
} as any;
223+
224+
// Use ObjectStack-convention 'reference' (not 'reference_to') to match real metadata
225+
const objectsWithChild = [
226+
{
227+
name: 'order',
228+
label: 'Order',
229+
fields: {
230+
name: { name: 'name', label: 'Name', type: 'text' },
231+
},
232+
},
233+
{
234+
name: 'order_item',
235+
label: 'Order Item',
236+
fields: {
237+
name: { name: 'name', label: 'Line Item', type: 'text' },
238+
order: { name: 'order', label: 'Order', type: 'lookup', reference: 'order' },
239+
quantity: { name: 'quantity', label: 'Quantity', type: 'number' },
240+
},
241+
},
242+
];
243+
244+
render(
245+
<MemoryRouter initialEntries={['/order/record/order-1']}>
246+
<Routes>
247+
<Route
248+
path="/:objectName/record/:recordId"
249+
element={
250+
<RecordDetailView
251+
dataSource={ds}
252+
objects={objectsWithChild}
253+
onEdit={vi.fn()}
254+
/>
255+
}
256+
/>
257+
</Routes>
258+
</MemoryRouter>,
259+
);
260+
261+
await waitFor(() => {
262+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Order #1');
263+
});
264+
265+
// Should fetch child records filtered by parent ID
266+
await waitFor(() => {
267+
expect(ds.find).toHaveBeenCalledWith('order_item', {
268+
$filter: { order: 'order-1' },
269+
});
270+
});
271+
272+
// Related tab should appear (child object discovered)
273+
await waitFor(() => {
274+
expect(screen.getByRole('tab', { name: /Related/ })).toBeInTheDocument();
275+
});
276+
277+
// Click on the Related tab to reveal its content
278+
await userEvent.click(screen.getByRole('tab', { name: /Related/ }));
279+
280+
// The child object title should appear in the related list card
281+
await waitFor(() => {
282+
expect(screen.getByText('Order Item')).toBeInTheDocument();
283+
});
284+
});
285+
});
286+
79287
describe('RecordDetailView — recordId handling', () => {
80288
it('passes URL recordId as-is to findOne (with objectName prefix)', async () => {
81289
const onEdit = vi.fn();

apps/console/src/components/RecordDetailView.tsx

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* the object field definitions.
77
*/
88

9-
import { useState, useEffect, useCallback } from 'react';
9+
import { useState, useEffect, useCallback, useMemo } from 'react';
1010
import { useParams, useNavigate } from 'react-router-dom';
1111
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
@@ -19,7 +19,7 @@ import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
1919
import { SkeletonDetail } from './skeletons';
2020
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
2121
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
22-
import type { DetailViewSchema, FeedItem } from '@object-ui/types';
22+
import type { DetailViewSchema, FeedItem, HighlightField, SectionGroup } from '@object-ui/types';
2323
import type { ActionDef, ActionParamDef } from '@object-ui/core';
2424

2525
interface RecordDetailViewProps {
@@ -30,6 +30,9 @@ interface RecordDetailViewProps {
3030

3131
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
3232

33+
/** Field names automatically promoted to the highlight banner when present. */
34+
const HIGHLIGHT_FIELD_NAMES = ['status', 'stage', 'priority', 'category', 'type', 'owner', 'amount'];
35+
3336
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
3437
const { objectName, recordId } = useParams();
3538
const { showDebug } = useMetadataInspector();
@@ -39,6 +42,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
3942
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
4043
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
4144
const [actionRefreshKey, setActionRefreshKey] = useState(0);
45+
const [childRelatedData, setChildRelatedData] = useState<Record<string, any[]>>({});
4246
const objectDef = objects.find((o: any) => o.name === objectName);
4347

4448
// Use the URL recordId as-is — it contains the actual record _id.
@@ -113,6 +117,59 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
113117
}
114118
}, [dataSource, objectName, pureRecordId]);
115119

120+
// Discover reverse references: other objects with lookup/master_detail fields
121+
// pointing to the current object (e.g., order_item.order → order).
122+
const childRelations = useMemo(() => {
123+
if (!objectDef || !objects) return [];
124+
const relations: Array<{ childObject: string; childLabel: string; referenceField: string }> = [];
125+
for (const obj of objects) {
126+
if (obj.name === objectDef.name) continue;
127+
for (const [fieldName, fieldDef] of Object.entries<any>(obj.fields || {})) {
128+
if (
129+
fieldDef &&
130+
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') &&
131+
(fieldDef.reference_to || fieldDef.reference) === objectDef.name
132+
) {
133+
relations.push({
134+
childObject: obj.name,
135+
childLabel: obj.label || obj.name,
136+
referenceField: fieldName,
137+
});
138+
}
139+
}
140+
}
141+
return relations;
142+
}, [objectDef, objects]);
143+
144+
// Fetch related child records for each reverse reference
145+
useEffect(() => {
146+
if (!dataSource || !pureRecordId || childRelations.length === 0) return;
147+
let cancelled = false;
148+
Promise.all(
149+
childRelations.map(({ childObject, referenceField }) =>
150+
dataSource.find(childObject, {
151+
$filter: { [referenceField]: pureRecordId },
152+
})
153+
.then((res: any) => {
154+
const items = Array.isArray(res) ? res : res?.data || [];
155+
return { childObject, items };
156+
})
157+
.catch((err: any) => {
158+
console.warn(`[RecordDetailView] Failed to fetch related ${childObject}:`, err);
159+
return { childObject, items: [] as any[] };
160+
})
161+
)
162+
).then((results) => {
163+
if (cancelled) return;
164+
const data: Record<string, any[]> = {};
165+
for (const { childObject, items } of results) {
166+
data[childObject] = items;
167+
}
168+
setChildRelatedData(data);
169+
});
170+
return () => { cancelled = true; };
171+
}, [dataSource, pureRecordId, childRelations]);
172+
116173
const currentUser = user
117174
? { id: user.id, name: user.name, avatar: user.image }
118175
: FALLBACK_USER;
@@ -297,12 +354,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
297354
console.warn(`[RecordDetailView] Field "${fieldName}" not found in ${objectDef.name} definition`);
298355
return { name: fieldName, label: fieldName };
299356
}
357+
const refTarget = fieldDef.reference_to || fieldDef.reference;
300358
return {
301359
name: fieldName,
302360
label: fieldDef.label || fieldName,
303361
type: fieldDef.type || 'text',
304362
...(fieldDef.options && { options: fieldDef.options }),
305-
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
363+
...(refTarget && { reference_to: refTarget }),
306364
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
307365
...(fieldDef.currency && { currency: fieldDef.currency }),
308366
};
@@ -313,12 +371,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
313371
title: 'Details',
314372
fields: Object.keys(objectDef.fields || {}).map(key => {
315373
const fieldDef = objectDef.fields[key];
374+
const refTarget = fieldDef.reference_to || fieldDef.reference;
316375
return {
317376
name: key,
318377
label: fieldDef.label || key,
319378
type: fieldDef.type || 'text',
320379
...(fieldDef.options && { options: fieldDef.options }),
321-
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
380+
...(refTarget && { reference_to: refTarget }),
322381
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
323382
...(fieldDef.currency && { currency: fieldDef.currency }),
324383
};
@@ -331,6 +390,28 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
331390
(a: any) => a.locations?.includes('record_header'),
332391
);
333392

393+
// Build highlightFields: prefer explicit config, fallback to auto-detect key fields
394+
const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields;
395+
const highlightFields: HighlightField[] = explicitHighlight
396+
?? Object.entries(objectDef.fields || {})
397+
.filter(([key]: [string, any]) => HIGHLIGHT_FIELD_NAMES.includes(key))
398+
.map(([key, def]: [string, any]) => ({
399+
name: key,
400+
label: def.label || key,
401+
...(def.type && { type: def.type }),
402+
}));
403+
404+
// Build sectionGroups from objectDef detail/form config if available
405+
const sectionGroups: SectionGroup[] | undefined =
406+
objectDef.views?.detail?.sectionGroups ?? objectDef.views?.form?.sectionGroups;
407+
408+
// Build related entries from reverse-reference child objects
409+
const related = childRelations.map(({ childObject, childLabel }) => ({
410+
title: childLabel,
411+
type: 'table' as const,
412+
data: childRelatedData[childObject] || [],
413+
}));
414+
334415
const detailSchema: DetailViewSchema = {
335416
type: 'detail-view',
336417
objectName: objectDef.name,
@@ -341,6 +422,11 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
341422
title: objectDef.label,
342423
primaryField,
343424
sections,
425+
autoTabs: true,
426+
autoDiscoverRelated: true,
427+
...(related.length > 0 && { related }),
428+
...(highlightFields.length > 0 && { highlightFields }),
429+
...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
344430
...(recordHeaderActions.length > 0 && {
345431
actions: [{
346432
type: 'action:bar',

0 commit comments

Comments
 (0)