@object-ui/fields 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/dist/fields/src/index.d.ts +7 -0
- package/dist/fields/src/widgets/LookupField.d.ts +13 -2
- package/dist/index.cjs +4 -4
- package/dist/index.js +1153 -985
- package/package.json +5 -5
- package/src/__tests__/cell-renderers.test.tsx +133 -4
- package/src/complex-widgets.test.tsx +426 -2
- package/src/index.tsx +70 -22
- package/src/widgets/LookupField.tsx +366 -65
package/src/index.tsx
CHANGED
|
@@ -60,6 +60,39 @@ export interface CellRendererProps {
|
|
|
60
60
|
onChange?: (value: any) => void;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Coerce a value to a safe primitive for rendering.
|
|
65
|
+
* Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
|
|
66
|
+
* reference objects, and arrays so that no raw object is ever passed as
|
|
67
|
+
* a React child — preventing React error #310.
|
|
68
|
+
*/
|
|
69
|
+
export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
|
|
70
|
+
if (value == null) return value as null | undefined;
|
|
71
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
72
|
+
if (value instanceof Date) return value.toISOString();
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((v) => {
|
|
75
|
+
if (v != null && typeof v === 'object') {
|
|
76
|
+
const obj = v as Record<string, unknown>;
|
|
77
|
+
return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
|
|
78
|
+
}
|
|
79
|
+
return String(v);
|
|
80
|
+
}).join(', ');
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'object') {
|
|
83
|
+
const obj = value as Record<string, unknown>;
|
|
84
|
+
// MongoDB numeric wrapper: { $numberDecimal: "250000" }
|
|
85
|
+
if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
|
|
86
|
+
// MongoDB ObjectId wrapper: { $oid: "abc123" }
|
|
87
|
+
if ('$oid' in obj) return String(obj.$oid);
|
|
88
|
+
// MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
|
|
89
|
+
if ('$date' in obj) return String(obj.$date);
|
|
90
|
+
// Expanded reference / general object: extract name/label/id
|
|
91
|
+
return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
|
|
92
|
+
}
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
63
96
|
/**
|
|
64
97
|
* Format currency value
|
|
65
98
|
*/
|
|
@@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string {
|
|
|
192
225
|
* Text field cell renderer
|
|
193
226
|
*/
|
|
194
227
|
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
195
|
-
|
|
228
|
+
const safe = coerceToSafeValue(value);
|
|
229
|
+
return <span className="truncate">{(safe != null && safe !== '') ? String(safe) : '-'}</span>;
|
|
196
230
|
}
|
|
197
231
|
|
|
198
232
|
/**
|
|
@@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
201
235
|
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
202
236
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
203
237
|
|
|
238
|
+
const safe = coerceToSafeValue(value);
|
|
204
239
|
const numField = field as any;
|
|
205
240
|
const precision = numField.precision ?? 0;
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
:
|
|
241
|
+
const num = Number(safe);
|
|
242
|
+
const formatted = !isNaN(num)
|
|
243
|
+
? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
|
|
244
|
+
: String(safe);
|
|
209
245
|
|
|
210
246
|
return <span className="tabular-nums">{formatted}</span>;
|
|
211
247
|
}
|
|
@@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
216
252
|
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
217
253
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
218
254
|
|
|
255
|
+
const safe = coerceToSafeValue(value);
|
|
219
256
|
const currencyField = field as any;
|
|
220
257
|
const currency = currencyField.currency || 'USD';
|
|
221
|
-
const
|
|
258
|
+
const num = Number(safe);
|
|
259
|
+
const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe);
|
|
222
260
|
|
|
223
261
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
224
262
|
}
|
|
@@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
|
232
270
|
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
233
271
|
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
234
272
|
|
|
273
|
+
const safe = coerceToSafeValue(value);
|
|
235
274
|
const percentField = field as any;
|
|
236
275
|
const precision = percentField.precision ?? 0;
|
|
237
|
-
const numValue = Number(
|
|
276
|
+
const numValue = Number(safe);
|
|
277
|
+
if (isNaN(numValue)) {
|
|
278
|
+
return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
|
|
279
|
+
}
|
|
238
280
|
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
|
|
239
281
|
// Fields like "progress" or "completion" store values as 0-100, not 0-1
|
|
240
282
|
const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
|
|
@@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
319
361
|
*/
|
|
320
362
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
321
363
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
|
364
|
+
const safe = coerceToSafeValue(value);
|
|
322
365
|
const dateField = field as any;
|
|
323
366
|
const style = dateField.format || 'relative';
|
|
324
|
-
const formatted = formatDate(
|
|
367
|
+
const formatted = formatDate(safe as string | Date, style);
|
|
325
368
|
|
|
326
369
|
// Determine if date is overdue (in the past)
|
|
327
|
-
const date = typeof
|
|
370
|
+
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
328
371
|
const isValidDate = date instanceof Date && !isNaN(date.getTime());
|
|
329
372
|
const startOfToday = new Date();
|
|
330
373
|
startOfToday.setHours(0, 0, 0, 0);
|
|
331
374
|
const isOverdue = isValidDate && date < startOfToday;
|
|
332
|
-
const isoString = isValidDate ? date.toISOString() : String(
|
|
375
|
+
const isoString = isValidDate ? date.toISOString() : String(safe);
|
|
333
376
|
|
|
334
377
|
return (
|
|
335
378
|
<span
|
|
@@ -346,8 +389,9 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
346
389
|
*/
|
|
347
390
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
348
391
|
if (!value) return <span className="text-muted-foreground">-</span>;
|
|
349
|
-
const
|
|
350
|
-
|
|
392
|
+
const safe = coerceToSafeValue(value);
|
|
393
|
+
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
394
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
|
|
351
395
|
|
|
352
396
|
const datePart = date.toLocaleDateString(undefined, {
|
|
353
397
|
month: 'numeric',
|
|
@@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
478
522
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
479
523
|
if (!value) return <span>-</span>;
|
|
480
524
|
|
|
525
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
481
526
|
const [copied, setCopied] = React.useState(false);
|
|
482
527
|
|
|
483
528
|
const handleCopy = (e: React.MouseEvent) => {
|
|
484
529
|
e.stopPropagation();
|
|
485
530
|
e.preventDefault();
|
|
486
|
-
navigator.clipboard.writeText(
|
|
531
|
+
navigator.clipboard.writeText(safe).then(() => {
|
|
487
532
|
setCopied(true);
|
|
488
533
|
setTimeout(() => setCopied(false), 2000);
|
|
489
534
|
}).catch(() => { /* clipboard not available */ });
|
|
@@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
497
542
|
asChild
|
|
498
543
|
>
|
|
499
544
|
<a
|
|
500
|
-
href={`mailto:${
|
|
545
|
+
href={`mailto:${safe}`}
|
|
501
546
|
onClick={(e) => e.stopPropagation()}
|
|
502
547
|
>
|
|
503
|
-
{
|
|
548
|
+
{safe}
|
|
504
549
|
</a>
|
|
505
550
|
</Button>
|
|
506
551
|
<button
|
|
@@ -525,6 +570,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
525
570
|
export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
526
571
|
if (!value) return <span>-</span>;
|
|
527
572
|
|
|
573
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
528
574
|
return (
|
|
529
575
|
<Button
|
|
530
576
|
variant="link"
|
|
@@ -532,12 +578,12 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
532
578
|
asChild
|
|
533
579
|
>
|
|
534
580
|
<a
|
|
535
|
-
href={
|
|
581
|
+
href={safe}
|
|
536
582
|
target="_blank"
|
|
537
583
|
rel="noopener noreferrer"
|
|
538
584
|
onClick={(e) => e.stopPropagation()}
|
|
539
585
|
>
|
|
540
|
-
{
|
|
586
|
+
{safe}
|
|
541
587
|
</a>
|
|
542
588
|
</Button>
|
|
543
589
|
);
|
|
@@ -549,12 +595,13 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
549
595
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
550
596
|
if (!value) return <span>-</span>;
|
|
551
597
|
|
|
598
|
+
const safe = String(coerceToSafeValue(value) ?? '');
|
|
552
599
|
const [copied, setCopied] = React.useState(false);
|
|
553
600
|
|
|
554
601
|
const handleCopy = (e: React.MouseEvent) => {
|
|
555
602
|
e.stopPropagation();
|
|
556
603
|
e.preventDefault();
|
|
557
|
-
navigator.clipboard.writeText(
|
|
604
|
+
navigator.clipboard.writeText(safe).then(() => {
|
|
558
605
|
setCopied(true);
|
|
559
606
|
setTimeout(() => setCopied(false), 2000);
|
|
560
607
|
}).catch(() => { /* clipboard not available */ });
|
|
@@ -563,12 +610,12 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
563
610
|
return (
|
|
564
611
|
<span className="inline-flex items-center gap-1 group/phone">
|
|
565
612
|
<a
|
|
566
|
-
href={`tel:${
|
|
613
|
+
href={`tel:${safe}`}
|
|
567
614
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
|
|
568
615
|
onClick={(e) => e.stopPropagation()}
|
|
569
616
|
>
|
|
570
617
|
<PhoneIcon className="h-3 w-3" />
|
|
571
|
-
{
|
|
618
|
+
{safe}
|
|
572
619
|
</a>
|
|
573
620
|
<button
|
|
574
621
|
type="button"
|
|
@@ -667,7 +714,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
667
714
|
{value.map((item, idx) => {
|
|
668
715
|
let label: string;
|
|
669
716
|
if (item != null && typeof item === 'object') {
|
|
670
|
-
label = item.name || item.label || item._id || String(item);
|
|
717
|
+
label = item.name || item.label || item.id || item._id || String(item);
|
|
671
718
|
} else {
|
|
672
719
|
label = resolveLabel(item);
|
|
673
720
|
}
|
|
@@ -685,7 +732,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
685
732
|
}
|
|
686
733
|
|
|
687
734
|
if (typeof value === 'object' && value !== null) {
|
|
688
|
-
const label = value.name || value.label || value._id || '[Object]';
|
|
735
|
+
const label = value.name || value.label || value.id || value._id || '[Object]';
|
|
689
736
|
return <span className="truncate">{label}</span>;
|
|
690
737
|
}
|
|
691
738
|
|
|
@@ -697,9 +744,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
697
744
|
* Formula field cell renderer (read-only)
|
|
698
745
|
*/
|
|
699
746
|
export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
747
|
+
const safe = coerceToSafeValue(value);
|
|
700
748
|
return (
|
|
701
749
|
<span className="text-gray-700 font-mono text-sm">
|
|
702
|
-
{
|
|
750
|
+
{safe != null ? String(safe) : '-'}
|
|
703
751
|
</span>
|
|
704
752
|
);
|
|
705
753
|
}
|