@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.
- package/CHANGELOG.md +97 -0
- package/dist/index.cjs +4 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1776 -1194
- package/dist/widgets/CapabilityMultiSelectField.d.ts +11 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/FilterConditionField.d.ts +4 -0
- package/dist/widgets/ObjectRefField.d.ts +4 -0
- package/dist/widgets/RecipientPickerField.d.ts +4 -0
- package/package.json +9 -9
- package/src/FieldEditWidget.tsx +3 -0
- package/src/index.tsx +26 -0
- package/src/widgets/CapabilityMultiSelectField.test.tsx +143 -0
- package/src/widgets/CapabilityMultiSelectField.tsx +204 -0
- package/src/widgets/FilterConditionField.tsx +343 -0
- package/src/widgets/ObjectRefField.tsx +106 -0
- package/src/widgets/RecipientPickerField.tsx +150 -0
|
@@ -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;
|