@object-ui/fields 13.1.0 → 14.0.0

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.
@@ -0,0 +1,343 @@
1
+ import React from 'react';
2
+ import { FilterBuilder, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * FilterConditionField — visual criteria builder for a stored FilterCondition
8
+ * (e.g. `sys_sharing_rule.criteria_json`), scoped to the object chosen in a
9
+ * sibling `object_name` field.
10
+ *
11
+ * Reached via the field `widget: 'filter-condition'` hint (resolves as
12
+ * `field:filter-condition`). Reads the live `object_name` from
13
+ * `dependentValues`, loads that object's fields via
14
+ * `dataSource.getObjectSchema(...)`, and renders `<FilterBuilder>` over them —
15
+ * so an admin builds `type == "customer" AND is_active == true` by picking
16
+ * fields/operators instead of hand-writing JSON.
17
+ *
18
+ * Storage contract: the value round-trips as a **MongoDB-style object filter**
19
+ * (`{ field: value }`, `{ field: { $gt: n } }`, `{ $or: [...] }`), JSON-encoded
20
+ * — the exact shape the sharing evaluator spreads into `engine.find(object,
21
+ * { filter })`. Criteria that can't be represented in the builder (nested
22
+ * mixes, unknown operators) fall back to a raw-JSON editor so nothing is hidden
23
+ * or lost; an "Edit as JSON" toggle is always available.
24
+ */
25
+
26
+ interface FilterFieldDef {
27
+ value: string;
28
+ label: string;
29
+ type?: string;
30
+ options?: Array<{ value: string; label: string }>;
31
+ referenceTo?: string;
32
+ }
33
+
34
+ interface BuilderCondition {
35
+ id: string;
36
+ field: string;
37
+ operator: string;
38
+ value: any;
39
+ }
40
+ interface BuilderGroup {
41
+ id: string;
42
+ logic: 'and' | 'or';
43
+ conditions: BuilderCondition[];
44
+ }
45
+
46
+ const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] };
47
+
48
+ /** Field types that are not meaningfully filterable in a simple builder. */
49
+ const NON_FILTERABLE = new Set([
50
+ 'object', 'vector', 'file', 'image', 'avatar', 'signature',
51
+ 'richtext', 'html', 'markdown', 'location', 'grid', 'json', 'code',
52
+ ]);
53
+
54
+ function deriveFilterFields(schema: any): FilterFieldDef[] {
55
+ const raw = schema?.fields;
56
+ const entries: Array<[string, any]> = Array.isArray(raw)
57
+ ? raw.map((f: any) => [f?.name, f])
58
+ : raw && typeof raw === 'object'
59
+ ? Object.entries(raw)
60
+ : [];
61
+ const out: FilterFieldDef[] = [];
62
+ for (const [name, f] of entries) {
63
+ if (!name || !f || f.hidden) continue;
64
+ const type = f.type as string | undefined;
65
+ if (type && NON_FILTERABLE.has(type)) continue;
66
+ out.push({
67
+ value: name,
68
+ label: f.label || name,
69
+ type,
70
+ options: Array.isArray(f.options)
71
+ ? f.options.map((o: any) =>
72
+ typeof o === 'string'
73
+ ? { value: o, label: o }
74
+ : { value: String(o?.value), label: String(o?.label ?? o?.value) },
75
+ )
76
+ : undefined,
77
+ referenceTo: f.reference_to || f.reference,
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function coerceByType(value: any, type?: string): any {
84
+ if (value == null) return value;
85
+ if (type === 'boolean' || type === 'toggle') {
86
+ if (typeof value === 'boolean') return value;
87
+ if (value === 'true') return true;
88
+ if (value === 'false') return false;
89
+ return value;
90
+ }
91
+ if (type === 'number' || type === 'currency' || type === 'percent' || type === 'rating' || type === 'slider') {
92
+ const n = Number(value);
93
+ return Number.isFinite(n) && value !== '' ? n : value;
94
+ }
95
+ return value;
96
+ }
97
+
98
+ function toArray(value: any): any[] {
99
+ if (Array.isArray(value)) return value;
100
+ if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean);
101
+ return value == null ? [] : [value];
102
+ }
103
+
104
+ function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
105
+ const { field, operator, value } = c || ({} as BuilderCondition);
106
+ if (!field) return null;
107
+ const t = typeOf(field);
108
+ const cv = coerceByType(value, t);
109
+ switch (operator) {
110
+ case 'equals': return { [field]: cv };
111
+ case 'notEquals': return { [field]: { $ne: cv } };
112
+ case 'contains': return { [field]: { $contains: value } };
113
+ case 'notContains': return { [field]: { $ncontains: value } };
114
+ case 'isEmpty': return { [field]: { $in: [null, ''] } };
115
+ case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
116
+ case 'greaterThan':
117
+ case 'after': return { [field]: { $gt: cv } };
118
+ case 'lessThan':
119
+ case 'before': return { [field]: { $lt: cv } };
120
+ case 'greaterOrEqual': return { [field]: { $gte: cv } };
121
+ case 'lessOrEqual': return { [field]: { $lte: cv } };
122
+ case 'between': {
123
+ const [a, b] = Array.isArray(value) ? value : [undefined, undefined];
124
+ return { [field]: { $gte: coerceByType(a, t), $lte: coerceByType(b, t) } };
125
+ }
126
+ case 'in': return { [field]: { $in: toArray(value).map((v) => coerceByType(v, t)) } };
127
+ case 'notIn': return { [field]: { $nin: toArray(value).map((v) => coerceByType(v, t)) } };
128
+ default: return { [field]: cv };
129
+ }
130
+ }
131
+
132
+ function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string | undefined): Record<string, any> | null {
133
+ const frags = (group?.conditions ?? [])
134
+ .map((c) => condToMongo(c, typeOf))
135
+ .filter((x): x is Record<string, any> => !!x);
136
+ if (frags.length === 0) return null; // empty = match all
137
+ if (group.logic === 'or') return { $or: frags };
138
+ if (frags.length === 1) return frags[0];
139
+ const keys = frags.flatMap((f) => Object.keys(f));
140
+ const noCollision = new Set(keys).size === keys.length;
141
+ return noCollision ? Object.assign({}, ...frags) : { $and: frags };
142
+ }
143
+
144
+ function arraysEqual(a: any, b: any[]): boolean {
145
+ return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
146
+ }
147
+
148
+ function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
149
+ const id = `c_${idx}_${field}`;
150
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
151
+ return { id, field, operator: 'equals', value: v };
152
+ }
153
+ const opKeys = Object.keys(v);
154
+ if (opKeys.length === 1) {
155
+ const op = opKeys[0];
156
+ const val = v[op];
157
+ switch (op) {
158
+ case '$ne': return { id, field, operator: 'notEquals', value: val };
159
+ case '$contains': return { id, field, operator: 'contains', value: val };
160
+ case '$ncontains': return { id, field, operator: 'notContains', value: val };
161
+ case '$gt': return { id, field, operator: 'greaterThan', value: val };
162
+ case '$lt': return { id, field, operator: 'lessThan', value: val };
163
+ case '$gte': return { id, field, operator: 'greaterOrEqual', value: val };
164
+ case '$lte': return { id, field, operator: 'lessOrEqual', value: val };
165
+ case '$in':
166
+ return arraysEqual(val, [null, ''])
167
+ ? { id, field, operator: 'isEmpty', value: '' }
168
+ : { id, field, operator: 'in', value: val };
169
+ case '$nin':
170
+ return arraysEqual(val, [null, ''])
171
+ ? { id, field, operator: 'isNotEmpty', value: '' }
172
+ : { id, field, operator: 'notIn', value: val };
173
+ default: return null;
174
+ }
175
+ }
176
+ if (opKeys.length === 2 && '$gte' in v && '$lte' in v) {
177
+ return { id, field, operator: 'between', value: [v.$gte, v.$lte] };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Returns a BuilderGroup, or `null` when the criteria can't be represented. */
183
+ function mongoToFilterGroup(mongo: any): BuilderGroup | null {
184
+ if (mongo == null) return { ...EMPTY_GROUP, conditions: [] };
185
+ if (typeof mongo !== 'object' || Array.isArray(mongo)) return null;
186
+ const entries = Object.entries(mongo);
187
+ if (entries.length === 0) return { ...EMPTY_GROUP, conditions: [] };
188
+ if (entries.length === 1 && (mongo.$or || mongo.$and)) {
189
+ const logic: 'and' | 'or' = mongo.$or ? 'or' : 'and';
190
+ const arr = mongo.$or || mongo.$and;
191
+ if (!Array.isArray(arr)) return null;
192
+ const conditions: BuilderCondition[] = [];
193
+ for (let i = 0; i < arr.length; i++) {
194
+ const frag = arr[i];
195
+ if (!frag || typeof frag !== 'object' || Object.keys(frag).length !== 1) return null;
196
+ const field = Object.keys(frag)[0];
197
+ if (field.startsWith('$')) return null;
198
+ const c = kvToCondition(field, frag[field], i);
199
+ if (!c) return null;
200
+ conditions.push(c);
201
+ }
202
+ return { id: 'root', logic, conditions };
203
+ }
204
+ const conditions: BuilderCondition[] = [];
205
+ let i = 0;
206
+ for (const [field, v] of entries) {
207
+ if (field.startsWith('$')) return null; // mixed logical + field → raw
208
+ const c = kvToCondition(field, v, i++);
209
+ if (!c) return null;
210
+ conditions.push(c);
211
+ }
212
+ return { id: 'root', logic: 'and', conditions };
213
+ }
214
+
215
+ function stringifyValue(value: string | object | undefined | null): string {
216
+ if (value == null || value === '') return '';
217
+ if (typeof value === 'string') return value;
218
+ try {
219
+ return JSON.stringify(value);
220
+ } catch {
221
+ return '';
222
+ }
223
+ }
224
+
225
+ export function FilterConditionField({
226
+ value,
227
+ onChange,
228
+ readonly,
229
+ className,
230
+ ...props
231
+ }: FieldWidgetProps<string | object>) {
232
+ const ctx = React.useContext(SchemaRendererContext);
233
+ const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
234
+ const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
235
+ const objectName = String(dependentValues.object_name ?? '');
236
+
237
+ const [fields, setFields] = React.useState<FilterFieldDef[] | null>(null);
238
+
239
+ React.useEffect(() => {
240
+ setFields(null);
241
+ if (!dataSource || !objectName || typeof dataSource.getObjectSchema !== 'function') return;
242
+ let cancelled = false;
243
+ (async () => {
244
+ try {
245
+ const schema = await dataSource.getObjectSchema(objectName);
246
+ if (!cancelled) setFields(deriveFilterFields(schema));
247
+ } catch {
248
+ if (!cancelled) setFields([]);
249
+ }
250
+ })();
251
+ return () => {
252
+ cancelled = true;
253
+ };
254
+ }, [dataSource, objectName]);
255
+
256
+ const rawValue = React.useMemo(() => stringifyValue(value), [value]);
257
+
258
+ const parsed = React.useMemo(() => {
259
+ if (!rawValue.trim()) return { mongo: {}, ok: true };
260
+ try {
261
+ return { mongo: JSON.parse(rawValue), ok: true };
262
+ } catch {
263
+ return { mongo: null, ok: false };
264
+ }
265
+ }, [rawValue]);
266
+
267
+ const group = React.useMemo(
268
+ () => (parsed.ok ? mongoToFilterGroup(parsed.mongo) : null),
269
+ [parsed],
270
+ );
271
+
272
+ // Raw JSON mode: forced when the stored value can't be represented in the
273
+ // builder; otherwise opt-in via the toggle.
274
+ const representable = parsed.ok && group !== null;
275
+ const [rawMode, setRawMode] = React.useState<boolean>(!representable);
276
+ React.useEffect(() => {
277
+ if (!representable) setRawMode(true);
278
+ }, [representable]);
279
+
280
+ const typeOf = React.useMemo(() => {
281
+ const map = new Map((fields ?? []).map((f) => [f.value, f.type]));
282
+ return (f: string) => map.get(f);
283
+ }, [fields]);
284
+
285
+ const handleBuilderChange = (g: BuilderGroup) => {
286
+ const mongo = filterGroupToMongo(g, typeOf);
287
+ onChange((mongo == null ? '' : JSON.stringify(mongo)) as any);
288
+ };
289
+
290
+ if (!objectName) {
291
+ return (
292
+ <p className={cn('text-sm text-muted-foreground', className)}>
293
+ Select an object first.
294
+ </p>
295
+ );
296
+ }
297
+
298
+ if (readonly) {
299
+ if (!rawValue.trim()) {
300
+ return <span className={cn('text-sm text-muted-foreground', className)}>All records</span>;
301
+ }
302
+ return (
303
+ <pre className={cn('overflow-x-auto rounded bg-muted/40 p-2 text-xs', className)}>
304
+ {rawValue}
305
+ </pre>
306
+ );
307
+ }
308
+
309
+ return (
310
+ <div className={cn('flex flex-col gap-2', className)}>
311
+ {rawMode ? (
312
+ <>
313
+ <textarea
314
+ className="min-h-[96px] w-full rounded border bg-background px-2 py-1 font-mono text-xs"
315
+ value={rawValue}
316
+ placeholder='{ "type": "customer", "is_active": true }'
317
+ onChange={(e) => onChange(e.target.value as any)}
318
+ />
319
+ {!parsed.ok && (
320
+ <span className="text-xs text-destructive">Invalid JSON — the rule will match no records until fixed.</span>
321
+ )}
322
+ </>
323
+ ) : (
324
+ <FilterBuilder
325
+ fields={fields ?? []}
326
+ value={(group ?? EMPTY_GROUP) as any}
327
+ onChange={handleBuilderChange as any}
328
+ />
329
+ )}
330
+ <button
331
+ type="button"
332
+ className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
333
+ onClick={() => setRawMode((m) => !m)}
334
+ disabled={!representable && !rawMode}
335
+ title={!representable ? 'This criteria can only be edited as JSON' : undefined}
336
+ >
337
+ {rawMode ? 'Use visual builder' : 'Edit as JSON'}
338
+ </button>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ export default FilterConditionField;
@@ -0,0 +1,106 @@
1
+ import React from 'react';
2
+ import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * ObjectRefField — object-name picker for form fields that store the machine
8
+ * name of a registered object (e.g. `sys_sharing_rule.object_name`).
9
+ *
10
+ * Reached via the field `widget: 'object-ref'` hint (declared on the object
11
+ * metadata; resolves as `field:object-ref`). Renders a searchable dropdown of
12
+ * the platform's registered objects instead of a free-text input where the
13
+ * admin has to remember a machine name.
14
+ *
15
+ * Object catalog source, in order of preference:
16
+ * 1. `dataSource.getObjects()` — the metadata-registry list endpoint
17
+ * (`GET /api/v1/meta/object`), which merges code- and DB-defined objects.
18
+ * 2. Fallback: `dataSource.find('sys_metadata', { type: 'object' })` — the
19
+ * same source the Setup app's object grid browses.
20
+ * The stored value is always the object's `name` string. If the catalog can't
21
+ * load, the current value is still shown, so the control degrades safely.
22
+ */
23
+ interface ObjectHeader {
24
+ name: string;
25
+ label?: string;
26
+ }
27
+
28
+ export function ObjectRefField({
29
+ value,
30
+ onChange,
31
+ readonly,
32
+ className,
33
+ ...props
34
+ }: FieldWidgetProps<string>) {
35
+ const ctx = React.useContext(SchemaRendererContext);
36
+ const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
37
+ const disabled = (props as any).disabled as boolean | undefined;
38
+
39
+ const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
40
+
41
+ React.useEffect(() => {
42
+ if (!dataSource) return;
43
+ let cancelled = false;
44
+ (async () => {
45
+ try {
46
+ let rows: ObjectHeader[] = [];
47
+ if (typeof dataSource.getObjects === 'function') {
48
+ rows = (await dataSource.getObjects()) ?? [];
49
+ } else if (typeof dataSource.find === 'function') {
50
+ const res = await dataSource.find('sys_metadata', {
51
+ $filter: { type: 'object' },
52
+ $top: 1000,
53
+ });
54
+ const list: any[] =
55
+ res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
56
+ rows = list
57
+ .map((r) => ({
58
+ name: String(r?.name ?? ''),
59
+ label: r?.label != null ? String(r.label) : undefined,
60
+ }))
61
+ .filter((r) => r.name);
62
+ }
63
+ if (!cancelled) setObjects(rows);
64
+ } catch {
65
+ if (!cancelled) setObjects([]);
66
+ }
67
+ })();
68
+ return () => {
69
+ cancelled = true;
70
+ };
71
+ }, [dataSource]);
72
+
73
+ const options = React.useMemo(() => {
74
+ const opts = (objects ?? []).map((o) => ({
75
+ value: o.name,
76
+ label: o.label ? `${o.label} (${o.name})` : o.name,
77
+ }));
78
+ // Keep the current value selectable even if the catalog didn't include it
79
+ // (unknown / not-yet-published object), so editing never drops it.
80
+ if (value && !opts.some((o) => o.value === value)) {
81
+ opts.unshift({ value, label: value });
82
+ }
83
+ return opts.sort((a, b) => a.label.localeCompare(b.label));
84
+ }, [objects, value]);
85
+
86
+ if (readonly) {
87
+ if (!value) return <EmptyValue />;
88
+ const label = options.find((o) => o.value === value)?.label ?? value;
89
+ return <span className={className}>{label}</span>;
90
+ }
91
+
92
+ return (
93
+ <Combobox
94
+ options={options}
95
+ value={value ?? ''}
96
+ onValueChange={(v) => onChange(v as any)}
97
+ placeholder={objects === null ? 'Loading objects…' : 'Select an object'}
98
+ searchPlaceholder="Search objects…"
99
+ emptyText={objects === null ? 'Loading…' : 'No objects found'}
100
+ disabled={disabled}
101
+ className={cn(className)}
102
+ />
103
+ );
104
+ }
105
+
106
+ export default ObjectRefField;
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * RecipientPickerField — dependent record picker for a polymorphic recipient
8
+ * reference (e.g. `sys_sharing_rule.recipient_id`), whose target object is
9
+ * decided by a sibling `recipient_type` select.
10
+ *
11
+ * Reached via the field `widget: 'recipient-picker'` hint (resolves as
12
+ * `field:recipient-picker`). Reads the live `recipient_type` from
13
+ * `dependentValues` (the form record), loads candidate records of the mapped
14
+ * object via `dataSource.find(...)`, and stores the value the sharing evaluator
15
+ * expects for that type:
16
+ *
17
+ * user → sys_user, store `id`
18
+ * team → sys_team, store `id`
19
+ * business_unit → sys_business_unit, store `id`
20
+ * unit_and_subordinates → sys_business_unit, store `id`
21
+ * position → sys_position, store `name` (matched against
22
+ * sys_user_position.position at evaluation time)
23
+ *
24
+ * When `recipient_type` changes after mount the stored id is reset (an id valid
25
+ * for one type is meaningless for another). Unknown types degrade to a plain
26
+ * text input so nothing breaks.
27
+ */
28
+ interface RecipientMapping {
29
+ object: string;
30
+ /** Which record field to persist into recipient_id. */
31
+ storeField: 'id' | 'name';
32
+ /** Candidate display-label fields, in preference order. */
33
+ labelFields: string[];
34
+ }
35
+
36
+ const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
37
+ user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
38
+ team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
39
+ business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
40
+ unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
41
+ position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
42
+ };
43
+
44
+ export function RecipientPickerField({
45
+ value,
46
+ onChange,
47
+ readonly,
48
+ className,
49
+ ...props
50
+ }: FieldWidgetProps<string>) {
51
+ const ctx = React.useContext(SchemaRendererContext);
52
+ const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
53
+ const disabled = (props as any).disabled as boolean | undefined;
54
+ const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
55
+ const recipientType = String(dependentValues.recipient_type ?? '');
56
+ const mapping = TYPE_TO_OBJECT[recipientType];
57
+
58
+ const [records, setRecords] = React.useState<any[] | null>(null);
59
+
60
+ // Reset the stored recipient when the type changes AFTER mount (an id for a
61
+ // user is not a valid team/business-unit id). The ref starts null so the
62
+ // initial render of an existing rule never clears its value.
63
+ const prevType = React.useRef<string | null>(null);
64
+ React.useEffect(() => {
65
+ if (prevType.current !== null && prevType.current !== recipientType && value) {
66
+ onChange('' as any);
67
+ }
68
+ prevType.current = recipientType;
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [recipientType]);
71
+
72
+ React.useEffect(() => {
73
+ setRecords(null);
74
+ if (!dataSource || !mapping || typeof dataSource.find !== 'function') return;
75
+ let cancelled = false;
76
+ (async () => {
77
+ try {
78
+ const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' });
79
+ const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
80
+ if (!cancelled) setRecords(Array.isArray(list) ? list : []);
81
+ } catch {
82
+ if (!cancelled) setRecords([]);
83
+ }
84
+ })();
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [dataSource, mapping?.object]);
89
+
90
+ const labelOf = (r: any): string => {
91
+ for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
92
+ return String(r?.id ?? '');
93
+ };
94
+ const valueOf = (r: any): string => String(r?.[mapping?.storeField ?? 'id'] ?? '');
95
+
96
+ const options = React.useMemo(() => {
97
+ const opts = (records ?? [])
98
+ .map((r) => ({ value: valueOf(r), label: labelOf(r) }))
99
+ .filter((o) => o.value);
100
+ if (value && !opts.some((o) => o.value === value)) opts.unshift({ value, label: value });
101
+ return opts;
102
+ // eslint-disable-next-line react-hooks/exhaustive-deps
103
+ }, [records, value, mapping]);
104
+
105
+ if (!recipientType) {
106
+ return (
107
+ <p className={cn('text-sm text-muted-foreground', className)}>
108
+ Select a recipient type first.
109
+ </p>
110
+ );
111
+ }
112
+
113
+ if (!mapping) {
114
+ // Unknown / unsupported recipient type — keep a plain text input so the
115
+ // field is never un-editable.
116
+ return (
117
+ <input
118
+ className={cn(
119
+ 'w-full rounded border bg-background px-2 py-1 text-sm',
120
+ className,
121
+ )}
122
+ value={value ?? ''}
123
+ disabled={disabled || readonly}
124
+ onChange={(e) => onChange(e.target.value as any)}
125
+ />
126
+ );
127
+ }
128
+
129
+ if (readonly) {
130
+ if (!value) return <EmptyValue />;
131
+ return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
132
+ }
133
+
134
+ return (
135
+ <Combobox
136
+ options={options}
137
+ value={value ?? ''}
138
+ onValueChange={(v) => onChange(v as any)}
139
+ placeholder={
140
+ records === null ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}`
141
+ }
142
+ searchPlaceholder="Search…"
143
+ emptyText={records === null ? 'Loading…' : 'No matches'}
144
+ disabled={disabled}
145
+ className={className}
146
+ />
147
+ );
148
+ }
149
+
150
+ export default RecipientPickerField;