@object-ui/fields 4.0.7 → 4.0.9

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
@@ -9,8 +9,122 @@
9
9
  import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
11
  import { ComponentRegistry } from '@object-ui/core';
12
- import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue } from '@object-ui/components';
12
+ import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
13
13
  import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
14
+ import { useObjectTranslation } from '@object-ui/react';
15
+ import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
16
+
17
+ // Module-level cache so multiple renderers fetching the same lookup ID
18
+ // only trigger one network call. Keyed by `${objectName}:${id}`.
19
+ type LookupCacheEntry =
20
+ | { state: 'pending'; promise: Promise<void> }
21
+ | { state: 'ok'; name: string | undefined }
22
+ | { state: 'err' };
23
+ const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
24
+
25
+ /**
26
+ * Pick the most reasonable display name from an arbitrary record object.
27
+ * Tries common name-like keys in priority order, then falls back to undefined.
28
+ */
29
+ function pickRecordDisplayName(record: Record<string, unknown> | null | undefined): string | undefined {
30
+ if (!record || typeof record !== 'object') return undefined;
31
+ const candidates = ['name', 'full_name', 'display_name', 'label', 'title', 'subject', 'username', 'email'];
32
+ for (const k of candidates) {
33
+ const v = record[k];
34
+ if (typeof v === 'string' && v.trim()) return v.trim();
35
+ if (typeof v === 'number') return String(v);
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
42
+ * or BSON ObjectId). Used so we don't display random gibberish to users when
43
+ * a lookup wasn't expanded.
44
+ */
45
+ export function isLikelyOpaqueId(v: unknown): boolean {
46
+ if (typeof v !== 'string') return false;
47
+ // 12-32 chars, only [A-Za-z0-9_-], no whitespace.
48
+ if (!/^[A-Za-z0-9_-]{12,32}$/.test(v)) return false;
49
+ // Must have BOTH upper- and lower-case letters (real words rarely do at this length).
50
+ // Also accept tokens that contain `_` or `-` separators alongside any case mix.
51
+ const hasUpper = /[A-Z]/.test(v);
52
+ const hasLower = /[a-z]/.test(v);
53
+ const hasDigitOrSep = /[0-9_-]/.test(v);
54
+ return (hasUpper && hasLower) || (hasUpper && hasDigitOrSep) || (hasLower && hasDigitOrSep);
55
+ }
56
+
57
+ /**
58
+ * Fetch-on-demand resolver for foreign-key IDs that weren't expanded by the
59
+ * server. Reads `dataSource` from SchemaRendererContext; safely no-ops if
60
+ * the context isn't installed. Returns the resolved display name or
61
+ * `undefined` while pending or unresolvable.
62
+ */
63
+ function useLookupName(referenceTo: string | undefined, value: unknown): string | undefined {
64
+ const ctx = React.useContext(_SchemaRendererContext);
65
+ const dataSource = ctx?.dataSource;
66
+ const [, force] = React.useState(0);
67
+
68
+ const isResolvable =
69
+ !!referenceTo &&
70
+ !!dataSource &&
71
+ typeof dataSource.find === 'function' &&
72
+ (typeof value === 'string' || typeof value === 'number') &&
73
+ value !== '';
74
+ const cacheKey = isResolvable ? `${referenceTo}:${String(value)}` : '';
75
+
76
+ React.useEffect(() => {
77
+ if (!isResolvable) return;
78
+ const existing = lookupNameCache.get(cacheKey);
79
+ if (existing && existing.state !== 'pending' && (existing as any).promise == null) return;
80
+ if (existing?.state === 'pending') return;
81
+
82
+ const promise: Promise<void> = (async () => {
83
+ try {
84
+ let record: Record<string, unknown> | undefined;
85
+ if (typeof (dataSource as any).findOne === 'function') {
86
+ record = await (dataSource as any).findOne(referenceTo, value);
87
+ } else {
88
+ const result = await (dataSource as any).find(referenceTo, {
89
+ $filter: { id: value },
90
+ options: { $top: 1 },
91
+ });
92
+ const records: any[] = Array.isArray(result)
93
+ ? result
94
+ : (result?.value || result?.data || []);
95
+ record = records[0];
96
+ }
97
+ const name = pickRecordDisplayName(record);
98
+ lookupNameCache.set(cacheKey, { state: 'ok', name });
99
+ } catch {
100
+ lookupNameCache.set(cacheKey, { state: 'err' });
101
+ }
102
+ force((n) => n + 1);
103
+ })();
104
+
105
+ lookupNameCache.set(cacheKey, { state: 'pending', promise });
106
+ }, [cacheKey, isResolvable, referenceTo, value, dataSource]);
107
+
108
+ if (!isResolvable) return undefined;
109
+ const entry = lookupNameCache.get(cacheKey);
110
+ return entry?.state === 'ok' ? entry.name : undefined;
111
+ }
112
+
113
+ /**
114
+ * Safe label resolver for cell-level UI strings. Falls back to the English
115
+ * default when no I18nProvider is available or when the key is missing.
116
+ */
117
+ function useFieldLabel() {
118
+ try {
119
+ const { t } = useObjectTranslation();
120
+ return (key: string, fallback: string) => {
121
+ const v = t(key);
122
+ return !v || v === key ? fallback : v;
123
+ };
124
+ } catch {
125
+ return (_k: string, fallback: string) => fallback;
126
+ }
127
+ }
14
128
 
15
129
  import { TextField } from './widgets/TextField';
16
130
  import { NumberField } from './widgets/NumberField';
@@ -127,6 +241,22 @@ export function formatCompactCurrency(value: number, currency: string = 'USD'):
127
241
  }
