@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.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/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.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/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.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/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- 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/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { Popover, PopoverAnchor, PopoverContent } from '../../primitives/popover'
|
|
4
|
+
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
|
5
|
+
import { Alert, AlertDescription } from '../../primitives/alert'
|
|
6
|
+
import { Button } from '../../primitives/button'
|
|
7
|
+
import { Input } from '../../primitives/input'
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from '../../primitives/dialog'
|
|
16
|
+
import { Filter, Save, Trash2 } from 'lucide-react'
|
|
17
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
18
|
+
import {
|
|
19
|
+
deserializeTreeFromPersist,
|
|
20
|
+
isPersistedFilterTree,
|
|
21
|
+
serializeTreeForPersist,
|
|
22
|
+
type AdvancedFilterTree,
|
|
23
|
+
type PersistedFilterTree,
|
|
24
|
+
} from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
25
|
+
import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
26
|
+
import { getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
27
|
+
import type { ValidationError } from './filterValidation'
|
|
28
|
+
import { AdvancedFilterBuilder } from './AdvancedFilterBuilder'
|
|
29
|
+
import { FilterEmptyState } from './FilterEmptyState'
|
|
30
|
+
import { QuickFilters, type FilterPreset } from './QuickFilters'
|
|
31
|
+
import { FilterFieldPicker } from './FilterFieldPicker'
|
|
32
|
+
import { treeReducer } from './treeReducer'
|
|
33
|
+
import { flash } from '../FlashMessages'
|
|
34
|
+
|
|
35
|
+
export type AdvancedFilterPanelProps = {
|
|
36
|
+
fields: FilterFieldDef[]
|
|
37
|
+
value: AdvancedFilterTree
|
|
38
|
+
onChange: (t: AdvancedFilterTree) => void
|
|
39
|
+
onApply: () => void
|
|
40
|
+
onClear: () => void
|
|
41
|
+
onFlush?: () => void
|
|
42
|
+
pendingErrors: ValidationError[]
|
|
43
|
+
userId: string
|
|
44
|
+
presets: FilterPreset[]
|
|
45
|
+
open: boolean
|
|
46
|
+
onOpenChange: (open: boolean) => void
|
|
47
|
+
triggerRef?: React.RefObject<HTMLElement | null>
|
|
48
|
+
aiSlot?: React.ReactNode
|
|
49
|
+
savedFilterStorageKey?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type SavedAdvancedFilter = {
|
|
53
|
+
id: string
|
|
54
|
+
name: string
|
|
55
|
+
tree: PersistedFilterTree
|
|
56
|
+
createdAt: string
|
|
57
|
+
updatedAt: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Versioned envelope for saved-filter records in localStorage. The `v` discriminator
|
|
62
|
+
* lets future schema changes (rename fields, retention policy, sharing flags) migrate
|
|
63
|
+
* existing records instead of silently dropping them. Bump `v` when adding a breaking
|
|
64
|
+
* change and add a read-old migration branch in `readSavedFilters`.
|
|
65
|
+
*/
|
|
66
|
+
type SavedFiltersEnvelopeV1 = {
|
|
67
|
+
v: 1
|
|
68
|
+
filters: SavedAdvancedFilter[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SAVED_FILTER_STORAGE_PREFIX = 'open-mercato:advanced-filters:'
|
|
72
|
+
const MAX_SAVED_FILTERS = 20
|
|
73
|
+
const CURRENT_SAVED_FILTERS_VERSION = 1 as const
|
|
74
|
+
|
|
75
|
+
function makeSavedFilterStorageKey(key?: string): string | null {
|
|
76
|
+
const trimmed = key?.trim()
|
|
77
|
+
return trimmed ? `${SAVED_FILTER_STORAGE_PREFIX}${trimmed}` : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSavedAdvancedFilter(value: unknown): value is SavedAdvancedFilter {
|
|
81
|
+
if (!value || typeof value !== 'object') return false
|
|
82
|
+
const record = value as Record<string, unknown>
|
|
83
|
+
return (
|
|
84
|
+
typeof record.id === 'string'
|
|
85
|
+
&& typeof record.name === 'string'
|
|
86
|
+
&& typeof record.createdAt === 'string'
|
|
87
|
+
&& typeof record.updatedAt === 'string'
|
|
88
|
+
&& isPersistedFilterTree(record.tree)
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readSavedFilters(storageKey: string): SavedAdvancedFilter[] {
|
|
93
|
+
if (typeof window === 'undefined') return []
|
|
94
|
+
try {
|
|
95
|
+
const raw = window.localStorage.getItem(storageKey)
|
|
96
|
+
if (!raw) return []
|
|
97
|
+
const parsed = JSON.parse(raw)
|
|
98
|
+
// v1 envelope (current shape).
|
|
99
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
100
|
+
const envelope = parsed as { v?: unknown; filters?: unknown }
|
|
101
|
+
if (envelope.v === 1 && Array.isArray(envelope.filters)) {
|
|
102
|
+
return envelope.filters.filter(isSavedAdvancedFilter)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Pre-envelope shape (bare array) — migrate forward on next write.
|
|
106
|
+
if (Array.isArray(parsed)) {
|
|
107
|
+
return parsed.filter(isSavedAdvancedFilter)
|
|
108
|
+
}
|
|
109
|
+
return []
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeSavedFilters(storageKey: string, filters: SavedAdvancedFilter[]) {
|
|
116
|
+
if (typeof window === 'undefined') return
|
|
117
|
+
const envelope: SavedFiltersEnvelopeV1 = { v: CURRENT_SAVED_FILTERS_VERSION, filters }
|
|
118
|
+
window.localStorage.setItem(storageKey, JSON.stringify(envelope))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function SaveFilterDialog({
|
|
122
|
+
open,
|
|
123
|
+
onOpenChange,
|
|
124
|
+
onSave,
|
|
125
|
+
t,
|
|
126
|
+
}: {
|
|
127
|
+
open: boolean
|
|
128
|
+
onOpenChange: (open: boolean) => void
|
|
129
|
+
onSave: (name: string) => Promise<void>
|
|
130
|
+
t: (key: string, fallback: string) => string
|
|
131
|
+
}) {
|
|
132
|
+
const [name, setName] = React.useState('')
|
|
133
|
+
const [saving, setSaving] = React.useState(false)
|
|
134
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
135
|
+
const trimmed = name.trim()
|
|
136
|
+
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
if (!open) {
|
|
139
|
+
setName('')
|
|
140
|
+
setSaving(false)
|
|
141
|
+
setError(null)
|
|
142
|
+
}
|
|
143
|
+
}, [open])
|
|
144
|
+
|
|
145
|
+
const submit = React.useCallback(async () => {
|
|
146
|
+
if (!trimmed || saving) return
|
|
147
|
+
setSaving(true)
|
|
148
|
+
setError(null)
|
|
149
|
+
try {
|
|
150
|
+
await onSave(trimmed)
|
|
151
|
+
onOpenChange(false)
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
setError(err instanceof Error ? err.message : t('ui.advancedFilter.saveFilter.error', 'Failed to save filter'))
|
|
154
|
+
} finally {
|
|
155
|
+
setSaving(false)
|
|
156
|
+
}
|
|
157
|
+
}, [onSave, onOpenChange, saving, t, trimmed])
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
161
|
+
{/* `elevated` lifts this dialog above the popover (z-popover=45) — at default
|
|
162
|
+
z-modal=40 the dialog is occluded by the popover that opens it. */}
|
|
163
|
+
<DialogContent elevated className="sm:max-w-md" data-advanced-filter-portal>
|
|
164
|
+
<form
|
|
165
|
+
className="contents"
|
|
166
|
+
onSubmit={(event) => {
|
|
167
|
+
event.preventDefault()
|
|
168
|
+
void submit()
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<DialogHeader>
|
|
172
|
+
<DialogTitle>{t('ui.advancedFilter.saveFilter.title', 'Save filter')}</DialogTitle>
|
|
173
|
+
<DialogDescription>
|
|
174
|
+
{t('ui.advancedFilter.saveFilter.description', 'Name this filter so you can reuse the current conditions later.')}
|
|
175
|
+
</DialogDescription>
|
|
176
|
+
</DialogHeader>
|
|
177
|
+
<div className="space-y-2">
|
|
178
|
+
<Input
|
|
179
|
+
value={name}
|
|
180
|
+
onChange={(event) => setName(event.target.value)}
|
|
181
|
+
placeholder={t('ui.advancedFilter.saveFilter.placeholder', 'Filter name')}
|
|
182
|
+
aria-label={t('ui.advancedFilter.saveFilter.nameAriaLabel', 'Filter name')}
|
|
183
|
+
autoFocus
|
|
184
|
+
/>
|
|
185
|
+
{error ? <p className="text-sm text-status-error-text">{error}</p> : null}
|
|
186
|
+
</div>
|
|
187
|
+
<DialogFooter>
|
|
188
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
189
|
+
{t('common.cancel', 'Cancel')}
|
|
190
|
+
</Button>
|
|
191
|
+
<Button type="submit" disabled={!trimmed || saving}>
|
|
192
|
+
{saving ? t('ui.advancedFilter.saveFilter.saving', 'Saving...') : t('ui.advancedFilter.saveFilter.submit', 'Save filter')}
|
|
193
|
+
</Button>
|
|
194
|
+
</DialogFooter>
|
|
195
|
+
</form>
|
|
196
|
+
</DialogContent>
|
|
197
|
+
</Dialog>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function SavedFiltersSection({
|
|
202
|
+
filters,
|
|
203
|
+
canSaveCurrent,
|
|
204
|
+
onSaveClick,
|
|
205
|
+
onApply,
|
|
206
|
+
onDelete,
|
|
207
|
+
t,
|
|
208
|
+
}: {
|
|
209
|
+
filters: SavedAdvancedFilter[]
|
|
210
|
+
canSaveCurrent: boolean
|
|
211
|
+
onSaveClick: () => void
|
|
212
|
+
onApply: (filter: SavedAdvancedFilter) => void
|
|
213
|
+
onDelete: (filterId: string) => void
|
|
214
|
+
t: (key: string, fallback: string) => string
|
|
215
|
+
}) {
|
|
216
|
+
if (!canSaveCurrent && filters.length === 0) return null
|
|
217
|
+
return (
|
|
218
|
+
<div className="flex flex-col gap-2" data-testid="advanced-filter-saved-filters">
|
|
219
|
+
<div className="flex items-center justify-between gap-3">
|
|
220
|
+
<div className="text-overline font-medium uppercase tracking-widest text-muted-foreground">
|
|
221
|
+
{t('ui.advancedFilter.savedFilters.heading', 'Saved filters')}
|
|
222
|
+
</div>
|
|
223
|
+
{canSaveCurrent ? (
|
|
224
|
+
<Button
|
|
225
|
+
data-testid="advanced-filter-save-trigger"
|
|
226
|
+
type="button"
|
|
227
|
+
variant="outline"
|
|
228
|
+
size="sm"
|
|
229
|
+
className="h-8 rounded-full gap-1.5"
|
|
230
|
+
onClick={onSaveClick}
|
|
231
|
+
>
|
|
232
|
+
<Save className="size-3.5 text-muted-foreground" />
|
|
233
|
+
{t('ui.advancedFilter.saveFilter.button', 'Save filter')}
|
|
234
|
+
</Button>
|
|
235
|
+
) : null}
|
|
236
|
+
</div>
|
|
237
|
+
{filters.length ? (
|
|
238
|
+
<div className="flex flex-wrap gap-2">
|
|
239
|
+
{filters.map((filter) => (
|
|
240
|
+
<div key={filter.id} className="inline-flex max-w-full items-center overflow-hidden rounded-full border border-border bg-background">
|
|
241
|
+
<Button
|
|
242
|
+
type="button"
|
|
243
|
+
variant="ghost"
|
|
244
|
+
size="sm"
|
|
245
|
+
className="h-8 min-w-0 rounded-r-none px-3 gap-1.5"
|
|
246
|
+
title={filter.name}
|
|
247
|
+
onClick={() => onApply(filter)}
|
|
248
|
+
>
|
|
249
|
+
<Filter className="size-3.5 shrink-0 text-muted-foreground" />
|
|
250
|
+
<span className="truncate">{filter.name}</span>
|
|
251
|
+
</Button>
|
|
252
|
+
<Button
|
|
253
|
+
type="button"
|
|
254
|
+
variant="ghost"
|
|
255
|
+
size="sm"
|
|
256
|
+
className="h-8 w-8 rounded-l-none px-0 text-muted-foreground hover:text-status-error-text"
|
|
257
|
+
aria-label={`${t('ui.advancedFilter.savedFilters.delete', 'Delete saved filter')}: ${filter.name}`}
|
|
258
|
+
onClick={() => onDelete(filter.id)}
|
|
259
|
+
>
|
|
260
|
+
<Trash2 className="size-3.5" />
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
) : (
|
|
266
|
+
<p className="text-xs text-muted-foreground">
|
|
267
|
+
{t('ui.advancedFilter.savedFilters.empty', 'No saved filters yet')}
|
|
268
|
+
</p>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function AdvancedFilterPanel(props: AdvancedFilterPanelProps) {
|
|
275
|
+
const t = useT()
|
|
276
|
+
const empty = props.value.root.children.length === 0
|
|
277
|
+
const [pickerOpen, setPickerOpen] = React.useState(false)
|
|
278
|
+
const [saveDialogOpen, setSaveDialogOpen] = React.useState(false)
|
|
279
|
+
const [savedFilters, setSavedFilters] = React.useState<SavedAdvancedFilter[]>([])
|
|
280
|
+
const addConditionRef = React.useRef<HTMLButtonElement>(null)
|
|
281
|
+
const savedFilterStorageKey = React.useMemo(
|
|
282
|
+
() => makeSavedFilterStorageKey(props.savedFilterStorageKey),
|
|
283
|
+
[props.savedFilterStorageKey],
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
// Flush on close — the hook's useEffect on close also flushes; this is belt-and-suspenders.
|
|
287
|
+
const wasOpenRef = React.useRef(props.open)
|
|
288
|
+
React.useEffect(() => {
|
|
289
|
+
if (wasOpenRef.current && !props.open && props.onFlush) props.onFlush()
|
|
290
|
+
wasOpenRef.current = props.open
|
|
291
|
+
}, [props.open, props.onFlush])
|
|
292
|
+
|
|
293
|
+
const handlePresetApply = (tree: AdvancedFilterTree) => {
|
|
294
|
+
props.onChange(tree)
|
|
295
|
+
props.onOpenChange(false)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
React.useEffect(() => {
|
|
299
|
+
if (!savedFilterStorageKey) {
|
|
300
|
+
setSavedFilters([])
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
if (props.open) {
|
|
304
|
+
setSavedFilters(readSavedFilters(savedFilterStorageKey))
|
|
305
|
+
}
|
|
306
|
+
}, [props.open, savedFilterStorageKey])
|
|
307
|
+
|
|
308
|
+
const persistSavedFilters = React.useCallback((next: SavedAdvancedFilter[]) => {
|
|
309
|
+
if (!savedFilterStorageKey) return
|
|
310
|
+
setSavedFilters(next)
|
|
311
|
+
writeSavedFilters(savedFilterStorageKey, next)
|
|
312
|
+
}, [savedFilterStorageKey])
|
|
313
|
+
|
|
314
|
+
const handleSaveFilter = React.useCallback(async (name: string) => {
|
|
315
|
+
if (!savedFilterStorageKey) return
|
|
316
|
+
const now = new Date().toISOString()
|
|
317
|
+
const normalizedName = name.trim()
|
|
318
|
+
const existing = savedFilters.find((item) => item.name.trim().toLowerCase() === normalizedName.toLowerCase())
|
|
319
|
+
const record: SavedAdvancedFilter = {
|
|
320
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
321
|
+
name: normalizedName,
|
|
322
|
+
tree: serializeTreeForPersist(props.value),
|
|
323
|
+
createdAt: existing?.createdAt ?? now,
|
|
324
|
+
updatedAt: now,
|
|
325
|
+
}
|
|
326
|
+
const next = [
|
|
327
|
+
record,
|
|
328
|
+
...savedFilters.filter((item) => item.id !== record.id),
|
|
329
|
+
].slice(0, MAX_SAVED_FILTERS)
|
|
330
|
+
persistSavedFilters(next)
|
|
331
|
+
flash(t('ui.advancedFilter.saveFilter.success', 'Filter saved'), 'success')
|
|
332
|
+
}, [persistSavedFilters, props.value, savedFilterStorageKey, savedFilters, t])
|
|
333
|
+
|
|
334
|
+
const handleApplySavedFilter = React.useCallback((filter: SavedAdvancedFilter) => {
|
|
335
|
+
const tree = deserializeTreeFromPersist(filter.tree)
|
|
336
|
+
if (!tree) return
|
|
337
|
+
props.onChange(tree)
|
|
338
|
+
props.onOpenChange(false)
|
|
339
|
+
}, [props.onChange, props.onOpenChange])
|
|
340
|
+
|
|
341
|
+
const handleDeleteSavedFilter = React.useCallback((filterId: string) => {
|
|
342
|
+
persistSavedFilters(savedFilters.filter((item) => item.id !== filterId))
|
|
343
|
+
}, [persistSavedFilters, savedFilters])
|
|
344
|
+
|
|
345
|
+
const shouldShowSavedFilters = Boolean(savedFilterStorageKey && (!empty || savedFilters.length > 0))
|
|
346
|
+
const savedFiltersSection = shouldShowSavedFilters ? (
|
|
347
|
+
<SavedFiltersSection
|
|
348
|
+
filters={savedFilters}
|
|
349
|
+
canSaveCurrent={!empty}
|
|
350
|
+
onSaveClick={() => setSaveDialogOpen(true)}
|
|
351
|
+
onApply={handleApplySavedFilter}
|
|
352
|
+
onDelete={handleDeleteSavedFilter}
|
|
353
|
+
t={t}
|
|
354
|
+
/>
|
|
355
|
+
) : null
|
|
356
|
+
|
|
357
|
+
const emptyStateShortcuts = props.presets.length || savedFiltersSection ? (
|
|
358
|
+
<div className="flex flex-col gap-4">
|
|
359
|
+
{props.presets.length ? <QuickFilters presets={props.presets} userId={props.userId} onApply={handlePresetApply} /> : null}
|
|
360
|
+
{savedFiltersSection}
|
|
361
|
+
</div>
|
|
362
|
+
) : null
|
|
363
|
+
|
|
364
|
+
const builderErrors = React.useMemo(
|
|
365
|
+
() => props.pendingErrors.map(e => ({ ruleId: e.ruleId, messageKey: e.messageKey, message: e.message })),
|
|
366
|
+
[props.pendingErrors],
|
|
367
|
+
)
|
|
368
|
+
const ignoreAdvancedFilterPortalInteractions = React.useCallback((event: { target: EventTarget | null; preventDefault: () => void }) => {
|
|
369
|
+
const target = event.target
|
|
370
|
+
if (!(target instanceof Element)) return
|
|
371
|
+
if (target.closest('[data-advanced-filter-portal]')) {
|
|
372
|
+
event.preventDefault()
|
|
373
|
+
}
|
|
374
|
+
}, [])
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<Popover open={props.open} onOpenChange={props.onOpenChange}>
|
|
378
|
+
{props.triggerRef ? (
|
|
379
|
+
<PopoverAnchor virtualRef={props.triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />
|
|
380
|
+
) : null}
|
|
381
|
+
<PopoverContent
|
|
382
|
+
className="w-[min(calc(100vw-2rem),780px)] max-h-[min(calc(100vh-6rem),720px)] overflow-y-auto p-0 gap-0"
|
|
383
|
+
align="end"
|
|
384
|
+
sideOffset={8}
|
|
385
|
+
data-testid="advanced-filter-panel"
|
|
386
|
+
onPointerDownOutside={ignoreAdvancedFilterPortalInteractions}
|
|
387
|
+
onFocusOutside={ignoreAdvancedFilterPortalInteractions}
|
|
388
|
+
onInteractOutside={ignoreAdvancedFilterPortalInteractions}
|
|
389
|
+
>
|
|
390
|
+
<VisuallyHidden>
|
|
391
|
+
<h2>{t('ui.advancedFilter.dialog.title', 'Advanced filter')}</h2>
|
|
392
|
+
</VisuallyHidden>
|
|
393
|
+
<div className="flex flex-col">
|
|
394
|
+
{empty ? (
|
|
395
|
+
<FilterEmptyState
|
|
396
|
+
onAddCondition={() => setPickerOpen(true)}
|
|
397
|
+
addConditionRef={addConditionRef}
|
|
398
|
+
aiSlot={props.aiSlot}
|
|
399
|
+
quickFilters={emptyStateShortcuts}
|
|
400
|
+
/>
|
|
401
|
+
) : (
|
|
402
|
+
<>
|
|
403
|
+
<AdvancedFilterBuilder
|
|
404
|
+
fields={props.fields}
|
|
405
|
+
value={props.value}
|
|
406
|
+
onChange={props.onChange}
|
|
407
|
+
onApply={props.onApply}
|
|
408
|
+
onClear={props.onClear}
|
|
409
|
+
pendingErrors={builderErrors}
|
|
410
|
+
/>
|
|
411
|
+
{savedFiltersSection ? (
|
|
412
|
+
<div className="border-t border-border p-3">
|
|
413
|
+
{savedFiltersSection}
|
|
414
|
+
</div>
|
|
415
|
+
) : null}
|
|
416
|
+
</>
|
|
417
|
+
)}
|
|
418
|
+
{props.pendingErrors.length > 0 ? (
|
|
419
|
+
<div className="p-3 border-t border-border" data-testid="filter-validation-banner">
|
|
420
|
+
<Alert variant="destructive">
|
|
421
|
+
<AlertDescription>
|
|
422
|
+
{props.pendingErrors.length === 1
|
|
423
|
+
? t('ui.advancedFilter.banner.incomplete', '{count} filter is incomplete — filters won\'t apply until all values are picked.', { count: 1 })
|
|
424
|
+
: t('ui.advancedFilter.banner.incompletePlural', '{count} filters are incomplete — filters won\'t apply until all values are picked.', { count: props.pendingErrors.length })}
|
|
425
|
+
</AlertDescription>
|
|
426
|
+
</Alert>
|
|
427
|
+
</div>
|
|
428
|
+
) : null}
|
|
429
|
+
</div>
|
|
430
|
+
</PopoverContent>
|
|
431
|
+
<FilterFieldPicker
|
|
432
|
+
fields={props.fields}
|
|
433
|
+
open={pickerOpen}
|
|
434
|
+
onOpenChange={setPickerOpen}
|
|
435
|
+
onSelect={(field) => {
|
|
436
|
+
// Pass defaultOperator so the rule starts with a valid operator for the field's
|
|
437
|
+
// type — otherwise emptyRule defaults to 'contains', which only matches text.
|
|
438
|
+
const next = treeReducer(props.value, {
|
|
439
|
+
type: 'addRule',
|
|
440
|
+
groupId: props.value.root.id,
|
|
441
|
+
defaultField: field.key,
|
|
442
|
+
defaultOperator: getDefaultOperator(field.type),
|
|
443
|
+
})
|
|
444
|
+
props.onChange(next)
|
|
445
|
+
}}
|
|
446
|
+
triggerRef={addConditionRef}
|
|
447
|
+
/>
|
|
448
|
+
<SaveFilterDialog
|
|
449
|
+
open={saveDialogOpen}
|
|
450
|
+
onOpenChange={setSaveDialogOpen}
|
|
451
|
+
onSave={handleSaveFilter}
|
|
452
|
+
t={t}
|
|
453
|
+
/>
|
|
454
|
+
</Popover>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { Filter, Plus } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
|
|
7
|
+
export type FilterEmptyStateProps = {
|
|
8
|
+
onAddCondition: () => void
|
|
9
|
+
addConditionRef: React.RefObject<HTMLButtonElement | null>
|
|
10
|
+
aiSlot?: React.ReactNode
|
|
11
|
+
quickFilters?: React.ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function FilterEmptyState({ onAddCondition, addConditionRef, aiSlot, quickFilters }: FilterEmptyStateProps) {
|
|
15
|
+
const t = useT()
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col items-stretch gap-4 p-2" data-testid="filter-empty-state">
|
|
18
|
+
<div className="flex flex-col items-center text-center py-6 gap-3">
|
|
19
|
+
<div className="flex size-14 items-center justify-center rounded-full bg-brand-violet/10">
|
|
20
|
+
<Filter className="size-6 text-brand-violet" />
|
|
21
|
+
</div>
|
|
22
|
+
<div className="flex flex-col gap-1">
|
|
23
|
+
<div className="text-base font-semibold">
|
|
24
|
+
{t('ui.advancedFilter.empty.title', 'No filters applied')}
|
|
25
|
+
</div>
|
|
26
|
+
<div className="text-sm text-muted-foreground max-w-md">
|
|
27
|
+
{t('ui.advancedFilter.empty.subtitle', 'Add a condition to narrow down the list, or describe what you want in natural language.')}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex items-center gap-2">
|
|
31
|
+
<Button
|
|
32
|
+
type="button"
|
|
33
|
+
ref={addConditionRef}
|
|
34
|
+
onClick={onAddCondition}
|
|
35
|
+
className="bg-brand-violet text-brand-violet-foreground hover:bg-brand-violet/90"
|
|
36
|
+
>
|
|
37
|
+
<Plus className="size-4" />
|
|
38
|
+
{t('ui.advancedFilter.empty.addCondition', 'Add condition')}
|
|
39
|
+
</Button>
|
|
40
|
+
{aiSlot}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
{quickFilters ? (
|
|
44
|
+
<>
|
|
45
|
+
<div className="border-t border-border" />
|
|
46
|
+
{quickFilters}
|
|
47
|
+
</>
|
|
48
|
+
) : null}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// packages/ui/src/backend/filters/FilterFieldPicker.tsx
|
|
2
|
+
'use client'
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'
|
|
5
|
+
import { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
8
|
+
|
|
9
|
+
const TYPE_ICON: Record<FilterFieldType, LucideIcon> = {
|
|
10
|
+
text: ALargeSmall,
|
|
11
|
+
select: Activity,
|
|
12
|
+
tags: Tag,
|
|
13
|
+
date: Calendar,
|
|
14
|
+
number: Hash,
|
|
15
|
+
boolean: ArrowRight,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const NAMED_ICONS: Record<string, LucideIcon> = {
|
|
19
|
+
'user-round': UserRound,
|
|
20
|
+
mail: Mail,
|
|
21
|
+
phone: Phone,
|
|
22
|
+
tag: Tag,
|
|
23
|
+
filter: Filter,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveIcon(field: FilterFieldDef): LucideIcon {
|
|
27
|
+
if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName]
|
|
28
|
+
return TYPE_ICON[field.type] ?? ALargeSmall
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FilterFieldPickerProps = {
|
|
32
|
+
fields: FilterFieldDef[]
|
|
33
|
+
open: boolean
|
|
34
|
+
onOpenChange: (open: boolean) => void
|
|
35
|
+
onSelect: (field: FilterFieldDef) => void
|
|
36
|
+
triggerRef: React.RefObject<HTMLElement | null>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }: FilterFieldPickerProps) {
|
|
40
|
+
const t = useT()
|
|
41
|
+
const [query, setQuery] = React.useState('')
|
|
42
|
+
const [activeIdx, setActiveIdx] = React.useState(0)
|
|
43
|
+
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); searchRef.current?.focus() } }, [open])
|
|
46
|
+
|
|
47
|
+
const filtered = React.useMemo(() => {
|
|
48
|
+
if (!query.trim()) return fields
|
|
49
|
+
const q = query.trim().toLowerCase()
|
|
50
|
+
return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q))
|
|
51
|
+
}, [fields, query])
|
|
52
|
+
|
|
53
|
+
const grouped = React.useMemo(() => {
|
|
54
|
+
const map = new Map<string, FilterFieldDef[]>()
|
|
55
|
+
const order: string[] = []
|
|
56
|
+
for (const f of filtered) {
|
|
57
|
+
const g = f.group ?? 'More'
|
|
58
|
+
if (!map.has(g)) { map.set(g, []); order.push(g) }
|
|
59
|
+
map.get(g)!.push(f)
|
|
60
|
+
}
|
|
61
|
+
return order.map(g => ({ group: g, items: map.get(g)! }))
|
|
62
|
+
}, [filtered])
|
|
63
|
+
|
|
64
|
+
const flatVisible = React.useMemo(() => grouped.flatMap(g => g.items), [grouped])
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
if (!open) return
|
|
68
|
+
function onKey(e: KeyboardEvent) {
|
|
69
|
+
if (e.key === 'Escape') { onOpenChange(false); return }
|
|
70
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flatVisible.length - 1)) }
|
|
71
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)) }
|
|
72
|
+
if (e.key === 'Enter') {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
const f = flatVisible[activeIdx]
|
|
75
|
+
if (f) { onSelect(f); onOpenChange(false) }
|
|
76
|
+
}
|
|
77
|
+
if (e.key === '/' && document.activeElement !== searchRef.current) {
|
|
78
|
+
e.preventDefault(); searchRef.current?.focus()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
window.addEventListener('keydown', onKey)
|
|
82
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
83
|
+
}, [open, flatVisible, activeIdx, onSelect, onOpenChange])
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover open={open} onOpenChange={onOpenChange}>
|
|
87
|
+
{/* Radix expects RefObject<Measurable>; our prop accepts null. Narrow cast covers the React 19 ↔ Radix variance. */}
|
|
88
|
+
<PopoverAnchor virtualRef={triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />
|
|
89
|
+
<PopoverContent className="w-80 p-0" align="start" data-advanced-filter-portal="">
|
|
90
|
+
<div className="flex flex-col">
|
|
91
|
+
<div className="relative p-2 border-b border-border">
|
|
92
|
+
<Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
93
|
+
<input
|
|
94
|
+
ref={searchRef}
|
|
95
|
+
type="text"
|
|
96
|
+
value={query}
|
|
97
|
+
onChange={(e) => { setQuery(e.target.value); setActiveIdx(0) }}
|
|
98
|
+
placeholder={t('ui.advancedFilter.fieldPicker.search', 'Search field…')}
|
|
99
|
+
className="h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
100
|
+
aria-label={t('ui.advancedFilter.fieldPicker.search', 'Search field…')}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="max-h-[400px] overflow-y-auto py-1" role="listbox">
|
|
104
|
+
{grouped.map(({ group, items }) => (
|
|
105
|
+
<div key={group}>
|
|
106
|
+
<div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{group}</div>
|
|
107
|
+
{items.map((f) => {
|
|
108
|
+
const flatIdx = flatVisible.indexOf(f)
|
|
109
|
+
const Icon = resolveIcon(f)
|
|
110
|
+
const active = flatIdx === activeIdx
|
|
111
|
+
return (
|
|
112
|
+
// Raw <button> required: needs role="option" inside the listbox, which <Button>
|
|
113
|
+
// would override (Button forces role="button"). a11y semantics take precedence
|
|
114
|
+
// over the primitive contract for listbox items.
|
|
115
|
+
<button
|
|
116
|
+
key={f.key}
|
|
117
|
+
type="button"
|
|
118
|
+
role="option"
|
|
119
|
+
aria-selected={active}
|
|
120
|
+
onClick={() => { onSelect(f); onOpenChange(false) }}
|
|
121
|
+
onMouseEnter={() => setActiveIdx(flatIdx)}
|
|
122
|
+
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent ${active ? 'bg-secondary' : ''}`}
|
|
123
|
+
>
|
|
124
|
+
<Icon className="size-4 text-muted-foreground" />
|
|
125
|
+
<span>{f.label}</span>
|
|
126
|
+
</button>
|
|
127
|
+
)
|
|
128
|
+
})}
|
|
129
|
+
</div>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground">
|
|
133
|
+
{t('ui.advancedFilter.fieldPicker.hint', 'Tip: Type to search, ↑↓ to navigate, Enter to select')}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</PopoverContent>
|
|
137
|
+
</Popover>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { SearchX, Undo2 } from 'lucide-react'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
|
|
7
|
+
export type FilteredEmptyResultsProps = {
|
|
8
|
+
entityNamePlural: string
|
|
9
|
+
canRemoveLast: boolean
|
|
10
|
+
onClearAll: () => void
|
|
11
|
+
onRemoveLast: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function FilteredEmptyResults({ entityNamePlural, canRemoveLast, onClearAll, onRemoveLast }: FilteredEmptyResultsProps) {
|
|
15
|
+
const t = useT()
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col items-center justify-center gap-4 py-12" data-testid="filtered-empty-results">
|
|
18
|
+
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
|
|
19
|
+
<SearchX className="size-6 text-muted-foreground" />
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex flex-col items-center gap-1">
|
|
22
|
+
<div className="text-base font-semibold">
|
|
23
|
+
{t('ui.advancedFilter.empty.noMatches', 'No {entity} match these filters', { entity: entityNamePlural })}
|
|
24
|
+
</div>
|
|
25
|
+
<div className="text-sm text-muted-foreground max-w-md text-center">
|
|
26
|
+
{t('ui.advancedFilter.empty.tryRemoving', 'Try removing the most restrictive filter, or clear all filters to see everyone.')}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="flex items-center gap-2">
|
|
30
|
+
<Button type="button" onClick={onClearAll}>
|
|
31
|
+
{t('ui.advancedFilter.empty.clearAll', 'Clear all filters')}
|
|
32
|
+
</Button>
|
|
33
|
+
<Button type="button" variant="outline" disabled={!canRemoveLast} onClick={onRemoveLast}>
|
|
34
|
+
<Undo2 className="size-4" />
|
|
35
|
+
{t('ui.advancedFilter.empty.removeLast', 'Remove last filter')}
|
|
36
|
+
</Button>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|