Skip to content

Commit 64e630d

Browse files
authored
Merge pull request #985 from objectstack-ai/copilot/integrate-detail-schema-functions
2 parents 6c236b4 + 1515e8f commit 64e630d

6 files changed

Lines changed: 311 additions & 7 deletions

File tree

ROADMAP.md

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

14721474
---
14731475

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 } from 'react-router-dom';
1111
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
1212
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
@@ -15,7 +15,7 @@ import { useAuth } from '@object-ui/auth';
1515
import { Database, Users } from 'lucide-react';
1616
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
1717
import { SkeletonDetail } from './skeletons';
18-
import type { DetailViewSchema, FeedItem } from '@object-ui/types';
18+
import type { DetailViewSchema, FeedItem, HighlightField, SectionGroup } from '@object-ui/types';
1919

2020
interface RecordDetailViewProps {
2121
dataSource: any;
@@ -25,20 +25,77 @@ interface RecordDetailViewProps {
2525

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

28+
/** Field names automatically promoted to the highlight banner when present. */
29+
const HIGHLIGHT_FIELD_NAMES = ['status', 'stage', 'priority', 'category', 'type', 'owner', 'amount'];
30+
2831
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
2932
const { objectName, recordId } = useParams();
3033
const { showDebug } = useMetadataInspector();
3134
const { user } = useAuth();
3235
const [isLoading, setIsLoading] = useState(true);
3336
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
3437
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
38+
const [childRelatedData, setChildRelatedData] = useState<Record<string, any[]>>({});
3539
const objectDef = objects.find((o: any) => o.name === objectName);
3640

3741
// Use the URL recordId as-is — it contains the actual record _id.
3842
// Navigation code passes `record._id || record.id` directly into the URL
3943
// without adding any prefix, so no stripping is needed.
4044
const pureRecordId = recordId;
4145

46+
// Discover reverse references: other objects with lookup/master_detail fields
47+
// pointing to the current object (e.g., order_item.order → order).
48+
const childRelations = useMemo(() => {
49+
if (!objectDef || !objects) return [];
50+
const relations: Array<{ childObject: string; childLabel: string; referenceField: string }> = [];
51+
for (const obj of objects) {
52+
if (obj.name === objectDef.name) continue;
53+
for (const [fieldName, fieldDef] of Object.entries<any>(obj.fields || {})) {
54+
if (
55+
fieldDef &&
56+
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') &&
57+
(fieldDef.reference_to || fieldDef.reference) === objectDef.name
58+
) {
59+
relations.push({
60+
childObject: obj.name,
61+
childLabel: obj.label || obj.name,
62+
referenceField: fieldName,
63+
});
64+
}
65+
}
66+
}
67+
return relations;
68+
}, [objectDef, objects]);
69+
70+
// Fetch related child records for each reverse reference
71+
useEffect(() => {
72+
if (!dataSource || !pureRecordId || childRelations.length === 0) return;
73+
let cancelled = false;
74+
Promise.all(
75+
childRelations.map(({ childObject, referenceField }) =>
76+
dataSource.find(childObject, {
77+
$filter: { [referenceField]: pureRecordId },
78+
})
79+
.then((res: any) => {
80+
const items = Array.isArray(res) ? res : res?.data || [];
81+
return { childObject, items };
82+
})
83+
.catch((err: any) => {
84+
console.warn(`[RecordDetailView] Failed to fetch related ${childObject}:`, err);
85+
return { childObject, items: [] as any[] };
86+
})
87+
)
88+
).then((results) => {
89+
if (cancelled) return;
90+
const data: Record<string, any[]> = {};
91+
for (const { childObject, items } of results) {
92+
data[childObject] = items;
93+
}
94+
setChildRelatedData(data);
95+
});
96+
return () => { cancelled = true; };
97+
}, [dataSource, pureRecordId, childRelations]);
98+
4299
const currentUser = user
43100
? { id: user.id, name: user.name, avatar: user.image }
44101
: FALLBACK_USER;
@@ -223,12 +280,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
223280
console.warn(`[RecordDetailView] Field "${fieldName}" not found in ${objectDef.name} definition`);
224281
return { name: fieldName, label: fieldName };
225282
}
283+
const refTarget = fieldDef.reference_to || fieldDef.reference;
226284
return {
227285
name: fieldName,
228286
label: fieldDef.label || fieldName,
229287
type: fieldDef.type || 'text',
230288
...(fieldDef.options && { options: fieldDef.options }),
231-
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
289+
...(refTarget && { reference_to: refTarget }),
232290
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
233291
...(fieldDef.currency && { currency: fieldDef.currency }),
234292
};
@@ -239,12 +297,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
239297
title: 'Details',
240298
fields: Object.keys(objectDef.fields || {}).map(key => {
241299
const fieldDef = objectDef.fields[key];
300+
const refTarget = fieldDef.reference_to || fieldDef.reference;
242301
return {
243302
name: key,
244303
label: fieldDef.label || key,
245304
type: fieldDef.type || 'text',
246305
...(fieldDef.options && { options: fieldDef.options }),
247-
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
306+
...(refTarget && { reference_to: refTarget }),
248307
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
249308
...(fieldDef.currency && { currency: fieldDef.currency }),
250309
};
@@ -257,6 +316,28 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
257316
(a: any) => a.locations?.includes('record_header'),
258317
);
259318

319+
// Build highlightFields: prefer explicit config, fallback to auto-detect key fields
320+
const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields;
321+
const highlightFields: HighlightField[] = explicitHighlight
322+
?? Object.entries(objectDef.fields || {})
323+
.filter(([key]: [string, any]) => HIGHLIGHT_FIELD_NAMES.includes(key))
324+
.map(([key, def]: [string, any]) => ({
325+
name: key,
326+
label: def.label || key,
327+
...(def.type && { type: def.type }),
328+
}));
329+
330+
// Build sectionGroups from objectDef detail/form config if available
331+
const sectionGroups: SectionGroup[] | undefined =
332+
objectDef.views?.detail?.sectionGroups ?? objectDef.views?.form?.sectionGroups;
333+
334+
// Build related entries from reverse-reference child objects
335+
const related = childRelations.map(({ childObject, childLabel }) => ({
336+
title: childLabel,
337+
type: 'table' as const,
338+
data: childRelatedData[childObject] || [],
339+
}));
340+
260341
const detailSchema: DetailViewSchema = {
261342
type: 'detail-view',
262343
objectName: objectDef.name,
@@ -267,6 +348,11 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
267348
title: objectDef.label,
268349
primaryField,
269350
sections,
351+
autoTabs: true,
352+
autoDiscoverRelated: true,
353+
...(related.length > 0 && { related }),
354+
...(highlightFields.length > 0 && { highlightFields }),
355+
...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
270356
...(recordHeaderActions.length > 0 && {
271357
actions: [{
272358
type: 'action:bar',

0 commit comments

Comments
 (0)