diff --git a/.changeset/action-params-shared-field-widgets.md b/.changeset/action-params-shared-field-widgets.md new file mode 100644 index 000000000..8abd444b9 --- /dev/null +++ b/.changeset/action-params-shared-field-widgets.md @@ -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. diff --git a/content/docs/core/enhanced-actions.mdx b/content/docs/core/enhanced-actions.mdx index 14bbd24fa..2f83f04df 100644 --- a/content/docs/core/enhanced-actions.mdx +++ b/content/docs/core/enhanced-actions.mdx @@ -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: diff --git a/docs/adr/0059-action-params-shared-field-widgets.md b/docs/adr/0059-action-params-shared-field-widgets.md new file mode 100644 index 000000000..85b41ca6a --- /dev/null +++ b/docs/adr/0059-action-params-shared-field-widgets.md @@ -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 +``. 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. diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index ca7ae3b23..77cf3ea36 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -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 diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts new file mode 100644 index 000000000..64295f195 --- /dev/null +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -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 => ({ + 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' }); + }); +}); diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts new file mode 100644 index 000000000..4c18b04a0 --- /dev/null +++ b/packages/app-shell/src/utils/paramToField.ts @@ -0,0 +1,95 @@ +/** + * paramToField — pure adapter from a resolved `ActionParamDef` to the + * `{ name, type, ...config }` field shape the shared form field widgets + * (`@object-ui/fields`) consume. + * + * `ActionParamDialog` renders every param through the same field-widget + * renderer the object form uses (`fieldWidgetMap` / `FORM_FIELD_TYPES`), so a + * declared action param of ANY form-supported field type — `file`, `image`, + * `richtext`, `color`, `address`, … — gets its real widget instead of + * collapsing to a text input (ADR-0059). This module is the whole translation + * layer: pure and exported so the mapping is unit-testable without the dialog + * render tree (mirrors `filterVisibleParams`' style), with a drift test + * asserting param support ⊇ form support. + */ +import type { ActionParamDef } from '@object-ui/core'; +import { resolveFormWidgetType } from '@object-ui/fields'; + +/** + * Param-only type spellings the dialog historically accepted, folded onto the + * canonical form widget vocabulary. These are legacy dialect entries kept for + * params already authored with them — new params should use spec `FieldType` + * values directly. + */ +const PARAM_TYPE_ALIASES: Record = { + checkbox: 'boolean', + reference: 'lookup', + 'datetime-local': 'datetime', + // Spec `FieldType` spells it `autonumber`; the widget map key is `auto_number`. + autonumber: 'auto_number', +}; + +/** + * Resolve a param `type` to the form widget key that renders it. Any type in + * `FORM_FIELD_TYPES` resolves to itself (identity — asserted by the drift + * test); aliases and unknown types resolve through the same fallback chain the + * form applies (unknown → `text`). + */ +export function resolveParamWidgetType(paramType: string): string { + return resolveFormWidgetType(PARAM_TYPE_ALIASES[paramType] ?? paramType); +} + +/** Widget keys that render the record-picker family and need a reference target. */ +const LOOKUP_WIDGET_TYPES = new Set(['lookup', 'master_detail']); + +/** + * Map an `ActionParamDef` to the field-metadata shape `FieldWidgetProps.field` + * expects. Lossless for the widget-relevant config: options, `multiple`, + * upload `accept`/`maxSize`, and the full lookup picker config that + * `resolveActionParams()` copies from the underlying object field. + * + * Param-only fallback: a `lookup`/`reference` param with no known + * `referenceTo` target renders as a plain text input (the picker cannot query + * without a target object) — preserving the dialog's long-standing behavior + * for partially-resolved metadata. + */ +export function paramToField(param: ActionParamDef): Record { + let type = resolveParamWidgetType(param.type); + if (LOOKUP_WIDGET_TYPES.has(type) && !param.referenceTo) { + type = 'text'; + } + + const field: Record = { + name: param.name, + label: param.label, + type, + required: param.required, + placeholder: param.placeholder, + options: param.options, + multiple: param.multiple, + accept: param.accept, + maxSize: param.maxSize, + }; + + // The dialog's boolean params render as an inline checkbox row (label beside + // the control), matching the pre-ADR-0059 dialog UX — not the form's switch. + if (type === 'boolean') { + field.widget = 'checkbox'; + } + + if (LOOKUP_WIDGET_TYPES.has(type) || type === 'user' || type === 'owner') { + Object.assign(field, { + reference_to: param.referenceTo, + display_field: param.displayField, + id_field: param.idField, + description_field: param.descriptionField, + title_format: param.titleFormat, + lookup_columns: param.lookupColumns, + lookup_filters: param.lookupFilters, + lookup_page_size: param.lookupPageSize, + depends_on: param.dependsOn, + }); + } + + return field; +} diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index bad05eeb6..098626d38 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -67,3 +67,66 @@ describe('resolveActionParams — visible propagation', () => { expect(out[1].visible).toBeUndefined(); }); }); + +describe('resolveActionParams — widget config (ADR-0059)', () => { + const fileCtx = () => + ctx({ + objects: [ + { + name: 'sys_user', + fields: { + avatar_file: { + type: 'file', + label: 'Avatar', + multiple: true, + accept: ['image/*'], + maxSize: 1024, + }, + phone_number: { type: 'text', label: 'Phone' }, + }, + }, + ], + }); + + it('carries multiple/accept/maxSize on an inline param', () => { + const params: RawActionParam[] = [ + { name: 'attachments', type: 'file', multiple: true, accept: ['application/pdf'], maxSize: 2048 }, + ]; + expect(resolveActionParams(params, ctx())[0]).toMatchObject({ + type: 'file', + multiple: true, + accept: ['application/pdf'], + maxSize: 2048, + }); + }); + + it('inherits multiple/accept/maxSize from the referenced field (any type, not just lookup)', () => { + const params: RawActionParam[] = [{ field: 'avatar_file' }]; + expect(resolveActionParams(params, fileCtx())[0]).toMatchObject({ + type: 'file', + multiple: true, + accept: ['image/*'], + maxSize: 1024, + }); + }); + + it('inline overrides win over the field metadata', () => { + const params: RawActionParam[] = [{ field: 'avatar_file', multiple: false, maxSize: 4096 }]; + expect(resolveActionParams(params, fileCtx())[0]).toMatchObject({ + multiple: false, + accept: ['image/*'], + maxSize: 4096, + }); + }); + + it('carries multiple/accept/maxSize on the missing-field fallback branch', () => { + const params: RawActionParam[] = [ + { field: 'does_not_exist', type: 'file', multiple: true, accept: ['.csv'], maxSize: 99 }, + ]; + expect(resolveActionParams(params, ctx())[0]).toMatchObject({ + multiple: true, + accept: ['.csv'], + maxSize: 99, + }); + }); +}); diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 19d16240b..5c77ef4ec 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -16,12 +16,11 @@ import type { ActionParamDef } from '@object-ui/core'; /** - * `ActionParamDialog` switches on raw `FieldType` values - * (`text` / `email` / `select` / `textarea` / `number` / `url` / `date` / …), - * matching the `FieldType` enum in `@objectstack/spec`. **Do not** route - * through `mapFieldTypeToFormType()` here — that helper translates into the - * FormField widget vocabulary (`field:select`, …) which the dialog does not - * understand. + * Resolved params keep raw `FieldType` values (`text` / `email` / `select` / + * `file` / …), matching the `FieldType` enum in `@objectstack/spec`. **Do + * not** translate into the FormField widget vocabulary (`field:select`, …) + * here — `ActionParamDialog` owns that translation via its `paramToField()` + * adapter (ADR-0059). */ /** Raw param as authored on a schema action (post-zod). */ @@ -38,6 +37,12 @@ export interface RawActionParam { defaultValue?: unknown; /** When true, seed defaultValue from the row record using the field name. */ defaultFromRow?: boolean; + /** Allow multiple values (file/image/lookup/user params → array value). */ + multiple?: boolean; + /** Accepted upload types (MIME types / extensions) for `file`/`image` params. */ + accept?: string[]; + /** Max upload size in bytes for `file`/`image` params. */ + maxSize?: number; /** * Visibility predicate (CEL) — mirrors the spec `ActionParamSchema.visible`. * The server serialises it through `ExpressionInputSchema` as an @@ -60,6 +65,9 @@ interface RuntimeField { options?: Array<{ label: string; value: string } | string>; multiple?: boolean; defaultValue?: unknown; + // ── Upload widget config (file/image fields) ── + accept?: string[]; + maxSize?: number; // ── Lookup-specific metadata (preserved when resolving lookup params) ── reference_to?: string; reference?: string; @@ -161,6 +169,9 @@ export function resolveActionParam( helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, visible: normaliseVisible(param.visible), + multiple: param.multiple, + accept: param.accept, + maxSize: param.maxSize, }; } @@ -182,6 +193,9 @@ export function resolveActionParam( helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, visible: normaliseVisible(param.visible), + multiple: param.multiple, + accept: param.accept, + maxSize: param.maxSize, }; } @@ -201,7 +215,6 @@ export function resolveActionParam( displayField: field.display_field ?? field.reference_field, idField: field.id_field, descriptionField: field.description_field, - multiple: field.multiple, titleFormat: field.title_format, lookupColumns: field.lookup_columns, lookupFilters: field.lookup_filters, @@ -220,6 +233,11 @@ export function resolveActionParam( helpText: param.helpText ?? field.help ?? field.description, defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue, visible: normaliseVisible(param.visible), + // 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, + accept: param.accept ?? field.accept, + maxSize: param.maxSize ?? field.maxSize, ...lookupExtras, }; } diff --git a/packages/app-shell/src/views/ActionParamDialog.test.tsx b/packages/app-shell/src/views/ActionParamDialog.test.tsx index 07caee7b9..d0e405e0a 100644 --- a/packages/app-shell/src/views/ActionParamDialog.test.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.test.tsx @@ -12,10 +12,17 @@ * form offering a `phoneNumber` field the default backend rejects: the param is * `visible: 'features.phoneNumber == true'`, so it's hidden unless the opt-in * phoneNumber auth plugin is loaded. + * + * ActionParamDialog render tests — the dialog routes every param through the + * shared form field-widget renderer (ADR-0059), so these pin the behavior + * contract across the swap: each type renders its real widget (lazy, behind + * Suspense), values round-trip through `resolve`, and `required` validation + * still blocks submit. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import type { ActionParamDef } from '@object-ui/core'; -import { filterVisibleParams } from './ActionParamDialog'; +import { ActionParamDialog, filterVisibleParams } from './ActionParamDialog'; const p = (name: string, visible?: string): ActionParamDef => ({ name, @@ -63,3 +70,124 @@ describe('filterVisibleParams', () => { expect(filterVisibleParams(params, { features: { phoneNumber: true } }).map((x) => x.name)).toEqual(['phoneNumber']); }); }); + +/** Mount the dialog open with the given params; returns the resolve spy. */ +function openDialog(params: ActionParamDef[]) { + const resolve = vi.fn(); + render( + {}} + />, + ); + return resolve; +} + +const confirm = () => fireEvent.click(screen.getByText('actionDialog.confirm')); + +const def = (over: Partial): ActionParamDef => ({ + name: 'p1', + label: 'Param One', + type: 'text', + ...over, +}); + +describe('ActionParamDialog — shared field-widget rendering (ADR-0059)', () => { + it('renders a text param and round-trips the typed value on confirm', async () => { + const resolve = openDialog([def({ name: 'note', type: 'text' })]); + const input = await screen.findByLabelText('Param One'); + fireEvent.change(input, { target: { value: 'hello' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ note: 'hello' })); + }); + + it('renders a textarea param through the shared TextAreaField', async () => { + const resolve = openDialog([def({ name: 'reason', type: 'textarea' })]); + const box = await screen.findByLabelText('Param One'); + expect(box.tagName).toBe('TEXTAREA'); + fireEvent.change(box, { target: { value: 'because' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ reason: 'because' })); + }); + + it('renders a number param and emits a numeric value (not a string)', async () => { + const resolve = openDialog([def({ name: 'count', type: 'number' })]); + const input = await screen.findByLabelText('Param One'); + expect(input.getAttribute('type')).toBe('number'); + fireEvent.change(input, { target: { value: '42' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ count: 42 })); + }); + + it('renders a boolean param as a checkbox row that toggles to true', async () => { + const resolve = openDialog([def({ name: 'force', type: 'boolean' })]); + const checkbox = await screen.findByRole('checkbox'); + fireEvent.click(checkbox); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ force: true })); + }); + + it('renders a select param through the shared SelectField (combobox trigger)', async () => { + openDialog([ + def({ + name: 'env', + type: 'select', + options: [ + { label: 'Production', value: 'prod' }, + { label: 'Staging', value: 'stage' }, + ], + }), + ]); + expect(await screen.findByRole('combobox')).toBeTruthy(); + }); + + it('renders a date param through the shared DateField (native date input)', async () => { + const resolve = openDialog([def({ name: 'due', type: 'date' })]); + const input = await screen.findByLabelText('Param One'); + expect(input.getAttribute('type')).toBe('date'); + fireEvent.change(input, { target: { value: '2026-07-19' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ due: '2026-07-19' })); + }); + + it('renders a file param as a real upload control, not a text box (#2698)', async () => { + openDialog([def({ name: 'attachments', type: 'file', multiple: true })]); + await waitFor(() => { + const fileInput = document.querySelector('input[type="file"]'); + expect(fileInput).toBeTruthy(); + expect((fileInput as HTMLInputElement).multiple).toBe(true); + }); + }); + + it('renders a color param through the shared ColorField (color input)', async () => { + openDialog([def({ name: 'tint', type: 'color' })]); + await waitFor(() => { + expect(document.querySelector('input[type="color"]')).toBeTruthy(); + }); + }); + + it('required + empty blocks submit and shows the error message', async () => { + const resolve = openDialog([def({ name: 'note', type: 'text', required: true })]); + await screen.findByLabelText(/Param One/); + confirm(); + expect(await screen.findByText('actionDialog.requiredError')).toBeTruthy(); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('an unknown param type falls back to a plain text input', async () => { + const resolve = openDialog([def({ name: 'x', type: 'no-such-type' })]); + const input = await screen.findByLabelText('Param One'); + expect(input.getAttribute('type')).toBe('text'); + fireEvent.change(input, { target: { value: 'v' } }); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ x: 'v' })); + }); + + it('seeds defaultValue and returns it untouched on confirm', async () => { + const resolve = openDialog([def({ name: 'note', type: 'text', defaultValue: 'seed' })]); + const input = await screen.findByLabelText('Param One'); + expect((input as HTMLInputElement).value).toBe('seed'); + confirm(); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ note: 'seed' })); + }); +}); diff --git a/packages/app-shell/src/views/ActionParamDialog.tsx b/packages/app-shell/src/views/ActionParamDialog.tsx index c5281c017..110e14313 100644 --- a/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.tsx @@ -1,16 +1,23 @@ /** * ActionParamDialog — Collects user input for action parameters before execution. * - * Dynamically renders form fields from ActionParamDef[] definitions: - * - type: 'select' → Shadcn Select component - * - type: 'text' → Shadcn Input component - * - type: 'textarea' → Shadcn Textarea component - * - other types → Shadcn Input with appropriate HTML type + * Renders each `ActionParamDef` through the SAME field-widget renderer the + * object form uses (`@object-ui/fields` — `fieldWidgetMap` via + * `getLazyFieldWidget`), so a declared action param of any form-supported + * field type (`select`, `lookup`, `file`, `image`, `richtext`, `color`, + * `date`, …) renders its real widget instead of collapsing to a text input + * (ADR-0059). The param → field translation lives in the pure + * `paramToField()` adapter; widgets stay lazy behind `` so opening + * a dialog only loads the widgets its params actually use. + * + * Ambient context is relied on, not threaded: `UploadProvider` (file/image + * uploads) and `SchemaRendererContext` (dataSource for lookup/user pickers) + * come from the host view, exactly as the previous `LookupField` reuse did. * * Returns collected param values or null on cancel. */ -import { useState, useEffect, useMemo } from 'react'; +import { Suspense, useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, @@ -19,21 +26,14 @@ import { DialogHeader, DialogTitle, Button, - Input, Label, - Textarea, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Checkbox, } from '@object-ui/components'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { ActionParamDef } from '@object-ui/core'; import { ExpressionEvaluator } from '@object-ui/core'; import { usePredicateScope } from '@object-ui/react'; -import { LookupField } from '@object-ui/fields'; +import { getLazyFieldWidget } from '@object-ui/fields'; +import { paramToField } from '../utils/paramToField'; export interface ParamDialogState { open: boolean; @@ -73,6 +73,11 @@ export function filterVisibleParams( }); } +/** Skeleton shown while a lazy field widget's chunk loads. */ +function WidgetFallback() { + return