@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/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
- return <span className="truncate">{(value != null && value !== '') ? String(value) : '-'}</span>;
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 formatted = typeof value === 'number'
207
- ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value)
208
- : value;
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 formatted = formatCurrency(Number(value), currency);
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(value);
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(value, style);
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 value === 'string' ? new Date(value) : value;
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(value);
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 date = typeof value === 'string' ? new Date(value) : value;
350
- if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
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(String(value)).then(() => {
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:${value}`}
545
+ href={`mailto:${safe}`}
501
546
  onClick={(e) => e.stopPropagation()}
502
547
  >
503
- {value}
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={value}
581
+ href={safe}
536
582
  target="_blank"
537
583
  rel="noopener noreferrer"
538
584
  onClick={(e) => e.stopPropagation()}
539
585
  >
540
- {value}
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(String(value)).then(() => {
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:${value}`}
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
- {value}
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
- {value != null ? String(value) : '-'}
750
+ {safe != null ? String(safe) : '-'}
703
751
  </span>
704
752
  );
705
753
  }