Skip to content

Commit 6181108

Browse files
Copilothotlong
andcommitted
feat: DetailView/RecordDetailView optimizations (type rendering, responsive layout, virtual scroll, metadata-driven highlights, useMemo, collapseWhenEmpty)
- HeaderHighlight: Use getCellRenderer for type-aware display (currency, badge, etc.) - autoLayout: Add containerWidth parameter for responsive column capping - RecordChatterPanel: Add collapseWhenEmpty prop for auto-collapsing empty panels - DetailSection: Add virtualScroll option for progressive batch rendering - RecordDetailView: Wrap detailSchema in useMemo for performance - RecordDetailView: Remove hardcoded HIGHLIGHT_FIELD_NAMES, read from objectDef metadata only - Add comprehensive tests for all changes Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent d33c135 commit 6181108

11 files changed

Lines changed: 308 additions & 30 deletions

File tree

apps/console/src/components/RecordDetailView.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ 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-
3633
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
3734
const { objectName, recordId } = useParams();
3835
const { showDebug } = useMetadataInspector();
@@ -398,16 +395,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
398395
});
399396
})();
400397

401-
// Build highlightFields: prefer explicit config, fallback to auto-detect key fields
402-
const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields;
403-
const highlightFields: HighlightField[] = explicitHighlight
404-
?? Object.entries(objectDef.fields || {})
405-
.filter(([key]: [string, any]) => HIGHLIGHT_FIELD_NAMES.includes(key))
406-
.map(([key, def]: [string, any]) => ({
407-
name: key,
408-
label: def.label || key,
409-
...(def.type && { type: def.type }),
410-
}));
398+
// Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
399+
const highlightFields: HighlightField[] = objectDef.views?.detail?.highlightFields ?? [];
411400

412401
// Build sectionGroups from objectDef detail/form config if available
413402
const sectionGroups: SectionGroup[] | undefined =
@@ -421,7 +410,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
421410
data: childRelatedData[childObject] || [],
422411
}));
423412

424-
const detailSchema: DetailViewSchema = {
413+
const detailSchema: DetailViewSchema = useMemo(() => ({
425414
type: 'detail-view',
426415
objectName: objectDef.name,
427416
resourceId: pureRecordId,
@@ -443,7 +432,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
443432
actions: recordHeaderActions,
444433
} as any],
445434
}),
446-
};
435+
}), [objectDef, pureRecordId, related, childRelatedData, actionRefreshKey]);
447436

448437
return (
449438
<div className="h-full bg-background overflow-hidden flex flex-col relative">
@@ -482,13 +471,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
482471
<RecordChatterPanel
483472
config={{
484473
position: 'bottom',
485-
collapsible: false,
474+
collapsible: true,
486475
feed: {
487476
enableReactions: true,
488477
enableThreading: true,
489478
showCommentInput: true,
490479
},
491480
}}
481+
collapseWhenEmpty
492482
items={feedItems}
493483
onAddComment={handleAddComment}
494484
onAddReply={handleAddReply}

packages/plugin-detail/src/DetailSection.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number
5353
return '';
5454
}
5555

56+
export interface VirtualScrollOptions {
57+
/** Enable virtual scrolling for large field sets */
58+
enabled?: boolean;
59+
/** Height of each field row in px (default: 60) */
60+
itemHeight?: number;
61+
/** Number of extra items to render above/below the visible area (default: 3) */
62+
overscan?: number;
63+
}
64+
5665
export interface DetailSectionProps {
5766
section: DetailViewSectionType;
5867
data?: any;
@@ -65,6 +74,8 @@ export interface DetailSectionProps {
6574
isEditing?: boolean;
6675
/** Callback when a field value changes during inline editing */
6776
onFieldChange?: (field: string, value: any) => void;
77+
/** Virtual scrolling configuration for sections with many fields */
78+
virtualScroll?: VirtualScrollOptions;
6879
}
6980

7081
export const DetailSection: React.FC<DetailSectionProps> = ({
@@ -75,9 +86,11 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
7586
objectName,
7687
isEditing = false,
7788
onFieldChange,
89+
virtualScroll,
7890
}) => {
7991
const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
8092
const [copiedField, setCopiedField] = React.useState<string | null>(null);
93+
const [visibleCount, setVisibleCount] = React.useState<number | undefined>(undefined);
8194
const { t } = useDetailTranslation();
8295
const { fieldLabel } = useSafeFieldLabel();
8396

@@ -213,6 +226,30 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
213226
);
214227
};
215228

