Skip to content
Merged
29 changes: 28 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ROADMAP documents the useMemo deps as objectDef, pureRecordId, related, childRelatedData, actionRefreshKey but the actual implementation at RecordDetailView.tsx:436 uses objectDef.name, pureRecordId, childRelatedData, actionRefreshKey — neither objectDef (the whole object) nor related are in the real dependency array. Please update the ROADMAP to match the actual implementation (or fix the implementation to match the intended deps).

Copilot uses AI. Check for mistakes.
- [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.

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ROADMAP states "17 new" tests while the PR description says "14 new tests total." Counting the new tests in the diffs: 7 (autoLayout) + 3 (RecordChatterPanel) + 6 (HeaderHighlight) + 3 (DetailSection) = 19 new tests. Please reconcile the test counts to be accurate.

Copilot uses AI. Check for mistakes.

---

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

Expand Down
23 changes: 7 additions & 16 deletions apps/console/src/components/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 =
Expand All @@ -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,
Expand All @@ -443,7 +432,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
actions: recordHeaderActions,
} as any],
}),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [objectDef.name, pureRecordId, childRelatedData, actionRefreshKey]);
Comment on lines +435 to +436

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useMemo dependency array only lists [objectDef.name, pureRecordId, childRelatedData, actionRefreshKey], but the memo body captures several locally computed variables that are re-derived every render: primaryField, sections, related, highlightFields, sectionGroups, recordHeaderActions, and objectDef.label. Since these are plain const assignments (not themselves memoized), the useMemo will return a stale schema whenever only those upstream values change.

Additionally, the ROADMAP at line 1438 documents the deps as including objectDef and related, which doesn't match the actual code.

Consider either (a) including the missing variables in the dependency array, or (b) individually memoizing the intermediate values (sections, primaryField, etc.) and including those in the useMemo deps. Suppressing the lint rule here is hiding a real correctness gap.

Copilot uses AI. Check for mistakes.

return (
<div className="h-full bg-background overflow-hidden flex flex-col relative">
Expand Down Expand Up @@ -482,13 +472,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
<RecordChatterPanel
config={{
position: 'bottom',
collapsible: false,
collapsible: true,
feed: {
enableReactions: true,
enableThreading: true,
showCommentInput: true,
},
}}
collapseWhenEmpty
items={feedItems}
onAddComment={handleAddComment}
onAddReply={handleAddReply}
Expand Down
41 changes: 40 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number
return '';
}

export interface VirtualScrollOptions {
/** Enable virtual scrolling for large field sets */
enabled?: boolean;
/** Height of each field row in px (default: 60) */
itemHeight?: number;
/** Number of fields to render in the initial batch before revealing all (default: 20) */
batchSize?: number;
}
Comment on lines +56 to +63

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature named virtualScroll is actually progressive/deferred batch rendering: it renders the first batch, waits 100ms, then renders all remaining fields at once. True virtual scrolling involves windowing (only rendering items visible in the viewport and recycling DOM nodes). The current naming may mislead consumers into thinking DOM recycling or viewport-based rendering is happening.

Consider renaming to deferredRender or progressiveRender to better communicate the actual behavior, or alternatively documenting clearly in the interface JSDoc that this is not windowed virtual scrolling.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +63

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The itemHeight property is declared in VirtualScrollOptions but is never read or used anywhere in the implementation. This is misleading for consumers who might expect it to control actual virtual scrolling behavior. Either remove the unused property or document it as reserved for future use. If it's intended for a future true-virtualization implementation (with windowing), consider marking it with a @deprecated or @reserved JSDoc tag.

Copilot uses AI. Check for mistakes.

export interface DetailSectionProps {
section: DetailViewSectionType;
data?: any;
Expand All @@ -65,6 +74,8 @@ export interface DetailSectionProps {
isEditing?: boolean;
/** Callback when a field value changes during inline editing */
onFieldChange?: (field: string, value: any) => void;
/** Virtual scrolling configuration for sections with many fields */
virtualScroll?: VirtualScrollOptions;
}

export const DetailSection: React.FC<DetailSectionProps> = ({
Expand All @@ -75,9 +86,11 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
objectName,
isEditing = false,
onFieldChange,
virtualScroll,
}) => {
const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
const [copiedField, setCopiedField] = React.useState<string | null>(null);
const [visibleCount, setVisibleCount] = React.useState<number | undefined>(undefined);

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The visibleCount state is initialized as undefined, meaning the first synchronous render mounts all fields (since renderedFields falls through to layoutFields when visibleCount is undefined). Only after the useEffect fires does it set visibleCount to batchSize, causing a second render with the batch. This defeats the purpose of reducing the initial render cost — all 50 fields are mounted on the first paint, then immediately unmounted and re-mounted with only the batch.

To actually reduce initial render cost, initialize visibleCount eagerly based on the props:

const [visibleCount, setVisibleCount] = React.useState<number | undefined>(() => {
  if (virtualScroll?.enabled && section.fields.length > (virtualScroll.batchSize ?? 20)) {
    return virtualScroll.batchSize ?? 20;
  }
  return undefined;
});

This ensures the first render only mounts the batch.

Suggested change
const [visibleCount, setVisibleCount] = React.useState<number | undefined>(undefined);
const [visibleCount, setVisibleCount] = React.useState<number | undefined>(() => {
if (virtualScroll?.enabled && section.fields.length > (virtualScroll.batchSize ?? 20)) {
return virtualScroll.batchSize ?? 20;
}
return undefined;
});

Copilot uses AI. Check for mistakes.
const { t } = useDetailTranslation();
const { fieldLabel } = useSafeFieldLabel();

Expand Down Expand Up @@ -213,6 +226,32 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
);
};

