From 7737814f8b3507805fa6b966ff91c07aaa50cbb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 07:02:27 +0000 Subject: [PATCH] feat(sharing): pick-not-type sharing rule form via field widget hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:`) 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013wGu7aa1YXhHseojW9CBRf --- .changeset/sharing-rule-form-pickers.md | 26 +++++++++++++++++++ .../src/objects/sys-sharing-rule.object.ts | 24 +++++++++++++++-- .../src/translations/en.objects.generated.ts | 5 ++-- .../translations/es-ES.objects.generated.ts | 3 +-- .../translations/ja-JP.objects.generated.ts | 3 +-- .../translations/zh-CN.objects.generated.ts | 3 +-- packages/spec/liveness/field.json | 4 +++ packages/spec/src/data/field.zod.ts | 14 ++++++++++ 8 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 .changeset/sharing-rule-form-pickers.md diff --git a/.changeset/sharing-rule-form-pickers.md b/.changeset/sharing-rule-form-pickers.md new file mode 100644 index 000000000..fcac21469 --- /dev/null +++ b/.changeset/sharing-rule-form-pickers.md @@ -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:`) 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. diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 0c25f7e57..153954c4f 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -120,6 +120,10 @@ 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', }), @@ -127,12 +131,21 @@ export const SysSharingRule = ObjectSchema.create({ 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, @@ -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', }), diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index f0d579c54..a3c812419 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -137,13 +137,12 @@ export const enObjects: NonNullable = { 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", diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index f77519e4a..f06d613ca 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -137,8 +137,7 @@ export const esESObjects: NonNullable = { 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: { diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index ee8e6acb7..7b8f076d6 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -137,8 +137,7 @@ export const jaJPObjects: NonNullable = { team: "チーム", business_unit: "business_unit", position: "ポジション", - unit_and_subordinates: "unit_and_subordinates", - queue: "キュー" + unit_and_subordinates: "unit_and_subordinates" } }, recipient_id: { diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 7f1fff2b2..205e6770e 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -137,8 +137,7 @@ export const zhCNObjects: NonNullable = { team: "团队", business_unit: "business_unit", position: "岗位", - unit_and_subordinates: "unit_and_subordinates", - queue: "队列" + unit_and_subordinates: "unit_and_subordinates" } }, recipient_id: { diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index f881daad2..04f45b96b 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -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:` 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", diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index fcef92a0a..4b950998c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -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:` 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:`) 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'),