@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56

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.
Files changed (131) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +23 -14
  3. package/dist/backend/AppShell.js +8 -32
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +18 -0
  6. package/dist/backend/CrudForm.js.map +2 -2
  7. package/dist/backend/DataTable.js +149 -53
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/FilterBar.js +11 -14
  10. package/dist/backend/FilterBar.js.map +2 -2
  11. package/dist/backend/columns/ColumnChooserPanel.js +4 -6
  12. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  13. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  14. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  15. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  16. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  17. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  18. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  19. package/dist/backend/filters/FilterEmptyState.js +41 -0
  20. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  21. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  22. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  23. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  24. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  25. package/dist/backend/filters/QuickFilters.js +41 -0
  26. package/dist/backend/filters/QuickFilters.js.map +7 -0
  27. package/dist/backend/filters/filterValidation.js +47 -0
  28. package/dist/backend/filters/filterValidation.js.map +7 -0
  29. package/dist/backend/filters/treeReducer.js +159 -0
  30. package/dist/backend/filters/treeReducer.js.map +7 -0
  31. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  32. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +11 -7
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/EventPatternInput.js +5 -2
  36. package/dist/backend/inputs/EventPatternInput.js.map +2 -2
  37. package/dist/backend/inputs/EventSelect.js +10 -4
  38. package/dist/backend/inputs/EventSelect.js.map +2 -2
  39. package/dist/backend/inputs/LookupSelect.js +51 -47
  40. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  41. package/dist/backend/inputs/PhoneNumberField.js +196 -45
  42. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  43. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  44. package/dist/backend/messages/message-compose-form-groups.js +2 -2
  45. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  46. package/dist/backend/utils/api.js +18 -1
  47. package/dist/backend/utils/api.js.map +2 -2
  48. package/dist/backend/utils/serverErrors.js +6 -2
  49. package/dist/backend/utils/serverErrors.js.map +2 -2
  50. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  51. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  52. package/dist/backend/utils/useCurrentUserId.js +31 -0
  53. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  54. package/dist/portal/utils/nav.js +17 -0
  55. package/dist/portal/utils/nav.js.map +2 -2
  56. package/dist/primitives/amount-input.js +128 -0
  57. package/dist/primitives/amount-input.js.map +7 -0
  58. package/dist/primitives/button-input.js +47 -0
  59. package/dist/primitives/button-input.js.map +7 -0
  60. package/dist/primitives/card-input.js +133 -0
  61. package/dist/primitives/card-input.js.map +7 -0
  62. package/dist/primitives/dialog.js +7 -5
  63. package/dist/primitives/dialog.js.map +2 -2
  64. package/dist/primitives/email-input.js +29 -0
  65. package/dist/primitives/email-input.js.map +7 -0
  66. package/dist/primitives/password-input.js +64 -0
  67. package/dist/primitives/password-input.js.map +7 -0
  68. package/dist/primitives/search-input.js +57 -0
  69. package/dist/primitives/search-input.js.map +7 -0
  70. package/dist/primitives/select.js +43 -5
  71. package/dist/primitives/select.js.map +2 -2
  72. package/dist/primitives/tag.js +4 -2
  73. package/dist/primitives/tag.js.map +2 -2
  74. package/dist/primitives/website-input.js +56 -0
  75. package/dist/primitives/website-input.js.map +7 -0
  76. package/package.json +3 -3
  77. package/src/backend/AppShell.tsx +7 -30
  78. package/src/backend/CrudForm.tsx +17 -0
  79. package/src/backend/DataTable.tsx +307 -63
  80. package/src/backend/FilterBar.tsx +5 -6
  81. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  82. package/src/backend/__tests__/serverErrors.test.ts +79 -1
  83. package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
  84. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  85. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  86. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  87. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  88. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  89. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  90. package/src/backend/filters/QuickFilters.tsx +62 -0
  91. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  92. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  93. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  94. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  95. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  96. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  97. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  98. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  99. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  100. package/src/backend/filters/filterValidation.ts +61 -0
  101. package/src/backend/filters/treeReducer.ts +201 -0
  102. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  103. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  104. package/src/backend/inputs/ComboboxInput.tsx +32 -26
  105. package/src/backend/inputs/EventPatternInput.tsx +11 -2
  106. package/src/backend/inputs/EventSelect.tsx +13 -4
  107. package/src/backend/inputs/LookupSelect.tsx +75 -53
  108. package/src/backend/inputs/PhoneNumberField.tsx +237 -47
  109. package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
  110. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
  111. package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
  112. package/src/backend/messages/message-compose-form-groups.tsx +2 -2
  113. package/src/backend/utils/api.ts +27 -2
  114. package/src/backend/utils/serverErrors.ts +15 -8
  115. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  116. package/src/backend/utils/useCurrentUserId.ts +40 -0
  117. package/src/portal/utils/__tests__/nav.test.ts +80 -0
  118. package/src/portal/utils/nav.ts +23 -1
  119. package/src/primitives/__tests__/password-input.test.tsx +93 -0
  120. package/src/primitives/__tests__/search-input.test.tsx +95 -0
  121. package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
  122. package/src/primitives/amount-input.tsx +184 -0
  123. package/src/primitives/button-input.tsx +68 -0
  124. package/src/primitives/card-input.tsx +193 -0
  125. package/src/primitives/dialog.tsx +18 -7
  126. package/src/primitives/email-input.tsx +36 -0
  127. package/src/primitives/password-input.tsx +100 -0
  128. package/src/primitives/search-input.tsx +87 -0
  129. package/src/primitives/select.tsx +98 -11
  130. package/src/primitives/tag.tsx +3 -1
  131. package/src/primitives/website-input.tsx +72 -0
@@ -1,6 +1,25 @@
1
- "use client"
1
+ 'use client'
2
2
  import * as React from 'react'
3
- import { ChevronDown, Plus, Trash2, X } from 'lucide-react'
3
+ import { createPortal } from 'react-dom'
4
+ import { Plus, Trash2, X, GripVertical, ChevronDown } from 'lucide-react'
5
+ import {
6
+ DndContext,
7
+ DragOverlay,
8
+ PointerSensor,
9
+ KeyboardSensor,
10
+ useSensor,
11
+ useSensors,
12
+ closestCenter,
13
+ type DragEndEvent,
14
+ type DragStartEvent,
15
+ } from '@dnd-kit/core'
16
+ import {
17
+ SortableContext,
18
+ sortableKeyboardCoordinates,
19
+ useSortable,
20
+ verticalListSortingStrategy,
21
+ } from '@dnd-kit/sortable'
22
+ import { CSS } from '@dnd-kit/utilities'
4
23
  import { Button } from '../../primitives/button'
5
24
  import { IconButton } from '../../primitives/icon-button'
6
25
  import { Input } from '../../primitives/input'
@@ -11,220 +30,643 @@ import {
11
30
  SelectTrigger,
12
31
  SelectValue,
13
32
  } from '../../primitives/select'
33
+ import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
34
+ import { Checkbox } from '../../primitives/checkbox'
14
35
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
36
  import type {
16
- AdvancedFilterState,
17
- FilterCondition,
18
37
  FilterFieldDef,
19
38
  FilterFieldType,
20
- FilterJoinOperator,
21
39
  FilterOperator,
22
40
  } from '@open-mercato/shared/lib/query/advanced-filter'
