Skip to content

Commit 80edbd4

Browse files
os-zhuangclaude
andauthored
fix(view,components): the spec→FilterBuilder operator table covers the whole view vocabulary (#2945) (#2989)
`SPEC_TO_BUILDER_OP` resolved 10 of the spec's 19 canonical `VIEW_FILTER_OPERATORS`. The nine it missed — not_equals, starts_with, ends_with, greater_than, less_than, greater_than_or_equal, less_than_or_equal, is_null, is_not_null — are all canonical members a stored view legitimately carries, and each reached the FilterBuilder as a raw spelling its dropdown cannot select. Same defect and cause as #2974, one table over: spellings enumerated by hand. Now derived from the spec's own canonical list and VIEW_FILTER_OPERATOR_ALIASES, matched case- and separator-insensitively. Four canonical operators have no FilterBuilder equivalent and are recorded as asserted `null`s rather than folded onto a near-equivalent, which would rewrite the author's operator on the next save. Also retires BUILDER_TO_SPEC_OP + toSpecFilter — the write direction, dead since the studio's spec-driven inspector replaced buildViewConfigSchema, and objectui's last emitter of `'not in'`, `before` and `after` as filter-AST operators (objectstack-ai/objectstack#3948). @object-ui/components now exports FILTER_BUILDER_OPERATORS so tables that map onto that vocabulary assert against it instead of restating it. Refs #2945, #2901 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 23871f1 commit 80edbd4

4 files changed

Lines changed: 267 additions & 57 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@object-ui/components": minor
3+
"@object-ui/plugin-view": patch
4+
---
5+
6+
fix(view,components): the spec→FilterBuilder operator table covers the whole view vocabulary, and the dead write direction is gone
7+
8+
`view-config-utils`' `SPEC_TO_BUILDER_OP` resolved **10 of the spec's 19
9+
canonical `VIEW_FILTER_OPERATORS`**. The nine it missed —
10+
`not_equals`, `starts_with`, `ends_with`, `greater_than`, `less_than`,
11+
`greater_than_or_equal`, `less_than_or_equal`, `is_null`, `is_not_null` — all
12+
appear in stored view metadata (they are canonical; `ViewFilterRuleSchema`
13+
validates against exactly this list), and each reached the FilterBuilder as a
14+
raw spelling its operator dropdown cannot select.
15+
16+
Same defect and same cause as #2974, one table over: spellings were enumerated
17+
by hand. That table is now derived from the spec's own canonical list and
18+
`VIEW_FILTER_OPERATOR_ALIASES`, matched case- and separator-insensitively, so
19+
`not_in` / `notIn` / `'not in'` / `NOT_IN` are one entry rather than four
20+
chances to miss one.
21+
22+
Four canonical operators have no FilterBuilder equivalent —
23+
`starts_with`/`ends_with` (absent from its vocabulary) and `is_null`/
24+
`is_not_null` (distinct from the `is_empty`/`is_not_empty` it does have). They
25+
are recorded as explicit `null`s and asserted, and deliberately left unmapped:
26+
folding them onto a near-equivalent would silently rewrite the author's
27+
operator on the next save, whereas an unmapped operator surfaces as a condition
28+
row the author must complete.
29+
30+
Also retired `BUILDER_TO_SPEC_OP` and `toSpecFilter` — the write direction,
31+
dead since the legacy `buildViewConfigSchema` engine was replaced by the
32+
studio's spec-driven inspector (no caller anywhere in the repo, and not part of
33+
`@object-ui/plugin-view`'s public exports). It was objectui's last emitter of
34+
`'not in'` with a space, plus `before`/`after`, as *filter-AST* operators —
35+
spellings that reached the server outside `VALID_AST_OPERATORS` and were dropped
36+
without an error (objectstack-ai/objectstack#3948).
37+
38+
`@object-ui/components` now exports `FILTER_BUILDER_OPERATORS` (and the
39+
`FilterBuilderOperator` type), derived from the operators the FilterBuilder
40+
actually renders, so tables mapping onto that vocabulary can assert against it
41+
instead of restating it.
42+
43+
Refs objectstack-ai/objectui#2945, #2901.

packages/components/src/custom/filter-builder.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,23 @@ const defaultOperators = [
7171
{ value: "between", label: "Between" },
7272
{ value: "in", label: "In" },
7373
{ value: "notIn", label: "Not in" },
74-
]
74+
] as const
75+
76+
/**
77+
* The FilterBuilder's own operator vocabulary — every id its dropdown can
78+
* hold, derived from the operators it actually renders.
79+
*
80+
* Exported because several translation tables map an external vocabulary
81+
* (the spec's `VIEW_FILTER_OPERATORS`, Mongo `$`-tokens) *onto* this one, and
82+
* a value that is not in this list produces a condition row whose operator
83+
* select has nothing to select. Those tables assert against this export
84+
* rather than restating it, so adding an operator here is the only edit
85+
* needed to widen them.
86+
*/
87+
export const FILTER_BUILDER_OPERATORS = defaultOperators.map(o => o.value)
88+
89+
/** An operator id the FilterBuilder can render. */
90+
export type FilterBuilderOperator = (typeof defaultOperators)[number]['value']
7591

7692
const useSafeFilterTranslation = createSafeTranslation(
7793
{
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* View operator → FilterBuilder operator parity (#2901, #2945).
11+
*
12+
* The third of objectui's spec-operator translation tables. The other two —
13+
* `ListView.mapOperator` and `data-objectstack`'s `normalizeFilterOperator` —
14+
* were pinned to the spec in #2974, which found eight spellings they had missed
15+
* by enumerating instead of deriving. This one maps the same vocabulary onto the
16+
* FilterBuilder's operator ids, and it had missed nine:
17+
*
18+
* not_equals, greater_than, less_than, greater_than_or_equal,
19+
* less_than_or_equal, starts_with, ends_with, is_null, is_not_null
20+
*
21+
* All nine are canonical `VIEW_FILTER_OPERATORS` members, so a stored view
22+
* legitimately carries them, and all nine reached the builder as a raw spelling
23+
* its dropdown cannot select. Five now map; four are recorded as deliberate
24+
* gaps, asserted below so the list cannot grow silently.
25+
*/
26+
import { describe, it, expect } from 'vitest';
27+
import { VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui';
28+
import { FILTER_BUILDER_OPERATORS } from '@object-ui/components';
29+
import { specToBuilderOperator, __CANONICAL_TO_BUILDER } from '../view-config-utils';
30+
31+
/**
32+
* Canonical view operators the FilterBuilder cannot express.
33+
*
34+
* `starts_with`/`ends_with`: no such operator in the builder's vocabulary.
35+
* `is_null`/`is_not_null`: distinct from `is_empty`/`is_not_empty`, which the
36+
* builder does have — folding them together would rewrite the author's operator
37+
* on the next save.
38+
*
39+
* Shrink this list by adding the operator to the FilterBuilder, never by
40+
* mapping onto a near-equivalent.
41+
*/
42+
const NO_BUILDER_EQUIVALENT = ['starts_with', 'ends_with', 'is_null', 'is_not_null'];
43+
44+
describe('CANONICAL_TO_BUILDER', () => {
45+
it('covers every canonical view operator, and nothing else', () => {
46+
expect(Object.keys(__CANONICAL_TO_BUILDER).sort()).toEqual([...VIEW_FILTER_OPERATORS].sort());
47+
});
48+
49+
it('maps onto operator ids the FilterBuilder actually renders', () => {
50+
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
51+
const notRenderable = Object.entries(__CANONICAL_TO_BUILDER)
52+
.filter(([, id]) => id !== null && !builderIds.has(id as never))
53+
.map(([op, id]) => `${op} -> ${id}`);
54+
expect(notRenderable).toEqual([]);
55+
});
56+
57+
it('records exactly the documented gaps', () => {
58+
const unmapped = Object.entries(__CANONICAL_TO_BUILDER)
59+
.filter(([, id]) => id === null)
60+
.map(([op]) => op);
61+
expect(unmapped.sort()).toEqual([...NO_BUILDER_EQUIVALENT].sort());
62+
});
63+
});
64+
65+
describe('specToBuilderOperator', () => {
66+
it('resolves every canonical view operator that has an equivalent', () => {
67+
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
68+
const unresolved = VIEW_FILTER_OPERATORS
69+
.filter(op => !NO_BUILDER_EQUIVALENT.includes(op))
70+
.filter(op => !builderIds.has(specToBuilderOperator(op) as never));
71+
expect(unresolved).toEqual([]);
72+
});
73+
74+
it('resolves every legacy alias the spec still folds', () => {
75+
const builderIds = new Set(FILTER_BUILDER_OPERATORS);
76+
const unresolved = Object.entries(VIEW_FILTER_OPERATOR_ALIASES)
77+
.filter(([, canonical]) => !NO_BUILDER_EQUIVALENT.includes(canonical))
78+
.filter(([alias]) => !builderIds.has(specToBuilderOperator(alias) as never))
79+
.map(([alias]) => alias);
80+
expect(unresolved).toEqual([]);
81+
});
82+
83+
it('resolves the infix spellings a stored filter array carries', () => {
84+
expect(specToBuilderOperator('=')).toBe('equals');
85+
expect(specToBuilderOperator('==')).toBe('equals');
86+
expect(specToBuilderOperator('!=')).toBe('notEquals');
87+
expect(specToBuilderOperator('<>')).toBe('notEquals');
88+
expect(specToBuilderOperator('>')).toBe('greaterThan');
89+
expect(specToBuilderOperator('<')).toBe('lessThan');
90+
expect(specToBuilderOperator('>=')).toBe('greaterOrEqual');
91+
expect(specToBuilderOperator('<=')).toBe('lessOrEqual');
92+
expect(specToBuilderOperator('nin')).toBe('notIn');
93+
expect(specToBuilderOperator('like')).toBe('contains');
94+
});
95+
96+
it('folds case and separators, so one spelling class is one entry', () => {
97+
for (const spelling of ['not_in', 'notIn', 'not in', 'NOT_IN', 'not-in', 'notin']) {
98+
expect(specToBuilderOperator(spelling)).toBe('notIn');
99+
}
100+
for (const spelling of ['greater_than_or_equal', 'greaterThanOrEqual', 'greaterorequal']) {
101+
expect(specToBuilderOperator(spelling)).toBe('greaterOrEqual');
102+
}
103+
});
104+
105+
it('returns an unmapped operator unchanged rather than coercing it', () => {
106+
// Visible in the UI as an incomplete condition row beats a silent rewrite
107+
// to `equals`, which would drop the author's predicate on the next save.
108+
expect(specToBuilderOperator('is_null')).toBe('is_null');
109+
expect(specToBuilderOperator('starts_with')).toBe('starts_with');
110+
expect(specToBuilderOperator('totally_made_up')).toBe('totally_made_up');
111+
});
112+
113+
it('defaults an absent operator to equals', () => {
114+
expect(specToBuilderOperator('')).toBe('equals');
115+
expect(specToBuilderOperator(undefined as unknown as string)).toBe('equals');
116+
});
117+
});

packages/plugin-view/src/config/view-config-utils.ts

Lines changed: 90 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,61 +6,109 @@
66
*/
77

88
import type { FilterGroup, SortItem } from '@object-ui/components';
9+
import { VIEW_FILTER_OPERATORS, VIEW_FILTER_OPERATOR_ALIASES } from '@objectstack/spec/ui';
10+
import type { ViewFilterOperator } from '@objectstack/spec/ui';
911

1012
// ---------------------------------------------------------------------------
11-
// Operator mapping: @objectstack/spec FilterBuilder
13+
// Operator mapping: @objectstack/spec FilterBuilder
1214
// ---------------------------------------------------------------------------
15+
//
16+
// One direction only. A stored view filter is *read* into a FilterBuilder here;
17+
// the write direction was retired with the legacy `buildViewConfigSchema`
18+
// engine, and the studio's spec-driven inspector persists the authored body
19+
// itself. The retired table was also objectui's last emitter of `'not in'`
20+
// (with a space), `before` and `after` as filter-AST operators — spellings the
21+
// server used to drop silently (#2901, objectstack#3948).
1322

14-
export const SPEC_TO_BUILDER_OP: Record<string, string> = {
15-
'=': 'equals',
16-
'==': 'equals',
17-
'!=': 'notEquals',
18-
'<>': 'notEquals',
19-
'>': 'greaterThan',
20-
'<': 'lessThan',
21-
'>=': 'greaterOrEqual',
22-
'<=': 'lessOrEqual',
23+
/**
24+
* Every canonical `VIEW_FILTER_OPERATORS` member, mapped onto the operator id
25+
* the FilterBuilder renders — `null` where the builder has no equivalent.
26+
*
27+
* Total by construction: the type is keyed by `ViewFilterOperator`, so an
28+
* operator added to the spec's view vocabulary fails to compile here instead
29+
* of reaching the builder as a raw spelling its dropdown cannot select.
30+
*
31+
* The four `null`s are honest gaps, not oversights. `starts_with`/`ends_with`
32+
* have no FilterBuilder operator at all, and `is_null`/`is_not_null` carry a
33+
* NULL/empty distinction that `isEmpty`/`isNotEmpty` erase — folding them onto
34+
* the near-equivalent would silently rewrite the author's operator the next
35+
* time the view was saved. An unmapped operator instead reaches the builder
36+
* unchanged and shows up as a condition row the author must complete, which is
37+
* the failure we want: visible, and lossless until they touch it.
38+
*/
39+
const CANONICAL_TO_BUILDER: Record<ViewFilterOperator, string | null> = {
40+
'equals': 'equals',
41+
'not_equals': 'notEquals',
2342
'contains': 'contains',
2443
'not_contains': 'notContains',
25-
'is_empty': 'isEmpty',
26-
'is_not_empty': 'isNotEmpty',
44+
'starts_with': null,
45+
'ends_with': null,
46+
'greater_than': 'greaterThan',
47+
'less_than': 'lessThan',
48+
'greater_than_or_equal': 'greaterOrEqual',
49+
'less_than_or_equal': 'lessOrEqual',
2750
'in': 'in',
2851
'not_in': 'notIn',
29-
'not in': 'notIn',
52+
'is_empty': 'isEmpty',
53+
'is_not_empty': 'isNotEmpty',
54+
'is_null': null,
55+
'is_not_null': null,
3056
'before': 'before',
3157
'after': 'after',
3258
'between': 'between',
33-
// Pass-through for already-normalized IDs
34-
'equals': 'equals',
35-
'notEquals': 'notEquals',
36-
'greaterThan': 'greaterThan',
37-
'lessThan': 'lessThan',
38-
'greaterOrEqual': 'greaterOrEqual',
39-
'lessOrEqual': 'lessOrEqual',
40-
'notContains': 'notContains',
41-
'isEmpty': 'isEmpty',
42-
'isNotEmpty': 'isNotEmpty',
43-
'notIn': 'notIn',
4459
};
4560

46-
export const BUILDER_TO_SPEC_OP: Record<string, string> = {
47-
'equals': '=',
48-
'notEquals': '!=',
49-
'greaterThan': '>',
50-
'lessThan': '<',
51-
'greaterOrEqual': '>=',
52-
'lessOrEqual': '<=',
53-
'contains': 'contains',
54-
'notContains': 'not_contains',
55-
'isEmpty': 'is_empty',
56-
'isNotEmpty': 'is_not_empty',
57-
'in': 'in',
58-
'notIn': 'not in',
59-
'before': 'before',
60-
'after': 'after',
61-
'between': 'between',
61+
/**
62+
* Infix and short spellings a stored *filter array* carries instead of a view
63+
* spelling — `['amount', '>', 100]`. These are `AST_OPERATOR_MAP` keys
64+
* (`data/filter.zod.ts`) that case-folding alone cannot reach.
65+
*/
66+
const INFIX_TO_CANONICAL: Record<string, ViewFilterOperator> = {
67+
'=': 'equals',
68+
'==': 'equals',
69+
'!=': 'not_equals',
70+
'<>': 'not_equals',
71+
'>': 'greater_than',
72+
'<': 'less_than',
73+
'>=': 'greater_than_or_equal',
74+
'<=': 'less_than_or_equal',
75+
'nin': 'not_in',
76+
'like': 'contains',
6277
};
6378

79+
/** Case- and separator-insensitive key: `not_in`, `notIn`, `'not in'` → `notin`. */
80+
const fold = (op: string) => op.toLowerCase().replace(/[\s_-]+/g, '');
81+
82+
/**
83+
* Every spelling the spec itself recognises, folded onto its canonical member.
84+
*
85+
* Derived from the spec's own canonical list and legacy-alias table rather than
86+
* restated, so the ~30 aliases `VIEW_FILTER_OPERATOR_ALIASES` still folds — all
87+
* of them live in stored metadata, because `saveMeta` persists the authored body
88+
* verbatim — are covered without this file enumerating them. Hand-enumerating
89+
* spellings is what left `before`/`after` unmapped in the first place.
90+
*/
91+
const FOLDED_TO_CANONICAL: Map<string, ViewFilterOperator> = new Map([
92+
...VIEW_FILTER_OPERATORS.map(op => [fold(op), op] as const),
93+
...Object.entries(VIEW_FILTER_OPERATOR_ALIASES).map(([alias, op]) => [fold(alias), op] as const),
94+
]);
95+
96+
/**
97+
* Resolve any stored operator spelling to a FilterBuilder operator id.
98+
*
99+
* Returns the input unchanged when no mapping exists, so an unrecognised
100+
* operator is visible in the UI rather than silently coerced to `equals`.
101+
*/
102+
export function specToBuilderOperator(op: string): string {
103+
const raw = String(op ?? '').trim();
104+
if (!raw) return 'equals';
105+
const canonical = INFIX_TO_CANONICAL[raw.toLowerCase()] ?? FOLDED_TO_CANONICAL.get(fold(raw));
106+
return (canonical ? CANONICAL_TO_BUILDER[canonical] : null) ?? raw;
107+
}
108+
109+
/** Exported for the parity guard — the mapping's canonical half. */
110+
export const __CANONICAL_TO_BUILDER = CANONICAL_TO_BUILDER;
111+
64112
// ---------------------------------------------------------------------------
65113
// Field type normalization: ObjectUI → FilterBuilder
66114
// ---------------------------------------------------------------------------
@@ -92,7 +140,7 @@ function parseTriplet(arr: any[]): { id: string; field: string; operator: string
92140
return {
93141
id: crypto.randomUUID(),
94142
field,
95-
operator: SPEC_TO_BUILDER_OP[op] || op,
143+
operator: specToBuilderOperator(op),
96144
value: value ?? '',
97145
};
98146
}
@@ -106,7 +154,7 @@ function parseSingleOrNested(item: any): Array<{ id: string; field: string; oper
106154
return [{
107155
id: item.id || crypto.randomUUID(),
108156
field: item.field,
109-
operator: SPEC_TO_BUILDER_OP[item.operator] || item.operator || 'equals',
157+
operator: specToBuilderOperator(item.operator),
110158
value: item.value ?? '',
111159
}];
112160
}
@@ -146,20 +194,6 @@ export function parseSpecFilter(raw: any): { logic: 'and' | 'or'; conditions: Ar
146194
return { logic: 'and', conditions: cond ? [cond] : [] };
147195
}
148196

149-
/**
150-
* Convert FilterGroup conditions back to spec-style filter array.
151-
*/
152-
export function toSpecFilter(logic: 'and' | 'or', conditions: Array<{ field: string; operator: string; value: any }>): any[] {
153-
const triplets = conditions
154-
.filter(c => c.field) // skip empty
155-
.map(c => [c.field, BUILDER_TO_SPEC_OP[c.operator] || c.operator, c.value]);
156-
157-
if (triplets.length === 0) return [];
158-
if (triplets.length === 1 && logic === 'and') return triplets[0];
159-
if (logic === 'or') return ['or', ...triplets];
160-
return triplets;
161-
}
162-
163197
// ---------------------------------------------------------------------------
164198
// Parse helpers
165199
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)