Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/sharing-rule-form-pickers.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/components/src/renderers/form/form.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<T extends Record<string, any>>(props: T): T {
const {
Expand Down
43 changes: 43 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1725,6 +1725,49 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
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<Array<{ name: string; label?: string }>> {
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<string, string> = { '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.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/fields/src/FieldEditWidget.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,9 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
// 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). */
Expand Down
18 changes: 18 additions & 0 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2022,6 +2022,16 @@ const fieldWidgetMap: Record<string, () => 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 })),
};

/**
Expand DownExpand Up@@ -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:<widget>`, so the
// bare-key fallback is never wanted.
'object-ref',
'filter-condition',
'recipient-picker',
]);

export function registerField(fieldType: string): void {
Expand DownExpand Up@@ -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';
Expand Down
Loading
Loading