diff --git a/.changeset/30260-action-param-widget-reader.md b/.changeset/30260-action-param-widget-reader.md new file mode 100644 index 00000000000..fa7cdc5c48a --- /dev/null +++ b/.changeset/30260-action-param-widget-reader.md @@ -0,0 +1,19 @@ +--- +'@object-ui/core': minor +'@object-ui/fields': minor +'@object-ui/app-shell': minor +'@objectstack/spec': minor +--- + +feat(ui): an action param's `widget` draws the named widget in the action dialog — `widget: 'permission-ref'` on a `text` param is the permission-set picker + +Clause-②: yes (widening) — `@object-ui/core`'s exported `ActionParamDef` gains one optional member, `widget?: string`; `@object-ui/fields`' `RawActionParam` gains the same optional member. No accepted key, value or answer is taken away. +Metadata-protocol: semantic + +`ActionParamSchema.widget` (declared by `@objectstack/spec` with the same key, vocabulary and meaning as `FieldSchema.widget`) was accepted and stored but read by nothing: the action dialog drew every param with its `type` widget. The console now reads it: + +- `resolveActionParams` copies the param's `widget` onto the resolved param. A field-backed param (`{ field }`) that inherits the field's `type` also inherits the field's `widget`; a param that overrides `type` does not, because the field's widget was chosen for the field's value. The param's own `widget` always wins. +- `paramToField` draws the named widget when it is registered, in place of the `type` widget. A name nothing registers keeps the `type` widget (a `textarea` param stays a textarea), and a registered widget on a lookup param without a target is drawn instead of the plain record-id text box. +- So `{ name: 'permissionSet', type: 'text', widget: 'permission-ref' }` opens the permission-set picker: the sets are listed by label beside their machine name, and the machine name is submitted. The server still validates the value by `type`, so the request body is unchanged. + +The spec's liveness ledger marks `params.widget` live, and its `.describe()` no longer says the key is not read yet. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 217a05d5f1e..9b6abf0d6e9 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -142,7 +142,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. | +| **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. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -236,7 +236,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. | +| **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. | | **required** | `boolean` | optional (default: `false`) | | | **options** | `{ label: string \| Record; value: string; visibleWhen?: string \| object }[]` | optional | | | **placeholder** | `string` | optional | | @@ -374,7 +374,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. | +| **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. | | **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 1650e2e3476..2792c5aa0ee 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -187,11 +187,12 @@ 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', () => { + // #30259 declared `action.params[].widget` before any reader (`planned` + + // `authorWarn`, pinned here as a warning). #30260 gave it its console reader + // and flipped the drilled row to `live`, so the SAME param now draws nothing. + // 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('no longer warns on an action param that names a widget — live since its console reader (#30260)', () => { const findings = lintLivenessProperties({ actions: [{ name: 'create_user', @@ -201,18 +202,23 @@ describe('lintLivenessProperties', () => { ], }], }); - 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. + expect(findings.filter((f) => f.message.includes('params.widget'))).toEqual([]); + expect(ruleOf(findings, 'params.widget')).toBeUndefined(); + // The live siblings the drill graded stay silent too. 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([]); + // CONTROL: the same walk still warns on a drilled `planned` + authorWarn + // row elsewhere in the shipped ledger, so the silence above is the + // widget row's verdict, not a walk that reaches nothing. + const flow = lintLivenessProperties({ + flows: [{ + name: 'close_account', + variables: [{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }], + nodes: [{ id: 'start', type: 'start' }], + }], + }); + expect(ruleOf(flow, 'variables.objectName')).toBe('liveness-planned-property'); }); it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index f9856db54f0..a31e9a9bac1 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -95,7 +95,7 @@ "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. 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.", + "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` gained its console reader in #30260.", "children": { "name": { "status": "live", @@ -129,12 +129,11 @@ "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", + "status": "live", "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." + "evidence": "ui/packages/fields/src/resolveActionParams.ts#resolveActionParam (copies the param's `widget` onto the resolved ActionParamDef inline; field-backed, the param's own else the field's FieldSchema.widget while the param inherits the field's `type`, via inheritedWidget); ui/packages/app-shell/src/utils/paramToField.ts#paramToField (a `widget` naming a registered field widget replaces the `type` widget key the dialog mounts through getLazyFieldWidget; an unregistered name keeps the `type` widget, via authoredWidgetKey)", + "note": "Declared spec-first by #30259 (ADR-0131 D4) so a param can ask for the `permission-ref` picker the grant column uses; the console reader landed in #30260, which flipped this row from planned and dropped its authorWarn. The value contract is unchanged: the dispatcher's resolveDeclaredActionParams still validates the submitted value by `type`, so a widget only changes how the value is picked. Create User's permissionSet param switches to it in #30117's server PR. Not read by the selection bar's bulk dialog (plugin-grid bulkParamToField), whose BulkActionParam shape carries no widget." }, "required": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 8580b0c7b86..96310efb1e2 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` | 70 | 0 | 0 | 1 | 3 | 74 | +| `action` | 71 | 0 | 0 | 1 | 2 | 74 | diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index b7423367238..4fc73641f98 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -253,17 +253,16 @@ export const ActionParamSchema = lazySchema(() => strictObject( * * 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. + * safely. The console reads it (#30260): `resolveActionParams` carries it + * onto the resolved param — a field-backed param that inherits or restates + * its `type` inherits the field's `widget` with it — and `paramToField` draws the named + * widget. The server still validates the submitted value by `type`. */ - // 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.', + + '(ADR-0131 D4). Degrades to the `type` renderer when unregistered.', ).meta({ title: 'Widget' }), /** * Required override; when omitted defaults to `false`. Consumers that wish diff --git a/ui/packages/app-shell/src/utils/paramToField.test.ts b/ui/packages/app-shell/src/utils/paramToField.test.ts index 05be0f49d21..6cf8fe03302 100644 --- a/ui/packages/app-shell/src/utils/paramToField.test.ts +++ b/ui/packages/app-shell/src/utils/paramToField.test.ts @@ -380,3 +380,63 @@ describe('paramDegradesWithoutTarget — one answer to "did this param degrade?" } }); }); + +describe('paramToField — an authored `widget` draws the param (#30260)', () => { + // `ActionParamDef.widget` is the spec's `ActionParamSchema.widget`, which + // shares `FieldSchema.widget`'s meaning: a registered name wins over `type`, + // an unregistered one degrades to the `type` widget. + + it('draws the registered widget a text param names, in place of the text widget', () => { + expect(paramToField(p({ type: 'text', widget: 'permission-ref' })).type).toBe('permission-ref'); + expect(paramToField(p({ type: 'text', widget: 'position-ref' })).type).toBe('position-ref'); + }); + + it('CONTROL: the same param without a widget is the text widget', () => { + expect(paramToField(p({ type: 'text' })).type).toBe('text'); + }); + + it('falls back to the `type` widget when the name is not registered', () => { + expect(paramToField(p({ type: 'text', widget: 'no_such_widget' })).type).toBe('text'); + // Not the text fallback: the param's OWN type widget. + expect(paramToField(p({ type: 'select', widget: 'no_such_widget' })).type).toBe('select'); + expect(paramToField(p({ type: 'textarea', widget: 'no_such_widget' })).type).toBe('textarea'); + }); + + it('an authored `text` widget is honoured as the text widget, not read as "unregistered"', () => { + expect(paramToField(p({ type: 'textarea', widget: 'text' })).type).toBe('text'); + }); + + it('a registered widget on a targetless picker param is drawn, and the param does not degrade', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const param = p({ type: 'lookup', widget: 'permission-ref' }); + expect(paramToField(param).type).toBe('permission-ref'); + expect(paramDegradesWithoutTarget(param)).toBe(false); + expect(warn).not.toHaveBeenCalled(); + // CONTROL: an unregistered widget leaves the degradation exactly as before. + const unregistered = p({ type: 'lookup', widget: 'no_such_widget' }); + expect(paramToField(unregistered).type).toBe('text'); + expect(paramDegradesWithoutTarget(unregistered)).toBe(true); + } finally { + warn.mockRestore(); + } + }); + + it('a widget that names a picker with no target degrades like the picker type does, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const param = p({ type: 'text', widget: 'lookup' }); + expect(paramDegradesWithoutTarget(param)).toBe(true); + expect(paramToField(param).type).toBe('text'); + expect(warn).toHaveBeenCalledTimes(1); + // CONTROL: with a target the named picker is drawn, and nothing warns. + warn.mockClear(); + const targeted = p({ type: 'text', widget: 'lookup', referenceTo: 'accounts' }); + expect(paramDegradesWithoutTarget(targeted)).toBe(false); + expect(paramToField(targeted).type).toBe('lookup'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/ui/packages/app-shell/src/utils/paramToField.ts b/ui/packages/app-shell/src/utils/paramToField.ts index 70b35303d54..213dfa49a68 100644 --- a/ui/packages/app-shell/src/utils/paramToField.ts +++ b/ui/packages/app-shell/src/utils/paramToField.ts @@ -17,7 +17,34 @@ import { EXPANDABLE_FIELD_TYPES, type ActionParamDef } from '@object-ui/core'; // beside the resolver that reads it first (objectui#12105). This adapter asks // the same question one step later, so it reads the same function — never a // local copy of the table. -import { resolveParamWidgetType } from '@object-ui/fields'; +import { resolveFormWidgetType, resolveParamWidgetType } from '@object-ui/fields'; + +/** + * What `resolveFormWidgetType` answers for a spelling nothing registers — its + * documented `text` fallback. Read by {@link authoredWidgetKey} alone. + */ +const UNKNOWN_SPELLING_WIDGET = 'text'; + +/** + * The widget key an authored `widget` names, or `undefined` when it names no + * registered widget (or the param authored none). + * + * `ActionParamDef.widget` is the spec's `ActionParamSchema.widget`, which + * shares `FieldSchema.widget`'s key, vocabulary and meaning: a REGISTERED name + * wins over `type` outright, and an unregistered one "degrades to the `type` + * renderer". So the answer is the shared resolver's own, read for its one + * documented fallback: a spelling it answers with `text`, when the author did + * not write `text`, named nothing — the same reading the console's `FormPage` + * makes of a field's `widget` (`namesRegisteredWidget` there). ⛔ No list of + * widgets lives here. A retired spelling resolves to itself, so its tombstone + * still refuses visibly rather than degrading. + */ +function authoredWidgetKey(param: ActionParamDef): string | undefined { + const widget = param.widget; + if (!widget) return undefined; + const key = resolveFormWidgetType(widget); + return key !== UNKNOWN_SPELLING_WIDGET || widget === UNKNOWN_SPELLING_WIDGET ? key : undefined; +} /** * Widget keys whose picker cannot query without an explicitly DECLARED target, @@ -60,7 +87,13 @@ const LOOKUP_WIDGET_TYPES = new Set(['lookup', 'master_detail']); * for why `user` must never degrade. */ export function paramDegradesWithoutTarget(param: ActionParamDef): boolean { - return LOOKUP_WIDGET_TYPES.has(resolveParamWidgetType(param.type)) && !param.referenceTo; + // Asked of the widget the param will actually DRAW: a registered authored + // `widget` replaces the `type` widget, so `{ type: 'lookup', widget: + // 'permission-ref' }` never degrades, while `{ type: 'text', widget: + // 'lookup' }` with no target does — it would otherwise mount a picker that + // has nothing to query. + const drawn = authoredWidgetKey(param) ?? resolveParamWidgetType(param.type); + return LOOKUP_WIDGET_TYPES.has(drawn) && !param.referenceTo; } /** @@ -81,7 +114,9 @@ export function paramDegradesWithoutTarget(param: ActionParamDef): boolean { * the user a box that wants a raw UUID. */ export function paramToField(param: ActionParamDef): Record { - let type = resolveParamWidgetType(param.type); + // An authored `widget` that names a registered widget draws the param; + // otherwise its `type` does (see {@link authoredWidgetKey}). + let type = authoredWidgetKey(param) ?? resolveParamWidgetType(param.type); if (paramDegradesWithoutTarget(param)) { if (process.env.NODE_ENV !== 'production') { console.warn( diff --git a/ui/packages/app-shell/src/utils/resolveActionParams.test.ts b/ui/packages/app-shell/src/utils/resolveActionParams.test.ts index 4dfe0035083..a2e9664fa76 100644 --- a/ui/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/ui/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -513,3 +513,96 @@ describe('resolveActionParams — field-inherited option keys (objectui#3559)', expect([noValue, noLabel]).toHaveLength(2); }); }); + +describe('resolveActionParams — the param `widget` reaches the resolved param (#30260)', () => { + // `ActionParamSchema.widget` shares `FieldSchema.widget`'s key and meaning. + // The resolver builds its output key by key, so a key it does not copy never + // reaches the dialog — the state before this card, measured: every branch + // below answered `widget: undefined`. + const grantCtx = (): ResolveActionParamsContext => + ctx({ + objects: [ + { + name: 'sys_user', + fields: { + permission_set: { type: 'text', label: 'Permission set', widget: 'permission-ref' } as any, + plain: { type: 'text', label: 'Plain' }, + account: { type: 'lookup', label: 'Account', reference: 'account', widget: 'record-ref' } as any, + }, + }, + ], + }); + + it('copies an inline param widget onto the resolved param', () => { + const [out] = resolveActionParams( + [{ name: 'permissionSet', type: 'text', widget: 'permission-ref' }], + grantCtx(), + ); + expect(out.widget).toBe('permission-ref'); + // `type` stays the value's type: the widget only changes how it is picked. + expect(out.type).toBe('text'); + }); + + it('inherits the field widget on a field-backed param that inherits the type', () => { + const [out] = resolveActionParams([{ field: 'permission_set' }], grantCtx()); + expect(out.widget).toBe('permission-ref'); + expect(out.type).toBe('text'); + }); + + it('inherits it too when the param restates the field type', () => { + const [out] = resolveActionParams([{ field: 'permission_set', type: 'text' }], grantCtx()); + expect(out.widget).toBe('permission-ref'); + }); + + it('inherits it when the param restates the field type in an alias spelling', () => { + // `reference` is the param dialect's alias of `lookup` (PARAM_TYPE_ALIASES). + const [out] = resolveActionParams([{ field: 'account', type: 'reference' }], grantCtx()); + expect(out.widget).toBe('record-ref'); + // CONTROL: a genuinely different type still does not inherit it. + const [other] = resolveActionParams([{ field: 'account', type: 'text' }], grantCtx()); + expect(other.widget).toBeUndefined(); + }); + + it('does NOT inherit the field widget onto a param that overrides the type', () => { + const [out] = resolveActionParams([{ field: 'permission_set', type: 'textarea' }], grantCtx()); + expect(out.widget).toBeUndefined(); + expect(out.type).toBe('textarea'); + }); + + it('lets the param widget win over the field widget', () => { + const [out] = resolveActionParams( + [{ field: 'permission_set', widget: 'position-ref' }], + grantCtx(), + ); + expect(out.widget).toBe('position-ref'); + }); + + it('CONTROL: a param and field that declare no widget resolve none', () => { + const [inline, backed] = resolveActionParams( + [{ name: 'note', type: 'text' }, { field: 'plain' }], + grantCtx(), + ); + expect(inline.widget).toBeUndefined(); + expect(backed.widget).toBeUndefined(); + }); + + it('keeps the param widget on the missing-field fallback branch', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [out] = resolveActionParams( + [{ field: 'no_such_field', widget: 'permission-ref' }], + grantCtx(), + ); + expect(out.unresolvedField).toBe('sys_user.no_such_field'); + expect(out.widget).toBe('permission-ref'); + } finally { + warn.mockRestore(); + } + }); + + it('accepts the widget through the public ActionParam authoring type', () => { + const authored: ActionParam[] = [{ name: 'permissionSet', type: 'text', widget: 'permission-ref' }]; + const [out] = resolveActionParams(authored as RawActionParam[], grantCtx()); + expect(paramToField(out).type).toBe('permission-ref'); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.test.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.test.tsx index 419f0f56349..ff5c15a1143 100644 --- a/ui/packages/app-shell/src/views/ActionParamDialog.test.tsx +++ b/ui/packages/app-shell/src/views/ActionParamDialog.test.tsx @@ -30,6 +30,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import type { ActionParamDef } from '@object-ui/core'; import { validateActionParams } from '@objectstack/spec/ui'; +import { SchemaRendererProvider } from '@object-ui/react'; import { ActionParamDialog, filterVisibleParams, serializeParamValues } from './ActionParamDialog'; import { expectedParamShape, classifyValueShape } from '../utils/paramValueShape'; @@ -815,3 +816,68 @@ describe('serializeParamValues', () => { }); }); }); + +describe('ActionParamDialog — a param `widget` draws the named widget (#30260)', () => { + // The Create User shape once its `permissionSet` param declares the picker + // (`ActionParamSchema.widget`, ADR-0131 D4): a `text` param whose value is a + // permission-set NAME. The registry pickers read their catalog through the + // data source of `SchemaRendererContext`, so the dialog is mounted inside the + // provider, as the console mounts it. + function openWithCatalog(params: ActionParamDef[]) { + const resolve = vi.fn(); + const ds = { + listMetadataItems: vi.fn(async (_type: string) => [ + { name: 'sales_user', label: 'Sales User' }, + { name: 'support_agent', label: 'Support Agent' }, + ]), + }; + const view = render( + + {}} /> + , + ); + return { resolve, ds, ...view }; + } + + it('draws the permission-set picker: labels listed, the machine name submitted', async () => { + const param = def({ name: 'permissionSet', label: 'Permission set', type: 'text', widget: 'permission-ref' }); + const { resolve, ds } = openWithCatalog([param]); + + const trigger = await screen.findByRole('combobox'); + await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledWith('permission')); + fireEvent.click(trigger); + fireEvent.click(await screen.findByText('Support Agent (support_agent)')); + await waitFor(() => expect(trigger).toHaveTextContent('Support Agent (support_agent)')); + + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ permissionSet: 'support_agent' })); + // The emitted value matches the declared shape contract for the widget. + expect(expectedParamShape(param)).toBe('string'); + expect(classifyValueShape(resolve.mock.calls[0][0].permissionSet)).toBe('string'); + }); + + it('CONTROL: the same param without a widget is a text box, and lists nothing', async () => { + const { resolve, ds } = openWithCatalog([ + def({ name: 'permissionSet', label: 'Permission set', type: 'text' }), + ]); + const input = await screen.findByLabelText('Permission set'); + expect(input.tagName).toBe('INPUT'); + expect(screen.queryByRole('combobox')).toBeNull(); + expect(ds.listMetadataItems).not.toHaveBeenCalled(); + fireEvent.change(input, { target: { value: 'typed_name' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ permissionSet: 'typed_name' })); + }); + + it('an unregistered widget name falls back to the `type` control', async () => { + const { resolve, ds } = openWithCatalog([ + def({ name: 'reason', label: 'Reason', type: 'textarea', widget: 'no_such_widget' }), + ]); + const box = await screen.findByLabelText('Reason'); + expect(box.tagName).toBe('TEXTAREA'); + expect(ds.listMetadataItems).not.toHaveBeenCalled(); + fireEvent.change(box, { target: { value: 'because' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ reason: 'because' })); + }); +}); diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 76bfe04684f..ef6fd8498ce 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -884,6 +884,19 @@ export interface ActionParamDef { // `ActionParamDialog` renders every param through the same field widgets // the object form uses (ADR-0059), so params carry the widget-relevant // config for their type. Mirrors `ActionParamSchema` in @objectstack/spec. + /** + * Widget override — `@objectstack/spec`'s `ActionParamSchema.widget`, the + * same key, vocabulary and meaning as `FieldSchema.widget`: it names a + * registered field widget that draws this param instead of the `type` + * default (`'permission-ref'` on a `text` param picks a permission set by + * its machine name, ADR-0131 D4). `type` stays the VALUE's type. + * + * Set by `resolveActionParams()` — the param's own `widget`, else, for a + * field-backed param that also inherits or restates its `type`, the field's. Read by + * app-shell's `paramToField()`, which draws the named widget when it is + * registered and the `type` widget when it is not. + */ + widget?: string; /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */ accept?: string[]; /** Max upload size in bytes for `file`/`image` params. */ diff --git a/ui/packages/fields/src/resolveActionParams.ts b/ui/packages/fields/src/resolveActionParams.ts index 8e26b92cd2e..960797fa1eb 100644 --- a/ui/packages/fields/src/resolveActionParams.ts +++ b/ui/packages/fields/src/resolveActionParams.ts @@ -162,6 +162,14 @@ export interface RawActionParam { */ label?: I18nLabel; type?: string; + /** + * Widget override — the spec's `ActionParamSchema.widget`, the same key and + * meaning as `FieldSchema.widget`: a registered field widget that draws the + * param instead of its `type` default (`'permission-ref'` on a `text` + * param). Copied onto `ActionParamDef.widget`; `paramToField()` decides + * whether it names a registered widget. + */ + widget?: string; required?: boolean; /** * Option list authored inline on the param. Speaks the same vocabulary as the @@ -355,6 +363,12 @@ function warnResolvedOnlyKeys(param: RawActionParam): void { /** Field metadata as exposed by `useMetadata().objects[].fields`. */ interface RuntimeField { type?: string; + /** + * `@objectstack/spec`'s `FieldSchema.widget`, the field's input override. + * A field-backed param that inherits the field's `type` inherits this with + * it (see {@link inheritedWidget}). + */ + widget?: string; label?: string; required?: boolean; placeholder?: string; @@ -572,6 +586,32 @@ function storedRow( return toPredicateRecord(row, rowObject?.fields); } +/** + * The widget a FIELD-BACKED param is drawn with: its own `widget`, else the + * field's — but the field's only while the param also inherits the field's + * `type`. + * + * The spec resolves a field-backed param's "widget mapping from object + * metadata" (`ActionParamSchema`), and the field's `widget` is that mapping. + * (A param that restates the field's own `type`, in any spelling the param-type + * fold maps onto the same widget, still inherits it.) + * It is a mapping FOR the field's type, though: the record form draws it over + * a value of that type, and the server validates the param's value by the + * param's `type` (ADR-0104 D2). A param that overrides `type` asks for a + * different value, so a widget picked for the field's value is not inherited + * onto it; the param names its own `widget` if it wants one. + */ +function inheritedWidget(param: RawActionParam, field: RuntimeField): string | undefined { + if (param.widget !== undefined) return param.widget; + // Compared through the one param-type fold, so a param that restates the + // field's type in an alias spelling (`reference` on a `lookup` field) still + // restates it. + if (param.type === undefined) return field.widget; + const restated = field.type !== undefined + && resolveParamWidgetType(param.type) === resolveParamWidgetType(field.type); + return restated ? field.widget : undefined; +} + /** * Resolve a single raw param against object metadata. Inline params pass * through (with safe defaults); field-backed params inherit from the @@ -622,6 +662,9 @@ export function resolveActionParam( // copied here never reaches the dialog — which is how `carryOver` went // unhonoured until objectui#6246. Same line on the two branches below. carryOver: param.carryOver, + // Same key-by-key copy: without this line an authored `widget` never + // reached the dialog, which drew the `type` widget whatever it named. + widget: param.widget, multiple: param.multiple, accept: param.accept, maxSize: param.maxSize, @@ -679,6 +722,9 @@ export function resolveActionParam( defaultValue: rowDefault ?? param.defaultValue, visible: normaliseVisible(param.visible), carryOver: param.carryOver, + // Kept for parity; the dialog refuses an unresolved param before it + // builds any widget, so nothing draws it. + widget: param.widget, multiple: param.multiple, accept: param.accept, maxSize: param.maxSize, @@ -845,6 +891,7 @@ export function resolveActionParam( visible: normaliseVisible(param.visible), // A declaration of the PARAM, never inherited from the field. carryOver: param.carryOver, + widget: inheritedWidget(param, field), // Widget config inherited from the field for every type (not just // lookup): multi-value shape and upload constraints (ADR-0059). multiple: param.multiple ?? field.multiple,