@object-ui/plugin-detail 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +53 -47
  2. package/CHANGELOG.md +20 -0
  3. package/dist/AddressField-DBkEyMcG.js +93 -0
  4. package/dist/AutoNumberField-Baa191z-.js +14 -0
  5. package/dist/AvatarField-YGj51ozd.js +80 -0
  6. package/dist/BooleanField-CaA898Tk.js +40 -0
  7. package/dist/CodeField-BU51nl1L.js +22 -0
  8. package/dist/ColorField-Cnf6ZM7c.js +37 -0
  9. package/dist/CurrencyField-Wg-XOId2.js +51 -0
  10. package/dist/DateField-Cth1ky_m.js +21 -0
  11. package/dist/DateTimeField-B0m6FhHL.js +32 -0
  12. package/dist/EmailField-Do7qT_L_.js +28 -0
  13. package/dist/FileField-aRJAdbQb.js +151 -0
  14. package/dist/FormulaField-DTMkagFx.js +14 -0
  15. package/dist/GeolocationField-RqpHWTEv.js +113 -0
  16. package/dist/GridField-D4IH0cpo.js +51 -0
  17. package/dist/ImageField-BYCFajjr.js +75 -0
  18. package/dist/LocationField-Bi_ew9sd.js +35 -0
  19. package/dist/LookupField-BjwlDPtt.js +902 -0
  20. package/dist/MasterDetailField-I1A9oEGC.js +94 -0
  21. package/dist/NumberField-D_NucQlp.js +26 -0
  22. package/dist/ObjectField-CG-LaM65.js +52 -0
  23. package/dist/PasswordField-DBtluGJ1.js +35 -0
  24. package/dist/PercentField-B6sO_J3i.js +63 -0
  25. package/dist/PhoneField-CcQAWwR6.js +28 -0
  26. package/dist/QRCodeField-CEjWs-J5.js +72 -0
  27. package/dist/RatingField-B_Mnr63i.js +39 -0
  28. package/dist/RichTextField-qOEJl5Ai.js +32 -0
  29. package/dist/SelectField-C8hWu3gm.js +30 -0
  30. package/dist/SignatureField-CddhEK9u.js +92 -0
  31. package/dist/SliderField-Df5hMzNc.js +34 -0
  32. package/dist/SummaryField-DgiFm-Cr.js +19 -0
  33. package/dist/TextAreaField-DuriTqsD.js +36 -0
  34. package/dist/TextField-CGNSl7RU.js +29 -0
  35. package/dist/TimeField-YO58ctFg.js +21 -0
  36. package/dist/UrlField-1-BMM1jn.js +33 -0
  37. package/dist/UserField-B6GqxP_S.js +78 -0
  38. package/dist/VectorField-BkEjbSt0.js +36 -0
  39. package/dist/index.js +4092 -33
  40. package/dist/index.umd.cjs +88 -81
  41. package/dist/plugin-detail.css +3 -1
  42. package/dist/src/DetailSection.d.ts +10 -0
  43. package/dist/src/DetailSection.d.ts.map +1 -1
  44. package/dist/src/HeaderHighlight.d.ts +2 -0
  45. package/dist/src/HeaderHighlight.d.ts.map +1 -1
  46. package/dist/src/RecordChatterPanel.d.ts +2 -0
  47. package/dist/src/RecordChatterPanel.d.ts.map +1 -1
  48. package/dist/src/autoLayout.d.ts +10 -3
  49. package/dist/src/autoLayout.d.ts.map +1 -1
  50. package/dist/src/index.d.ts +1 -1
  51. package/dist/src/index.d.ts.map +1 -1
  52. package/dist/src-CXr1-vVl.js +77662 -0
  53. package/package.json +10 -10
  54. package/src/DetailSection.tsx +40 -1
  55. package/src/DetailView.tsx +1 -1
  56. package/src/HeaderHighlight.tsx +22 -1
  57. package/src/RecordChatterPanel.tsx +6 -1
  58. package/src/RelatedList.tsx +1 -1
  59. package/src/__tests__/DetailSection.test.tsx +61 -0
  60. package/src/__tests__/HeaderHighlight.test.tsx +145 -0
  61. package/src/__tests__/RecordChatterPanel.test.tsx +38 -0
  62. package/src/__tests__/RelatedList.test.tsx +3 -3
  63. package/src/__tests__/autoLayout.test.ts +44 -0
  64. package/src/autoLayout.ts +25 -8
  65. package/src/index.tsx +1 -1
  66. package/dist/AddressField-QBIlXCFl.js +0 -96
  67. package/dist/AutoNumberField-BxnFqllo.js +0 -8
  68. package/dist/AvatarField-BEZuQTAH.js +0 -82
  69. package/dist/BooleanField-doa93aFX.js +0 -37
  70. package/dist/CodeField-jVV-hIXg.js +0 -21
  71. package/dist/ColorField-B53qKQGW.js +0 -42
  72. package/dist/CurrencyField-og0NJ2ax.js +0 -43
  73. package/dist/DateField-BFx64AtG.js +0 -21
  74. package/dist/DateTimeField-Cxs2Rx2f.js +0 -28
  75. package/dist/EmailField-BfcpzRe7.js +0 -31
  76. package/dist/FileField-KarqvhYm.js +0 -133
  77. package/dist/FormulaField-CJkkwIK8.js +0 -9
  78. package/dist/GeolocationField-B5SKZaqn.js +0 -123
  79. package/dist/GridField-DOotrUTo.js +0 -30
  80. package/dist/ImageField-Ddotp4u-.js +0 -90
  81. package/dist/LocationField-tOkQaPIM.js +0 -31
  82. package/dist/LookupField-DF36GvIP.js +0 -96
  83. package/dist/MasterDetailField-CpHw3nTE.js +0 -108
  84. package/dist/NumberField-CzBb2a28.js +0 -26
  85. package/dist/ObjectField-BoL-JqE4.js +0 -48
  86. package/dist/PasswordField-DrTzkYgj.js +0 -38
  87. package/dist/PercentField-B9ZUQ3zE.js +0 -63
  88. package/dist/PhoneField-Bf9lhpdu.js +0 -31
  89. package/dist/QRCodeField-PzMpdBKd.js +0 -77
  90. package/dist/RatingField-CeBMFe8o.js +0 -47
  91. package/dist/RichTextField-Ch7CHSQ0.js +0 -38
  92. package/dist/SelectField-f5Nbi02x.js +0 -26
  93. package/dist/SignatureField-CpxTX2tR.js +0 -85
  94. package/dist/SliderField-BoZtzgcr.js +0 -30
  95. package/dist/SummaryField-ugYPYxjP.js +0 -9
  96. package/dist/TextAreaField-rT1DLnV2.js +0 -39
  97. package/dist/TextField-CflRxusu.js +0 -32
  98. package/dist/TimeField-DeVeCpRu.js +0 -21
  99. package/dist/UrlField-UWKfhP9T.js +0 -33
  100. package/dist/UserField-Cp2zQDjz.js +0 -49
  101. package/dist/VectorField-CKg9jdGa.js +0 -25
  102. package/dist/index-V_WBvcaA.js +0 -100249
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/plugin-detail",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "DetailView plugin for Object UI - comprehensive detail page with sections, tabs, and related lists",
@@ -24,12 +24,12 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "lucide-react": "^0.576.0",
28
- "@object-ui/components": "3.1.2",
29
- "@object-ui/core": "3.1.2",
30
- "@object-ui/fields": "3.1.2",
31
- "@object-ui/types": "3.1.2",
32
- "@object-ui/react": "3.1.2"
27
+ "lucide-react": "^0.577.0",
28
+ "@object-ui/components": "3.1.4",
29
+ "@object-ui/fields": "3.1.4",
30
+ "@object-ui/core": "3.1.4",
31
+ "@object-ui/react": "3.1.4",
32
+ "@object-ui/types": "3.1.4"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",
@@ -38,11 +38,11 @@
38
38
  "devDependencies": {
39
39
  "@types/react": "19.2.14",
40
40
  "@types/react-dom": "19.2.3",
41
- "@vitejs/plugin-react": "^5.1.4",
41
+ "@vitejs/plugin-react": "^6.0.1",
42
42
  "typescript": "^5.9.3",
43
- "vite": "^7.3.1",
43
+ "vite": "^8.0.1",
44
44
  "vite-plugin-dts": "^4.5.4",
45
- "vitest": "^4.0.18"
45
+ "vitest": "^4.1.0"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "vite build",
@@ -53,6 +53,15 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number
53
53
  return '';
54
54
  }
