@open-mercato/ui 0.6.1-develop.3260.1.dc719cac84 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/tag.tsx +3 -1
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import {
|
|
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:
|
|
34
|
-
onChange: (state:
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
238
|
+
node: FilterRule | FilterGroup
|
|
239
|
+
parent: FilterGroup
|
|
83
240
|
fields: FilterFieldDef[]
|
|
84
|
-
|
|
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
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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=
|
|
106
|
-
<
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
389
|
+
tree={tree}
|
|
390
|
+
dispatch={dispatch}
|
|
391
|
+
defaultField={defaultField}
|
|
392
|
+
errorByRuleId={errorByRuleId}
|
|
167
393
|
t={t}
|
|
168
394
|
/>
|
|
169
|
-
)
|
|
395
|
+
))}
|
|
396
|
+
</SortableContext>
|
|
397
|
+
)
|
|
398
|
+
}
|
|
170
399
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
186
|
-
fields,
|
|
187
|
-
fieldType,
|
|
188
|
-
onUpdate,
|
|
189
|
-
t,
|
|
581
|
+
rule, fields, fieldType, dispatch, hasError, t,
|
|
190
582
|
}: {
|
|
191
|
-
|
|
583
|
+
rule: FilterRule
|
|
192
584
|
fields: FilterFieldDef[]
|
|
193
585
|
fieldType: FilterFieldType
|
|
194
|
-
|
|
195
|
-
|
|
586
|
+
dispatch: (a: TreeAction) => void
|
|
587
|
+
hasError: boolean
|
|
588
|
+
t: Translator
|
|
196
589
|
}) {
|
|
197
|
-
const fieldDef = fields.find((f) => f.key ===
|
|
198
|
-
const 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) =>
|
|
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
|
-
|
|
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}>
|
|
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
|
-
<
|
|
662
|
+
<Input
|
|
224
663
|
type="date"
|
|
225
|
-
|
|
664
|
+
size="sm"
|
|
665
|
+
className={`w-auto min-w-[160px] ${errorClass}`}
|
|
226
666
|
value={typeof value === 'string' ? value : ''}
|
|
227
|
-
onChange={(e) =>
|
|
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=
|
|
680
|
+
className={`w-[120px] ${errorClass}`}
|
|
239
681
|
value={typeof value === 'number' ? value : typeof value === 'string' ? value : ''}
|
|
240
|
-
onChange={(e) =>
|
|
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=
|
|
695
|
+
className={`w-auto min-w-[160px] ${errorClass}`}
|
|
252
696
|
value={typeof value === 'string' ? value : ''}
|
|
253
|
-
onChange={(e) =>
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
{
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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.
|
|
915
|
+
{t('ui.advancedFilter.addCondition', '+ Add condition')}
|
|
344
916
|
</Button>
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
onClick={
|
|
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
|
-
|
|
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
|
}
|