// Virtual scroll: progressive batch rendering for large field sets
const vsEnabled = virtualScroll?.enabled === true;
const vsBatchSize = virtualScroll?.batchSize ?? 20;
/** Delay (ms) before revealing remaining fields after the initial batch */
const VS_REVEAL_DELAY = 100;

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), VS_REVEAL_DELAY);
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 = (
<div
className={cn(
Expand All @@ -223,7 +262,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
)}
>
{layoutFields.map(renderField)}
{renderedFields.map(renderField)}
</div>
);

Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ export const DetailView: React.FC<DetailViewProps> = ({

{/* Header Highlight Area */}
{schema.highlightFields && schema.highlightFields.length > 0 && (
<HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} />
<HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} objectSchema={objectSchema} />
)}

{/* Auto Tabs mode: wrap sections, related, activity into tabs */}
Expand Down
42 changes: 40 additions & 2 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,29 @@

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';

/** 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;
className?: string;
/** Object name for i18n field label resolution */
objectName?: string;
/** Object schema for field metadata enrichment */
objectSchema?: any;
}

export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
fields,
data,
className,
objectName,
objectSchema,
}) => {
const { fieldLabel } = useSafeFieldLabel();
if (!fields.length || !data) return null;
Expand All @@ -48,14 +55,45 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
)}>
{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<string, any> = { ...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) {
// 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);

if (isPlainObject && !OBJECT_SAFE_TYPES.includes(resolvedType)) {
displayValue = String(value?.name || value?.label || value?._id || JSON.stringify(value));
} else {
displayValue = <CellRenderer value={value} field={enrichedField as unknown as FieldMetadata} />;
}
}
}

return (
<div key={field.name} className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{field.icon && <span className="mr-1">{field.icon}</span>}
{fieldLabel(objectName || '', field.name, field.label)}
</span>
<span className="text-sm font-semibold truncate">
{String(value)}
{displayValue}
</span>
</div>
);
Expand Down
7 changes: 6 additions & 1 deletion packages/plugin-detail/src/RecordChatterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -63,12 +65,13 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
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);

Comment on lines 76 to 77

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collapseWhenEmpty logic computes defaultCollapsed at mount time and passes it to useState, which only uses the value once. When items is initially [] (as in RecordDetailView where feedItems starts empty and is populated asynchronously), the panel will start collapsed and stay collapsed even after comments load — because no useEffect syncs the collapsed state when items transitions from empty to non-empty.

Consider adding an effect that auto-expands the panel when collapseWhenEmpty is true and items changes from empty to non-empty, e.g.:

React.useEffect(() => {
  if (collapseWhenEmpty && items.length > 0) {
    setCollapsed(false);
  }
}, [collapseWhenEmpty, items.length]);
Suggested change
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
const prevItemsLengthRef = React.useRef(items.length);
React.useEffect(() => {
if (!collapseWhenEmpty) {
prevItemsLengthRef.current = items.length;
return;
}
const prevLength = prevItemsLengthRef.current;
// Auto-expand when items transition from empty to non-empty
if (prevLength === 0 && items.length > 0) {
setCollapsed(false);
}
prevItemsLengthRef.current = items.length;
}, [collapseWhenEmpty, items.length]);

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -142,6 +145,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
onToggleSubscription={onToggleSubscription}
filterMode={filterMode}
onFilterChange={onFilterChange}
collapseWhenEmpty={collapseWhenEmpty}
className="border-0 shadow-none"
/>
</div>
Expand Down Expand Up @@ -194,6 +198,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
onToggleSubscription={onToggleSubscription}
filterMode={filterMode}
onFilterChange={onFilterChange}
collapseWhenEmpty={collapseWhenEmpty}
/>
</div>
)}
Expand Down
61 changes: 61 additions & 0 deletions packages/plugin-detail/src/__tests__/DetailSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DetailSection
section={section}
data={{}}
virtualScroll={{ enabled: true, batchSize: 10 }}
/>
);
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(
<DetailSection section={section} data={{}} />
);
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(
<DetailSection
section={section}
data={{}}
virtualScroll={{ enabled: true, batchSize: 20 }}
/>
);
const grid = container.querySelector('.grid');
expect(grid).toBeTruthy();
expect(grid!.children.length).toBe(5);
});
});

describe('getResponsiveSpanClass', () => {
Expand Down
Loading