55
55
 
56
+ export interface VirtualScrollOptions {
57
+ /** Enable virtual scrolling for large field sets */
58
+ enabled?: boolean;
59
+ /** Height of each field row in px (default: 60) */
60
+ itemHeight?: number;
61
+ /** Number of fields to render in the initial batch before revealing all (default: 20) */
62
+ batchSize?: number;
63
+ }
64
+
56
65
  export interface DetailSectionProps {
57
66
  section: DetailViewSectionType;
58
67
  data?: any;
@@ -65,6 +74,8 @@ export interface DetailSectionProps {
65
74
  isEditing?: boolean;
66
75
  /** Callback when a field value changes during inline editing */
67
76
  onFieldChange?: (field: string, value: any) => void;
77
+ /** Virtual scrolling configuration for sections with many fields */
78
+ virtualScroll?: VirtualScrollOptions;
68
79
  }
69
80
 
70
81
  export const DetailSection: React.FC<DetailSectionProps> = ({
@@ -75,9 +86,11 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
75
86
  objectName,
76
87
  isEditing = false,
77
88
  onFieldChange,
89
+ virtualScroll,
78
90
  }) => {
79
91
  const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
80
92
  const [copiedField, setCopiedField] = React.useState<string | null>(null);
93
+ const [visibleCount, setVisibleCount] = React.useState<number | undefined>(undefined);
81
94
  const { t } = useDetailTranslation();
82
95
  const { fieldLabel } = useSafeFieldLabel();
83
96
 
@@ -213,6 +226,32 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
213
226
  );
214
227
  };
