Skip to content

Commit 1c8935a

Browse files
os-zhuangclaude
andauthored
feat(app-shell): render ActionParamDialog params through the shared form field-widget renderer (#2700, ADR-0059) (#2704)
Retire the bespoke per-type branch chain in ActionParamDialog. Every declared action param now renders through the same fieldWidgetMap the object form uses, so a param of ANY form-supported field type (file, image, richtext, color, address, code, date, ...) gets its real widget — lazily, behind Suspense — instead of collapsing to a text input. Subsumes the single `file` branch ask in #2698 (upload via the ambient UploadProvider, multiple/accept/maxSize honored). - fields: export resolveFormWidgetType() + getLazyFieldWidget() (per-type cached React.lazy over the form's own widget loaders). - core: ActionParamDef gains accept/maxSize; multiple is general widget config (was lookup-only). - app-shell: pure paramToField() adapter (param -> field shape, with explicit param-only fallbacks: legacy aliases, lookup-without-referenceTo -> text); resolveActionParams() inherits multiple/accept/maxSize from the referenced field for every type; required validation, visible CEL gating, helpText, error styling, and value shapes for previously-supported types unchanged. - tests: dialog behavior net across text/textarea/number/boolean/select/date/ file/color/unknown + required blocking + defaultValue round-trip; drift test pinning param support ⊇ FORM_FIELD_TYPES (mirrors the FieldEditWidget parity guard). - docs: ADR-0059, enhanced-actions "Action Params" section, app-shell/fields READMEs, objectui skill protocol rule; changeset (minor). Claude-Session: https://claude.ai/code/session_01Rg7oavKSUdb2KzYEEYrCHX Co-authored-by: Claude <noreply@anthropic.com>
1 parent f80aaf2 commit 1c8935a

14 files changed

Lines changed: 787 additions & 109 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@object-ui/fields": minor
3+
"@object-ui/core": minor
4+
"@object-ui/app-shell": minor
5+
---
6+
7+
feat(app-shell): render ActionParamDialog params through the shared form field-widget renderer (ADR-0059, #2700)
8+
9+
`ActionParamDialog` no longer hand-rolls a per-type ternary chain (select /
10+
lookup / textarea / number / boolean, everything else → text input). Every
11+
declared action param now renders through the same `fieldWidgetMap` the object
12+
form uses, so a param of ANY form-supported field type — `file`, `image`,
13+
`richtext`, `markdown`, `color`, `address`, `code`, `date`, … — gets its real
14+
widget, lazily loaded behind `Suspense`. Subsumes the single `file` branch ask
15+
in #2698: `type: 'file'` params render the real `FileField` upload control via
16+
the ambient `UploadProvider`, honoring `multiple`/`accept`/`maxSize`.
17+
18+
- `@object-ui/fields`: new exports `resolveFormWidgetType(type)` (widget-key
19+
resolution incl. spec aliases, text fallback) and `getLazyFieldWidget(type)`
20+
(per-type-cached `React.lazy` over the form's own widget loaders).
21+
- `@object-ui/core`: `ActionParamDef` gains `accept`/`maxSize`; `multiple` is
22+
now general widget config (was lookup-only).
23+
- `@object-ui/app-shell`: new pure `paramToField()` adapter (param → field
24+
shape) with a drift test pinning param support ⊇ form support (`FORM_FIELD_TYPES`),
25+
mirroring the FieldEditWidget parity guard; `resolveActionParams()` inherits
26+
`multiple`/`accept`/`maxSize` from the referenced field for every type.
27+
`required` validation, `visible` CEL gating, helpText, error styling, and
28+
value shapes for previously-supported types are unchanged.

content/docs/core/enhanced-actions.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,44 @@ const dialogAction: ActionSchema = {
131131
};
132132
```
133133

134+
## Action Params (input collection)
135+
136+
An action may declare `params` (spec `ActionParamSchema`) to collect user input
137+
in a dialog before it runs. Each param renders through the **same field-widget
138+
renderer the object form uses**, so a param of *any* form-supported field type —
139+
`select`, `lookup`, `date`, `file`, `image`, `richtext`, `color`, `address`, … —
140+
gets its real widget, not a text box (ADR-0059):
141+
142+
```json
143+
{
144+
"name": "approve",
145+
"label": "Approve",
146+
"params": [
147+
{ "name": "comment", "type": "textarea", "label": "Comment", "required": true },
148+
{ "name": "attachments", "type": "file", "multiple": true, "accept": ["application/pdf"] },
149+
{ "name": "assignee", "field": "owner_id" },
150+
{ "name": "notify", "type": "boolean", "label": "Notify the requester", "defaultValue": true }
151+
]
152+
}
153+
```
154+
155+
- **Inline params** declare `name` + `type` (any spec `FieldType`), plus widget
156+
config: `options`, `multiple`, `accept`, `maxSize`, `placeholder`,
157+
`helpText`, `defaultValue`.
158+
- **Field-backed params** declare `field` (+ optional `objectOverride`) and
159+
inherit label, type, options, lookup picker config, `multiple`, `accept`,
160+
and `maxSize` from the object's field definition; inline properties override.
161+
- `required` blocks submit while the value is empty; `visible` (a CEL
162+
predicate over `features` / `current_user` / `app` / `data`) hides a param
163+
entirely — e.g. gate a param on an opt-in server capability.
164+
- Values are passed through to the action exactly as the widget emits them
165+
(`number` → number, `date``YYYY-MM-DD`, lookup → record id(s), `file`
166+
uploaded file descriptor(s); arrays when `multiple`).
167+
168+
File/image params upload through the ambient `UploadProvider`; lookup/user
169+
params query through the surrounding `SchemaRendererContext` data source — no
170+
extra wiring per action.
171+
134172
## Action Chaining
135173

136174
Execute multiple actions in sequence or parallel:
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# ADR-0059: Action params render through the shared form field-widget renderer
2+
3+
**Status**: Accepted — implemented (2026-07-19)
4+
**Author**: ObjectUI renderer team
5+
**Consumers**: `@object-ui/app-shell` (`ActionParamDialog`, `resolveActionParams`), `@object-ui/fields` (`resolveFormWidgetType` / `getLazyFieldWidget`), `@object-ui/core` (`ActionParamDef`), `@objectstack/spec` (`ActionParamSchema`), every host of declared object actions (ObjectView / RecordDetailView / DeclaredActionsBar / approvals inbox)
6+
**Companion to**: the `FieldEditWidget``FORM_FIELD_TYPES` drift guard (inline-editor parity) — this applies the same "one widget surface, pinned by a drift test" philosophy to action params. Resolves objectui#2700; generalizes the single-type ask in objectui#2698.
7+
8+
---
9+
10+
## TL;DR
11+
12+
`ActionParamDialog` was a **bespoke, hand-rolled form**: a manual ternary chain
13+
over `param.type` with five branches (`select`, `lookup`, `textarea`, `number`,
14+
boolean) and a plain text `Input` fallback for **everything else**. Every rich
15+
type a designer might declare on an action param — `file`, `image`, `richtext`,
16+
`markdown`, `color`, `address`, `code`, … — silently collapsed to a text box,
17+
and each fix (e.g. the `file` branch proposed by #2698) would have been another
18+
one-off branch trailing the form surface forever.
19+
20+
Meanwhile the object **form** already renders every one of these through
21+
`fieldWidgetMap` (`@object-ui/fields`, keys frozen as `FORM_FIELD_TYPES`),
22+
lazy-loaded and registered via `ComponentRegistry`.
23+
24+
**Decision: the dialog now renders every param through that same widget map.**
25+
A pure `paramToField()` adapter translates the resolved `ActionParamDef` into
26+
the `{ name, type, ...config }` field shape `FieldWidgetProps.field` expects,
27+
and the dialog mounts the widget returned by `getLazyFieldWidget(type)` behind
28+
`<Suspense>`. A drift test pins **param support ⊇ form support**, so the two
29+
surfaces can never silently diverge again.
30+
31+
## Decision
32+
33+
1. **`@object-ui/fields` exports the resolution + lazy loading**
34+
- `resolveFormWidgetType(type)` — widget-map keys resolve to themselves;
35+
spec aliases (`toggle`, `json`, `secret`, `tree`, `repeater`, …) resolve
36+
through `mapFieldTypeToFormType`; unknown types fall back to `text` (the
37+
form's own fallback).
38+
- `getLazyFieldWidget(type)` — the widget wrapped in `React.lazy`, cached
39+
per type. Shares the exact loaders `registerField()` uses for forms, so
40+
the dialog adds **zero** eager widget weight to the bundle.
41+
42+
2. **`paramToField()` (app-shell) is the whole translation layer** — pure and
43+
unit-tested, mirroring `filterVisibleParams`' style. It carries options,
44+
`multiple`, upload `accept`/`maxSize`, and the full lookup picker config
45+
(`referenceTo``reference_to`, …) that `resolveActionParams()` copies from
46+
the underlying object field. `resolveActionParams()` now inherits
47+
`multiple`/`accept`/`maxSize` from the referenced field for **every** type,
48+
not just lookup; `ActionParamSchema` in `@objectstack/spec` gained the
49+
matching optional keys for inline params.
50+
51+
3. **Param semantics stay in the dialog, not the widgets**: `required`
52+
validation (`isMissingValue`), `visible` CEL gating (`usePredicateScope` +
53+
`ExpressionEvaluator`), label/error/help chrome, and i18n option-label
54+
localization are unchanged. Widgets receive `{ value, onChange, field }`
55+
and nothing else.
56+
57+
4. **Ambient context, no adapter threading**`UploadProvider` (file/image)
58+
and `SchemaRendererContext` (dataSource for lookup/user pickers) come from
59+
the host view, exactly as the dialog's previous `LookupField` reuse relied
60+
on.
61+
62+
5. **Param-only fallbacks are explicit and few**:
63+
- `checkbox` / `reference` / `datetime-local` / `autonumber` — legacy param
64+
spellings folded onto canonical widget keys (`PARAM_TYPE_ALIASES`).
65+
- a `lookup`/`reference` param with **no `referenceTo`** target renders a
66+
text input with the "paste an ID" hint (a picker cannot query without a
67+
target object) — the dialog's long-standing partial-metadata behavior.
68+
- boolean params render the shared `BooleanField` with `widget: 'checkbox'`
69+
in the dialog's inline label row (params opt into confirm-style checkbox
70+
UX, not the form's switch).
71+
72+
## Why not the inline-edit path
73+
74+
`FieldEditWidget` (grid inline editing) deliberately excludes heavy/binary
75+
types (`INLINE_EXCLUDED_FIELD_TYPES`: `file`, `image`, `richtext`, …) because a
76+
grid cell can't host them. A dialog can — so the **form** widget surface is the
77+
right one to reuse, and the param dialog intentionally supports the full
78+
`FORM_FIELD_TYPES` set.
79+
80+
## The drift guard
81+
82+
`packages/app-shell/src/utils/paramToField.test.ts` asserts that every type in
83+
`FORM_FIELD_TYPES` resolves to **its own widget** through
84+
`resolveParamWidgetType` (identity — never the text fallback). Adding a new
85+
widget type to `fieldWidgetMap` automatically extends the param dialog; removing
86+
or special-casing one fails CI. This mirrors the `FieldEditWidget`
87+
`FORM_FIELD_TYPES` drift test that caught `lookup` falling back to a text box
88+
inline.
89+
90+
## Value shapes
91+
92+
Widgets emit their own value shapes and the dialog passes them through
93+
untouched to the action runner (`resolve(values)`), exactly as before for the
94+
previously-supported types (`select` → string, `number` → number, `boolean`
95+
boolean, `date``YYYY-MM-DD`, lookup → id / id[]). New types follow their
96+
widget's contract — e.g. `file` → uploaded-file descriptor(s)
97+
(`{ name, url, … }`, array when `multiple`). Endpoint authors declare params
98+
with the shape their route expects, same as record forms.
99+
100+
## Consequences
101+
102+
- A declared action param of **any** form-supported field type renders its real
103+
widget for free; "param type X falls through to a text box" is no longer a
104+
reachable bug class.
105+
- objectui#2698's `file` param need is subsumed: `type: 'file'` params render
106+
the real `FileField` upload control (ambient `UploadProvider`), honoring
107+
`multiple`/`accept`/`maxSize` — unblocking attachment-carrying declared
108+
actions and the approvals composer retirement.
109+
- The dialog's per-type branches are deleted; future field types cost zero
110+
dialog work.
111+
- Bundle stays lazy: opening a param dialog loads only the widget chunks its
112+
params actually use.

packages/app-shell/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,18 @@ Renders custom page schemas.
114114
/>
115115
```
116116

117+
### ActionParamDialog
118+
119+
Collects user input for a declared action's `params` before execution. Every
120+
param renders through the shared form field-widget renderer from
121+
`@object-ui/fields` (`getLazyFieldWidget`), so a param of any form-supported
122+
field type — `select`, `lookup`, `date`, `file`, `image`, `richtext`, `color`,
123+
… — gets its real widget instead of a text-input fallback (ADR-0059). The pure
124+
`paramToField()` adapter owns the param → field translation, and a drift test
125+
pins param support ⊇ form support. `required` validation and `visible` CEL
126+
gating are applied by the dialog; file/image uploads use the ambient
127+
`UploadProvider`, lookup/user pickers the surrounding `SchemaRendererContext`.
128+
117129
## Metadata designers
118130

119131
The metadata-admin engine (`src/views/metadata-admin`) renders an in-app editor
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* paramToField — the param → field adapter behind ActionParamDialog's shared
11+
* field-widget rendering (ADR-0059), plus the drift guard pinning param
12+
* support ⊇ form support. The dialog used to hand-roll a per-type ternary
13+
* chain, so every form type without its own branch (`file`, `image`,
14+
* `richtext`, `color`, …) silently collapsed to a text box; routing through
15+
* `FORM_FIELD_TYPES` + this drift test makes that class of bug impossible to
16+
* reintroduce silently.
17+
*/
18+
import { describe, it, expect } from 'vitest';
19+
import { FORM_FIELD_TYPES } from '@object-ui/fields';
20+
import type { ActionParamDef } from '@object-ui/core';
21+
import { paramToField, resolveParamWidgetType } from './paramToField';
22+
23+
const p = (over: Partial<ActionParamDef>): ActionParamDef => ({
24+
name: 'x',
25+
label: 'X',
26+
type: 'text',
27+
...over,
28+
});
29+
30+
describe('param widget support ⊇ form widget support (drift guard)', () => {
31+
it('every form field type resolves to its own widget — never the text fallback', () => {
32+
// If this fails: a type was added to `fieldWidgetMap` that the param
33+
// dialog would degrade to another widget. The adapter resolves widget-map
34+
// keys by identity, so this can only regress if that resolution changes —
35+
// do not special-case types out without an alias entry here.
36+
const degraded = FORM_FIELD_TYPES.filter((t) => resolveParamWidgetType(t) !== t);
37+
expect(degraded).toEqual([]);
38+
});
39+
40+
it('legacy param-only spellings fold onto canonical widgets', () => {
41+
expect(resolveParamWidgetType('checkbox')).toBe('boolean');
42+
expect(resolveParamWidgetType('reference')).toBe('lookup');
43+
expect(resolveParamWidgetType('datetime-local')).toBe('datetime');
44+
expect(resolveParamWidgetType('autonumber')).toBe('auto_number');
45+
});
46+
47+
it('spec FieldType aliases resolve through the form mapping, unknown types fall back to text', () => {
48+
expect(resolveParamWidgetType('toggle')).toBe('boolean');
49+
expect(resolveParamWidgetType('json')).toBe('code');
50+
expect(resolveParamWidgetType('secret')).toBe('password');
51+
expect(resolveParamWidgetType('tree')).toBe('lookup');
52+
expect(resolveParamWidgetType('no-such-type')).toBe('text');
53+
});
54+
});
55+
56+
describe('paramToField', () => {
57+
it('maps the widget-relevant config for a plain param', () => {
58+
const field = paramToField(p({
59+
name: 'reason',
60+
label: 'Reason',
61+
type: 'textarea',
62+
required: true,
63+
placeholder: 'Why?',
64+
}));
65+
expect(field).toMatchObject({
66+
name: 'reason',
67+
label: 'Reason',
68+
type: 'textarea',
69+
required: true,
70+
placeholder: 'Why?',
71+
});
72+
});
73+
74+
it('carries options for select params', () => {
75+
const options = [{ label: 'A', value: 'a' }];
76+
expect(paramToField(p({ type: 'select', options }))).toMatchObject({ type: 'select', options });
77+
});
78+
79+
it('carries upload config (multiple/accept/maxSize) for file params', () => {
80+
const field = paramToField(p({
81+
type: 'file',
82+
multiple: true,
83+
accept: ['application/pdf'],
84+
maxSize: 5 * 1024 * 1024,
85+
}));
86+
expect(field).toMatchObject({
87+
type: 'file',
88+
multiple: true,
89+
accept: ['application/pdf'],
90+
maxSize: 5 * 1024 * 1024,
91+
});
92+
});
93+
94+
it('renders boolean params as a checkbox (dialog inline-row UX), not the form switch', () => {
95+
expect(paramToField(p({ type: 'boolean' }))).toMatchObject({ type: 'boolean', widget: 'checkbox' });
96+
expect(paramToField(p({ type: 'checkbox' }))).toMatchObject({ type: 'boolean', widget: 'checkbox' });
97+
});
98+
99+
it('maps the full lookup picker config to snake_case field metadata', () => {
100+
const field = paramToField(p({
101+
type: 'lookup',
102+
referenceTo: 'space_users',
103+
displayField: 'name',
104+
idField: 'id',
105+
descriptionField: 'email',
106+
multiple: true,
107+
titleFormat: '{first_name} {last_name}',
108+
lookupColumns: [{ field: 'name' }],
109+
lookupFilters: [{ field: 'active', operator: '=', value: true }],
110+
lookupPageSize: 25,
111+
dependsOn: ['org'],
112+
}));
113+
expect(field).toMatchObject({
114+
type: 'lookup',
115+
reference_to: 'space_users',
116+
display_field: 'name',
117+
id_field: 'id',
118+
description_field: 'email',
119+
multiple: true,
120+
title_format: '{first_name} {last_name}',
121+
lookup_columns: [{ field: 'name' }],
122+
lookup_filters: [{ field: 'active', operator: '=', value: true }],
123+
lookup_page_size: 25,
124+
depends_on: ['org'],
125+
});
126+
});
127+
128+
it('lookup param without a referenceTo target falls back to a text input (param-only fallback)', () => {
129+
expect(paramToField(p({ type: 'lookup' }))).toMatchObject({ type: 'text' });
130+
expect(paramToField(p({ type: 'reference' }))).toMatchObject({ type: 'text' });
131+
});
132+
133+
it('user params keep their picker without needing referenceTo (implicit sys_user)', () => {
134+
expect(paramToField(p({ type: 'user' }))).toMatchObject({ type: 'user' });
135+
});
136+
});

0 commit comments

Comments
 (0)