229+
// Virtual scroll: progressive batch rendering for large field sets
230+
const vsEnabled = virtualScroll?.enabled === true;
231+
const vsBatchSize = virtualScroll?.overscan ?? 20;
232+
233+
React.useEffect(() => {
234+
if (!vsEnabled) {
235+
setVisibleCount(undefined);
236+
return;
237+
}
238+
// Start with a batch, then progressively reveal more
239+
if (layoutFields.length <= vsBatchSize) {
240+
setVisibleCount(undefined);
241+
return;
242+
}
243+
setVisibleCount(vsBatchSize);
244+
const timer = setTimeout(() => setVisibleCount(undefined), 100);
245+
return () => clearTimeout(timer);
246+
// eslint-disable-next-line react-hooks/exhaustive-deps
247+
}, [vsEnabled, layoutFields.length, vsBatchSize]);
248+
249+
const renderedFields = visibleCount !== undefined
250+
? layoutFields.slice(0, visibleCount)
251+
: layoutFields;
252+
216253
const content = (
217254
<div
218255
className={cn(
@@ -223,7 +260,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
223260
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
224261
)}
225262
>
226-
{layoutFields.map(renderField)}
263+
{renderedFields.map(renderField)}
227264
</div>
228265
);
229266

packages/plugin-detail/src/DetailView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
600600

601601
{/* Header Highlight Area */}
602602
{schema.highlightFields && schema.highlightFields.length > 0 && (
603-
<HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} />
603+
<HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} objectSchema={objectSchema} />
604604
)}
605605

606606
{/* Auto Tabs mode: wrap sections, related, activity into tabs */}

packages/plugin-detail/src/HeaderHighlight.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
import * as React from 'react';
1010
import { cn, Card, CardContent } from '@object-ui/components';
11-
import type { HighlightField } from '@object-ui/types';
11+
import type { HighlightField, FieldMetadata } from '@object-ui/types';
12+
import { getCellRenderer } from '@object-ui/fields';
1213
import { useSafeFieldLabel } from '@object-ui/react';
1314

1415
export interface HeaderHighlightProps {
@@ -17,13 +18,16 @@ export interface HeaderHighlightProps {
1718
className?: string;
1819
/** Object name for i18n field label resolution */
1920
objectName?: string;
21+
/** Object schema for field metadata enrichment */
22+
objectSchema?: any;
2023
}
2124

2225
export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
2326
fields,
2427
data,
2528
className,
2629
objectName,
30+
objectSchema,
2731
}) => {
2832
const { fieldLabel } = useSafeFieldLabel();
2933
if (!fields.length || !data) return null;
@@ -48,14 +52,35 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
4852
)}>
4953
{visibleFields.map((field) => {
5054
const value = data[field.name];
55+
// Enrich field with objectSchema metadata for type-aware rendering
56+
const objectDefField = objectSchema?.fields?.[field.name];
57+
const resolvedType = field.type || objectDefField?.type;
58+
const enrichedField: Record<string, any> = { ...field };
59+
if (objectDefField) {
60+
if (!field.type && objectDefField.type) enrichedField.type = objectDefField.type;
61+
if (objectDefField.options && !enrichedField.options) enrichedField.options = objectDefField.options;
62+
if (objectDefField.currency && !enrichedField.currency) enrichedField.currency = objectDefField.currency;
63+
if (objectDefField.precision !== undefined && enrichedField.precision === undefined) enrichedField.precision = objectDefField.precision;
64+
if (objectDefField.format && !enrichedField.format) enrichedField.format = objectDefField.format;
65+
}
66+
67+
// Use type-aware cell renderer when field type is available
68+
let displayValue: React.ReactNode = String(value);
69+
if (resolvedType) {
70+
const CellRenderer = getCellRenderer(resolvedType);
71+
if (CellRenderer) {
72+
displayValue = <CellRenderer value={value} field={enrichedField as unknown as FieldMetadata} />;
73+
}
74+
}
75+
5176
return (
5277
<div key={field.name} className="flex flex-col gap-0.5">
5378
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
5479
{field.icon && <span className="mr-1">{field.icon}</span>}
5580
{fieldLabel(objectName || '', field.name, field.label)}
5681
</span>
5782
<span className="text-sm font-semibold truncate">
58-
{String(value)}
83+
{displayValue}
5984
</span>
6085
</div>
6186
);

packages/plugin-detail/src/RecordChatterPanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface RecordChatterPanelProps {
3838
filterMode?: FeedFilterMode;
3939
/** Called when filter changes */
4040
onFilterChange?: (mode: FeedFilterMode) => void;
41+
/** When true, auto-collapse panel when there are no feed items */
42+
collapseWhenEmpty?: boolean;
4143
className?: string;
4244
}
4345

@@ -63,12 +65,13 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
6365
onToggleSubscription,
6466
filterMode,
6567
onFilterChange,
68+
collapseWhenEmpty = false,
6669
className,
6770
}) => {
6871
const position = config?.position ?? 'right';
6972
const width = config?.width ?? '360px';
7073
const collapsible = config?.collapsible ?? true;
71-
const defaultCollapsed = config?.defaultCollapsed ?? false;
74+
const defaultCollapsed = (collapseWhenEmpty && items.length === 0) || (config?.defaultCollapsed ?? false);
7275

7376
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
7477

@@ -142,6 +145,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
142145
onToggleSubscription={onToggleSubscription}
143146
filterMode={filterMode}
144147
onFilterChange={onFilterChange}
148+
collapseWhenEmpty={collapseWhenEmpty}
145149
className="border-0 shadow-none"
146150
/>
147151
</div>
@@ -194,6 +198,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
194198
onToggleSubscription={onToggleSubscription}
195199
filterMode={filterMode}
196200
onFilterChange={onFilterChange}
201+
collapseWhenEmpty={collapseWhenEmpty}
197202
/>
198203
</div>
199204
)}

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,67 @@ describe('DetailSection', () => {
391391
});
392392
});
393393
});
394+
395+
it('should initially render a batch when virtualScroll is enabled with many fields', () => {
396+
const section = {
397+
title: 'Virtual',
398+
fields: Array.from({ length: 50 }, (_, i) => ({
399+
name: `field_${i}`,
400+
label: `Field ${i}`,
401+
type: 'text',
402+
})),
403+
};
404+
const { container } = render(
405+
<DetailSection
406+
section={section}
407+
data={{}}
408+
virtualScroll={{ enabled: true, overscan: 10 }}
409+
/>
410+
);
411+
const grid = container.querySelector('.grid');
412+
expect(grid).toBeTruthy();
413+
// Initially should render only the batch (10 fields), not all 50
414+
const fieldElements = grid!.children;
415+
expect(fieldElements.length).toBeLessThanOrEqual(10);
416+
});
417+
418+
it('should render all fields when virtualScroll is disabled', () => {
419+
const section = {
420+
title: 'No Virtual',
421+
fields: Array.from({ length: 50 }, (_, i) => ({
422+
name: `field_${i}`,
423+
label: `Field ${i}`,
424+
type: 'text',
425+
})),
426+
};
427+
const { container } = render(
428+
<DetailSection section={section} data={{}} />
429+
);
430+
const grid = container.querySelector('.grid');
431+
expect(grid).toBeTruthy();
432+
expect(grid!.children.length).toBe(50);
433+
});
434+
435+
it('should render all fields when virtualScroll is enabled but field count is below batch size', () => {
436+
const section = {
437+
title: 'Small',
438+
fields: Array.from({ length: 5 }, (_, i) => ({
439+
name: `field_${i}`,
440+
label: `Field ${i}`,
441+
type: 'text',
442+
})),
443+
};
444+
const { container } = render(
445+
<DetailSection
446+
section={section}
447+
data={{}}
448+
virtualScroll={{ enabled: true, overscan: 20 }}
449+
/>
450+
);
451+
const grid = container.querySelector('.grid');
452+
expect(grid).toBeTruthy();
453+
expect(grid!.children.length).toBe(5);
454+
});
394455
});
395456

