From d33c13500483f65c4d6d77a8fa29cf5601e53d57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:04:16 +0000 Subject: [PATCH 1/8] Initial plan From 6181108c6d01a2ee4679b6c3dcb38ddd63711d60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:15:26 +0000 Subject: [PATCH 2/8] 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> --- .../src/components/RecordDetailView.tsx | 22 ++----- packages/plugin-detail/src/DetailSection.tsx | 39 +++++++++++- packages/plugin-detail/src/DetailView.tsx | 2 +- .../plugin-detail/src/HeaderHighlight.tsx | 29 ++++++++- .../plugin-detail/src/RecordChatterPanel.tsx | 7 ++- .../src/__tests__/DetailSection.test.tsx | 61 +++++++++++++++++++ .../src/__tests__/HeaderHighlight.test.tsx | 61 +++++++++++++++++++ .../src/__tests__/RecordChatterPanel.test.tsx | 38 ++++++++++++ .../src/__tests__/autoLayout.test.ts | 44 +++++++++++++ packages/plugin-detail/src/autoLayout.ts | 33 +++++++--- packages/plugin-detail/src/index.tsx | 2 +- 11 files changed, 308 insertions(+), 30 deletions(-) diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index 318b098dcf..241d14ed22 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -30,9 +30,6 @@ interface RecordDetailViewProps { const FALLBACK_USER = { id: 'current-user', name: 'Demo User' }; -/** Field names automatically promoted to the highlight banner when present. */ -const HIGHLIGHT_FIELD_NAMES = ['status', 'stage', 'priority', 'category', 'type', 'owner', 'amount']; - export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) { const { objectName, recordId } = useParams(); const { showDebug } = useMetadataInspector(); @@ -398,16 +395,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi }); })(); - // Build highlightFields: prefer explicit config, fallback to auto-detect key fields - const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields; - const highlightFields: HighlightField[] = explicitHighlight - ?? Object.entries(objectDef.fields || {}) - .filter(([key]: [string, any]) => HIGHLIGHT_FIELD_NAMES.includes(key)) - .map(([key, def]: [string, any]) => ({ - name: key, - label: def.label || key, - ...(def.type && { type: def.type }), - })); + // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback) + const highlightFields: HighlightField[] = objectDef.views?.detail?.highlightFields ?? []; // Build sectionGroups from objectDef detail/form config if available const sectionGroups: SectionGroup[] | undefined = @@ -421,7 +410,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi data: childRelatedData[childObject] || [], })); - const detailSchema: DetailViewSchema = { + const detailSchema: DetailViewSchema = useMemo(() => ({ type: 'detail-view', objectName: objectDef.name, resourceId: pureRecordId, @@ -443,7 +432,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi actions: recordHeaderActions, } as any], }), - }; + }), [objectDef, pureRecordId, related, childRelatedData, actionRefreshKey]); return (
@@ -482,13 +471,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi void; + /** Virtual scrolling configuration for sections with many fields */ + virtualScroll?: VirtualScrollOptions; } export const DetailSection: React.FC = ({ @@ -75,9 +86,11 @@ export const DetailSection: React.FC = ({ objectName, isEditing = false, onFieldChange, + virtualScroll, }) => { const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false); const [copiedField, setCopiedField] = React.useState(null); + const [visibleCount, setVisibleCount] = React.useState(undefined); const { t } = useDetailTranslation(); const { fieldLabel } = useSafeFieldLabel(); @@ -213,6 +226,30 @@ export const DetailSection: React.FC = ({ ); }; + // Virtual scroll: progressive batch rendering for large field sets + const vsEnabled = virtualScroll?.enabled === true; + const vsBatchSize = virtualScroll?.overscan ?? 20; + + React.useEffect(() => { + if (!vsEnabled) { + setVisibleCount(undefined); + return; + } + // Start with a batch, then progressively reveal more + if (layoutFields.length <= vsBatchSize) { + setVisibleCount(undefined); + return; + } + setVisibleCount(vsBatchSize); + const timer = setTimeout(() => setVisibleCount(undefined), 100); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vsEnabled, layoutFields.length, vsBatchSize]); + + const renderedFields = visibleCount !== undefined + ? layoutFields.slice(0, visibleCount) + : layoutFields; + const content = (
= ({ "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" )} > - {layoutFields.map(renderField)} + {renderedFields.map(renderField)}
); diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index 242b40a85f..2d5069873f 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -600,7 +600,7 @@ export const DetailView: React.FC = ({ {/* Header Highlight Area */} {schema.highlightFields && schema.highlightFields.length > 0 && ( - + )} {/* Auto Tabs mode: wrap sections, related, activity into tabs */} diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index ac5d4979f9..370ba3eca3 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -8,7 +8,8 @@ import * as React from 'react'; import { cn, Card, CardContent } from '@object-ui/components'; -import type { HighlightField } from '@object-ui/types'; +import type { HighlightField, FieldMetadata } from '@object-ui/types'; +import { getCellRenderer } from '@object-ui/fields'; import { useSafeFieldLabel } from '@object-ui/react'; export interface HeaderHighlightProps { @@ -17,6 +18,8 @@ export interface HeaderHighlightProps { className?: string; /** Object name for i18n field label resolution */ objectName?: string; + /** Object schema for field metadata enrichment */ + objectSchema?: any; } export const HeaderHighlight: React.FC = ({ @@ -24,6 +27,7 @@ export const HeaderHighlight: React.FC = ({ data, className, objectName, + objectSchema, }) => { const { fieldLabel } = useSafeFieldLabel(); if (!fields.length || !data) return null; @@ -48,6 +52,27 @@ export const HeaderHighlight: React.FC = ({ )}> {visibleFields.map((field) => { const value = data[field.name]; + // Enrich field with objectSchema metadata for type-aware rendering + const objectDefField = objectSchema?.fields?.[field.name]; + const resolvedType = field.type || objectDefField?.type; + const enrichedField: Record = { ...field }; + if (objectDefField) { + if (!field.type && objectDefField.type) enrichedField.type = objectDefField.type; + if (objectDefField.options && !enrichedField.options) enrichedField.options = objectDefField.options; + if (objectDefField.currency && !enrichedField.currency) enrichedField.currency = objectDefField.currency; + if (objectDefField.precision !== undefined && enrichedField.precision === undefined) enrichedField.precision = objectDefField.precision; + if (objectDefField.format && !enrichedField.format) enrichedField.format = objectDefField.format; + } + + // Use type-aware cell renderer when field type is available + let displayValue: React.ReactNode = String(value); + if (resolvedType) { + const CellRenderer = getCellRenderer(resolvedType); + if (CellRenderer) { + displayValue = ; + } + } + return (
@@ -55,7 +80,7 @@ export const HeaderHighlight: React.FC = ({ {fieldLabel(objectName || '', field.name, field.label)} - {String(value)} + {displayValue}
); diff --git a/packages/plugin-detail/src/RecordChatterPanel.tsx b/packages/plugin-detail/src/RecordChatterPanel.tsx index a49a44efc4..4a4c082d47 100644 --- a/packages/plugin-detail/src/RecordChatterPanel.tsx +++ b/packages/plugin-detail/src/RecordChatterPanel.tsx @@ -38,6 +38,8 @@ export interface RecordChatterPanelProps { filterMode?: FeedFilterMode; /** Called when filter changes */ onFilterChange?: (mode: FeedFilterMode) => void; + /** When true, auto-collapse panel when there are no feed items */ + collapseWhenEmpty?: boolean; className?: string; } @@ -63,12 +65,13 @@ export const RecordChatterPanel: React.FC = ({ onToggleSubscription, filterMode, onFilterChange, + collapseWhenEmpty = false, className, }) => { const position = config?.position ?? 'right'; const width = config?.width ?? '360px'; const collapsible = config?.collapsible ?? true; - const defaultCollapsed = config?.defaultCollapsed ?? false; + const defaultCollapsed = (collapseWhenEmpty && items.length === 0) || (config?.defaultCollapsed ?? false); const [collapsed, setCollapsed] = React.useState(defaultCollapsed); @@ -142,6 +145,7 @@ export const RecordChatterPanel: React.FC = ({ onToggleSubscription={onToggleSubscription} filterMode={filterMode} onFilterChange={onFilterChange} + collapseWhenEmpty={collapseWhenEmpty} className="border-0 shadow-none" />
@@ -194,6 +198,7 @@ export const RecordChatterPanel: React.FC = ({ onToggleSubscription={onToggleSubscription} filterMode={filterMode} onFilterChange={onFilterChange} + collapseWhenEmpty={collapseWhenEmpty} /> )} diff --git a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx index 239d91cc3c..a47a010748 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx @@ -391,6 +391,67 @@ describe('DetailSection', () => { }); }); }); + + it('should initially render a batch when virtualScroll is enabled with many fields', () => { + const section = { + title: 'Virtual', + fields: Array.from({ length: 50 }, (_, i) => ({ + name: `field_${i}`, + label: `Field ${i}`, + type: 'text', + })), + }; + const { container } = render( + + ); + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + // Initially should render only the batch (10 fields), not all 50 + const fieldElements = grid!.children; + expect(fieldElements.length).toBeLessThanOrEqual(10); + }); + + it('should render all fields when virtualScroll is disabled', () => { + const section = { + title: 'No Virtual', + fields: Array.from({ length: 50 }, (_, i) => ({ + name: `field_${i}`, + label: `Field ${i}`, + type: 'text', + })), + }; + const { container } = render( + + ); + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + expect(grid!.children.length).toBe(50); + }); + + it('should render all fields when virtualScroll is enabled but field count is below batch size', () => { + const section = { + title: 'Small', + fields: Array.from({ length: 5 }, (_, i) => ({ + name: `field_${i}`, + label: `Field ${i}`, + type: 'text', + })), + }; + const { container } = render( + + ); + const grid = container.querySelector('.grid'); + expect(grid).toBeTruthy(); + expect(grid!.children.length).toBe(5); + }); }); describe('getResponsiveSpanClass', () => { diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx index 8ff2718b3a..ad6f9f5729 100644 --- a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx @@ -65,4 +65,65 @@ describe('HeaderHighlight', () => { render(); expect(screen.getByText('💰')).toBeInTheDocument(); }); + + it('should render currency fields formatted via getCellRenderer when type is provided', () => { + const currencyFields: HighlightField[] = [ + { name: 'amount', label: 'Amount', type: 'currency' }, + ]; + render(); + // CurrencyCellRenderer should format the number — should NOT show raw "250000" + expect(screen.queryByText('250000')).not.toBeInTheDocument(); + expect(screen.getByText(/250,000/)).toBeInTheDocument(); + }); + + it('should render select fields as badge via getCellRenderer when type is provided', () => { + const selectFields: HighlightField[] = [ + { name: 'stage', label: 'Stage', type: 'select' }, + ]; + render( + + ); + expect(screen.getByText('Prospecting')).toBeInTheDocument(); + }); + + it('should enrich field type from objectSchema when field.type is not set', () => { + const fieldsNoType: HighlightField[] = [ + { name: 'amount', label: 'Amount' }, + ]; + render( + + ); + // Should use CurrencyCellRenderer, not raw String() + expect(screen.queryByText('5000')).not.toBeInTheDocument(); + expect(screen.getByText(/5,000/)).toBeInTheDocument(); + }); + + it('should fall back to String(value) when no type info is available', () => { + const fieldsNoType: HighlightField[] = [ + { name: 'custom', label: 'Custom' }, + ]; + render(); + expect(screen.getByText('raw-value')).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx index ed06f54d79..ecf2d8e8e9 100644 --- a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx +++ b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx @@ -224,4 +224,42 @@ describe('RecordChatterPanel', () => { expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); }); }); + + describe('collapseWhenEmpty', () => { + it('should auto-collapse when empty and collapseWhenEmpty is true (inline mode)', () => { + render( + , + ); + // Should be collapsed because items is empty + expect(screen.getByLabelText('Show discussion')).toBeInTheDocument(); + }); + + it('should not auto-collapse when items exist and collapseWhenEmpty is true', () => { + render( + , + ); + // Should be expanded because there are items + expect(screen.getByText('Activity')).toBeInTheDocument(); + }); + + it('should auto-collapse sidebar when empty and collapseWhenEmpty is true', () => { + render( + , + ); + // Should be collapsed + expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/plugin-detail/src/__tests__/autoLayout.test.ts b/packages/plugin-detail/src/__tests__/autoLayout.test.ts index 57effc89a7..1537ce44a1 100644 --- a/packages/plugin-detail/src/__tests__/autoLayout.test.ts +++ b/packages/plugin-detail/src/__tests__/autoLayout.test.ts @@ -37,6 +37,33 @@ describe('Detail Auto-Layout', () => { expect(inferDetailColumns(15)).toBe(3); expect(inferDetailColumns(50)).toBe(3); }); + + it('should cap to 1 column when containerWidth < 640', () => { + expect(inferDetailColumns(15, 500)).toBe(1); + expect(inferDetailColumns(5, 639)).toBe(1); + }); + + it('should cap to 2 columns when containerWidth < 900', () => { + expect(inferDetailColumns(15, 800)).toBe(2); + expect(inferDetailColumns(15, 640)).toBe(2); + expect(inferDetailColumns(5, 899)).toBe(2); + }); + + it('should not cap columns when containerWidth >= 900', () => { + expect(inferDetailColumns(15, 900)).toBe(3); + expect(inferDetailColumns(15, 1200)).toBe(3); + }); + + it('should not cap below field-count inference', () => { + // 2 fields → 1 column, containerWidth 800 would cap at 2, but inference says 1 + expect(inferDetailColumns(2, 800)).toBe(1); + }); + + it('should still work without containerWidth (backward compatible)', () => { + expect(inferDetailColumns(15)).toBe(3); + expect(inferDetailColumns(5)).toBe(2); + expect(inferDetailColumns(2)).toBe(1); + }); }); describe('isWideFieldType', () => { @@ -180,5 +207,22 @@ describe('Detail Auto-Layout', () => { expect(result.columns).toBe(2); expect(result.fields.length).toBe(8); }); + + it('should cap columns based on containerWidth when provided', () => { + const fields = Array.from({ length: 15 }, (_, i) => ({ + name: `f${i}`, label: `F${i}`, type: 'text', + })); + // Narrow container: should cap to 1 + const result = applyDetailAutoLayout(fields, undefined, 500); + expect(result.columns).toBe(1); + }); + + it('should still respect explicit schemaColumns regardless of containerWidth', () => { + const fields = Array.from({ length: 15 }, (_, i) => ({ + name: `f${i}`, label: `F${i}`, type: 'text', + })); + const result = applyDetailAutoLayout(fields, 3, 500); + expect(result.columns).toBe(3); + }); }); }); diff --git a/packages/plugin-detail/src/autoLayout.ts b/packages/plugin-detail/src/autoLayout.ts index 8d8fa1beec..33033bf86c 100644 --- a/packages/plugin-detail/src/autoLayout.ts +++ b/packages/plugin-detail/src/autoLayout.ts @@ -46,16 +46,31 @@ export function isWideFieldType(type: string): boolean { /** * Infer optimal number of columns for a detail section based on field count. + * When containerWidth is provided, limits columns for narrower viewports. * - * Rules: + * Rules (field-count based): * - 0-3 fields → 1 column * - 4-10 fields → 2 columns * - 11+ fields → 3 columns + * + * Responsive capping (when containerWidth is supplied): + * - containerWidth < 640px → max 1 column + * - containerWidth < 900px → max 2 columns + * - containerWidth >= 900px → no cap */ -export function inferDetailColumns(fieldCount: number): number { - if (fieldCount <= 3) return 1; - if (fieldCount <= 10) return 2; - return 3; +export function inferDetailColumns(fieldCount: number, containerWidth?: number): number { + let cols: number; + if (fieldCount <= 3) cols = 1; + else if (fieldCount <= 10) cols = 2; + else cols = 3; + + // Apply responsive capping when container width is known + if (containerWidth !== undefined) { + if (containerWidth < 640) return Math.min(cols, 1); + if (containerWidth < 900) return Math.min(cols, 2); + } + + return cols; } /** @@ -89,11 +104,13 @@ export function applyAutoSpan( * * @param fields - The section fields * @param schemaColumns - User-provided columns (from DetailViewSection or DetailViewSchema) + * @param containerWidth - Optional container width in px for responsive column capping * @returns Object with processed fields and inferred columns */ export function applyDetailAutoLayout( fields: DetailViewField[], - schemaColumns: number | undefined + schemaColumns: number | undefined, + containerWidth?: number ): { fields: DetailViewField[]; columns: number } { // If user explicitly set columns, respect it but still apply auto span if (schemaColumns !== undefined) { @@ -101,8 +118,8 @@ export function applyDetailAutoLayout( return { fields: processed, columns: schemaColumns }; } - // Infer columns from field count - const columns = inferDetailColumns(fields.length); + // Infer columns from field count (with optional container-width capping) + const columns = inferDetailColumns(fields.length, containerWidth); // Apply auto span for wide fields const processed = applyAutoSpan(fields, columns); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 9b710cfa93..5495c3451d 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -36,7 +36,7 @@ export { SubscriptionToggle } from './SubscriptionToggle'; export { ReactionPicker } from './ReactionPicker'; export { ThreadedReplies } from './ThreadedReplies'; export type { DetailViewProps } from './DetailView'; -export type { DetailSectionProps } from './DetailSection'; +export type { DetailSectionProps, VirtualScrollOptions } from './DetailSection'; export type { DetailTabsProps } from './DetailTabs'; export type { RelatedListProps } from './RelatedList'; export type { SectionGroupProps } from './SectionGroup'; From 1d751d87c6119a6197339355695dca323de65878 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:18:15 +0000 Subject: [PATCH 3/8] refactor: address code review feedback - rename overscan to batchSize, extract VS_REVEAL_DELAY constant, use granular useMemo deps Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 29 ++++++++++++++++++- .../src/components/RecordDetailView.tsx | 3 +- packages/plugin-detail/src/DetailSection.tsx | 10 ++++--- .../src/__tests__/DetailSection.test.tsx | 4 +-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a50da4a04d..6a7215b755 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1414,7 +1414,34 @@ All 313 `@object-ui/fields` tests pass. --- -### DetailView Rendering Optimization (March 2026) +### DetailView/RecordDetailView SDUI Optimization (March 2026) + +> Type-aware rendering, responsive layout, virtual scrolling, metadata-driven highlights, performance optimization, and activity panel collapse-when-empty. + +**HeaderHighlight (`@object-ui/plugin-detail`):** +- [x] Use `getCellRenderer` for type-aware display (currency → `$250,000.00`, select → Badge, etc.) instead of raw `String(value)` +- [x] Add `objectSchema` prop for field metadata enrichment (type, options, currency, precision, format) + +**autoLayout (`@object-ui/plugin-detail`):** +- [x] `inferDetailColumns` accepts optional `containerWidth` for responsive column capping (`<640px→1col`, `<900px→max 2col`) +- [x] `applyDetailAutoLayout` passes through `containerWidth` parameter + +**RecordChatterPanel (`@object-ui/plugin-detail`):** +- [x] `collapseWhenEmpty` prop: auto-collapse panel when no feed items exist +- [x] Pass `collapseWhenEmpty` through to embedded `RecordActivityTimeline` + +**DetailSection (`@object-ui/plugin-detail`):** +- [x] `virtualScroll` config (`VirtualScrollOptions`): progressive batch rendering for sections with many fields +- [x] Export `VirtualScrollOptions` type from package index + +**RecordDetailView (`apps/console`):** +- [x] Wrap `detailSchema` construction with `useMemo` (deps: `objectDef`, `pureRecordId`, `related`, `childRelatedData`, `actionRefreshKey`) +- [x] Remove hardcoded `HIGHLIGHT_FIELD_NAMES`; read exclusively from `objectDef.views.detail.highlightFields` (no fallback) +- [x] Enable `collapseWhenEmpty` + `collapsible: true` on `RecordChatterPanel` + +**Tests:** 125 plugin-detail tests passing (17 new) covering HeaderHighlight type-aware rendering, autoLayout responsive columns, RecordChatterPanel collapseWhenEmpty, DetailSection virtualScroll. + +--- > Platform-level DetailView enhancements: auto-grouping from form sections, empty value hiding, smart header with primaryField/summaryFields, responsive breakpoint fix, and activity timeline collapse. diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx index 241d14ed22..3f4a6b1871 100644 --- a/apps/console/src/components/RecordDetailView.tsx +++ b/apps/console/src/components/RecordDetailView.tsx @@ -432,7 +432,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi actions: recordHeaderActions, } as any], }), - }), [objectDef, pureRecordId, related, childRelatedData, actionRefreshKey]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [objectDef.name, pureRecordId, childRelatedData, actionRefreshKey]); return (
diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 143f90ecd3..ccfde12ff7 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -58,8 +58,8 @@ export interface VirtualScrollOptions { enabled?: boolean; /** Height of each field row in px (default: 60) */ itemHeight?: number; - /** Number of extra items to render above/below the visible area (default: 3) */ - overscan?: number; + /** Number of fields to render in the initial batch before revealing all (default: 20) */ + batchSize?: number; } export interface DetailSectionProps { @@ -228,7 +228,9 @@ export const DetailSection: React.FC = ({ // Virtual scroll: progressive batch rendering for large field sets const vsEnabled = virtualScroll?.enabled === true; - const vsBatchSize = virtualScroll?.overscan ?? 20; + const vsBatchSize = virtualScroll?.batchSize ?? 20; + /** Delay (ms) before revealing remaining fields after the initial batch */ + const VS_REVEAL_DELAY = 100; React.useEffect(() => { if (!vsEnabled) { @@ -241,7 +243,7 @@ export const DetailSection: React.FC = ({ return; } setVisibleCount(vsBatchSize); - const timer = setTimeout(() => setVisibleCount(undefined), 100); + const timer = setTimeout(() => setVisibleCount(undefined), VS_REVEAL_DELAY); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [vsEnabled, layoutFields.length, vsBatchSize]); diff --git a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx index a47a010748..2ed40bf1d0 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx @@ -405,7 +405,7 @@ describe('DetailSection', () => { ); const grid = container.querySelector('.grid'); @@ -445,7 +445,7 @@ describe('DetailSection', () => { ); const grid = container.querySelector('.grid'); From ebb6775600ffef2d93bd49b6efcbc4b8fa7ca4fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:44:09 +0000 Subject: [PATCH 4/8] fix: add safety guard in HeaderHighlight for object values to prevent React error #310 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-detail/src/HeaderHighlight.tsx | 13 ++++- .../src/__tests__/HeaderHighlight.test.tsx | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 370ba3eca3..5e57181008 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -69,7 +69,18 @@ export const HeaderHighlight: React.FC = ({ if (resolvedType) { const CellRenderer = getCellRenderer(resolvedType); if (CellRenderer) { - displayValue = ; + // Guard: plain objects (e.g. MongoDB Decimal128 {$numberDecimal}, expanded refs) + // can crash renderers that pass non-primitive values straight to JSX children. + // Types like lookup/user/owner/file/image handle objects natively. + const isPlainObject = value !== null && typeof value === 'object' + && !Array.isArray(value) && !(value instanceof Date); + const OBJECT_SAFE_TYPES = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'object']; + + if (isPlainObject && !OBJECT_SAFE_TYPES.includes(resolvedType)) { + displayValue = String(value.name || value.label || value._id || JSON.stringify(value)); + } else { + displayValue = ; + } } } diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx index ad6f9f5729..768c868161 100644 --- a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx @@ -126,4 +126,56 @@ describe('HeaderHighlight', () => { render(); expect(screen.getByText('raw-value')).toBeInTheDocument(); }); + + it('should safely render object values without crashing (React error #310 guard)', () => { + // Simulates MongoDB Decimal128 or expanded reference objects + const fieldsWithNumber: HighlightField[] = [ + { name: 'amount', label: 'Amount' }, + { name: 'account', label: 'Account' }, + ]; + const objectData = { + amount: { $numberDecimal: '250000' }, + account: { _id: 'abc', name: 'Acme Corp' }, + }; + const objectSchema = { + fields: { + amount: { type: 'number' }, + account: { type: 'text' }, + }, + }; + // Should NOT crash with "Objects are not valid as a React child" + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + // Object with name property should extract the name + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + it('should pass object values through to object-safe renderers (lookup, user)', () => { + const lookupFields: HighlightField[] = [ + { name: 'owner', label: 'Owner' }, + ]; + const lookupData = { + owner: { _id: 'u1', name: 'Jane Doe' }, + }; + const objectSchema = { + fields: { + owner: { type: 'lookup' }, + }, + }; + // LookupCellRenderer handles object values natively + render( + + ); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); }); From 7cf936a3965c5b9f90ecc6f2de87ff451f9f87bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:46:04 +0000 Subject: [PATCH 5/8] refactor: hoist OBJECT_SAFE_TYPES constant outside render loop, use optional chaining Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-detail/src/HeaderHighlight.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 5e57181008..0df950470a 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -12,6 +12,9 @@ import type { HighlightField, FieldMetadata } from '@object-ui/types'; import { getCellRenderer } from '@object-ui/fields'; import { useSafeFieldLabel } from '@object-ui/react'; +/** Renderer types that natively handle object values without crashing */ +const OBJECT_SAFE_TYPES = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'object']; + export interface HeaderHighlightProps { fields: HighlightField[]; data?: any; @@ -74,10 +77,9 @@ export const HeaderHighlight: React.FC = ({ // Types like lookup/user/owner/file/image handle objects natively. const isPlainObject = value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); - const OBJECT_SAFE_TYPES = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'object']; if (isPlainObject && !OBJECT_SAFE_TYPES.includes(resolvedType)) { - displayValue = String(value.name || value.label || value._id || JSON.stringify(value)); + displayValue = String(value?.name || value?.label || value?._id || JSON.stringify(value)); } else { displayValue = ; } From d6d17bbc508c6f97f2920ea21145d0c1ed12ee2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:28:46 +0000 Subject: [PATCH 6/8] fix: replace getCellRenderer in HeaderHighlight with direct safe formatting to prevent React error #310 Instead of routing highlight values through full CellRenderer components (which may not handle all data shapes from the API), use direct string formatting functions (formatCurrency, formatDate, etc.) that guarantee only primitives reach JSX. All values are coerced through toSafeString() first, which safely handles MongoDB types, expanded references, arrays of objects, and other non-primitive values. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-detail/src/HeaderHighlight.tsx | 142 ++++++++++++++---- .../src/__tests__/HeaderHighlight.test.tsx | 42 +++++- 2 files changed, 147 insertions(+), 37 deletions(-) diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 0df950470a..29eb2e9fcb 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -7,13 +7,65 @@ */ import * as React from 'react'; -import { cn, Card, CardContent } from '@object-ui/components'; -import type { HighlightField, FieldMetadata } from '@object-ui/types'; -import { getCellRenderer } from '@object-ui/fields'; +import { cn, Card, CardContent, Badge } from '@object-ui/components'; +import type { HighlightField } from '@object-ui/types'; +import { formatCurrency, formatPercent, formatDate, formatDateTime, humanizeLabel } from '@object-ui/fields'; import { useSafeFieldLabel } from '@object-ui/react'; -/** Renderer types that natively handle object values without crashing */ -const OBJECT_SAFE_TYPES = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'object']; +/** + * Coerce any value to a safe primitive string. + * Handles MongoDB types ($numberDecimal, $oid), expanded references, arrays, etc. + */ +function toSafeString(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) { + return value.map((v) => { + if (v != null && typeof v === 'object') { + return (v as any).name || (v as any).label || (v as any)._id || JSON.stringify(v); + } + return String(v); + }).join(', '); + } + if (typeof value === 'object') { + const obj = value as Record; + return obj.name || obj.label || obj._id || JSON.stringify(value); + } + return String(value); +} + +/** + * Semantic color mapping for select/status badge auto-coloring. + */ +const SEMANTIC_COLOR_MAP: Record = { + critical: 'bg-red-100 text-red-800 border-red-300', + urgent: 'bg-red-100 text-red-800 border-red-300', + high: 'bg-orange-100 text-orange-800 border-orange-300', + medium: 'bg-yellow-100 text-yellow-800 border-yellow-300', + normal: 'bg-blue-100 text-blue-800 border-blue-300', + low: 'bg-gray-100 text-gray-800 border-gray-300', + paid: 'bg-green-100 text-green-800 border-green-300', + completed: 'bg-green-100 text-green-800 border-green-300', + done: 'bg-green-100 text-green-800 border-green-300', + active: 'bg-green-100 text-green-800 border-green-300', + approved: 'bg-green-100 text-green-800 border-green-300', + pending: 'bg-yellow-100 text-yellow-800 border-yellow-300', + in_progress: 'bg-blue-100 text-blue-800 border-blue-300', + open: 'bg-blue-100 text-blue-800 border-blue-300', + draft: 'bg-gray-100 text-gray-800 border-gray-300', + new: 'bg-gray-100 text-gray-800 border-gray-300', + closed: 'bg-gray-100 text-gray-800 border-gray-300', + cancelled: 'bg-red-100 text-red-800 border-red-300', + rejected: 'bg-red-100 text-red-800 border-red-300', + failed: 'bg-red-100 text-red-800 border-red-300', +}; + +function getSemanticBadgeClasses(val: string): string { + const key = val.toLowerCase().replace(/[\s-]/g, '_'); + return SEMANTIC_COLOR_MAP[key] || 'bg-muted text-muted-foreground border-border'; +} export interface HeaderHighlightProps { fields: HighlightField[]; @@ -55,35 +107,65 @@ export const HeaderHighlight: React.FC = ({ )}> {visibleFields.map((field) => { const value = data[field.name]; - // Enrich field with objectSchema metadata for type-aware rendering + // Resolve field metadata from objectSchema const objectDefField = objectSchema?.fields?.[field.name]; const resolvedType = field.type || objectDefField?.type; - const enrichedField: Record = { ...field }; - if (objectDefField) { - if (!field.type && objectDefField.type) enrichedField.type = objectDefField.type; - if (objectDefField.options && !enrichedField.options) enrichedField.options = objectDefField.options; - if (objectDefField.currency && !enrichedField.currency) enrichedField.currency = objectDefField.currency; - if (objectDefField.precision !== undefined && enrichedField.precision === undefined) enrichedField.precision = objectDefField.precision; - if (objectDefField.format && !enrichedField.format) enrichedField.format = objectDefField.format; - } + const options: Array<{ value: string; label: string; color?: string }> = + objectDefField?.options || (field as any).options || []; + const currency: string = objectDefField?.currency || (field as any).currency || 'USD'; + const precision: number = objectDefField?.precision ?? (field as any).precision ?? 0; - // Use type-aware cell renderer when field type is available - let displayValue: React.ReactNode = String(value); - if (resolvedType) { - const CellRenderer = getCellRenderer(resolvedType); - if (CellRenderer) { - // Guard: plain objects (e.g. MongoDB Decimal128 {$numberDecimal}, expanded refs) - // can crash renderers that pass non-primitive values straight to JSX children. - // Types like lookup/user/owner/file/image handle objects natively. - const isPlainObject = value !== null && typeof value === 'object' - && !Array.isArray(value) && !(value instanceof Date); + // Format the value as a safe string based on field type. + // Uses direct formatting functions instead of CellRenderers + // to guarantee only primitives reach JSX (prevents React error #310). + let displayNode: React.ReactNode; - if (isPlainObject && !OBJECT_SAFE_TYPES.includes(resolvedType)) { - displayValue = String(value?.name || value?.label || value?._id || JSON.stringify(value)); - } else { - displayValue = ; - } + switch (resolvedType) { + case 'currency': { + const num = Number(toSafeString(value)); + displayNode = isNaN(num) ? toSafeString(value) : formatCurrency(num, currency); + break; + } + case 'number': { + const num = Number(toSafeString(value)); + displayNode = isNaN(num) ? toSafeString(value) : new Intl.NumberFormat('en-US', { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }).format(num); + break; + } + case 'percent': { + const num = Number(toSafeString(value)); + displayNode = isNaN(num) ? toSafeString(value) : formatPercent(num, precision); + break; + } + case 'date': + displayNode = formatDate(toSafeString(value), objectDefField?.format); + break; + case 'datetime': + displayNode = formatDateTime(toSafeString(value)); + break; + case 'boolean': + displayNode = value ? 'Yes' : 'No'; + break; + case 'select': + case 'status': { + const strVal = toSafeString(value); + const option = options.find((opt) => String(opt.value) === strVal); + const label = option?.label || humanizeLabel(strVal); + const colorClasses = option?.color + ? `bg-${option.color}-100 text-${option.color}-800 border-${option.color}-300` + : getSemanticBadgeClasses(strVal); + displayNode = ( + + {label} + + ); + break; } + default: + displayNode = toSafeString(value); + break; } return ( @@ -93,7 +175,7 @@ export const HeaderHighlight: React.FC = ({ {fieldLabel(objectName || '', field.name, field.label)} - {displayValue} + {displayNode}
); diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx index 768c868161..c13ac70ad8 100644 --- a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx @@ -66,17 +66,17 @@ describe('HeaderHighlight', () => { expect(screen.getByText('💰')).toBeInTheDocument(); }); - it('should render currency fields formatted via getCellRenderer when type is provided', () => { + it('should render currency fields with formatted value', () => { const currencyFields: HighlightField[] = [ { name: 'amount', label: 'Amount', type: 'currency' }, ]; render(); - // CurrencyCellRenderer should format the number — should NOT show raw "250000" + // Should format as currency — should NOT show raw "250000" expect(screen.queryByText('250000')).not.toBeInTheDocument(); expect(screen.getByText(/250,000/)).toBeInTheDocument(); }); - it('should render select fields as badge via getCellRenderer when type is provided', () => { + it('should render select fields as badge', () => { const selectFields: HighlightField[] = [ { name: 'stage', label: 'Stage', type: 'select' }, ]; @@ -114,12 +114,12 @@ describe('HeaderHighlight', () => { }} /> ); - // Should use CurrencyCellRenderer, not raw String() + // Should format as currency, not raw String() expect(screen.queryByText('5000')).not.toBeInTheDocument(); expect(screen.getByText(/5,000/)).toBeInTheDocument(); }); - it('should fall back to String(value) when no type info is available', () => { + it('should fall back to string when no type info is available', () => { const fieldsNoType: HighlightField[] = [ { name: 'custom', label: 'Custom' }, ]; @@ -156,7 +156,7 @@ describe('HeaderHighlight', () => { expect(screen.getByText('Acme Corp')).toBeInTheDocument(); }); - it('should pass object values through to object-safe renderers (lookup, user)', () => { + it('should safely render lookup object values as string', () => { const lookupFields: HighlightField[] = [ { name: 'owner', label: 'Owner' }, ]; @@ -168,7 +168,7 @@ describe('HeaderHighlight', () => { owner: { type: 'lookup' }, }, }; - // LookupCellRenderer handles object values natively + // Object value is coerced to safe string (name extracted) render( { ); expect(screen.getByText('Jane Doe')).toBeInTheDocument(); }); + + it('should safely render array values', () => { + const arrayFields: HighlightField[] = [ + { name: 'tags', label: 'Tags' }, + ]; + const arrayData = { + tags: ['urgent', 'follow-up'], + }; + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + expect(screen.getByText('urgent, follow-up')).toBeInTheDocument(); + }); + + it('should safely render array of objects', () => { + const arrayFields: HighlightField[] = [ + { name: 'contacts', label: 'Contacts' }, + ]; + const arrayData = { + contacts: [{ name: 'Alice' }, { name: 'Bob' }], + }; + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + expect(screen.getByText('Alice, Bob')).toBeInTheDocument(); + }); }); From 23350ed9c7deae8368ec0668d0f07c3abca17185 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:31:36 +0000 Subject: [PATCH 7/8] refactor: use JIT-safe color map for badges, replace JSON.stringify with [Object] placeholder Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-detail/src/HeaderHighlight.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index 29eb2e9fcb..eecde3b6f1 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -24,14 +24,15 @@ function toSafeString(value: unknown): string { if (Array.isArray(value)) { return value.map((v) => { if (v != null && typeof v === 'object') { - return (v as any).name || (v as any).label || (v as any)._id || JSON.stringify(v); + const obj = v as Record; + return obj.name || obj.label || obj._id || '[Object]'; } return String(v); }).join(', '); } if (typeof value === 'object') { - const obj = value as Record; - return obj.name || obj.label || obj._id || JSON.stringify(value); + const obj = value as Record; + return String(obj.name || obj.label || obj._id || '[Object]'); } return String(value); } @@ -62,6 +63,19 @@ const SEMANTIC_COLOR_MAP: Record = { failed: 'bg-red-100 text-red-800 border-red-300', }; +/** Map named colors to full Tailwind class strings (JIT-safe, no template interpolation) */ +const COLOR_CLASS_MAP: Record = { + gray: 'bg-gray-100 text-gray-800 border-gray-300', + red: 'bg-red-100 text-red-800 border-red-300', + orange: 'bg-orange-100 text-orange-800 border-orange-300', + yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300', + green: 'bg-green-100 text-green-800 border-green-300', + blue: 'bg-blue-100 text-blue-800 border-blue-300', + indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300', + purple: 'bg-purple-100 text-purple-800 border-purple-300', + pink: 'bg-pink-100 text-pink-800 border-pink-300', +}; + function getSemanticBadgeClasses(val: string): string { const key = val.toLowerCase().replace(/[\s-]/g, '_'); return SEMANTIC_COLOR_MAP[key] || 'bg-muted text-muted-foreground border-border'; @@ -154,7 +168,7 @@ export const HeaderHighlight: React.FC = ({ const option = options.find((opt) => String(opt.value) === strVal); const label = option?.label || humanizeLabel(strVal); const colorClasses = option?.color - ? `bg-${option.color}-100 text-${option.color}-800 border-${option.color}-300` + ? (COLOR_CLASS_MAP[option.color] || 'bg-muted text-muted-foreground border-border') : getSemanticBadgeClasses(strVal); displayNode = ( From 9e2b98017d80ace57c7822f3a103c2850655a0f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:49:53 +0000 Subject: [PATCH 8/8] fix: add coerceToSafeValue to cell renderers at source, revert HeaderHighlight to use getCellRenderer The proper architectural fix for React error #310: instead of working around unsafe renderers in HeaderHighlight, fix the renderers themselves. Added coerceToSafeValue() utility in @object-ui/fields that safely coerces MongoDB wrapper types ($numberDecimal, $oid, $date), expanded reference objects, arrays, and Date instances to primitive values. Applied coerceToSafeValue in: NumberCellRenderer, CurrencyCellRenderer, PercentCellRenderer, TextCellRenderer, EmailCellRenderer, UrlCellRenderer, PhoneCellRenderer, FormulaCellRenderer, DateCellRenderer, DateTimeCellRenderer. HeaderHighlight reverted to use getCellRenderer for type-aware rendering, since all renderers are now safe against non-primitive values. 16 new tests for coerceToSafeValue and cell renderer object safety. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/cell-renderers.test.tsx | 129 ++++++++++++++++ packages/fields/src/index.tsx | 88 ++++++++--- .../plugin-detail/src/HeaderHighlight.tsx | 145 ++---------------- .../src/__tests__/HeaderHighlight.test.tsx | 26 ++-- 4 files changed, 228 insertions(+), 160 deletions(-) diff --git a/packages/fields/src/__tests__/cell-renderers.test.tsx b/packages/fields/src/__tests__/cell-renderers.test.tsx index ba50dfb272..d7f370b7e3 100644 --- a/packages/fields/src/__tests__/cell-renderers.test.tsx +++ b/packages/fields/src/__tests__/cell-renderers.test.tsx @@ -1041,3 +1041,132 @@ describe('PercentCellRenderer progress-type fields', () => { expect(bar).toHaveAttribute('aria-valuenow', '50'); }); }); + +// ========================================================================= +// Object value safety (coerceToSafeValue) — prevents React error #310 +// ========================================================================= +import { + NumberCellRenderer, + CurrencyCellRenderer, + FormulaCellRenderer, + coerceToSafeValue, +} from '../index'; + +describe('coerceToSafeValue', () => { + it('should pass through primitives unchanged', () => { + expect(coerceToSafeValue('hello')).toBe('hello'); + expect(coerceToSafeValue(42)).toBe(42); + expect(coerceToSafeValue(true)).toBe(true); + expect(coerceToSafeValue(null)).toBe(null); + expect(coerceToSafeValue(undefined)).toBe(undefined); + }); + + it('should extract number from MongoDB $numberDecimal', () => { + expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000); + }); + + it('should extract string from MongoDB $oid', () => { + expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123'); + }); + + it('should extract string from MongoDB $date', () => { + expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z'); + }); + + it('should extract name from expanded reference object', () => { + expect(coerceToSafeValue({ _id: 'x', name: 'Acme Corp' })).toBe('Acme Corp'); + }); + + it('should extract label when name is not present', () => { + expect(coerceToSafeValue({ _id: 'x', label: 'Active' })).toBe('Active'); + }); + + it('should fall back to _id when no name/label', () => { + expect(coerceToSafeValue({ _id: 'abc123' })).toBe('abc123'); + }); + + it('should handle arrays of primitives', () => { + expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c'); + }); + + it('should handle arrays of objects', () => { + expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob'); + }); + + it('should convert Date to ISO string', () => { + const d = new Date('2024-06-15T12:00:00Z'); + expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z'); + }); +}); + +describe('NumberCellRenderer object safety', () => { + it('should handle MongoDB $numberDecimal without crashing', () => { + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + expect(screen.getByText('250,000')).toBeInTheDocument(); + }); + + it('should handle expanded reference object without crashing', () => { + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + // Should render the extracted name string, not crash + expect(screen.getByText('Not a number')).toBeInTheDocument(); + }); +}); + +describe('CurrencyCellRenderer object safety', () => { + it('should handle MongoDB $numberDecimal', () => { + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + expect(screen.getByText(/5,000/)).toBeInTheDocument(); + }); +}); + +describe('TextCellRenderer object safety', () => { + it('should extract name from object instead of [object Object]', () => { + render( + + ); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + it('should handle arrays of objects', () => { + render( + + ); + expect(screen.getByText('Alice, Bob')).toBeInTheDocument(); + }); +}); + +describe('FormulaCellRenderer object safety', () => { + it('should extract value from MongoDB $numberDecimal', () => { + render( + + ); + expect(screen.getByText('42.5')).toBeInTheDocument(); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 1f2e179a09..9148cf62af 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -60,6 +60,39 @@ export interface CellRendererProps { onChange?: (value: any) => void; } +/** + * Coerce a value to a safe primitive for rendering. + * Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded + * reference objects, and arrays so that no raw object is ever passed as + * a React child — preventing React error #310. + */ +export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined { + if (value == null) return value as null | undefined; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) { + return value.map((v) => { + if (v != null && typeof v === 'object') { + const obj = v as Record; + return String(obj.name || obj.label || obj._id || '[Object]'); + } + return String(v); + }).join(', '); + } + if (typeof value === 'object') { + const obj = value as Record; + // MongoDB numeric wrapper: { $numberDecimal: "250000" } + if ('$numberDecimal' in obj) return Number(obj.$numberDecimal); + // MongoDB ObjectId wrapper: { $oid: "abc123" } + if ('$oid' in obj) return String(obj.$oid); + // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" } + if ('$date' in obj) return String(obj.$date); + // Expanded reference / general object: extract name/label/_id + return String(obj.name || obj.label || obj._id || '[Object]'); + } + return String(value); +} + /** * Format currency value */ @@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string { * Text field cell renderer */ export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement { - return {(value != null && value !== '') ? String(value) : '-'}; + const safe = coerceToSafeValue(value); + return {(safe != null && safe !== '') ? String(safe) : '-'}; } /** @@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement { if (value == null) return -; + const safe = coerceToSafeValue(value); const numField = field as any; const precision = numField.precision ?? 0; - const formatted = typeof value === 'number' - ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value) - : value; + const num = Number(safe); + const formatted = !isNaN(num) + ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num) + : String(safe); return {formatted}; } @@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement { if (value == null) return -; + const safe = coerceToSafeValue(value); const currencyField = field as any; const currency = currencyField.currency || 'USD'; - const formatted = formatCurrency(Number(value), currency); + const num = Number(safe); + const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe); return {formatted}; } @@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/; export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement { if (value == null) return -; + const safe = coerceToSafeValue(value); const percentField = field as any; const precision = percentField.precision ?? 0; - const numValue = Number(value); + const numValue = Number(safe); + if (isNaN(numValue)) { + return {String(safe)}; + } // Use field name to disambiguate 0-1 fraction vs 0-100 whole number: // Fields like "progress" or "completion" store values as 0-100, not 0-1 const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || ''); @@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. */ export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement { if (!value) return -; + const safe = coerceToSafeValue(value); const dateField = field as any; const style = dateField.format || 'relative'; - const formatted = formatDate(value, style); + const formatted = formatDate(safe as string | Date, style); // Determine if date is overdue (in the past) - const date = typeof value === 'string' ? new Date(value) : value; + const date = typeof safe === 'string' ? new Date(safe) : safe; const isValidDate = date instanceof Date && !isNaN(date.getTime()); const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); const isOverdue = isValidDate && date < startOfToday; - const isoString = isValidDate ? date.toISOString() : String(value); + const isoString = isValidDate ? date.toISOString() : String(safe); return ( -; - const date = typeof value === 'string' ? new Date(value) : value; - if (isNaN(date.getTime())) return -; + const safe = coerceToSafeValue(value); + const date = typeof safe === 'string' ? new Date(safe) : safe; + if (!(date instanceof Date) || isNaN(date.getTime())) return -; const datePart = date.toLocaleDateString(undefined, { month: 'numeric', @@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement { if (!value) return -; + const safe = String(coerceToSafeValue(value) ?? ''); const [copied, setCopied] = React.useState(false); const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - navigator.clipboard.writeText(String(value)).then(() => { + navigator.clipboard.writeText(safe).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(() => { /* clipboard not available */ }); @@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem asChild > e.stopPropagation()} > - {value} + {safe} ); @@ -549,12 +595,13 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement { if (!value) return -; + const safe = String(coerceToSafeValue(value) ?? ''); const [copied, setCopied] = React.useState(false); const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - navigator.clipboard.writeText(String(value)).then(() => { + navigator.clipboard.writeText(safe).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(() => { /* clipboard not available */ }); @@ -563,12 +610,12 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem return ( e.stopPropagation()} > - {value} + {safe}