215
228
 
229
+ // Virtual scroll: progressive batch rendering for large field sets
230
+ const vsEnabled = virtualScroll?.enabled === true;
231
+ const vsBatchSize = virtualScroll?.batchSize ?? 20;
232
+ /** Delay (ms) before revealing remaining fields after the initial batch */
233
+ const VS_REVEAL_DELAY = 100;
234
+
235
+ React.useEffect(() => {
236
+ if (!vsEnabled) {
237
+ setVisibleCount(undefined);
238
+ return;
239
+ }
240
+ // Start with a batch, then progressively reveal more
241
+ if (layoutFields.length <= vsBatchSize) {
242
+ setVisibleCount(undefined);
243
+ return;
244
+ }
245
+ setVisibleCount(vsBatchSize);
246
+ const timer = setTimeout(() => setVisibleCount(undefined), VS_REVEAL_DELAY);
247
+ return () => clearTimeout(timer);
248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
+ }, [vsEnabled, layoutFields.length, vsBatchSize]);
250
+
251
+ const renderedFields = visibleCount !== undefined
252
+ ? layoutFields.slice(0, visibleCount)
253
+ : layoutFields;
254
+
216
255
  const content = (
217
256
  <div
218
257
  className={cn(
@@ -223,7 +262,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
223
262
  "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
224
263
  )}
225
264
  >
226
- {layoutFields.map(renderField)}
265
+ {renderedFields.map(renderField)}
227
266
  </div>
228
267
  );
229
268
 
@@ -600,7 +600,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
600
600
 
601
601
  {/* Header Highlight Area */}
602
602
  {schema.highlightFields && schema.highlightFields.length > 0 && (
603
- <HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} />
603
+ <HeaderHighlight fields={schema.highlightFields} data={data} objectName={schema.objectName} objectSchema={objectSchema} />
604
604
  )}
605
605
 
606
606
  {/* Auto Tabs mode: wrap sections, related, activity into tabs */}
@@ -9,6 +9,7 @@
9
9
  import * as React from 'react';
10
10
  import { cn, Card, CardContent } from '@object-ui/components';
11
11
  import type { HighlightField } from '@object-ui/types';
12
+ import { getCellRenderer } from '@object-ui/fields';
12
13
  import { useSafeFieldLabel } from '@object-ui/react';
13
14
 
