@object-ui/fields 4.0.8 → 4.0.10

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.
@@ -1,5 +1,11 @@
1
1
  import { default as React } from 'react';
2
2
  import { FieldMetadata } from '../../types/src';
3
+ /**
4
+ * Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
5
+ * or BSON ObjectId). Used so we don't display random gibberish to users when
6
+ * a lookup wasn't expanded.
7
+ */
8
+ export declare function isLikelyOpaqueId(v: unknown): boolean;
3
9
  /**
4
10
  * Cell renderer props
5
11
  */
@@ -88,8 +94,35 @@ export declare function DateCellRenderer({ value, field }: CellRendererProps): R
88
94
  * DateTime field cell renderer (Airtable-style with date and time visually separated)
89
95
  */
90
96
  export declare function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement;
97
+ export declare function getBadgeColorClasses(color?: string, val?: unknown): string;
98
+ /**
99
+ * Resolve a semantic color name (e.g. "red", "green") for a value, suitable
100
+ * for callers that need a raw color token rather than CSS classes (for
101
+ * example, the Gantt renderer paints bars via inline styles).
102
+ *
103
+ * Resolution order: explicit option color → semantic value mapping →
104
+ * deterministic hash fallback. Returns `undefined` only when no value is
105
+ * supplied so the caller can fall back to its own default.
106
+ */
107
+ export declare function getSemanticColorName(color?: string, val?: unknown): string | undefined;
91
108
  /**
92
- * Select field cell renderer (with badges)
109
+ * Map a semantic color name to its Tailwind -500 hex value. Used by
110
+ * inline-style consumers (Gantt bars). Falls back to the supplied default
111
+ * (or the platform default blue) when the name is unrecognized.
112
+ */
113
+ export declare function getSemanticHex(name?: string, fallback?: string): string;
114
+ /**
115
+ * Select field cell renderer.
116
+ *
117
+ * Two visual styles, controlled by `field.appearance` (renderer-level option,
118
+ * not part of the `@objectstack/spec` field schema):
119
+ * - `'badge'` (default for spec compatibility): soft-pill colored badge.
120
+ * - `'dot'`: a small colored dot followed by the option label. Used by
121
+ * dense list/grid contexts to keep the table visually quiet — repeated
122
+ * filled badges across many rows create heavy visual noise.
123
+ *
124
+ * Metadata always wins: callers can pass `appearance: 'badge'` on the field
125
+ * descriptor to force the legacy badge in any context.
93
126
  */
94
127
  export declare function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement;
95
128
  /**
@@ -113,7 +146,14 @@ export declare function FileCellRenderer({ value, field }: CellRendererProps): R
113
146
  */
114
147
  export declare function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement;
115
148
  /**
116
- * Lookup/Master-Detail field cell renderer
149
+ * Lookup/Master-Detail field cell renderer.
150
+ *
151
+ * Display order:
152
+ * 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name
153
+ * 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label
154
+ * 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
155
+ * is known, resolve via dataSource and show the related record's display name.
156
+ * Falls back to a muted placeholder while pending and on failure.
117
157
  */
118
158
  export declare function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement;
119
159
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "4.0.8",
3
+ "version": "4.0.10",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,12 +27,12 @@
27
27
  "clsx": "^2.1.1",
28
28
  "lucide-react": "^1.14.0",
29
29
  "tailwind-merge": "^3.6.0",
30
- "@object-ui/components": "4.0.8",
31
- "@object-ui/core": "4.0.8",
32
- "@object-ui/i18n": "4.0.8",
33
- "@object-ui/providers": "4.0.8",
34
- "@object-ui/react": "4.0.8",
35
- "@object-ui/types": "4.0.8"
30
+ "@object-ui/components": "4.0.10",
31
+ "@object-ui/core": "4.0.10",
32
+ "@object-ui/i18n": "4.0.10",
33
+ "@object-ui/providers": "4.0.10",
34
+ "@object-ui/react": "4.0.10",
35
+ "@object-ui/types": "4.0.10"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^18.0.0 || ^19.0.0",
package/src/index.tsx CHANGED
@@ -9,9 +9,106 @@
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
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
+ }
15
112
 
