Skip to content

Commit 47a23d0

Browse files
committed
fix(spec): enforce ViewFilterRule operator enum with legacy-alias normalization
Replace the free-form `operator: z.string()` on ViewFilterRuleSchema with a canonical `z.enum` (VIEW_FILTER_OPERATORS) wrapped in `z.preprocess` that folds legacy shorthand/camelCase spellings (eq, gt, isNull, notEquals, …) to canonical on parse. This surfaces a clean operator enum to JSON-Schema consumers (ObjectUI SchemaForm renders a dropdown) and rejects genuinely unknown operators, while already-stored metadata keeps validating and upgrades to one vocabulary. - Export VIEW_FILTER_OPERATORS, ViewFilterOperator, VIEW_FILTER_OPERATOR_ALIASES and a normalizeFilterOperator() helper as the single source of truth. - Pin `widget: 'filter-builder'` on the list view `filter` field in view.form.ts, matching dataset/page forms so the visual builder renders instead of a repeater. - Canonicalize plugin-sharing sys_share_link filters (isNull -> is_null, isNotNull -> is_not_null), preserving null semantics. - Keep the legacy string `sort` form (deprecation note only); retain the objectui#2601 regression fixture. - Relative-date operators (this_quarter, …) are not filter-rule operators; tests updated to canonical operators and alias-normalization / unknown-rejection coverage added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PwbhoMeqh33xFWq5M77QTJ
1 parent 9833822 commit 47a23d0

4 files changed

Lines changed: 111 additions & 11 deletions

File tree

packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const SysShareLink = ObjectSchema.create({
5858
label: 'Active',
5959
data: { provider: 'object', object: 'sys_share_link' },
6060
columns: ['object_name', 'record_id', 'permission', 'audience', 'expires_at', 'use_count', 'last_used_at'],
61-
filter: [{ field: 'revoked_at', operator: 'isNull' }],
61+
filter: [{ field: 'revoked_at', operator: 'is_null' }],
6262
sort: [{ field: 'created_at', order: 'desc' }],
6363
pagination: { pageSize: 100 },
6464
},
@@ -78,7 +78,7 @@ export const SysShareLink = ObjectSchema.create({
7878
label: 'Revoked',
7979
data: { provider: 'object', object: 'sys_share_link' },
8080
columns: ['object_name', 'record_id', 'revoked_at', 'created_by'],
81-
filter: [{ field: 'revoked_at', operator: 'isNotNull' }],
81+
filter: [{ field: 'revoked_at', operator: 'is_not_null' }],
8282
sort: [{ field: 'revoked_at', order: 'desc' }],
8383
pagination: { pageSize: 50 },
8484
},

packages/spec/src/ui/view.form.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const viewForm = defineForm({
3737
description: 'What rows show and how users filter them.',
3838
fields: [
3939
{ field: 'columns', type: 'repeater', required: true, helpText: 'Columns to display (field names from selected object)' },
40-
{ field: 'filter', type: 'repeater', helpText: 'Filter conditions' },
40+
{ 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' },
4141
{ field: 'sort', type: 'repeater', helpText: 'Default sort order' },
4242
{ field: 'searchableFields', widget: 'string-tags', helpText: 'Field names available for quick search' },
4343
{ field: 'filterableFields', widget: 'string-tags', helpText: 'Field names available for filtering' },

packages/spec/src/ui/view.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,7 @@ describe('ViewSchema', () => {
734734
type: 'grid',
735735
columns: ['name', 'account_name', 'amount', 'stage', 'close_date'],
736736
filter: [
737-
{ field: 'close_date', operator: 'this_quarter' },
737+
{ field: 'close_date', operator: 'after', value: '2024-01-01' },
738738
],
739739
sort: [{ field: 'amount', order: 'desc' }],
740740
},
@@ -2477,15 +2477,29 @@ describe('ViewFilterRuleSchema', () => {
24772477

24782478
it('should accept a unary filter rule without value', () => {
24792479
const rule = ViewFilterRuleSchema.parse({
2480-
field: 'close_date',
2481-
operator: 'this_quarter',
2480+
field: 'archived_at',
2481+
operator: 'is_empty',
24822482
});
24832483
expect(rule.value).toBeUndefined();
24842484
});
24852485

24862486
it('should accept boolean and number filter values', () => {
24872487
expect(() => ViewFilterRuleSchema.parse({ field: 'archived', operator: 'equals', value: false })).not.toThrow();
2488-
expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1000 })).not.toThrow();
2488+
expect(() => ViewFilterRuleSchema.parse({ field: 'amount', operator: 'greater_than_or_equal', value: 1000 })).not.toThrow();
2489+
});
2490+
2491+
it('should normalize legacy operator aliases to canonical', () => {
2492+
expect(ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gte', value: 1 }).operator).toBe('greater_than_or_equal');
2493+
expect(ViewFilterRuleSchema.parse({ field: 'amount', operator: 'gt', value: 1 }).operator).toBe('greater_than');
2494+
expect(ViewFilterRuleSchema.parse({ field: 'name', operator: 'eq', value: 'x' }).operator).toBe('equals');
2495+
expect(ViewFilterRuleSchema.parse({ field: 'name', operator: 'notEquals', value: 'x' }).operator).toBe('not_equals');
2496+
expect(ViewFilterRuleSchema.parse({ field: 'revoked_at', operator: 'isNull' }).operator).toBe('is_null');
2497+
expect(ViewFilterRuleSchema.parse({ field: 'tags', operator: 'nin', value: ['a'] }).operator).toBe('not_in');
2498+
});
2499+
2500+
it('should reject a genuinely unknown operator', () => {
2501+
expect(() => ViewFilterRuleSchema.parse({ field: 'close_date', operator: 'this_quarter' })).toThrow();
2502+
expect(() => ViewFilterRuleSchema.parse({ field: 'x', operator: 'totally_bogus' })).toThrow();
24892503
});
24902504

24912505
it('should accept array filter values (for IN operator)', () => {

packages/spec/src/ui/view.zod.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,78 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider',
5555
}),
5656
]));
5757

58+
/**
59+
* Canonical filter operators for view filter rules.
60+
*
61+
* This is the SINGLE authoring vocabulary for `ViewFilterRule.operator`.
62+
* Exposing it as an enum (rather than a free `z.string()`) lets JSON-Schema
63+
* consumers — notably ObjectUI's SchemaForm — auto-render an operator
64+
* dropdown, and rejects genuinely unknown operators at parse time.
65+
*
66+
* Unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) take
67+
* no `value`. `before` / `after` are the date-friendly spellings of
68+
* `less_than` / `greater_than`; `between` expects a two-element `value` array.
69+
*
70+
* Note: relative-date operators (`this_quarter`, `last_7_days`, …) are NOT
71+
* filter-rule operators — they are date-range presets and live on dashboard
72+
* date-range config (`DashboardFilterSchema.defaultRange`), not here.
73+
*/
74+
export const VIEW_FILTER_OPERATORS = [
75+
'equals', 'not_equals',
76+
'contains', 'not_contains',
77+
'starts_with', 'ends_with',
78+
'greater_than', 'less_than',
79+
'greater_than_or_equal', 'less_than_or_equal',
80+
'in', 'not_in',
81+
'is_empty', 'is_not_empty',
82+
'is_null', 'is_not_null',
83+
'before', 'after', 'between',
84+
] as const;
85+
86+
export type ViewFilterOperator = (typeof VIEW_FILTER_OPERATORS)[number];
87+
88+
/**
89+
* Legacy operator spellings normalized to the canonical vocabulary above.
90+
*
91+
* These are historical shorthand (`eq`, `gt`) and camelCase (`notEquals`,
92+
* `greaterThan`) forms that older authoring tools and already-stored view
93+
* metadata may still carry. They are folded to canonical on parse so every
94+
* downstream consumer sees exactly one vocabulary — one strict contract, not
95+
* N dialects. Deprecated: new producers MUST emit the canonical forms; these
96+
* aliases are a migration bridge and may be dropped in a future major.
97+
*/
98+
export const VIEW_FILTER_OPERATOR_ALIASES: Record<string, ViewFilterOperator> = {
99+
eq: 'equals',
100+
ne: 'not_equals', neq: 'not_equals', notequals: 'not_equals', notEquals: 'not_equals',
101+
notcontains: 'not_contains', notContains: 'not_contains',
102+
startswith: 'starts_with', startsWith: 'starts_with',
103+
endswith: 'ends_with', endsWith: 'ends_with',
104+
gt: 'greater_than', greaterthan: 'greater_than', greaterThan: 'greater_than',
105+
lt: 'less_than', lessthan: 'less_than', lessThan: 'less_than',
106+
gte: 'greater_than_or_equal', greaterorequal: 'greater_than_or_equal',
107+
greaterOrEqual: 'greater_than_or_equal', greaterThanOrEqual: 'greater_than_or_equal',
108+
lte: 'less_than_or_equal', lessorequal: 'less_than_or_equal',
109+
lessOrEqual: 'less_than_or_equal', lessThanOrEqual: 'less_than_or_equal',
110+
nin: 'not_in', notin: 'not_in', notIn: 'not_in',
111+
isempty: 'is_empty', isEmpty: 'is_empty',
112+
isnotempty: 'is_not_empty', isNotEmpty: 'is_not_empty',
113+
isnull: 'is_null', isNull: 'is_null',
114+
isnotnull: 'is_not_null', isNotNull: 'is_not_null',
115+
};
116+
117+
/**
118+
* Fold a legacy operator spelling to its canonical form. Returns canonical
119+
* operators unchanged, maps known aliases, and returns unknown input verbatim
120+
* (so the enum's own validation reports it as invalid). Exported so producers
121+
* and renderers can normalize stored metadata against the SAME canonical map
122+
* the schema uses, instead of inventing a second dialect.
123+
*/
124+
export function normalizeFilterOperator(op: unknown): string {
125+
if (typeof op !== 'string') return op as string;
126+
if ((VIEW_FILTER_OPERATORS as readonly string[]).includes(op)) return op;
127+
return VIEW_FILTER_OPERATOR_ALIASES[op] ?? VIEW_FILTER_OPERATOR_ALIASES[op.toLowerCase()] ?? op;
128+
}
129+
58130
/**
59131
* View Filter Rule Schema
60132
* Standardized filter condition used in list views, tabs, and page-level filters.
@@ -64,16 +136,22 @@ export const ViewDataSchema = lazySchema(() => z.discriminatedUnion('provider',
64136
* ```ts
65137
* filter: [
66138
* { field: 'status', operator: 'equals', value: 'active' },
67-
* { field: 'close_date', operator: 'this_quarter' },
139+
* { field: 'close_date', operator: 'after', value: '2024-01-01' },
140+
* { field: 'archived_at', operator: 'is_empty' },
68141
* ]
69142
* ```
70143
*/
71144
export const ViewFilterRuleSchema = lazySchema(() => z.object({
72145
/** Field name to filter on */
73146
field: z.string().describe('Field name to filter on'),
74-
/** Filter operator */
75-
operator: z.string().describe('Filter operator (e.g. equals, not_equals, contains, this_quarter)'),
76-
/** Filter value (optional for unary operators like is_null, this_quarter) */
147+
/**
148+
* Filter operator (canonical vocabulary). Legacy shorthand/camelCase
149+
* spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to
150+
* canonical on parse.
151+
*/
152+
operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS))
153+
.describe('Filter operator'),
154+
/** Filter value (optional for unary operators like is_empty, is_null) */
77155
value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])
78156
.optional().describe('Filter value'),
79157
}).describe('View filter rule'));
@@ -551,6 +629,14 @@ export const ListViewSchema = lazySchema(() => z.object({
551629
z.array(ListColumnSchema), // Enhanced: detailed column config
552630
]).describe('Fields to display as columns'),
553631
filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'),
632+
/**
633+
* Sort order. Prefer the structured `{ field, order }[]` form.
634+
*
635+
* @deprecated The bare string form (`"field desc"`) is legacy and retained
636+
* only for backward compatibility (it was the exact shape that crashed the
637+
* renderer in objectui#2601 — kept covered by a live fixture). Removal will
638+
* go through its own deprecation cycle; do not drop it here.
639+
*/
554640
sort: z.union([
555641
z.string(), //Legacy "field desc"
556642
z.array(z.object({

0 commit comments

Comments
 (0)