14
15
  export interface HeaderHighlightProps {
@@ -17,6 +18,8 @@ export interface HeaderHighlightProps {
17
18
  className?: string;
18
19
  /** Object name for i18n field label resolution */
19
20
  objectName?: string;
21
+ /** Object schema for field metadata enrichment */
22
+ objectSchema?: any;
20
23
  }
21
24
 
22
25
  export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
@@ -24,6 +27,7 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
24
27
  data,
25
28
  className,
26
29
  objectName,
30
+ objectSchema,
27
31
  }) => {
28
32
  const { fieldLabel } = useSafeFieldLabel();
29
33
  if (!fields.length || !data) return null;
@@ -48,6 +52,23 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
48
52
  )}>
49
53
  {visibleFields.map((field) => {
50
54
  const value = data[field.name];
55
+ // Enrich field metadata from objectSchema
56
+ const objectDefField = objectSchema?.fields?.[field.name];
57
+ const resolvedType = field.type || objectDefField?.type;
58
+ const enrichedField = {
59
+ name: field.name,
60
+ label: field.label,
61
+ type: resolvedType || 'text',
62
+ ...(objectDefField?.options && { options: objectDefField.options }),
63
+ ...(objectDefField?.currency && { currency: objectDefField.currency }),
64
+ ...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }),
65
+ ...(objectDefField?.format && { format: objectDefField.format }),
66
+ };
67
+
68
+ // Use type-aware cell renderer — all renderers coerce values via
69
+ // coerceToSafeValue() so even object/array data is safe (no error #310).
70
+ const CellRenderer = getCellRenderer(resolvedType || 'text');
71
+
51
72
  return (
52
73
  <div key={field.name} className="flex flex-col gap-0.5">
53
74
  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
@@ -55,7 +76,7 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
55
76
  {fieldLabel(objectName || '', field.name, field.label)}
56
77
  </span>
57
78
  <span className="text-sm font-semibold truncate">
58
- {String(value)}
79
+ <CellRenderer value={value} field={enrichedField as any} />
59
80
  </span>
60
81
  </div>
61
82
  );
@@ -38,6 +38,8 @@ export interface RecordChatterPanelProps {
38
38
  filterMode?: FeedFilterMode;
39
39
  /** Called when filter changes */
40
40
  onFilterChange?: (mode: FeedFilterMode) => void;
41
+ /** When true, auto-collapse panel when there are no feed items */
42
+ collapseWhenEmpty?: boolean;
41
43
  className?: string;
42
44
  }
43
45
 
@@ -63,12 +65,13 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
63
65
  onToggleSubscription,
64
66
  filterMode,
65
67
  onFilterChange,
68
+ collapseWhenEmpty = false,
66
69
  className,
67
70
  }) => {
68
71
  const position = config?.position ?? 'right';
69
72
  const width = config?.width ?? '360px';
70
73
  const collapsible = config?.collapsible ?? true;
71
- const defaultCollapsed = config?.defaultCollapsed ?? false;
74
+ const defaultCollapsed = (collapseWhenEmpty && items.length === 0) || (config?.defaultCollapsed ?? false);
72
75
 
73
76
  const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
74
77
 
@@ -142,6 +145,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
142
145
  onToggleSubscription={onToggleSubscription}
143
146
  filterMode={filterMode}
144
147
  onFilterChange={onFilterChange}
148
+ collapseWhenEmpty={collapseWhenEmpty}
145
149
  className="border-0 shadow-none"
146
150
  />
147
151
  </div>
@@ -194,6 +198,7 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
194
198
  onToggleSubscription={onToggleSubscription}
195
199
  filterMode={filterMode}
196
200
  onFilterChange={onFilterChange}
201
+ collapseWhenEmpty={collapseWhenEmpty}
197
202
  />
198
203
  </div>
199
204
  )}
@@ -197,7 +197,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
197
197
  if (!objectSchema?.fields) return [];
198
198
  const resolvedObjectName = objectName || api || '';
199
199
  return Object.entries(objectSchema.fields)
200
- .filter(([key]) => !key.startsWith('_'))
200
+ .filter(([key]) => !key.startsWith('_') && key !== 'id')
201
201
  .map(([key, def]: [string, any]) => {
202
202
  const col: any = {
203
203
  accessorKey: key,
@@ -391,6 +391,67 @@ describe('DetailSection', () => {
391
391
  });
392
392
  });
393
393
  });
