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
26 changes: 26 additions & 0 deletions .changeset/sharing-rule-form-pickers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
---

Field metadata gains a `widget` override (`FieldSchema.widget`) — names a
registered form component (resolved as `field:<widget>`) to render a field with,
overriding the default widget derived from `type` and degrading back to it when
unregistered. The generic object form already honored this hint (objectui
`ObjectForm`/`form.tsx` resolve `widget || type`); this promotes it to a
first-class, liveness-classified authoring property so any config object can ask
for a picker instead of a raw input.

`sys_sharing_rule` uses it so the Setup **New Sharing Rule** form is
pick-not-type instead of asking admins to hand-enter machine data:

- `object_name` → `object-ref` (choose a registered object by name)
- `criteria_json` → `filter-condition` (visual criteria builder scoped to the
chosen object's fields; `dependsOn: object_name`)
- `recipient_id` → `recipient-picker` (record picker whose target follows
`recipient_type`; `dependsOn: recipient_type`)

Also removes the `queue` recipient type: it is declared-but-unenforced (the
evaluator expands no users for it), so offering it authored a silently-inert rule
(ADR-0078). i18n bundles regenerated. Requires the matching objectui widgets; the
fields degrade to their `type` renderer where those aren't loaded.
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,32 @@ export const SysSharingRule = ObjectSchema.create({
label: 'Object',
required: true,
maxLength: 100,
// Rendered as an object picker (choose a registered object by name)
// instead of a free-text machine-name input. Falls back to a text input
// when the `field:object-ref` widget is unavailable.
widget: 'object-ref',
description: 'Short object name (e.g. opportunity, account)',
group: 'Target',
}),

criteria_json: Field.textarea({
label: 'Criteria (FilterCondition JSON)',
required: false,
// Rendered as a visual criteria builder scoped to the selected object's
// fields (dependsOn: object_name), storing the same JSON FilterCondition.
// An "Edit as JSON" fallback keeps hand-authored / advanced filters
// editable. Falls back to a textarea when the widget is unavailable.
widget: 'filter-condition',
dependsOn: ['object_name'],
description: 'JSON FilterCondition matched against records of object_name. Empty = match all.',
group: 'Target',
}),

recipient_type: Field.select(
['user', 'team', 'business_unit', 'position', 'unit_and_subordinates', 'queue'],
// `queue` was removed: it is declared-but-unenforced (the evaluator returns
// no users for it), so offering it would author a silently-inert rule
// (ADR-0078). The five values below are the ones the evaluator expands.
['user', 'team', 'business_unit', 'position', 'unit_and_subordinates'],
{
label: 'Recipient Type',
required: true,
Expand All @@ -146,7 +159,14 @@ export const SysSharingRule = ObjectSchema.create({
label: 'Recipient',
required: true,
maxLength: 200,
description: 'business-unit id / team id / position name / queue name / user id depending on recipient_type',
// Rendered as a record picker whose target object follows recipient_type
// (dependsOn: recipient_type): sys_user / sys_team / sys_business_unit /
// sys_position. Stores the value the evaluator matches on — a record id
// for user/team/business_unit, the position NAME for `position`. Falls
// back to a text input when the widget is unavailable.
widget: 'recipient-picker',
dependsOn: ['recipient_type'],
description: 'business-unit id / team id / position name / user id depending on recipient_type',
group: 'Recipient',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
team: "team",
business_unit: "business_unit",
position: "position",
unit_and_subordinates: "unit_and_subordinates",
queue: "queue"
unit_and_subordinates: "unit_and_subordinates"
}
},
recipient_id: {
label: "Recipient",
help: "business unit id / team id / position name / queue name / user id depending on recipient_type"
help: "business unit id / team id / position name / user id depending on recipient_type"
},
access_level: {
label: "Access Level",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
team: "Equipo",
business_unit: "business_unit",
position: "posición",
unit_and_subordinates: "unit_and_subordinates",
queue: "Cola"
unit_and_subordinates: "unit_and_subordinates"
}
},
recipient_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
team: "チーム",
business_unit: "business_unit",
position: "ポジション",
unit_and_subordinates: "unit_and_subordinates",
queue: "キュー"
unit_and_subordinates: "unit_and_subordinates"
}
},
recipient_id: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
team: "团队",
business_unit: "business_unit",
position: "岗位",
unit_and_subordinates: "unit_and_subordinates",
queue: "队列"
unit_and_subordinates: "unit_and_subordinates"
}
},
recipient_id: {
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
"status": "live",
"note": "renderer."
},
"widget": {
"status": "live",
"note": "objectui generic form widget override. ObjectForm (packages/plugin-form/src/ObjectForm.tsx) threads `field.widget` into the renderer; form.tsx resolves `widget || type` and looks it up as `field:<widget>` in the ComponentRegistry (renderFieldComponent). Used to render pick-not-type controls for sys_sharing_rule (object-ref / filter-condition / recipient-picker) and sys_permission_set (capability-multiselect). Degrades to the `type` renderer when the widget is unregistered."
},
"requiredPermissions": {
"status": "live",
"evidence": "packages/plugins/plugin-security/src/security-plugin.ts",
Expand Down
14 changes: 14 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,20 @@ export const FieldSchema = lazySchema(() => z.object({
* @deprecated Alias of `requiredWhen` — kept for back-compat. */
conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. Alias of `requiredWhen`.'),

/**
* Form widget override. Names a registered field/UI component to render this
* field with, overriding the default widget derived from `type`. Honored by
* the generic object form (objectui `ObjectForm`/`form.tsx` resolve
* `widget || type`, looking the value up as `field:<widget>` in the component
* registry). Use it when the raw `type` renderer would force a user to type
* machine data that should be *picked* instead — e.g. an object-name field
* (`widget: 'object-ref'`), a stored FilterCondition (`widget:
* 'filter-condition'`), or a recipient reference whose target depends on a
* sibling field (`widget: 'recipient-picker'`). Unknown/unregistered values
* fall back to the `type` renderer, so it degrades safely.
*/
widget: z.string().optional().describe('Form widget override — names a registered field component (resolved as `field:<widget>`) to render this field instead of the `type` default. Degrades to the `type` renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker".'),

/** Security & Visibility */
hidden: z.boolean().default(false).describe('Hidden from default UI'),
readonly: z.boolean().default(false).describe('Read-only in UI'),
Expand Down