Skip to content

Commit 7182011

Browse files
baozhoutaoclaude
andcommitted
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, 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 <noreply@anthropic.com>
1 parent 54f479a commit 7182011

4 files changed

Lines changed: 98 additions & 1 deletion

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
feat(spec): let an inline `lookup` action param declare its reference target (#3405)
6+
7+
`ActionParamSchema` had no way to name the object an inline record-picker param
8+
should search. Authors reasonably wrote the same key the field schema uses —
9+
`{ name: 'inspector', type: 'lookup', reference: 'sys_user' }` — and the schema
10+
stripped it as an unknown key, without an error. Downstream, the param dialog
11+
saw a picker with no target and degraded it to a "paste the record id (UUID)"
12+
text input. The authored intent was dropped silently and the user was handed a
13+
control that a human cannot reasonably operate.
14+
15+
- Added `reference` to `ActionParamSchema`, spelled to match
16+
`FieldSchema.reference` so one spelling works in both places. It sits with the
17+
existing inline widget config (`multiple` / `accept` / `maxSize`), which had
18+
covered the file/image params but not the picker ones.
19+
- A `lookup` / `master_detail` param declared **inline** with no `reference` is
20+
now a parse-time error pointing at the missing key, instead of degrading at
21+
render time. Field-backed params are unaffected: they inherit the target from
22+
the referenced field's metadata, which is not visible at parse time.

examples/app-showcase/src/ui/actions/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ export const ActionParamGalleryAction = defineAction({
204204
],
205205
},
206206
{ name: 'p_date', type: 'date', label: 'Effective date' },
207+
// #3405 — an INLINE record picker. `reference` names the object the picker
208+
// searches; without it the param would degrade to a "paste the record id
209+
// (UUID)" text box, which is what shipped before. Accounts are seeded with
210+
// enough volume (incl. a CJK name) to exercise search here.
211+
{
212+
name: 'p_account', type: 'lookup', reference: 'showcase_account', label: 'Related account',
213+
helpText: 'Inline lookup param — searchable record picker, no UUID typing.',
214+
},
207215
{ name: 'p_color', type: 'color', label: 'Accent color', defaultValue: '#7C3AED' },
208216
// Spec `autonumber` param → the AutoNumber widget (read-only, auto-assigned).
209217
{ name: 'p_reference', type: 'autonumber', label: 'Reference #' },

packages/spec/src/ui/action.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ describe('ActionParamSchema', () => {
7474
expect(() => ActionParamSchema.parse({ name: 'p', label: 'P', type })).not.toThrow();
7575
}
7676
});
77+
78+
// #3405 — an inline record-picker param carries its own target object.
79+
// Before this, `reference` was an unknown key: zod stripped it silently and
80+
// the param dialog degraded to a "paste the record id (UUID)" text input,
81+
// with no signal that the authored config had been dropped.
82+
describe('inline lookup reference target (#3405)', () => {
83+
it('keeps `reference` on an inline lookup param', () => {
84+
const result = ActionParamSchema.parse({
85+
name: 'inspector',
86+
label: 'Inspector',
87+
type: 'lookup' as const,
88+
reference: 'sys_user',
89+
required: true,
90+
});
91+
expect(result.reference).toBe('sys_user');
92+
});
93+
94+
it('rejects an inline lookup/master_detail param with no reference target', () => {
95+
for (const type of ['lookup', 'master_detail'] as const) {
96+
const result = ActionParamSchema.safeParse({ name: 'owner', label: 'Owner', type });
97+
expect(result.success).toBe(false);
98+
expect(result.error?.issues[0]?.path).toEqual(['reference']);
99+
}
100+
});
101+
102+
it('allows a field-backed lookup param to omit it (inherited from the field at runtime)', () => {
103+
expect(() => ActionParamSchema.parse({ field: 'inspector', type: 'lookup' as const })).not.toThrow();
104+
expect(() => ActionParamSchema.parse({ field: 'inspector' })).not.toThrow();
105+
});
106+
107+
it('leaves `reference` undefined for non-picker params', () => {
108+
expect(ActionParamSchema.parse({ name: 'note', type: 'textarea' as const }).reference).toBeUndefined();
109+
});
110+
});
77111
});
78112

79113
// #2874 P1 — declarative `requiresFeature` sugar, lowered at parse time into
@@ -368,6 +402,7 @@ describe('ActionSchema', () => {
368402
name: 'new_owner',
369403
label: 'New Owner',
370404
type: 'lookup',
405+
reference: 'sys_user',
371406
required: true,
372407
},
373408
{
@@ -497,6 +532,7 @@ describe('ActionSchema', () => {
497532
name: 'new_owner',
498533
label: 'New Owner',
499534
type: 'lookup',
535+
reference: 'sys_user',
500536
required: true,
501537
},
502538
{

packages/spec/src/ui/action.zod.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,15 @@ import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/publi
3131
*
3232
* 2. **Inline** (legacy / bespoke) — declare `name`, `label`, `type` etc.
3333
* inline when no matching object field exists. Inline values may also be
34-
* used alongside `field` to override individual properties.
34+
* used alongside `field` to override individual properties. A `lookup` /
35+
* `master_detail` param declared this way MUST name its target object via
36+
* `reference` — there is no field to inherit it from:
37+
*
38+
* ```ts
39+
* params: [
40+
* { name: 'inspector', label: 'Inspector', type: 'lookup', reference: 'sys_user' },
41+
* ]
42+
* ```
3543
*
3644
* `name` is required unless `field` is provided (in which case it defaults
3745
* to the field name and is used as the request-body key).
@@ -76,6 +84,19 @@ export const ActionParamSchema = lazySchema(() => z.object({
7684
accept: z.array(z.string()).optional().describe('Accepted upload types (MIME types / extensions) for file/image params.'),
7785
/** Max upload size in bytes for `file`/`image` params. */
7886
maxSize: z.number().int().positive().optional().describe('Max upload size in bytes for file/image params.'),
87+
/**
88+
* Reference target for an inline `lookup` / `master_detail` param — the
89+
* object whose records the picker searches. Field-backed params inherit it
90+
* from the referenced field, so it is only needed inline.
91+
*
92+
* Without it the dialog cannot query anything and degrades to a plain text
93+
* input asking for a raw record id, which is unusable for a human — hence
94+
* the `.refine()` below rejects a targetless lookup param at parse time.
95+
*
96+
* Key name deliberately mirrors `FieldSchema.reference` so the same spelling
97+
* works in both places.
98+
*/
99+
reference: SnakeCaseIdentifierSchema.optional().describe('Reference target object for inline lookup/master_detail params; mirrors FieldSchema.reference.'),
79100
/**
80101
* When true, the param's default value is pulled from the current row record
81102
* (key = the resolved field name) when the action runs from a list_item
@@ -105,6 +126,16 @@ export const ActionParamSchema = lazySchema(() => z.object({
105126
}).refine(
106127
(p) => Boolean(p.name) || Boolean(p.field),
107128
{ message: 'ActionParam requires either "name" or "field"' },
129+
).refine(
130+
// An INLINE record-picker param must name its target object. Only inline
131+
// params are checked: a field-backed one inherits the target from the
132+
// referenced field's metadata, which is not visible at parse time.
133+
(p) => !(!p.field && (p.type === 'lookup' || p.type === 'master_detail') && !p.reference),
134+
{
135+
path: ['reference'],
136+
message:
137+
'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.',
138+
},
108139
).transform((p, ctx) => lowerRequiresFeature(p, ctx)));
109140

110141
/**

0 commit comments

Comments
 (0)