128
242
  }
129
243
 
244
+ /**
245
+ * Format a plain number with thousands separators, no currency symbol.
246
+ * Used as a safe fallback when a currency-typed field has no `currency`
247
+ * configured — we'd rather render `1,234.50` than silently assume USD.
248
+ */
249
+ export function formatNumber(value: number, decimals: number = 2): string {
250
+ try {
251
+ return new Intl.NumberFormat('en-US', {
252
+ minimumFractionDigits: decimals,
253
+ maximumFractionDigits: decimals,
254
+ }).format(value);
255
+ } catch {
256
+ return value.toFixed(decimals);
257
+ }
258
+ }
259
+
130
260
  /**
131
261
  * Format percent value
132
262
  * Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
@@ -153,10 +283,10 @@ export function humanizeLabel(value: string): string {
153
283
  /**
154
284
  * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
155
285
  */
156
- export function formatRelativeDate(value: string | Date): string {
157
- if (!value) return '—';
158
- const date = typeof value === 'string' ? new Date(value) : value;
159
- if (isNaN(date.getTime())) return '—';
286
+ export function formatRelativeDate(value: string | Date | number): string {
287
+ if (value === null || value === undefined || value === '') return '—';
288
+ const date = value instanceof Date ? value : new Date(value as any);
289
+ if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
160
290
 
161
291
  const now = new Date();
162
292
  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -179,10 +309,10 @@ export function formatRelativeDate(value: string | Date): string {
179
309
  /**
180
310
  * Format date value
181
311
  */
182
- export function formatDate(value: string | Date, style?: string): string {
183
- if (!value) return '—';
184
- const date = typeof value === 'string' ? new Date(value) : value;
185
- if (isNaN(date.getTime())) return '—';
312
+ export function formatDate(value: string | Date | number, style?: string): string {
313
+ if (value === null || value === undefined || value === '') return '—';
314
+ const date = value instanceof Date ? value : new Date(value as any);
315
+ if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
186
316
 
187
317
  if (style === 'short') {
188
318
  // Compact format for mobile: "Jan 15, '24"
@@ -207,10 +337,10 @@ export function formatDate(value: string | Date, style?: string): string {
207
337
  /**
208
338
  * Format datetime value
209
339
  */
210
- export function formatDateTime(value: string | Date): string {
211
- if (!value) return '—';
212
- const date = typeof value === 'string' ? new Date(value) : value;
213
- if (isNaN(date.getTime())) return '—';
340
+ export function formatDateTime(value: string | Date | number): string {
341
+ if (value === null || value === undefined || value === '') return '—';
342
+ const date = value instanceof Date ? value : new Date(value as any);
343
+ if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
214
344
 
215
345
  return date.toLocaleDateString(undefined, {
216
346
  year: 'numeric',
@@ -255,9 +385,14 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
255
385
 
256
386
  const safe = coerceToSafeValue(value);
257
387
  const currencyField = field as any;
258
- const currency = currencyField.currency || 'USD';
388
+ // Honor both `currency` (legacy/grid configs) and `defaultCurrency`
389
+ // (the canonical key from `@objectstack/spec` Field.currency()).
390
+ const currency: string | undefined = currencyField.currency || currencyField.defaultCurrency;
259
391
  const num = Number(safe);
260
- const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe);
392
+ const decimals = currencyField.precision ?? currencyField.scale ?? currencyField.decimals ?? 2;
393
+ const formatted = !isNaN(num)
394
+ ? (currency ? formatCurrency(num, currency) : formatNumber(num, decimals))
395
+ : String(safe);
261
396
 
262
397
  return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
263
398
  }
@@ -290,7 +425,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
290
425
  return (
291
426
  <div className="flex items-center gap-2">
292
427
  <div
293
- className="h-1.5 w-16 rounded-full bg-muted overflow-hidden shrink-0"
428
+ className="h-1.5 w-16 rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden shrink-0"
294
429
  role="progressbar"
295
430
  aria-valuenow={clampedBar}
296
431
  aria-valuemin={0}
@@ -351,7 +486,7 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
351
486
  }
352
487
 
353
488
  return (
354
- <div className="flex items-center justify-center">
489
+ <div className="flex items-center justify-start">
355
490
  <Checkbox checked={!!value} disabled className="pointer-events-none" />
356
491
  </div>
357
492
  );
@@ -370,16 +505,16 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
370
505
  // Determine if date is overdue (in the past) — but only color it red when the
371
506
  // field is *semantically* a due/deadline. A plain "start_date" or "created_at"
372
507
  // in the past should not render in red.
373
- const date = typeof safe === 'string' ? new Date(safe) : safe;
374
- const isValidDate = date instanceof Date && !isNaN(date.getTime());
508
+ const date = safe != null ? new Date(safe as string | number) : null;
509
+ const isValidDate = date !== null && !isNaN(date.getTime());
375
510
  const startOfToday = new Date();
376
511
  startOfToday.setHours(0, 0, 0, 0);
377
512
  const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
378
513
  const dueLike =
379
514
  dateField?.dueLike === true ||
380
515
  /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
381
- const isOverdue = dueLike && isValidDate && date < startOfToday;
382
- const isoString = isValidDate ? date.toISOString() : String(safe);
516
+ const isOverdue = dueLike && isValidDate && date! < startOfToday;
517
+ const isoString = isValidDate ? date!.toISOString() : String(safe);
383
518
 
384
519
  return (
385
520
  <span
@@ -397,8 +532,8 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
397
532
  export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
398
533
  if (!value) return <EmptyValue />;
399
534
  const safe = coerceToSafeValue(value);
400
- const date = typeof safe === 'string' ? new Date(safe) : safe;
401
- if (!(date instanceof Date) || isNaN(date.getTime())) return <EmptyValue />;
535
+ const date = safe != null ? new Date(safe as string | number) : null;
536
+ if (date === null || isNaN(date.getTime())) return <EmptyValue />;
402
537
 
403
538
  const datePart = date.toLocaleDateString(undefined, {
404
539
  month: 'numeric',
@@ -421,8 +556,9 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE
421
556
 
422
557
  // Semantic color mapping (auto-detect from value text for priority & status fields)
423
558
  // Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
559
+ // Chinese keys are stored as-is and matched directly (no normalization side-effects).
424
560
  const SEMANTIC_COLOR_MAP: Record<string, string> = {
425
- // Priority values
561
+ // Priority values (en)
426
562
  critical: 'red',
427
563
  urgent: 'red',
428
564
  high: 'orange',
@@ -430,7 +566,7 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
430
566
  normal: 'blue',
431
567
  low: 'gray',
432
568
  none: 'gray',
433
- // Status values
569
+ // Status values (en)
434
570
  paid: 'green',
435
571
  completed: 'green',
436
572
  done: 'green',
@@ -455,6 +591,52 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
455
591
  overdue: 'red',
456
592
  delivered: 'purple',
457
593
  archived: 'indigo',
594
+ // CRM lifecycle values (en)
595
+ contacted: 'blue',
596
+ qualified: 'purple',
597
+ converted: 'green',
598
+ won: 'green',
599
+ lost: 'red',
600
+ // Priority values (zh)
601
+ 紧急: 'red',
602
+ 严重: 'red',
603
+ 高: 'orange',
604
+ 中: 'yellow',
605
+ 普通: 'blue',
606
+ 低: 'gray',
607
+ 无: 'gray',
608
+ // Status values (zh)
609
+ 新建: 'gray',
610
+ 草稿: 'gray',
611
+ 待处理: 'yellow',
612
+ 待审核: 'yellow',
613
+ 待联系: 'yellow',
614
+ 挂起: 'yellow',
615
+ 进行中: 'blue',
616
+ 处理中: 'blue',
617
+ 已联系: 'blue',
618
+ 跟进中: 'blue',
619
+ 已发货: 'blue',
620
+ 打开: 'blue',
621
+ 已确认: 'green',
622
+ 已审核: 'green',
623
+ 已通过: 'green',
624
+ 已完成: 'green',
625
+ 已支付: 'green',
626
+ 已签收: 'green',
627
+ 已转化: 'green',
628
+ 成单: 'green',
629
+ 赢得: 'green',
630
+ 已签约: 'green',
631
+ 已交付: 'purple',
632
+ 已归档: 'indigo',
633
+ 已关闭: 'gray',
634
+ 已取消: 'red',
635
+ 已拒绝: 'red',
636
+ 失败: 'red',
637
+ 逾期: 'red',
638
+ 流失: 'red',
639
+ 丢失: 'red',
458
640
  };
459
641
 
460
642
  // Color to Tailwind class mapping for custom Badge styling
@@ -475,6 +657,22 @@ const BADGE_COLOR_MAP: Record<string, string> = {
475
657
  pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
476
658
  };
477
659
 
660
+ // Solid color → Tailwind background class for the small dot used by the
661
+ // `appearance: 'dot'` rendering of select/status fields. Uses the -500 shade
662
+ // for both light and dark modes so the dot remains a clear visual anchor
663
+ // without becoming a heavy color block.
664
+ const DOT_COLOR_MAP: Record<string, string> = {
665
+ gray: 'bg-gray-400 dark:bg-gray-500',
666
+ red: 'bg-red-500',
667
+ orange: 'bg-orange-500',
668
+ yellow: 'bg-yellow-500',
669
+ green: 'bg-green-500',
670
+ blue: 'bg-blue-500',
671
+ indigo: 'bg-indigo-500',
672
+ purple: 'bg-purple-500',
673
+ pink: 'bg-pink-500',
674
+ };
675
+
478
676
  // Color palette used by the deterministic fallback when no schema/semantic
479
677
  // color matches. Excludes 'gray' to ensure visual contrast between values.
480
678
  const BADGE_FALLBACK_PALETTE: readonly string[] = [
@@ -494,7 +692,7 @@ function hashToColor(value: string): string {
494
692
  return BADGE_FALLBACK_PALETTE[idx];
495
693
  }
496
694
 
497
- function getBadgeColorClasses(color?: string, val?: unknown): string {
695
+ export function getBadgeColorClasses(color?: string, val?: unknown): string {
498
696
  if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
499
697
  if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
500
698
  const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
@@ -506,50 +704,121 @@ function getBadgeColorClasses(color?: string, val?: unknown): string {
506
704
  }
507
705
 
508
706
  /**
509
- * Select field cell renderer (with badges)
707
+ * Resolve a semantic color name (e.g. "red", "green") for a value, suitable
708
+ * for callers that need a raw color token rather than CSS classes (for
709
+ * example, the Gantt renderer paints bars via inline styles).
710
+ *
711
+ * Resolution order: explicit option color → semantic value mapping →
712
+ * deterministic hash fallback. Returns `undefined` only when no value is
713
+ * supplied so the caller can fall back to its own default.
714
+ */
715
+ export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
716
+ if (color && BADGE_COLOR_MAP[color]) return color;
717
+ if (val == null || val === '') return undefined;
718
+ const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
719
+ const semantic = SEMANTIC_COLOR_MAP[key];
720
+ if (semantic) return semantic;
721
+ return hashToColor(key);
722
+ }
723
+
724
+ // Resolved hex values for the -500 shade of each palette color. Mirrors
725
+ // `DOT_COLOR_MAP` and is consumed by callers that paint via inline styles
726
+ // (e.g. Gantt task bars, where Tailwind classes can't be applied to dynamic
727
+ // `style={}` values).
728
+ const COLOR_NAME_HEX: Record<string, string> = {
729
+ gray: '#6b7280',
730
+ red: '#ef4444',
731
+ orange: '#f97316',
732
+ yellow: '#eab308',
733
+ green: '#22c55e',
734
+ blue: '#3b82f6',
735
+ indigo: '#6366f1',
736
+ purple: '#a855f7',
737
+ pink: '#ec4899',
738
+ };
739
+
740
+ /**
741
+ * Map a semantic color name to its Tailwind -500 hex value. Used by
742
+ * inline-style consumers (Gantt bars). Falls back to the supplied default
743
+ * (or the platform default blue) when the name is unrecognized.
744
+ */
745
+ export function getSemanticHex(name?: string, fallback: string = '#3b82f6'): string {
746
+ if (!name) return fallback;
747
+ return COLOR_NAME_HEX[name] ?? fallback;
748
+ }
749
+
750
+ /**
751
+ * Select field cell renderer.
752
+ *
753
+ * Two visual styles, controlled by `field.appearance` (renderer-level option,
754
+ * not part of the `@objectstack/spec` field schema):
755
+ * - `'badge'` (default for spec compatibility): soft-pill colored badge.
756
+ * - `'dot'`: a small colored dot followed by the option label. Used by
757
+ * dense list/grid contexts to keep the table visually quiet — repeated
758
+ * filled badges across many rows create heavy visual noise.
759
+ *
760
+ * Metadata always wins: callers can pass `appearance: 'badge'` on the field
761
+ * descriptor to force the legacy badge in any context.
510
762
  */
511
763
  export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
512
764
  const selectField = field as any;
513
765
  const options: SelectOptionMetadata[] = selectField.options || [];
514
-
766
+ const appearance: 'badge' | 'dot' = selectField.appearance === 'dot' ? 'dot' : 'badge';
767
+
515
768
  if (value == null || value === '') return <EmptyValue />;
516
-
769
+
770
+ // Match a stored value to a configured option, falling back to a
771
+ // case-insensitive comparison so seed data with mixed case
772
+ // (e.g. "Referral" stored, "referral" defined) still resolves to the
773
+ // localized option label.
774
+ const findOption = (val: any): SelectOptionMetadata | undefined => {
775
+ const exact = options.find(opt => opt.value === val);
776
+ if (exact) return exact;
777
+ const norm = String(val).toLowerCase();
778
+ return options.find(opt => String(opt.value).toLowerCase() === norm);
779
+ };
780
+
781
+ const renderOne = (val: any, key?: number): React.ReactElement => {
782
+ const option = findOption(val);
783
+ const label = option?.label || humanizeLabel(String(val));
784
+
785
+ if (appearance === 'dot') {
786
+ // Resolve a real CSS color for the dot. Prefer explicit option color,
787
+ // then semantic mapping for the value, then deterministic palette.
788
+ const colorName = option?.color
789
+ || SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
790
+ || hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
791
+ const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
792
+ return (
793
+ <span key={key} className="inline-flex items-center gap-1.5 text-sm">
794
+ <span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
795
+ <span className="truncate">{label}</span>
796
+ </span>
797
+ );
798
+ }
799
+
800
+ const colorClasses = getBadgeColorClasses(option?.color, val);
801
+ return (
802
+ <Badge
803
+ key={key}
804
+ variant="outline"
805
+ className={colorClasses}
806
+ >
807
+ {label}
808
+ </Badge>
809
+ );
810
+ };
811
+
517
812
  // Handle multiple values
518
813
  if (Array.isArray(value)) {
519
814
  return (
520
- <div className="flex flex-wrap gap-1">
521
- {value.map((val, idx) => {
522
- const option = options.find(opt => opt.value === val);
523
- const label = option?.label || humanizeLabel(String(val));
524
- const colorClasses = getBadgeColorClasses(option?.color, val);
525
-
526
- return (
527
- <Badge
528
- key={idx}
529
- variant="outline"
530
- className={colorClasses}
531
- >
532
- {label}
533
- </Badge>
534
- );
535
- })}
815
+ <div className={cn('flex flex-wrap', appearance === 'dot' ? 'gap-x-3 gap-y-1' : 'gap-1')}>
816
+ {value.map((val, idx) => renderOne(val, idx))}
536
817
  </div>
537
818
  );
538
819
  }
539
-
540
- // Handle single value
541
- const option = options.find(opt => opt.value === value);
542
- const label = option?.label || humanizeLabel(String(value));
543
- const colorClasses = getBadgeColorClasses(option?.color, value);
544
-
545
- return (
546
- <Badge
547
- variant="outline"
548
- className={colorClasses}
549
- >
550
- {label}
551
- </Badge>
552
- );
820
+
821
+ return renderOne(value);
553
822
  }
554
823
 
555
824
  /**
@@ -557,7 +826,8 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
557
826
  */
558
827
  export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
559
828
  if (!value) return <EmptyValue />;
560
-
829
+
830
+ const label = useFieldLabel();
561
831
  const safe = String(coerceToSafeValue(value) ?? '');
562
832
  const [copied, setCopied] = React.useState(false);
563
833
 
@@ -588,7 +858,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
588
858
  type="button"
589
859
  className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
590
860
  onClick={handleCopy}
591
- aria-label="Copy email"
861
+ aria-label={label('detail.copyEmail', 'Copy email')}
592
862
  >
593
863
  {copied ? (
594
864
  <Check className="h-3 w-3 text-green-600" />
@@ -630,7 +900,8 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
630
900
  */
631
901
  export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
632
902
  if (!value) return <EmptyValue />;
633
-
903
+
904
+ const label = useFieldLabel();
634
905
  const safe = String(coerceToSafeValue(value) ?? '');
635
906
  const [copied, setCopied] = React.useState(false);
636
907
 
@@ -657,7 +928,7 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
657
928
  type="button"
658
929
  className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
659
930
  onClick={handleCopy}
660
- aria-label="Copy phone number"
931
+ aria-label={label('detail.copyPhone', 'Copy phone number')}
661
932
  >
662
933
  {copied ? (
663
934
  <Check className="h-3 w-3 text-green-600" />
@@ -727,21 +998,62 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
727
998
  }
728
999
 
729
1000
  /**
730
- * Lookup/Master-Detail field cell renderer
1001
+ * Lookup/Master-Detail field cell renderer.
1002
+ *
1003
+ * Display order:
1004
+ * 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name
1005
+ * 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label
1006
+ * 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
1007
+ * is known, resolve via dataSource and show the related record's display name.
1008
+ * Falls back to a muted placeholder while pending and on failure.
731
1009
  */
732
1010
  export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
1011
+ const referenceTo = (field as { reference_to?: string }).reference_to;
1012
+
1013
+ // Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
1014
+ // to keep the cell cheap; multi-value lookups should generally be expanded server-side).
1015
+ const primaryPrimitiveId = (() => {
1016
+ if (Array.isArray(value)) {
1017
+ const firstPrimitive = value.find(
1018
+ (v) => v != null && (typeof v === 'string' || typeof v === 'number') && v !== '',
1019
+ );
1020
+ return firstPrimitive;
1021
+ }
1022
+ if (
1023
+ value != null &&
1024
+ value !== '' &&
1025
+ (typeof value === 'string' || typeof value === 'number') &&
1026
+ typeof value !== 'object'
1027
+ ) {
1028
+ return value;
1029
+ }
1030
+ return undefined;
1031
+ })();
1032
+
1033
+ // Always call the hook (rules of hooks). It safely no-ops when inputs are missing.
1034
+ const resolvedName = useLookupName(referenceTo, primaryPrimitiveId);
1035
+
733
1036
  if (value == null || value === '') return <EmptyValue />;
734
1037
 
735
1038
  const options: Array<{ value: unknown; label: string }> =
736
1039
  (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
737
1040
 
738
- // Resolve a primitive ID to a label via options if available
739
- const resolveLabel = (val: unknown): string => {
1041
+ // Resolve a primitive ID to a label. Order:
1042
+ // options → server-resolved name (via useLookupName) → muted placeholder for opaque IDs → raw value
1043
+ const resolveLabel = (val: unknown): { text: string; muted: boolean } => {
740
1044
  if (options.length > 0) {
741
1045
  const found = options.find((opt) => String(opt.value) === String(val));
742
- if (found) return found.label;
1046
+ if (found) return { text: found.label, muted: false };
1047
+ }
1048
+ if (val === primaryPrimitiveId && resolvedName) {
1049
+ return { text: resolvedName, muted: false };
743
1050
  }
744
- return String(val);
1051
+ if (isLikelyOpaqueId(val)) {
1052
+ // Don't dump a random-looking ID at the user. Show a soft placeholder
1053
+ // that conveys "this is a reference, name unavailable".
1054
+ return { text: '—', muted: true };
1055
+ }
1056
+ return { text: String(val), muted: false };
745
1057
  };
746
1058
 
747
1059
  if (Array.isArray(value)) {
@@ -749,15 +1061,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
749
1061
  <div className="flex flex-wrap gap-1">
750
1062
  {value.map((item, idx) => {
751
1063
  let label: string;
1064
+ let muted = false;
752
1065
  if (item != null && typeof item === 'object') {
753
- label = item.name || item.label || item.id || item._id || String(item);
1066
+ label = pickRecordDisplayName(item as Record<string, unknown>) || String((item as any).id || (item as any)._id || '[Object]');
754
1067
  } else {
755
- label = resolveLabel(item);
1068
+ const r = resolveLabel(item);
1069
+ label = r.text;
1070
+ muted = r.muted;
756
1071
  }
757
1072
  return (
758
1073
  <span
759
1074
  key={idx}
760
- className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200"
1075
+ className={cn(
1076
+ 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
1077
+ muted
1078
+ ? 'bg-muted/40 text-muted-foreground'
1079
+ : 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
1080
+ )}
761
1081
  >
762
1082
  {label}
763
1083
  </span>
@@ -766,14 +1086,17 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
766
1086
  </div>
767
1087
  );
768
1088
  }
769
-
1089
+
770
1090
  if (typeof value === 'object' && value !== null) {
771
- const label = value.name || value.label || value.id || value._id || '[Object]';
1091
+ const label =
1092
+ pickRecordDisplayName(value as Record<string, unknown>) ||
1093
+ String((value as any).id || (value as any)._id || '[Object]');
772
1094
  return <span className="truncate">{label}</span>;
773
1095
  }
774
-
775
- // Primitive value (e.g. raw ID): try to resolve from options
776
- return <span className="truncate">{resolveLabel(value)}</span>;
1096
+
1097
+ // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
1098
+ const { text, muted } = resolveLabel(value);
1099
+ return <span className={cn('truncate', muted && 'text-muted-foreground')}>{text}</span>;
777
1100
  }
778
1101
 
779
1102
  /**
@@ -868,6 +1191,51 @@ export function registerFieldRenderer(type: string, renderer: React.FC<CellRende
868
1191
  fieldRegistry.set(type, renderer);
869
1192
  }
870
1193
 
1194
+ /**
1195
+ * Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should
1196
+ * map to a richer cell renderer than the bare type would imply. The
1197
+ * canonical ObjectStack pattern uses `Field.text({ format: 'phone' })`
1198
+ * for plain text columns that should still render with a tel: link,
1199
+ * `Field.text({ format: 'url' })` for clickable links, etc.
1200
+ *
1201
+ * Only applies when the field's base type is a generic text type;
1202
+ * explicit types like 'phone'/'email'/'currency' already win without
1203
+ * any format hint.
1204
+ */
1205
+ const FORMAT_TO_RENDERER: Record<string, string> = {
1206
+ phone: 'phone',
1207
+ tel: 'phone',
1208
+ telephone: 'phone',
1209
+ email: 'email',
1210
+ url: 'url',
1211
+ uri: 'url',
1212
+ link: 'url',
1213
+ currency: 'currency',
1214
+ money: 'currency',
1215
+ percent: 'percent',
1216
+ percentage: 'percent',
1217
+ };
1218
+
1219
+ const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '']);
1220
+
1221
+ /**
1222
+ * Resolve the canonical cell-renderer key for a field. Accepts either a
1223
+ * raw type string (back-compat) or a field-metadata object so that
1224
+ * format hints (`format: 'phone'` etc.) can promote a plain `text`
1225
+ * field to its richer renderer counterpart.
1226
+ */
1227
+ export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string {
1228
+ if (!fieldOrType) return 'text';
1229
+ if (typeof fieldOrType === 'string') return fieldOrType;
1230
+ const baseType = (fieldOrType.type || '').toLowerCase();
1231
+ const formatRaw = fieldOrType.format;
1232
+ const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : '';
1233
+ if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) {
1234
+ return FORMAT_TO_RENDERER[format];
1235
+ }
1236
+ return fieldOrType.type || 'text';
1237
+ }
1238
+
871
1239
  /**
872
1240
  * Get the appropriate cell renderer for a field type
873
1241
  */