diff --git a/examples/app-showcase/src/data/index.ts b/examples/app-showcase/src/data/index.ts index fa1d82e164..8c02ccab38 100644 --- a/examples/app-showcase/src/data/index.ts +++ b/examples/app-showcase/src/data/index.ts @@ -27,6 +27,18 @@ const accounts = defineSeed(Account, { { 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' }, { 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' }, { 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' }, + // Extra accounts so the record picker has enough volume to exercise search, + // sorting, pagination and the (non-churned) scoping filter on invoices. + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, ], }); diff --git a/examples/app-showcase/src/objects/invoice.object.ts b/examples/app-showcase/src/objects/invoice.object.ts index 6770b80f8a..25201eb29b 100644 --- a/examples/app-showcase/src/objects/invoice.object.ts +++ b/examples/app-showcase/src/objects/invoice.object.ts @@ -44,7 +44,22 @@ export const Invoice = ObjectSchema.create({ fields: { name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }), - account: Field.lookup('showcase_account', { label: 'Account', required: true }), + // Forward record-picker config (spec lookup* props). The renderer + // auto-derives columns from the Account schema, but here we curate them and + // — crucially — SCOPE the picker so a rep can't invoice a churned account. + // `lookupFilters` is the structured, picker-honoured form of referenceFilters. + account: Field.lookup('showcase_account', { + label: 'Account', + required: true, + descriptionField: 'industry', + lookupColumns: [ + 'name', + { field: 'industry', label: 'Industry', type: 'select' }, + { field: 'status', label: 'Lifecycle', type: 'select' }, + { field: 'annual_revenue', label: 'Annual Revenue', type: 'currency' }, + ], + lookupFilters: [{ field: 'status', operator: 'ne', value: 'churned' }], + }), // Owner email — the row-level-security anchor. The `showcase_contributor` // permission set scopes invoice SELECT to `owner = current_user.email` (email // is the unique, seedable identifier), so a contributor sees only their own diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 78a10dad33..4a36a3b919 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -151,9 +151,33 @@ }, "referenceFilters": { "status": "dead", - "evidence": "lookup dialog reads lookup_filters (LookupField.tsx:171) — entirely dead as authored (naming drift)", + "evidence": "lookup dialog reads lookup_filters/lookupFilters (LookupField.tsx) — the string[] referenceFilters form is not read (naming drift)", "authorWarn": true, - "authorHint": "The lookup dialog reads `lookup_filters`, not `referenceFilters` — as authored this filters nothing. Use the supported lookup-filter key, or remove it to avoid implying a constrained lookup." + "authorHint": "The picker reads the structured `lookupFilters` ({field,operator,value}), not the string[] `referenceFilters` — as authored this filters nothing. Use `lookupFilters`, or remove this to avoid implying a constrained lookup." + }, + "displayField": { + "status": "live", + "note": "objectui LookupField/RecordPickerDialog — candidate label field in the record picker (reads displayField || display_field)." + }, + "descriptionField": { + "status": "live", + "note": "objectui LookupField — secondary line under each option in the quick-select popover (reads descriptionField || description_field)." + }, + "lookupColumns": { + "status": "live", + "note": "objectui RecordPickerDialog — record-picker table columns; renderer auto-derives from the referenced object when omitted (reads lookupColumns || lookup_columns)." + }, + "lookupPageSize": { + "status": "live", + "note": "objectui RecordPickerDialog — rows per page in the record-picker dialog (reads lookupPageSize || lookup_page_size)." + }, + "lookupFilters": { + "status": "live", + "note": "objectui LookupField + RecordPickerDialog — base scoping filters honoured by BOTH popover and dialog; structured supported form of referenceFilters (reads lookupFilters || lookup_filters)." + }, + "dependsOn": { + "status": "live", + "note": "objectui LookupField — dependent/cascading lookup; restricts candidates by another field's value (reads dependsOn || depends_on)." }, "maxRating": { "status": "dead", diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index fa2ef78477..9c63d7d6d4 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -296,6 +296,40 @@ describe('FieldSchema', () => { const result = FieldSchema.parse(lookupField); expect(result.deleteBehavior).toBe('set_null'); }); + + it('should preserve forward record-picker config (display/columns/filters/depends)', () => { + const lookupField: Field = { + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + displayField: 'name', + descriptionField: 'industry', + lookupColumns: ['name', { field: 'annual_revenue', label: 'Revenue', type: 'currency' }], + lookupPageSize: 20, + lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }], + dependsOn: ['region', { field: 'country', param: 'country_code' }], + }; + + const result = FieldSchema.parse(lookupField); + expect(result.displayField).toBe('name'); + expect(result.descriptionField).toBe('industry'); + expect(result.lookupColumns).toHaveLength(2); + expect(result.lookupPageSize).toBe(20); + expect(result.lookupFilters?.[0]).toEqual({ field: 'status', operator: 'eq', value: 'active' }); + expect(result.dependsOn).toHaveLength(2); + }); + + it('builds a lookup with picker config via Field.lookup', () => { + const f = Field.lookup('crm_account', { + label: 'Account', + lookupColumns: ['name', 'industry'], + lookupFilters: [{ field: 'status', operator: 'eq', value: 'active' }], + }); + const result = FieldSchema.parse({ name: 'account', ...f }); + expect(result.lookupColumns).toEqual(['name', 'industry']); + expect(result.lookupFilters?.[0].operator).toBe('eq'); + }); }); describe('Calculated Fields', () => { diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3dabc1ed91..bd231b23e6 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -445,6 +445,35 @@ export const FieldSchema = lazySchema(() => z.object({ /** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */ relatedListColumns: z.array(z.any()).optional().describe('Explicit columns for the detail-page related list (derived from the child object when omitted)'), + /** + * LOOKUP PICKER (forward) config — how THIS lookup/master_detail field's + * record picker presents and scopes candidate records when the user selects a + * related record. Read-side mirror is relatedList* (the PARENT's detail-page + * list of children); these configure the CHILD-side picker that chooses the + * parent. All optional: the renderer auto-derives a sensible multi-column + * result from the referenced object's schema when omitted (objectui + * packages/fields: LookupField / RecordPickerDialog / deriveLookupColumns, + * which read both these camelCase keys and their snake_case aliases). + */ + displayField: z.string().optional().describe("Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title)."), + descriptionField: z.string().optional().describe('Secondary field shown under the label in the quick-select popover.'), + lookupColumns: z.array(z.union([z.string(), z.object({ + field: z.string(), + label: z.string().optional(), + width: z.string().optional(), + type: z.string().optional(), + })])).optional().describe('Explicit columns for the record-picker table; auto-derived from the referenced object when omitted.'), + lookupPageSize: z.number().int().positive().optional().describe('Rows per page in the record-picker dialog (default 10).'), + lookupFilters: z.array(z.object({ + field: z.string(), + operator: z.enum(['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'contains', 'in', 'notIn']), + value: z.any(), + })).optional().describe('Base filters restricting which records are selectable (e.g. only active). Structured, picker-honoured form of referenceFilters.'), + dependsOn: z.array(z.union([z.string(), z.object({ + field: z.string(), + param: z.string().optional(), + })])).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.'), + /** Calculation — CEL formula. Plain string accepted for back-compat; build emits canonical envelope. */ expression: ExpressionInputSchema.optional().describe('Formula expression (CEL). e.g. F`record.amount * 0.1`'), summaryOperations: z.object({