Skip to content

Commit 6334f4c

Browse files
os-zhuangclaude
andcommitted
feat(spec,showcase): lookup record-picker authoring metadata + demo
The objectui record picker can present multi-column, scoped, dependent lookup results, but FieldSchema exposed none of the authoring surface for it — so the props were silently dropped by defineStack's Zod parse (cf. the FieldSchema property-liveness audit, which flags referenceFilters reaching the renderer under a different key). Authors had no supported way to curate or scope a picker. spec (FieldSchema, packages/spec/src/data/field.zod.ts): - Add forward record-picker props to the lookup/master_detail surface: `displayField`, `descriptionField`, `lookupColumns`, `lookupPageSize`, `lookupFilters` (structured, picker-honoured form of referenceFilters), and `dependsOn` (dependent/cascading lookups). All are genuinely wired — the objectui renderer reads them (camelCase + snake_case aliases). They flow through Field.lookup() automatically via FieldInput. - Tests: assert the props survive FieldSchema.parse (not stripped) and that Field.lookup() carries them through. showcase (examples/app-showcase): - Expand the account seed from 3 → 13 (varied industry / revenue / lifecycle, incl. churned) so the picker exercises search, sort, pagination and scoping. - Configure showcase_invoice.account with curated `lookupColumns` (name / industry / lifecycle / revenue), a `descriptionField`, and a `lookupFilters` rule that hides churned accounts — a rep can't invoice a churned customer. Demonstrates the new authoring surface end-to-end. Pairs with objectui PR #1860 (renderer: auto-derive columns + i18n + accept both key casings + apply lookupFilters in the popover). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 173f420 commit 6334f4c

4 files changed

Lines changed: 91 additions & 1 deletion

File tree

examples/app-showcase/src/data/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ const accounts = defineSeed(Account, {
2727
{ name: 'Northwind', industry: 'retail', annual_revenue: 8_000_000, website: 'https://northwind.example', status: 'active', hq: { lat: 47.6062, lng: -122.3321 }, tax_id: '91-1144442', billing_email: 'ap@northwind.example' },
2828
{ name: 'Contoso', industry: 'technology', annual_revenue: 25_000_000, website: 'https://contoso.example', status: 'active', hq: { lat: 37.7749, lng: -122.4194 }, tax_id: '20-3399881', billing_email: 'billing@contoso.example' },
2929
{ name: 'Fabrikam', industry: 'healthcare', annual_revenue: 12_000_000, website: 'https://fabrikam.example', status: 'prospect', hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '46-7782013', billing_email: 'accounts@fabrikam.example' },
30+
// Extra accounts so the record picker has enough volume to exercise search,
31+
// sorting, pagination and the (non-churned) scoping filter on invoices.
32+
{ name: 'Initech', industry: 'finance', annual_revenue: 5_400_000, website: 'https://initech.example', status: 'active', hq: { lat: 30.2672, lng: -97.7431 }, tax_id: '74-2233110', billing_email: 'ap@initech.example' },
33+
{ name: 'Globex', industry: 'technology', annual_revenue: 42_000_000, website: 'https://globex.example', status: 'active', hq: { lat: 34.0522, lng: -118.2437 }, tax_id: '95-8841200', billing_email: 'billing@globex.example' },
34+
{ name: 'Stark Industries', industry: 'technology', annual_revenue: 180_000_000, website: 'https://stark.example', status: 'active', hq: { lat: 40.7580, lng: -73.9855 }, tax_id: '13-5567421', billing_email: 'ap@stark.example' },
35+
{ name: 'Wayne Enterprises', industry: 'finance', annual_revenue: 210_000_000, website: 'https://wayne.example', status: 'active', hq: { lat: 40.7128, lng: -74.0060 }, tax_id: '22-9087733', billing_email: 'billing@wayne.example' },
36+
{ name: 'Acme Retail', industry: 'retail', annual_revenue: 3_200_000, website: 'https://acme.example', status: 'prospect', hq: { lat: 41.8781, lng: -87.6298 }, tax_id: '36-4471209', billing_email: 'accounts@acme.example' },
37+
{ name: 'Soylent Foods', industry: 'healthcare', annual_revenue: 9_900_000, website: 'https://soylent.example', status: 'prospect', hq: { lat: 37.3382, lng: -121.8863 }, tax_id: '77-1029384', billing_email: 'ap@soylent.example' },
38+
{ name: 'Hooli', industry: 'technology', annual_revenue: 96_000_000, website: 'https://hooli.example', status: 'active', hq: { lat: 37.3861, lng: -122.0839 }, tax_id: '45-7781230', billing_email: 'billing@hooli.example' },
39+
{ name: 'Vandelay Industries', industry: 'finance', annual_revenue: 6_700_000, website: 'https://vandelay.example', status: 'active', hq: { lat: 40.6782, lng: -73.9442 }, tax_id: '11-3344556', billing_email: 'ap@vandelay.example' },
40+
{ name: 'Umbrella Health', industry: 'healthcare', annual_revenue: 33_000_000, website: 'https://umbrella.example', status: 'churned', hq: { lat: 39.9526, lng: -75.1652 }, tax_id: '88-2200117', churn_reason: 'Switched to in-house platform', billing_email: 'accounts@umbrella.example' },
41+
{ name: 'Wonka Brands', industry: 'retail', annual_revenue: 14_500_000, website: 'https://wonka.example', status: 'churned', hq: { lat: 41.4993, lng: -81.6944 }, tax_id: '52-7741093', churn_reason: 'Budget cuts', billing_email: 'ap@wonka.example' },
3042
],
3143
});
3244

