|
| 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. |
0 commit comments