diff --git a/ROADMAP.md b/ROADMAP.md index a50da4a04..6a7215b75 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 318b098dc..3f4a6b187 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,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]); return (
@@ -482,13 +472,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi { 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 1f2e179a0..9148cf62a 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}
@@ -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 239d91cc3..2ed40bf1d 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 8ff2718b3..2ed206d7a 100644 --- a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx @@ -65,4 +65,149 @@ describe('HeaderHighlight', () => { render(); expect(screen.getByText('💰')).toBeInTheDocument(); }); + + it('should render currency fields with formatted value via CellRenderer', () => { + const currencyFields: HighlightField[] = [ + { name: 'amount', label: 'Amount', type: 'currency' }, + ]; + render(); + // CurrencyCellRenderer should format — 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 CellRenderer', () => { + 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( + + ); + // CurrencyCellRenderer should format, not raw String() + expect(screen.queryByText('5000')).not.toBeInTheDocument(); + expect(screen.getByText(/5,000/)).toBeInTheDocument(); + }); + + it('should fall back to text when no type info is available', () => { + const fieldsNoType: HighlightField[] = [ + { name: 'custom', label: 'Custom' }, + ]; + 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 — cell renderers coerce values via coerceToSafeValue + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + // NumberCellRenderer coerces $numberDecimal to number + expect(screen.getByText('250,000')).toBeInTheDocument(); + // TextCellRenderer extracts name from object + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + it('should safely render lookup object values via LookupCellRenderer', () => { + 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(); + }); + + it('should safely render array values via TextCellRenderer', () => { + const arrayFields: HighlightField[] = [ + { name: 'tags', label: 'Tags' }, + ]; + const arrayData = { + tags: ['urgent', 'follow-up'], + }; + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + // TextCellRenderer coerces arrays to comma-separated string + expect(screen.getByText('urgent, follow-up')).toBeInTheDocument(); + }); + + it('should safely render array of objects via TextCellRenderer', () => { + const arrayFields: HighlightField[] = [ + { name: 'contacts', label: 'Contacts' }, + ]; + const arrayData = { + contacts: [{ name: 'Alice' }, { name: 'Bob' }], + }; + const { container } = render( + + ); + expect(container.innerHTML).not.toBe(''); + // TextCellRenderer coerces array of objects to "Alice, Bob" + expect(screen.getByText('Alice, Bob')).toBeInTheDocument(); + }); }); diff --git a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx index ed06f54d7..ecf2d8e8e 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 57effc89a..1537ce44a 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 8d8fa1bee..33033bf86 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 9b710cfa9..5495c3451 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';