394
+
395
+ it('should initially render a batch when virtualScroll is enabled with many fields', () => {
396
+ const section = {
397
+ title: 'Virtual',
398
+ fields: Array.from({ length: 50 }, (_, i) => ({
399
+ name: `field_${i}`,
400
+ label: `Field ${i}`,
401
+ type: 'text',
402
+ })),
403
+ };
404
+ const { container } = render(
405
+ <DetailSection
406
+ section={section}
407
+ data={{}}
408
+ virtualScroll={{ enabled: true, batchSize: 10 }}
409
+ />
410
+ );
411
+ const grid = container.querySelector('.grid');
412
+ expect(grid).toBeTruthy();
413
+ // Initially should render only the batch (10 fields), not all 50
414
+ const fieldElements = grid!.children;
415
+ expect(fieldElements.length).toBeLessThanOrEqual(10);
416
+ });
417
+
418
+ it('should render all fields when virtualScroll is disabled', () => {
419
+ const section = {
420
+ title: 'No Virtual',
421
+ fields: Array.from({ length: 50 }, (_, i) => ({
422
+ name: `field_${i}`,
423
+ label: `Field ${i}`,
424
+ type: 'text',
425
+ })),
426
+ };
427
+ const { container } = render(
428
+ <DetailSection section={section} data={{}} />
429
+ );
430
+ const grid = container.querySelector('.grid');
431
+ expect(grid).toBeTruthy();
432
+ expect(grid!.children.length).toBe(50);
433
+ });
434
+
435
+ it('should render all fields when virtualScroll is enabled but field count is below batch size', () => {
436
+ const section = {
437
+ title: 'Small',
438
+ fields: Array.from({ length: 5 }, (_, i) => ({
439
+ name: `field_${i}`,
440
+ label: `Field ${i}`,
441
+ type: 'text',
442
+ })),
443
+ };
444
+ const { container } = render(
445
+ <DetailSection
446
+ section={section}
447
+ data={{}}
448
+ virtualScroll={{ enabled: true, batchSize: 20 }}
449
+ />
450
+ );
451
+ const grid = container.querySelector('.grid');
452
+ expect(grid).toBeTruthy();
453
+ expect(grid!.children.length).toBe(5);
454
+ });
394
455
  });
395
456
 