23
41
  import {
24
42
  OPERATORS_BY_FIELD_TYPE,
25
43
  getDefaultOperator,
26
44
  isValuelessOperator,
27
- createEmptyCondition,
28
- normalizeAdvancedFilterState,
29
45
  } from '@open-mercato/shared/lib/query/advanced-filter'
46
+ import {
47
+ type AdvancedFilterTree,
48
+ type FilterRule,
49
+ type FilterGroup,
50
+ type FilterCombinator,
51
+ TREE_LIMITS,
52
+ } from '@open-mercato/shared/lib/query/advanced-filter-tree'
53
+ import { treeReducer, canAddRule, canAddGroup, type TreeAction } from './treeReducer'
54
+ import { FilterFieldPicker } from './FilterFieldPicker'
30
55
 
31
56
  export type AdvancedFilterBuilderProps = {
32
57
  fields: FilterFieldDef[]
33
- value: AdvancedFilterState
34
- onChange: (state: AdvancedFilterState) => void
58
+ value: AdvancedFilterTree
59
+ onChange: (state: AdvancedFilterTree) => void
35
60
  onApply: () => void
36
61
  onClear: () => void
62
+ pendingErrors?: Array<{ ruleId: string; messageKey: string; message: string }>
37
63
  }
38
64
 
39
65
  const OPERATOR_LABELS: Record<FilterOperator, string> = {
40
- is: 'is',
41
- is_not: 'is not',
42
- contains: 'contains',
43
- does_not_contain: 'does not contain',
44
- starts_with: 'starts with',
45
- ends_with: 'ends with',
46
- is_empty: 'is empty',
47
- is_not_empty: 'is not empty',
48
- equals: 'equals',
49
- not_equals: 'not equals',
50
- greater_than: 'greater than',
51
- less_than: 'less than',
52
- greater_or_equal: 'greater or equal',
53
- less_or_equal: 'less or equal',
54
- between: 'between',
55
- is_before: 'is before',
56
- is_after: 'is after',
57
- is_any_of: 'is any of',
58
- is_none_of: 'is none of',
59
- is_true: 'is true',
60
- is_false: 'is false',
61
- has_any_of: 'has any of',
62
- has_all_of: 'has all of',
63
- has_none_of: 'has none of',
66
+ is: 'is', is_not: 'is not', contains: 'contains', does_not_contain: 'does not contain',
67
+ starts_with: 'starts with', ends_with: 'ends with', is_empty: 'is empty', is_not_empty: 'is not empty',
68
+ equals: 'equals', not_equals: 'not equals', greater_than: 'greater than', less_than: 'less than',
69
+ greater_or_equal: 'greater or equal', less_or_equal: 'less or equal', between: 'between',
70
+ is_before: 'is before', is_after: 'is after', is_any_of: 'is any of', is_none_of: 'is none of',
71
+ is_true: 'is true', is_false: 'is false', has_any_of: 'has any of', has_all_of: 'has all of', has_none_of: 'has none of',
64
72
  }
65
73
 
66
74
  function getFieldType(fields: FilterFieldDef[], fieldKey: string): FilterFieldType {
67
- const field = fields.find((f) => f.key === fieldKey)
68
- return field?.type ?? 'text'
75
+ return fields.find((f) => f.key === fieldKey)?.type ?? 'text'
76
+ }
77
+
78
+ // Operator value shapes. Used to decide whether changing operator preserves the
79
+ // current value: same shape preserves (`is` → `is not` keeps "Active"); different
80
+ // shape resets (`is` → `between` clears, because value goes from scalar to pair).
81
+ type OperatorShape = 'valueless' | 'multi' | 'range' | 'single'
82
+
83
+ const MULTI_VALUE_OPS = new Set<FilterOperator>([
84
+ 'is_any_of', 'is_none_of', 'has_any_of', 'has_all_of', 'has_none_of',
85
+ ])
86
+
87
+ function getOperatorShape(op: FilterOperator): OperatorShape {
88
+ if (isValuelessOperator(op)) return 'valueless'
89
+ if (MULTI_VALUE_OPS.has(op)) return 'multi'
90
+ if (op === 'between') return 'range'
91
+ return 'single'
92
+ }
93
+
94
+ type DragHandleProps = React.HTMLAttributes<HTMLButtonElement> & {
95
+ ref?: React.Ref<HTMLButtonElement>
96
+ }
97
+
98
+ type Translator = ReturnType<typeof useT>
99
+
100
+ export function AdvancedFilterBuilder({
101
+ fields, value, onChange, onApply, onClear, pendingErrors,
102
+ }: AdvancedFilterBuilderProps) {
103
+ const t = useT()
104
+
105
+ const dispatch = React.useCallback((action: TreeAction) => {
106
+ onChange(treeReducer(value, action))
107
+ }, [onChange, value])
108
+
109
+ React.useEffect(() => {
110
+ function onKey(e: KeyboardEvent) {
111
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
112
+ e.preventDefault()
113
+ onApply()
114
+ }
115
+ }
116
+ window.addEventListener('keydown', onKey)
117
+ return () => window.removeEventListener('keydown', onKey)
118
+ }, [onApply])
119
+
120
+ const errorByRuleId = React.useMemo(() => {
121
+ const m = new Map<string, string>()
122
+ for (const e of pendingErrors ?? []) m.set(e.ruleId, t(e.messageKey, e.message))
123
+ return m
124
+ }, [pendingErrors, t])
125
+
126
+ const empty = value.root.children.length === 0
127
+ const defaultField = fields.length > 0 ? fields[0].key : undefined
128
+
129
+ // Per-group SortableContexts scope drag-and-drop to within-group ordering.
130
+ // The DragOverlay below renders a stable preview that
131
+ // matches the original row's footprint — without it, dnd-kit applies
132
+ // `transform` to the source row in place, which the wrapping flex layout
133
+ // distorted into a "tall" / morphed ghost.
134
+ const sensors = useSensors(
135
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
136
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
137
+ )
138
+ const [activeDrag, setActiveDrag] = React.useState<{ node: FilterRule | FilterGroup; parent: FilterGroup } | null>(null)
139
+ const findNode = React.useCallback((id: string) => locateInTree(value.root, id), [value])
140
+
141
+ const handleDragStart = React.useCallback((e: DragStartEvent) => {
142
+ const found = findNode(String(e.active.id))
143
+ if (found) setActiveDrag(found)
144
+ }, [findNode])
145
+
146
+ const handleDragEnd = React.useCallback((e: DragEndEvent) => {
147
+ setActiveDrag(null)
148
+ const { active, over } = e
149
+ if (!over || active.id === over.id) return
150
+ const from = findNode(String(active.id))
151
+ const to = findNode(String(over.id))
152
+ if (!from || !to) return
153
+ if (from.parent.id !== to.parent.id) return
154
+ const fromIdx = from.parent.children.findIndex((c) => c.id === active.id)
155
+ const toIdx = to.parent.children.findIndex((c) => c.id === over.id)
156
+ if (fromIdx < 0 || toIdx < 0) return
157
+ dispatch({ type: 'reorderChildren', groupId: from.parent.id, fromIdx, toIdx })
158
+ }, [dispatch, findNode])
159
+
160
+ const handleDragCancel = React.useCallback(() => setActiveDrag(null), [])
161
+
162
+ // Wrapper has no `min-w-[640px]`: it forced horizontal scroll on a 375px viewport.
163
+ // Rules wrap onto multiple lines on mobile; on desktop (`sm:max-w-[720px]`) they fit
164
+ // on one line.
165
+ return (
166
+ <DndContext
167
+ sensors={sensors}
168
+ collisionDetection={closestCenter}
169
+ onDragStart={handleDragStart}
170
+ onDragEnd={handleDragEnd}
171
+ onDragCancel={handleDragCancel}
172
+ >
173
+ <div className="flex flex-col gap-3 p-3">
174
+ {!empty ? (
175
+ <GroupView
176
+ group={value.root}
177
+ level={1}
178
+ fields={fields}
179
+ tree={value}
180
+ dispatch={dispatch}
181
+ defaultField={defaultField}
182
+ errorByRuleId={errorByRuleId}
183
+ t={t}
184
+ />
185
+ ) : null}
186
+ {!empty ? (
187
+ <div className="flex items-center gap-3 pt-2 border-t border-border">
188
+ <Button
189
+ type="button"
190
+ variant="ghost"
191
+ size="sm"
192
+ className="h-auto px-0 py-0 text-base text-muted-foreground hover:bg-transparent hover:text-foreground"
193
+ onClick={onClear}
194
+ >
195
+ <X className="size-4" />
196
+ {t('ui.advancedFilter.clearAll', 'Clear all')}
197
+ </Button>
198
+ </div>
199
+ ) : null}
200
+ </div>
201
+ {/* Portal DragOverlay to document.body. Radix DialogContent uses CSS `transform`
202
+ for centering, which becomes the containing block for `position: fixed`
203
+ descendants (dnd-kit's overlay) and shifts the ghost off-screen. Portaling
204
+ restores viewport-relative coordinates. */}
205
+ {typeof document !== 'undefined'
206
+ ? createPortal(
207
+ // dnd-kit's DragOverlay sets its own inline z-index, so a Tailwind
208
+ // utility class would be overridden — read the DS token via CSS var.
209
+ <DragOverlay dropAnimation={null} style={{ zIndex: 'var(--z-index-modal-elevated, 55)' }}>
210
+ {activeDrag ? (
211
+ <DragGhost node={activeDrag.node} parent={activeDrag.parent} fields={fields} t={t} />
212
+ ) : null}
213
+ </DragOverlay>,
214
+ document.body,
215
+ )
216
+ : null}
217
+ </DndContext>
218
+ )
69
219
  }
