@object-ui/plugin-detail 3.1.1 → 3.1.3
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 +43 -41
- package/CHANGELOG.md +20 -0
- package/dist/{AddressField-B1iVr404.js → AddressField-BtiTrEpf.js} +1 -1
- package/dist/{AvatarField-Duw4xOLZ.js → AvatarField-CwlnWNSf.js} +7 -7
- package/dist/{BooleanField-CZ4axVeq.js → BooleanField-DpMXU2ya.js} +1 -1
- package/dist/{CodeField-BSz-mk2v.js → CodeField-gwmcFihg.js} +2 -2
- package/dist/{ColorField-B522ad8m.js → ColorField-CWmF_zoW.js} +1 -1
- package/dist/{CurrencyField-Cwr3_pow.js → CurrencyField-BF3tYAgm.js} +1 -1
- package/dist/{DateField-DCo6dxud.js → DateField-a6Ka9ph2.js} +1 -1
- package/dist/{DateTimeField-BWfBuANO.js → DateTimeField-C4wWOEiw.js} +1 -1
- package/dist/{EmailField-CpwbdVCU.js → EmailField-DJqiQ4sp.js} +1 -1
- package/dist/{FileField-DVAUAJ8e.js → FileField-ChjjCydz.js} +1 -1
- package/dist/{GeolocationField-DNCKitgo.js → GeolocationField-BnkeUBek.js} +1 -1
- package/dist/{GridField-DSblZNfp.js → GridField-DoHqc2ON.js} +6 -6
- package/dist/{ImageField-DBAlnMon.js → ImageField-Ld7SHA8N.js} +1 -1
- package/dist/{LocationField-DsHsXA6R.js → LocationField-Bgu-vMAE.js} +1 -1
- package/dist/{MasterDetailField-Db8b7Gqs.js → MasterDetailField-Bp5WBTzU.js} +3 -3
- package/dist/{NumberField-0IGp7lcA.js → NumberField-uBqVZ-gt.js} +1 -1
- package/dist/{ObjectField-BLApgJtS.js → ObjectField-BH1Md9gH.js} +6 -6
- package/dist/{PasswordField-pHKyNlmo.js → PasswordField-D8GZjY7d.js} +1 -1
- package/dist/{PercentField-CwgKmlIb.js → PercentField-DyK8vg8M.js} +1 -1
- package/dist/{PhoneField-lKtbYOdN.js → PhoneField-B3qJyLP0.js} +1 -1
- package/dist/{QRCodeField-BTTasT3w.js → QRCodeField-CGiRTCZq.js} +1 -1
- package/dist/{RatingField-De2X-l44.js → RatingField-CWVaJNyf.js} +4 -4
- package/dist/{RichTextField-B5QnvUOr.js → RichTextField-CusveP9T.js} +1 -1
- package/dist/{SelectField-C9AZRHWu.js → SelectField-UdDfsEZo.js} +1 -1
- package/dist/{SignatureField-BgcEmYzd.js → SignatureField-DFvPKbuI.js} +1 -1
- package/dist/{SliderField-BzrttVOY.js → SliderField-C-HvGV9e.js} +1 -1
- package/dist/{TextAreaField-DSE_CaU6.js → TextAreaField-C5KygUT3.js} +1 -1
- package/dist/{TextField-DFQ4T9PR.js → TextField-oUjuqQ1x.js} +1 -1
- package/dist/{TimeField-F0cfmsps.js → TimeField-SsQ6rfk5.js} +1 -1
- package/dist/{UrlField-DLXrFIH-.js → UrlField-kd48Ip95.js} +1 -1
- package/dist/{UserField-PXMmxJY9.js → UserField-BOjE_CAz.js} +11 -11
- package/dist/index-D2t9pLAg.js +99948 -0
- package/dist/index.js +10 -10
- package/dist/index.umd.cjs +118 -49
- package/dist/plugin-detail.css +1 -1
- package/dist/src/DetailSection.d.ts +19 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.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/package.json +7 -7
- package/src/DetailSection.tsx +79 -22
- package/src/DetailView.tsx +16 -9
- package/src/HeaderHighlight.tsx +22 -1
- package/src/RecordChatterPanel.tsx +6 -1
- package/src/RelatedList.tsx +1 -1
- package/src/__tests__/DetailSection.test.tsx +171 -1
- package/src/__tests__/DetailView.test.tsx +31 -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/LookupField-CsT0QQz2.js +0 -96
- package/dist/index-qQ1C-yUR.js +0 -59976
- package/src/registration.test.tsx +0 -18
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
10
|
import { render, screen } from '@testing-library/react';
|
|
11
|
-
import { DetailSection } from '../DetailSection';
|
|
11
|
+
import { DetailSection, getResponsiveSpanClass } from '../DetailSection';
|
|
12
12
|
|
|
13
13
|
describe('DetailSection', () => {
|
|
14
14
|
it('should render text fields as plain text', () => {
|
|
@@ -317,4 +317,174 @@ describe('DetailSection', () => {
|
|
|
317
317
|
// Should use 'text' renderer, not 'number'
|
|
318
318
|
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
319
319
|
});
|
|
320
|
+
|
|
321
|
+
it('should use responsive span classes for wide fields in 3-column layout', () => {
|
|
322
|
+
const section = {
|
|
323
|
+
title: 'Wide Fields',
|
|
324
|
+
fields: Array.from({ length: 12 }, (_, i) => ({
|
|
325
|
+
name: `field_${i}`,
|
|
326
|
+
label: `Field ${i}`,
|
|
327
|
+
type: i === 5 ? 'textarea' : 'text',
|
|
328
|
+
})),
|
|
329
|
+
};
|
|
330
|
+
const { container } = render(
|
|
331
|
+
<DetailSection section={section} data={{}} />
|
|
332
|
+
);
|
|
333
|
+
const grid = container.querySelector('.grid');
|
|
334
|
+
expect(grid).toBeTruthy();
|
|
335
|
+
expect(grid!.className).toContain('lg:grid-cols-3');
|
|
336
|
+
// Wide field (textarea) should have responsive span, not bare col-span-3
|
|
337
|
+
const fields = container.querySelectorAll('[class*="col-span"]');
|
|
338
|
+
fields.forEach((field) => {
|
|
339
|
+
// No bare col-span-3 at base level — must be lg: prefixed
|
|
340
|
+
const classes = field.className.split(/\s+/);
|
|
341
|
+
const hasBareSpan3 = classes.some((c: string) => c === 'col-span-3');
|
|
342
|
+
expect(hasBareSpan3).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should use responsive span classes for wide fields in 2-column layout', () => {
|
|
347
|
+
const section = {
|
|
348
|
+
title: 'Wide Fields',
|
|
349
|
+
fields: [
|
|
350
|
+
{ name: 'a', label: 'A', type: 'text' },
|
|
351
|
+
{ name: 'b', label: 'B', type: 'text' },
|
|
352
|
+
{ name: 'c', label: 'C', type: 'text' },
|
|
353
|
+
{ name: 'd', label: 'D', type: 'text' },
|
|
354
|
+
{ name: 'notes', label: 'Notes', type: 'textarea' },
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
const { container } = render(
|
|
358
|
+
<DetailSection section={section} data={{}} />
|
|
359
|
+
);
|
|
360
|
+
const grid = container.querySelector('.grid');
|
|
361
|
+
expect(grid!.className).toContain('md:grid-cols-2');
|
|
362
|
+
// Wide field should have md:col-span-2, not bare col-span-2
|
|
363
|
+
const fields = container.querySelectorAll('[class*="col-span"]');
|
|
364
|
+
fields.forEach((field) => {
|
|
365
|
+
const classes = field.className.split(/\s+/);
|
|
366
|
+
const hasBareSpan2 = classes.some((c: string) => c === 'col-span-2');
|
|
367
|
+
expect(hasBareSpan2).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should not apply col-span at base breakpoint to prevent implicit grid columns on mobile', () => {
|
|
372
|
+
const section = {
|
|
373
|
+
title: 'Mobile Safe',
|
|
374
|
+
fields: Array.from({ length: 15 }, (_, i) => ({
|
|
375
|
+
name: `field_${i}`,
|
|
376
|
+
label: `Field ${i}`,
|
|
377
|
+
type: i === 0 ? 'textarea' : 'text',
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
const { container } = render(
|
|
381
|
+
<DetailSection section={section} data={{}} />
|
|
382
|
+
);
|
|
383
|
+
// Ensure no bare col-span-N (N>1) classes without responsive prefix
|
|
384
|
+
const allElements = container.querySelectorAll('*');
|
|
385
|
+
allElements.forEach((el) => {
|
|
386
|
+
const classes = el.className?.split?.(/\s+/) || [];
|
|
387
|
+
classes.forEach((cls: string) => {
|
|
388
|
+
if (cls.match(/^col-span-[2-9]$/)) {
|
|
389
|
+
throw new Error(`Found bare "${cls}" class without responsive prefix — would break mobile single-column layout`);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|
|
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
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe('getResponsiveSpanClass', () => {
|
|
458
|
+
it('should return empty string for no span', () => {
|
|
459
|
+
expect(getResponsiveSpanClass(undefined, 2)).toBe('');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should return empty string for span=1', () => {
|
|
463
|
+
expect(getResponsiveSpanClass(1, 3)).toBe('');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should return empty string for 1-column layout', () => {
|
|
467
|
+
expect(getResponsiveSpanClass(3, 1)).toBe('');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should return md:col-span-2 for span=2 in 2-column layout', () => {
|
|
471
|
+
expect(getResponsiveSpanClass(2, 2)).toBe('md:col-span-2');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('should cap span to 2 in 2-column layout', () => {
|
|
475
|
+
expect(getResponsiveSpanClass(3, 2)).toBe('md:col-span-2');
|
|
476
|
+
expect(getResponsiveSpanClass(6, 2)).toBe('md:col-span-2');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should return md:col-span-2 for span=2 in 3-column layout', () => {
|
|
480
|
+
expect(getResponsiveSpanClass(2, 3)).toBe('md:col-span-2');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should return responsive classes for span=3 in 3-column layout', () => {
|
|
484
|
+
expect(getResponsiveSpanClass(3, 3)).toBe('md:col-span-2 lg:col-span-3');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should cap span to 3 in 3-column layout', () => {
|
|
488
|
+
expect(getResponsiveSpanClass(6, 3)).toBe('md:col-span-2 lg:col-span-3');
|
|
489
|
+
});
|
|
320
490
|
});
|
|
@@ -539,6 +539,37 @@ describe('DetailView', () => {
|
|
|
539
539
|
expect(onBack).toHaveBeenCalled();
|
|
540
540
|
});
|
|
541
541
|
|
|
542
|
+
it('should try fallback with alternate ID when first findOne throws an error', async () => {
|
|
543
|
+
let callCount = 0;
|
|
544
|
+
const mockDataSource = {
|
|
545
|
+
findOne: vi.fn().mockImplementation((_obj: string, id: string) => {
|
|
546
|
+
callCount++;
|
|
547
|
+
if (callCount === 1) {
|
|
548
|
+
// First call throws (simulate server error)
|
|
549
|
+
return Promise.reject(new Error('Server error'));
|
|
550
|
+
}
|
|
551
|
+
// Second call (fallback) succeeds
|
|
552
|
+
return Promise.resolve({ name: 'Alice' });
|
|
553
|
+
}),
|
|
554
|
+
} as any;
|
|
555
|
+
|
|
556
|
+
const schema: DetailViewSchema = {
|
|
557
|
+
type: 'detail-view',
|
|
558
|
+
title: 'Contact Details',
|
|
559
|
+
objectName: 'contact',
|
|
560
|
+
resourceId: 'contact-123',
|
|
561
|
+
fields: [{ name: 'name', label: 'Name' }],
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
|
|
565
|
+
// The fallback should find the record using the stripped ID
|
|
566
|
+
expect(await findByText('Alice')).toBeInTheDocument();
|
|
567
|
+
// findOne should be called twice: first with original ID, then with stripped prefix
|
|
568
|
+
expect(mockDataSource.findOne).toHaveBeenCalledTimes(2);
|
|
569
|
+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(1, 'contact', 'contact-123');
|
|
570
|
+
expect(mockDataSource.findOne).toHaveBeenNthCalledWith(2, 'contact', '123');
|
|
571
|
+
});
|
|
572
|
+
|
|
542
573
|
it('should call findOne with $expand when objectSchema has lookup fields', async () => {
|
|
543
574
|
const mockDataSource = {
|
|
544
575
|
getObjectSchema: vi.fn().mockResolvedValue({
|
|
@@ -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';
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { jsx as l, jsxs as r } from "react/jsx-runtime";
|
|
2
|
-
import { useState as y } from "react";
|
|
3
|
-
import { h as u, X as D, D as j, i as k, B, j as C, k as O, l as F, m as I, I as L } from "./index-qQ1C-yUR.js";
|
|
4
|
-
function R({ value: s, onChange: n, field: x, readonly: g, ...b }) {
|
|
5
|
-
const [N, f] = y(!1), [h, v] = y(""), c = x || b.schema, o = c?.options || [], i = c.multiple || !1, d = c.display_field || "label", p = o.filter(
|
|
6
|
-
(e) => e.label.toLowerCase().includes(h.toLowerCase())
|
|
7
|
-
), t = i ? (Array.isArray(s) ? s : []).map(
|
|
8
|
-
(e) => o.find((a) => a.value === e)
|
|
9
|
-
).filter(Boolean) : s ? [o.find((e) => e.value === s)].filter(Boolean) : [], w = (e) => {
|
|
10
|
-
if (i) {
|
|
11
|
-
const a = Array.isArray(s) ? s : [], m = a.includes(e.value);
|
|
12
|
-
n(m ? a.filter((A) => A !== e.value) : [...a, e.value]);
|
|
13
|
-
} else
|
|
14
|
-
n(e.value), f(!1);
|
|
15
|
-
}, S = (e) => {
|
|
16
|
-
if (i) {
|
|
17
|
-
const a = Array.isArray(s) ? s : [];
|
|
18
|
-
n(a.filter((m) => m !== e));
|
|
19
|
-
} else
|
|
20
|
-
n(null);
|
|
21
|
-
};
|
|
22
|
-
return g ? t.length ? i ? /* @__PURE__ */ l("div", { className: "flex flex-wrap gap-1", children: t.map((e, a) => /* @__PURE__ */ l(u, { variant: "outline", children: e?.[d] || e?.label }, a)) }) : /* @__PURE__ */ l("span", { className: "text-sm", children: t[0]?.[d] || t[0]?.label }) : /* @__PURE__ */ l("span", { className: "text-sm", children: "-" }) : /* @__PURE__ */ r("div", { className: "space-y-2", children: [
|
|
23
|
-
t.length > 0 && /* @__PURE__ */ l("div", { className: "flex flex-wrap gap-1", children: t.map((e, a) => /* @__PURE__ */ r(
|
|
24
|
-
u,
|
|
25
|
-
{
|
|
26
|
-
variant: "outline",
|
|
27
|
-
className: "gap-1",
|
|
28
|
-
children: [
|
|
29
|
-
e?.[d] || e?.label,
|
|
30
|
-
/* @__PURE__ */ l(
|
|
31
|
-
"button",
|
|
32
|
-
{
|
|
33
|
-
onClick: () => S(e?.value),
|
|
34
|
-
className: "ml-1 hover:text-destructive",
|
|
35
|
-
type: "button",
|
|
36
|
-
"aria-label": `Remove ${e?.[d] || e?.label}`,
|
|
37
|
-
children: /* @__PURE__ */ l(D, { className: "size-3" })
|
|
38
|
-
}
|
|
39
|
-
)
|
|
40
|
-
]
|
|
41
|
-
},
|
|
42
|
-
a
|
|
43
|
-
)) }),
|
|
44
|
-
/* @__PURE__ */ r(j, { open: N, onOpenChange: f, children: [
|
|
45
|
-
/* @__PURE__ */ l(k, { asChild: !0, children: /* @__PURE__ */ r(
|
|
46
|
-
B,
|
|
47
|
-
{
|
|
48
|
-
variant: "outline",
|
|
49
|
-
className: "w-full justify-start text-left font-normal",
|
|
50
|
-
type: "button",
|
|
51
|
-
children: [
|
|
52
|
-
/* @__PURE__ */ l(C, { className: "mr-2 size-4" }),
|
|
53
|
-
t.length === 0 ? c?.placeholder || "Select..." : i ? `${t.length} selected` : "Change selection"
|
|
54
|
-
]
|
|
55
|
-
}
|
|
56
|
-
) }),
|
|
57
|
-
/* @__PURE__ */ r(O, { className: "max-w-md", children: [
|
|
58
|
-
/* @__PURE__ */ l(F, { children: /* @__PURE__ */ r(I, { children: [
|
|
59
|
-
c?.label || "Select",
|
|
60
|
-
" ",
|
|
61
|
-
i && "(multiple)"
|
|
62
|
-
] }) }),
|
|
63
|
-
/* @__PURE__ */ r("div", { className: "space-y-4", children: [
|
|
64
|
-
/* @__PURE__ */ l(
|
|
65
|
-
L,
|
|
66
|
-
{
|
|
67
|
-
placeholder: "Search...",
|
|
68
|
-
value: h,
|
|
69
|
-
onChange: (e) => v(e.target.value),
|
|
70
|
-
className: "w-full"
|
|
71
|
-
}
|
|
72
|
-
),
|
|
73
|
-
/* @__PURE__ */ l("div", { className: "max-h-64 overflow-y-auto space-y-1", children: p.length === 0 ? /* @__PURE__ */ l("p", { className: "text-sm text-gray-500 text-center py-4", children: "No options found" }) : p.map((e) => {
|
|
74
|
-
const a = i ? (Array.isArray(s) ? s : []).includes(e.value) : s === e.value;
|
|
75
|
-
return /* @__PURE__ */ r(
|
|
76
|
-
"button",
|
|
77
|
-
{
|
|
78
|
-
onClick: () => w(e),
|
|
79
|
-
className: `w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${a ? "bg-blue-50 text-blue-700" : ""}`,
|
|
80
|
-
type: "button",
|
|
81
|
-
children: [
|
|
82
|
-
/* @__PURE__ */ l("span", { children: e.label }),
|
|
83
|
-
a && /* @__PURE__ */ l(u, { variant: "default", className: "ml-2", children: "Selected" })
|
|
84
|
-
]
|
|
85
|
-
},
|
|
86
|
-
e.value
|
|
87
|
-
);
|
|
88
|
-
}) })
|
|
89
|
-
] })
|
|
90
|
-
] })
|
|
91
|
-
] })
|
|
92
|
-
] });
|
|
93
|
-
}
|
|
94
|
-
export {
|
|
95
|
-
R as LookupField
|
|
96
|
-
};
|