Skip to content

Commit 4f71712

Browse files
Copilothotlong
andcommitted
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>
1 parent 6b5f74b commit 4f71712

9 files changed

Lines changed: 396 additions & 8 deletions

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

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,165 @@ 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+
});
405564
});
406565

407566
// ====================================================================

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;

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

Lines changed: 33 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 EventObject = ObjectSchema.create({
3+
const _EventObject = ObjectSchema.create({
44
name: 'event',
55
label: 'Event',
66
icon: 'calendar',
@@ -36,3 +36,35 @@ export const EventObject = ObjectSchema.create({
3636
], { label: 'Reminder', defaultValue: 'min_15' })
3737
}
3838
});
39+
40+
// Enterprise lookup metadata — injected post-create because ObjectSchema.create()
41+
// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }).
42+
Object.assign(_EventObject.fields.participants, {
43+
description_field: 'email',
44+
lookup_columns: [
45+
{ field: 'name', label: 'Name' },
46+
{ field: 'email', label: 'Email' },
47+
{ field: 'company', label: 'Company' },
48+
{ field: 'status', label: 'Status', type: 'select' },
49+
{ field: 'is_active', label: 'Active', type: 'boolean', width: '80px' },
50+
],
51+
lookup_filters: [
52+
{ field: 'is_active', operator: 'eq', value: true },
53+
],
54+
});
55+
56+
Object.assign(_EventObject.fields.organizer, {
57+
description_field: 'email',
58+
lookup_columns: [
59+
{ field: 'name', label: 'Name' },
60+
{ field: 'email', label: 'Email' },
61+
{ field: 'role', label: 'Role', type: 'select' },
62+
{ field: 'department', label: 'Department' },
63+
{ field: 'active', label: 'Active', type: 'boolean', width: '80px' },
64+
],
65+
lookup_filters: [
66+
{ field: 'active', operator: 'eq', value: true },
67+
],
68+
});
69+
70+
export const EventObject = _EventObject;

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

Lines changed: 34 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 OpportunityObject = ObjectSchema.create({
3+
const _OpportunityObject = ObjectSchema.create({
44
name: 'opportunity',
55
label: 'Opportunity',
66
icon: 'trending-up',
@@ -34,3 +34,36 @@ export const OpportunityObject = ObjectSchema.create({
3434
description: Field.richtext({ label: 'Description' })
3535
}
3636
});
37+
38+
// Enterprise lookup metadata — injected post-create because ObjectSchema.create()
39+
// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }).
40+
Object.assign(_OpportunityObject.fields.account, {
41+
description_field: 'industry',
42+
lookup_columns: [
43+
{ field: 'name', label: 'Account Name' },
44+
{ field: 'industry', label: 'Industry', type: 'select' },
45+
{ field: 'rating', label: 'Rating', type: 'select' },
46+
{ field: 'type', label: 'Type', type: 'select' },
47+
{ field: 'annual_revenue', label: 'Revenue', type: 'currency', width: '120px' },
48+
],
49+
lookup_filters: [
50+
{ field: 'type', operator: 'in', value: ['Customer', 'Partner'] },
51+
],
52+
});
53+
54+
Object.assign(_OpportunityObject.fields.contacts, {
55+
description_field: 'email',
56+
lookup_columns: [
57+
{ field: 'name', label: 'Name' },
58+
{ field: 'email', label: 'Email' },
59+
{ field: 'company', label: 'Company' },
60+
{ field: 'status', label: 'Status', type: 'select' },
61+
{ field: 'is_active', label: 'Active', type: 'boolean', width: '80px' },
62+
],
63+
lookup_filters: [
64+
{ field: 'is_active', operator: 'eq', value: true },
65+
],
66+
lookup_page_size: 15,
67+
});
68+
69+
export const OpportunityObject = _OpportunityObject;

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

Lines changed: 33 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 OpportunityContactObject = ObjectSchema.create({
3+
const _OpportunityContactObject = ObjectSchema.create({
44
name: 'opportunity_contact',
55
label: 'Opportunity Contact',
66
icon: 'link',
@@ -19,3 +19,35 @@ export const OpportunityContactObject = ObjectSchema.create({
1919
is_primary: Field.boolean({ label: 'Primary Contact', defaultValue: false }),
2020
}
2121
});
22+
23+
// Enterprise lookup metadata — injected post-create because ObjectSchema.create()
24+
// Zod-strips non-spec properties. Preserved at runtime via defineStack({ strict: false }).
25+
Object.assign(_OpportunityContactObject.fields.opportunity, {
26+
description_field: 'stage',
27+
lookup_columns: [
28+
{ field: 'name', label: 'Opportunity Name' },
29+
{ field: 'amount', label: 'Amount', type: 'currency', width: '120px' },
30+
{ field: 'stage', label: 'Stage', type: 'select' },
31+
{ field: 'close_date', label: 'Close Date', type: 'date' },
32+
{ field: 'probability', label: 'Probability', type: 'percent', width: '100px' },
33+
],
34+
lookup_filters: [
35+
{ field: 'stage', operator: 'notIn', value: ['closed_won', 'closed_lost'] },
36+
],
37+
});
38+
39+
Object.assign(_OpportunityContactObject.fields.contact, {
40+
description_field: 'email',
41+
lookup_columns: [
42+
{ field: 'name', label: 'Name' },
43+
{ field: 'email', label: 'Email' },
44+
{ field: 'company', label: 'Company' },
45+
{ field: 'status', label: 'Status', type: 'select' },
46+
{ field: 'is_active', label: 'Active', type: 'boolean', width: '80px' },
47+
],
48+
lookup_filters: [
49+
{ field: 'is_active', operator: 'eq', value: true },
50+
],
51+
});
52+
53+
export const OpportunityContactObject = _OpportunityContactObject;

0 commit comments

Comments
 (0)