70
220
 
71
- function ConditionRow({
72
- condition,
73
- index,
74
- fields,
75
- join,
76
- onUpdate,
77
- onRemove,
78
- onToggleJoin,
79
- t,
221
+ /** Find a node in the tree along with its parent group. Used by drag handlers. */
222
+ function locateInTree(root: FilterGroup, id: string): { node: FilterRule | FilterGroup; parent: FilterGroup } | null {
223
+ for (const child of root.children) {
224
+ if (child.id === id) return { node: child, parent: root }
225
+ if (child.type === 'group') {
226
+ const found = locateInTree(child, id)
227
+ if (found) return found
228
+ }
229
+ }
230
+ return null
231
+ }
232
+
233
+ // Compact drag preview rendered inside DragOverlay. Cannot reuse RuleRow/GroupView —
234
+ // their popovers and form inputs conflict with the overlay.
235
+ function DragGhost({
236
+ node, parent, fields, t,
80
237
  }: {
81
- condition: FilterCondition
82
- index: number
238
+ node: FilterRule | FilterGroup
239
+ parent: FilterGroup
83
240
  fields: FilterFieldDef[]
84
- join: FilterJoinOperator
85
- onUpdate: (id: string, updates: Partial<FilterCondition>) => void
86
- onRemove: (id: string) => void
87
- onToggleJoin: (id: string) => void
88
- t: ReturnType<typeof useT>
241
+ t: Translator
89
242
  }) {
90
- const fieldType = getFieldType(fields, condition.field)
91
- const operators = OPERATORS_BY_FIELD_TYPE[fieldType] ?? OPERATORS_BY_FIELD_TYPE.text
92
- const valueless = isValuelessOperator(condition.operator)
243
+ const isGroup = node.type === 'group'
244
+ const labelParts: string[] = []
245
+ if (isGroup) {
246
+ const ruleCount = countRulesIn(node)
247
+ labelParts.push(t('ui.advancedFilter.dragGhost.group', 'Group'))
248
+ labelParts.push(`(${ruleCount})`)
249
+ } else {
250
+ const def = fields.find((f) => f.key === node.field)
251
+ labelParts.push(def?.label ?? (node.field || t('ui.advancedFilter.dragGhost.unsetField', 'Unset field')))
252
+ labelParts.push(OPERATOR_LABELS[node.operator] ?? node.operator)
253
+ if (typeof node.value === 'string' && node.value) labelParts.push(`"${node.value}"`)
254
+ }
255
+ return (
256
+ <div className="inline-flex items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-sm shadow-md cursor-grabbing">
257
+ <GripVertical className="size-4 text-muted-foreground" />
258
+ <span className="text-muted-foreground">{parent.combinator === 'and' ? t('ui.advancedFilter.connector.and', 'and') : t('ui.advancedFilter.connector.or', 'or')}</span>
259
+ <span className="font-medium">{labelParts.join(' ')}</span>
260
+ </div>
261
+ )
262
+ }
93
263
 
94
- const handleFieldChange = (newField: string) => {
95
- const newType = getFieldType(fields, newField)
96
- const newOp = getDefaultOperator(newType)
97
- onUpdate(condition.id, { field: newField, operator: newOp, value: '' })
264
+ function countRulesIn(group: FilterGroup): number {
265
+ let n = 0
266
+ for (const c of group.children) {
267
+ if (c.type === 'rule') n += 1
268
+ else n += countRulesIn(c)
98
269
  }
270
+ return n
271
+ }
99
272
 
100
- const joinLabel = join === 'and'
101
- ? t('ui.advancedFilter.and', 'And')
102
- : t('ui.advancedFilter.or', 'Or')
273
+ function GroupView({
274
+ group, level, fields, tree, dispatch, defaultField, errorByRuleId, t,
275
+ }: {
276
+ group: FilterGroup
277
+ level: number
278
+ fields: FilterFieldDef[]
279
+ tree: AdvancedFilterTree
280
+ dispatch: (a: TreeAction) => void
281
+ defaultField?: string
282
+ errorByRuleId: Map<string, string>
283
+ t: Translator
284
+ }) {
285
+ const isRoot = level === 1
286
+ // Sub-groups render as a card so the nesting boundary is visible; root group stays
287
+ // flush with the popover so it doesn't double-up the dialog's own card chrome.
288
+ const containerClass = isRoot
289
+ ? 'space-y-3'
290
+ : 'space-y-3 rounded-lg border border-border bg-muted/30 p-3'
103
291
 
292
+ // Toolbar-first layout (Kendo React Filter pattern): one [And]/[Or] toggle per group
293
+ // is the only combinator UI. Mixing AND with OR requires `+ Add subgroup`. Matches
294
+ // `CompositeFilterDescriptor` semantics (one logic per group).
104
295
  return (
105
- <div className="flex flex-wrap items-start gap-2">
106
- <div className="w-20 shrink-0 pt-0.5 text-sm text-muted-foreground">
107
- {index === 0 ? (
108
- <span>{t('ui.advancedFilter.where', 'Where')}</span>
109
- ) : (
110
- <Button
111
- type="button"
112
- variant="outline"
113
- size="sm"
114
- className="h-9 min-w-[5rem] justify-between px-3 text-sm font-medium"
115
- onClick={() => onToggleJoin(condition.id)}
116
- aria-label={t('ui.advancedFilter.toggleJoin', 'Toggle filter join operator')}
117
- title={t('ui.advancedFilter.toggleJoinHint', 'Click to switch between AND and OR')}
118
- >
119
- <span>{joinLabel}</span>
120
- <ChevronDown className="size-3.5 text-muted-foreground" />
121
- </Button>
122
- )}
123
- </div>
296
+ <div className={containerClass}>
297
+ <GroupToolbar
298
+ group={group}
299
+ level={level}
300
+ tree={tree}
301
+ dispatch={dispatch}
302
+ defaultField={defaultField}
303
+ fields={fields}
304
+ isRoot={isRoot}
305
+ t={t}
306
+ />
124
307
 
125
- <Select
126
- value={condition.field || undefined}
127
- onValueChange={(next) => handleFieldChange(next ?? '')}
128
- >
129
- <SelectTrigger
130
- className="min-w-[140px]"
131
- aria-label={t('ui.advancedFilter.selectField', 'Select field')}
132
- >
133
- <SelectValue placeholder={t('ui.advancedFilter.selectFieldPlaceholder', 'Select field...')} />
134
- </SelectTrigger>
135
- <SelectContent>
136
- {fields.map((f) => (
137
- <SelectItem key={f.key} value={f.key}>{f.label}</SelectItem>
138
- ))}
139
- </SelectContent>
140
- </Select>
308
+ <SortableChildrenList
309
+ group={group}
310
+ level={level}
311
+ fields={fields}
312
+ tree={tree}
313
+ dispatch={dispatch}
314
+ defaultField={defaultField}
315
+ errorByRuleId={errorByRuleId}
316
+ t={t}
317
+ />
318
+ </div>
319
+ )
320
+ }
141
321
 
142
- <Select
143
- value={condition.operator}
144
- onValueChange={(next) => onUpdate(condition.id, { operator: next as FilterOperator, value: '' })}
322
+ // Kendo-style segmented `[And] [Or]` toggle. Clicking the unselected side flips
323
+ // the group's combinator. Selected side is brand-violet filled so the affordance
324
+ // is unambiguous.
325
+ function AndOrToggle({
326
+ combinator, onChange, t,
327
+ }: {
328
+ combinator: FilterCombinator
329
+ onChange: (c: FilterCombinator) => void
330
+ t: Translator
331
+ }) {
332
+ const andLabel = t('ui.advancedFilter.combinator.and', 'And')
333
+ const orLabel = t('ui.advancedFilter.combinator.or', 'Or')
334
+ const baseBtn = 'h-8 px-3 text-sm font-medium outline-none focus-visible:shadow-focus disabled:cursor-not-allowed'
335
+ const selBtn = 'bg-brand-violet text-white hover:bg-brand-violet/90'
336
+ const unselBtn = 'bg-background text-foreground hover:bg-accent'
337
+ return (
338
+ <div
339
+ role="group"
340
+ aria-label={t('ui.advancedFilter.combinator.label', 'Group combinator')}
341
+ className="inline-flex rounded-md border border-input overflow-hidden shrink-0"
342
+ data-testid="advanced-filter-combinator-toggle"
343
+ data-combinator={combinator}
344
+ >
345
+ <button
346
+ type="button"
347
+ aria-pressed={combinator === 'and'}
348
+ onClick={() => combinator !== 'and' && onChange('and')}
349
+ className={`${baseBtn} ${combinator === 'and' ? selBtn : unselBtn}`}
145
350
  >
146
- <SelectTrigger
147
- className="min-w-[120px]"
148
- aria-label={t('ui.advancedFilter.selectOperator', 'Select operator')}
149
- >
150
- <SelectValue />
151
- </SelectTrigger>
152
- <SelectContent>
153
- {operators.map((op) => (
154
- <SelectItem key={op} value={op}>
155
- {t(`ui.advancedFilter.operator.${op}`, OPERATOR_LABELS[op])}
156
- </SelectItem>
157
- ))}
158
- </SelectContent>
159
- </Select>
351
+ {andLabel}
352
+ </button>
353
+ <button
354
+ type="button"
355
+ aria-pressed={combinator === 'or'}
356
+ onClick={() => combinator !== 'or' && onChange('or')}
357
+ className={`${baseBtn} border-l border-input ${combinator === 'or' ? selBtn : unselBtn}`}
358
+ >
359
+ {orLabel}
360
+ </button>
361
+ </div>
362
+ )
363
+ }
160
364
 
161
- {!valueless ? (
162
- <ValueInput
163
- condition={condition}
365
+ function SortableChildrenList({
366
+ group, level, fields, tree, dispatch, defaultField, errorByRuleId, t,
367
+ }: {
368
+ group: FilterGroup
369
+ level: number
370
+ fields: FilterFieldDef[]
371
+ tree: AdvancedFilterTree
372
+ dispatch: (a: TreeAction) => void
373
+ defaultField?: string
374
+ errorByRuleId: Map<string, string>
375
+ t: Translator
376
+ }) {
377
+ // DndContext + handlers live one level up so a drag can move children across
378
+ // groups. Each group owns its own SortableContext to scope sort ordering.
379
+ return (
380
+ <SortableContext items={group.children.map((c) => c.id)} strategy={verticalListSortingStrategy}>
381
+ {group.children.map((child, idx) => (
382
+ <SortableRow
383
+ key={child.id}
384
+ child={child}
385
+ idx={idx}
386
+ parent={group}
387
+ level={level}
164
388
  fields={fields}
165
- fieldType={fieldType}
166
- onUpdate={onUpdate}
389
+ tree={tree}
390
+ dispatch={dispatch}
391
+ defaultField={defaultField}
392
+ errorByRuleId={errorByRuleId}
167
393
  t={t}
168
394
  />
169
- ) : null}
395
+ ))}
396
+ </SortableContext>
397
+ )
398
+ }
170
399
 
171
- <IconButton
172
- variant="ghost"
173
- size="sm"
174
- type="button"
175
- onClick={() => onRemove(condition.id)}
176
- aria-label={t('ui.advancedFilter.removeCondition', 'Remove condition')}
177
- >
178
- <Trash2 className="size-4 text-muted-foreground" />
179
- </IconButton>
400
+ function SortableRow({
401
+ child, idx, parent, level, fields, tree, dispatch, defaultField, errorByRuleId, t,
402
+ }: {
403
+ child: FilterRule | FilterGroup
404
+ idx: number
405
+ parent: FilterGroup
406
+ level: number
407
+ fields: FilterFieldDef[]
408
+ tree: AdvancedFilterTree
409
+ dispatch: (a: TreeAction) => void
410
+ defaultField?: string
411
+ errorByRuleId: Map<string, string>
412
+ t: Translator
413
+ }) {
414
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
415
+ id: child.id,
416
+ })
417
+ // Hide the source while dragging (DragOverlay renders the snapshot); keep it in
418
+ // layout via `visibility: hidden` so siblings don't jump. Without this, the flex
419
+ // wrapper applies both `transform` and its own resizing to the source row.
420
+ const style: React.CSSProperties = isDragging
421
+ ? { visibility: 'hidden' as const }
422
+ : { transform: CSS.Transform.toString(transform), transition }
423
+ const dragHandleProps: DragHandleProps = { ...attributes, ...listeners }
424
+
425
+ return (
426
+ <div ref={setNodeRef} style={style} data-testid="filter-rule-row" data-filter-node-id={child.id}>
427
+ {child.type === 'rule' ? (
428
+ <RuleRow
429
+ rule={child}
430
+ fields={fields}
431
+ dispatch={dispatch}
432
+ errorByRuleId={errorByRuleId}
433
+ dragHandleProps={dragHandleProps}
434
+ t={t}
435
+ />
436
+ ) : (
437
+ <div className="flex items-start gap-2">
438
+ {/* Raw <button> required: dnd-kit spreads `attributes`+`listeners` onto the
439
+ activator; <Button>'s own props/ref handling breaks the keyboard sensor. */}
440
+ <button
441
+ type="button"
442
+ {...dragHandleProps}
443
+ data-testid="filter-drag-handle"
444
+ className="mt-1 text-muted-foreground/60 hover:text-muted-foreground cursor-grab outline-none focus-visible:shadow-focus rounded-sm"
445
+ aria-label={t('ui.advancedFilter.dragHandle', 'Drag to reorder')}
446
+ >
447
+ <GripVertical className="size-4" />
448
+ </button>
449
+ <div className="flex-1">
450
+ <GroupView
451
+ group={child}
452
+ level={level + 1}
453
+ fields={fields}
454
+ tree={tree}
455
+ dispatch={dispatch}
456
+ defaultField={defaultField}
457
+ errorByRuleId={errorByRuleId}
458
+ t={t}
459
+ />
460
+ </div>
461
+ </div>
462
+ )}
463
+ </div>
464
+ )
465
+ }
466
+
467
+ function RuleRow({
468
+ rule, fields, dispatch, errorByRuleId, dragHandleProps, t,
469
+ }: {
470
+ rule: FilterRule
471
+ fields: FilterFieldDef[]
472
+ dispatch: (a: TreeAction) => void
473
+ errorByRuleId: Map<string, string>
474
+ dragHandleProps: DragHandleProps
475
+ t: Translator
476
+ }) {
477
+ const fieldType = getFieldType(fields, rule.field)
478
+ const operators = OPERATORS_BY_FIELD_TYPE[fieldType] ?? OPERATORS_BY_FIELD_TYPE.text
479
+ const valueless = isValuelessOperator(rule.operator)
480
+ const errorMsg = errorByRuleId.get(rule.id)
481
+ const fieldDef = fields.find((f) => f.key === rule.field)
482
+ const [pickerOpen, setPickerOpen] = React.useState(false)
483
+ const fieldBtnRef = React.useRef<HTMLButtonElement>(null)
484
+
485
+ return (
486
+ <div className="flex flex-col gap-1" data-testid="filter-rule" data-filter-rule-id={rule.id}>
487
+ <div className="flex flex-wrap items-start gap-2">
488
+ {/* Raw <button> required for dnd-kit activator: see SortableRow drag-handle comment. */}
489
+ <button
490
+ type="button"
491
+ {...dragHandleProps}
492
+ data-testid="filter-drag-handle"
493
+ className="mt-1 text-muted-foreground/60 hover:text-muted-foreground cursor-grab outline-none focus-visible:shadow-focus rounded-sm"
494
+ aria-label={t('ui.advancedFilter.dragHandle', 'Drag to reorder')}
495
+ >
496
+ <GripVertical className="size-4" />
497
+ </button>
498
+ <Button
499
+ ref={fieldBtnRef}
500
+ type="button"
501
+ variant="outline"
502
+ size="sm"
503
+ onClick={() => setPickerOpen(true)}
504
+ className="min-w-[140px] justify-start"
505
+ >
506
+ {fieldDef?.label ?? t('ui.advancedFilter.selectField', 'Select field')}
507
+ </Button>
508
+ <FilterFieldPicker
509
+ fields={fields}
510
+ open={pickerOpen}
511
+ onOpenChange={setPickerOpen}
512
+ onSelect={(f) => {
513
+ const nextType = getFieldType(fields, f.key)
514
+ dispatch({
515
+ type: 'updateRule',
516
+ ruleId: rule.id,
517
+ updates: { field: f.key, operator: getDefaultOperator(nextType), value: '' },
518
+ })
519
+ }}
520
+ triggerRef={fieldBtnRef as React.RefObject<HTMLElement | null>}
521
+ />
522
+ <Select
523
+ value={rule.operator}
524
+ onValueChange={(next) => {
525
+ // Preserve the value when the operator shape doesn't change; reset
526
+ // only when shape changes (e.g. `is` → `between` clears scalar to pair).
527
+ const nextOp = next as FilterOperator
528
+ const sameShape = getOperatorShape(rule.operator) === getOperatorShape(nextOp)
529
+ const updates: Partial<Pick<FilterRule, 'operator' | 'value'>> = sameShape
530
+ ? { operator: nextOp }
531
+ : { operator: nextOp, value: getOperatorShape(nextOp) === 'multi' || getOperatorShape(nextOp) === 'range' ? [] : '' }
532
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates })
533
+ }}
534
+ >
535
+ {/* `w-auto` overrides SelectTrigger's default `w-full` so it sits
536
+ inline with the field/value inputs instead of breaking onto a new row. */}
537
+ <SelectTrigger
538
+ size="sm"
539
+ className="w-auto min-w-[140px]"
540
+ aria-label={t('ui.advancedFilter.selectOperator', 'Select operator')}
541
+ >
542
+ <SelectValue />
543
+ </SelectTrigger>
544
+ <SelectContent data-advanced-filter-portal="">
545
+ {operators.map((op) => (
546
+ <SelectItem key={op} value={op}>
547
+ {t(`ui.advancedFilter.operator.${op}`, OPERATOR_LABELS[op])}
548
+ </SelectItem>
549
+ ))}
550
+ </SelectContent>
551
+ </Select>
552
+ {!valueless ? (
553
+ <ValueInput
554
+ rule={rule}
555
+ fields={fields}
556
+ fieldType={fieldType}
557
+ dispatch={dispatch}
558
+ hasError={!!errorMsg}
559
+ t={t}
560
+ />
561
+ ) : null}
562
+ <IconButton
563
+ variant="ghost"
564
+ size="sm"
565
+ type="button"
566
+ onClick={() => dispatch({ type: 'removeNode', nodeId: rule.id })}
567
+ aria-label={t('ui.advancedFilter.removeCondition', 'Remove condition')}
568
+ title={t('ui.advancedFilter.removeCondition', 'Remove condition')}
569
+ >
570
+ <Trash2 className="size-4 text-muted-foreground" />
571
+ </IconButton>
572
+ </div>
573
+ {errorMsg ? (
574
+ <div className="ml-6 pl-12 text-xs text-status-error-text">{errorMsg}</div>
575
+ ) : null}
180
576
  </div>
181
577
  )
