Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/30260-action-param-widget-reader.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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:<widget>`) 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:<widget>`) 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<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
| **placeholder** | `string` | optional | |
Expand Down Expand Up @@ -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<string, string>` | 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:<widget>`) 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:<widget>`) 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<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
| **placeholder** | `string` | optional | |
Expand Down Expand Up @@ -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<string, string>` | 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:<widget>`) 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:<widget>`) 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<string, string>; value: string; visibleWhen?: string \| object }[]` | optional | |
| **placeholder** | `string` | optional | |
Expand Down
34 changes: 20 additions & 14 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)', () => {
Expand Down
9 changes: 4 additions & 5 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
11 changes: 5 additions & 6 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<widget>`) '
+ '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
Expand Down
60 changes: 60 additions & 0 deletions ui/packages/app-shell/src/utils/paramToField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
Loading
Loading