66 */
77
88import 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