16
113
  /**
17
114
  * Safe label resolver for cell-level UI strings. Falls back to the English
@@ -328,7 +425,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
328
425
  return (
329
426
  <div className="flex items-center gap-2">
330
427
  <div
331
- 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"
332
429
  role="progressbar"
333
430
  aria-valuenow={clampedBar}
334
431
  aria-valuemin={0}
@@ -560,6 +657,22 @@ const BADGE_COLOR_MAP: Record<string, string> = {
560
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',
561
658
  };
562
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
+
563
676
  // Color palette used by the deterministic fallback when no schema/semantic
564
677
  // color matches. Excludes 'gray' to ensure visual contrast between values.
565
678
  const BADGE_FALLBACK_PALETTE: readonly string[] = [
@@ -579,7 +692,7 @@ function hashToColor(value: string): string {
579
692
  return BADGE_FALLBACK_PALETTE[idx];
580
693
  }
581
694
 
582
- function getBadgeColorClasses(color?: string, val?: unknown): string {
695
+ export function getBadgeColorClasses(color?: string, val?: unknown): string {
583
696
  if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
584
697
  if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
585
698
  const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
@@ -591,11 +704,66 @@ function getBadgeColorClasses(color?: string, val?: unknown): string {
591
704
  }
592
705
 
593
706
  /**
594
- * 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.
595
762
  */
596
763
  export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
597
764
  const selectField = field as any;
598
765
  const options: SelectOptionMetadata[] = selectField.options || [];
766
+ const appearance: 'badge' | 'dot' = selectField.appearance === 'dot' ? 'dot' : 'badge';
599
767
 
600
768
  if (value == null || value === '') return <EmptyValue />;
601
769
 
@@ -610,42 +778,47 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
610
778
  return options.find(opt => String(opt.value).toLowerCase() === norm);
611
779
  };
612
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
+
613
812
  // Handle multiple values
614
813
  if (Array.isArray(value)) {
615
814
  return (
616
- <div className="flex flex-wrap gap-1">
617
- {value.map((val, idx) => {
618
- const option = findOption(val);
619
- const label = option?.label || humanizeLabel(String(val));
620
- const colorClasses = getBadgeColorClasses(option?.color, val);
621
-
622
- return (
623
- <Badge
624
- key={idx}
625
- variant="outline"
626
- className={colorClasses}
627
- >
628
- {label}
629
- </Badge>
630
- );
631
- })}
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))}
632
817
  </div>
633
818
  );
634
819
  }
635
820
 
636
- // Handle single value
637
- const option = findOption(value);
638
- const label = option?.label || humanizeLabel(String(value));
639
- const colorClasses = getBadgeColorClasses(option?.color, value);
640
-
641
- return (
642
- <Badge
643
- variant="outline"
644
- className={colorClasses}
645
- >
646
- {label}
647
- </Badge>
648
- );
821
+ return renderOne(value);
649
822
  }
650
823
 
651
824
  /**
@@ -825,21 +998,62 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
825
998
  }
826
999
 
827
1000
  /**
828
- * 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.
829
1009
  */
830
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
+
831
1036
  if (value == null || value === '') return <EmptyValue />;
832
1037
 
833
1038
  const options: Array<{ value: unknown; label: string }> =
834
1039
  (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
835
1040
 
836
- // Resolve a primitive ID to a label via options if available
837
- 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 } => {
838
1044
  if (options.length > 0) {
839
1045
  const found = options.find((opt) => String(opt.value) === String(val));
840
- 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 };
1050
+ }
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 };
841
1055
  }
842
- return String(val);
1056
+ return { text: String(val), muted: false };
843
1057
  };
844
1058
 
845
1059
  if (Array.isArray(value)) {
@@ -847,15 +1061,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
847
1061
  <div className="flex flex-wrap gap-1">
848
1062
  {value.map((item, idx) => {
849
1063
  let label: string;
1064
+ let muted = false;
850
1065
  if (item != null && typeof item === 'object') {
851
- 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]');
852
1067
  } else {
853
- label = resolveLabel(item);
1068
+ const r = resolveLabel(item);
1069
+ label = r.text;
1070
+ muted = r.muted;
854
1071
  }
855
1072
  return (
856
1073
  <span
857
1074
  key={idx}
858
- 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
+ )}
859
1081
  >
860
1082
  {label}
861
1083
  </span>
@@ -864,14 +1086,17 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
864
1086
  </div>
865
1087
  );
866
1088
  }
867
-
1089
+
868
1090
  if (typeof value === 'object' && value !== null) {
869
- 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]');
870
1094
  return <span className="truncate">{label}</span>;
871
1095
  }
872
-
873
- // Primitive value (e.g. raw ID): try to resolve from options
874
- 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>;
875
1100
  }
876
1101
 
877
1102
  /**