@spree/dashboard-core 0.1.0
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/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
CardTitle,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuCheckboxItem,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuRadioGroup,
|
|
10
|
+
DropdownMenuRadioItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
Input,
|
|
14
|
+
Popover,
|
|
15
|
+
PopoverContent,
|
|
16
|
+
PopoverTrigger,
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
Tooltip,
|
|
23
|
+
TooltipContent,
|
|
24
|
+
TooltipTrigger,
|
|
25
|
+
} from '@spree/dashboard-ui'
|
|
26
|
+
import { useQuery } from '@tanstack/react-query'
|
|
27
|
+
import {
|
|
28
|
+
ArrowUpDownIcon,
|
|
29
|
+
Columns3Icon,
|
|
30
|
+
FilterIcon,
|
|
31
|
+
PlusIcon,
|
|
32
|
+
SearchIcon,
|
|
33
|
+
Trash2Icon,
|
|
34
|
+
XIcon,
|
|
35
|
+
} from 'lucide-react'
|
|
36
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
37
|
+
import { useTranslation } from 'react-i18next'
|
|
38
|
+
import {
|
|
39
|
+
type ColumnDef,
|
|
40
|
+
type FilterRule,
|
|
41
|
+
parseFilterIds,
|
|
42
|
+
type SortOption,
|
|
43
|
+
} from '../lib/table-registry'
|
|
44
|
+
import { useStore } from '../providers/store-provider'
|
|
45
|
+
import { ResourceMultiAutocomplete } from './resource-multi-autocomplete'
|
|
46
|
+
import { StoreDatePicker } from './store-date-picker'
|
|
47
|
+
import { TagCombobox } from './tag-combobox'
|
|
48
|
+
|
|
49
|
+
interface TableToolbarProps {
|
|
50
|
+
/** Displayable columns (for column selector and table headers) */
|
|
51
|
+
columns: ColumnDef[]
|
|
52
|
+
visibleColumns: string[]
|
|
53
|
+
onVisibleColumnsChange: (columns: string[]) => void
|
|
54
|
+
search: string
|
|
55
|
+
onSearchChange: (value: string) => void
|
|
56
|
+
searchPlaceholder?: string
|
|
57
|
+
sort: SortOption
|
|
58
|
+
onSortChange: (sort: SortOption) => void
|
|
59
|
+
filters: FilterRule[]
|
|
60
|
+
onFiltersChange: (filters: FilterRule[]) => void
|
|
61
|
+
/** All columns including filter-only ones (for the filter drawer). Falls back to `columns` if not provided. */
|
|
62
|
+
allColumns?: ColumnDef[]
|
|
63
|
+
/** Title displayed in the toolbar header */
|
|
64
|
+
title?: string
|
|
65
|
+
actions?: React.ReactNode
|
|
66
|
+
/** Hide the sort dropdown — used when the table is drag-reorderable, where free sorting would defeat the drag. */
|
|
67
|
+
hideSort?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================================
|
|
71
|
+
// Operators
|
|
72
|
+
// ============================================================================
|
|
73
|
+
|
|
74
|
+
// Operator labels are stored as i18n keys (`admin.components.table_toolbar.operators.*`)
|
|
75
|
+
// and resolved with `t()` at render time so they follow the active language.
|
|
76
|
+
const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
|
|
77
|
+
string: [
|
|
78
|
+
{ value: 'cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
|
|
79
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
|
|
80
|
+
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.does_not_equal' },
|
|
81
|
+
{ value: 'start', labelKey: 'admin.components.table_toolbar.operators.starts_with' },
|
|
82
|
+
{ value: 'end', labelKey: 'admin.components.table_toolbar.operators.ends_with' },
|
|
83
|
+
{ value: 'present', labelKey: 'admin.components.table_toolbar.operators.is_set' },
|
|
84
|
+
{ value: 'blank', labelKey: 'admin.components.table_toolbar.operators.is_not_set' },
|
|
85
|
+
],
|
|
86
|
+
enum: [
|
|
87
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
|
|
88
|
+
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
|
|
89
|
+
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
90
|
+
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
91
|
+
],
|
|
92
|
+
boolean: [{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' }],
|
|
93
|
+
number: [
|
|
94
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
|
|
95
|
+
{ value: 'gt', labelKey: 'admin.components.table_toolbar.operators.greater_than' },
|
|
96
|
+
{ value: 'gteq', labelKey: 'admin.components.table_toolbar.operators.greater_than_or_equal' },
|
|
97
|
+
{ value: 'lt', labelKey: 'admin.components.table_toolbar.operators.less_than' },
|
|
98
|
+
{ value: 'lteq', labelKey: 'admin.components.table_toolbar.operators.less_than_or_equal' },
|
|
99
|
+
],
|
|
100
|
+
date: [
|
|
101
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
|
|
102
|
+
{ value: 'gt', labelKey: 'admin.components.table_toolbar.operators.after' },
|
|
103
|
+
{ value: 'lt', labelKey: 'admin.components.table_toolbar.operators.before' },
|
|
104
|
+
{ value: 'gteq', labelKey: 'admin.components.table_toolbar.operators.on_or_after' },
|
|
105
|
+
{ value: 'lteq', labelKey: 'admin.components.table_toolbar.operators.on_or_before' },
|
|
106
|
+
],
|
|
107
|
+
resource: [
|
|
108
|
+
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
109
|
+
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
110
|
+
],
|
|
111
|
+
// Tag names round-trip as CSV; `filtersToRansack` decodes to the
|
|
112
|
+
// `tags_name_in` / `tags_name_not_in` predicate when emitting Ransack.
|
|
113
|
+
tags: [
|
|
114
|
+
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
115
|
+
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
116
|
+
],
|
|
117
|
+
// Same shape as `enum` — the value picker just sources its option list
|
|
118
|
+
// from the active store's `supported_currencies`.
|
|
119
|
+
currency: [
|
|
120
|
+
{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
|
|
121
|
+
{ value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
|
|
122
|
+
{ value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
|
|
123
|
+
{ value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getOperators(type: string) {
|
|
128
|
+
return operatorsByType[type] || operatorsByType.string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const noValueOperators = ['present', 'blank']
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// TableToolbar
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
export function TableToolbar({
|
|
138
|
+
columns,
|
|
139
|
+
visibleColumns,
|
|
140
|
+
onVisibleColumnsChange,
|
|
141
|
+
search,
|
|
142
|
+
onSearchChange,
|
|
143
|
+
searchPlaceholder,
|
|
144
|
+
sort,
|
|
145
|
+
onSortChange,
|
|
146
|
+
filters,
|
|
147
|
+
onFiltersChange,
|
|
148
|
+
allColumns,
|
|
149
|
+
title,
|
|
150
|
+
actions,
|
|
151
|
+
hideSort = false,
|
|
152
|
+
}: TableToolbarProps) {
|
|
153
|
+
const { t } = useTranslation()
|
|
154
|
+
const [filterOpen, setFilterOpen] = useState(false)
|
|
155
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
156
|
+
|
|
157
|
+
const allCols = allColumns ?? columns
|
|
158
|
+
// Memoize so `FilterPanel`'s `useMemo` deps stay stable across parent
|
|
159
|
+
// re-renders. Otherwise picking a filter value triggers an `items` change
|
|
160
|
+
// in the field-picker Select, which Base UI re-emits as a state change.
|
|
161
|
+
const sortableColumns = useMemo(() => allCols.filter((c) => c.sortable), [allCols])
|
|
162
|
+
const filterableColumns = useMemo(() => allCols.filter((c) => c.filterable), [allCols])
|
|
163
|
+
const activeFilterCount = filters.length
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<>
|
|
167
|
+
<div className="flex flex-col lg:flex-row gap-2 items-start lg:items-center justify-between p-3 pl-4 border-b border-border">
|
|
168
|
+
{title && <CardTitle>{title}</CardTitle>}
|
|
169
|
+
<div className="flex gap-2 items-center flex-wrap ml-auto">
|
|
170
|
+
{/* Search */}
|
|
171
|
+
<div className="flex items-center gap-2 border border-border bg-card rounded-lg shadow-xs px-2.5 h-[2.125rem] lg:w-[300px] focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] transition-all duration-100 ease-in-out">
|
|
172
|
+
<SearchIcon className="size-4 text-muted-foreground shrink-0" />
|
|
173
|
+
<input
|
|
174
|
+
ref={searchRef}
|
|
175
|
+
placeholder={
|
|
176
|
+
searchPlaceholder ?? t('admin.components.table_toolbar.search_placeholder')
|
|
177
|
+
}
|
|
178
|
+
value={search}
|
|
179
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
180
|
+
className="h-full w-full border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-0"
|
|
181
|
+
/>
|
|
182
|
+
{search && (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="p-0.5 text-muted-foreground hover:text-foreground shrink-0"
|
|
186
|
+
onClick={() => {
|
|
187
|
+
onSearchChange('')
|
|
188
|
+
searchRef.current?.focus()
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<XIcon className="size-3.5" />
|
|
192
|
+
</button>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="flex gap-2 items-center">
|
|
198
|
+
{/* Filter button */}
|
|
199
|
+
{filterableColumns.length > 0 && (
|
|
200
|
+
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
|
201
|
+
<Tooltip>
|
|
202
|
+
<TooltipTrigger asChild>
|
|
203
|
+
<PopoverTrigger asChild>
|
|
204
|
+
<Button variant="outline" size="sm" className="h-[2.125rem]">
|
|
205
|
+
<FilterIcon className="size-4" />
|
|
206
|
+
{activeFilterCount > 0 && (
|
|
207
|
+
<Badge variant="outline" className="ml-1 px-1.5 py-0 text-xs">
|
|
208
|
+
{activeFilterCount}
|
|
209
|
+
</Badge>
|
|
210
|
+
)}
|
|
211
|
+
</Button>
|
|
212
|
+
</PopoverTrigger>
|
|
213
|
+
</TooltipTrigger>
|
|
214
|
+
<TooltipContent>
|
|
215
|
+
{t('admin.components.table_toolbar.filters_button')}
|
|
216
|
+
</TooltipContent>
|
|
217
|
+
</Tooltip>
|
|
218
|
+
<PopoverContent align="end" className="w-[480px] p-0">
|
|
219
|
+
<FilterPanel
|
|
220
|
+
columns={filterableColumns}
|
|
221
|
+
filters={filters}
|
|
222
|
+
onApply={(f) => {
|
|
223
|
+
onFiltersChange(f)
|
|
224
|
+
setFilterOpen(false)
|
|
225
|
+
}}
|
|
226
|
+
onClose={() => setFilterOpen(false)}
|
|
227
|
+
/>
|
|
228
|
+
</PopoverContent>
|
|
229
|
+
</Popover>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{/* Sort dropdown — hidden when the table is drag-reorderable, since free sorting would defeat the drag. */}
|
|
233
|
+
{!hideSort && sortableColumns.length > 0 && (
|
|
234
|
+
<SortDropdown columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* Column selector */}
|
|
238
|
+
<ColumnSelector
|
|
239
|
+
columns={columns.filter((c) => c.default !== undefined)}
|
|
240
|
+
visibleColumns={visibleColumns}
|
|
241
|
+
onVisibleColumnsChange={onVisibleColumnsChange}
|
|
242
|
+
/>
|
|
243
|
+
|
|
244
|
+
{actions}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Active filter badges */}
|
|
249
|
+
{filters.length > 0 && (
|
|
250
|
+
<div className="flex gap-2 flex-wrap px-3 py-2 border-b border-border">
|
|
251
|
+
{filters.map((filter) => (
|
|
252
|
+
<FilterChip
|
|
253
|
+
key={filter.id}
|
|
254
|
+
filter={filter}
|
|
255
|
+
col={allCols.find((c) => c.key === filter.field)}
|
|
256
|
+
onRemove={() => onFiltersChange(filters.filter((f) => f.id !== filter.id))}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
className="text-xs text-muted-foreground hover:text-foreground"
|
|
262
|
+
onClick={() => onFiltersChange([])}
|
|
263
|
+
>
|
|
264
|
+
{t('admin.components.table_toolbar.clear_all')}
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// Filter Chip
|
|
274
|
+
// ============================================================================
|
|
275
|
+
|
|
276
|
+
function FilterChip({
|
|
277
|
+
filter,
|
|
278
|
+
col,
|
|
279
|
+
onRemove,
|
|
280
|
+
}: {
|
|
281
|
+
filter: FilterRule
|
|
282
|
+
col: ColumnDef | undefined
|
|
283
|
+
onRemove: () => void
|
|
284
|
+
}) {
|
|
285
|
+
const { t } = useTranslation()
|
|
286
|
+
const ops = getOperators(col?.filterType ?? 'string')
|
|
287
|
+
const opLabelKey = ops.find((o) => o.value === filter.operator)?.labelKey
|
|
288
|
+
const opLabel = opLabelKey ? t(opLabelKey) : filter.operator
|
|
289
|
+
const showValue = !noValueOperators.includes(filter.operator)
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<Badge variant="outline" className="gap-1.5 pr-0.5">
|
|
293
|
+
<span className="font-medium">{col?.label ?? filter.field}</span>
|
|
294
|
+
<span className="text-muted-foreground">{opLabel}</span>
|
|
295
|
+
{showValue &&
|
|
296
|
+
(col?.filterType === 'resource' && col.filterResource ? (
|
|
297
|
+
<ResourceFilterValue value={filter.value} config={col.filterResource} />
|
|
298
|
+
) : col?.filterType === 'tags' ? (
|
|
299
|
+
<span className="font-medium">{parseFilterIds(filter.value).join(', ')}</span>
|
|
300
|
+
) : col?.filterType === 'enum' && col.filterOptions ? (
|
|
301
|
+
<span className="font-medium">
|
|
302
|
+
{col.filterOptions.find((o) => o.value === filter.value)?.label ?? filter.value}
|
|
303
|
+
</span>
|
|
304
|
+
) : (
|
|
305
|
+
<span className="font-medium">{filter.value}</span>
|
|
306
|
+
))}
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
className="ml-0.5 p-0.5 rounded-full hover:bg-accent"
|
|
310
|
+
onClick={onRemove}
|
|
311
|
+
>
|
|
312
|
+
<XIcon className="size-3" />
|
|
313
|
+
</button>
|
|
314
|
+
</Badge>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Hydrates the CSV id list in a resource filter into human labels. Reuses
|
|
320
|
+
* the resource's `hydrate` callback so each chip benefits from React Query's
|
|
321
|
+
* cache (the picker inside the panel populates the same `queryKey`).
|
|
322
|
+
*/
|
|
323
|
+
function ResourceFilterValue({
|
|
324
|
+
value,
|
|
325
|
+
config,
|
|
326
|
+
}: {
|
|
327
|
+
value: string
|
|
328
|
+
config: NonNullable<ColumnDef['filterResource']>
|
|
329
|
+
}) {
|
|
330
|
+
const { storeId } = useStore()
|
|
331
|
+
const ids = useMemo(() => parseFilterIds(value), [value])
|
|
332
|
+
|
|
333
|
+
const { data } = useQuery({
|
|
334
|
+
// Store-scope the cache key so chips don't resolve against another store's
|
|
335
|
+
// hydrate results after a store switch.
|
|
336
|
+
queryKey: ['filter-chip', config.queryKey, storeId, ids],
|
|
337
|
+
queryFn: () => config.hydrate(ids),
|
|
338
|
+
enabled: ids.length > 0,
|
|
339
|
+
staleTime: 60_000,
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
if (ids.length === 0) return null
|
|
343
|
+
|
|
344
|
+
const labels = ids.map((id) => {
|
|
345
|
+
const record = data?.data.find((r) => r.id === id)
|
|
346
|
+
return record ? config.getOptionLabel(record) : id
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
return <span className="font-medium">{labels.join(', ')}</span>
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Currency value-picker for `filterType: 'currency'`. Options come from the
|
|
354
|
+
* active store's `supported_currencies`. Branches on operator: `eq`/`not_eq`
|
|
355
|
+
* use a single `<Select>`; `in`/`not_in` accept a CSV via Base UI's
|
|
356
|
+
* multi-select Combobox chips, matching the other array-valued filters.
|
|
357
|
+
*/
|
|
358
|
+
function CurrencyFilterPicker({
|
|
359
|
+
value,
|
|
360
|
+
operator,
|
|
361
|
+
onChange,
|
|
362
|
+
}: {
|
|
363
|
+
value: string
|
|
364
|
+
operator: string
|
|
365
|
+
onChange: (next: string) => void
|
|
366
|
+
}) {
|
|
367
|
+
const { t } = useTranslation()
|
|
368
|
+
const { currencies } = useStore()
|
|
369
|
+
const items = useMemo(
|
|
370
|
+
() => currencies.map((code) => ({ value: code, label: code })),
|
|
371
|
+
[currencies],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
if (operator === 'in' || operator === 'not_in') {
|
|
375
|
+
return (
|
|
376
|
+
<div className="flex-1 min-w-0">
|
|
377
|
+
<CurrencyMultiSelect
|
|
378
|
+
codes={currencies}
|
|
379
|
+
value={parseFilterIds(value)}
|
|
380
|
+
onChange={(next) => onChange(next.join(','))}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<Select items={items} value={value || undefined} onValueChange={(val) => onChange(val)}>
|
|
388
|
+
<SelectTrigger size="sm" className="flex-1">
|
|
389
|
+
<SelectValue placeholder={t('admin.components.table_toolbar.filter_select_placeholder')} />
|
|
390
|
+
</SelectTrigger>
|
|
391
|
+
<SelectContent>
|
|
392
|
+
{items.map((opt) => (
|
|
393
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
394
|
+
{opt.label}
|
|
395
|
+
</SelectItem>
|
|
396
|
+
))}
|
|
397
|
+
</SelectContent>
|
|
398
|
+
</Select>
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function CurrencyMultiSelect({
|
|
403
|
+
codes,
|
|
404
|
+
value,
|
|
405
|
+
onChange,
|
|
406
|
+
}: {
|
|
407
|
+
codes: string[]
|
|
408
|
+
value: string[]
|
|
409
|
+
onChange: (next: string[]) => void
|
|
410
|
+
}) {
|
|
411
|
+
function toggle(code: string) {
|
|
412
|
+
onChange(value.includes(code) ? value.filter((c) => c !== code) : [...value, code])
|
|
413
|
+
}
|
|
414
|
+
return (
|
|
415
|
+
<div className="flex flex-wrap gap-1 rounded-md border border-input px-2 py-1.5 text-sm">
|
|
416
|
+
{codes.map((code) => {
|
|
417
|
+
const selected = value.includes(code)
|
|
418
|
+
return (
|
|
419
|
+
<button
|
|
420
|
+
key={code}
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => toggle(code)}
|
|
423
|
+
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
|
424
|
+
selected
|
|
425
|
+
? 'bg-primary text-primary-foreground'
|
|
426
|
+
: 'bg-muted text-muted-foreground hover:bg-accent'
|
|
427
|
+
}`}
|
|
428
|
+
>
|
|
429
|
+
{code}
|
|
430
|
+
</button>
|
|
431
|
+
)
|
|
432
|
+
})}
|
|
433
|
+
</div>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ============================================================================
|
|
438
|
+
// Sort Dropdown
|
|
439
|
+
// ============================================================================
|
|
440
|
+
|
|
441
|
+
function SortDropdown({
|
|
442
|
+
columns,
|
|
443
|
+
sort,
|
|
444
|
+
onSortChange,
|
|
445
|
+
}: {
|
|
446
|
+
columns: ColumnDef[]
|
|
447
|
+
sort: SortOption
|
|
448
|
+
onSortChange: (sort: SortOption) => void
|
|
449
|
+
}) {
|
|
450
|
+
const { t } = useTranslation()
|
|
451
|
+
const currentCol = columns.find((c) => c.key === sort.field)
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<DropdownMenu>
|
|
455
|
+
<Tooltip>
|
|
456
|
+
<TooltipTrigger asChild>
|
|
457
|
+
<DropdownMenuTrigger asChild>
|
|
458
|
+
<Button variant="outline" size="sm" className="h-[2.125rem]">
|
|
459
|
+
<ArrowUpDownIcon className="size-4" />
|
|
460
|
+
</Button>
|
|
461
|
+
</DropdownMenuTrigger>
|
|
462
|
+
</TooltipTrigger>
|
|
463
|
+
<TooltipContent>
|
|
464
|
+
{currentCol?.label ?? t('admin.components.table_toolbar.sort_tooltip')}
|
|
465
|
+
</TooltipContent>
|
|
466
|
+
</Tooltip>
|
|
467
|
+
<DropdownMenuContent align="end" className="min-w-48">
|
|
468
|
+
<DropdownMenuLabel>{t('admin.components.table_toolbar.sort_by')}</DropdownMenuLabel>
|
|
469
|
+
<DropdownMenuSeparator />
|
|
470
|
+
<DropdownMenuRadioGroup
|
|
471
|
+
value={sort.field}
|
|
472
|
+
onValueChange={(field) => onSortChange({ field, direction: sort.direction })}
|
|
473
|
+
>
|
|
474
|
+
{columns.map((col) => (
|
|
475
|
+
<DropdownMenuRadioItem key={col.key} value={col.key}>
|
|
476
|
+
{col.label}
|
|
477
|
+
</DropdownMenuRadioItem>
|
|
478
|
+
))}
|
|
479
|
+
</DropdownMenuRadioGroup>
|
|
480
|
+
<DropdownMenuSeparator />
|
|
481
|
+
<DropdownMenuLabel>{t('admin.components.table_toolbar.order')}</DropdownMenuLabel>
|
|
482
|
+
<DropdownMenuSeparator />
|
|
483
|
+
<DropdownMenuRadioGroup
|
|
484
|
+
value={sort.direction}
|
|
485
|
+
onValueChange={(dir) =>
|
|
486
|
+
onSortChange({
|
|
487
|
+
field: sort.field,
|
|
488
|
+
direction: dir as 'asc' | 'desc',
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
>
|
|
492
|
+
<DropdownMenuRadioItem value="asc">
|
|
493
|
+
{t('admin.components.table_toolbar.ascending')}
|
|
494
|
+
</DropdownMenuRadioItem>
|
|
495
|
+
<DropdownMenuRadioItem value="desc">
|
|
496
|
+
{t('admin.components.table_toolbar.descending')}
|
|
497
|
+
</DropdownMenuRadioItem>
|
|
498
|
+
</DropdownMenuRadioGroup>
|
|
499
|
+
</DropdownMenuContent>
|
|
500
|
+
</DropdownMenu>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ============================================================================
|
|
505
|
+
// Column Selector
|
|
506
|
+
// ============================================================================
|
|
507
|
+
|
|
508
|
+
function ColumnSelector({
|
|
509
|
+
columns,
|
|
510
|
+
visibleColumns,
|
|
511
|
+
onVisibleColumnsChange,
|
|
512
|
+
}: {
|
|
513
|
+
columns: ColumnDef[]
|
|
514
|
+
visibleColumns: string[]
|
|
515
|
+
onVisibleColumnsChange: (columns: string[]) => void
|
|
516
|
+
}) {
|
|
517
|
+
const { t } = useTranslation()
|
|
518
|
+
const defaults = columns.filter((c) => c.default).map((c) => c.key)
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<DropdownMenu>
|
|
522
|
+
<Tooltip>
|
|
523
|
+
<TooltipTrigger asChild>
|
|
524
|
+
<DropdownMenuTrigger asChild>
|
|
525
|
+
<Button variant="outline" size="sm" className="h-[2.125rem]">
|
|
526
|
+
<Columns3Icon className="size-4" />
|
|
527
|
+
</Button>
|
|
528
|
+
</DropdownMenuTrigger>
|
|
529
|
+
</TooltipTrigger>
|
|
530
|
+
<TooltipContent>{t('admin.components.table_toolbar.columns_tooltip')}</TooltipContent>
|
|
531
|
+
</Tooltip>
|
|
532
|
+
<DropdownMenuContent align="end" className="min-w-48">
|
|
533
|
+
<DropdownMenuLabel>{t('admin.components.table_toolbar.visible_columns')}</DropdownMenuLabel>
|
|
534
|
+
<DropdownMenuSeparator />
|
|
535
|
+
{columns.map((col) => (
|
|
536
|
+
<DropdownMenuCheckboxItem
|
|
537
|
+
key={col.key}
|
|
538
|
+
checked={visibleColumns.includes(col.key)}
|
|
539
|
+
onCheckedChange={(checked) => {
|
|
540
|
+
onVisibleColumnsChange(
|
|
541
|
+
checked
|
|
542
|
+
? [...visibleColumns, col.key]
|
|
543
|
+
: visibleColumns.filter((k) => k !== col.key),
|
|
544
|
+
)
|
|
545
|
+
}}
|
|
546
|
+
onSelect={(e) => e.preventDefault()}
|
|
547
|
+
>
|
|
548
|
+
{col.label}
|
|
549
|
+
</DropdownMenuCheckboxItem>
|
|
550
|
+
))}
|
|
551
|
+
<DropdownMenuSeparator />
|
|
552
|
+
<div className="px-1 py-1">
|
|
553
|
+
<Button
|
|
554
|
+
variant="ghost"
|
|
555
|
+
size="sm"
|
|
556
|
+
className="w-full justify-center"
|
|
557
|
+
onClick={() => onVisibleColumnsChange(defaults)}
|
|
558
|
+
>
|
|
559
|
+
{t('admin.components.table_toolbar.reset_to_default')}
|
|
560
|
+
</Button>
|
|
561
|
+
</div>
|
|
562
|
+
</DropdownMenuContent>
|
|
563
|
+
</DropdownMenu>
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ============================================================================
|
|
568
|
+
// Filter Drawer
|
|
569
|
+
// ============================================================================
|
|
570
|
+
|
|
571
|
+
function FilterPanel({
|
|
572
|
+
columns,
|
|
573
|
+
filters: initialFilters,
|
|
574
|
+
onApply,
|
|
575
|
+
onClose,
|
|
576
|
+
}: {
|
|
577
|
+
columns: ColumnDef[]
|
|
578
|
+
filters: FilterRule[]
|
|
579
|
+
onApply: (filters: FilterRule[]) => void
|
|
580
|
+
onClose: () => void
|
|
581
|
+
}) {
|
|
582
|
+
const { t } = useTranslation()
|
|
583
|
+
const { storeId } = useStore()
|
|
584
|
+
const [draft, setDraft] = useState<FilterRule[]>(initialFilters)
|
|
585
|
+
const booleanItems = useMemo(
|
|
586
|
+
() => [
|
|
587
|
+
{ value: 'true', label: t('admin.common.yes') },
|
|
588
|
+
{ value: 'false', label: t('admin.common.no') },
|
|
589
|
+
],
|
|
590
|
+
[t],
|
|
591
|
+
)
|
|
592
|
+
// Stable `{ value, label }` array for the field-picker `<Select items>`.
|
|
593
|
+
// Building it inline per render produces a new reference each time, which
|
|
594
|
+
// Base UI's Select treats as an `items` change and re-emits state — causing
|
|
595
|
+
// a "Maximum update depth exceeded" loop when the value-Select sits inside
|
|
596
|
+
// the same panel.
|
|
597
|
+
const fieldItems = useMemo(
|
|
598
|
+
() => columns.map((c) => ({ value: c.key, label: c.label })),
|
|
599
|
+
[columns],
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
const addFilter = useCallback(() => {
|
|
603
|
+
const first = columns[0]
|
|
604
|
+
if (!first) return
|
|
605
|
+
setDraft((prev) => [
|
|
606
|
+
...prev,
|
|
607
|
+
{
|
|
608
|
+
id: crypto.randomUUID(),
|
|
609
|
+
field: first.key,
|
|
610
|
+
operator: getOperators(first.filterType ?? 'string')[0].value,
|
|
611
|
+
value: '',
|
|
612
|
+
},
|
|
613
|
+
])
|
|
614
|
+
}, [columns])
|
|
615
|
+
|
|
616
|
+
const updateFilter = useCallback((id: string, update: Partial<FilterRule>) => {
|
|
617
|
+
setDraft((prev) => prev.map((f) => (f.id === id ? { ...f, ...update } : f)))
|
|
618
|
+
}, [])
|
|
619
|
+
|
|
620
|
+
const removeFilter = useCallback((id: string) => {
|
|
621
|
+
setDraft((prev) => prev.filter((f) => f.id !== id))
|
|
622
|
+
}, [])
|
|
623
|
+
|
|
624
|
+
function handleApply() {
|
|
625
|
+
const valid = draft.filter(
|
|
626
|
+
(f) => noValueOperators.includes(f.operator) || f.value.trim() !== '',
|
|
627
|
+
)
|
|
628
|
+
onApply(valid)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return (
|
|
632
|
+
<div className="flex flex-col">
|
|
633
|
+
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
634
|
+
<span className="text-sm font-medium">
|
|
635
|
+
{t('admin.components.table_toolbar.filters_button')}
|
|
636
|
+
</span>
|
|
637
|
+
<button type="button" onClick={onClose} className="p-1 rounded hover:bg-muted">
|
|
638
|
+
<XIcon className="size-3.5" />
|
|
639
|
+
</button>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<div className="max-h-[320px] overflow-y-auto p-3 space-y-2">
|
|
643
|
+
{draft.map((filter) => {
|
|
644
|
+
const col = columns.find((c) => c.key === filter.field)
|
|
645
|
+
const type = col?.filterType ?? 'string'
|
|
646
|
+
const ops = getOperators(type).map((op) => ({
|
|
647
|
+
value: op.value,
|
|
648
|
+
label: t(op.labelKey),
|
|
649
|
+
}))
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<div key={filter.id} className="flex min-w-0 items-center gap-1.5">
|
|
653
|
+
<Select
|
|
654
|
+
items={fieldItems}
|
|
655
|
+
value={filter.field}
|
|
656
|
+
onValueChange={(val) => {
|
|
657
|
+
const newCol = columns.find((c) => c.key === val)
|
|
658
|
+
const newOps = getOperators(newCol?.filterType ?? 'string')
|
|
659
|
+
updateFilter(filter.id, {
|
|
660
|
+
field: val,
|
|
661
|
+
operator: newOps[0].value,
|
|
662
|
+
value: '',
|
|
663
|
+
})
|
|
664
|
+
}}
|
|
665
|
+
>
|
|
666
|
+
<SelectTrigger size="sm" className="flex-1">
|
|
667
|
+
<SelectValue />
|
|
668
|
+
</SelectTrigger>
|
|
669
|
+
<SelectContent>
|
|
670
|
+
{columns.map((c) => (
|
|
671
|
+
<SelectItem key={c.key} value={c.key}>
|
|
672
|
+
{c.label}
|
|
673
|
+
</SelectItem>
|
|
674
|
+
))}
|
|
675
|
+
</SelectContent>
|
|
676
|
+
</Select>
|
|
677
|
+
|
|
678
|
+
<Select
|
|
679
|
+
items={ops}
|
|
680
|
+
value={filter.operator}
|
|
681
|
+
onValueChange={(val) => updateFilter(filter.id, { operator: val })}
|
|
682
|
+
>
|
|
683
|
+
<SelectTrigger size="sm" className="w-[120px] shrink-0">
|
|
684
|
+
<SelectValue />
|
|
685
|
+
</SelectTrigger>
|
|
686
|
+
<SelectContent>
|
|
687
|
+
{ops.map((op) => (
|
|
688
|
+
<SelectItem key={op.value} value={op.value}>
|
|
689
|
+
{op.label}
|
|
690
|
+
</SelectItem>
|
|
691
|
+
))}
|
|
692
|
+
</SelectContent>
|
|
693
|
+
</Select>
|
|
694
|
+
|
|
695
|
+
{!noValueOperators.includes(filter.operator) &&
|
|
696
|
+
(col?.filterType === 'resource' && col.filterResource ? (
|
|
697
|
+
<div className="flex-1 min-w-0">
|
|
698
|
+
<ResourceMultiAutocomplete
|
|
699
|
+
// Store-scope the cache key so a store switch can't reuse
|
|
700
|
+
// the previous store's search/hydrate results.
|
|
701
|
+
queryKey={`${col.filterResource.queryKey}-${storeId}`}
|
|
702
|
+
value={parseFilterIds(filter.value)}
|
|
703
|
+
onChange={(ids) => updateFilter(filter.id, { value: ids.join(',') })}
|
|
704
|
+
search={col.filterResource.search}
|
|
705
|
+
hydrate={col.filterResource.hydrate}
|
|
706
|
+
getOptionLabel={col.filterResource.getOptionLabel}
|
|
707
|
+
placeholder={col.filterResource.placeholder}
|
|
708
|
+
emptyText={col.filterResource.emptyText}
|
|
709
|
+
/>
|
|
710
|
+
</div>
|
|
711
|
+
) : col?.filterType === 'tags' && col.taggableType ? (
|
|
712
|
+
<div className="flex-1 min-w-0">
|
|
713
|
+
<TagCombobox
|
|
714
|
+
taggableType={col.taggableType}
|
|
715
|
+
value={parseFilterIds(filter.value)}
|
|
716
|
+
onChange={(names) => updateFilter(filter.id, { value: names.join(',') })}
|
|
717
|
+
/>
|
|
718
|
+
</div>
|
|
719
|
+
) : col?.filterType === 'enum' && col.filterOptions ? (
|
|
720
|
+
<Select
|
|
721
|
+
items={col.filterOptions}
|
|
722
|
+
value={filter.value || undefined}
|
|
723
|
+
onValueChange={(val) => updateFilter(filter.id, { value: val })}
|
|
724
|
+
>
|
|
725
|
+
<SelectTrigger size="sm" className="flex-1">
|
|
726
|
+
<SelectValue
|
|
727
|
+
placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
|
|
728
|
+
/>
|
|
729
|
+
</SelectTrigger>
|
|
730
|
+
<SelectContent>
|
|
731
|
+
{col.filterOptions.map((opt) => (
|
|
732
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
733
|
+
{opt.label}
|
|
734
|
+
</SelectItem>
|
|
735
|
+
))}
|
|
736
|
+
</SelectContent>
|
|
737
|
+
</Select>
|
|
738
|
+
) : col?.filterType === 'currency' ? (
|
|
739
|
+
<CurrencyFilterPicker
|
|
740
|
+
value={filter.value}
|
|
741
|
+
operator={filter.operator}
|
|
742
|
+
onChange={(val) => updateFilter(filter.id, { value: val })}
|
|
743
|
+
/>
|
|
744
|
+
) : col?.filterType === 'boolean' ? (
|
|
745
|
+
<Select
|
|
746
|
+
items={booleanItems}
|
|
747
|
+
value={filter.value || undefined}
|
|
748
|
+
onValueChange={(val) => updateFilter(filter.id, { value: val })}
|
|
749
|
+
>
|
|
750
|
+
<SelectTrigger size="sm" className="flex-1">
|
|
751
|
+
<SelectValue
|
|
752
|
+
placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
|
|
753
|
+
/>
|
|
754
|
+
</SelectTrigger>
|
|
755
|
+
<SelectContent>
|
|
756
|
+
<SelectItem value="true">{t('admin.common.yes')}</SelectItem>
|
|
757
|
+
<SelectItem value="false">{t('admin.common.no')}</SelectItem>
|
|
758
|
+
</SelectContent>
|
|
759
|
+
</Select>
|
|
760
|
+
) : col?.filterType === 'date' ? (
|
|
761
|
+
// Filter values are stored as plain strings; for dates we
|
|
762
|
+
// persist `yyyy-MM-dd`, which Ransack accepts as-is for
|
|
763
|
+
// `*_eq`/`*_gt`/`*_lt`. The picker emits `yyyy-MM-dd`
|
|
764
|
+
// directly in date-only mode.
|
|
765
|
+
<div className="flex-1">
|
|
766
|
+
<StoreDatePicker
|
|
767
|
+
value={filter.value || null}
|
|
768
|
+
onChange={(next) => updateFilter(filter.id, { value: next ?? '' })}
|
|
769
|
+
placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
|
|
770
|
+
/>
|
|
771
|
+
</div>
|
|
772
|
+
) : (
|
|
773
|
+
<Input
|
|
774
|
+
type={col?.filterType === 'number' ? 'number' : 'text'}
|
|
775
|
+
className="flex-1 py-1 px-2 text-sm h-7"
|
|
776
|
+
placeholder={t('admin.components.table_toolbar.filter_text_placeholder')}
|
|
777
|
+
value={filter.value}
|
|
778
|
+
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
|
|
779
|
+
/>
|
|
780
|
+
))}
|
|
781
|
+
|
|
782
|
+
<button
|
|
783
|
+
type="button"
|
|
784
|
+
className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
|
|
785
|
+
onClick={() => removeFilter(filter.id)}
|
|
786
|
+
>
|
|
787
|
+
<Trash2Icon className="size-3.5" />
|
|
788
|
+
</button>
|
|
789
|
+
</div>
|
|
790
|
+
)
|
|
791
|
+
})}
|
|
792
|
+
|
|
793
|
+
{draft.length === 0 && (
|
|
794
|
+
<p className="text-xs text-muted-foreground text-center py-4">
|
|
795
|
+
{t('admin.components.table_toolbar.no_filters')}
|
|
796
|
+
</p>
|
|
797
|
+
)}
|
|
798
|
+
</div>
|
|
799
|
+
|
|
800
|
+
<div className="flex items-center justify-between border-t px-3 py-2">
|
|
801
|
+
<Button variant="ghost" size="sm" onClick={addFilter}>
|
|
802
|
+
<PlusIcon className="size-3.5" />
|
|
803
|
+
{t('admin.components.table_toolbar.add_filter')}
|
|
804
|
+
</Button>
|
|
805
|
+
<div className="flex gap-1.5">
|
|
806
|
+
{draft.length > 0 && (
|
|
807
|
+
<Button variant="ghost" size="sm" onClick={() => setDraft([])}>
|
|
808
|
+
{t('admin.actions.clear')}
|
|
809
|
+
</Button>
|
|
810
|
+
)}
|
|
811
|
+
<Button size="sm" onClick={handleApply}>
|
|
812
|
+
{t('admin.actions.apply')}
|
|
813
|
+
</Button>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
)
|
|
818
|
+
}
|