Skip to content

Commit 9404248

Browse files
authored
Merge pull request #1090 from objectstack-ai/copilot/improve-lookup-fields-metadata
2 parents e35fa5a + 0b276ee commit 9404248

File tree

11 files changed

+520
-8
lines changed

11 files changed

+520
-8
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **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).
13+
- **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).
1214
- **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.
1315
- **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).
1416
- **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.

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
135135
- [x] Lookup field context DataSource — reads DataSource from SchemaRendererContext so forms work without explicit prop
136136
- [x] Lookup field UX polish — arrow key navigation, description field display, quick-create entry, ARIA listbox roles
137137
- [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
138+
- [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
138139
- [ ] Form conditional logic with branching
139140
- [ ] Multi-page forms with progress indicator
140141

examples/crm/src/__tests__/crm-metadata.test.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,286 @@ describe('CRM Metadata Spec Compliance', () => {
402402
}
403403
});
404404
});
405+
406+
// ------------------------------------------------------------------
407+
// Enterprise Lookup Field Configuration
408+
// ------------------------------------------------------------------
409+
410+
describe('Enterprise Lookup Metadata', () => {
411+
/** Extract all lookup fields from an object definition */
412+
function getLookupFields(obj: Record<string, any>): Array<[string, Record<string, any>]> {
413+
return Object.entries(obj.fields).filter(
414+
([, f]: [string, any]) => f.type === 'lookup' || f.type === 'master_detail',
415+
) as Array<[string, Record<string, any>]>;
416+
}
417+
418+
it('every CRM lookup field has lookup_columns configured', () => {
419+
for (const obj of allObjects) {
420+
const lookups = getLookupFields(obj);
421+
for (const [fieldName, field] of lookups) {
422+
expect(field.lookup_columns, `${obj.name}.${fieldName} missing lookup_columns`).toBeDefined();
423+
expect(Array.isArray(field.lookup_columns)).toBe(true);
424+
expect(field.lookup_columns.length).toBeGreaterThanOrEqual(2);
425+
}
426+
}
427+
});
428+
429+
it('every CRM lookup field has lookup_filters configured', () => {
430+
for (const obj of allObjects) {
431+
const lookups = getLookupFields(obj);
432+
for (const [fieldName, field] of lookups) {
433+
expect(field.lookup_filters, `${obj.name}.${fieldName} missing lookup_filters`).toBeDefined();
434+
expect(Array.isArray(field.lookup_filters)).toBe(true);
435+
expect(field.lookup_filters.length).toBeGreaterThanOrEqual(1);
436+
}
437+
}
438+
});
439+
440+
it('every CRM lookup field has description_field configured', () => {
441+
for (const obj of allObjects) {
442+
const lookups = getLookupFields(obj);
443+
for (const [fieldName, field] of lookups) {
444+
expect(field.description_field, `${obj.name}.${fieldName} missing description_field`).toBeDefined();
445+
expect(typeof field.description_field).toBe('string');
446+
}
447+
}
448+
});
449+
450+
it('lookup_columns include at least one column with a type hint for cell rendering', () => {
451+
for (const obj of allObjects) {
452+
const lookups = getLookupFields(obj);
453+
for (const [fieldName, field] of lookups) {
454+
const cols = field.lookup_columns as Array<string | Record<string, any>>;
455+
const typedCols = cols.filter(
456+
(c) => typeof c === 'object' && c.type,
457+
);
458+
expect(
459+
typedCols.length,
460+
`${obj.name}.${fieldName} has no typed columns for cell rendering`,
461+
).toBeGreaterThanOrEqual(1);
462+
}
463+
}
464+
});
465+
466+
it('lookup_columns cover diverse cell types (select, currency, boolean, date)', () => {
467+
const allTypedColumns: string[] = [];
468+
for (const obj of allObjects) {
469+
const lookups = getLookupFields(obj);
470+
for (const [, field] of lookups) {
471+
const cols = field.lookup_columns as Array<string | Record<string, any>>;
472+
for (const c of cols) {
473+
if (typeof c === 'object' && c.type) {
474+
allTypedColumns.push(c.type);
475+
}
476+
}
477+
}
478+
}
479+
const uniqueTypes = new Set(allTypedColumns);
480+
expect(uniqueTypes.has('select')).toBe(true);
481+
expect(uniqueTypes.has('currency')).toBe(true);
482+
expect(uniqueTypes.has('boolean')).toBe(true);
483+
expect(uniqueTypes.has('date')).toBe(true);
484+
});
485+
486+
it('lookup_filters have valid operator values', () => {
487+
const validOperators = ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'contains', 'in', 'notIn'];
488+
for (const obj of allObjects) {
489+
const lookups = getLookupFields(obj);
490+
for (const [fieldName, field] of lookups) {
491+
for (const filter of field.lookup_filters) {
492+
expect(filter).toHaveProperty('field');
493+
expect(filter).toHaveProperty('operator');
494+
expect(filter).toHaveProperty('value');
495+
expect(
496+
validOperators,
497+
`${obj.name}.${fieldName} filter operator "${filter.operator}" invalid`,
498+
).toContain(filter.operator);
499+
}
500+
}
501+
}
502+
});
503+
504+
it('lookup_filters cover diverse operators (eq, ne, in, notIn)', () => {
505+
const allOperators: string[] = [];
506+
for (const obj of allObjects) {
507+
const lookups = getLookupFields(obj);
508+
for (const [, field] of lookups) {
509+
for (const filter of field.lookup_filters) {
510+
allOperators.push(filter.operator);
511+
}
512+
}
513+
}
514+
const uniqueOps = new Set(allOperators);
515+
expect(uniqueOps.has('eq')).toBe(true);
516+
expect(uniqueOps.has('in')).toBe(true);
517+
expect(uniqueOps.has('notIn')).toBe(true);
518+
expect(uniqueOps.has('ne')).toBe(true);
519+
});
520+
521+
it('account.owner references user with active-only filter', () => {
522+
const owner = (AccountObject.fields as any).owner;
523+
expect(owner.reference).toBe('user');
524+
expect(owner.description_field).toBe('email');
525+
expect(owner.lookup_filters).toEqual([{ field: 'active', operator: 'eq', value: true }]);
526+
});
527+
528+
it('opportunity.account references account with type filter', () => {
529+
const account = (OpportunityObject.fields as any).account;
530+
expect(account.reference).toBe('account');
531+
expect(account.description_field).toBe('industry');
532+
const typeFilter = account.lookup_filters.find((f: any) => f.field === 'type');
533+
expect(typeFilter).toBeDefined();
534+
expect(typeFilter.operator).toBe('in');
535+
expect(typeFilter.value).toContain('Customer');
536+
});
537+
538+
it('order_item.product filters active products only', () => {
539+
const product = (OrderItemObject.fields as any).product;
540+
expect(product.reference).toBe('product');
541+
expect(product.description_field).toBe('sku');
542+
expect(product.lookup_filters).toEqual([{ field: 'is_active', operator: 'eq', value: true }]);
543+
const cols = product.lookup_columns as Array<Record<string, any>>;
544+
expect(cols.find((c) => c.field === 'price')?.type).toBe('currency');
545+
expect(cols.find((c) => c.field === 'stock')?.type).toBe('number');
546+
expect(cols.find((c) => c.field === 'is_active')?.type).toBe('boolean');
547+
});
548+
549+
it('opportunity_contact.opportunity filters out closed stages', () => {
550+
const opp = (OpportunityContactObject.fields as any).opportunity;
551+
expect(opp.reference).toBe('opportunity');
552+
const stageFilter = opp.lookup_filters.find((f: any) => f.field === 'stage');
553+
expect(stageFilter).toBeDefined();
554+
expect(stageFilter.operator).toBe('notIn');
555+
expect(stageFilter.value).toContain('closed_won');
556+
expect(stageFilter.value).toContain('closed_lost');
557+
});
558+
559+
it('opportunity.contacts has lookup_page_size for multi-select', () => {
560+
const contacts = (OpportunityObject.fields as any).contacts;
561+
expect(contacts.lookup_page_size).toBe(15);
562+
});
563+
});
564+
565+
// ------------------------------------------------------------------
566+
// Enterprise Query Parameter Injection & Filter Bar Integration
567+
// ------------------------------------------------------------------
568+
569+
describe('Enterprise Query Parameter & Filter Bar Compatibility', () => {
570+
/**
571+
* Simulate RecordPickerDialog's lookupFiltersToRecord conversion.
572+
* This mirrors the internal function in RecordPickerDialog.tsx to verify
573+
* that CRM metadata produces correct $filter query parameters.
574+
*/
575+
function lookupFiltersToRecord(
576+
filters: Array<{ field: string; operator: string; value: unknown }>,
577+
): Record<string, any> {
578+
const result: Record<string, any> = {};
579+
for (const f of filters) {
580+
switch (f.operator) {
581+
case 'eq': result[f.field] = f.value; break;
582+
case 'ne': result[f.field] = { $ne: f.value }; break;
583+
case 'gt': result[f.field] = { $gt: f.value }; break;
584+
case 'lt': result[f.field] = { $lt: f.value }; break;
585+
case 'gte': result[f.field] = { $gte: f.value }; break;
586+
case 'lte': result[f.field] = { $lte: f.value }; break;
587+
case 'contains': result[f.field] = { $contains: f.value }; break;
588+
case 'in': result[f.field] = { $in: f.value }; break;
589+
case 'notIn': result[f.field] = { $nin: f.value }; break;
590+
}
591+
}
592+
return result;
593+
}
594+
595+
/**
596+
* Simulate LookupField's mapFieldTypeToFilterType conversion.
597+
* This mirrors the internal function in LookupField.tsx to verify
598+
* CRM lookup_columns produce valid filter bar configurations.
599+
*/
600+
function mapFieldTypeToFilterType(fieldType: string): string | undefined {
601+
const mapping: Record<string, string> = {
602+
text: 'text', number: 'number', currency: 'number',
603+
percent: 'number', select: 'select', status: 'select',
604+
date: 'date', datetime: 'date', boolean: 'boolean',
605+
};
606+
return mapping[fieldType];
607+
}
608+
609+
it('account.owner lookup_filters produce correct $filter for active users', () => {
610+
const owner = (AccountObject.fields as any).owner;
611+
const $filter = lookupFiltersToRecord(owner.lookup_filters);
612+
expect($filter).toEqual({ active: true });
613+
});
614+
615+
it('contact.account lookup_filters produce $in for type restriction', () => {
616+
const account = (ContactObject.fields as any).account;
617+
const $filter = lookupFiltersToRecord(account.lookup_filters);
618+
expect($filter).toEqual({ type: { $in: ['Customer', 'Partner'] } });
619+
});
620+
621+
it('order_item.order lookup_filters produce $ne for cancelled exclusion', () => {
622+
const order = (OrderItemObject.fields as any).order;
623+
const $filter = lookupFiltersToRecord(order.lookup_filters);
624+
expect($filter).toEqual({ status: { $ne: 'cancelled' } });
625+
});
626+
627+
it('opportunity_contact.opportunity lookup_filters produce $nin for closed stages', () => {
628+
const opp = (OpportunityContactObject.fields as any).opportunity;
629+
const $filter = lookupFiltersToRecord(opp.lookup_filters);
630+
expect($filter).toEqual({ stage: { $nin: ['closed_won', 'closed_lost'] } });
631+
});
632+
633+
it('typed lookup_columns produce valid filter bar configurations', () => {
634+
const product = (OrderItemObject.fields as any).product;
635+
const cols = product.lookup_columns as Array<{ field: string; type?: string; label?: string }>;
636+
637+
const filterColumns = cols
638+
.filter((c) => c.type)
639+
.map((c) => ({
640+
field: c.field,
641+
label: c.label,
642+
type: mapFieldTypeToFilterType(c.type!),
643+
}))
644+
.filter((c) => c.type !== undefined);
645+
646+
// Product lookup should produce filter bar entries for select, currency(→number), number, boolean
647+
expect(filterColumns.length).toBeGreaterThanOrEqual(3);
648+
const types = filterColumns.map((c) => c.type);
649+
expect(types).toContain('select'); // category
650+
expect(types).toContain('number'); // price, stock
651+
expect(types).toContain('boolean'); // is_active
652+
});
653+
654+
it('opportunity.account typed columns map to valid filter bar types', () => {
655+
const account = (OpportunityObject.fields as any).account;
656+
const cols = account.lookup_columns as Array<{ field: string; type?: string }>;
657+
658+
const filterTypes = cols
659+
.filter((c) => c.type)
660+
.map((c) => mapFieldTypeToFilterType(c.type!))
661+
.filter(Boolean);
662+
663+
// account columns have select + currency(→number) types
664+
expect(filterTypes).toContain('select');
665+
expect(filterTypes).toContain('number');
666+
});
667+
668+
it('all CRM lookup_filters convert to valid $filter records without errors', () => {
669+
for (const obj of allObjects) {
670+
const lookups = Object.entries(obj.fields).filter(
671+
([, f]: [string, any]) => f.type === 'lookup' || f.type === 'master_detail',
672+
);
673+
for (const [fieldName, field] of lookups) {
674+
const f = field as any;
675+
if (!f.lookup_filters) continue;
676+
const $filter = lookupFiltersToRecord(f.lookup_filters);
677+
expect(
678+
Object.keys($filter).length,
679+
`${obj.name}.${fieldName} lookup_filters produced empty $filter`,
680+
).toBeGreaterThan(0);
681+
}
682+
}
683+
});
684+
});
405685
});
406686

