Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
280 changes: 280 additions & 0 deletions examples/crm/src/__tests__/crm-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,286 @@ 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<string, any>): Array<[string, Record<string, any>]> {
return Object.entries(obj.fields).filter(
([, f]: [string, any]) => f.type === 'lookup' || f.type === 'master_detail',
) as Array<[string, Record<string, any>]>;
}

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<string | Record<string, any>>;
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<string | Record<string, any>>;
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<Record<string, any>>;
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);
});
});

// ------------------------------------------------------------------
// 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<string, any> {
const result: Record<string, any> = {};
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<string, string> = {
text: 'text', number: 'number', currency: 'number',
percent: 'number', select: 'select', status: 'select',
date: 'date', datetime: 'date', boolean: 'boolean',
};
return mapping[fieldType];
}
Comment on lines +595 to +607

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'] } });
Comment on lines +571 to +618
});

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);
}
}
});
});
});

// ====================================================================
Expand Down
20 changes: 19 additions & 1 deletion examples/crm/src/objects/account.object.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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;
20 changes: 19 additions & 1 deletion examples/crm/src/objects/contact.object.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 }).
Comment on lines +39 to +40
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;
Loading
Loading