Skip to content

Commit 46d5248

Browse files
Copilothotlong
andcommitted
fix: resolve lookup field display issues in ListView, DetailView, and DetailSection
Bug 1 (ListView): Gate data fetch on objectDefLoaded to prevent race condition where $expand is empty on first fetch due to async objectDef loading. Bug 2 (DetailView): Load objectSchema via getObjectSchema, compute $expand fields, and pass them to findOne() so lookup/master_detail fields are expanded. Bug 3 (DetailSection): Accept objectSchema prop and enrich fields missing type/options/currency from objectSchema metadata before rendering. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 3a7d22c commit 46d5248

6 files changed

Lines changed: 304 additions & 28 deletions

File tree

packages/plugin-detail/src/DetailSection.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface DetailSectionProps {
3333
section: DetailViewSectionType;
3434
data?: any;
3535
className?: string;
36+
/** Object schema from DataSource for field type enrichment */
37+
objectSchema?: any;
3638
/** Whether inline editing is active */
3739
isEditing?: boolean;
3840
/** Callback when a field value changes during inline editing */
@@ -43,6 +45,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
4345
section,
4446
data,
4547
className,
48+
objectSchema,
4649
isEditing = false,
4750
onFieldChange,
4851
}) => {
@@ -75,11 +78,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
7578

7679
const displayValue = (() => {
7780
if (value === null || value === undefined) return '-';
78-
// Use type-aware cell renderer when field.type is available
79-
if (field.type) {
80-
const CellRenderer = getCellRenderer(field.type);
81+
// Enrich field with objectSchema metadata when field.type is not set
82+
const objectDefField = objectSchema?.fields?.[field.name];
83+
const resolvedType = field.type || objectDefField?.type;
84+
const enrichedField: Record<string, any> = { ...field };
85+
if (!field.type && objectDefField) {
86+
if (objectDefField.type) enrichedField.type = objectDefField.type;
87+
if (objectDefField.options && !enrichedField.options) enrichedField.options = objectDefField.options;
88+
if (objectDefField.currency && !enrichedField.currency) enrichedField.currency = objectDefField.currency;
89+
if (objectDefField.precision !== undefined && enrichedField.precision === undefined) enrichedField.precision = objectDefField.precision;
90+
if (objectDefField.format && !enrichedField.format) enrichedField.format = objectDefField.format;
91+
if (objectDefField.reference_to && !enrichedField.reference_to) enrichedField.reference_to = objectDefField.reference_to;
92+
if (objectDefField.reference_field && !enrichedField.reference_field) enrichedField.reference_field = objectDefField.reference_field;
93+
}
94+
// Use type-aware cell renderer when field type is available (explicit or enriched)
95+
if (resolvedType) {
96+
const CellRenderer = getCellRenderer(resolvedType);
8197
if (CellRenderer) {
82-
return <CellRenderer value={value} field={field as unknown as FieldMetadata} />;
98+
return <CellRenderer value={value} field={enrichedField as unknown as FieldMetadata} />;
8399
}
84100
}
85101
return String(value);

packages/plugin-detail/src/DetailView.tsx

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { RelatedList } from './RelatedList';
4343
import { RecordComments } from './RecordComments';
4444
import { ActivityTimeline } from './ActivityTimeline';
4545
import { SchemaRenderer } from '@object-ui/react';
46+
import { buildExpandFields } from '@object-ui/core';
4647
import type { DetailViewSchema, DataSource } from '@object-ui/types';
4748

4849
export interface DetailViewProps {
@@ -73,9 +74,12 @@ export const DetailView: React.FC<DetailViewProps> = ({
7374
const [isFavorite, setIsFavorite] = React.useState(false);
7475
const [isInlineEditing, setIsInlineEditing] = React.useState(false);
7576
const [editedValues, setEditedValues] = React.useState<Record<string, any>>({});
77+
const [objectSchema, setObjectSchema] = React.useState<any>(null);
7678

77-
// Fetch data if API or DataSource provided
79+
// Fetch objectSchema + data with $expand when DataSource is provided
7880
React.useEffect(() => {
81+
let isMounted = true;
82+
7983
// If inline data provided, use it
8084
if (schema.data) {
8185
setData(schema.data);
@@ -89,40 +93,73 @@ export const DetailView: React.FC<DetailViewProps> = ({
8993
const resourceId = schema.resourceId;
9094
const prefix = `${objectName}-`;
9195

92-
dataSource.findOne(objectName, resourceId).then((result) => {
93-
if (result) {
94-
setData(result);
95-
setLoading(false);
96-
return;
96+
// Collect all visible field names from sections and top-level fields
97+
const allFields = [
98+
...(schema.sections?.flatMap(s => s.fields) || []),
99+
...(schema.fields || []),
100+
];
101+
102+
// Load objectSchema first, then fetch data with $expand
103+
const schemaPromise = dataSource.getObjectSchema
104+
? dataSource.getObjectSchema(objectName).catch(() => null)
105+
: Promise.resolve(null);
106+
107+
schemaPromise.then((resolvedSchema) => {
108+
if (!isMounted) return;
109+
if (resolvedSchema) {
110+
setObjectSchema(resolvedSchema);
97111
}
98-
// Fallback: try alternate ID format for backward compatibility
99-
const altId = resourceId.startsWith(prefix)
100-
? resourceId.slice(prefix.length) // strip prefix
101-
: `${prefix}${resourceId}`; // prepend prefix
102-
return dataSource.findOne(objectName, altId).then((fallbackResult) => {
103-
setData(fallbackResult);
104-
setLoading(false);
105-
}).catch(() => {
106-
setData(null);
107-
setLoading(false);
112+
113+
// Compute $expand from objectSchema
114+
const expandFields = buildExpandFields(resolvedSchema?.fields, allFields);
115+
const params = expandFields.length > 0 ? { $expand: expandFields } : undefined;
116+
117+
return dataSource.findOne(objectName, resourceId, params).then((result) => {
118+
if (!isMounted) return;
119+
if (result) {
120+
setData(result);
121+
setLoading(false);
122+
return;
123+
}
124+
// Fallback: try alternate ID format for backward compatibility
125+
const altId = resourceId.startsWith(prefix)
126+
? resourceId.slice(prefix.length) // strip prefix
127+
: `${prefix}${resourceId}`; // prepend prefix
128+
return dataSource.findOne(objectName, altId, params).then((fallbackResult) => {
129+
if (isMounted) {
130+
setData(fallbackResult);
131+
setLoading(false);
132+
}
133+
}).catch(() => {
134+
if (isMounted) {
135+
setData(null);
136+
setLoading(false);
137+
}
138+
});
108139
});
109140
}).catch((err) => {
110-
console.error('Failed to fetch detail data:', err);
111-
setLoading(false);
141+
if (isMounted) {
142+
console.error('Failed to fetch detail data:', err);
143+
setLoading(false);
144+
}
112145
});
113146
} else if (schema.api && schema.resourceId) {
114147
setLoading(true);
115148
fetch(`${schema.api}/${schema.resourceId}`)
116149
.then(res => res.json())
117150
.then(result => {
118-
setData(result?.data || result);
151+
if (isMounted) {
152+
setData(result?.data || result);
153+
}
119154
})
120155
.catch(err => {
121156
console.error('Failed to fetch detail data:', err);
122157
})
123-
.finally(() => setLoading(false));
158+
.finally(() => { if (isMounted) setLoading(false); });
124159
}
125-
}, [schema.api, schema.resourceId]);
160+
161+
return () => { isMounted = false; };
162+
}, [schema.api, schema.resourceId, schema.objectName, dataSource, schema.sections, schema.fields]);
126163

127164
const handleBack = React.useCallback(() => {
128165
if (onBack) {
@@ -488,6 +525,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
488525
key={index}
489526
section={section}
490527
data={{ ...data, ...editedValues }}
528+
objectSchema={objectSchema}
491529
isEditing={isInlineEditing}
492530
onFieldChange={handleInlineFieldChange}
493531
/>
@@ -503,6 +541,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
503541
columns: schema.columns,
504542
}}
505543
data={{ ...data, ...editedValues }}
544+
objectSchema={objectSchema}
506545
isEditing={isInlineEditing}
507546
onFieldChange={handleInlineFieldChange}
508547
/>

packages/plugin-detail/src/__tests__/DetailSection.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,69 @@ describe('DetailSection', () => {
252252
expect(grid!.className).toContain('lg:grid-cols-3');
253253
expect(grid!.className).not.toContain('md:grid-cols-3');
254254
});
255+
256+
it('should enrich field type from objectSchema when field.type is not set', () => {
257+
const section = {
258+
title: 'Info',
259+
fields: [{ name: 'status', label: 'Status' }],
260+
columns: 1,
261+
};
262+
const objectSchema = {
263+
fields: {
264+
status: {
265+
type: 'select',
266+
options: [
267+
{ value: 'Draft', label: 'Draft', color: 'yellow' },
268+
{ value: 'Active', label: 'Active', color: 'green' },
269+
],
270+
},
271+
},
272+
};
273+
render(<DetailSection section={section} data={{ status: 'Draft' }} objectSchema={objectSchema} />);
274+
// Should render via SelectCellRenderer (displays label), not plain String()
275+
expect(screen.getByText('Draft')).toBeInTheDocument();
276+
});
277+
278+
it('should render percent field from objectSchema enrichment', () => {
279+
const section = {
280+
title: 'Info',
281+
fields: [{ name: 'discount', label: 'Discount' }],
282+
columns: 1,
283+
};
284+
const objectSchema = {
285+
fields: {
286+
discount: { type: 'percent' },
287+
},
288+
};
289+
render(<DetailSection section={section} data={{ discount: 25 }} objectSchema={objectSchema} />);
290+
// PercentCellRenderer should format as "25%"
291+
expect(screen.getByText(/25/)).toBeInTheDocument();
292+
expect(screen.getByText(/%/)).toBeInTheDocument();
293+
});
294+
295+
it('should fall back to String(value) when neither field.type nor objectSchema provides a type', () => {
296+
const section = {
297+
title: 'Info',
298+
fields: [{ name: 'notes', label: 'Notes' }],
299+
columns: 1,
300+
};
301+
render(<DetailSection section={section} data={{ notes: 'Hello World' }} />);
302+
expect(screen.getByText('Hello World')).toBeInTheDocument();
303+
});
304+
305+
it('should prefer explicit field.type over objectSchema type', () => {
306+
const section = {
307+
title: 'Info',
308+
fields: [{ name: 'name', label: 'Name', type: 'text' as const }],
309+
columns: 1,
310+
};
311+
const objectSchema = {
312+
fields: {
313+
name: { type: 'number' },
314+
},
315+
};
316+
render(<DetailSection section={section} data={{ name: 'Alice' }} objectSchema={objectSchema} />);
317+
// Should use 'text' renderer, not 'number'
318+
expect(screen.getByText('Alice')).toBeInTheDocument();
319+
});
255320
});

packages/plugin-detail/src/__tests__/DetailView.test.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { describe, it, expect, vi } from 'vitest';
10-
import { render, screen, fireEvent } from '@testing-library/react';
10+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
1111
import { DetailView } from '../DetailView';
1212
import type { DetailViewSchema } from '@object-ui/types';
1313

@@ -538,4 +538,86 @@ describe('DetailView', () => {
538538
fireEvent.click(goBackBtn);
539539
expect(onBack).toHaveBeenCalled();
540540
});
541+
542+
it('should call findOne with $expand when objectSchema has lookup fields', async () => {
543+
const mockDataSource = {
544+
getObjectSchema: vi.fn().mockResolvedValue({
545+
fields: {
546+
name: { type: 'text' },
547+
customer: { type: 'lookup', reference_to: 'contact' },
548+
account: { type: 'master_detail', reference_to: 'account' },
549+
},
550+
}),
551+
findOne: vi.fn().mockResolvedValue({ name: 'Order 1', customer: { name: 'Alice' }, account: { name: 'Acme' } }),
552+
} as any;
553+
554+
const schema: DetailViewSchema = {
555+
type: 'detail-view',
556+
title: 'Order Details',
557+
objectName: 'order',
558+
resourceId: 'order-1',
559+
fields: [
560+
{ name: 'name', label: 'Name' },
561+
{ name: 'customer', label: 'Customer' },
562+
{ name: 'account', label: 'Account' },
563+
],
564+
};
565+
566+
render(<DetailView schema={schema} dataSource={mockDataSource} />);
567+
568+
await waitFor(() => {
569+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order');
570+
expect(mockDataSource.findOne).toHaveBeenCalledWith(
571+
'order',
572+
'order-1',
573+
expect.objectContaining({ $expand: expect.arrayContaining(['customer', 'account']) }),
574+
);
575+
});
576+
});
577+
578+
it('should call findOne without $expand when objectSchema has no lookup fields', async () => {
579+
const mockDataSource = {
580+
getObjectSchema: vi.fn().mockResolvedValue({
581+
fields: {
582+
name: { type: 'text' },
583+
email: { type: 'text' },
584+
},
585+
}),
586+
findOne: vi.fn().mockResolvedValue({ name: 'Alice', email: 'alice@example.com' }),
587+
} as any;
588+
589+
const schema: DetailViewSchema = {
590+
type: 'detail-view',
591+
title: 'Contact Details',
592+
objectName: 'contact',
593+
resourceId: 'c1',
594+
fields: [
595+
{ name: 'name', label: 'Name' },
596+
{ name: 'email', label: 'Email' },
597+
],
598+
};
599+
600+
render(<DetailView schema={schema} dataSource={mockDataSource} />);
601+
602+
await waitFor(() => {
603+
expect(mockDataSource.findOne).toHaveBeenCalledWith('contact', 'c1', undefined);
604+
});
605+
});
606+
607+
it('should still work when getObjectSchema is not available on dataSource', async () => {
608+
const mockDataSource = {
609+
findOne: vi.fn().mockResolvedValue({ name: 'Bob' }),
610+
} as any;
611+
612+
const schema: DetailViewSchema = {
613+
type: 'detail-view',
614+
title: 'Contact Details',
615+
objectName: 'contact',
616+
resourceId: 'c1',
617+
fields: [{ name: 'name', label: 'Name' }],
618+
};
619+
620+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
621+
expect(await findByText('Bob')).toBeInTheDocument();
622+
});
541623
});

packages/plugin-list/src/ListView.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ export const ListView: React.FC<ListViewProps> = ({
364364
const [data, setData] = React.useState<any[]>([]);
365365
const [loading, setLoading] = React.useState(false);
366366
const [objectDef, setObjectDef] = React.useState<any>(null);
367+
const [objectDefLoaded, setObjectDefLoaded] = React.useState(false);
367368
const [refreshKey, setRefreshKey] = React.useState(0);
368369
const [dataLimitReached, setDataLimitReached] = React.useState(false);
369370

@@ -492,14 +493,21 @@ export const ListView: React.FC<ListViewProps> = ({
492493
React.useEffect(() => {
493494
let isMounted = true;
494495
const fetchObjectDef = async () => {
495-
if (!dataSource || !schema.objectName) return;
496+
if (!dataSource || !schema.objectName) {
497+
setObjectDefLoaded(true);
498+
return;
499+
}
496500
try {
497501
const def = await dataSource.getObjectSchema(schema.objectName);
498502
if (isMounted) {
499503
setObjectDef(def);
500504
}
501505
} catch (err) {
502506
console.warn("Failed to fetch object schema for ListView:", err);
507+
} finally {
508+
if (isMounted) {
509+
setObjectDefLoaded(true);
510+
}
503511
}
504512
};
505513
fetchObjectDef();
@@ -534,6 +542,9 @@ export const ListView: React.FC<ListViewProps> = ({
534542
setDataLimitReached(false);
535543
return;
536544
}
545+
546+
// Wait for objectDef to load before fetching data so that $expand is computed
547+
if (!objectDefLoaded) return;
537548

538549
const fetchData = async () => {
539550
if (!dataSource || !schema.objectName) return;
@@ -624,7 +635,7 @@ export const ListView: React.FC<ListViewProps> = ({
624635
fetchData();
625636

626637
return () => { isMounted = false; };
627-
}, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields]); // Re-fetch on filter/sort/search change
638+
}, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields, objectDefLoaded]); // Re-fetch on filter/sort/search change
628639

629640
// Available view types based on schema configuration
630641
const availableViews = React.useMemo(() => {

0 commit comments

Comments
 (0)