407687
// ====================================================================

examples/crm/src/objects/account.object.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ObjectSchema, Field } from '@objectstack/spec/data';
22

3-
export const AccountObject = ObjectSchema.create({
3+
const _AccountObject = ObjectSchema.create({
44
name: 'account',
55
label: 'Account',
66
icon: 'building-2',
@@ -40,3 +40,21 @@ export const AccountObject = ObjectSchema.create({
4040
created_at: Field.datetime({ label: 'Created Date', readonly: true })
4141
}
4242
});
43+
44+
// Enterprise lookup metadata — injected post-create because ObjectSchema.create()
45+
// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }).
46+
Object.assign(_AccountObject.fields.owner, {
47+
description_field: 'email',
48+
lookup_columns: [
49+
{ field: 'name', label: 'Name' },
50+
{ field: 'email', label: 'Email' },
51+
{ field: 'role', label: 'Role', type: 'select' },
52+
{ field: 'department', label: 'Department' },
53+
{ field: 'active', label: 'Active', type: 'boolean', width: '80px' },
54+
],
55+
lookup_filters: [
56+
{ field: 'active', operator: 'eq', value: true },
57+
],
58+
});
59+
60+
export const AccountObject = _AccountObject;

examples/crm/src/objects/contact.object.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ObjectSchema, Field } from '@objectstack/spec/data';
22

3-
export const ContactObject = ObjectSchema.create({
3+
const _ContactObject = ObjectSchema.create({
44
name: 'contact',
55
label: 'Contact',
66
icon: 'user',
@@ -35,3 +35,21 @@ export const ContactObject = ObjectSchema.create({
3535
notes: Field.richtext({ label: 'Notes' })
3636
}
3737
});
38+
39+
// Enterprise lookup metadata — injected post-create because ObjectSchema.create()
40+
// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }).
41+
Object.assign(_ContactObject.fields.account, {
42+
description_field: 'industry',
43+
lookup_columns: [
44+
{ field: 'name', label: 'Account Name' },
45+
{ field: 'industry', label: 'Industry', type: 'select' },
46+
{ field: 'rating', label: 'Rating', type: 'select' },
47+
{ field: 'type', label: 'Type', type: 'select' },
48+
{ field: 'annual_revenue', label: 'Revenue', type: 'currency', width: '120px' },
49+
],
50+
lookup_filters: [
51+
{ field: 'type', operator: 'in', value: ['Customer', 'Partner'] },
52+
],
53+
});
54+
55+
export const ContactObject = _ContactObject;

0 commit comments

Comments
 (0)