@moontra/moonui-pro 2.3.8 → 2.4.1
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/dist/index.d.ts +30 -3
- package/dist/index.mjs +1045 -91
- package/package.json +4 -3
- package/scripts/postinstall.js +26 -0
- package/src/components/data-table/data-table-bulk-actions.tsx +204 -0
- package/src/components/data-table/data-table-column-toggle.tsx +166 -0
- package/src/components/data-table/data-table-export.ts +156 -0
- package/src/components/data-table/data-table-filter-drawer.tsx +442 -0
- package/src/components/data-table/index.tsx +218 -87
- package/src/components/ui/alert-dialog.tsx +141 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useState, useMemo } from 'react'
|
|
4
|
+
import { Column, Table } from '@tanstack/react-table'
|
|
5
|
+
import { X, Filter, Trash2, Plus } from 'lucide-react'
|
|
6
|
+
import { Button } from '../ui/button'
|
|
7
|
+
import { Input } from '../ui/input'
|
|
8
|
+
import { Label } from '../ui/label'
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
10
|
+
import { Switch } from '../ui/switch'
|
|
11
|
+
import { Separator } from '../ui/separator'
|
|
12
|
+
import { cn } from '../../lib/utils'
|
|
13
|
+
import { motion, AnimatePresence } from 'framer-motion'
|
|
14
|
+
|
|
15
|
+
export interface FilterCondition {
|
|
16
|
+
column: string
|
|
17
|
+
operator: FilterOperator
|
|
18
|
+
value: any
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type FilterOperator =
|
|
22
|
+
| 'equals'
|
|
23
|
+
| 'notEquals'
|
|
24
|
+
| 'contains'
|
|
25
|
+
| 'notContains'
|
|
26
|
+
| 'startsWith'
|
|
27
|
+
| 'endsWith'
|
|
28
|
+
| 'greaterThan'
|
|
29
|
+
| 'lessThan'
|
|
30
|
+
| 'greaterThanOrEqual'
|
|
31
|
+
| 'lessThanOrEqual'
|
|
32
|
+
| 'between'
|
|
33
|
+
| 'in'
|
|
34
|
+
| 'notIn'
|
|
35
|
+
| 'isNull'
|
|
36
|
+
| 'isNotNull'
|
|
37
|
+
|
|
38
|
+
export interface DataTableFilterDrawerProps<TData> {
|
|
39
|
+
table: Table<TData>
|
|
40
|
+
open: boolean
|
|
41
|
+
onOpenChange: (open: boolean) => void
|
|
42
|
+
position?: 'left' | 'right'
|
|
43
|
+
width?: string
|
|
44
|
+
filters?: FilterCondition[]
|
|
45
|
+
onFiltersChange?: (filters: FilterCondition[]) => void
|
|
46
|
+
customFilters?: React.ReactNode
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const operatorLabels: Record<FilterOperator, string> = {
|
|
50
|
+
equals: 'Equals',
|
|
51
|
+
notEquals: 'Not equals',
|
|
52
|
+
contains: 'Contains',
|
|
53
|
+
notContains: 'Not contains',
|
|
54
|
+
startsWith: 'Starts with',
|
|
55
|
+
endsWith: 'Ends with',
|
|
56
|
+
greaterThan: 'Greater than',
|
|
57
|
+
lessThan: 'Less than',
|
|
58
|
+
greaterThanOrEqual: 'Greater than or equal',
|
|
59
|
+
lessThanOrEqual: 'Less than or equal',
|
|
60
|
+
between: 'Between',
|
|
61
|
+
in: 'In',
|
|
62
|
+
notIn: 'Not in',
|
|
63
|
+
isNull: 'Is empty',
|
|
64
|
+
isNotNull: 'Is not empty',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getOperatorsForColumnType(columnType?: string): FilterOperator[] {
|
|
68
|
+
switch (columnType) {
|
|
69
|
+
case 'number':
|
|
70
|
+
return ['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'between', 'isNull', 'isNotNull']
|
|
71
|
+
case 'date':
|
|
72
|
+
return ['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'between', 'isNull', 'isNotNull']
|
|
73
|
+
case 'boolean':
|
|
74
|
+
return ['equals', 'notEquals', 'isNull', 'isNotNull']
|
|
75
|
+
case 'select':
|
|
76
|
+
return ['equals', 'notEquals', 'in', 'notIn', 'isNull', 'isNotNull']
|
|
77
|
+
default: // string
|
|
78
|
+
return ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'isNull', 'isNotNull']
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function DataTableFilterDrawer<TData>({
|
|
83
|
+
table,
|
|
84
|
+
open,
|
|
85
|
+
onOpenChange,
|
|
86
|
+
position = 'right',
|
|
87
|
+
width = '400px',
|
|
88
|
+
filters: externalFilters,
|
|
89
|
+
onFiltersChange,
|
|
90
|
+
customFilters,
|
|
91
|
+
}: DataTableFilterDrawerProps<TData>) {
|
|
92
|
+
const [internalFilters, setInternalFilters] = useState<FilterCondition[]>([])
|
|
93
|
+
const [matchAll, setMatchAll] = useState(true)
|
|
94
|
+
|
|
95
|
+
const filters = externalFilters || internalFilters
|
|
96
|
+
const setFilters = onFiltersChange || setInternalFilters
|
|
97
|
+
|
|
98
|
+
// Get filterable columns
|
|
99
|
+
const filterableColumns = useMemo(() => {
|
|
100
|
+
return table.getAllColumns().filter(column => {
|
|
101
|
+
// Skip special columns
|
|
102
|
+
if (column.id === 'select' || column.id === 'actions' || column.id === 'expander') {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
// Only include columns that can be filtered
|
|
106
|
+
return column.getCanFilter()
|
|
107
|
+
})
|
|
108
|
+
}, [table])
|
|
109
|
+
|
|
110
|
+
const addFilter = () => {
|
|
111
|
+
const firstColumn = filterableColumns[0]
|
|
112
|
+
if (!firstColumn) return
|
|
113
|
+
|
|
114
|
+
const newFilter: FilterCondition = {
|
|
115
|
+
column: firstColumn.id,
|
|
116
|
+
operator: 'contains',
|
|
117
|
+
value: '',
|
|
118
|
+
}
|
|
119
|
+
setFilters([...filters, newFilter])
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const updateFilter = (index: number, updates: Partial<FilterCondition>) => {
|
|
123
|
+
const newFilters = [...filters]
|
|
124
|
+
newFilters[index] = { ...newFilters[index], ...updates }
|
|
125
|
+
setFilters(newFilters)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const removeFilter = (index: number) => {
|
|
129
|
+
setFilters(filters.filter((_, i) => i !== index))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const clearAllFilters = () => {
|
|
133
|
+
setFilters([])
|
|
134
|
+
table.resetColumnFilters()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const applyFilters = () => {
|
|
138
|
+
// Reset all column filters first
|
|
139
|
+
table.resetColumnFilters()
|
|
140
|
+
|
|
141
|
+
// Apply each filter
|
|
142
|
+
filters.forEach(filter => {
|
|
143
|
+
const column = table.getColumn(filter.column)
|
|
144
|
+
if (!column) return
|
|
145
|
+
|
|
146
|
+
// Apply filter based on operator
|
|
147
|
+
switch (filter.operator) {
|
|
148
|
+
case 'equals':
|
|
149
|
+
column.setFilterValue(filter.value)
|
|
150
|
+
break
|
|
151
|
+
case 'notEquals':
|
|
152
|
+
column.setFilterValue((value: any) => value !== filter.value)
|
|
153
|
+
break
|
|
154
|
+
case 'contains':
|
|
155
|
+
column.setFilterValue((value: any) =>
|
|
156
|
+
String(value).toLowerCase().includes(String(filter.value).toLowerCase())
|
|
157
|
+
)
|
|
158
|
+
break
|
|
159
|
+
case 'notContains':
|
|
160
|
+
column.setFilterValue((value: any) =>
|
|
161
|
+
!String(value).toLowerCase().includes(String(filter.value).toLowerCase())
|
|
162
|
+
)
|
|
163
|
+
break
|
|
164
|
+
case 'startsWith':
|
|
165
|
+
column.setFilterValue((value: any) =>
|
|
166
|
+
String(value).toLowerCase().startsWith(String(filter.value).toLowerCase())
|
|
167
|
+
)
|
|
168
|
+
break
|
|
169
|
+
case 'endsWith':
|
|
170
|
+
column.setFilterValue((value: any) =>
|
|
171
|
+
String(value).toLowerCase().endsWith(String(filter.value).toLowerCase())
|
|
172
|
+
)
|
|
173
|
+
break
|
|
174
|
+
case 'greaterThan':
|
|
175
|
+
column.setFilterValue((value: any) => Number(value) > Number(filter.value))
|
|
176
|
+
break
|
|
177
|
+
case 'lessThan':
|
|
178
|
+
column.setFilterValue((value: any) => Number(value) < Number(filter.value))
|
|
179
|
+
break
|
|
180
|
+
case 'greaterThanOrEqual':
|
|
181
|
+
column.setFilterValue((value: any) => Number(value) >= Number(filter.value))
|
|
182
|
+
break
|
|
183
|
+
case 'lessThanOrEqual':
|
|
184
|
+
column.setFilterValue((value: any) => Number(value) <= Number(filter.value))
|
|
185
|
+
break
|
|
186
|
+
case 'isNull':
|
|
187
|
+
column.setFilterValue((value: any) => value == null || value === '')
|
|
188
|
+
break
|
|
189
|
+
case 'isNotNull':
|
|
190
|
+
column.setFilterValue((value: any) => value != null && value !== '')
|
|
191
|
+
break
|
|
192
|
+
// Add more operators as needed
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
onOpenChange(false)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<>
|
|
201
|
+
{/* Backdrop */}
|
|
202
|
+
<AnimatePresence>
|
|
203
|
+
{open && (
|
|
204
|
+
<motion.div
|
|
205
|
+
initial={{ opacity: 0 }}
|
|
206
|
+
animate={{ opacity: 1 }}
|
|
207
|
+
exit={{ opacity: 0 }}
|
|
208
|
+
className="fixed inset-0 bg-black/20 z-40"
|
|
209
|
+
onClick={() => onOpenChange(false)}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
</AnimatePresence>
|
|
213
|
+
|
|
214
|
+
{/* Drawer */}
|
|
215
|
+
<AnimatePresence>
|
|
216
|
+
{open && (
|
|
217
|
+
<motion.div
|
|
218
|
+
initial={{ x: position === 'right' ? '100%' : '-100%' }}
|
|
219
|
+
animate={{ x: 0 }}
|
|
220
|
+
exit={{ x: position === 'right' ? '100%' : '-100%' }}
|
|
221
|
+
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
|
222
|
+
className={cn(
|
|
223
|
+
"fixed top-0 bottom-0 z-50 bg-background border-l shadow-xl",
|
|
224
|
+
position === 'right' ? 'right-0' : 'left-0'
|
|
225
|
+
)}
|
|
226
|
+
style={{ width }}
|
|
227
|
+
>
|
|
228
|
+
<div className="flex flex-col h-full">
|
|
229
|
+
{/* Header */}
|
|
230
|
+
<div className="flex items-center justify-between p-4 border-b">
|
|
231
|
+
<div className="flex items-center gap-2">
|
|
232
|
+
<Filter className="h-5 w-5" />
|
|
233
|
+
<h2 className="text-lg font-semibold">Filters</h2>
|
|
234
|
+
{filters.length > 0 && (
|
|
235
|
+
<span className="text-sm text-muted-foreground">
|
|
236
|
+
({filters.length} active)
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
<Button
|
|
241
|
+
variant="ghost"
|
|
242
|
+
size="icon"
|
|
243
|
+
onClick={() => onOpenChange(false)}
|
|
244
|
+
>
|
|
245
|
+
<X className="h-4 w-4" />
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Content */}
|
|
250
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
251
|
+
{/* Match mode */}
|
|
252
|
+
<div className="mb-6">
|
|
253
|
+
<Label className="text-sm font-medium mb-2 block">
|
|
254
|
+
Match conditions
|
|
255
|
+
</Label>
|
|
256
|
+
<div className="flex items-center gap-2">
|
|
257
|
+
<Button
|
|
258
|
+
variant={matchAll ? 'primary' : 'outline'}
|
|
259
|
+
size="sm"
|
|
260
|
+
onClick={() => setMatchAll(true)}
|
|
261
|
+
className="flex-1"
|
|
262
|
+
>
|
|
263
|
+
Match all
|
|
264
|
+
</Button>
|
|
265
|
+
<Button
|
|
266
|
+
variant={!matchAll ? 'primary' : 'outline'}
|
|
267
|
+
size="sm"
|
|
268
|
+
onClick={() => setMatchAll(false)}
|
|
269
|
+
className="flex-1"
|
|
270
|
+
>
|
|
271
|
+
Match any
|
|
272
|
+
</Button>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<Separator className="mb-6" />
|
|
277
|
+
|
|
278
|
+
{/* Custom filters */}
|
|
279
|
+
{customFilters && (
|
|
280
|
+
<>
|
|
281
|
+
{customFilters}
|
|
282
|
+
<Separator className="my-6" />
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{/* Filter conditions */}
|
|
287
|
+
<div className="space-y-4">
|
|
288
|
+
{filters.map((filter, index) => (
|
|
289
|
+
<FilterConditionRow
|
|
290
|
+
key={index}
|
|
291
|
+
filter={filter}
|
|
292
|
+
columns={filterableColumns}
|
|
293
|
+
onUpdate={(updates) => updateFilter(index, updates)}
|
|
294
|
+
onRemove={() => removeFilter(index)}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* Add filter button */}
|
|
300
|
+
<Button
|
|
301
|
+
variant="outline"
|
|
302
|
+
size="sm"
|
|
303
|
+
onClick={addFilter}
|
|
304
|
+
className="w-full mt-4"
|
|
305
|
+
>
|
|
306
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
307
|
+
Add filter
|
|
308
|
+
</Button>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Footer */}
|
|
312
|
+
<div className="p-4 border-t space-y-2">
|
|
313
|
+
<div className="flex gap-2">
|
|
314
|
+
<Button
|
|
315
|
+
variant="outline"
|
|
316
|
+
onClick={clearAllFilters}
|
|
317
|
+
disabled={filters.length === 0}
|
|
318
|
+
className="flex-1"
|
|
319
|
+
>
|
|
320
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
321
|
+
Clear all
|
|
322
|
+
</Button>
|
|
323
|
+
<Button onClick={applyFilters} className="flex-1">
|
|
324
|
+
Apply filters
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</motion.div>
|
|
330
|
+
)}
|
|
331
|
+
</AnimatePresence>
|
|
332
|
+
</>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface FilterConditionRowProps<TData> {
|
|
337
|
+
filter: FilterCondition
|
|
338
|
+
columns: Column<TData, any>[]
|
|
339
|
+
onUpdate: (updates: Partial<FilterCondition>) => void
|
|
340
|
+
onRemove: () => void
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function FilterConditionRow<TData>({
|
|
344
|
+
filter,
|
|
345
|
+
columns,
|
|
346
|
+
onUpdate,
|
|
347
|
+
onRemove,
|
|
348
|
+
}: FilterConditionRowProps<TData>) {
|
|
349
|
+
const selectedColumn = columns.find(col => col.id === filter.column)
|
|
350
|
+
const columnDef = selectedColumn?.columnDef as any
|
|
351
|
+
const columnType = columnDef?.meta?.filterType || 'string'
|
|
352
|
+
const availableOperators = getOperatorsForColumnType(columnType)
|
|
353
|
+
|
|
354
|
+
const needsValue = filter.operator !== 'isNull' && filter.operator !== 'isNotNull'
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div className="space-y-2 p-3 border rounded-lg bg-muted/30">
|
|
358
|
+
{/* Column selector */}
|
|
359
|
+
<div className="flex items-center gap-2">
|
|
360
|
+
<Select
|
|
361
|
+
value={filter.column}
|
|
362
|
+
onValueChange={(value) => onUpdate({ column: value })}
|
|
363
|
+
>
|
|
364
|
+
<SelectTrigger className="flex-1">
|
|
365
|
+
<SelectValue />
|
|
366
|
+
</SelectTrigger>
|
|
367
|
+
<SelectContent>
|
|
368
|
+
{columns.map(column => {
|
|
369
|
+
const header = column.columnDef.header
|
|
370
|
+
const label = typeof header === 'function' ? column.id : header || column.id
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<SelectItem key={column.id} value={column.id}>
|
|
374
|
+
{label}
|
|
375
|
+
</SelectItem>
|
|
376
|
+
)
|
|
377
|
+
})}
|
|
378
|
+
</SelectContent>
|
|
379
|
+
</Select>
|
|
380
|
+
|
|
381
|
+
<Button
|
|
382
|
+
variant="ghost"
|
|
383
|
+
size="icon"
|
|
384
|
+
onClick={onRemove}
|
|
385
|
+
className="h-8 w-8"
|
|
386
|
+
>
|
|
387
|
+
<X className="h-4 w-4" />
|
|
388
|
+
</Button>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
{/* Operator selector */}
|
|
392
|
+
<Select
|
|
393
|
+
value={filter.operator}
|
|
394
|
+
onValueChange={(value) => onUpdate({ operator: value as FilterOperator })}
|
|
395
|
+
>
|
|
396
|
+
<SelectTrigger>
|
|
397
|
+
<SelectValue />
|
|
398
|
+
</SelectTrigger>
|
|
399
|
+
<SelectContent>
|
|
400
|
+
{availableOperators.map(operator => (
|
|
401
|
+
<SelectItem key={operator} value={operator}>
|
|
402
|
+
{operatorLabels[operator]}
|
|
403
|
+
</SelectItem>
|
|
404
|
+
))}
|
|
405
|
+
</SelectContent>
|
|
406
|
+
</Select>
|
|
407
|
+
|
|
408
|
+
{/* Value input */}
|
|
409
|
+
{needsValue && (
|
|
410
|
+
<div>
|
|
411
|
+
{columnType === 'boolean' ? (
|
|
412
|
+
<Select
|
|
413
|
+
value={String(filter.value)}
|
|
414
|
+
onValueChange={(value) => onUpdate({ value: value === 'true' })}
|
|
415
|
+
>
|
|
416
|
+
<SelectTrigger>
|
|
417
|
+
<SelectValue />
|
|
418
|
+
</SelectTrigger>
|
|
419
|
+
<SelectContent>
|
|
420
|
+
<SelectItem value="true">True</SelectItem>
|
|
421
|
+
<SelectItem value="false">False</SelectItem>
|
|
422
|
+
</SelectContent>
|
|
423
|
+
</Select>
|
|
424
|
+
) : columnType === 'number' ? (
|
|
425
|
+
<Input
|
|
426
|
+
type="number"
|
|
427
|
+
value={filter.value || ''}
|
|
428
|
+
onChange={(e) => onUpdate({ value: e.target.value })}
|
|
429
|
+
placeholder="Enter value..."
|
|
430
|
+
/>
|
|
431
|
+
) : (
|
|
432
|
+
<Input
|
|
433
|
+
value={filter.value || ''}
|
|
434
|
+
onChange={(e) => onUpdate({ value: e.target.value })}
|
|
435
|
+
placeholder="Enter value..."
|
|
436
|
+
/>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
)
|
|
442
|
+
}
|