396
457
  describe('getResponsiveSpanClass', () => {
@@ -65,4 +65,149 @@ describe('HeaderHighlight', () => {
65
65
  render(<HeaderHighlight fields={fieldsWithIcon} data={{ revenue: '$5M' }} />);
66
66
  expect(screen.getByText('💰')).toBeInTheDocument();
67
67
  });
68
+
69
+ it('should render currency fields with formatted value via CellRenderer', () => {
70
+ const currencyFields: HighlightField[] = [
71
+ { name: 'amount', label: 'Amount', type: 'currency' },
72
+ ];
73
+ render(<HeaderHighlight fields={currencyFields} data={{ amount: 250000 }} />);
74
+ // CurrencyCellRenderer should format — should NOT show raw "250000"
75
+ expect(screen.queryByText('250000')).not.toBeInTheDocument();
76
+ expect(screen.getByText(/250,000/)).toBeInTheDocument();
77
+ });
78
+
79
+ it('should render select fields as badge via CellRenderer', () => {
80
+ const selectFields: HighlightField[] = [
81
+ { name: 'stage', label: 'Stage', type: 'select' },
82
+ ];
83
+ render(
84
+ <HeaderHighlight
85
+ fields={selectFields}
86
+ data={{ stage: 'prospecting' }}
87
+ objectSchema={{
88
+ fields: {
89
+ stage: {
90
+ type: 'select',
91
+ options: [
92
+ { value: 'prospecting', label: 'Prospecting', color: 'blue' },
93
+ ],
94
+ },
95
+ },
96
+ }}
97
+ />
98
+ );
99
+ expect(screen.getByText('Prospecting')).toBeInTheDocument();
100
+ });
101
+
102
+ it('should enrich field type from objectSchema when field.type is not set', () => {
103
+ const fieldsNoType: HighlightField[] = [
104
+ { name: 'amount', label: 'Amount' },
105
+ ];
106
+ render(
107
+ <HeaderHighlight
108
+ fields={fieldsNoType}
109
+ data={{ amount: 5000 }}
110
+ objectSchema={{
111
+ fields: {
112
+ amount: { type: 'currency', currency: 'USD' },
113
+ },
114
+ }}
115
+ />
116
+ );
117
+ // CurrencyCellRenderer should format, not raw String()
118
+ expect(screen.queryByText('5000')).not.toBeInTheDocument();
119
+ expect(screen.getByText(/5,000/)).toBeInTheDocument();
120
+ });
121
+
122
+ it('should fall back to text when no type info is available', () => {
123
+ const fieldsNoType: HighlightField[] = [
124
+ { name: 'custom', label: 'Custom' },
125
+ ];
126
+ render(<HeaderHighlight fields={fieldsNoType} data={{ custom: 'raw-value' }} />);
127
+ expect(screen.getByText('raw-value')).toBeInTheDocument();
128
+ });
129
+
130
+ it('should safely render object values without crashing (React error #310 guard)', () => {
131
+ // Simulates MongoDB Decimal128 or expanded reference objects
132
+ const fieldsWithNumber: HighlightField[] = [
133
+ { name: 'amount', label: 'Amount' },
134
+ { name: 'account', label: 'Account' },
135
+ ];
136
+ const objectData = {
137
+ amount: { $numberDecimal: '250000' },
138
+ account: { id: 'abc', name: 'Acme Corp' },
139
+ };
140
+ const objectSchema = {
141
+ fields: {
142
+ amount: { type: 'number' },
143
+ account: { type: 'text' },
144
+ },
145
+ };
146
+ // Should NOT crash — cell renderers coerce values via coerceToSafeValue
147
+ const { container } = render(
148
+ <HeaderHighlight
149
+ fields={fieldsWithNumber}
150
+ data={objectData}
151
+ objectSchema={objectSchema}
152
+ />
153
+ );
154
+ expect(container.innerHTML).not.toBe('');
155
+ // NumberCellRenderer coerces $numberDecimal to number
156
+ expect(screen.getByText('250,000')).toBeInTheDocument();
157
+ // TextCellRenderer extracts name from object
158
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
159
+ });
160
+
161
+ it('should safely render lookup object values via LookupCellRenderer', () => {
162
+ const lookupFields: HighlightField[] = [
163
+ { name: 'owner', label: 'Owner' },
164
+ ];
165
+ const lookupData = {
166
+ owner: { id: 'u1', name: 'Jane Doe' },
167
+ };
168
+ const objectSchema = {
169
+ fields: {
170
+ owner: { type: 'lookup' },
171
+ },
172
+ };
173
+ // LookupCellRenderer handles object values natively
174
+ render(
175
+ <HeaderHighlight
176
+ fields={lookupFields}
177
+ data={lookupData}
178
+ objectSchema={objectSchema}
179
+ />
180
+ );
181
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
182
+ });
183
+
184
+ it('should safely render array values via TextCellRenderer', () => {
185
+ const arrayFields: HighlightField[] = [
186
+ { name: 'tags', label: 'Tags' },
187
+ ];
188
+ const arrayData = {
189
+ tags: ['urgent', 'follow-up'],
190
+ };
191
+ const { container } = render(
192
+ <HeaderHighlight fields={arrayFields} data={arrayData} />
193
+ );
194
+ expect(container.innerHTML).not.toBe('');
195
+ // TextCellRenderer coerces arrays to comma-separated string
196
+ expect(screen.getByText('urgent, follow-up')).toBeInTheDocument();
197
+ });
198
+
199
+ it('should safely render array of objects via TextCellRenderer', () => {
200
+ const arrayFields: HighlightField[] = [
201
+ { name: 'contacts', label: 'Contacts' },
202
+ ];
203
+ const arrayData = {
204
+ contacts: [{ name: 'Alice' }, { name: 'Bob' }],
205
+ };
206
+ const { container } = render(
207
+ <HeaderHighlight fields={arrayFields} data={arrayData} />
208
+ );
209
+ expect(container.innerHTML).not.toBe('');
210
+ // TextCellRenderer coerces array of objects to "Alice, Bob"
211
+ expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
212
+ });
68
213
  });
@@ -224,4 +224,42 @@ describe('RecordChatterPanel', () => {
224
224
  expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
225
225
  });
226
226
  });
