From 96a7f68e684e79fc05f8ef071ffa9e513b84d6cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 07:02:41 +0000 Subject: [PATCH] feat(fields): pickers for the sharing rule form (object / criteria / recipient) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three widget-hint field components make the generic object form render pickers where an admin previously had to type machine data (driven by the framework `widget` hints on sys_sharing_rule; generalizes the capability-multiselect pattern). All degrade to the underlying `type` renderer when unregistered. - object-ref: choose a registered object by name (searchable Combobox), backed by the new DataSource.getObjects() (ObjectStackAdapter lists code- and DB-defined objects via /api/v1/meta/object), falling back to sys_metadata. - filter-condition: visual FilterBuilder scoped to the fields of the object chosen in a sibling field (getObjectSchema), round-tripping the stored MongoDB-style FilterCondition JSON. Unrepresentable / invalid criteria fall back to a raw-JSON editor (always-available toggle) — nothing is lost. - recipient-picker: record picker whose target object follows a sibling recipient_type (user/team/business_unit/position), storing the value the evaluator matches on (record id, or the position name); resets on type change. Wiring: the three keys join DATA_SOURCE_FIELD_TYPES (form.tsx) so the form threads dataSource + dependentValues, and INLINE_EXCLUDED_FIELD_TYPES. DataSource.getObjects() is optional on the interface; the adapter implements it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013wGu7aa1YXhHseojW9CBRf --- .changeset/sharing-rule-form-pickers.md | 32 ++ .../components/src/renderers/form/form.tsx | 7 +- packages/data-objectstack/src/index.ts | 43 +++ packages/fields/src/FieldEditWidget.tsx | 3 + packages/fields/src/index.tsx | 18 + .../src/widgets/FilterConditionField.tsx | 343 ++++++++++++++++++ .../fields/src/widgets/ObjectRefField.tsx | 106 ++++++ .../src/widgets/RecipientPickerField.tsx | 150 ++++++++ packages/types/src/data.ts | 10 + 9 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 .changeset/sharing-rule-form-pickers.md create mode 100644 packages/fields/src/widgets/FilterConditionField.tsx create mode 100644 packages/fields/src/widgets/ObjectRefField.tsx create mode 100644 packages/fields/src/widgets/RecipientPickerField.tsx diff --git a/.changeset/sharing-rule-form-pickers.md b/.changeset/sharing-rule-form-pickers.md new file mode 100644 index 0000000000..49bd846ed4 --- /dev/null +++ b/.changeset/sharing-rule-form-pickers.md @@ -0,0 +1,32 @@ +--- +"@object-ui/fields": minor +"@object-ui/components": minor +"@object-ui/data-objectstack": minor +"@object-ui/types": minor +--- + +Sharing-rule form: pick, don't type. Three new widget-hint field components make +the generic object form render pickers where an admin previously had to type +machine data (driven by the framework `widget` hints on `sys_sharing_rule`; +generalizes the `capability-multiselect` pattern). All degrade to the underlying +`type` renderer when a widget is unregistered. + +- **`object-ref`** — choose a registered object by name (searchable `Combobox`), + backed by the new `DataSource.getObjects()` (`ObjectStackAdapter` lists code- + and DB-defined objects via `/api/v1/meta/object`), falling back to a + `sys_metadata` query. Stores the object's `name`. +- **`filter-condition`** — a visual criteria builder (`FilterBuilder`) scoped to + the fields of the object chosen in a sibling field (via `getObjectSchema`), + round-tripping the stored **MongoDB-style** FilterCondition JSON. Criteria the + builder can't represent (or invalid JSON) fall back to a raw-JSON editor, with + an always-available "Edit as JSON" toggle — nothing is hidden or lost. +- **`recipient-picker`** — a record picker whose target object follows a sibling + `recipient_type` (`user`→sys_user, `team`→sys_team, `business_unit`/ + `unit_and_subordinates`→sys_business_unit, `position`→sys_position), storing the + value the evaluator matches on (a record id, or the position **name**). Resets + the stored id when the type changes. + +Wiring: the three keys join `DATA_SOURCE_FIELD_TYPES` (form.tsx) so the form +threads `dataSource` + `dependentValues` to them, and `INLINE_EXCLUDED_FIELD_TYPES` +(they're authored in the record form, not a grid cell). `DataSource.getObjects()` +is optional on the interface; the ObjectStack adapter implements it. diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 0d441275d7..16995eeca8 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -121,7 +121,12 @@ const computeDirty = ( }; const BUILTIN_FIELD_TYPES = new Set(['input', 'textarea', 'checkbox', 'switch', 'select']); -const DATA_SOURCE_FIELD_TYPES = new Set(['lookup', 'master_detail', 'tree', 'capability-multiselect']); +const DATA_SOURCE_FIELD_TYPES = new Set([ + 'lookup', 'master_detail', 'tree', 'capability-multiselect', + // Widget-hint pickers that resolve records / object catalogs and read sibling + // field values — they need both `dataSource` and `dependentValues` threaded. + 'object-ref', 'filter-condition', 'recipient-picker', +]); function stripRendererOnlyProps>(props: T): T { const { diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 1d837292bc..cd9a7ebfb1 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -1725,6 +1725,49 @@ export class ObjectStackAdapter implements DataSource { return data && typeof data === 'object' && 'item' in data ? data.item : data; } + /** + * List every registered object (code- and DB-defined) from the metadata + * registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }` + * headers for object-picker widgets (e.g. the sharing-rule `object-ref` + * field). The list endpoint is uncached server-side, so no cache-busting + * dance is needed. Returns `[]` on any failure so callers degrade gracefully. + */ + async getObjects(): Promise> { + try { + await this.connect(); + const baseUrl = (this.baseUrl || '').replace(/\/$/, ''); + // Avoid doubling /api/v1 when baseUrl already carries the version suffix + // (mirrors fetchObjectSchemaFresh). + const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl); + const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta'; + const url = `${baseUrl}${metaPath}/object`; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + + const res = await this.fetchImpl(url, { method: 'GET', headers }); + if (!res.ok) return []; + const body: any = await res.json(); + // Unwrap the `{ success, data }` envelope, the `{ type, items }` list + // shape, or a bare array. + const data = + body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body; + const items: any[] = Array.isArray(data) + ? data + : Array.isArray(data?.items) + ? data.items + : []; + return items + .map((it: any) => ({ + name: String(it?.name ?? ''), + label: it?.label != null ? String(it.label) : undefined, + })) + .filter((it) => it.name); + } catch { + return []; + } + } + /** * Get access to the underlying ObjectStack client for advanced operations. */ diff --git a/packages/fields/src/FieldEditWidget.tsx b/packages/fields/src/FieldEditWidget.tsx index 13b919ec39..262d1ee02e 100644 --- a/packages/fields/src/FieldEditWidget.tsx +++ b/packages/fields/src/FieldEditWidget.tsx @@ -106,6 +106,9 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set([ // Containers / non-authorable — a sub-form / sub-grid / embedding vector // doesn't belong in a single cell. 'object', 'grid', 'vector', + // Widget-hint-only pickers — authored in the record form (they depend on + // sibling fields / a loaded object catalog), not inline in a grid cell. + 'object-ref', 'filter-condition', 'recipient-picker', ]); /** Field types whose value is chosen in one discrete gesture (no free typing). */ diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 11cfe48eb3..d67b95b7a9 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2022,6 +2022,16 @@ const fieldWidgetMap: Record Promise<{ default: React.ComponentTyp 'geolocation': () => import('./widgets/GeolocationField').then(m => ({ default: m.GeolocationField })), 'signature': () => import('./widgets/SignatureField').then(m => ({ default: m.SignatureField })), 'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })), + + // Widget-hint-only pickers (reached via a field `widget:` override, never a + // bare field `type`). They render a *picker* over machine data an admin would + // otherwise have to type — used by sys_sharing_rule (ADR-0056 P2 pattern): + // object-ref → choose a registered object by name + // filter-condition → visual criteria builder scoped to the chosen object + // recipient-picker → record picker whose target follows a sibling type + 'object-ref': () => import('./widgets/ObjectRefField').then(m => ({ default: m.ObjectRefField })), + 'filter-condition': () => import('./widgets/FilterConditionField').then(m => ({ default: m.FilterConditionField })), + 'recipient-picker': () => import('./widgets/RecipientPickerField').then(m => ({ default: m.RecipientPickerField })), }; /** @@ -2071,6 +2081,11 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([ // same "bare-name fallback overwritten" warning at every boot regardless. 'time', 'address', + // Widget-hint-only pickers — resolved solely via `field:`, so the + // bare-key fallback is never wanted. + 'object-ref', + 'filter-condition', + 'recipient-picker', ]); export function registerField(fieldType: string): void { @@ -2220,6 +2235,9 @@ export * from './widgets/TextAreaField'; export * from './widgets/RichTextField'; export * from './widgets/LookupField'; export * from './widgets/CapabilityMultiSelectField'; +export * from './widgets/ObjectRefField'; +export * from './widgets/FilterConditionField'; +export * from './widgets/RecipientPickerField'; export * from './widgets/RecordPickerDialog'; export * from './widgets/FileField'; export * from './widgets/ImageField'; diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx new file mode 100644 index 0000000000..44c7430aa7 --- /dev/null +++ b/packages/fields/src/widgets/FilterConditionField.tsx @@ -0,0 +1,343 @@ +import React from 'react'; +import { FilterBuilder, cn } from '@object-ui/components'; +import { SchemaRendererContext } from '@object-ui/react'; +import type { FieldWidgetProps } from './types'; + +/** + * FilterConditionField — visual criteria builder for a stored FilterCondition + * (e.g. `sys_sharing_rule.criteria_json`), scoped to the object chosen in a + * sibling `object_name` field. + * + * Reached via the field `widget: 'filter-condition'` hint (resolves as + * `field:filter-condition`). Reads the live `object_name` from + * `dependentValues`, loads that object's fields via + * `dataSource.getObjectSchema(...)`, and renders `` over them — + * so an admin builds `type == "customer" AND is_active == true` by picking + * fields/operators instead of hand-writing JSON. + * + * Storage contract: the value round-trips as a **MongoDB-style object filter** + * (`{ field: value }`, `{ field: { $gt: n } }`, `{ $or: [...] }`), JSON-encoded + * — the exact shape the sharing evaluator spreads into `engine.find(object, + * { filter })`. Criteria that can't be represented in the builder (nested + * mixes, unknown operators) fall back to a raw-JSON editor so nothing is hidden + * or lost; an "Edit as JSON" toggle is always available. + */ + +interface FilterFieldDef { + value: string; + label: string; + type?: string; + options?: Array<{ value: string; label: string }>; + referenceTo?: string; +} + +interface BuilderCondition { + id: string; + field: string; + operator: string; + value: any; +} +interface BuilderGroup { + id: string; + logic: 'and' | 'or'; + conditions: BuilderCondition[]; +} + +const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] }; + +/** Field types that are not meaningfully filterable in a simple builder. */ +const NON_FILTERABLE = new Set([ + 'object', 'vector', 'file', 'image', 'avatar', 'signature', + 'richtext', 'html', 'markdown', 'location', 'grid', 'json', 'code', +]); + +function deriveFilterFields(schema: any): FilterFieldDef[] { + const raw = schema?.fields; + const entries: Array<[string, any]> = Array.isArray(raw) + ? raw.map((f: any) => [f?.name, f]) + : raw && typeof raw === 'object' + ? Object.entries(raw) + : []; + const out: FilterFieldDef[] = []; + for (const [name, f] of entries) { + if (!name || !f || f.hidden) continue; + const type = f.type as string | undefined; + if (type && NON_FILTERABLE.has(type)) continue; + out.push({ + value: name, + label: f.label || name, + type, + options: Array.isArray(f.options) + ? f.options.map((o: any) => + typeof o === 'string' + ? { value: o, label: o } + : { value: String(o?.value), label: String(o?.label ?? o?.value) }, + ) + : undefined, + referenceTo: f.reference_to || f.reference, + }); + } + return out; +} + +function coerceByType(value: any, type?: string): any { + if (value == null) return value; + if (type === 'boolean' || type === 'toggle') { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; + } + if (type === 'number' || type === 'currency' || type === 'percent' || type === 'rating' || type === 'slider') { + const n = Number(value); + return Number.isFinite(n) && value !== '' ? n : value; + } + return value; +} + +function toArray(value: any): any[] { + if (Array.isArray(value)) return value; + if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean); + return value == null ? [] : [value]; +} + +function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record | null { + const { field, operator, value } = c || ({} as BuilderCondition); + if (!field) return null; + const t = typeOf(field); + const cv = coerceByType(value, t); + switch (operator) { + case 'equals': return { [field]: cv }; + case 'notEquals': return { [field]: { $ne: cv } }; + case 'contains': return { [field]: { $contains: value } }; + case 'notContains': return { [field]: { $ncontains: value } }; + case 'isEmpty': return { [field]: { $in: [null, ''] } }; + case 'isNotEmpty': return { [field]: { $nin: [null, ''] } }; + case 'greaterThan': + case 'after': return { [field]: { $gt: cv } }; + case 'lessThan': + case 'before': return { [field]: { $lt: cv } }; + case 'greaterOrEqual': return { [field]: { $gte: cv } }; + case 'lessOrEqual': return { [field]: { $lte: cv } }; + case 'between': { + const [a, b] = Array.isArray(value) ? value : [undefined, undefined]; + return { [field]: { $gte: coerceByType(a, t), $lte: coerceByType(b, t) } }; + } + case 'in': return { [field]: { $in: toArray(value).map((v) => coerceByType(v, t)) } }; + case 'notIn': return { [field]: { $nin: toArray(value).map((v) => coerceByType(v, t)) } }; + default: return { [field]: cv }; + } +} + +function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string | undefined): Record | null { + const frags = (group?.conditions ?? []) + .map((c) => condToMongo(c, typeOf)) + .filter((x): x is Record => !!x); + if (frags.length === 0) return null; // empty = match all + if (group.logic === 'or') return { $or: frags }; + if (frags.length === 1) return frags[0]; + const keys = frags.flatMap((f) => Object.keys(f)); + const noCollision = new Set(keys).size === keys.length; + return noCollision ? Object.assign({}, ...frags) : { $and: frags }; +} + +function arraysEqual(a: any, b: any[]): boolean { + return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]); +} + +function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null { + const id = `c_${idx}_${field}`; + if (v === null || typeof v !== 'object' || Array.isArray(v)) { + return { id, field, operator: 'equals', value: v }; + } + const opKeys = Object.keys(v); + if (opKeys.length === 1) { + const op = opKeys[0]; + const val = v[op]; + switch (op) { + case '$ne': return { id, field, operator: 'notEquals', value: val }; + case '$contains': return { id, field, operator: 'contains', value: val }; + case '$ncontains': return { id, field, operator: 'notContains', value: val }; + case '$gt': return { id, field, operator: 'greaterThan', value: val }; + case '$lt': return { id, field, operator: 'lessThan', value: val }; + case '$gte': return { id, field, operator: 'greaterOrEqual', value: val }; + case '$lte': return { id, field, operator: 'lessOrEqual', value: val }; + case '$in': + return arraysEqual(val, [null, '']) + ? { id, field, operator: 'isEmpty', value: '' } + : { id, field, operator: 'in', value: val }; + case '$nin': + return arraysEqual(val, [null, '']) + ? { id, field, operator: 'isNotEmpty', value: '' } + : { id, field, operator: 'notIn', value: val }; + default: return null; + } + } + if (opKeys.length === 2 && '$gte' in v && '$lte' in v) { + return { id, field, operator: 'between', value: [v.$gte, v.$lte] }; + } + return null; +} + +/** Returns a BuilderGroup, or `null` when the criteria can't be represented. */ +function mongoToFilterGroup(mongo: any): BuilderGroup | null { + if (mongo == null) return { ...EMPTY_GROUP, conditions: [] }; + if (typeof mongo !== 'object' || Array.isArray(mongo)) return null; + const entries = Object.entries(mongo); + if (entries.length === 0) return { ...EMPTY_GROUP, conditions: [] }; + if (entries.length === 1 && (mongo.$or || mongo.$and)) { + const logic: 'and' | 'or' = mongo.$or ? 'or' : 'and'; + const arr = mongo.$or || mongo.$and; + if (!Array.isArray(arr)) return null; + const conditions: BuilderCondition[] = []; + for (let i = 0; i < arr.length; i++) { + const frag = arr[i]; + if (!frag || typeof frag !== 'object' || Object.keys(frag).length !== 1) return null; + const field = Object.keys(frag)[0]; + if (field.startsWith('$')) return null; + const c = kvToCondition(field, frag[field], i); + if (!c) return null; + conditions.push(c); + } + return { id: 'root', logic, conditions }; + } + const conditions: BuilderCondition[] = []; + let i = 0; + for (const [field, v] of entries) { + if (field.startsWith('$')) return null; // mixed logical + field → raw + const c = kvToCondition(field, v, i++); + if (!c) return null; + conditions.push(c); + } + return { id: 'root', logic: 'and', conditions }; +} + +function stringifyValue(value: string | object | undefined | null): string { + if (value == null || value === '') return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +export function FilterConditionField({ + value, + onChange, + readonly, + className, + ...props +}: FieldWidgetProps) { + const ctx = React.useContext(SchemaRendererContext); + const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null; + const dependentValues: Record = (props as any).dependentValues ?? {}; + const objectName = String(dependentValues.object_name ?? ''); + + const [fields, setFields] = React.useState(null); + + React.useEffect(() => { + setFields(null); + if (!dataSource || !objectName || typeof dataSource.getObjectSchema !== 'function') return; + let cancelled = false; + (async () => { + try { + const schema = await dataSource.getObjectSchema(objectName); + if (!cancelled) setFields(deriveFilterFields(schema)); + } catch { + if (!cancelled) setFields([]); + } + })(); + return () => { + cancelled = true; + }; + }, [dataSource, objectName]); + + const rawValue = React.useMemo(() => stringifyValue(value), [value]); + + const parsed = React.useMemo(() => { + if (!rawValue.trim()) return { mongo: {}, ok: true }; + try { + return { mongo: JSON.parse(rawValue), ok: true }; + } catch { + return { mongo: null, ok: false }; + } + }, [rawValue]); + + const group = React.useMemo( + () => (parsed.ok ? mongoToFilterGroup(parsed.mongo) : null), + [parsed], + ); + + // Raw JSON mode: forced when the stored value can't be represented in the + // builder; otherwise opt-in via the toggle. + const representable = parsed.ok && group !== null; + const [rawMode, setRawMode] = React.useState(!representable); + React.useEffect(() => { + if (!representable) setRawMode(true); + }, [representable]); + + const typeOf = React.useMemo(() => { + const map = new Map((fields ?? []).map((f) => [f.value, f.type])); + return (f: string) => map.get(f); + }, [fields]); + + const handleBuilderChange = (g: BuilderGroup) => { + const mongo = filterGroupToMongo(g, typeOf); + onChange((mongo == null ? '' : JSON.stringify(mongo)) as any); + }; + + if (!objectName) { + return ( +

+ Select an object first. +

+ ); + } + + if (readonly) { + if (!rawValue.trim()) { + return All records; + } + return ( +
+        {rawValue}
+      
+ ); + } + + return ( +
+ {rawMode ? ( + <> +