diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 1895653aa1..3ee4f25a0a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -366,7 +366,7 @@ Type: `string` | **columns** | `string[]` | optional | Fields to display in the related list. Optional: when omitted, columns derive from the related object's highlightFields / default list columns (a related list is just another surface that lists that object). Override chain: child highlightFields → field-level relatedListColumns → this inline list. | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order for related records | | **limit** | `integer` | ✅ | Number of records to display initially | -| **filter** | `{ field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Additional filter criteria for related records | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Additional filter criteria for related records | | **title** | `string` | optional | Custom title for the related list | | **showViewAll** | `boolean` | ✅ | Show "View All" link to see all related records | | **actions** | `string[]` | optional | Action IDs available for related records | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 0811994cf0..7691003f41 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -51,7 +51,7 @@ Interface-level page configuration (Airtable parity) | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -83,7 +83,7 @@ Interface-level page configuration (Airtable parity) | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional | | | **assignedProfiles** | `string[]` | optional | | -| **interfaceConfig** | `{ source?: string; columns?: string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]; sort?: { field: string; order: Enum<'asc' \| 'desc'> }[]; filterBy?: { field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | +| **interfaceConfig** | `{ source?: string; columns?: string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]; sort?: { field: string; order: Enum<'asc' \| 'desc'> }[]; filterBy?: { field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react' \| 'jsx'>` | optional | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX/HTML+Tailwind compiled (parsed, never executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias); react = real-React source executed at render by the runtime (ADR-0081); it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. | | **slots** | `{ header?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; actions?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; alerts?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; highlights?: { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … } \| { type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'user:profile' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:filter' \| 'element:form' \| 'element:record_picker' \| 'element:text_input'> \| string; id?: string; label?: string; properties?: Record; … }[]; … }` | optional | Slot override map for slotted pages | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 0bb7f44a21..2c557002a3 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -341,7 +341,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -430,7 +430,7 @@ List chart view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -713,7 +713,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `string` | ✅ | Filter operator (e.g. equals, not_equals, contains, this_quarter) | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| string \| number[]` | optional | Filter value | @@ -836,7 +836,7 @@ Tab configuration for multi-tab view interface | **label** | `string` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator: string; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| 'less_than_or_equal' \| 'in' \| 'not_in' \| 'is_empty' \| 'is_not_empty' \| 'is_null' \| 'is_not_null' \| 'before' \| 'after' \| 'between'>; value?: string \| number \| boolean \| null \| string \| number[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | ✅ | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | ✅ | Set as the default active tab | diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index 816535e9ba..dd3d475550 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -58,7 +58,7 @@ export const SysShareLink = ObjectSchema.create({ label: 'Active', data: { provider: 'object', object: 'sys_share_link' }, columns: ['object_name', 'record_id', 'permission', 'audience', 'expires_at', 'use_count', 'last_used_at'], - filter: [{ field: 'revoked_at', operator: 'isNull' }], + filter: [{ field: 'revoked_at', operator: 'is_null' }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 100 }, }, @@ -78,7 +78,7 @@ export const SysShareLink = ObjectSchema.create({ label: 'Revoked', data: { provider: 'object', object: 'sys_share_link' }, columns: ['object_name', 'record_id', 'revoked_at', 'created_by'], - filter: [{ field: 'revoked_at', operator: 'isNotNull' }], + filter: [{ field: 'revoked_at', operator: 'is_not_null' }], sort: [{ field: 'revoked_at', order: 'desc' }], pagination: { pageSize: 50 }, }, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 51c448b517..13e087e283 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3388,9 +3388,12 @@ "UserFilterFieldSchema (const)", "UserFilters (type)", "UserFiltersSchema (const)", + "VIEW_FILTER_OPERATORS (const)", + "VIEW_FILTER_OPERATOR_ALIASES (const)", "View (type)", "ViewData (type)", "ViewDataSchema (const)", + "ViewFilterOperator (type)", "ViewFilterRule (type)", "ViewFilterRuleSchema (const)", "ViewItem (type)", @@ -3445,6 +3448,7 @@ "expandViewContainer (function)", "expandViewContainerWithDiagnostics (function)", "isAggregatedViewContainer (function)", + "normalizeFilterOperator (function)", "pageForm (const)", "reportForm (const)", "viewForm (const)" diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 04087bca4e..df6e426847 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -37,7 +37,7 @@ export const viewForm = defineForm({ description: 'What rows show and how users filter them.', fields: [ { field: 'columns', type: 'repeater', required: true, helpText: 'Columns to display (field names from selected object)' }, - { field: 'filter', type: 'repeater', helpText: 'Filter conditions' }, + { field: 'filter', widget: 'filter-builder', dependsOn: 'data.object', helpText: 'Filter conditions — same visual builder as the list toolbar, with field-type-aware operators and value inputs' }, { field: 'sort', type: 'repeater', helpText: 'Default sort order' }, { field: 'searchableFields', widget: 'string-tags', helpText: 'Field names available for quick search' }, { field: 'filterableFields', widget: 'string-tags', helpText: 'Field names available for filtering' }, diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 68226aa6d1..82601e2185 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -734,7 +734,7 @@ describe('ViewSchema', () => { type: 'grid', columns: ['name', 'account_name', 'amount', 'stage', 'close_date'], filter: [ - { field: 'close_date', operator: 'this_quarter' }, + { field: 'close_date', operator: 'after', value: '2024-01-01' }, ], sort: [{ field: 'amount', order: 'desc' }], }, @@ -2477,15 +2477,29 @@ describe('ViewFilterRuleSchema', () => { it('should accept a unary filter rule without value', () => { const rule = ViewFilterRuleSchema.parse({ - field: 'close_date', - operator: 'this_quarter', + field: 'archived_at', + operator: 'is_empty', }); expect(rule.value).toBeUndefined(); }); it('should accept boolean and number filter values', () => { expect(() => ViewFilterRuleSchema.parse({ field: 'archived', operator: 'equals', value: false })).not.toThrow(); - expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1000 })).not.toThrow(); + expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'greater_than_or_equal', value: 1000 })).not.toThrow(); + }); + + it('should normalize legacy operator aliases to canonical', () => { + expect(ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1 }).operator).toBe('greater_than_or_equal'); + expect(ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gt', value: 1 }).operator).toBe('greater_than'); + expect(ViewFilterRuleSchema.parse({ field: 'name', operator: 'eq', value: 'x' }).operator).toBe('equals'); + expect(ViewFilterRuleSchema.parse({ field: 'name', operator: 'notEquals', value: 'x' }).operator).toBe('not_equals'); + expect(ViewFilterRuleSchema.parse({ field: 'revoked_at', operator: 'isNull' }).operator).toBe('is_null'); + expect(ViewFilterRuleSchema.parse({ field: 'tags', operator: 'nin', value: ['a'] }).operator).toBe('not_in'); + }); + + it('should reject a genuinely unknown operator', () => { + expect(() => ViewFilterRuleSchema.parse({ field: 'close_date', operator: 'this_quarter' })).toThrow(); + expect(() => ViewFilterRuleSchema.parse({ field: 'x', operator: 'totally_bogus' })).toThrow(); }); it('should accept array filter values (for IN operator)', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8bbed71fc1..5ea99cb665 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -55,6 +55,78 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', }), ])); +/** + * Canonical filter operators for view filter rules. + * + * This is the SINGLE authoring vocabulary for `ViewFilterRule.operator`. + * Exposing it as an enum (rather than a free `z.string()`) lets JSON-Schema + * consumers — notably ObjectUI's SchemaForm — auto-render an operator + * dropdown, and rejects genuinely unknown operators at parse time. + * + * Unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) take + * no `value`. `before` / `after` are the date-friendly spellings of + * `less_than` / `greater_than`; `between` expects a two-element `value` array. + * + * Note: relative-date operators (`this_quarter`, `last_7_days`, …) are NOT + * filter-rule operators — they are date-range presets and live on dashboard + * date-range config (`DashboardFilterSchema.defaultRange`), not here. + */ +export const VIEW_FILTER_OPERATORS = [ + 'equals', 'not_equals', + 'contains', 'not_contains', + 'starts_with', 'ends_with', + 'greater_than', 'less_than', + 'greater_than_or_equal', 'less_than_or_equal', + 'in', 'not_in', + 'is_empty', 'is_not_empty', + 'is_null', 'is_not_null', + 'before', 'after', 'between', +] as const; + +export type ViewFilterOperator = (typeof VIEW_FILTER_OPERATORS)[number]; + +/** + * Legacy operator spellings normalized to the canonical vocabulary above. + * + * These are historical shorthand (`eq`, `gt`) and camelCase (`notEquals`, + * `greaterThan`) forms that older authoring tools and already-stored view + * metadata may still carry. They are folded to canonical on parse so every + * downstream consumer sees exactly one vocabulary — one strict contract, not + * N dialects. Deprecated: new producers MUST emit the canonical forms; these + * aliases are a migration bridge and may be dropped in a future major. + */ +export const VIEW_FILTER_OPERATOR_ALIASES: Record = { + eq: 'equals', + ne: 'not_equals', neq: 'not_equals', notequals: 'not_equals', notEquals: 'not_equals', + notcontains: 'not_contains', notContains: 'not_contains', + startswith: 'starts_with', startsWith: 'starts_with', + endswith: 'ends_with', endsWith: 'ends_with', + gt: 'greater_than', greaterthan: 'greater_than', greaterThan: 'greater_than', + lt: 'less_than', lessthan: 'less_than', lessThan: 'less_than', + gte: 'greater_than_or_equal', greaterorequal: 'greater_than_or_equal', + greaterOrEqual: 'greater_than_or_equal', greaterThanOrEqual: 'greater_than_or_equal', + lte: 'less_than_or_equal', lessorequal: 'less_than_or_equal', + lessOrEqual: 'less_than_or_equal', lessThanOrEqual: 'less_than_or_equal', + nin: 'not_in', notin: 'not_in', notIn: 'not_in', + isempty: 'is_empty', isEmpty: 'is_empty', + isnotempty: 'is_not_empty', isNotEmpty: 'is_not_empty', + isnull: 'is_null', isNull: 'is_null', + isnotnull: 'is_not_null', isNotNull: 'is_not_null', +}; + +/** + * Fold a legacy operator spelling to its canonical form. Returns canonical + * operators unchanged, maps known aliases, and returns unknown input verbatim + * (so the enum's own validation reports it as invalid). Exported so producers + * and renderers can normalize stored metadata against the SAME canonical map + * the schema uses, instead of inventing a second dialect. + */ +export function normalizeFilterOperator(op: unknown): string { + if (typeof op !== 'string') return op as string; + if ((VIEW_FILTER_OPERATORS as readonly string[]).includes(op)) return op; + return VIEW_FILTER_OPERATOR_ALIASES[op] ?? VIEW_FILTER_OPERATOR_ALIASES[op.toLowerCase()] ?? op; +} + /** * View Filter Rule Schema * Standardized filter condition used in list views, tabs, and page-level filters. @@ -64,16 +136,22 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider', * ```ts * filter: [ * { field: 'status', operator: 'equals', value: 'active' }, - * { field: 'close_date', operator: 'this_quarter' }, + * { field: 'close_date', operator: 'after', value: '2024-01-01' }, + * { field: 'archived_at', operator: 'is_empty' }, * ] * ``` */ export const ViewFilterRuleSchema = lazySchema(() => z.object({ /** Field name to filter on */ field: z.string().describe('Field name to filter on'), - /** Filter operator */ - operator: z.string().describe('Filter operator (e.g. equals, not_equals, contains, this_quarter)'), - /** Filter value (optional for unary operators like is_null, this_quarter) */ + /** + * Filter operator (canonical vocabulary). Legacy shorthand/camelCase + * spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to + * canonical on parse. + */ + operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)) + .describe('Filter operator'), + /** Filter value (optional for unary operators like is_empty, is_null) */ value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) .optional().describe('Filter value'), }).describe('View filter rule')); @@ -551,6 +629,14 @@ export const ListViewSchema = lazySchema(() => z.object({ z.array(ListColumnSchema), // Enhanced: detailed column config ]).describe('Fields to display as columns'), filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), + /** + * Sort order. Prefer the structured `{ field, order }[]` form. + * + * @deprecated The bare string form (`"field desc"`) is legacy and retained + * only for backward compatibility (it was the exact shape that crashed the + * renderer in objectui#2601 — kept covered by a live fixture). Removal will + * go through its own deprecation cycle; do not drop it here. + */ sort: z.union([ z.string(), //Legacy "field desc" z.array(z.object({