@object-ui/fields 13.2.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,4 @@
1
+ import { default as React } from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+ export declare function FilterConditionField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string | object>): React.JSX.Element;
4
+ export default FilterConditionField;
@@ -0,0 +1,4 @@
1
+ import { default as React } from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+ export declare function ObjectRefField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string>): React.JSX.Element;
4
+ export default ObjectRefField;
@@ -0,0 +1,4 @@
1
+ import { default as React } from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+ export declare function RecipientPickerField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string>): React.JSX.Element;
4
+ export default RecipientPickerField;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "13.2.0",
3
+ "version": "14.0.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,17 +25,17 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "clsx": "^2.1.1",
28
- "lucide-react": "^1.22.0",
28
+ "lucide-react": "^1.24.0",
29
29
  "react-markdown": "^10.1.0",
30
30
  "rehype-sanitize": "^6.0.0",
31
31
  "remark-gfm": "^4.0.1",
32
32
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "13.2.0",
34
- "@object-ui/core": "13.2.0",
35
- "@object-ui/i18n": "13.2.0",
36
- "@object-ui/providers": "13.2.0",
37
- "@object-ui/react": "13.2.0",
38
- "@object-ui/types": "13.2.0"
33
+ "@object-ui/components": "14.0.0",
34
+ "@object-ui/core": "14.0.0",
35
+ "@object-ui/i18n": "14.0.0",
36
+ "@object-ui/providers": "14.0.0",
37
+ "@object-ui/react": "14.0.0",
38
+ "@object-ui/types": "14.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.0.0 || ^19.0.0",
@@ -46,7 +46,7 @@
46
46
  "@types/react-dom": "19.2.3",
47
47
  "@vitejs/plugin-react": "^6.0.3",
48
48
  "typescript": "^6.0.3",
49
- "vite": "^8.1.3",
49
+ "vite": "^8.1.4",
50
50
  "vite-plugin-dts": "^5.0.3"
51
51
  },
52
52
  "publishConfig": {
@@ -106,6 +106,9 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
106
106
  // Containers / non-authorable — a sub-form / sub-grid / embedding vector
107
107
  // doesn't belong in a single cell.
108
108
  'object', 'grid', 'vector',
109
+ // Widget-hint-only pickers — authored in the record form (they depend on
110
+ // sibling fields / a loaded object catalog), not inline in a grid cell.
111
+ 'object-ref', 'filter-condition', 'recipient-picker',
109
112
  ]);
110
113
 
111
114
  /** Field types whose value is chosen in one discrete gesture (no free typing). */
package/src/index.tsx CHANGED
@@ -2022,6 +2022,16 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
2022
2022
  'geolocation': () => import('./widgets/GeolocationField').then(m => ({ default: m.GeolocationField })),
2023
2023
  'signature': () => import('./widgets/SignatureField').then(m => ({ default: m.SignatureField })),
2024
2024
  'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
2025
+
2026
+ // Widget-hint-only pickers (reached via a field `widget:` override, never a
2027
+ // bare field `type`). They render a *picker* over machine data an admin would
2028
+ // otherwise have to type — used by sys_sharing_rule (ADR-0056 P2 pattern):
2029
+ // object-ref → choose a registered object by name
2030
+ // filter-condition → visual criteria builder scoped to the chosen object
2031
+ // recipient-picker → record picker whose target follows a sibling type
2032
+ 'object-ref': () => import('./widgets/ObjectRefField').then(m => ({ default: m.ObjectRefField })),
2033
+ 'filter-condition': () => import('./widgets/FilterConditionField').then(m => ({ default: m.FilterConditionField })),
2034
+ 'recipient-picker': () => import('./widgets/RecipientPickerField').then(m => ({ default: m.RecipientPickerField })),
2025
2035
  };
2026
2036
 
2027
2037
  /**
@@ -2071,6 +2081,11 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
2071
2081
  // same "bare-name fallback overwritten" warning at every boot regardless.
2072
2082
  'time',
2073
2083
  'address',
2084
+ // Widget-hint-only pickers — resolved solely via `field:<widget>`, so the
2085
+ // bare-key fallback is never wanted.
2086
+ 'object-ref',
2087
+ 'filter-condition',
2088
+ 'recipient-picker',
2074
2089
  ]);
2075
2090
 
2076
2091
  export function registerField(fieldType: string): void {
@@ -2220,6 +2235,9 @@ export * from './widgets/TextAreaField';
2220
2235
  export * from './widgets/RichTextField';
2221
2236
  export * from './widgets/LookupField';
2222
2237
  export * from './widgets/CapabilityMultiSelectField';
2238
+ export * from './widgets/ObjectRefField';
2239
+ export * from './widgets/FilterConditionField';
2240
+ export * from './widgets/RecipientPickerField';
2223
2241
  export * from './widgets/RecordPickerDialog';
2224
2242
  export * from './widgets/FileField';
2225
2243
  export * from './widgets/ImageField';
@@ -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;