182
578
  }
183
579
 
184
580
  function ValueInput({
185
- condition,
186
- fields,
187
- fieldType,
188
- onUpdate,
189
- t,
581
+ rule, fields, fieldType, dispatch, hasError, t,
190
582
  }: {
191
- condition: FilterCondition
583
+ rule: FilterRule
192
584
  fields: FilterFieldDef[]
193
585
  fieldType: FilterFieldType
194
- onUpdate: (id: string, updates: Partial<FilterCondition>) => void
195
- t: ReturnType<typeof useT>
586
+ dispatch: (a: TreeAction) => void
587
+ hasError: boolean
588
+ t: Translator
196
589
  }) {
197
- const fieldDef = fields.find((f) => f.key === condition.field)
198
- const value = condition.value
590
+ const fieldDef = fields.find((f) => f.key === rule.field)
591
+ const value = rule.value
592
+ const errorClass = hasError
593
+ ? 'border-status-error-border ring-1 ring-status-error-border focus-visible:border-status-error-border focus-within:border-status-error-border'
594
+ : ''
199
595
 
200
596
  if (fieldType === 'select' && fieldDef?.options) {
597
+ // Multi-value operators (`is_any_of`, `is_none_of`, `has_any_of`, etc.)
598
+ // need an array value with multiple selectable options. Radix `Select` is
599
+ // single-select only, so the previous code silently overwrote each
600
+ // selection (and validation kept failing because the value never became an
601
+ // array). Render a checkbox-list popover when the operator is multi-shape.
602
+ if (getOperatorShape(rule.operator) === 'multi') {
603
+ return (
604
+ <MultiValuePicker
605
+ rule={rule}
606
+ options={fieldDef.options}
607
+ dispatch={dispatch}
608
+ errorClass={errorClass}
609
+ t={t}
610
+ />
611
+ )
612
+ }
201
613
  return (
202
614
  <Select
203
- value={typeof value === 'string' && value ? value : undefined}
204
- onValueChange={(next) => onUpdate(condition.id, { value: next ?? '' })}
615
+ value={typeof value === 'string' && value.length ? value : undefined}
616
+ onValueChange={(next) =>
617
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value: next ?? '' } })
618
+ }
205
619
  >
