diff --git a/.changeset/30259-action-param-widget.md b/.changeset/30259-action-param-widget.md new file mode 100644 index 00000000000..ab5fe63cc03 --- /dev/null +++ b/.changeset/30259-action-param-widget.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): an action param can name the widget that draws it (`widget`, same key as on a field), so a param can ask for the permission-set picker (ADR-0131 D4) + +Clause-②: yes (widening) +Metadata-protocol: added + +- **`@objectstack/spec`: `ActionParamSchema.widget`**, an optional string with the same key, vocabulary and meaning as `FieldSchema.widget`. It names a registered field component, looked up as `field:` plus the name, to draw the param in the action dialog instead of the `type` default. `type` stays the value's type. The case it exists for is `widget: 'permission-ref'` on a `text` param: it lists the permission sets of the security catalog and submits the chosen machine name, the picker the grant column `sys_user_permission_set.permission_set` already uses. Like the field key it is an open string: an unregistered name is kept, and the dialog draws the `type` renderer instead. A non-string is refused. A param that does not declare the key parses exactly as before. Until now a param naming `widget` was refused as an unknown key. +- **Not read yet.** The console's param resolver does not carry the key to the dialog's widget until objectstack#30260 lands. Until then a param that declares it is drawn by its `type` renderer, and its value is submitted as before. The key's description says so, and the liveness ledger grades it `planned` with an author warning: `os validate` and `os lint` warn an author who writes it. +- **`@objectstack/platform-objects`: the action form's Params repeater offers the key** as a `Widget` column, labelled in en, zh-CN, ja-JP and es-ES. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 9da9d6ecb15..ec2eace6cae 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -141,6 +141,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -234,6 +235,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| … +35 more>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -370,6 +372,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | optional | | +| **widget** | `string` | optional | Widget override for this param — names a registered field component (resolved as `field:`) to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` stays the value's type. e.g. "permission-ref" on a text param picks a permission set by name (ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console's action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 42811070ee2..1650e2e3476 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -187,6 +187,34 @@ describe('lintLivenessProperties', () => { }).filter((f) => f.message.includes('objectName'))).toEqual([]); }); + // #30259 — `action.params[].widget` is declared before the console's dialog + // reads it (#30260 adds the pass-through), so the drilled row is `planned` + + // `authorWarn`. Pinned through the SHIPPED ledger and an array of params, so + // the drill (`params.widget`) and the array fan-out both have to hold. + it('warns on an action param that names a widget — planned, nothing reads it yet', () => { + const findings = lintLivenessProperties({ + actions: [{ + name: 'create_user', + params: [ + { name: 'email', type: 'email', required: true }, + { name: 'permissionSet', type: 'text', widget: 'permission-ref' }, + ], + }], + }); + const warned = findings.filter((f) => f.message.includes('sets `params.widget`')); + expect(warned).toHaveLength(1); + expect(warned[0].rule).toBe('liveness-planned-property'); + expect(warned[0].hint).toContain('nothing reads it yet'); + // The live siblings the drill graded stay silent. + for (const key of ['name', 'type', 'required', 'options', 'helpText', 'carryOver']) { + expect(ruleOf(findings, `params.${key}`), key).toBeUndefined(); + } + // And an action whose params name no widget draws nothing about it. + expect(lintLivenessProperties({ + actions: [{ name: 'a2', params: [{ name: 'note', type: 'text' }] }], + }).filter((f) => f.message.includes('widget'))).toEqual([]); + }); + it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { // `experimental` warns implicitly — shouldWarn() treats a declared-but- // unenforced guarantee like an opted-in dead prop. Repointed from diff --git a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts index 20a80a21f8b..c13467bb158 100644 --- a/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/action-body-panel-echo-decisions.test.ts @@ -637,8 +637,9 @@ describe('#19403 round 5 — the blind spot, FIFTH shape: one schema, two forms, // `repeater-row-properties.test.ts` derives a row property for every child // of a `type: 'repeater'` field. On `actionForm` that is exactly ONE // repeater — `params`, with eighteen children — and every one of those - // carried its own text in all three locales before this round. - expect(ACTION_ENUMERATED.covered.length).toBe(18); + // carried its own text in all three locales before this round. Nineteen + // since #30259 added `params.widget`, authored in all three locales too. + expect(ACTION_ENUMERATED.covered.length).toBe(19); expect(ACTION_ENUMERATED.covered).toContain('params.carryOver'); expect(ACTION_ENUMERATED.covered).toContain('params.requiresFeature'); for (const [locale, forms] of TRANSLATED_LOCALES) { diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index d7993bd5eb7..28f59fbf475 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1756,6 +1756,9 @@ export const enMetadataForms: NonNullable = { "params.type": { label: "Type" }, + "params.widget": { + label: "Widget" + }, "params.required": { label: "Required" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index b2a44f09fe4..464ed702254 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1756,6 +1756,9 @@ export const esESMetadataForms: NonNullable = "params.type": { label: "Tipo" }, + "params.widget": { + label: "Control del campo" + }, "params.required": { label: "Obligatorio" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index dbe5b24c425..d7c71a5c6b1 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1756,6 +1756,9 @@ export const jaJPMetadataForms: NonNullable = "params.type": { label: "型" }, + "params.widget": { + label: "ウィジェット指定" + }, "params.required": { label: "必須" }, diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index da392eb5285..688e2825ea7 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1259,7 +1259,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 701 since #29442: the flow form's variables repeater offers // `objectName` (a start input declared a record id) — one row label, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(701); + // 702 since #30259: the action form's params repeater offers `widget` + // (the widget that draws the param, the same key as a field's) — one + // row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(702); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index c856b2f2e04..e3b9f6fb75b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1756,6 +1756,9 @@ export const zhCNMetadataForms: NonNullable = "params.type": { label: "类型" }, + "params.widget": { + label: "控件覆盖" + }, "params.required": { label: "必填" }, diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 7d1c8d71f7c..f2ee3f4c054 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -158,6 +158,7 @@ "ui/ActionParam:requiresFeature", "ui/ActionParam:type", "ui/ActionParam:visible", + "ui/ActionParam:widget", "ui/ActionSession:organizationId", "ui/ActionSession:positions", "ui/ActionSession:roles", diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 4e6bdf703d7..e5b4b9ef114 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -95,7 +95,148 @@ "verifiedAt": "2026-09-15", "evidenceScope": "cross-repo", "evidence": "cloud @cb8ee7ff: packages/service-ai/src/tools/action-tools.ts#buildParametersSchema — each declared param becomes one JSON Schema property on the exposed tool.", - "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed." + "note": "field/objectOverride/defaultFromRow/options/placeholder/helpText/defaultValue/required all consumed. DRILLED 2026-11-05 (#30259): each member now carries its own verdict and in-repo evidence (the console resolver, the param dialog, paramToField, and the dispatcher's ADR-0104 D2 check), and the container left the undrilled-containers baseline. The container's own row keeps its cross-repo AI-tool evidence. `widget` is this type's planned member, with authorWarn, until #30260 gives it a reader.", + "children": { + "name": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (the request-body key of the resolved param, `param.name ?? ''` inline, falling back to the field name); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (keys the collected values and each control's error by `param.name`); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams (the dispatcher's ADR-0104 D2 descriptor is keyed by `p?.name ?? fieldRef`)" + }, + "field": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (a param with `field` takes the field-backed branch: label, type, options, placeholder, help, default and the picker group inherited from `owner.fields[param.field]`); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams (inherits type / multiple / required / options from the referenced field before validating the submitted value)" + }, + "objectOverride": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`const ownerName = param.objectOverride ?? ctx.objectName` picks the object whose field a field-backed param resolves against)", + "note": "Console only: the dispatcher's resolveDeclaredActionParams resolves a field-backed param against the action's own object and does not read this key, so its inherited value check is the own object's field (or open, when that object has no such field)." + }, + "label": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`resolveInlineI18nLabel(param.label, ctx.locale)`, else the field's label or the name); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (draws the control's label); packages/runtime/src/action-execution.ts#summarizeActionParams (the MCP action summary carries a string label)" + }, + "type": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.type ?? 'text'` inline, `param.type ?? field.type` field-backed); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (folds the type to the widget that draws it); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (the submitted value is checked against `valueSchemaFor` of this type, ADR-0104 D2)" + }, + "widget": { + "status": "planned", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: the console's action dialog draws the param with its `type` widget until the dialog passes `widget` through, so a param naming `permission-ref` is a text box for now.", + "note": "PLANNED RECORD (#30259, ADR-0131 D4): the widget override, the same key, vocabulary and meaning as FieldSchema.widget, declared spec-first so a param can ask for the `permission-ref` picker the grant column uses. Accepted by the parse door and carried in the parsed param; read by nothing yet, measured: the console's resolveActionParam builds an ActionParamDef from named members and copies no `widget`, paramToField sets a field widget only for `boolean`, and the dispatcher's resolveDeclaredActionParams copies five named members and validates by `type`, so the value contract is unchanged. The reader is #30260 (the resolveActionParams / paramToField pass-through); flip this row to live there. Create User's permissionSet param switches to it in #30117's server PR." + }, + "required": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.required ?? false`, field-backed `?? field.required`); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (a required param with no value blocks submit); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (an absent required value is refused `required`)" + }, + "options": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (an authored list wins over the field's, its labels resolved by resolveOptionLabels); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (passes the list into the widget's field metadata); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (a submitted value must be one of the option values, ADR-0104 D2)", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (resolves an inline per-locale map to the string the dialog shows, falling back to the value); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (re-localises each entry's label with pickLocalized before drawing it)" + }, + "value": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (the fallback label of an unresolved entry); packages/spec/src/ui/action-params.zod.ts#validateActionParams (membership is judged over the option values through valueSchemaFor); ui/packages/core/src/evaluator/optionRules.ts#resolveCascadingOptions (filters the offered set by entry)" + }, + "visibleWhen": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveOptionLabels (an inline list reaches the dialog verbatim, `visibleWhen` included, the pinned fast path); ui/packages/core/src/evaluator/optionRules.ts#resolveCascadingOptions (the select, multiselect, radio and checkboxes widgets narrow the offered set by evaluating it)", + "note": "Client-side only: the dispatcher checks the submitted value against the option values and does not evaluate visibleWhen, so hiding an option is not authorization (the key's TSDoc says so)." + } + } + }, + "placeholder": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.placeholder`, field-backed `?? field.placeholder`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the widget's field metadata)" + }, + "helpText": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`actionCopySource(param.helpText)`, field-backed falling back to the field's help); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (fills its holes and draws it under the control); packages/runtime/src/action-execution.ts#summarizeActionParams (the MCP summary's help, falling back to the field description)" + }, + "defaultValue": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`rowDefault ?? param.defaultValue`); ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog (seeds the dialog state with it, so an untouched control submits it verbatim)" + }, + "multiple": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.multiple`, field-backed `?? field.multiple`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the widget's field metadata); packages/runtime/src/action-execution.ts#resolveDeclaredActionParams and packages/spec/src/ui/action-params.zod.ts#validateActionParams (an array value shape when multi-valued)" + }, + "accept": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.accept`, field-backed `?? field.accept`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the upload widget's field metadata)" + }, + "maxSize": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.maxSize`, field-backed `?? field.maxSize`); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (copied onto the upload widget's field metadata)" + }, + "reference": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`referenceTo: param.reference` inline, `param.reference ?? field.reference` field-backed); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (a lookup param with no target degrades to a text input, paramDegradesWithoutTarget)" + }, + "defaultFromRow": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (`param.defaultFromRow && ctx.row …` seeds the default from the current row's stored value)" + }, + "carryOver": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (copied onto ActionParamDef.carryOver on every branch); ui/packages/app-shell/src/views/ActionParamDialog.tsx#CarryOverParam (draws it as a read-only summary) and ui/packages/app-shell/src/views/ActionParamDialog.tsx#serializeParamValues (submits it verbatim, skipping the upload rewrite)" + }, + "visible": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (normaliseVisible); ui/packages/app-shell/src/views/ActionParamDialog.tsx#filterVisibleParams (evaluates the predicate and omits the param when it is false)" + }, + "requiresFeature": { + "status": "live", + "verifiedAt": "2026-11-05", + "evidenceScope": "in-repo", + "evidence": "packages/spec/src/kernel/public-auth-features.ts#lowerRequiresFeature, wired as the param schema's .transform() in packages/spec/src/ui/action.zod.ts", + "note": "Author-facing sugar consumed AT PARSE TIME, as on the action itself: lowered into this param's `visible` predicate and stripped from the output, so no downstream reader sees it." + } + } }, "variant": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 0fabbcf63fa..8580b0c7b86 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 51 | 0 | 0 | 1 | 2 | 54 | +| `action` | 70 | 0 | 0 | 1 | 3 | 74 | diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index cd85f162a71..11bafae87b8 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -58,7 +58,6 @@ ], "containers": [ "action/ai", - "action/params", "action/resultDialog", "agent/guardrails", "agent/memory", diff --git a/packages/spec/src/ui/action-param-widget.test.ts b/packages/spec/src/ui/action-param-widget.test.ts new file mode 100644 index 00000000000..e8edd62f66e --- /dev/null +++ b/packages/spec/src/ui/action-param-widget.test.ts @@ -0,0 +1,97 @@ +// #30259 — `ActionParamSchema.widget`: an action param can name the widget that +// draws it, with the same key, vocabulary and meaning as `FieldSchema.widget`. +// The case that asked for it is the permission-set picker ADR-0131 D4 gives the +// grant column `sys_user_permission_set.permission_set` (`widget: +// 'permission-ref'`): before this key no param could ask for that picker, so +// Create User's `permissionSet` param was a box for a typed machine name +// (#30117). +// +// These pins hold the widening: a param that declares the key parses and keeps +// it; an unregistered name is KEPT, exactly as the field key keeps it (both +// degrade to the `type` renderer at draw time); a non-string is refused; and a +// param that does not declare the key parses exactly as it did before. +import { describe, it, expect } from 'vitest'; +import { ActionParamSchema } from './action.zod'; +import { FieldSchema } from '../data/field.zod'; + +describe('ActionParamSchema.widget — a param names the widget that draws it (#30259)', () => { + it('accepts permission-ref on an inline text param and keeps it, with `type` unchanged', () => { + const r = ActionParamSchema.safeParse({ + name: 'permissionSet', + label: 'Permission Set', + type: 'text', + widget: 'permission-ref', + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('permission-ref'); + // `type` stays the VALUE's type: the widget changes how it is picked only. + expect(r.data.type).toBe('text'); + }); + + it('accepts the key on a field-backed param (an authored override, as every inline value is)', () => { + const r = ActionParamSchema.safeParse({ field: 'permission_set', widget: 'permission-ref' }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('permission-ref'); + }); + + it('is a declared key, not an unrecognized one', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 'permission-ref' }); + const issues = r.success ? [] : r.error.issues; + expect(issues.filter((i) => i.code === 'unrecognized_keys')).toEqual([]); + }); + + describe('value — an open string, matching FieldSchema.widget', () => { + // The field key is `z.string().optional()`: an unregistered name is kept and + // the renderer falls back to the `type` default. The param key makes the same + // promise, so each value below must get the same verdict on both surfaces. + const values: unknown[] = ['permission-ref', 'position-ref', 'object-ref', 'no-such-widget', 42, true, null]; + + for (const value of values) { + it(`gives ${JSON.stringify(value)} the verdict the field key gives it`, () => { + const onParam = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: value }); + const onField = FieldSchema.safeParse({ name: 'p', type: 'text', widget: value }); + expect(onParam.success).toBe(onField.success); + if (onParam.success && onField.success) { + expect(onParam.data.widget).toBe(value); + expect(onField.data.widget).toBe(value); + } + }); + } + + it('keeps an unregistered name rather than refusing it (it degrades at draw time)', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 'no-such-widget' }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.widget).toBe('no-such-widget'); + }); + + it('refuses a non-string on the widget path', () => { + const r = ActionParamSchema.safeParse({ name: 'p', type: 'text', widget: 42 }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.some((i) => i.path.join('.') === 'widget' && i.code === 'invalid_type')).toBe(true); + }); + }); + + describe('existing params parse unchanged', () => { + const existing: Array> = [ + // Create User's param as it stands on main (sys-user.object.ts): inline text. + { name: 'permissionSet', label: 'Permission Set', type: 'text', required: false }, + { field: 'email' }, + { field: 'role', objectOverride: 'sys_member' }, + { name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' }, + { name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }, + ]; + + for (const param of existing) { + it(`parses ${JSON.stringify(param)} with no widget member added`, () => { + const r = ActionParamSchema.safeParse(param); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + if (!r.success) return; + expect('widget' in r.data).toBe(false); + }); + } + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 67836f47392..c86ce3de265 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -77,6 +77,7 @@ export const actionForm = defineForm({ { field: 'objectOverride', label: 'Object Override' }, { field: 'label', label: 'Label' }, { field: 'type', label: 'Type' }, + { field: 'widget', label: 'Widget' }, { field: 'required', label: 'Required' }, { field: 'options', label: 'Options' }, { field: 'placeholder', label: 'Placeholder' }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 197e8a8788e..b7423367238 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -236,6 +236,35 @@ export const ActionParamSchema = lazySchema(() => strictObject( label: I18nLabelSchema.optional().meta({ title: 'Label' }), /** Overrides the resolved field type (or sets it for inline params). */ type: FieldType.optional().meta({ title: 'Type' }), + /** + * Widget override (#30259) — the same key, with the same vocabulary and the + * same meaning, as `FieldSchema.widget`: it names a registered field + * component (looked up as `field:` plus this name) that draws this param in + * the dialog instead of the `type` default. `type` stays the VALUE's type; + * the widget only changes how the value is picked. + * + * The case it exists for is a name that must be picked, not typed: + * `widget: 'permission-ref'` on a `text` param lists the permission sets of + * the security catalog and submits the chosen machine name — the picker + * ADR-0131 D4 gives the grant column + * `sys_user_permission_set.permission_set` ("a picker lists the registry, an + * assignment names one of its entries"). Before this key no param could ask + * for it, so Create User's `permissionSet` was a box for a typed name. + * + * Like the field key it is an open string, not an enum: an unregistered name + * is kept and the dialog draws the `type` renderer instead, so it degrades + * safely. Planned: the console's param resolver does not carry it to the + * widget yet (#30260); until it does, the param draws its `type` renderer + * and its value is submitted as before. + */ + // The describe's "Not read yet" sentence comes off when #30260's pass-through lands. + widget: z.string().optional().describe( + 'Widget override for this param — names a registered field component (resolved as `field:`) ' + + 'to draw it instead of the `type` default; same key and vocabulary as FieldSchema.widget, and `type` ' + + 'stays the value\'s type. e.g. "permission-ref" on a text param picks a permission set by name ' + + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered. Not read yet: until the console\'s ' + + 'action dialog passes it through, the dialog draws every param with its `type` renderer whatever this names.', + ).meta({ title: 'Widget' }), /** * Required override; when omitted defaults to `false`. Consumers that wish * to inherit the underlying field's `required` flag should leave this