227
+
228
+ describe('collapseWhenEmpty', () => {
229
+ it('should auto-collapse when empty and collapseWhenEmpty is true (inline mode)', () => {
230
+ render(
231
+ <RecordChatterPanel
232
+ config={{ position: 'bottom', collapsible: true }}
233
+ collapseWhenEmpty
234
+ items={[]}
235
+ />,
236
+ );
237
+ // Should be collapsed because items is empty
238
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
239
+ });
240
+
241
+ it('should not auto-collapse when items exist and collapseWhenEmpty is true', () => {
242
+ render(
243
+ <RecordChatterPanel
244
+ config={{ position: 'bottom', collapsible: true }}
245
+ collapseWhenEmpty
246
+ items={mockItems}
247
+ />,
248
+ );
249
+ // Should be expanded because there are items
250
+ expect(screen.getByText('Activity')).toBeInTheDocument();
251
+ });
252
+
253
+ it('should auto-collapse sidebar when empty and collapseWhenEmpty is true', () => {
254
+ render(
255
+ <RecordChatterPanel
256
+ config={{ position: 'right', collapsible: true }}
257
+ collapseWhenEmpty
258
+ items={[]}
259
+ />,
260
+ );
261
+ // Should be collapsed
262
+ expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument();
263
+ });
264
+ });
227
265
  });
@@ -71,7 +71,7 @@ describe('RelatedList', () => {
71
71
  fields: {
72
72
  product: { type: 'string', label: 'Product' },
73
73
  quantity: { type: 'number', label: 'Quantity' },
74
- _id: { type: 'string', label: 'ID' },
74
+ id: { type: 'string', label: 'ID' },
75
75
  },
76
76
  }),
77
77
  find: vi.fn(),
@@ -92,12 +92,12 @@ describe('RelatedList', () => {
92
92
  expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order_item');
93
93
  });
94
94
 
95
- // Verify columns are generated from schema (excluding _id)
95
+ // Verify columns are generated from schema (excluding id)
96
96
  await waitFor(() => {
97
97
  expect(screen.getByText('Product')).toBeInTheDocument();
98
98
  expect(screen.getByText('Quantity')).toBeInTheDocument();
99
99
  });
100
- // _id should be filtered out
100
+ // id should be filtered out
101
101
  expect(screen.queryByText('ID')).not.toBeInTheDocument();
102
102
  });
103
103
 
@@ -37,6 +37,33 @@ describe('Detail Auto-Layout', () => {
37
37
  expect(inferDetailColumns(15)).toBe(3);
38
38
  expect(inferDetailColumns(50)).toBe(3);
39
39
  });
40
+
41
+ it('should cap to 1 column when containerWidth < 640', () => {
42
+ expect(inferDetailColumns(15, 500)).toBe(1);
43
+ expect(inferDetailColumns(5, 639)).toBe(1);
44
+ });
45
+
46
+ it('should cap to 2 columns when containerWidth < 900', () => {
47
+ expect(inferDetailColumns(15, 800)).toBe(2);
48
+ expect(inferDetailColumns(15, 640)).toBe(2);
49
+ expect(inferDetailColumns(5, 899)).toBe(2);
50
+ });
51
+
52
+ it('should not cap columns when containerWidth >= 900', () => {
53
+ expect(inferDetailColumns(15, 900)).toBe(3);
54
+ expect(inferDetailColumns(15, 1200)).toBe(3);
55
+ });
56
+
57
+ it('should not cap below field-count inference', () => {
58
+ // 2 fields → 1 column, containerWidth 800 would cap at 2, but inference says 1
59
+ expect(inferDetailColumns(2, 800)).toBe(1);
60
+ });
61
+
62
+ it('should still work without containerWidth (backward compatible)', () => {
63
+ expect(inferDetailColumns(15)).toBe(3);
64
+ expect(inferDetailColumns(5)).toBe(2);
65
+ expect(inferDetailColumns(2)).toBe(1);
66
+ });
40
67
  });
41
68
 
