From 71820118fc7b18a5c93a477adc0fac45db3a4cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 22 Jul 2026 08:21:52 -0700 Subject: [PATCH 1/2] feat(spec): let an inline `lookup` action param declare its reference target (#3405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ActionParamSchema` had no way to name the object an inline record-picker param should search. Authors reasonably wrote the same key the field schema uses — `{ name: 'inspector', type: 'lookup', reference: 'sys_user' }` — and the schema stripped it as an unknown key, silently. Downstream the param dialog saw a picker with no target and degraded it to a "paste the record id (UUID)" text input, so the authored intent was dropped and the user was handed a control a human cannot reasonably operate (found on a QC dispatch assign/transfer action). - Added `reference` to `ActionParamSchema`, spelled to match `FieldSchema.reference` so one spelling works in both places. It joins the existing inline widget config (`multiple` / `accept` / `maxSize`), which had covered the file/image params but not the picker ones. - A `lookup` / `master_detail` param declared inline with no `reference` is now a parse-time error pointing at the missing key, instead of degrading at render time. Field-backed params are unaffected: they inherit the target from the referenced field's metadata, which is not visible at parse time. - app-showcase's action-param gallery gains the inline picker specimen it could not previously express, next to the other widget types. Verified: `objectstack validate` on app-showcase fails with the new message when `reference` is removed and passes with it, and the param renders a working searchable picker in the console (see objectui's companion change). Refs #3405 Co-Authored-By: Claude Opus 4.8 --- .../action-param-inline-lookup-reference.md | 22 ++++++++++++ examples/app-showcase/src/ui/actions/index.ts | 8 +++++ packages/spec/src/ui/action.test.ts | 36 +++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 33 ++++++++++++++++- 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .changeset/action-param-inline-lookup-reference.md diff --git a/.changeset/action-param-inline-lookup-reference.md b/.changeset/action-param-inline-lookup-reference.md new file mode 100644 index 0000000000..ae76a045ec --- /dev/null +++ b/.changeset/action-param-inline-lookup-reference.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": patch +--- + +feat(spec): let an inline `lookup` action param declare its reference target (#3405) + +`ActionParamSchema` had no way to name the object an inline record-picker param +should search. Authors reasonably wrote the same key the field schema uses — +`{ name: 'inspector', type: 'lookup', reference: 'sys_user' }` — and the schema +stripped it as an unknown key, without an error. Downstream, the param dialog +saw a picker with no target and degraded it to a "paste the record id (UUID)" +text input. The authored intent was dropped silently and the user was handed a +control that a human cannot reasonably operate. + +- Added `reference` to `ActionParamSchema`, spelled to match + `FieldSchema.reference` so one spelling works in both places. It sits with the + existing inline widget config (`multiple` / `accept` / `maxSize`), which had + covered the file/image params but not the picker ones. +- A `lookup` / `master_detail` param declared **inline** with no `reference` is + now a parse-time error pointing at the missing key, instead of degrading at + render time. Field-backed params are unaffected: they inherit the target from + the referenced field's metadata, which is not visible at parse time. diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 4e7f8c0d05..f449873fcd 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -204,6 +204,14 @@ export const ActionParamGalleryAction = defineAction({ ], }, { name: 'p_date', type: 'date', label: 'Effective date' }, + // #3405 — an INLINE record picker. `reference` names the object the picker + // searches; without it the param would degrade to a "paste the record id + // (UUID)" text box, which is what shipped before. Accounts are seeded with + // enough volume (incl. a CJK name) to exercise search here. + { + name: 'p_account', type: 'lookup', reference: 'showcase_account', label: 'Related account', + helpText: 'Inline lookup param — searchable record picker, no UUID typing.', + }, { name: 'p_color', type: 'color', label: 'Accent color', defaultValue: '#7C3AED' }, // Spec `autonumber` param → the AutoNumber widget (read-only, auto-assigned). { name: 'p_reference', type: 'autonumber', label: 'Reference #' }, diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index 990707101a..65c70c7226 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -74,6 +74,40 @@ describe('ActionParamSchema', () => { expect(() => ActionParamSchema.parse({ name: 'p', label: 'P', type })).not.toThrow(); } }); + + // #3405 — an inline record-picker param carries its own target object. + // Before this, `reference` was an unknown key: zod stripped it silently and + // the param dialog degraded to a "paste the record id (UUID)" text input, + // with no signal that the authored config had been dropped. + describe('inline lookup reference target (#3405)', () => { + it('keeps `reference` on an inline lookup param', () => { + const result = ActionParamSchema.parse({ + name: 'inspector', + label: 'Inspector', + type: 'lookup' as const, + reference: 'sys_user', + required: true, + }); + expect(result.reference).toBe('sys_user'); + }); + + it('rejects an inline lookup/master_detail param with no reference target', () => { + for (const type of ['lookup', 'master_detail'] as const) { + const result = ActionParamSchema.safeParse({ name: 'owner', label: 'Owner', type }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(['reference']); + } + }); + + it('allows a field-backed lookup param to omit it (inherited from the field at runtime)', () => { + expect(() => ActionParamSchema.parse({ field: 'inspector', type: 'lookup' as const })).not.toThrow(); + expect(() => ActionParamSchema.parse({ field: 'inspector' })).not.toThrow(); + }); + + it('leaves `reference` undefined for non-picker params', () => { + expect(ActionParamSchema.parse({ name: 'note', type: 'textarea' as const }).reference).toBeUndefined(); + }); + }); }); // #2874 P1 — declarative `requiresFeature` sugar, lowered at parse time into @@ -368,6 +402,7 @@ describe('ActionSchema', () => { name: 'new_owner', label: 'New Owner', type: 'lookup', + reference: 'sys_user', required: true, }, { @@ -497,6 +532,7 @@ describe('ActionSchema', () => { name: 'new_owner', label: 'New Owner', type: 'lookup', + reference: 'sys_user', required: true, }, { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 1774464d32..35ddc7ddd1 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -31,7 +31,15 @@ import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/publi * * 2. **Inline** (legacy / bespoke) — declare `name`, `label`, `type` etc. * inline when no matching object field exists. Inline values may also be - * used alongside `field` to override individual properties. + * used alongside `field` to override individual properties. A `lookup` / + * `master_detail` param declared this way MUST name its target object via + * `reference` — there is no field to inherit it from: + * + * ```ts + * params: [ + * { name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' }, + * ] + * ``` * * `name` is required unless `field` is provided (in which case it defaults * to the field name and is used as the request-body key). @@ -76,6 +84,19 @@ export const ActionParamSchema = lazySchema(() => z.object({ accept: z.array(z.string()).optional().describe('Accepted upload types (MIME types / extensions) for file/image params.'), /** Max upload size in bytes for `file`/`image` params. */ maxSize: z.number().int().positive().optional().describe('Max upload size in bytes for file/image params.'), + /** + * Reference target for an inline `lookup` / `master_detail` param — the + * object whose records the picker searches. Field-backed params inherit it + * from the referenced field, so it is only needed inline. + * + * Without it the dialog cannot query anything and degrades to a plain text + * input asking for a raw record id, which is unusable for a human — hence + * the `.refine()` below rejects a targetless lookup param at parse time. + * + * Key name deliberately mirrors `FieldSchema.reference` so the same spelling + * works in both places. + */ + reference: SnakeCaseIdentifierSchema.optional().describe('Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference.'), /** * When true, the param's default value is pulled from the current row record * (key = the resolved field name) when the action runs from a list_item @@ -105,6 +126,16 @@ export const ActionParamSchema = lazySchema(() => z.object({ }).refine( (p) => Boolean(p.name) || Boolean(p.field), { message: 'ActionParam requires either "name" or "field"' }, +).refine( + // An INLINE record-picker param must name its target object. Only inline + // params are checked: a field-backed one inherits the target from the + // referenced field's metadata, which is not visible at parse time. + (p) => !(!p.field && (p.type === 'lookup' || p.type === 'master_detail') && !p.reference), + { + path: ['reference'], + message: + 'ActionParam with type "lookup"/"master_detail" requires "reference" (the target object) when declared inline — without it the param dialog degrades to a raw record-id text input. Set `reference: \'\'`, or use a field-backed param (`{ field: \'\' }`) to inherit it.', + }, ).transform((p, ctx) => lowerRequiresFeature(p, ctx))); /** From 2c6f347a55a071295c8889c8c17181aed3110e8a Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 23 Jul 2026 04:27:54 +0800 Subject: [PATCH 2/2] docs(spec): regenerate ui/action reference for inline lookup `reference` (#3410) Regenerates content/docs/references/ui/action.mdx so it reflects the new `reference` key on ActionParamSchema, fixing the check:docs CI step on #3406. Includes an empty changeset (docs-only regeneration releases nothing). --- .../regenerate-ui-action-reference-doc.md | 7 +++++++ content/docs/references/ui/action.mdx | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/regenerate-ui-action-reference-doc.md diff --git a/.changeset/regenerate-ui-action-reference-doc.md b/.changeset/regenerate-ui-action-reference-doc.md new file mode 100644 index 0000000000..d4835d6686 --- /dev/null +++ b/.changeset/regenerate-ui-action-reference-doc.md @@ -0,0 +1,7 @@ +--- +--- + +docs(spec): regenerate the `ui/action` reference doc for the inline lookup +`reference` key. Generated output only — the release for this change is +declared by the `@objectstack/spec` changeset in #3406, so this PR itself +releases nothing. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 8b0d15621b..d0e58c94ba 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -35,7 +35,21 @@ params: [ inline when no matching object field exists. Inline values may also be -used alongside `field` to override individual properties. +used alongside `field` to override individual properties. A `lookup` / + +`master_detail` param declared this way MUST name its target object via + +`reference` — there is no field to inherit it from: + +```ts + +params: [ + +\{ name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' \}, + +] + +``` `name` is required unless `field` is provided (in which case it defaults @@ -153,6 +167,7 @@ const result = Action.parse(data); | **multiple** | `boolean` | optional | Allow multiple values (array value shape); mirrors FieldSchema.multiple. | | **accept** | `string[]` | optional | Accepted upload types (MIME types / extensions) for file/image params. | | **maxSize** | `integer` | optional | Max upload size in bytes for file/image params. | +| **reference** | `string` | optional | Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference. | | **defaultFromRow** | `boolean` | optional | | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Param visibility predicate (CEL); omits the param when false. | | **requiresFeature** | `Enum<'twoFactor' \| 'passkeys' \| 'magicLink' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this param; lowered into `visible` at parse time. |