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
28 changes: 28 additions & 0 deletions .changeset/action-params-shared-field-widgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@object-ui/fields": minor
"@object-ui/core": minor
"@object-ui/app-shell": minor
---

feat(app-shell): render ActionParamDialog params through the shared form field-widget renderer (ADR-0059, #2700)

`ActionParamDialog` no longer hand-rolls a per-type ternary chain (select /
lookup / textarea / number / boolean, everything else → text input). 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`, `markdown`, `color`, `address`, `code`, `date`, … — gets its real
widget, lazily loaded behind `Suspense`. Subsumes the single `file` branch ask
in #2698: `type: 'file'` params render the real `FileField` upload control via
the ambient `UploadProvider`, honoring `multiple`/`accept`/`maxSize`.

- `@object-ui/fields`: new exports `resolveFormWidgetType(type)` (widget-key
resolution incl. spec aliases, text fallback) and `getLazyFieldWidget(type)`
(per-type-cached `React.lazy` over the form's own widget loaders).
- `@object-ui/core`: `ActionParamDef` gains `accept`/`maxSize`; `multiple` is
now general widget config (was lookup-only).
- `@object-ui/app-shell`: new pure `paramToField()` adapter (param → field
shape) with a drift test pinning param support ⊇ form support (`FORM_FIELD_TYPES`),
mirroring the FieldEditWidget parity guard; `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 are unchanged.
38 changes: 38 additions & 0 deletions content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,44 @@ const dialogAction: ActionSchema = {
};
```

## Action Params (input collection)

An action may declare `params` (spec `ActionParamSchema`) to collect user input
in a dialog before it runs. Each param renders through the **same field-widget
renderer the object form uses**, so a param of *any* form-supported field type —
`select`, `lookup`, `date`, `file`, `image`, `richtext`, `color`, `address`, … —
gets its real widget, not a text box (ADR-0059):

```json
{
"name": "approve",
"label": "Approve",
"params": [
{ "name": "comment", "type": "textarea", "label": "Comment", "required": true },
{ "name": "attachments", "type": "file", "multiple": true, "accept": ["application/pdf"] },
{ "name": "assignee", "field": "owner_id" },
{ "name": "notify", "type": "boolean", "label": "Notify the requester", "defaultValue": true }
]
}
```

- **Inline params** declare `name` + `type` (any spec `FieldType`), plus widget
config: `options`, `multiple`, `accept`, `maxSize`, `placeholder`,
`helpText`, `defaultValue`.
- **Field-backed params** declare `field` (+ optional `objectOverride`) and
inherit label, type, options, lookup picker config, `multiple`, `accept`,
and `maxSize` from the object's field definition; inline properties override.
- `required` blocks submit while the value is empty; `visible` (a CEL
predicate over `features` / `current_user` / `app` / `data`) hides a param
entirely — e.g. gate a param on an opt-in server capability.
- Values are passed through to the action exactly as the widget emits them
(`number` → number, `date` → `YYYY-MM-DD`, lookup → record id(s), `file` →
uploaded file descriptor(s); arrays when `multiple`).

File/image params upload through the ambient `UploadProvider`; lookup/user
params query through the surrounding `SchemaRendererContext` data source — no
extra wiring per action.

## Action Chaining

Execute multiple actions in sequence or parallel:
Expand Down
112 changes: 112 additions & 0 deletions docs/adr/0059-action-params-shared-field-widgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# ADR-0059: Action params render through the shared form field-widget renderer

**Status**: Accepted — implemented (2026-07-19)
**Author**: ObjectUI renderer team
**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)
**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.

---

## TL;DR

`ActionParamDialog` was a **bespoke, hand-rolled form**: a manual ternary chain
over `param.type` with five branches (`select`, `lookup`, `textarea`, `number`,
boolean) and a plain text `Input` fallback for **everything else**. Every rich
type a designer might declare on an action param — `file`, `image`, `richtext`,
`markdown`, `color`, `address`, `code`, … — silently collapsed to a text box,
and each fix (e.g. the `file` branch proposed by #2698) would have been another
one-off branch trailing the form surface forever.

Meanwhile the object **form** already renders every one of these through
`fieldWidgetMap` (`@object-ui/fields`, keys frozen as `FORM_FIELD_TYPES`),
lazy-loaded and registered via `ComponentRegistry`.

**Decision: the dialog now renders every param through that same widget map.**
A pure `paramToField()` adapter translates the resolved `ActionParamDef` into
the `{ name, type, ...config }` field shape `FieldWidgetProps.field` expects,
and the dialog mounts the widget returned by `getLazyFieldWidget(type)` behind
`<Suspense>`. A drift test pins **param support ⊇ form support**, so the two
surfaces can never silently diverge again.

## Decision

1. **`@object-ui/fields` exports the resolution + lazy loading**
- `resolveFormWidgetType(type)` — widget-map keys resolve to themselves;
spec aliases (`toggle`, `json`, `secret`, `tree`, `repeater`, …) resolve
through `mapFieldTypeToFormType`; unknown types fall back to `text` (the
form's own fallback).
- `getLazyFieldWidget(type)` — the widget wrapped in `React.lazy`, cached
per type. Shares the exact loaders `registerField()` uses for forms, so
the dialog adds **zero** eager widget weight to the bundle.

2. **`paramToField()` (app-shell) is the whole translation layer** — pure and
unit-tested, mirroring `filterVisibleParams`' style. It carries options,
`multiple`, upload `accept`/`maxSize`, and the full lookup picker config
(`referenceTo` → `reference_to`, …) that `resolveActionParams()` copies from
the underlying object field. `resolveActionParams()` now inherits
`multiple`/`accept`/`maxSize` from the referenced field for **every** type,
not just lookup; `ActionParamSchema` in `@objectstack/spec` gained the
matching optional keys for inline params.

3. **Param semantics stay in the dialog, not the widgets**: `required`
validation (`isMissingValue`), `visible` CEL gating (`usePredicateScope` +
`ExpressionEvaluator`), label/error/help chrome, and i18n option-label
localization are unchanged. Widgets receive `{ value, onChange, field }`
and nothing else.

4. **Ambient context, no adapter threading** — `UploadProvider` (file/image)
and `SchemaRendererContext` (dataSource for lookup/user pickers) come from
the host view, exactly as the dialog's previous `LookupField` reuse relied
on.

5. **Param-only fallbacks are explicit and few**:
- `checkbox` / `reference` / `datetime-local` / `autonumber` — legacy param
spellings folded onto canonical widget keys (`PARAM_TYPE_ALIASES`).
- a `lookup`/`reference` param with **no `referenceTo`** target renders a
text input with the "paste an ID" hint (a picker cannot query without a
target object) — the dialog's long-standing partial-metadata behavior.
- boolean params render the shared `BooleanField` with `widget: 'checkbox'`
in the dialog's inline label row (params opt into confirm-style checkbox
UX, not the form's switch).

## Why not the inline-edit path

`FieldEditWidget` (grid inline editing) deliberately excludes heavy/binary
types (`INLINE_EXCLUDED_FIELD_TYPES`: `file`, `image`, `richtext`, …) because a
grid cell can't host them. A dialog can — so the **form** widget surface is the
right one to reuse, and the param dialog intentionally supports the full
`FORM_FIELD_TYPES` set.

## The drift guard

`packages/app-shell/src/utils/paramToField.test.ts` asserts that every type in
`FORM_FIELD_TYPES` resolves to **its own widget** through
`resolveParamWidgetType` (identity — never the text fallback). Adding a new
widget type to `fieldWidgetMap` automatically extends the param dialog; removing
or special-casing one fails CI. This mirrors the `FieldEditWidget` ↔
`FORM_FIELD_TYPES` drift test that caught `lookup` falling back to a text box
inline.

## Value shapes

Widgets emit their own value shapes and the dialog passes them through
untouched to the action runner (`resolve(values)`), exactly as before for the
previously-supported types (`select` → string, `number` → number, `boolean` →
boolean, `date` → `YYYY-MM-DD`, lookup → id / id[]). New types follow their
widget's contract — e.g. `file` → uploaded-file descriptor(s)
(`{ name, url, … }`, array when `multiple`). Endpoint authors declare params
with the shape their route expects, same as record forms.

## Consequences

- A declared action param of **any** form-supported field type renders its real
widget for free; "param type X falls through to a text box" is no longer a
reachable bug class.
- objectui#2698's `file` param need is subsumed: `type: 'file'` params render
the real `FileField` upload control (ambient `UploadProvider`), honoring
`multiple`/`accept`/`maxSize` — unblocking attachment-carrying declared
actions and the approvals composer retirement.
- The dialog's per-type branches are deleted; future field types cost zero
dialog work.
- Bundle stays lazy: opening a param dialog loads only the widget chunks its
params actually use.
12 changes: 12 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ Renders custom page schemas.
/>
```

### ActionParamDialog

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

## Metadata designers

The metadata-admin engine (`src/views/metadata-admin`) renders an in-app editor
Expand Down
136 changes: 136 additions & 0 deletions packages/app-shell/src/utils/paramToField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* paramToField — the param → field adapter behind ActionParamDialog's shared
* field-widget rendering (ADR-0059), plus the drift guard pinning param
* support ⊇ form support. The dialog used to hand-roll a per-type ternary
* chain, so every form type without its own branch (`file`, `image`,
* `richtext`, `color`, …) silently collapsed to a text box; routing through
* `FORM_FIELD_TYPES` + this drift test makes that class of bug impossible to
* reintroduce silently.
*/
import { describe, it, expect } from 'vitest';
import { FORM_FIELD_TYPES } from '@object-ui/fields';
import type { ActionParamDef } from '@object-ui/core';
import { paramToField, resolveParamWidgetType } from './paramToField';

const p = (over: Partial<ActionParamDef>): ActionParamDef => ({
name: 'x',
label: 'X',
type: 'text',
...over,
});

describe('param widget support ⊇ form widget support (drift guard)', () => {
it('every form field type resolves to its own widget — never the text fallback', () => {
// If this fails: a type was added to `fieldWidgetMap` that the param
// dialog would degrade to another widget. The adapter resolves widget-map
// keys by identity, so this can only regress if that resolution changes —
// do not special-case types out without an alias entry here.
const degraded = FORM_FIELD_TYPES.filter((t) => resolveParamWidgetType(t) !== t);
expect(degraded).toEqual([]);
});

it('legacy param-only spellings fold onto canonical widgets', () => {
expect(resolveParamWidgetType('checkbox')).toBe('boolean');
expect(resolveParamWidgetType('reference')).toBe('lookup');
expect(resolveParamWidgetType('datetime-local')).toBe('datetime');
expect(resolveParamWidgetType('autonumber')).toBe('auto_number');
});

it('spec FieldType aliases resolve through the form mapping, unknown types fall back to text', () => {
expect(resolveParamWidgetType('toggle')).toBe('boolean');
expect(resolveParamWidgetType('json')).toBe('code');
expect(resolveParamWidgetType('secret')).toBe('password');
expect(resolveParamWidgetType('tree')).toBe('lookup');
expect(resolveParamWidgetType('no-such-type')).toBe('text');
});
});

describe('paramToField', () => {
it('maps the widget-relevant config for a plain param', () => {
const field = paramToField(p({
name: 'reason',
label: 'Reason',
type: 'textarea',
required: true,
placeholder: 'Why?',
}));
expect(field).toMatchObject({
name: 'reason',
label: 'Reason',
type: 'textarea',
required: true,
placeholder: 'Why?',
});
});

it('carries options for select params', () => {
const options = [{ label: 'A', value: 'a' }];
expect(paramToField(p({ type: 'select', options }))).toMatchObject({ type: 'select', options });
});

it('carries upload config (multiple/accept/maxSize) for file params', () => {
const field = paramToField(p({
type: 'file',
multiple: true,
accept: ['application/pdf'],
maxSize: 5 * 1024 * 1024,
}));
expect(field).toMatchObject({
type: 'file',
multiple: true,
accept: ['application/pdf'],
maxSize: 5 * 1024 * 1024,
});
});

it('renders boolean params as a checkbox (dialog inline-row UX), not the form switch', () => {
expect(paramToField(p({ type: 'boolean' }))).toMatchObject({ type: 'boolean', widget: 'checkbox' });
expect(paramToField(p({ type: 'checkbox' }))).toMatchObject({ type: 'boolean', widget: 'checkbox' });
});

it('maps the full lookup picker config to snake_case field metadata', () => {
const field = paramToField(p({
type: 'lookup',
referenceTo: 'space_users',
displayField: 'name',
idField: 'id',
descriptionField: 'email',
multiple: true,
titleFormat: '{first_name} {last_name}',
lookupColumns: [{ field: 'name' }],
lookupFilters: [{ field: 'active', operator: '=', value: true }],
lookupPageSize: 25,
dependsOn: ['org'],
}));
expect(field).toMatchObject({
type: 'lookup',
reference_to: 'space_users',
display_field: 'name',
id_field: 'id',
description_field: 'email',
multiple: true,
title_format: '{first_name} {last_name}',
lookup_columns: [{ field: 'name' }],
lookup_filters: [{ field: 'active', operator: '=', value: true }],
lookup_page_size: 25,
depends_on: ['org'],
});
});

it('lookup param without a referenceTo target falls back to a text input (param-only fallback)', () => {
expect(paramToField(p({ type: 'lookup' }))).toMatchObject({ type: 'text' });
expect(paramToField(p({ type: 'reference' }))).toMatchObject({ type: 'text' });
});

it('user params keep their picker without needing referenceTo (implicit sys_user)', () => {
expect(paramToField(p({ type: 'user' }))).toMatchObject({ type: 'user' });
});
});
Loading
Loading