42
69
  describe('isWideFieldType', () => {
@@ -180,5 +207,22 @@ describe('Detail Auto-Layout', () => {
180
207
  expect(result.columns).toBe(2);
181
208
  expect(result.fields.length).toBe(8);
182
209
  });
210
+
211
+ it('should cap columns based on containerWidth when provided', () => {
212
+ const fields = Array.from({ length: 15 }, (_, i) => ({
213
+ name: `f${i}`, label: `F${i}`, type: 'text',
214
+ }));
215
+ // Narrow container: should cap to 1
216
+ const result = applyDetailAutoLayout(fields, undefined, 500);
217
+ expect(result.columns).toBe(1);
218
+ });
219
+
220
+ it('should still respect explicit schemaColumns regardless of containerWidth', () => {
221
+ const fields = Array.from({ length: 15 }, (_, i) => ({
222
+ name: `f${i}`, label: `F${i}`, type: 'text',
223
+ }));
224
+ const result = applyDetailAutoLayout(fields, 3, 500);
225
+ expect(result.columns).toBe(3);
226
+ });
183
227
  });
184
228
  });
package/src/autoLayout.ts CHANGED
@@ -46,16 +46,31 @@ export function isWideFieldType(type: string): boolean {
46
46
 
47
47
  /**
48
48
  * Infer optimal number of columns for a detail section based on field count.
49
+ * When containerWidth is provided, limits columns for narrower viewports.
49
50
  *
50
- * Rules:
51
+ * Rules (field-count based):
51
52
  * - 0-3 fields → 1 column
52
53
  * - 4-10 fields → 2 columns
53
54
  * - 11+ fields → 3 columns
55
+ *
56
+ * Responsive capping (when containerWidth is supplied):
57
+ * - containerWidth < 640px → max 1 column
58
+ * - containerWidth < 900px → max 2 columns
59
+ * - containerWidth >= 900px → no cap
54
60
  */
55
- export function inferDetailColumns(fieldCount: number): number {
56
- if (fieldCount <= 3) return 1;
57
- if (fieldCount <= 10) return 2;
58
- return 3;
61
+ export function inferDetailColumns(fieldCount: number, containerWidth?: number): number {
62
+ let cols: number;
63
+ if (fieldCount <= 3) cols = 1;
64
+ else if (fieldCount <= 10) cols = 2;
65
+ else cols = 3;
66
+
67
+ // Apply responsive capping when container width is known
68
+ if (containerWidth !== undefined) {
69
+ if (containerWidth < 640) return Math.min(cols, 1);
70
+ if (containerWidth < 900) return Math.min(cols, 2);
71
+ }
72
+
73
+ return cols;
59
74
  }
60
75
 
61
76
  /**
@@ -89,11 +104,13 @@ export function applyAutoSpan(
89
104
  *
90
105
  * @param fields - The section fields
91
106
  * @param schemaColumns - User-provided columns (from DetailViewSection or DetailViewSchema)
107
+ * @param containerWidth - Optional container width in px for responsive column capping
92
108
  * @returns Object with processed fields and inferred columns
93
109
  */
94
110
  export function applyDetailAutoLayout(
95
111
  fields: DetailViewField[],
96
- schemaColumns: number | undefined
112
+ schemaColumns: number | undefined,
113
+ containerWidth?: number
97
114
  ): { fields: DetailViewField[]; columns: number } {
98
115
  // If user explicitly set columns, respect it but still apply auto span
99
116
  if (schemaColumns !== undefined) {
@@ -101,8 +118,8 @@ export function applyDetailAutoLayout(
101
118
  return { fields: processed, columns: schemaColumns };
102
119
  }
103
120
 
104
- // Infer columns from field count
105
- const columns = inferDetailColumns(fields.length);
121
+ // Infer columns from field count (with optional container-width capping)
122
+ const columns = inferDetailColumns(fields.length, containerWidth);
106
123
 
107
124
  // Apply auto span for wide fields
108
125
  const processed = applyAutoSpan(fields, columns);
package/src/index.tsx CHANGED
@@ -36,7 +36,7 @@ export { SubscriptionToggle } from './SubscriptionToggle';
36
36
  export { ReactionPicker } from './ReactionPicker';
37
37
  export { ThreadedReplies } from './ThreadedReplies';
38
38
  export type { DetailViewProps } from './DetailView';
39
- export type { DetailSectionProps } from './DetailSection';
39
+ export type { DetailSectionProps, VirtualScrollOptions } from './DetailSection';
40
40
  export type { DetailTabsProps } from './DetailTabs';
41
41
  export type { RelatedListProps } from './RelatedList';
42
42
  export type { SectionGroupProps } from './SectionGroup';