@open-mercato/shared 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3278.1.a4d26475e4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ // packages/shared/src/lib/query/advanced-filter-tree.ts
2
+ import type { FilterOperator } from './advanced-filter'
3
+ import { isValuelessOperator } from './advanced-filter'
4
+ import { escapeLikePattern } from '../db/escapeLikePattern'
5
+
6
+ export type FilterCombinator = 'and' | 'or'
7
+
8
+ export type FilterRule = {
9
+ id: string
10
+ type: 'rule'
11
+ field: string
12
+ operator: FilterOperator
13
+ value: unknown
14
+ // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find
15
+ // the most recently inserted node. MUST NOT be serialized into URL or persisted state.
16
+ addedAt?: number
17
+ }
18
+
19
+ export type FilterGroup = {
20
+ id: string
21
+ type: 'group'
22
+ combinator: FilterCombinator
23
+ children: Array<FilterRule | FilterGroup>
24
+ // Runtime-only metadata used by the editor reducer (e.g. removeLast) to find
25
+ // the most recently inserted node. MUST NOT be serialized into URL or persisted state.
26
+ addedAt?: number
27
+ }
28
+
29
+ export type AdvancedFilterTree = {
30
+ root: FilterGroup
31
+ }
32
+
33
+ export const TREE_LIMITS = {
34
+ maxGroupLevel: 3, // root = level 1; deepest nested group = level 3
35
+ maxChildrenPerGroup: 15, // counts both rules and nested groups
36
+ maxTotalRules: 50,
37
+ } as const
38
+
39
+ export function createEmptyTree(): AdvancedFilterTree {
40
+ return {
41
+ root: {
42
+ id: crypto.randomUUID(),
43
+ type: 'group',
44
+ combinator: 'and',
45
+ children: [],
46
+ },
47
+ }
48
+ }
49
+
50
+ export type ValidationResult =
51
+ | { ok: true }
52
+ | { ok: false; reason: 'depth' | 'width' | 'total' }
53
+
54
+ export function validateTreeLimits(tree: AdvancedFilterTree): ValidationResult {
55
+ let totalRules = 0
56
+ function walk(node: FilterRule | FilterGroup, level: number): ValidationResult {
57
+ if (node.type === 'rule') {
58
+ totalRules += 1
59
+ return { ok: true }
60
+ }
61
+ if (level > TREE_LIMITS.maxGroupLevel) return { ok: false, reason: 'depth' }
62
+ if (node.children.length > TREE_LIMITS.maxChildrenPerGroup) return { ok: false, reason: 'width' }
63
+ for (const child of node.children) {
64
+ const r = walk(child, level + 1)
65
+ if (!r.ok) return r
66
+ }
67
+ return { ok: true }
68
+ }
69
+ const r = walk(tree.root, 1)
70
+ if (!r.ok) return r
71
+ if (totalRules > TREE_LIMITS.maxTotalRules) return { ok: false, reason: 'total' }
72
+ return { ok: true }
73
+ }
74
+
75
+ function normalizeSingleValue(value: unknown): unknown {
76
+ if (typeof value !== 'string') return value
77
+ const trimmed = value.trim()
78
+ return trimmed.length > 0 ? trimmed : null
79
+ }
80
+
81
+ function normalizeListValue(value: unknown): unknown[] {
82
+ const list = Array.isArray(value) ? value : [value]
83
+ return list.map(normalizeSingleValue).filter((v) => v !== null)
84
+ }
85
+
86
+ function compileRule(rule: FilterRule): Record<string, unknown> | null {
87
+ if (!rule.field || !rule.operator) return null
88
+ const filter: Record<string, unknown> = {}
89
+ switch (rule.operator) {
90
+ case 'is':
91
+ case 'equals': {
92
+ const v = normalizeSingleValue(rule.value)
93
+ if (v === null) return null
94
+ filter[rule.field] = { $eq: v }; break
95
+ }
96
+ case 'is_not':
97
+ case 'not_equals': {
98
+ const v = normalizeSingleValue(rule.value)
99
+ if (v === null) return null
100
+ filter[rule.field] = { $ne: v }; break
101
+ }
102
+ case 'contains': {
103
+ const v = normalizeSingleValue(rule.value)
104
+ if (v === null) return null
105
+ filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}%` }; break
106
+ }
107
+ case 'does_not_contain': {
108
+ const v = normalizeSingleValue(rule.value)
109
+ if (v === null) return null
110
+ filter[rule.field] = { $not: { $ilike: `%${escapeLikePattern(String(v))}%` } }; break
111
+ }
112
+ case 'starts_with': {
113
+ const v = normalizeSingleValue(rule.value)
114
+ if (v === null) return null
115
+ filter[rule.field] = { $ilike: `${escapeLikePattern(String(v))}%` }; break
116
+ }
117
+ case 'ends_with': {
118
+ const v = normalizeSingleValue(rule.value)
119
+ if (v === null) return null
120
+ filter[rule.field] = { $ilike: `%${escapeLikePattern(String(v))}` }; break
121
+ }
122
+ case 'is_empty': filter[rule.field] = { $exists: false }; break
123
+ case 'is_not_empty': filter[rule.field] = { $exists: true }; break
124
+ case 'greater_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }
125
+ case 'less_than': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }
126
+ case 'greater_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gte: v }; break }
127
+ case 'less_or_equal': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lte: v }; break }
128
+ case 'between': {
129
+ if (Array.isArray(rule.value) && rule.value.length === 2) {
130
+ const start = normalizeSingleValue(rule.value[0])
131
+ const end = normalizeSingleValue(rule.value[1])
132
+ if (start === null && end === null) return null
133
+ if (start !== null && end !== null) filter[rule.field] = { $gte: start, $lte: end }
134
+ else if (start !== null) filter[rule.field] = { $gte: start }
135
+ else filter[rule.field] = { $lte: end }
136
+ } else {
137
+ return null
138
+ }
139
+ break
140
+ }
141
+ case 'is_before': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $lt: v }; break }
142
+ case 'is_after': { const v = normalizeSingleValue(rule.value); if (v === null) return null; filter[rule.field] = { $gt: v }; break }
143
+ case 'is_any_of':
144
+ case 'has_any_of': {
145
+ const list = normalizeListValue(rule.value)
146
+ if (list.length === 0) return null
147
+ filter[rule.field] = { $in: list }; break
148
+ }
149
+ case 'is_none_of':
150
+ case 'has_none_of': {
151
+ const list = normalizeListValue(rule.value)
152
+ if (list.length === 0) return null
153
+ filter[rule.field] = { $nin: list }; break
154
+ }
155
+ case 'has_all_of': {
156
+ const list = normalizeListValue(rule.value)
157
+ if (list.length === 0) return null
158
+ filter[rule.field] = { $contains: list }; break
159
+ }
160
+ case 'is_true': filter[rule.field] = { $eq: true }; break
161
+ case 'is_false': filter[rule.field] = { $eq: false }; break
162
+ }
163
+ return Object.keys(filter).length > 0 ? filter : null
164
+ }
165
+
166
+ export function compileTreeToWhere(tree: AdvancedFilterTree): Record<string, unknown> | null {
167
+ return compileGroup(tree.root)
168
+ }
169
+
170
+ function compileGroup(node: FilterGroup): Record<string, unknown> | null {
171
+ const compiled = node.children
172
+ .map((child) => (child.type === 'rule' ? compileRule(child) : compileGroup(child)))
173
+ .filter((c): c is Record<string, unknown> => c !== null)
174
+ if (compiled.length === 0) return null
175
+ if (compiled.length === 1) return compiled[0]
176
+ return node.combinator === 'or' ? { $or: compiled } : { $and: compiled }
177
+ }
178
+
179
+ // Re-export isValuelessOperator so consumers of this module can access it
180
+ export { isValuelessOperator }
181
+
182
+ /**
183
+ * Build a single-rule tree wrapped in an AND root group.
184
+ * Useful for quick-filter presets that apply one rule.
185
+ */
186
+ export function makeRuleTree(rule: { field: string; operator: FilterOperator; value: unknown }): AdvancedFilterTree {
187
+ const ruleNode: FilterRule = {
188
+ id: crypto.randomUUID(),
189
+ type: 'rule',
190
+ field: rule.field,
191
+ operator: rule.operator,
192
+ value: rule.value,
193
+ }
194
+ return {
195
+ root: {
196
+ id: crypto.randomUUID(),
197
+ type: 'group',
198
+ combinator: 'and',
199
+ children: [ruleNode],
200
+ },
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Persisted shape of an `AdvancedFilterTree` inside `PerspectiveSettings.filters`.
206
+ * The `v: 2` marker disambiguates the tree from legacy flat `FilterValues` and
207
+ * from the plain `{root: ...}` shape. Runtime-only `addedAt` is stripped before
208
+ * persisting so saved perspectives are deterministic across reloads.
209
+ */
210
+ export type PersistedFilterTree = {
211
+ v: 2
212
+ root: FilterGroup
213
+ }
214
+
215
+ function stripRuntimeMetadataDeep<T extends FilterRule | FilterGroup>(node: T): T {
216
+ if (node.type === 'rule') {
217
+ const { addedAt: _ignored, ...rest } = node as FilterRule & { addedAt?: number }
218
+ return rest as T
219
+ }
220
+ const { addedAt: _ignored, children, ...rest } = node as FilterGroup & { addedAt?: number }
221
+ const cleanChildren = children.map((c) => stripRuntimeMetadataDeep(c))
222
+ return { ...rest, children: cleanChildren } as T
223
+ }
224
+
225
+ /** Serialize a tree for persistence (perspective settings, exports, etc.). */
226
+ export function serializeTreeForPersist(tree: AdvancedFilterTree): PersistedFilterTree {
227
+ return { v: 2, root: stripRuntimeMetadataDeep(tree.root) }
228
+ }
229
+
230
+ /** Type guard: does `value` look like a persisted filter tree? */
231
+ export function isPersistedFilterTree(value: unknown): value is PersistedFilterTree {
232
+ if (!value || typeof value !== 'object') return false
233
+ const record = value as Record<string, unknown>
234
+ if (record.v !== 2) return false
235
+ const root = record.root
236
+ if (!root || typeof root !== 'object') return false
237
+ const r = root as Record<string, unknown>
238
+ return r.type === 'group' && (r.combinator === 'and' || r.combinator === 'or') && Array.isArray(r.children)
239
+ }
240
+
241
+ /**
242
+ * Restore a tree from its persisted shape. Returns `null` when the input is
243
+ * not a recognizable persisted tree — callers should fall back to legacy
244
+ * filter handling in that case.
245
+ */
246
+ export function deserializeTreeFromPersist(value: unknown): AdvancedFilterTree | null {
247
+ if (!isPersistedFilterTree(value)) return null
248
+ return { root: value.root }
249
+ }
250
+
251
+ /**
252
+ * Best-effort back-conversion of a tree to the legacy flat `AdvancedFilterState`
253
+ * shape (`{logic, conditions[]}`). The flat shape only supports a single level
254
+ * of AND/OR joining between sibling conditions; nested groups are flattened by
255
+ * walking all leaf rules into the top level. Use this only for the legacy
256
+ * `DataTable.advancedFilter` BC bridge — new code should keep the tree shape.
257
+ *
258
+ * The function picks `logic` from the root group's combinator and uses each
259
+ * rule's parent-group combinator as its `join`. The first rule's `join` is
260
+ * always `'and'` (it has no left neighbor).
261
+ */
262
+ export function treeToFlat(tree: AdvancedFilterTree): {
263
+ logic: 'and' | 'or'
264
+ conditions: Array<{
265
+ id: string
266
+ field: string
267
+ operator: import('./advanced-filter').FilterOperator
268
+ value: unknown
269
+ join: 'and' | 'or'
270
+ }>
271
+ } {
272
+ const conditions: Array<{
273
+ id: string
274
+ field: string
275
+ operator: import('./advanced-filter').FilterOperator
276
+ value: unknown
277
+ join: 'and' | 'or'
278
+ }> = []
279
+
280
+ function walk(group: FilterGroup, parentJoin: 'and' | 'or') {
281
+ for (const child of group.children) {
282
+ if (child.type === 'rule') {
283
+ conditions.push({
284
+ id: child.id,
285
+ field: child.field,
286
+ operator: child.operator,
287
+ value: child.value,
288
+ // First rule overall gets 'and' (it has no left neighbor); the rest
289
+ // inherit the parent group's combinator.
290
+ join: conditions.length === 0 ? 'and' : parentJoin,
291
+ })
292
+ } else {
293
+ walk(child, child.combinator)
294
+ }
295
+ }
296
+ }
297
+ walk(tree.root, tree.root.combinator)
298
+
299
+ return { logic: tree.root.combinator, conditions }
300
+ }
301
+
302
+ /**
303
+ * Build a multi-rule tree wrapped in a root group with the given combinator.
304
+ * Useful for quick-filter presets that combine multiple rules (e.g.
305
+ * `status is win AND close_date is_after start_of_quarter`).
306
+ */
307
+ export function makeMultiRuleTree(
308
+ rules: Array<{ field: string; operator: FilterOperator; value: unknown }>,
309
+ combinator: FilterCombinator = 'and',
310
+ ): AdvancedFilterTree {
311
+ return {
312
+ root: {
313
+ id: crypto.randomUUID(),
314
+ type: 'group',
315
+ combinator,
316
+ children: rules.map((rule) => {
317
+ const node: FilterRule = {
318
+ id: crypto.randomUUID(),
319
+ type: 'rule',
320
+ field: rule.field,
321
+ operator: rule.operator,
322
+ value: rule.value,
323
+ }
324
+ return node
325
+ }),
326
+ },
327
+ }
328
+ }
@@ -10,9 +10,12 @@ export type FilterOperator =
10
10
 
11
11
  export type FilterFieldType = 'text' | 'number' | 'date' | 'select' | 'boolean' | 'tags'
12
12
 
13
+ export type FilterOptionTone = 'success' | 'error' | 'warning' | 'info' | 'neutral' | 'brand' | 'pink'
14
+
13
15
  export type FilterOption = {
14
16
  value: string
15
17
  label: string
18
+ tone?: FilterOptionTone
16
19
  }
17
20
 
18
21
  export type FilterFieldDef = {
@@ -20,6 +23,7 @@ export type FilterFieldDef = {
20
23
  label: string
21
24
  type: FilterFieldType
22
25
  group?: string
26
+ iconName?: string
23
27
  loadOptions?: (query?: string) => Promise<FilterOption[]>
24
28
  options?: FilterOption[]
25
29
  }
@@ -302,3 +306,216 @@ export function convertAdvancedFilterToWhere(state: AdvancedFilterState): Record
302
306
 
303
307
  return clauses.length > 1 ? { $or: clauses } : clauses[0]
304
308
  }
309
+
310
+ // -----------------------------------------------------------------------------
311
+ // v2 tree serialization (advanced-filter-tree)
312
+ // -----------------------------------------------------------------------------
313
+
314
+ import type {
315
+ AdvancedFilterTree,
316
+ FilterRule as TreeFilterRule,
317
+ FilterGroup as TreeFilterGroup,
318
+ FilterCombinator as TreeFilterCombinator,
319
+ } from './advanced-filter-tree'
320
+
321
+ export function serializeTree(tree: AdvancedFilterTree): Record<string, string> {
322
+ if (!treeHasRules(tree.root)) return {}
323
+ const out: Record<string, string> = { 'filter[v]': '2' }
324
+ serializeTreeGroup(tree.root, 'filter[root]', out)
325
+ return out
326
+ }
327
+
328
+ function treeHasRules(group: TreeFilterGroup): boolean {
329
+ return group.children.some((child) => child.type === 'rule' || treeHasRules(child))
330
+ }
331
+
332
+ function serializeTreeGroup(group: TreeFilterGroup, prefix: string, out: Record<string, string>): void {
333
+ out[`${prefix}[combinator]`] = group.combinator
334
+ group.children.forEach((child, idx) => {
335
+ const childPrefix = `${prefix}[children][${idx}]`
336
+ if (child.type === 'rule') serializeTreeRule(child, childPrefix, out)
337
+ else { out[`${childPrefix}[type]`] = 'group'; serializeTreeGroup(child, childPrefix, out) }
338
+ })
339
+ }
340
+
341
+ function serializeTreeRule(rule: TreeFilterRule, prefix: string, out: Record<string, string>): void {
342
+ out[`${prefix}[type]`] = 'rule'
343
+ out[`${prefix}[field]`] = rule.field
344
+ out[`${prefix}[op]`] = rule.operator
345
+ if (!isValuelessOperator(rule.operator) && rule.value != null) {
346
+ out[`${prefix}[value]`] = typeof rule.value === 'object'
347
+ ? JSON.stringify(rule.value)
348
+ : String(rule.value)
349
+ }
350
+ }
351
+
352
+ export function deserializeTree(query: Record<string, unknown>): AdvancedFilterTree | null {
353
+ if (query['filter[v]'] !== '2') return null
354
+ const root = readTreeGroup('filter[root]', query)
355
+ if (!root) return null
356
+ return { root }
357
+ }
358
+
359
+ function readTreeGroup(prefix: string, query: Record<string, unknown>): TreeFilterGroup | null {
360
+ const combRaw = query[`${prefix}[combinator]`]
361
+ if (combRaw !== 'and' && combRaw !== 'or') return null
362
+ const children: Array<TreeFilterRule | TreeFilterGroup> = []
363
+ for (let i = 0; i < 64; i++) {
364
+ const childPrefix = `${prefix}[children][${i}]`
365
+ const type = query[`${childPrefix}[type]`]
366
+ if (type === 'rule') {
367
+ const field = query[`${childPrefix}[field]`]
368
+ const op = query[`${childPrefix}[op]`]
369
+ if (typeof field !== 'string' || typeof op !== 'string' || !isValidOperator(op)) continue
370
+ const rawVal = query[`${childPrefix}[value]`]
371
+ children.push({
372
+ id: crypto.randomUUID(),
373
+ type: 'rule',
374
+ field,
375
+ operator: op as FilterOperator,
376
+ value: parseSerializedFilterValue(rawVal),
377
+ })
378
+ } else if (type === 'group') {
379
+ const sub = readTreeGroup(childPrefix, query)
380
+ if (sub) children.push(sub)
381
+ } else {
382
+ break
383
+ }
384
+ }
385
+ return {
386
+ id: crypto.randomUUID(),
387
+ type: 'group',
388
+ combinator: combRaw as TreeFilterCombinator,
389
+ children,
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Runtime discriminator: is this value an `AdvancedFilterState` (legacy flat
395
+ * shape with `logic` + `conditions`) rather than an `AdvancedFilterTree`
396
+ * (`{root: FilterGroup}`)?
397
+ *
398
+ * Used at the DataTable boundary to bridge legacy callers onto the new tree
399
+ * model without forcing third-party module developers to migrate immediately.
400
+ * See `BACKWARD_COMPATIBILITY.md` §3 and the spec's "Migration & Backward
401
+ * Compatibility" section.
402
+ */
403
+ export function isAdvancedFilterState(value: unknown): value is AdvancedFilterState {
404
+ if (!value || typeof value !== 'object') return false
405
+ const record = value as Record<string, unknown>
406
+ return Array.isArray(record.conditions) && (record.logic === 'and' || record.logic === 'or')
407
+ }
408
+
409
+ /**
410
+ * Convert legacy flat AdvancedFilterState into a tree under standard SQL precedence
411
+ * (AND binds tighter than OR). Runs of consecutive AND-joined conditions become
412
+ * AND-subgroups, and the OR connectors join those subgroups in a root OR-group.
413
+ */
414
+ export function flatToTree(flat: AdvancedFilterState): AdvancedFilterTree {
415
+ if (flat.conditions.length === 0) {
416
+ return { root: { id: crypto.randomUUID(), type: 'group', combinator: 'and', children: [] } }
417
+ }
418
+
419
+ // Step A: split into AND-runs separated by OR connectors. The first row's `join`
420
+ // is logically "and" (no left neighbor); we never use it as a separator.
421
+ const andRuns: FilterCondition[][] = [[flat.conditions[0]]]
422
+ for (let i = 1; i < flat.conditions.length; i++) {
423
+ const c = flat.conditions[i]
424
+ if (c.join === 'or') andRuns.push([c])
425
+ else andRuns[andRuns.length - 1].push(c)
426
+ }
427
+
428
+ const ruleFromCondition = (c: FilterCondition): TreeFilterRule => ({
429
+ id: crypto.randomUUID(),
430
+ type: 'rule',
431
+ field: c.field,
432
+ operator: c.operator,
433
+ value: c.value,
434
+ })
435
+
436
+ // Step B: each AND-run becomes either a rule (length 1) or an AND-group.
437
+ const orChildren: Array<TreeFilterRule | TreeFilterGroup> = andRuns.map((run) => {
438
+ if (run.length === 1) return ruleFromCondition(run[0])
439
+ return {
440
+ id: crypto.randomUUID(),
441
+ type: 'group',
442
+ combinator: 'and',
443
+ children: run.map(ruleFromCondition),
444
+ }
445
+ })
446
+
447
+ // Step C: zero or one OR-disjunct -> root combinator stays "and"; otherwise OR.
448
+ if (orChildren.length === 1) {
449
+ const only = orChildren[0]
450
+ if (only.type === 'group') return { root: only }
451
+ return { root: { id: crypto.randomUUID(), type: 'group', combinator: 'and', children: [only] } }
452
+ }
453
+ return {
454
+ root: { id: crypto.randomUUID(), type: 'group', combinator: 'or', children: orChildren },
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Map a dictionary entry's display color (named like 'red'/'green' or a hex like '#ef4444')
460
+ * to a `FilterOptionTone`, so dictionary-backed select options can render with the
461
+ * correct status dot in chips and value pills. Unknown / undefined inputs return
462
+ * `undefined` so the consumer falls back to plain (no tone) rendering.
463
+ */
464
+ export function mapDictionaryColorToTone(color: string | null | undefined): FilterOptionTone | undefined {
465
+ if (!color || typeof color !== 'string') return undefined
466
+ const trimmed = color.trim().toLowerCase()
467
+ if (!trimmed) return undefined
468
+ const named: Record<string, FilterOptionTone> = {
469
+ red: 'error',
470
+ crimson: 'error',
471
+ pink: 'pink',
472
+ rose: 'pink',
473
+ fuchsia: 'pink',
474
+ magenta: 'pink',
475
+ green: 'success',
476
+ emerald: 'success',
477
+ lime: 'success',
478
+ teal: 'success',
479
+ amber: 'warning',
480
+ yellow: 'warning',
481
+ orange: 'warning',
482
+ blue: 'info',
483
+ cyan: 'info',
484
+ sky: 'info',
485
+ indigo: 'info',
486
+ gray: 'neutral',
487
+ grey: 'neutral',
488
+ slate: 'neutral',
489
+ zinc: 'neutral',
490
+ stone: 'neutral',
491
+ violet: 'brand',
492
+ purple: 'brand',
493
+ }
494
+ if (named[trimmed]) return named[trimmed]
495
+ const hexMatch = /^#?([0-9a-f]{6})$/i.exec(trimmed)
496
+ if (!hexMatch) return undefined
497
+ const hex = hexMatch[1]
498
+ const r = parseInt(hex.slice(0, 2), 16)
499
+ const g = parseInt(hex.slice(2, 4), 16)
500
+ const b = parseInt(hex.slice(4, 6), 16)
501
+ const max = Math.max(r, g, b)
502
+ const min = Math.min(r, g, b)
503
+ const delta = max - min
504
+ if (delta < 24) return 'neutral'
505
+ let hue = 0
506
+ if (max === r) hue = ((g - b) / delta) % 6
507
+ else if (max === g) hue = (b - r) / delta + 2
508
+ else hue = (r - g) / delta + 4
509
+ hue = (hue * 60 + 360) % 360
510
+ // Hue ranges (degrees): 0-20 red(error), 20-50 orange(warning), 50-80 yellow(warning),
511
+ // 80-170 green(success), 170-260 blue/indigo(info), 260-320 violet/purple(brand),
512
+ // 320-340 pink/magenta(pink), 340-360 red(error).
513
+ if (hue < 20) return 'error'
514
+ if (hue < 50) return 'warning'
515
+ if (hue < 80) return 'warning'
516
+ if (hue < 170) return 'success'
517
+ if (hue < 260) return 'info'
518
+ if (hue < 320) return 'brand'
519
+ if (hue < 340) return 'pink'
520
+ return 'error'
521
+ }
@@ -442,9 +442,10 @@ export class BasicQueryEngine implements QueryEngine {
442
442
  }
443
443
  if (resolvedGroupFilters.length > 0) {
444
444
  q = q.where((eb: any) => eb.or(
445
- resolvedGroupFilters.map((group) => eb.and(
446
- group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
447
- ))
445
+ resolvedGroupFilters.map((group) => {
446
+ const parts = group.map((rf) => this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value))
447
+ return parts.length === 1 ? parts[0] : eb.and(parts)
448
+ })
448
449
  ))
449
450
  }
450
451
  }