examples/app-showcase/src/objects/invoice.object.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,22 @@ export const Invoice = ObjectSchema.create({
4444

4545
fields: {
4646
name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }),
47-
account: Field.lookup('showcase_account', { label: 'Account', required: true }),
47+
// Forward record-picker config (spec lookup* props). The renderer
48+
// auto-derives columns from the Account schema, but here we curate them and
49+
// — crucially — SCOPE the picker so a rep can't invoice a churned account.
50+
// `lookupFilters` is the structured, picker-honoured form of referenceFilters.
51+
account: Field.lookup('showcase_account', {
52+
label: 'Account',
53+
required: true,
54+
descriptionField: 'industry',
55+
lookupColumns: [
56+
'name',
57+
{ field: 'industry', label: 'Industry', type: 'select' },
58+
{ field: 'status', label: 'Lifecycle', type: 'select' },
59+
{ field: 'annual_revenue', label: 'Annual Revenue', type: 'currency' },
60+
],
61+
lookupFilters: [{ field: 'status', operator: 'ne', value: 'churned' }],
62+
}),
4863
// Owner email — the row-level-security anchor. The `showcase_contributor`
4964
// permission set scopes invoice SELECT to `owner = current_user.email` (email
5065
// is the unique, seedable identifier), so a contributor sees only their own

packages/spec/src/data/field.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,40 @@ describe('FieldSchema', () => {
296296
const result = FieldSchema.parse(lookupField);
297297
expect(result.deleteBehavior).toBe('set_null');
298298
});
299+
300+
it('should preserve forward record-picker config (display/columns/filters/depends)', () => {
301+
const lookupField: Field = {
302+
name: 'account',
303+
label: 'Account',
304+
type: 'lookup',
305+
reference: 'crm_account',
306+
displayField: 'name',
307+
descriptionField: 'industry',
308+
lookupColumns: ['name', { field: 'annual_revenue', label: 'Revenue', type: 'currency' }],
309+
lookupPageSize: 20,
310+
lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }],
311+
dependsOn: ['region', { field: 'country', param: 'country_code' }],
312+
};
313+
314+
const result = FieldSchema.parse(lookupField);
315+
expect(result.displayField).toBe('name');
316+
expect(result.descriptionField).toBe('industry');
317+
expect(result.lookupColumns).toHaveLength(2);
318+
expect(result.lookupPageSize).toBe(20);
319+
expect(result.lookupFilters?.[0]).toEqual({ field: 'status', operator: 'eq', value: 'active' });
320+
expect(result.dependsOn).toHaveLength(2);
321+
});
322+
323+
it('builds a lookup with picker config via Field.lookup', () => {
324+
const f = Field.lookup('crm_account', {
325+
label: 'Account',
326+
lookupColumns: ['name', 'industry'],
327+
lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }],
328+
});
329+
const result = FieldSchema.parse({ name: 'account', ...f });
330+
expect(result.lookupColumns).toEqual(['name', 'industry']);
331+
expect(result.lookupFilters?.[0].operator).toBe('eq');
332+
});
299333
});
300334

301335
describe('Calculated Fields', () => {

packages/spec/src/data/field.zod.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,35 @@ export const FieldSchema = lazySchema(() => z.object({
445445
/** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */
446446
relatedListColumns: z.array(z.any()).optional().describe('Explicit columns for the detail-page related list (derived from the child object when omitted)'),
447447

448+
/**
449+
* LOOKUP PICKER (forward) config — how THIS lookup/master_detail field's
450+
* record picker presents and scopes candidate records when the user selects a
451+
* related record. Read-side mirror is relatedList* (the PARENT's detail-page
452+
* list of children); these configure the CHILD-side picker that chooses the
453+
* parent. All optional: the renderer auto-derives a sensible multi-column
454+
* result from the referenced object's schema when omitted (objectui
455+
* packages/fields: LookupField / RecordPickerDialog / deriveLookupColumns,
456+
* which read both these camelCase keys and their snake_case aliases).
457+
*/
458+
displayField: z.string().optional().describe("Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title)."),
459+
descriptionField: z.string().optional().describe('Secondary field shown under the label in the quick-select popover.'),
460+
lookupColumns: z.array(z.union([z.string(), z.object({
461+
field: z.string(),
462+
label: z.string().optional(),
463+
width: z.string().optional(),
464+
type: z.string().optional(),
465+
})])).optional().describe('Explicit columns for the record-picker table; auto-derived from the referenced object when omitted.'),
466+
lookupPageSize: z.number().int().positive().optional().describe('Rows per page in the record-picker dialog (default 10).'),
467+
lookupFilters: z.array(z.object({
468+
field: z.string(),
469+
operator: z.enum(['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'contains', 'in', 'notIn']),
470+
value: z.any(),
471+
})).optional().describe('Base filters restricting which records are selectable (e.g. only active). Structured, picker-honoured form of referenceFilters.'),
472+
dependsOn: z.array(z.union([z.string(), z.object({
473+
field: z.string(),
474+
param: z.string().optional(),
475+
})])).optional().describe('Dependent lookup: restrict candidates by the value of other field(s) on the same record. String = same local/remote key; {field,param} when the remote filter key differs.'),
476+
448477
/** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */
449478
expression: ExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'),
450479
summaryOperations: z.object({

0 commit comments

Comments
 (0)