@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.
- package/CHANGELOG.md +46 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1679 -1205
- 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 +18 -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,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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
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.
|
|
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": "
|
|
34
|
-
"@object-ui/core": "
|
|
35
|
-
"@object-ui/i18n": "
|
|
36
|
-
"@object-ui/providers": "
|
|
37
|
-
"@object-ui/react": "
|
|
38
|
-
"@object-ui/types": "
|
|
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.
|
|
49
|
+
"vite": "^8.1.4",
|
|
50
50
|
"vite-plugin-dts": "^5.0.3"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
package/src/FieldEditWidget.tsx
CHANGED
|
@@ -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;
|