206
620
  <SelectTrigger
207
- className="min-w-[140px]"
621
+ size="sm"
622
+ className={`w-auto min-w-[160px] ${errorClass}`}
208
623
  aria-label={t('ui.advancedFilter.selectValue', 'Select value')}
209
624
  >
210
625
  <SelectValue placeholder={t('ui.advancedFilter.selectValuePlaceholder', 'Select...')} />
211
626
  </SelectTrigger>
212
- <SelectContent>
627
+ <SelectContent data-advanced-filter-portal="">
213
628
  {fieldDef.options.map((opt) => (
214
- <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
629
+ <SelectItem key={opt.value} value={opt.value}>
630
+ {opt.tone ? (
631
+ <span className="inline-flex items-center gap-2">
632
+ <span className={toneDotClass(opt.tone)} aria-hidden="true" />
633
+ {opt.label}
634
+ </span>
635
+ ) : (
636
+ opt.label
637
+ )}
638
+ </SelectItem>
215
639
  ))}
216
640
  </SelectContent>
217
641
  </Select>
218
642
  )
219
643
  }
220
644
 
645
+ // `Input` ships `w-full` by default — use `w-auto` so inputs don't stretch
646
+ // inside the wrapping flex row. `between` needs a `[start, end]` pair, so it
647
+ // gets its own component below.
648
+ if (rule.operator === 'between' && (fieldType === 'number' || fieldType === 'date')) {
649
+ return (
650
+ <BetweenInput
651
+ rule={rule}
652
+ fieldType={fieldType}
653
+ dispatch={dispatch}
654
+ errorClass={errorClass}
655
+ t={t}
656
+ />
657
+ )
658
+ }
659
+
221
660
  if (fieldType === 'date') {
222
661
  return (
223
- <input
662
+ <Input
224
663
  type="date"
225
- className="rounded border bg-background px-2 py-1.5 text-sm"
664
+ size="sm"
665
+ className={`w-auto min-w-[160px] ${errorClass}`}
226
666
  value={typeof value === 'string' ? value : ''}
227
- onChange={(e) => onUpdate(condition.id, { value: e.target.value })}
667
+ onChange={(e) =>
668
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value: e.target.value } })
669
+ }
228
670
  aria-label={t('ui.advancedFilter.dateValue', 'Date value')}
