Skip to content

Commit 1dc97ed

Browse files
authored
Merge pull request #1040 from objectstack-ai/copilot/optimize-detailview-performance
2 parents 4a188c5 + 9e2b980 commit 1dc97ed

File tree

14 files changed

+615
-50
lines changed

14 files changed

+615
-50
lines changed

ROADMAP.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,34 @@ All 313 `@object-ui/fields` tests pass.
14141414

14151415
---
14161416

1417-
### DetailView Rendering Optimization (March 2026)
1417+
### DetailView/RecordDetailView SDUI Optimization (March 2026)
1418+
1419+
> Type-aware rendering, responsive layout, virtual scrolling, metadata-driven highlights, performance optimization, and activity panel collapse-when-empty.
1420+
1421+
**HeaderHighlight (`@object-ui/plugin-detail`):**
1422+
- [x] Use `getCellRenderer` for type-aware display (currency → `$250,000.00`, select → Badge, etc.) instead of raw `String(value)`
1423+
- [x] Add `objectSchema` prop for field metadata enrichment (type, options, currency, precision, format)
1424+
1425+
**autoLayout (`@object-ui/plugin-detail`):**
1426+
- [x] `inferDetailColumns` accepts optional `containerWidth` for responsive column capping (`<640px→1col`, `<900px→max 2col`)
1427+
- [x] `applyDetailAutoLayout` passes through `containerWidth` parameter
1428+
1429+
**RecordChatterPanel (`@object-ui/plugin-detail`):**
1430+
- [x] `collapseWhenEmpty` prop: auto-collapse panel when no feed items exist
1431+
- [x] Pass `collapseWhenEmpty` through to embedded `RecordActivityTimeline`
1432+
1433+
**DetailSection (`@object-ui/plugin-detail`):**
1434+
- [x] `virtualScroll` config (`VirtualScrollOptions`): progressive batch rendering for sections with many fields
1435+
- [x] Export `VirtualScrollOptions` type from package index
1436+
1437+
**RecordDetailView (`apps/console`):**
1438+
- [x] Wrap `detailSchema` construction with `useMemo` (deps: `objectDef`, `pureRecordId`, `related`, `childRelatedData`, `actionRefreshKey`)
1439+
- [x] Remove hardcoded `HIGHLIGHT_FIELD_NAMES`; read exclusively from `objectDef.views.detail.highlightFields` (no fallback)
1440+
- [x] Enable `collapseWhenEmpty` + `collapsible: true` on `RecordChatterPanel`
1441+
1442+
**Tests:** 125 plugin-detail tests passing (17 new) covering HeaderHighlight type-aware rendering, autoLayout responsive columns, RecordChatterPanel collapseWhenEmpty, DetailSection virtualScroll.
1443+
1444+
---
14181445

14191446
> Platform-level DetailView enhancements: auto-grouping from form sections, empty value hiding, smart header with primaryField/summaryFields, responsive breakpoint fix, and activity timeline collapse.
14201447

apps/console/src/components/RecordDetailView.tsx

Lines changed: 7 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,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
443432
actions: recordHeaderActions,
444433
} as any],
445434
}),
446-
};
435+
// eslint-disable-next-line react-hooks/exhaustive-deps
436+
}), [objectDef.name, pureRecordId, childRelatedData, actionRefreshKey]);
447437

