From 8c545bf1f4deaee7f416718d0d4487c29ec2aaf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 11:01:18 +0000 Subject: [PATCH 1/2] fix(app-shell): make the Action designer's two panes agree on the param type vocabulary `ActionDefaultInspector`'s `PARAM_TYPE_OPTS` offers eight `type` spellings. `ActionPreview.renderFieldMock` switched on the RAW authored spelling over a private table of five, so three of the eight previewed as a control `ActionParamDialog` does not render: datetime -> plain text box (runtime: DateTimeField) lookup + reference -> plain text box (runtime: LookupField record picker) select, no options -> plain text box (runtime: SelectField, empty picker) and the options-first branch inverted the same defect: a `text` param carrying `options` previewed as a select the runtime never draws, because `resolveFormWidgetType('text')` is `text` and `TextField` reads no options. The mock now resolves each param through `paramToField`'s `resolveParamWidgetType` / `paramDegradesWithoutTarget` -- the same adapter the dialog renders through -- so the two panes read one member set. The preview owns exactly two crossings and restates no table: the authoring `reference` key is renamed to the resolved `referenceTo` the predicate reads (what `resolveActionParams` does before the dialog sees a param), and a field-backed param is never previewed as degraded because its target is inherited from the bound field at runtime. A param with no declared `type` at all is field-backed and unresolvable here, so it keeps inferring from its options. The per-param editor gains an `options` control for `select`. Triage allowed a visible pointer instead, but the pointer lost on measurement: `params` is in `CURATED_FIELDS`, so the collapsed "More fields" SchemaForm hides the whole array too, and the only surface that could author `options` was the raw JSON source tab. The population stays the eight `PARAM_TYPE_OPTS` offers -- no `FieldType` mock gallery, and `long_text` / `integer` stay deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .../action-designer-param-vocabulary.md | 24 ++ .../ActionDesigner.paramVocabulary.test.tsx | 333 ++++++++++++++++++ .../inspectors/ActionDefaultInspector.tsx | 108 +++++- .../metadata-admin/previews/ActionPreview.tsx | 196 +++++++++-- 4 files changed, 624 insertions(+), 37 deletions(-) create mode 100644 .changeset/action-designer-param-vocabulary.md create mode 100644 packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx diff --git a/.changeset/action-designer-param-vocabulary.md b/.changeset/action-designer-param-vocabulary.md new file mode 100644 index 0000000000..9b82c424a0 --- /dev/null +++ b/.changeset/action-designer-param-vocabulary.md @@ -0,0 +1,24 @@ +--- +"@object-ui/app-shell": patch +--- + +fix(app-shell): the Action designer's preview draws what the runtime dialog will draw + +`ActionDefaultInspector` offers eight param `type` spellings; `ActionPreview`'s +dialog mock switched on five of them over a private table, so three of the eight +previewed as a control `ActionParamDialog` does not render — `datetime` and a +targeted `lookup` as plain text boxes, and a `select` whose options were not +authored yet as a text box as well. A `text` param that happened to carry +`options` previewed as a select the runtime never draws, for the same reason in +the other direction. + +The mock now resolves each param through `paramToField`'s +`resolveParamWidgetType` / `paramDegradesWithoutTarget` — the same adapter the +dialog itself renders through — so the two panes cannot disagree about a +spelling again. `datetime` draws a date/time control, a `lookup` with a declared +`reference` draws a record picker, a targetless one draws the record-id text box +the dialog degrades to and says why, and a `select` always draws a picker. + +The per-param editor also gains an `options` control for `select` params. It had +none, and `params` is hidden from the collapsed "More fields" form, so the panel +that offered the type had nowhere to author the choices the type needs. diff --git a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx new file mode 100644 index 0000000000..1d836ff712 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **The Action designer's two panes agree on the param `type` vocabulary** + * (objectui#6538). + * + * `ActionPreview`'s declared job is "a faux button / dialog rendered so authors + * can see the visual weight before they ship it" — it shows what WILL render. + * `ActionDefaultInspector`'s `PARAM_TYPE_OPTS` decides what an author can pick. + * Until this card the two disagreed, and the preview lost: for three of the + * eight offered spellings it drew a control the runtime dialog does not draw. + * + * ## Where "what the runtime draws" comes from + * + * `ActionParamDialog` renders every param through the shared form field widgets + * (ADR-0059): `paramToField()` resolves the param's `type` to a widget key via + * `resolveParamWidgetType`, `paramDegradesWithoutTarget()` decides whether a + * picker collapses to a text box for want of a declared target, and + * `getLazyFieldWidget(key)` renders it. So the runtime's answer for a spelling + * is a pure function this file can call, and does — see {@link runtimeWidgetFor}. + * + * ## Why the expectations are hand-declared AND cross-checked + * + * `EXPECTED` below is read out of the runtime by a human: `datetime` renders + * `DateTimeField`, so its mock must be a datetime control, not a text box. If + * the test DERIVED each expectation from the same resolver the preview calls, + * it could never catch the preview calling the resolver wrongly — the pin would + * assert the implementation against itself. So the per-case expectation is + * literal, and a separate drift guard asserts that the literal table still + * matches what the resolver says. The first half is the pin; the second keeps + * it honest as the widget map moves. + * + * ## The population is EIGHT, not forty-nine + * + * `PARAM_TYPE_OPTS` is the population, imported rather than restated: the + * defect is that two panes of ONE designer disagree, not that the preview is + * incomplete against the spec's whole 49-member `FieldType`. The coverage + * guards below fail if a ninth spelling is offered without a case here, and if a + * case names a spelling the inspector does not offer. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import type { ActionParam, ResolvableParamFieldType } from '@object-ui/types'; + +// ActionDefaultInspector calls useObjectOptions()/useObjectFields() at mount +// (objectui#4697) — stub the shared client so mounting escapes to no network. +const state = vi.hoisted(() => ({ + metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }, +})); +vi.mock('./useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); + +import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../utils/paramToField.js'; +import { ActionPreview } from './previews/ActionPreview'; +import { ActionDefaultInspector, PARAM_TYPE_OPTS } from './inspectors/ActionDefaultInspector'; +import { __setCelFormulaLoader } from './celAuthoring'; + +beforeEach(() => { + // Both ConditionBuilders mount unconditionally; give them a loader that + // resolves so nothing reaches for the real CEL engine during these renders. + __setCelFormulaLoader(() => + Promise.resolve({ + validateExpression: () => ({ ok: true, errors: [], warnings: [] }), + introspectScope: () => ({ fields: [], roots: [], functions: [] }), + inferExpressionType: () => 'boolean' as const, + }), + ); +}); + +afterEach(() => { + cleanup(); + __setCelFormulaLoader(undefined); +}); + +/** The eight spellings the inspector offers — the population, by reference. */ +const OFFERED: readonly string[] = PARAM_TYPE_OPTS.map((o) => o.value); + +/** + * A control the preview's dialog mock can draw, as a stable spelling. + * `input:*` is the DOM `type` of the rendered ``; `record-picker` is the + * disabled combobox the preview mocks a `LookupField` with. + */ +type MockControl = + | 'input:text' + | 'input:number' + | 'input:date' + | 'input:datetime-local' + | 'input:checkbox' + | 'textarea' + | 'select' + | 'record-picker'; + +/** + * The faithful mock for each runtime widget key the eight can resolve to. + * Hand-written on purpose (see the header): this is a human reading of what + * `@object-ui/fields` renders, which is the thing the preview is supposed to + * show. The drift guard below re-checks it against the resolver. + */ +const MOCK_FOR_RUNTIME_WIDGET: Readonly> = { + text: 'input:text', + textarea: 'textarea', + number: 'input:number', + boolean: 'input:checkbox', + select: 'select', + date: 'input:date', + datetime: 'input:datetime-local', + lookup: 'record-picker', +}; + +interface Case { + /** One of the eight `PARAM_TYPE_OPTS` spellings. */ + readonly spelling: ResolvableParamFieldType; + /** Extra authored keys that change what the runtime does with the spelling. */ + readonly extra?: Partial; + /** What the RUNTIME dialog draws for this param — read out of the runtime. */ + readonly expected: MockControl; + readonly why: string; +} + +const CASES: readonly Case[] = [ + { spelling: 'text', expected: 'input:text', why: 'TextField' }, + { + spelling: 'text', + extra: { options: [{ label: 'Duplicate', value: 'dup' }] }, + expected: 'input:text', + why: 'options on a `text` param are ignored by the runtime — it still renders TextField', + }, + { spelling: 'textarea', expected: 'textarea', why: 'TextAreaField' }, + { spelling: 'number', expected: 'input:number', why: 'NumberField' }, + { spelling: 'boolean', expected: 'input:checkbox', why: 'BooleanField, widget `checkbox`' }, + { + spelling: 'select', + extra: { options: [{ label: 'Duplicate', value: 'dup' }, { label: 'Spam', value: 'spam' }] }, + expected: 'select', + why: 'SelectField', + }, + { + spelling: 'select', + expected: 'select', + why: 'SelectField renders an EMPTY picker with a placeholder — not a text box', + }, + { spelling: 'date', expected: 'input:date', why: 'DateField' }, + { spelling: 'datetime', expected: 'input:datetime-local', why: 'DateTimeField' }, + { + spelling: 'lookup', + extra: { reference: 'account' }, + expected: 'record-picker', + why: 'LookupField — a record picker, once a target is declared', + }, + { + spelling: 'lookup', + expected: 'input:text', + why: 'a targetless lookup DEGRADES to a record-id text box (paramDegradesWithoutTarget)', + }, +]; + +function paramFor(c: Case): ActionParam { + return { name: 'p', label: 'P', type: c.spelling, ...c.extra }; +} + +function describeCase(c: Case): string { + const extra = c.extra ? ` + ${Object.keys(c.extra).join('/')}` : ''; + return `${c.spelling}${extra}`; +} + +/** + * What `ActionParamDialog` would resolve this AUTHORED param to. + * + * The one rename this crossing needs: the authoring key is `reference` + * (`ActionParamSchema`), the resolved key `paramToField` reads is `referenceTo` + * — `resolveActionParams()` performs exactly that copy before the dialog sees a + * param. Nothing else is restated; the membership tables stay where they are. + */ +function runtimeWidgetFor(param: ActionParam): string { + const type = param.type as string; + const resolved = { name: param.name ?? '', label: '', type, referenceTo: param.reference }; + return paramDegradesWithoutTarget(resolved) ? 'text' : resolveParamWidgetType(type); +} + +function renderPreviewWith(param: ActionParam): HTMLElement { + const { container } = render( + , + ); + return container as HTMLElement; +} + +/** + * The one control the dialog mock drew for the one param in the draft. + * + * Asserting the count is part of the instrument: an empty match would make + * every `not.toBe('input:text')` pass vacuously, and two matches would mean the + * query is picking up something outside the mock. + */ +function drawnControl(container: HTMLElement): MockControl { + const controls = Array.from( + container.querySelectorAll('input, textarea, select, button[aria-haspopup="dialog"]'), + ); + expect(controls.map((el) => el.outerHTML), 'exactly one control per param mock').toHaveLength(1); + const el = controls[0]!; + const tag = el.tagName.toLowerCase(); + if (tag === 'select') return 'select'; + if (tag === 'textarea') return 'textarea'; + if (tag === 'button') return 'record-picker'; + return `input:${(el as HTMLInputElement).type}` as MockControl; +} + +describe('Action designer — the preview draws what the runtime dialog will draw (#6538)', () => { + it.each(CASES.map((c) => [describeCase(c), c] as const))( + 'previews an offered `%s` param as the control the runtime renders', + (_name, c) => { + const container = renderPreviewWith(paramFor(c)); + expect( + drawnControl(container), + `the inspector offers "${c.spelling}"; the runtime dialog draws ${c.why}`, + ).toBe(c.expected); + }, + ); + + it('draws no offered spelling as a plain text box unless the runtime does', () => { + // The disagreement, stated as one sentence rather than per case: a text box + // in the preview is a CLAIM that the runtime shows a text box. + const lying = CASES.filter((c) => { + const drawn = drawnControl(renderPreviewWith(paramFor(c))); + cleanup(); + return drawn === 'input:text' && MOCK_FOR_RUNTIME_WIDGET[runtimeWidgetFor(paramFor(c))] !== 'input:text'; + }).map(describeCase); + expect(lying, 'these spellings preview as a text box the runtime dialog will not draw').toEqual([]); + }); + + // ── Guards on the instrument itself ────────────────────────────────────── + + it('covers every spelling the inspector offers, and no other', () => { + const covered = [...new Set(CASES.map((c) => c.spelling as string))].sort(); + expect(covered, 'PARAM_TYPE_OPTS is the population — a ninth entry needs a case here').toEqual( + [...OFFERED].sort(), + ); + }); + + it('the hand-declared expectations still match what the runtime resolver says', () => { + const drifted = CASES.filter((c) => MOCK_FOR_RUNTIME_WIDGET[runtimeWidgetFor(paramFor(c))] !== c.expected).map( + (c) => `${describeCase(c)} → ${runtimeWidgetFor(paramFor(c))} (expected mock ${c.expected})`, + ); + expect(drifted, 'the widget map moved under this table — re-read the runtime before editing').toEqual([]); + }); + + it('every runtime widget the eight resolve to has a declared mock', () => { + const unmapped = CASES.map((c) => runtimeWidgetFor(paramFor(c))).filter( + (w) => MOCK_FOR_RUNTIME_WIDGET[w] === undefined, + ); + expect([...new Set(unmapped)]).toEqual([]); + }); + + it('an untyped (field-backed) param keeps inferring from its options', () => { + // Not one of the eight: a `{ field: … }` param carries no `type` in the + // draft and the designer does not resolve object metadata here, so options + // are the only evidence the preview has. Pinned so the type-driven + // switch above cannot quietly take this case with it. + const withOptions = renderPreviewWith({ field: 'status', options: [{ label: 'Open', value: 'open' }] }); + expect(drawnControl(withOptions)).toBe('select'); + cleanup(); + expect(drawnControl(renderPreviewWith({ field: 'status' }))).toBe('input:text'); + }); +}); + +/* ─────────────── The other half of the same seam ─────────────── */ + +/** + * Stateful harness — the inspector is CONTROLLED, so a committed patch has to + * round-trip through the draft or the next commit reverts the last one. + */ +function InspectorHarness({ onDraft, params }: { + onDraft: (d: Record) => void; + params: ActionParam[]; +}) { + const [draft, setDraft] = React.useState>({ + name: 'approve', + label: 'Approve', + type: 'script', + params, + }); + React.useEffect(() => onDraft(draft), [draft, onDraft]); + return ( + ) => setDraft((d) => ({ ...d, ...patch }))} + readOnly={false} + locale={'en-US' as never} + /> + ); +} + +function renderInspector(params: ActionParam[]) { + const seen: Record[] = []; + const onDraft = (d: Record) => { seen.push(d); }; + render(); + return { latestParams: () => (seen.at(-1)?.params ?? []) as ActionParam[] }; +} + +describe('Action designer — the panel that offers `select` can author its options (#6538)', () => { + it('offers an options editor for a select param', () => { + renderInspector([{ name: 'reason', label: 'Reason', type: 'select' }]); + // Before this card the per-param editor had controls for field, name, + // label, type, placeholder, required and defaultFromRow — and no way at all + // to author `options`. `params` is in CURATED_FIELDS, so the collapsed + // "More fields" SchemaForm hides the whole array too: there was nowhere in + // this panel to put them. + expect(screen.getByRole('group', { name: 'Options' })).toBeInTheDocument(); + }); + + it('writes an authored option onto the param the preview reads', () => { + const { latestParams } = renderInspector([{ name: 'reason', label: 'Reason', type: 'select' }]); + const group = () => screen.getByRole('group', { name: 'Options' }); + fireEvent.click(within(group()).getByRole('button', { name: /add option/i })); + fireEvent.change(within(group()).getByLabelText('Label'), { target: { value: 'Duplicate' } }); + fireEvent.change(within(group()).getByLabelText('Value'), { target: { value: 'dup' } }); + expect(latestParams()[0]?.options).toEqual([{ label: 'Duplicate', value: 'dup' }]); + }); + + it('shows no options editor for a spelling the runtime does not read options for', () => { + renderInspector([{ name: 'note', label: 'Note', type: 'text' }]); + expect(screen.queryByRole('group', { name: 'Options' })).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx index 09e4f1715a..b403686769 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx @@ -112,7 +112,15 @@ const BODY_LANG_OPTS = [ * the way `long_text` reached `ActionPreview` while the local `type?: string` * was still in force (objectui#6329). */ -const PARAM_TYPE_OPTS = [ +/* + * Exported for objectui#6538's pin, which compares what this dropdown OFFERS + * against what `ActionPreview` draws — and reads the eight from here by + * reference rather than restating them, so a ninth entry has to fail that pin. + * A hand-kept copy of the vocabulary could not. The cost is this panel's fast + * refresh, which the directive below accepts by name. + */ +// eslint-disable-next-line react-refresh/only-export-components -- see above +export const PARAM_TYPE_OPTS = [ { value: 'text', label: 'Text' }, { value: 'textarea', label: 'Long text' }, { value: 'number', label: 'Number' }, @@ -300,6 +308,94 @@ function FieldPicker({ label, objectName, value, onCommit, disabled }: { return ; } +/** One entry of `ActionParam.options`, read off the published type. */ +type ActionParamOption = NonNullable[number]; + +/** + * Options editor for a `select` param — the choices the dialog will offer. + * + * ## Why this exists (objectui#6538) + * + * `PARAM_TYPE_OPTS` offers `Select`, and `ActionPreview` draws the picker the + * dialog will render — but until this card the per-param editor had controls + * for `field`, `name`, `label`, `type`, `placeholder`, `required` and + * `defaultFromRow`, and none for `options`. The panel that offered the type + * could not produce the data the type needs. + * + * Triage allowed either a real control or a visible POINTER to wherever options + * are authored. The pointer lost on measurement: `params` is listed in + * {@link CURATED_FIELDS}, so the collapsed "More fields" `SchemaForm` hides the + * whole array too. The only surface that could author `options` was the raw + * JSON source tab — i.e. the pointer would have had to say "leave the + * designer", which is the designer conceding it cannot author its own offered + * type. So: a control. + * + * ## Scoped to `select`, deliberately + * + * `select` is the only spelling among the eight whose runtime widget reads + * `options` (`SelectField`). A `text` param carrying `options` is not an + * author's mistake to be enabled here — `TextField` ignores them, which is + * exactly what the preview now shows. + * + * Localised labels: an option label committed here is written as a plain + * string, flattening an authored `{ en, fr-FR }` map — the same trade the + * param's own "Label" control above has always made. Locale maps stay + * authorable through the JSON source tab. + */ +function ParamOptionsEditor({ options, onCommit, disabled }: { + options: ActionParamOption[] | undefined; + onCommit: (next: ActionParamOption[]) => void; + disabled?: boolean; +}) { + const opts = Array.isArray(options) ? options : []; + return ( +
+
Options
+ {opts.length === 0 ? ( +

+ No choices yet — a Select with no options opens an empty picker in the dialog. +

+ ) : ( + opts.map((o, j) => ( +
+
+ onCommit(spliceArray(opts, j, { ...o, label: v }))} + disabled={disabled} + /> + onCommit(spliceArray(opts, j, { ...o, value: v }))} + disabled={disabled} + mono + /> +
+ +
+ )) + )} + {!disabled && ( + + )} +
+ ); +} + /* * No local `ActionParam` here (objectui#6329). `@object-ui/types` publishes the * authoring shape, derived from the spec's `ActionParamSchema` input; this @@ -496,6 +592,16 @@ export function ActionDefaultInspector({ {!p.field && ( patchParam(i, { type: asParamFieldType(v) })} disabled={readOnly} /> )} + {/* `select` is the one offered spelling whose runtime widget reads + `options` — see ParamOptionsEditor for why this is a control + and not a pointer (objectui#6538). */} + {!p.field && p.type === 'select' && ( + patchParam(i, { options: next.length > 0 ? next : undefined })} + disabled={readOnly} + /> + )} patchParam(i, { placeholder: v })} disabled={readOnly} />
patchParam(i, { required: v })} disabled={readOnly} /> diff --git a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx index 614802f36d..7a3ca1edbb 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx @@ -36,12 +36,14 @@ import { MoreHorizontal, Pencil, RefreshCw, + Search, Sparkles, Square, Workflow, icons as lucideIcons, } from 'lucide-react'; import type { ActionParam } from '@object-ui/types'; +import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js'; import type { MetadataPreviewProps } from '../preview-registry.js'; import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js'; @@ -368,50 +370,172 @@ function DialogMock({ title, params, variant }: { title: string; params: ActionP ); } +/** + * Stand-in target for a FIELD-BACKED picker. Not a real object name — only a + * non-empty value, so `paramDegradesWithoutTarget` answers "a target exists". + * The real one arrives from the bound field at runtime + * (`resolveActionParams`: `referenceTo: param.reference ?? field.reference_to`). + */ +const INHERITED_TARGET = '(inherited from the bound field)'; + +/** + * The widget key `ActionParamDialog` will render this param through, and + * whether that widget DEGRADED to a text box for want of a target. + * + * Asked of the adapter that PERFORMS the mapping (`paramToField`'s + * `resolveParamWidgetType` / `paramDegradesWithoutTarget`), never restated + * here. The preview's declared job is to show what will render, so "what will + * render" has to be one question with one answer — the same discipline + * objectui#5654 imposed on the dialog's own hint logic. + * + * Before objectui#6538 this function did not exist: `renderFieldMock` switched + * on the RAW authored spelling over a private table of five, while + * `PARAM_TYPE_OPTS` offered eight. Three of the eight previewed as something + * the runtime does not draw — `datetime` and `lookup` as plain text boxes, and + * a `select` whose options were not authored yet as a text box too — and a + * `text` param that happened to carry `options` previewed as a select the + * runtime never renders (it resolves `text`, and `TextField` reads no options). + * + * Two crossings this function owns, and nothing else: + * + * 1. **`reference` → `referenceTo`.** `reference` is the AUTHORING spelling + * (`ActionParamSchema`); `referenceTo` is the RESOLVED one the predicate + * reads. `resolveActionParams()` performs exactly that copy before the + * dialog sees a param, and this preview reads the UNRESOLVED draft, so the + * rename happens here. The membership tables stay where they are. + * 2. **A field-backed param is never previewed as degraded.** Its target is + * inherited from the bound field at runtime; the designer has not resolved + * that field here, so claiming the degradation would be the same lie + * pointing the other way. + * + * `widget: undefined` means the draft declares no `type` at all — a field-backed + * param whose type the runtime inherits from object metadata this preview does + * not load. Genuinely unresolvable here, which is why the caller may fall back + * to the only other evidence it has; see there. + */ +function runtimeWidgetFor(p: ActionParam): { widget: string | undefined; degraded: boolean } { + if (!p.type) return { widget: undefined, degraded: false }; + const referenceTo = p.reference ?? (p.field ? INHERITED_TARGET : undefined); + const degraded = paramDegradesWithoutTarget({ name: p.name ?? '', label: '', type: p.type, referenceTo }); + return { widget: degraded ? 'text' : resolveParamWidgetType(p.type), degraded }; +} + function renderFieldMock(p: ActionParam): React.ReactElement { const cls = 'w-full text-xs px-2 py-1 border rounded bg-background pointer-events-none'; const placeholder = p.placeholder || (p.defaultFromRow ? '(from selected row)' : ''); const def = p.defaultValue; - if (Array.isArray(p.options) && p.options.length > 0) { - return ( - - ); - } - if (p.type === 'boolean') { + const value = def != null ? String(def) : ''; + const options = Array.isArray(p.options) ? p.options : []; + const label = localize(p.label) || p.name || p.field || 'value'; + const { widget, degraded } = runtimeWidgetFor(p); + + const inputMock = (type: string) => ( + + ); + const selectMock = ( + + ); + const note = (text: React.ReactNode) =>
{text}
; + + // No declared `type`: a field-backed param inherits its type from the object + // field, and this preview resolves no object metadata. Authored `options` are + // then the only evidence of what the dialog will draw, so they still decide — + // the one place in this function where they may. Everywhere below, the + // resolved WIDGET decides and options are read only where the widget reads + // them, because that is what the runtime does. + if (widget === undefined) return options.length > 0 ? selectMock : inputMock('text'); + + // A targetless picker collapses to a record-id text box in the dialog, with + // its own placeholder and help text (#3405). The box alone would be honest + // about the control and silent about the reason, which is the state that + // help text was added for. + if (degraded) { return ( - +
+ + {note( + <> + No reference object is configured, so the record picker is + unavailable and the dialog asks for a record id. + , + )} +
); } - // No `long_text` / `integer` branches (objectui#6329). Both are spellings - // from OTHER vocabularies — `long_text` from the console's form-builder - // dialect (`apps/console/src/components/FormPage.tsx`), `integer` from JSON - // Schema (`ToolPreview.tsx`, `json-schema-to-fields.ts`) — and neither is in - // `ResolvableParamFieldType`, which is the spec's 49-member `FieldType` plus - // objectui's three declared param aliases. `ActionParamSchema` is `.strict()` - // with a `FieldType` enum on `type`, so a param spelled either way is a parse - // rejection on the server and can never reach this preview. The local - // `type?: string` was the only thing that made the comparisons compile. - if (p.type === 'textarea' || p.type === 'html') { - return