Skip to content

Commit d6a72eb

Browse files
os-zhuangclaude
andauthored
feat(sharing): pick-not-type sharing rule form via field widget hints (#2878)
Make the Setup "New Sharing Rule" form select data instead of asking admins to hand-type machine identifiers. - spec: add a first-class `FieldSchema.widget` override — names a registered form component (resolved as `field:<widget>`) to render a field with, overriding the `type` default and degrading back to it when unregistered. The generic object form already honored the hint (`widget || type`); this promotes it to an authored, liveness-classified property (field.json). - plugin-sharing: sys_sharing_rule declares widgets + dependsOn — object_name → object-ref, criteria_json → filter-condition (dependsOn object_name), recipient_id → recipient-picker (dependsOn recipient_type). - plugin-sharing: drop the `queue` recipient type — declared-but-unenforced (the evaluator expands no users for it), so it authored a silently-inert rule (ADR-0078); i18n bundles regenerated. Widgets ship in objectui; fields degrade to their `type` renderer where the widgets aren't loaded, so the two repos land independently. Claude-Session: https://claude.ai/code/session_013wGu7aa1YXhHseojW9CBRf Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7cb85b commit d6a72eb

8 files changed

Lines changed: 71 additions & 11 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
---
5+
6+
Field metadata gains a `widget` override (`FieldSchema.widget`) — names a
7+
registered form component (resolved as `field:<widget>`) to render a field with,
8+
overriding the default widget derived from `type` and degrading back to it when
9+
unregistered. The generic object form already honored this hint (objectui
10+
`ObjectForm`/`form.tsx` resolve `widget || type`); this promotes it to a
11+
first-class, liveness-classified authoring property so any config object can ask
12+
for a picker instead of a raw input.
13+
14+
`sys_sharing_rule` uses it so the Setup **New Sharing Rule** form is
15+
pick-not-type instead of asking admins to hand-enter machine data:
16+
17+
- `object_name``object-ref` (choose a registered object by name)
18+
- `criteria_json``filter-condition` (visual criteria builder scoped to the
19+
chosen object's fields; `dependsOn: object_name`)
20+
- `recipient_id``recipient-picker` (record picker whose target follows
21+
`recipient_type`; `dependsOn: recipient_type`)
22+
23+
Also removes the `queue` recipient type: it is declared-but-unenforced (the
24+
evaluator expands no users for it), so offering it authored a silently-inert rule
25+
(ADR-0078). i18n bundles regenerated. Requires the matching objectui widgets; the
26+
fields degrade to their `type` renderer where those aren't loaded.

packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,32 @@ export const SysSharingRule = ObjectSchema.create({
120120
label: 'Object',
121121
required: true,
122122
maxLength: 100,
123+
// Rendered as an object picker (choose a registered object by name)
124+
// instead of a free-text machine-name input. Falls back to a text input
125+
// when the `field:object-ref` widget is unavailable.
126+
widget: 'object-ref',
123127
description: 'Short object name (e.g. opportunity, account)',
124128
group: 'Target',
125129
}),
126130

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

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

packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
137137
team: "team",
138138
business_unit: "business_unit",
139139
position: "position",
140-
unit_and_subordinates: "unit_and_subordinates",
141-
queue: "queue"
140+
unit_and_subordinates: "unit_and_subordinates"
142141
}
143142
},
144143
recipient_id: {
145144
label: "Recipient",
146-
help: "business unit id / team id / position name / queue name / user id depending on recipient_type"
145+
help: "business unit id / team id / position name / user id depending on recipient_type"
147146
},
148147
access_level: {
149148
label: "Access Level",

packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
137137
team: "Equipo",
138138
business_unit: "business_unit",
139139
position: "posición",
140-
unit_and_subordinates: "unit_and_subordinates",
141-
queue: "Cola"
140+
unit_and_subordinates: "unit_and_subordinates"
142141
}
143142
},
144143
recipient_id: {

packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
137137
team: "チーム",
138138
business_unit: "business_unit",
139139
position: "ポジション",
140-
unit_and_subordinates: "unit_and_subordinates",
141-
queue: "キュー"
140+
unit_and_subordinates: "unit_and_subordinates"
142141
}
143142
},
144143
recipient_id: {

packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
137137
team: "团队",
138138
business_unit: "business_unit",
139139
position: "岗位",
140-
unit_and_subordinates: "unit_and_subordinates",
141-
queue: "队列"
140+
unit_and_subordinates: "unit_and_subordinates"
142141
}
143142
},
144143
recipient_id: {

packages/spec/liveness/field.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
"status": "live",
9393
"note": "renderer."
9494
},
95+
"widget": {
96+
"status": "live",
97+
"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."
98+
},
9599
"requiredPermissions": {
96100
"status": "live",
97101
"evidence": "packages/plugins/plugin-security/src/security-plugin.ts",

packages/spec/src/data/field.zod.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,20 @@ export const FieldSchema = lazySchema(() => z.object({
597597
* @deprecated Alias of `requiredWhen` — kept for back-compat. */
598598
conditionalRequired: ExpressionInputSchema.optional().describe('Predicate (CEL) — field is required when TRUE. Alias of `requiredWhen`.'),
599599

600+
/**
601+
* Form widget override. Names a registered field/UI component to render this
602+
* field with, overriding the default widget derived from `type`. Honored by
603+
* the generic object form (objectui `ObjectForm`/`form.tsx` resolve
604+
* `widget || type`, looking the value up as `field:<widget>` in the component
605+
* registry). Use it when the raw `type` renderer would force a user to type
606+
* machine data that should be *picked* instead — e.g. an object-name field
607+
* (`widget: 'object-ref'`), a stored FilterCondition (`widget:
608+
* 'filter-condition'`), or a recipient reference whose target depends on a
609+
* sibling field (`widget: 'recipient-picker'`). Unknown/unregistered values
610+
* fall back to the `type` renderer, so it degrades safely.
611+
*/
612+
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".'),
613+
600614
/** Security & Visibility */
601615
hidden: z.boolean().default(false).describe('Hidden from default UI'),
602616
readonly: z.boolean().default(false).describe('Read-only in UI'),

0 commit comments

Comments
 (0)