From 6b5f74b3437b03227a59523859415f4f561dc704 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:01:27 +0000 Subject: [PATCH 1/4] Initial plan From 4f7171270a423ecd4af6754098133b07577ab681 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:23:05 +0000 Subject: [PATCH 2/4] feat(crm): add enterprise lookup metadata to all CRM lookup fields Add lookup_columns, lookup_filters, description_field to all 14 lookup fields across 8 CRM objects. Use post-create Object.assign injection pattern to bypass ObjectSchema.create() Zod stripping. Add 12 new enterprise lookup tests covering: - lookup_columns presence and type hints - lookup_filters presence and operator validity - description_field coverage - Diverse cell types (select, currency, boolean, date) - Diverse filter operators (eq, ne, in, notIn) - Specific business logic validations Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../crm/src/__tests__/crm-metadata.test.ts | 159 ++++++++++++++++++ examples/crm/src/objects/account.object.ts | 20 ++- examples/crm/src/objects/contact.object.ts | 20 ++- examples/crm/src/objects/event.object.ts | 34 +++- .../crm/src/objects/opportunity.object.ts | 35 +++- .../src/objects/opportunity_contact.object.ts | 34 +++- examples/crm/src/objects/order.object.ts | 34 +++- examples/crm/src/objects/order_item.object.ts | 34 +++- examples/crm/src/objects/project.object.ts | 34 +++- 9 files changed, 396 insertions(+), 8 deletions(-) diff --git a/examples/crm/src/__tests__/crm-metadata.test.ts b/examples/crm/src/__tests__/crm-metadata.test.ts index a395e5d37..a49be56bc 100644 --- a/examples/crm/src/__tests__/crm-metadata.test.ts +++ b/examples/crm/src/__tests__/crm-metadata.test.ts @@ -402,6 +402,165 @@ describe('CRM Metadata Spec Compliance', () => { } }); }); + + // ------------------------------------------------------------------ + // Enterprise Lookup Field Configuration + // ------------------------------------------------------------------ + + describe('Enterprise Lookup Metadata', () => { + /** Extract all lookup fields from an object definition */ + function getLookupFields(obj: Record): Array<[string, Record]> { + return Object.entries(obj.fields).filter( + ([, f]: [string, any]) => f.type === 'lookup' || f.type === 'master_detail', + ) as Array<[string, Record]>; + } + + it('every CRM lookup field has lookup_columns configured', () => { + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [fieldName, field] of lookups) { + expect(field.lookup_columns, `${obj.name}.${fieldName} missing lookup_columns`).toBeDefined(); + expect(Array.isArray(field.lookup_columns)).toBe(true); + expect(field.lookup_columns.length).toBeGreaterThanOrEqual(2); + } + } + }); + + it('every CRM lookup field has lookup_filters configured', () => { + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [fieldName, field] of lookups) { + expect(field.lookup_filters, `${obj.name}.${fieldName} missing lookup_filters`).toBeDefined(); + expect(Array.isArray(field.lookup_filters)).toBe(true); + expect(field.lookup_filters.length).toBeGreaterThanOrEqual(1); + } + } + }); + + it('every CRM lookup field has description_field configured', () => { + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [fieldName, field] of lookups) { + expect(field.description_field, `${obj.name}.${fieldName} missing description_field`).toBeDefined(); + expect(typeof field.description_field).toBe('string'); + } + } + }); + + it('lookup_columns include at least one column with a type hint for cell rendering', () => { + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [fieldName, field] of lookups) { + const cols = field.lookup_columns as Array>; + const typedCols = cols.filter( + (c) => typeof c === 'object' && c.type, + ); + expect( + typedCols.length, + `${obj.name}.${fieldName} has no typed columns for cell rendering`, + ).toBeGreaterThanOrEqual(1); + } + } + }); + + it('lookup_columns cover diverse cell types (select, currency, boolean, date)', () => { + const allTypedColumns: string[] = []; + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [, field] of lookups) { + const cols = field.lookup_columns as Array>; + for (const c of cols) { + if (typeof c === 'object' && c.type) { + allTypedColumns.push(c.type); + } + } + } + } + const uniqueTypes = new Set(allTypedColumns); + expect(uniqueTypes.has('select')).toBe(true); + expect(uniqueTypes.has('currency')).toBe(true); + expect(uniqueTypes.has('boolean')).toBe(true); + expect(uniqueTypes.has('date')).toBe(true); + }); + + it('lookup_filters have valid operator values', () => { + const validOperators = ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'contains', 'in', 'notIn']; + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [fieldName, field] of lookups) { + for (const filter of field.lookup_filters) { + expect(filter).toHaveProperty('field'); + expect(filter).toHaveProperty('operator'); + expect(filter).toHaveProperty('value'); + expect( + validOperators, + `${obj.name}.${fieldName} filter operator "${filter.operator}" invalid`, + ).toContain(filter.operator); + } + } + } + }); + + it('lookup_filters cover diverse operators (eq, ne, in, notIn)', () => { + const allOperators: string[] = []; + for (const obj of allObjects) { + const lookups = getLookupFields(obj); + for (const [, field] of lookups) { + for (const filter of field.lookup_filters) { + allOperators.push(filter.operator); + } + } + } + const uniqueOps = new Set(allOperators); + expect(uniqueOps.has('eq')).toBe(true); + expect(uniqueOps.has('in')).toBe(true); + expect(uniqueOps.has('notIn')).toBe(true); + expect(uniqueOps.has('ne')).toBe(true); + }); + + it('account.owner references user with active-only filter', () => { + const owner = (AccountObject.fields as any).owner; + expect(owner.reference).toBe('user'); + expect(owner.description_field).toBe('email'); + expect(owner.lookup_filters).toEqual([{ field: 'active', operator: 'eq', value: true }]); + }); + + it('opportunity.account references account with type filter', () => { + const account = (OpportunityObject.fields as any).account; + expect(account.reference).toBe('account'); + expect(account.description_field).toBe('industry'); + const typeFilter = account.lookup_filters.find((f: any) => f.field === 'type'); + expect(typeFilter).toBeDefined(); + expect(typeFilter.operator).toBe('in'); + expect(typeFilter.value).toContain('Customer'); + }); + + it('order_item.product filters active products only', () => { + const product = (OrderItemObject.fields as any).product; + expect(product.reference).toBe('product'); + expect(product.description_field).toBe('sku'); + expect(product.lookup_filters).toEqual([{ field: 'is_active', operator: 'eq', value: true }]); + const cols = product.lookup_columns as Array>; + expect(cols.find((c) => c.field === 'price')?.type).toBe('currency'); + expect(cols.find((c) => c.field === 'stock')?.type).toBe('number'); + expect(cols.find((c) => c.field === 'is_active')?.type).toBe('boolean'); + }); + + it('opportunity_contact.opportunity filters out closed stages', () => { + const opp = (OpportunityContactObject.fields as any).opportunity; + expect(opp.reference).toBe('opportunity'); + const stageFilter = opp.lookup_filters.find((f: any) => f.field === 'stage'); + expect(stageFilter).toBeDefined(); + expect(stageFilter.operator).toBe('notIn'); + expect(stageFilter.value).toContain('closed_won'); + expect(stageFilter.value).toContain('closed_lost'); + }); + + it('opportunity.contacts has lookup_page_size for multi-select', () => { + const contacts = (OpportunityObject.fields as any).contacts; + expect(contacts.lookup_page_size).toBe(15); + }); + }); }); // ==================================================================== diff --git a/examples/crm/src/objects/account.object.ts b/examples/crm/src/objects/account.object.ts index e42bf3cc8..1e11c03c1 100644 --- a/examples/crm/src/objects/account.object.ts +++ b/examples/crm/src/objects/account.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const AccountObject = ObjectSchema.create({ +const _AccountObject = ObjectSchema.create({ name: 'account', label: 'Account', icon: 'building-2', @@ -40,3 +40,21 @@ export const AccountObject = ObjectSchema.create({ created_at: Field.datetime({ label: 'Created Date', readonly: true }) } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_AccountObject.fields.owner, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'role', label: 'Role', type: 'select' }, + { field: 'department', label: 'Department' }, + { field: 'active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'active', operator: 'eq', value: true }, + ], +}); + +export const AccountObject = _AccountObject; diff --git a/examples/crm/src/objects/contact.object.ts b/examples/crm/src/objects/contact.object.ts index a5427891c..953f65974 100644 --- a/examples/crm/src/objects/contact.object.ts +++ b/examples/crm/src/objects/contact.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const ContactObject = ObjectSchema.create({ +const _ContactObject = ObjectSchema.create({ name: 'contact', label: 'Contact', icon: 'user', @@ -35,3 +35,21 @@ export const ContactObject = ObjectSchema.create({ notes: Field.richtext({ label: 'Notes' }) } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_ContactObject.fields.account, { + description_field: 'industry', + lookup_columns: [ + { field: 'name', label: 'Account Name' }, + { field: 'industry', label: 'Industry', type: 'select' }, + { field: 'rating', label: 'Rating', type: 'select' }, + { field: 'type', label: 'Type', type: 'select' }, + { field: 'annual_revenue', label: 'Revenue', type: 'currency', width: '120px' }, + ], + lookup_filters: [ + { field: 'type', operator: 'in', value: ['Customer', 'Partner'] }, + ], +}); + +export const ContactObject = _ContactObject; diff --git a/examples/crm/src/objects/event.object.ts b/examples/crm/src/objects/event.object.ts index 2a3670349..845406bc5 100644 --- a/examples/crm/src/objects/event.object.ts +++ b/examples/crm/src/objects/event.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const EventObject = ObjectSchema.create({ +const _EventObject = ObjectSchema.create({ name: 'event', label: 'Event', icon: 'calendar', @@ -36,3 +36,35 @@ export const EventObject = ObjectSchema.create({ ], { label: 'Reminder', defaultValue: 'min_15' }) } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_EventObject.fields.participants, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'company', label: 'Company' }, + { field: 'status', label: 'Status', type: 'select' }, + { field: 'is_active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}); + +Object.assign(_EventObject.fields.organizer, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'role', label: 'Role', type: 'select' }, + { field: 'department', label: 'Department' }, + { field: 'active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'active', operator: 'eq', value: true }, + ], +}); + +export const EventObject = _EventObject; diff --git a/examples/crm/src/objects/opportunity.object.ts b/examples/crm/src/objects/opportunity.object.ts index a52428b15..ae7eba970 100644 --- a/examples/crm/src/objects/opportunity.object.ts +++ b/examples/crm/src/objects/opportunity.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const OpportunityObject = ObjectSchema.create({ +const _OpportunityObject = ObjectSchema.create({ name: 'opportunity', label: 'Opportunity', icon: 'trending-up', @@ -34,3 +34,36 @@ export const OpportunityObject = ObjectSchema.create({ description: Field.richtext({ label: 'Description' }) } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_OpportunityObject.fields.account, { + description_field: 'industry', + lookup_columns: [ + { field: 'name', label: 'Account Name' }, + { field: 'industry', label: 'Industry', type: 'select' }, + { field: 'rating', label: 'Rating', type: 'select' }, + { field: 'type', label: 'Type', type: 'select' }, + { field: 'annual_revenue', label: 'Revenue', type: 'currency', width: '120px' }, + ], + lookup_filters: [ + { field: 'type', operator: 'in', value: ['Customer', 'Partner'] }, + ], +}); + +Object.assign(_OpportunityObject.fields.contacts, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'company', label: 'Company' }, + { field: 'status', label: 'Status', type: 'select' }, + { field: 'is_active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], + lookup_page_size: 15, +}); + +export const OpportunityObject = _OpportunityObject; diff --git a/examples/crm/src/objects/opportunity_contact.object.ts b/examples/crm/src/objects/opportunity_contact.object.ts index 308f622de..7b6406890 100644 --- a/examples/crm/src/objects/opportunity_contact.object.ts +++ b/examples/crm/src/objects/opportunity_contact.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const OpportunityContactObject = ObjectSchema.create({ +const _OpportunityContactObject = ObjectSchema.create({ name: 'opportunity_contact', label: 'Opportunity Contact', icon: 'link', @@ -19,3 +19,35 @@ export const OpportunityContactObject = ObjectSchema.create({ is_primary: Field.boolean({ label: 'Primary Contact', defaultValue: false }), } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_OpportunityContactObject.fields.opportunity, { + description_field: 'stage', + lookup_columns: [ + { field: 'name', label: 'Opportunity Name' }, + { field: 'amount', label: 'Amount', type: 'currency', width: '120px' }, + { field: 'stage', label: 'Stage', type: 'select' }, + { field: 'close_date', label: 'Close Date', type: 'date' }, + { field: 'probability', label: 'Probability', type: 'percent', width: '100px' }, + ], + lookup_filters: [ + { field: 'stage', operator: 'notIn', value: ['closed_won', 'closed_lost'] }, + ], +}); + +Object.assign(_OpportunityContactObject.fields.contact, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'company', label: 'Company' }, + { field: 'status', label: 'Status', type: 'select' }, + { field: 'is_active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}); + +export const OpportunityContactObject = _OpportunityContactObject; diff --git a/examples/crm/src/objects/order.object.ts b/examples/crm/src/objects/order.object.ts index 9a92a7f00..dbf777cf7 100644 --- a/examples/crm/src/objects/order.object.ts +++ b/examples/crm/src/objects/order.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const OrderObject = ObjectSchema.create({ +const _OrderObject = ObjectSchema.create({ name: 'order', label: 'Order', icon: 'shopping-cart', @@ -26,3 +26,35 @@ export const OrderObject = ObjectSchema.create({ notes: Field.richtext({ label: 'Notes' }) } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_OrderObject.fields.customer, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'company', label: 'Company' }, + { field: 'status', label: 'Status', type: 'select' }, + { field: 'is_active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}); + +Object.assign(_OrderObject.fields.account, { + description_field: 'industry', + lookup_columns: [ + { field: 'name', label: 'Account Name' }, + { field: 'industry', label: 'Industry', type: 'select' }, + { field: 'rating', label: 'Rating', type: 'select' }, + { field: 'type', label: 'Type', type: 'select' }, + { field: 'annual_revenue', label: 'Revenue', type: 'currency', width: '120px' }, + ], + lookup_filters: [ + { field: 'type', operator: 'in', value: ['Customer', 'Partner'] }, + ], +}); + +export const OrderObject = _OrderObject; diff --git a/examples/crm/src/objects/order_item.object.ts b/examples/crm/src/objects/order_item.object.ts index 48976f9dc..4673e010f 100644 --- a/examples/crm/src/objects/order_item.object.ts +++ b/examples/crm/src/objects/order_item.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const OrderItemObject = ObjectSchema.create({ +const _OrderItemObject = ObjectSchema.create({ name: 'order_item', label: 'Order Item', icon: 'list-ordered', @@ -21,3 +21,35 @@ export const OrderItemObject = ObjectSchema.create({ notes: Field.text({ label: 'Notes' }), } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_OrderItemObject.fields.order, { + description_field: 'status', + lookup_columns: [ + { field: 'name', label: 'Order Number' }, + { field: 'amount', label: 'Amount', type: 'currency', width: '120px' }, + { field: 'status', label: 'Status', type: 'select' }, + { field: 'order_date', label: 'Order Date', type: 'date' }, + ], + lookup_filters: [ + { field: 'status', operator: 'ne', value: 'cancelled' }, + ], +}); + +Object.assign(_OrderItemObject.fields.product, { + description_field: 'sku', + lookup_columns: [ + { field: 'name', label: 'Product Name' }, + { field: 'sku', label: 'SKU' }, + { field: 'category', label: 'Category', type: 'select' }, + { field: 'price', label: 'Price', type: 'currency', width: '100px' }, + { field: 'stock', label: 'Stock', type: 'number', width: '80px' }, + { field: 'is_active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'is_active', operator: 'eq', value: true }, + ], +}); + +export const OrderItemObject = _OrderItemObject; diff --git a/examples/crm/src/objects/project.object.ts b/examples/crm/src/objects/project.object.ts index df9fea3fd..053e790a5 100644 --- a/examples/crm/src/objects/project.object.ts +++ b/examples/crm/src/objects/project.object.ts @@ -1,6 +1,6 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; -export const ProjectObject = ObjectSchema.create({ +const _ProjectObject = ObjectSchema.create({ name: 'project_task', label: 'Project Task', icon: 'kanban-square', @@ -31,3 +31,35 @@ export const ProjectObject = ObjectSchema.create({ dependencies: Field.text({ label: 'Dependencies' }), } }); + +// Enterprise lookup metadata — injected post-create because ObjectSchema.create() +// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }). +Object.assign(_ProjectObject.fields.manager, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'role', label: 'Role', type: 'select' }, + { field: 'department', label: 'Department' }, + { field: 'active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'active', operator: 'eq', value: true }, + ], +}); + +Object.assign(_ProjectObject.fields.assignee, { + description_field: 'email', + lookup_columns: [ + { field: 'name', label: 'Name' }, + { field: 'email', label: 'Email' }, + { field: 'role', label: 'Role', type: 'select' }, + { field: 'department', label: 'Department' }, + { field: 'active', label: 'Active', type: 'boolean', width: '80px' }, + ], + lookup_filters: [ + { field: 'active', operator: 'eq', value: true }, + ], +}); + +export const ProjectObject = _ProjectObject; From 23db6bc8eb5fb2b96615a106957fafc6b4b2477e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:24:07 +0000 Subject: [PATCH 3/4] docs: update CHANGELOG.md and ROADMAP.md with enterprise lookup metadata changes Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 2 ++ ROADMAP.md | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e69f556..9a19c6575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **CRM Enterprise Lookup Metadata** (`examples/crm`): All 14 lookup fields across 8 CRM objects now have enterprise-grade RecordPicker configuration — `lookup_columns` (with type hints for cell rendering: select, currency, boolean, date, number, percent), `lookup_filters` (base business filters using eq/ne/in/notIn operators), and `description_field`. Uses post-create `Object.assign` injection pattern to bypass `ObjectSchema.create()` Zod stripping (analogous to the listViews passthrough approach). +- **Enterprise Lookup Tests** (`examples/crm`): 12 new test cases validating lookup_columns presence & type diversity, lookup_filters operator validity, description_field coverage, and specific business logic (e.g., active-only users, non-cancelled orders, open opportunities). - **RecordPickerDialog Component** (`@object-ui/fields`): New enterprise-grade record selection dialog with multi-column table display, pagination, search, column sorting with `$orderby`, keyboard navigation (Arrow keys + Enter), loading/error/empty states, and single/multi-select support. Responsive layout with mobile-friendly width. Provides the foundation for Salesforce-style Lookup experience. - **LookupField Popover Typeahead** (`@object-ui/fields`): Level 1 quick-select upgraded from Dialog to Popover for inline typeahead experience (anchored dropdown, not modal). Includes "Show All Results" footer button that opens the full RecordPickerDialog (Level 2). - **LookupFieldMetadata Schema Enhancement** (`@object-ui/types`): Added `lookup_columns`, `description_field`, `lookup_page_size`, `lookup_filters` to `LookupFieldMetadata`. New `LookupColumnDef` interface with `type` hint for cell formatting. New `LookupFilterDef` interface for base filter configuration. diff --git a/ROADMAP.md b/ROADMAP.md index c88489933..8e1acdced 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Lookup field context DataSource — reads DataSource from SchemaRendererContext so forms work without explicit prop - [x] Lookup field UX polish — arrow key navigation, description field display, quick-create entry, ARIA listbox roles - [x] Enterprise Record Picker — `RecordPickerDialog` component with multi-column table, pagination, search; LookupField two-level interaction (quick-select + "Show All Results" → full picker); `lookup_columns` / `lookup_page_size` schema config +- [x] CRM Enterprise Lookup Metadata — all 14 lookup fields across 8 CRM objects configured with `lookup_columns` (type-aware cell rendering), `lookup_filters` (business-level base filters), `description_field`; uses post-create injection to bypass `ObjectSchema.create()` Zod stripping; 12 dedicated test cases - [ ] Form conditional logic with branching - [ ] Multi-page forms with progress indicator From 0b276ee592b0edb9c77c337f4124bcf32f224c8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:24:57 +0000 Subject: [PATCH 4/4] test(crm): add query parameter injection and filter bar integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 integration tests that verify CRM metadata compatibility with RecordPickerDialog enterprise features: - lookup_filters → $filter query parameter conversion (eq, $in, $ne, $nin) - lookup_columns type hints → filter bar configuration (select, number, boolean) - All CRM filters produce valid non-empty $filter records Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../crm/src/__tests__/crm-metadata.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/examples/crm/src/__tests__/crm-metadata.test.ts b/examples/crm/src/__tests__/crm-metadata.test.ts index a49be56bc..2c891a2fc 100644 --- a/examples/crm/src/__tests__/crm-metadata.test.ts +++ b/examples/crm/src/__tests__/crm-metadata.test.ts @@ -561,6 +561,127 @@ describe('CRM Metadata Spec Compliance', () => { expect(contacts.lookup_page_size).toBe(15); }); }); + + // ------------------------------------------------------------------ + // Enterprise Query Parameter Injection & Filter Bar Integration + // ------------------------------------------------------------------ + + describe('Enterprise Query Parameter & Filter Bar Compatibility', () => { + /** + * Simulate RecordPickerDialog's lookupFiltersToRecord conversion. + * This mirrors the internal function in RecordPickerDialog.tsx to verify + * that CRM metadata produces correct $filter query parameters. + */ + function lookupFiltersToRecord( + filters: Array<{ field: string; operator: string; value: unknown }>, + ): Record { + const result: Record = {}; + for (const f of filters) { + switch (f.operator) { + case 'eq': result[f.field] = f.value; break; + case 'ne': result[f.field] = { $ne: f.value }; break; + case 'gt': result[f.field] = { $gt: f.value }; break; + case 'lt': result[f.field] = { $lt: f.value }; break; + case 'gte': result[f.field] = { $gte: f.value }; break; + case 'lte': result[f.field] = { $lte: f.value }; break; + case 'contains': result[f.field] = { $contains: f.value }; break; + case 'in': result[f.field] = { $in: f.value }; break; + case 'notIn': result[f.field] = { $nin: f.value }; break; + } + } + return result; + } + + /** + * Simulate LookupField's mapFieldTypeToFilterType conversion. + * This mirrors the internal function in LookupField.tsx to verify + * CRM lookup_columns produce valid filter bar configurations. + */ + function mapFieldTypeToFilterType(fieldType: string): string | undefined { + const mapping: Record = { + text: 'text', number: 'number', currency: 'number', + percent: 'number', select: 'select', status: 'select', + date: 'date', datetime: 'date', boolean: 'boolean', + }; + return mapping[fieldType]; + } + + it('account.owner lookup_filters produce correct $filter for active users', () => { + const owner = (AccountObject.fields as any).owner; + const $filter = lookupFiltersToRecord(owner.lookup_filters); + expect($filter).toEqual({ active: true }); + }); + + it('contact.account lookup_filters produce $in for type restriction', () => { + const account = (ContactObject.fields as any).account; + const $filter = lookupFiltersToRecord(account.lookup_filters); + expect($filter).toEqual({ type: { $in: ['Customer', 'Partner'] } }); + }); + + it('order_item.order lookup_filters produce $ne for cancelled exclusion', () => { + const order = (OrderItemObject.fields as any).order; + const $filter = lookupFiltersToRecord(order.lookup_filters); + expect($filter).toEqual({ status: { $ne: 'cancelled' } }); + }); + + it('opportunity_contact.opportunity lookup_filters produce $nin for closed stages', () => { + const opp = (OpportunityContactObject.fields as any).opportunity; + const $filter = lookupFiltersToRecord(opp.lookup_filters); + expect($filter).toEqual({ stage: { $nin: ['closed_won', 'closed_lost'] } }); + }); + + it('typed lookup_columns produce valid filter bar configurations', () => { + const product = (OrderItemObject.fields as any).product; + const cols = product.lookup_columns as Array<{ field: string; type?: string; label?: string }>; + + const filterColumns = cols + .filter((c) => c.type) + .map((c) => ({ + field: c.field, + label: c.label, + type: mapFieldTypeToFilterType(c.type!), + })) + .filter((c) => c.type !== undefined); + + // Product lookup should produce filter bar entries for select, currency(→number), number, boolean + expect(filterColumns.length).toBeGreaterThanOrEqual(3); + const types = filterColumns.map((c) => c.type); + expect(types).toContain('select'); // category + expect(types).toContain('number'); // price, stock + expect(types).toContain('boolean'); // is_active + }); + + it('opportunity.account typed columns map to valid filter bar types', () => { + const account = (OpportunityObject.fields as any).account; + const cols = account.lookup_columns as Array<{ field: string; type?: string }>; + + const filterTypes = cols + .filter((c) => c.type) + .map((c) => mapFieldTypeToFilterType(c.type!)) + .filter(Boolean); + + // account columns have select + currency(→number) types + expect(filterTypes).toContain('select'); + expect(filterTypes).toContain('number'); + }); + + it('all CRM lookup_filters convert to valid $filter records without errors', () => { + for (const obj of allObjects) { + const lookups = Object.entries(obj.fields).filter( + ([, f]: [string, any]) => f.type === 'lookup' || f.type === 'master_detail', + ); + for (const [fieldName, field] of lookups) { + const f = field as any; + if (!f.lookup_filters) continue; + const $filter = lookupFiltersToRecord(f.lookup_filters); + expect( + Object.keys($filter).length, + `${obj.name}.${fieldName} lookup_filters produced empty $filter`, + ).toBeGreaterThan(0); + } + } + }); + }); }); // ====================================================================