229
671
  />
230
672
  )
@@ -235,9 +677,11 @@ function ValueInput({
235
677
  <Input
236
678
  type="number"
237
679
  size="sm"
238
- className="w-[120px]"
680
+ className={`w-[120px] ${errorClass}`}
239
681
  value={typeof value === 'number' ? value : typeof value === 'string' ? value : ''}
240
- onChange={(e) => onUpdate(condition.id, { value: e.target.value })}
682
+ onChange={(e) =>
683
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value: e.target.value } })
684
+ }
241
685
  placeholder={t('ui.advancedFilter.numberPlaceholder', 'Value')}
242
686
  aria-label={t('ui.advancedFilter.numberValue', 'Number value')}
243
687
  />
@@ -248,118 +692,277 @@ function ValueInput({
248
692
  <Input
249
693
  type="text"
250
694
  size="sm"
251
- className="min-w-[140px]"
695
+ className={`w-auto min-w-[160px] ${errorClass}`}
252
696
  value={typeof value === 'string' ? value : ''}
253
- onChange={(e) => onUpdate(condition.id, { value: e.target.value })}
697
+ onChange={(e) =>
698
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value: e.target.value } })
699
+ }
254
700
  placeholder={t('ui.advancedFilter.textPlaceholder', 'Value...')}
255
701
  aria-label={t('ui.advancedFilter.textValue', 'Text value')}
256
702
  />
257
703
  )
258
704
  }
259
705
 
