Skip to content

Commit 41337eb

Browse files
Copilothotlong
andcommitted
fix: add reverse-reference child discovery and fix empty related data
- RecordDetailView now scans all objects for lookup/master_detail fields pointing back to the current object (e.g., order_item.order → order), fetches filtered child records, and passes them as explicit related entries - DetailView's effectiveRelated now passes api: objectName for auto-discovered forward references so RelatedList can fetch data via dataSource - RelatedList syncs internal state when data prop changes (fixes stale initial state from useState(data)) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 5f01741 commit 41337eb

4 files changed

Lines changed: 143 additions & 1 deletion

File tree

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,94 @@ describe('RecordDetailView — detail schema features', () => {
193193
const activeElements = screen.getAllByText('Active');
194194
expect(activeElements.length).toBeGreaterThanOrEqual(1);
195195
});
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+
const objectsWithChild = [
225+
{
226+
name: 'order',
227+
label: 'Order',
228+
fields: {
229+
name: { name: 'name', label: 'Name', type: 'text' },
230+
},
231+
},
232+
{
233+
name: 'order_item',
234+
label: 'Order Item',
235+
fields: {
236+
name: { name: 'name', label: 'Line Item', type: 'text' },
237+
order: { name: 'order', label: 'Order', type: 'lookup', reference_to: 'order' },
238+
quantity: { name: 'quantity', label: 'Quantity', type: 'number' },
239+
},
240+
},
241+
];
242+
243+
render(
244+
<MemoryRouter initialEntries={['/order/record/order-1']}>
245+
<Routes>
246+
<Route
247+
path="/:objectName/record/:recordId"
248+
element={
249+
<RecordDetailView
250+
dataSource={ds}
251+
objects={objectsWithChild}
252+
onEdit={vi.fn()}
253+
/>
254+
}
255+
/>
256+
</Routes>
257+
</MemoryRouter>,
258+
);
259+
260+
await waitFor(() => {
261+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Order #1');
262+
});
263+
264+
// Should fetch child records filtered by parent ID
265+
await waitFor(() => {
266+
expect(ds.find).toHaveBeenCalledWith('order_item', {
267+
$filter: "order eq 'order-1'",
268+
});
269+
});
270+
271+
// Related tab should appear (child object discovered)
272+
await waitFor(() => {
273+
expect(screen.getByRole('tab', { name: /Related/ })).toBeInTheDocument();
274+
});
275+
276+
// Click on the Related tab to reveal its content
277+
await userEvent.click(screen.getByRole('tab', { name: /Related/ }));
278+
279+
// The child object title should appear in the related list card
280+
await waitFor(() => {
281+
expect(screen.getByText('Order Item')).toBeInTheDocument();
282+
});
283+
});
196284
});
197285

198286
describe('RecordDetailView — recordId handling', () => {

apps/console/src/components/RecordDetailView.tsx

Lines changed: 49 additions & 1 deletion
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';
@@ -35,13 +35,53 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
3535
const [isLoading, setIsLoading] = useState(true);
3636
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
3737
const [recordViewers, setRecordViewers] = useState<PresenceUser[]>([]);
38+
const [childRelatedData, setChildRelatedData] = useState<Record<string, any[]>>({});
3839
const objectDef = objects.find((o: any) => o.name === objectName);
3940

4041
// Use the URL recordId as-is — it contains the actual record _id.
4142
// Navigation code passes `record._id || record.id` directly into the URL
4243
// without adding any prefix, so no stripping is needed.
4344
const pureRecordId = recordId;
4445

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 === 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+
for (const { childObject, referenceField } of childRelations) {
74+
dataSource.find(childObject, {
75+
$filter: `${referenceField} eq '${pureRecordId}'`,
76+
})
77+
.then((res: any) => {
78+
const items = Array.isArray(res) ? res : res?.data || [];
79+
setChildRelatedData(prev => ({ ...prev, [childObject]: items }));
80+
})
81+
.catch(() => {});
82+
}
83+
}, [dataSource, pureRecordId, childRelations]);
84+
4585
const currentUser = user
4686
? { id: user.id, name: user.name, avatar: user.image }
4787
: FALLBACK_USER;
@@ -275,6 +315,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
275315
const sectionGroups: SectionGroup[] | undefined =
276316
objectDef.views?.detail?.sectionGroups ?? objectDef.views?.form?.sectionGroups;
277317

318+
// Build related entries from reverse-reference child objects
319+
const related = childRelations.map(({ childObject, childLabel }) => ({
320+
title: childLabel,
321+
type: 'table' as const,
322+
data: childRelatedData[childObject] || [],
323+
}));
324+
278325
const detailSchema: DetailViewSchema = {
279326
type: 'detail-view',
280327
objectName: objectDef.name,
@@ -287,6 +334,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
287334
sections,
288335
autoTabs: true,
289336
autoDiscoverRelated: true,
337+
...(related.length > 0 && { related }),
290338
...(highlightFields.length > 0 && { highlightFields }),
291339
...(sectionGroups && sectionGroups.length > 0 && { sectionGroups }),
292340
...(recordHeaderActions.length > 0 && {

packages/plugin-detail/src/DetailView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
324324
return discoveredRelated.map((r) => ({
325325
title: r.title,
326326
type: r.type,
327+
api: r.objectName,
327328
data: [] as any[],
328329
}));
329330
}, [schema.related, discoveredRelated]);

packages/plugin-detail/src/RelatedList.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export const RelatedList: React.FC<RelatedListProps> = ({
7878
const [filterText, setFilterText] = React.useState('');
7979
const { t } = useDetailTranslation();
8080

81+
// Sync internal state when data prop changes (e.g., parent fetches async data)
82+
React.useEffect(() => {
83+
setRelatedData(data);
84+
}, [data]);
85+
8186
React.useEffect(() => {
8287
if (api && !data.length) {
8388
setLoading(true);

0 commit comments

Comments
 (0)