396457
describe('getResponsiveSpanClass', () => {

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,65 @@ describe('HeaderHighlight', () => {
6565
render(<HeaderHighlight fields={fieldsWithIcon} data={{ revenue: '$5M' }} />);
6666
expect(screen.getByText('💰')).toBeInTheDocument();
6767
});
68+
69+
it('should render currency fields formatted via getCellRenderer when type is provided', () => {
70+
const currencyFields: HighlightField[] = [
71+
{ name: 'amount', label: 'Amount', type: 'currency' },
72+
];
73+
render(<HeaderHighlight fields={currencyFields} data={{ amount: 250000 }} />);
74+
// CurrencyCellRenderer should format the number — should NOT show raw "250000"
75+
expect(screen.queryByText('250000')).not.toBeInTheDocument();
76+
expect(screen.getByText(/250,000/)).toBeInTheDocument();
77+
});
78+
79+
it('should render select fields as badge via getCellRenderer when type is provided', () => {
80+
const selectFields: HighlightField[] = [
81+
{ name: 'stage', label: 'Stage', type: 'select' },
82+
];
83+
render(
84+
<HeaderHighlight
85+
fields={selectFields}
86+
data={{ stage: 'prospecting' }}
87+
objectSchema={{
88+
fields: {
89+
stage: {
90+
type: 'select',
91+
options: [
92+
{ value: 'prospecting', label: 'Prospecting', color: 'blue' },
93+
],
94+
},
95+
},
96+
}}
97+
/>
98+
);
99+
expect(screen.getByText('Prospecting')).toBeInTheDocument();
100+
});
101+
102+
it('should enrich field type from objectSchema when field.type is not set', () => {
103+
const fieldsNoType: HighlightField[] = [
104+
{ name: 'amount', label: 'Amount' },
105+
];
106+
render(
107+
<HeaderHighlight
108+
fields={fieldsNoType}
109+
data={{ amount: 5000 }}
110+
objectSchema={{
111+
fields: {
112+
amount: { type: 'currency', currency: 'USD' },
113+
},
114+
}}
115+
/>
116+
);
117+
// Should use CurrencyCellRenderer, not raw String()
118+
expect(screen.queryByText('5000')).not.toBeInTheDocument();
119+
expect(screen.getByText(/5,000/)).toBeInTheDocument();
120+
});
121+
122+
it('should fall back to String(value) when no type info is available', () => {
123+
const fieldsNoType: HighlightField[] = [
124+
{ name: 'custom', label: 'Custom' },
125+
];
126+
render(<HeaderHighlight fields={fieldsNoType} data={{ custom: 'raw-value' }} />);
127+
expect(screen.getByText('raw-value')).toBeInTheDocument();
128+
});
68129
});

0 commit comments

Comments
 (0)