@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.
- package/.turbo/turbo-build.log +53 -47
- package/CHANGELOG.md +20 -0
- package/dist/AddressField-DBkEyMcG.js +93 -0
- package/dist/AutoNumberField-Baa191z-.js +14 -0
- package/dist/AvatarField-YGj51ozd.js +80 -0
- package/dist/BooleanField-CaA898Tk.js +40 -0
- package/dist/CodeField-BU51nl1L.js +22 -0
- package/dist/ColorField-Cnf6ZM7c.js +37 -0
- package/dist/CurrencyField-Wg-XOId2.js +51 -0
- package/dist/DateField-Cth1ky_m.js +21 -0
- package/dist/DateTimeField-B0m6FhHL.js +32 -0
- package/dist/EmailField-Do7qT_L_.js +28 -0
- package/dist/FileField-aRJAdbQb.js +151 -0
- package/dist/FormulaField-DTMkagFx.js +14 -0
- package/dist/GeolocationField-RqpHWTEv.js +113 -0
- package/dist/GridField-D4IH0cpo.js +51 -0
- package/dist/ImageField-BYCFajjr.js +75 -0
- package/dist/LocationField-Bi_ew9sd.js +35 -0
- package/dist/LookupField-BjwlDPtt.js +902 -0
- package/dist/MasterDetailField-I1A9oEGC.js +94 -0
- package/dist/NumberField-D_NucQlp.js +26 -0
- package/dist/ObjectField-CG-LaM65.js +52 -0
- package/dist/PasswordField-DBtluGJ1.js +35 -0
- package/dist/PercentField-B6sO_J3i.js +63 -0
- package/dist/PhoneField-CcQAWwR6.js +28 -0
- package/dist/QRCodeField-CEjWs-J5.js +72 -0
- package/dist/RatingField-B_Mnr63i.js +39 -0
- package/dist/RichTextField-qOEJl5Ai.js +32 -0
- package/dist/SelectField-C8hWu3gm.js +30 -0
- package/dist/SignatureField-CddhEK9u.js +92 -0
- package/dist/SliderField-Df5hMzNc.js +34 -0
- package/dist/SummaryField-DgiFm-Cr.js +19 -0
- package/dist/TextAreaField-DuriTqsD.js +36 -0
- package/dist/TextField-CGNSl7RU.js +29 -0
- package/dist/TimeField-YO58ctFg.js +21 -0
- package/dist/UrlField-1-BMM1jn.js +33 -0
- package/dist/UserField-B6GqxP_S.js +78 -0
- package/dist/VectorField-BkEjbSt0.js +36 -0
- package/dist/index.js +4092 -33
- package/dist/index.umd.cjs +88 -81
- package/dist/plugin-detail.css +3 -1
- package/dist/src/DetailSection.d.ts +10 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/HeaderHighlight.d.ts +2 -0
- package/dist/src/HeaderHighlight.d.ts.map +1 -1
- package/dist/src/RecordChatterPanel.d.ts +2 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -1
- package/dist/src/autoLayout.d.ts +10 -3
- package/dist/src/autoLayout.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src-CXr1-vVl.js +77662 -0
- package/package.json +10 -10
- package/src/DetailSection.tsx +40 -1
- package/src/DetailView.tsx +1 -1
- package/src/HeaderHighlight.tsx +22 -1
- package/src/RecordChatterPanel.tsx +6 -1
- package/src/RelatedList.tsx +1 -1
- package/src/__tests__/DetailSection.test.tsx +61 -0
- package/src/__tests__/HeaderHighlight.test.tsx +145 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +38 -0
- package/src/__tests__/RelatedList.test.tsx +3 -3
- package/src/__tests__/autoLayout.test.ts +44 -0
- package/src/autoLayout.ts +25 -8
- package/src/index.tsx +1 -1
- package/dist/AddressField-QBIlXCFl.js +0 -96
- package/dist/AutoNumberField-BxnFqllo.js +0 -8
- package/dist/AvatarField-BEZuQTAH.js +0 -82
- package/dist/BooleanField-doa93aFX.js +0 -37
- package/dist/CodeField-jVV-hIXg.js +0 -21
- package/dist/ColorField-B53qKQGW.js +0 -42
- package/dist/CurrencyField-og0NJ2ax.js +0 -43
- package/dist/DateField-BFx64AtG.js +0 -21
- package/dist/DateTimeField-Cxs2Rx2f.js +0 -28
- package/dist/EmailField-BfcpzRe7.js +0 -31
- package/dist/FileField-KarqvhYm.js +0 -133
- package/dist/FormulaField-CJkkwIK8.js +0 -9
- package/dist/GeolocationField-B5SKZaqn.js +0 -123
- package/dist/GridField-DOotrUTo.js +0 -30
- package/dist/ImageField-Ddotp4u-.js +0 -90
- package/dist/LocationField-tOkQaPIM.js +0 -31
- package/dist/LookupField-DF36GvIP.js +0 -96
- package/dist/MasterDetailField-CpHw3nTE.js +0 -108
- package/dist/NumberField-CzBb2a28.js +0 -26
- package/dist/ObjectField-BoL-JqE4.js +0 -48
- package/dist/PasswordField-DrTzkYgj.js +0 -38
- package/dist/PercentField-B9ZUQ3zE.js +0 -63
- package/dist/PhoneField-Bf9lhpdu.js +0 -31
- package/dist/QRCodeField-PzMpdBKd.js +0 -77
- package/dist/RatingField-CeBMFe8o.js +0 -47
- package/dist/RichTextField-Ch7CHSQ0.js +0 -38
- package/dist/SelectField-f5Nbi02x.js +0 -26
- package/dist/SignatureField-CpxTX2tR.js +0 -85
- package/dist/SliderField-BoZtzgcr.js +0 -30
- package/dist/SummaryField-ugYPYxjP.js +0 -9
- package/dist/TextAreaField-rT1DLnV2.js +0 -39
- package/dist/TextField-CflRxusu.js +0 -32
- package/dist/TimeField-DeVeCpRu.js +0 -21
- package/dist/UrlField-UWKfhP9T.js +0 -33
- package/dist/UserField-Cp2zQDjz.js +0 -49
- package/dist/VectorField-CKg9jdGa.js +0 -25
- 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.
|
|
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.
|
|
28
|
-
"@object-ui/components": "3.1.
|
|
29
|
-
"@object-ui/
|
|
30
|
-
"@object-ui/
|
|
31
|
-
"@object-ui/
|
|
32
|
-
"@object-ui/
|
|
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": "^
|
|
41
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
42
42
|
"typescript": "^5.9.3",
|
|
43
|
-
"vite": "^
|
|
43
|
+
"vite": "^8.0.1",
|
|
44
44
|
"vite-plugin-dts": "^4.5.4",
|
|
45
|
-
"vitest": "^4.0
|
|
45
|
+
"vitest": "^4.1.0"
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "vite build",
|
package/src/DetailSection.tsx
CHANGED
|
@@ -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
|
-
{
|
|
265
|
+
{renderedFields.map(renderField)}
|
|
227
266
|
</div>
|
|
228
267
|
);
|
|
229
268
|
|
package/src/DetailView.tsx
CHANGED
|
@@ -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 */}
|
package/src/HeaderHighlight.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
)}
|
package/src/RelatedList.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
57
|
-
if (fieldCount <=
|
|
58
|
-
|
|
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';
|