448438
return (
449439
<div className="h-full bg-background overflow-hidden flex flex-col relative">
@@ -482,13 +472,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
482472
<RecordChatterPanel
483473
config={{
484474
position: 'bottom',
485-
collapsible: false,
475+
collapsible: true,
486476
feed: {
487477
enableReactions: true,
488478
enableThreading: true,
489479
showCommentInput: true,
490480
},
491481
}}
482+
collapseWhenEmpty
492483
items={feedItems}
493484
onAddComment={handleAddComment}
494485
onAddReply={handleAddReply}

packages/fields/src/__tests__/cell-renderers.test.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,3 +1041,132 @@ describe('PercentCellRenderer progress-type fields', () => {
10411041
expect(bar).toHaveAttribute('aria-valuenow', '50');
10421042
});
10431043
});
1044+
1045+
// =========================================================================
1046+
// Object value safety (coerceToSafeValue) — prevents React error #310
1047+
// =========================================================================
1048+
import {
1049+
NumberCellRenderer,
1050+
CurrencyCellRenderer,
1051+
FormulaCellRenderer,
1052+
coerceToSafeValue,
1053+
} from '../index';
1054+
1055+
describe('coerceToSafeValue', () => {
1056+
it('should pass through primitives unchanged', () => {
1057+
expect(coerceToSafeValue('hello')).toBe('hello');
1058+
expect(coerceToSafeValue(42)).toBe(42);
1059+
expect(coerceToSafeValue(true)).toBe(true);
1060+
expect(coerceToSafeValue(null)).toBe(null);
1061+
expect(coerceToSafeValue(undefined)).toBe(undefined);
1062+
});
1063+
1064+
it('should extract number from MongoDB $numberDecimal', () => {
1065+
expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
1066+
});
1067+
1068+
it('should extract string from MongoDB $oid', () => {
1069+
expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
1070+
});
1071+
1072+
it('should extract string from MongoDB $date', () => {
1073+
expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
1074+
});
1075+
1076+
it('should extract name from expanded reference object', () => {
1077+
expect(coerceToSafeValue({ _id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
1078+
});
1079+
1080+
it('should extract label when name is not present', () => {
1081+
expect(coerceToSafeValue({ _id: 'x', label: 'Active' })).toBe('Active');
1082+
});
1083+
1084+
it('should fall back to _id when no name/label', () => {
1085+
expect(coerceToSafeValue({ _id: 'abc123' })).toBe('abc123');
1086+
});
1087+
1088+
it('should handle arrays of primitives', () => {
1089+
expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
1090+
});
1091+
1092+
it('should handle arrays of objects', () => {
1093+
expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
1094+
});
1095+
1096+
it('should convert Date to ISO string', () => {
1097+
const d = new Date('2024-06-15T12:00:00Z');
1098+
expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
1099+
});
1100+
});
1101+
1102+
describe('NumberCellRenderer object safety', () => {
1103+
it('should handle MongoDB $numberDecimal without crashing', () => {
1104+
const { container } = render(
1105+
<NumberCellRenderer
1106+
value={{ $numberDecimal: '250000' }}
1107+
field={{ name: 'amount', type: 'number' } as any}
1108+
/>
1109+
);
1110+
expect(container.innerHTML).not.toBe('');
1111+
expect(screen.getByText('250,000')).toBeInTheDocument();
1112+
});
1113+
1114+
it('should handle expanded reference object without crashing', () => {
1115+
const { container } = render(
1116+
<NumberCellRenderer
1117+
value={{ _id: 'abc', name: 'Not a number' }}
1118+
field={{ name: 'amount', type: 'number' } as any}
1119+
/>
1120+
);
1121+
expect(container.innerHTML).not.toBe('');
1122+
// Should render the extracted name string, not crash
1123+
expect(screen.getByText('Not a number')).toBeInTheDocument();
1124+
});
1125+
});
1126+
1127+
describe('CurrencyCellRenderer object safety', () => {
1128+
it('should handle MongoDB $numberDecimal', () => {
1129+
const { container } = render(
1130+
<CurrencyCellRenderer
1131+
value={{ $numberDecimal: '5000' }}
1132+
field={{ name: 'price', type: 'currency', currency: 'USD' } as any}
1133+
/>
1134+
);
1135+
expect(container.innerHTML).not.toBe('');
1136+
expect(screen.getByText(/5,000/)).toBeInTheDocument();
1137+
});
1138+
});
1139+
1140+
describe('TextCellRenderer object safety', () => {
1141+
it('should extract name from object instead of [object Object]', () => {
1142+
render(
1143+
<TextCellRenderer
1144+
value={{ _id: 'abc', name: 'Acme Corp' }}
1145+
field={{ name: 'company', type: 'text' } as any}
1146+
/>
1147+
);
1148+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1149+
});
1150+
1151+
it('should handle arrays of objects', () => {
1152+
render(
1153+
<TextCellRenderer
1154+
value={[{ name: 'Alice' }, { name: 'Bob' }]}
1155+
field={{ name: 'contacts', type: 'text' } as any}
1156+
/>
1157+
);
1158+
expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
1159+
});
1160+
});
1161+
1162+
describe('FormulaCellRenderer object safety', () => {
1163+
it('should extract value from MongoDB $numberDecimal', () => {
1164+
render(
1165+
<FormulaCellRenderer
1166+
value={{ $numberDecimal: '42.5' }}
1167+
field={{ name: 'calc', type: 'formula' } as any}
1168+
/>
1169+
);
1170+
expect(screen.getByText('42.5')).toBeInTheDocument();
1171+
});
1172+
});

0 commit comments

Comments
 (0)