260
- export function AdvancedFilterBuilder({
261
- fields,
262
- value,
263
- onChange,
264
- onApply,
265
- onClear,
266
- }: AdvancedFilterBuilderProps) {
267
- const t = useT()
268
- const normalizedValue = React.useMemo(() => normalizeAdvancedFilterState(value), [value])
269
- const emitChange = React.useCallback((next: AdvancedFilterState) => {
270
- onChange(normalizeAdvancedFilterState(next))
271
- }, [onChange])
272
-
273
- const updateCondition = React.useCallback((id: string, updates: Partial<FilterCondition>) => {
274
- emitChange({
275
- ...normalizedValue,
276
- conditions: normalizedValue.conditions.map((c) => (c.id === id ? { ...c, ...updates } : c)),
277
- })
278
- }, [emitChange, normalizedValue])
279
-
280
- const removeCondition = React.useCallback((id: string) => {
281
- emitChange({
282
- ...normalizedValue,
283
- conditions: normalizedValue.conditions.filter((c) => c.id !== id),
284
- })
285
- }, [emitChange, normalizedValue])
286
-
287
- const addCondition = React.useCallback(() => {
288
- const newCondition = createEmptyCondition()
289
- if (fields.length > 0) {
290
- newCondition.field = fields[0].key
291
- newCondition.operator = getDefaultOperator(fields[0].type)
292
- }
293
- emitChange({
294
- ...normalizedValue,
295
- conditions: [...normalizedValue.conditions, newCondition],
296
- })
297
- }, [emitChange, fields, normalizedValue])
298
-
299
- const toggleConditionJoin = React.useCallback((id: string) => {
300
- emitChange({
301
- ...normalizedValue,
302
- conditions: normalizedValue.conditions.map((condition) => (
303
- condition.id === id
304
- ? { ...condition, join: condition.join === 'or' ? 'and' : 'or' }
305
- : condition
306
- )),
307
- })
308
- }, [emitChange, normalizedValue])
706
+ /**
707
+ * Two inputs (start / end) for the `between` operator on date and number
708
+ * fields. Stores the value as `[start, end]`; either side can be empty while
709
+ * the user types, but validation requires both to be set before apply.
710
+ */
711
+ function BetweenInput({
712
+ rule, fieldType, dispatch, errorClass, t,
713
+ }: {
714
+ rule: FilterRule
715
+ fieldType: FilterFieldType
716
+ dispatch: (a: TreeAction) => void
717
+ errorClass: string
718
+ t: Translator
719
+ }) {
720
+ const pair = Array.isArray(rule.value) ? rule.value : []
721
+ const start = typeof pair[0] === 'string' || typeof pair[0] === 'number' ? String(pair[0] ?? '') : ''
722
+ const end = typeof pair[1] === 'string' || typeof pair[1] === 'number' ? String(pair[1] ?? '') : ''
723
+ const update = (which: 'start' | 'end', next: string) => {
724
+ const value = which === 'start' ? [next, end] : [start, next]
725
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value } })
726
+ }
727
+ const inputType = fieldType === 'date' ? 'date' : 'number'
728
+ const inputWidth = fieldType === 'date' ? 'w-auto min-w-[140px]' : 'w-[110px]'
729
+ return (
730
+ <div className="inline-flex items-center gap-1">
731
+ <Input
732
+ type={inputType}
733
+ size="sm"
734
+ className={`${inputWidth} ${errorClass}`}
735
+ value={start}
736
+ onChange={(e) => update('start', e.target.value)}
737
+ placeholder={fieldType === 'number' ? t('ui.advancedFilter.numberPlaceholder', 'Value') : undefined}
738
+ aria-label={t('ui.advancedFilter.betweenStart', 'Start')}
739
+ />
740
+ <span className="text-xs text-muted-foreground">{t('ui.advancedFilter.and', 'and')}</span>
741
+ <Input
742
+ type={inputType}
743
+ size="sm"
744
+ className={`${inputWidth} ${errorClass}`}
745
+ value={end}
746
+ onChange={(e) => update('end', e.target.value)}
747
+ placeholder={fieldType === 'number' ? t('ui.advancedFilter.numberPlaceholder', 'Value') : undefined}
748
+ aria-label={t('ui.advancedFilter.betweenEnd', 'End')}
749
+ />
750
+ </div>
751
+ )
752
+ }
309
753
 
754
+ /**
755
+ * Trigger + popover with a checkbox list. Used when a select-typed rule has a
756
+ * multi-shape operator (`is_any_of`, `has_all_of`, etc.). The trigger displays
757
+ * a "{first} +N" summary; the popover lets the user toggle each option.
758
+ */
759
+ function MultiValuePicker({
760
+ rule, options, dispatch, errorClass, t,
761
+ }: {
762
+ rule: FilterRule
763
+ options: NonNullable<FilterFieldDef['options']>
764
+ dispatch: (a: TreeAction) => void
765
+ errorClass: string
766
+ t: Translator
767
+ }) {
768
+ const selected: string[] = Array.isArray(rule.value)
769
+ ? rule.value.map((v) => String(v)).filter((v) => v.length > 0)
770
+ : []
771
+ const selectedLabel = (() => {
772
+ if (selected.length === 0) return t('ui.advancedFilter.selectValuePlaceholder', 'Select...')
773
+ const firstOpt = options.find((o) => o.value === selected[0])
774
+ const head = firstOpt?.label ?? selected[0]
775
+ const more = selected.length - 1
776
+ return more > 0 ? `${head} +${more}` : head
777
+ })()
778
+ const toggle = (val: string) => {
779
+ const next = selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val]
780
+ dispatch({ type: 'updateRule', ruleId: rule.id, updates: { value: next } })
781
+ }
310
782
  return (
311
- <div className="inline-flex flex-col gap-3 p-3">
312
- {normalizedValue.conditions.length === 0 ? (
313
- <p className="text-sm text-muted-foreground">
314
- {t('ui.advancedFilter.noConditions', 'No filter conditions. Click "Add filter" to start.')}
315
- </p>
316
- ) : (
317
- <div className="space-y-2">
318
- {normalizedValue.conditions.map((condition, index) => (
319
- <ConditionRow
320
- key={condition.id}
321
- condition={condition}
322
- index={index}
323
- fields={fields}
324
- join={condition.join ?? normalizedValue.logic}
325
- onUpdate={updateCondition}
326
- onRemove={removeCondition}
327
- onToggleJoin={toggleConditionJoin}
328
- t={t}
329
- />
330
- ))}
783
+ <Popover>
784
+ <PopoverTrigger asChild>
785
+ {/* Visually mirrors `SelectTrigger size="sm"` (h-8, rounded-md, border)
786
+ so the operator + value pair stays visually consistent. */}
787
+ <button
788
+ type="button"
789
+ className={`inline-flex h-8 items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-xs shadow-xs transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:shadow-focus focus-visible:border-foreground w-auto min-w-[160px] ${errorClass}`}
790
+ aria-label={t('ui.advancedFilter.selectValue', 'Select value')}
791
+ data-testid="multi-value-trigger"
792
+ >
793
+ <span className={selected.length === 0 ? 'text-muted-foreground' : ''}>{selectedLabel}</span>
794
+ <ChevronDown className="size-3 text-muted-foreground" />
795
+ </button>
796
+ </PopoverTrigger>
797
+ <PopoverContent className="w-64 p-1" align="start" data-advanced-filter-portal="">
798
+ <div className="max-h-64 overflow-y-auto" role="listbox" aria-multiselectable="true">
799
+ {options.map((opt) => {
800
+ const isOn = selected.includes(opt.value)
801
+ return (
802
+ // Raw <button>: needs `role="option"` + `aria-selected`; <Button> would override role.
803
+ <button
804
+ key={opt.value}
805
+ type="button"
806
+ role="option"
807
+ aria-selected={isOn}
808
+ onClick={() => toggle(opt.value)}
809
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
810
+ >
811
+ <Checkbox checked={isOn} aria-hidden="true" tabIndex={-1} className="pointer-events-none" />
812
+ {opt.tone ? (
813
+ <span className="inline-flex items-center gap-2">
814
+ <span className={toneDotClass(opt.tone)} aria-hidden="true" />
815
+ {opt.label}
816
+ </span>
817
+ ) : (
818
+ <span>{opt.label}</span>
819
+ )}
820
+ </button>
821
+ )
822
+ })}
331
823
  </div>
332
- )}
824
+ </PopoverContent>
825
+ </Popover>
826
+ )
827
+ }
828
+
829
+ function toneDotClass(tone: NonNullable<FilterFieldDef['options']>[number]['tone']): string {
830
+ // Solid filled dot using the saturated `*-icon` token (matches Tag's `dotColorMap`).
831
+ const base = 'inline-block size-2 rounded-full shrink-0'
832
+ switch (tone) {
833
+ case 'success': return `${base} bg-status-success-icon`
834
+ case 'error': return `${base} bg-status-error-icon`
835
+ case 'warning': return `${base} bg-status-warning-icon`
836
+ case 'info': return `${base} bg-status-info-icon`
837
+ case 'neutral': return `${base} bg-status-neutral-icon`
838
+ case 'pink': return `${base} bg-status-pink-icon`
839
+ case 'brand': return `${base} bg-brand-violet`
840
+ default: return `${base} bg-muted-foreground/40`
841
+ }
842
+ }
333
843
 
