@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.
- package/CHANGELOG.md +22 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +895 -775
- package/dist/packages/fields/src/index.d.ts +42 -2
- package/package.json +7 -7
- package/src/index.tsx +271 -46
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
31
|
-
"@object-ui/core": "4.0.
|
|
32
|
-
"@object-ui/i18n": "4.0.
|
|
33
|
-
"@object-ui/providers": "4.0.
|
|
34
|
-
"@object-ui/react": "4.0.
|
|
35
|
-
"@object-ui/types": "4.0.
|
|
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
|
-
*
|
|
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=
|
|
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
|
-
|
|
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
|
|
837
|
-
|
|
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
|
|
1066
|
+
label = pickRecordDisplayName(item as Record<string, unknown>) || String((item as any).id || (item as any)._id || '[Object]');
|
|
852
1067
|
} else {
|
|
853
|
-
|
|
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=
|
|
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 =
|
|
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
|
|
874
|
-
|
|
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
|
/**
|