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
22 changes: 22 additions & 0 deletions .changeset/action-param-inline-lookup-reference.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/regenerate-ui-action-reference-doc.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 #' },
Expand Down
36 changes: 36 additions & 0 deletions packages/spec/src/ui/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -368,6 +402,7 @@ describe('ActionSchema', () => {
name: 'new_owner',
label: 'New Owner',
type: 'lookup',
reference: 'sys_user',
required: true,
},
{
Expand Down Expand Up @@ -497,6 +532,7 @@ describe('ActionSchema', () => {
name: 'new_owner',
label: 'New Owner',
type: 'lookup',
reference: 'sys_user',
required: true,
},
{
Expand Down
33 changes: 32 additions & 1 deletion packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: \'<object>\'`, or use a field-backed param (`{ field: \'<lookup_field>\' }`) to inherit it.',
},
).transform((p, ctx) => lowerRequiresFeature(p, ctx)));

/**
Expand Down