334
- <div className="flex flex-wrap items-center gap-3 pt-2">
844
+ // Single Kendo-style toolbar per group: combinator toggle, `+ Add condition`,
845
+ // `+ Add subgroup` / `+ Add group`, plus a trailing delete for non-root groups.
846
+ function GroupToolbar({
847
+ group, level, tree, dispatch, defaultField, fields, isRoot, t,
848
+ }: {
849
+ group: FilterGroup
850
+ level: number
851
+ tree: AdvancedFilterTree
852
+ dispatch: (a: TreeAction) => void
853
+ defaultField?: string
854
+ fields: FilterFieldDef[]
855
+ isRoot: boolean
856
+ t: Translator
857
+ }) {
858
+ const [pickerOpen, setPickerOpen] = React.useState(false)
859
+ const addBtnRef = React.useRef<HTMLButtonElement>(null)
860
+ const addRuleEnabled = canAddRule(tree, group.id)
861
+ const addGroupEnabled = canAddGroup(tree, group.id)
862
+
863
+ const widthCapHit = group.children.length >= TREE_LIMITS.maxChildrenPerGroup
864
+ const depthCapHit = level >= TREE_LIMITS.maxGroupLevel
865
+
866
+ const addRuleTitle = !addRuleEnabled
867
+ ? widthCapHit
868
+ ? t('ui.advancedFilter.limitWidthReached', 'Maximum {max} conditions per group', {
869
+ max: TREE_LIMITS.maxChildrenPerGroup,
870
+ })
871
+ : t('ui.advancedFilter.limitTotalReached', 'Maximum {max} conditions reached', {
872
+ max: TREE_LIMITS.maxTotalRules,
873
+ })
874
+ : undefined
875
+
876
+ const addGroupTitle = !addGroupEnabled
877
+ ? depthCapHit
878
+ ? t('ui.advancedFilter.limitDepthReached', 'Maximum nesting depth reached')
879
+ : widthCapHit
880
+ ? t('ui.advancedFilter.limitWidthReached', 'Maximum {max} conditions per group', {
881
+ max: TREE_LIMITS.maxChildrenPerGroup,
882
+ })
883
+ : t('ui.advancedFilter.limitTotalReached', 'Maximum {max} conditions reached', {
884
+ max: TREE_LIMITS.maxTotalRules,
885
+ })
886
+ : undefined
887
+
888
+ const addGroupLabel = level === 1
889
+ ? t('ui.advancedFilter.addGroup', '+ Add group')
890
+ : t('ui.advancedFilter.addSubgroup', '+ Add subgroup')
891
+
892
+ // `text-brand-violet` overrides Button's default disabled token, so we have to
893
+ // re-state muted disabled colors here — otherwise disabled CTAs still look clickable.
894
+ const ctaClass = 'text-brand-violet hover:text-brand-violet/80 hover:bg-transparent px-0 disabled:text-muted-foreground/50 disabled:hover:text-muted-foreground/50'
895
+
896
+ return (
897
+ <div className="flex flex-col gap-1">
898
+ <div className="flex flex-wrap items-center gap-3">
899
+ <AndOrToggle
900
+ combinator={group.combinator}
901
+ onChange={(c) => dispatch({ type: 'updateGroupCombinator', groupId: group.id, combinator: c })}
902
+ t={t}
903
+ />
335
904
  <Button
905
+ ref={addBtnRef}
336
906
  type="button"
337
907
  variant="ghost"
338
908
  size="sm"
339
- className="h-auto px-0 py-0 text-base font-medium text-primary hover:bg-transparent hover:text-primary/90"
340
- onClick={addCondition}
909
+ disabled={!addRuleEnabled}
910
+ title={addRuleTitle}
911
+ onClick={() => setPickerOpen(true)}
912
+ className={ctaClass}
341
913
  >
342
914
  <Plus className="size-4" />
343
- {t('ui.advancedFilter.addFilter', 'Add filter')}
915
+ {t('ui.advancedFilter.addCondition', '+ Add condition')}
344
916
  </Button>
345
- {normalizedValue.conditions.length > 0 ? (
346
- <Button
347
- type="button"
917
+ <FilterFieldPicker
918
+ fields={fields}
919
+ open={pickerOpen}
920
+ onOpenChange={setPickerOpen}
921
+ onSelect={(f) =>
922
+ dispatch({
923
+ type: 'addRule',
924
+ groupId: group.id,
925
+ defaultField: f.key,
926
+ defaultOperator: getDefaultOperator(f.type),
927
+ })
928
+ }
929
+ triggerRef={addBtnRef as React.RefObject<HTMLElement | null>}
930
+ />
931
+ <Button
932
+ type="button"
933
+ variant="ghost"
934
+ size="sm"
935
+ disabled={!addGroupEnabled}
936
+ title={addGroupTitle}
937
+ onClick={() =>
938
+ dispatch({ type: 'addGroup', groupId: group.id, defaultField })
939
+ }
940
+ className={ctaClass}
941
+ >
942
+ <Plus className="size-4" />
943
+ {addGroupLabel}
944
+ </Button>
945
+ {!isRoot ? (
946
+ <IconButton
348
947
  variant="ghost"
349
948
  size="sm"
350
- className="h-auto px-0 py-0 text-base text-muted-foreground hover:bg-transparent hover:text-foreground"
351
- onClick={onClear}
949
+ type="button"
950
+ onClick={() => dispatch({ type: 'removeNode', nodeId: group.id })}
951
+ aria-label={t('ui.advancedFilter.deleteGroup', 'Delete group')}
952
+ title={t('ui.advancedFilter.deleteGroup', 'Delete group')}
953
+ className="ml-auto"
352
954
  >
353
- <X className="size-4" />
354
- {t('ui.advancedFilter.clear', 'Clear')}
355
- </Button>
356
- ) : null}
357
- {normalizedValue.conditions.length > 0 ? (
358
- <Button type="button" className="ml-auto min-w-[8rem]" onClick={onApply}>
359
- {t('ui.advancedFilter.apply', 'Apply')}
360
- </Button>
955
+ <X className="size-4 text-muted-foreground" />
956
+ </IconButton>
361
957
  ) : null}
362
958
  </div>
959
+ {!addGroupEnabled && depthCapHit ? (
960
+ <p className="text-xs text-muted-foreground" data-testid="filter-depth-limit-hint">
961
+ {t('ui.advancedFilter.limitDepthHint', 'You\'ve reached the maximum nesting depth ({max} levels). Switch this group\'s combinator with the toggle above instead of nesting deeper.', {
962
+ max: TREE_LIMITS.maxGroupLevel,
963
+ })}
964
+ </p>
965
+ ) : null}
363
966
  </div>
364
967
  )
365
968
  }