@moontra/moonui-pro 2.3.7 → 2.4.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.
@@ -13,6 +13,7 @@ import {
13
13
  ColumnFiltersState,
14
14
  VisibilityState,
15
15
  OnChangeFn,
16
+ Row,
16
17
  } from '@tanstack/react-table'
17
18
  import { Button } from '../ui/button'
18
19
  import { Input } from '../ui/input'
@@ -32,18 +33,34 @@ import {
32
33
  Settings,
33
34
  Lock,
34
35
  Sparkles,
35
- Loader2
36
+ Loader2,
37
+ FileDown,
38
+ FileJson,
39
+ FileSpreadsheet
36
40
  } from 'lucide-react'
37
41
  import { cn } from '../../lib/utils'
38
42
  import { useSubscription } from '../../hooks/use-subscription'
39
43
  import { motion, AnimatePresence } from 'framer-motion'
44
+ import { DataTableColumnToggle } from './data-table-column-toggle'
45
+ import { DataTableBulkActions, type BulkAction } from './data-table-bulk-actions'
46
+ import { exportData, type ExportFormat, getVisibleColumns } from './data-table-export'
47
+ import {
48
+ DropdownMenu,
49
+ DropdownMenuContent,
50
+ DropdownMenuItem,
51
+ DropdownMenuTrigger,
52
+ } from '../ui/dropdown-menu'
40
53
 
41
54
  interface DataTableProps<TData, TValue> {
42
55
  columns: ColumnDef<TData, TValue>[]
43
56
  data: TData[]
44
57
  searchable?: boolean
45
58
  filterable?: boolean
46
- exportable?: boolean
59
+ exportable?: boolean | {
60
+ formats?: ExportFormat[]
61
+ filename?: string
62
+ onExport?: (data: TData[], format: ExportFormat) => void
63
+ }
47
64
  selectable?: boolean
48
65
  pagination?: boolean
49
66
  pageSize?: number
@@ -54,6 +71,7 @@ interface DataTableProps<TData, TValue> {
54
71
  renderSubComponent?: (props: { row: { original: TData; id: string } }) => React.ReactNode
55
72
  expandedRows?: Set<string>
56
73
  onRowExpandChange?: (expandedRows: Set<string>) => void
74
+ bulkActions?: BulkAction<TData>[]
57
75
  // Additional props for compatibility
58
76
  enableSorting?: boolean
59
77
  enableFiltering?: boolean
@@ -115,6 +133,7 @@ export function DataTable<TData, TValue>({
115
133
  renderSubComponent,
116
134
  expandedRows: controlledExpandedRows,
117
135
  onRowExpandChange,
136
+ bulkActions = [],
118
137
  features = {},
119
138
  theme = {},
120
139
  texts = {},
@@ -182,8 +201,11 @@ export function DataTable<TData, TValue>({
182
201
 
183
202
  const actualPageSize = defaultPageSize || pageSize
184
203
 
204
+ // Memoize data to prevent unnecessary re-renders
205
+ const stableData = React.useMemo(() => data, [data])
206
+
185
207
  const table = useReactTable({
186
- data,
208
+ data: stableData,
187
209
  columns,
188
210
  onSortingChange: onSortingChange !== undefined ? onSortingChange : setSorting,
189
211
  onColumnFiltersChange: onColumnFiltersChange !== undefined ? onColumnFiltersChange : setColumnFilters,
@@ -209,6 +231,11 @@ export function DataTable<TData, TValue>({
209
231
  pageSize: actualPageSize,
210
232
  },
211
233
  },
234
+ // Prevent re-renders on state changes
235
+ autoResetAll: false,
236
+ autoResetPageIndex: false,
237
+ autoResetExpanded: false,
238
+ getRowId: (row: TData) => (row as any).id || (row as any).orderId || Math.random().toString(),
212
239
  })
213
240
 
214
241
  React.useEffect(() => {
@@ -217,6 +244,22 @@ export function DataTable<TData, TValue>({
217
244
  onRowSelect(selectedRows)
218
245
  }
219
246
  }, [rowSelection, onRowSelect, selectable, table])
247
+
248
+ // Memoize row model to prevent unnecessary re-renders when only expanded state changes
249
+ const tableState = table.getState()
250
+ const rowModel = table.getRowModel()
251
+
252
+ // Use a ref to track if rows actually changed
253
+ const rowsRef = React.useRef(rowModel.rows)
254
+ const rowsChanged = React.useMemo(() => {
255
+ const changed = rowsRef.current !== rowModel.rows
256
+ if (changed) {
257
+ rowsRef.current = rowModel.rows
258
+ }
259
+ return changed
260
+ }, [rowModel.rows])
261
+
262
+ const rows = rowsRef.current
220
263
 
221
264
  // Merge features with defaults
222
265
  const enabledFeatures = {
@@ -229,14 +272,51 @@ export function DataTable<TData, TValue>({
229
272
  export: features.export !== false || exportable,
230
273
  }
231
274
 
232
- const handleExport = () => {
275
+ const handleExport = async (format: ExportFormat) => {
276
+ const selectedRows = table.getFilteredSelectedRowModel().rows
277
+ const dataToExport = selectedRows.length > 0
278
+ ? selectedRows.map(row => row.original)
279
+ : table.getFilteredRowModel().rows.map(row => row.original)
280
+
281
+ // Use custom export handler if provided
282
+ if (typeof exportable === 'object' && exportable.onExport) {
283
+ exportable.onExport(dataToExport, format)
284
+ return
285
+ }
286
+
287
+ // Use legacy onExport if provided
233
288
  if (onExport) {
234
- const selectedRows = table.getFilteredSelectedRowModel().rows
235
- const dataToExport = selectedRows.length > 0
236
- ? selectedRows.map(row => row.original)
237
- : table.getFilteredRowModel().rows.map(row => row.original)
238
289
  onExport(dataToExport)
290
+ return
239
291
  }
292
+
293
+ // Default export behavior
294
+ const filename = typeof exportable === 'object' && exportable.filename
295
+ ? exportable.filename
296
+ : 'data-export'
297
+
298
+ const visibleColumns = getVisibleColumns(columns as any, columnVisibility)
299
+
300
+ await exportData(dataToExport as Record<string, any>[], {
301
+ format,
302
+ filename,
303
+ columns: visibleColumns,
304
+ includeHeaders: true
305
+ })
306
+ }
307
+
308
+ // Parse export options
309
+ const exportFormats: ExportFormat[] = React.useMemo(() => {
310
+ if (!exportable) return []
311
+ if (exportable === true) return ['csv', 'json']
312
+ if (typeof exportable === 'object' && exportable.formats) {
313
+ return exportable.formats
314
+ }
315
+ return ['csv', 'json']
316
+ }, [exportable])
317
+
318
+ const clearRowSelection = () => {
319
+ table.resetRowSelection()
240
320
  }
241
321
 
242
322
  return (
@@ -264,20 +344,52 @@ export function DataTable<TData, TValue>({
264
344
  Filters
265
345
  </Button>
266
346
  )}
347
+
348
+ {/* Bulk actions */}
349
+ {selectable && bulkActions.length > 0 && (
350
+ <DataTableBulkActions
351
+ selectedRows={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
352
+ actions={bulkActions}
353
+ onClearSelection={clearRowSelection}
354
+ />
355
+ )}
267
356
  </div>
268
357
 
269
358
  <div className="flex items-center space-x-2">
270
- {exportable && (
271
- <Button variant="outline" size="sm" onClick={handleExport}>
272
- <span suppressHydrationWarning><Download className="mr-2 h-4 w-4" /></span>
273
- Export
274
- </Button>
359
+ {/* Export dropdown */}
360
+ {exportable && exportFormats.length > 0 && (
361
+ <DropdownMenu>
362
+ <DropdownMenuTrigger asChild>
363
+ <Button variant="outline" size="sm">
364
+ <span suppressHydrationWarning><Download className="mr-2 h-4 w-4" /></span>
365
+ Export
366
+ </Button>
367
+ </DropdownMenuTrigger>
368
+ <DropdownMenuContent align="end">
369
+ {exportFormats.includes('csv') && (
370
+ <DropdownMenuItem onClick={() => handleExport('csv')}>
371
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
372
+ Export as CSV
373
+ </DropdownMenuItem>
374
+ )}
375
+ {exportFormats.includes('json') && (
376
+ <DropdownMenuItem onClick={() => handleExport('json')}>
377
+ <FileJson className="mr-2 h-4 w-4" />
378
+ Export as JSON
379
+ </DropdownMenuItem>
380
+ )}
381
+ {exportFormats.includes('xlsx') && (
382
+ <DropdownMenuItem onClick={() => handleExport('xlsx')}>
383
+ <FileDown className="mr-2 h-4 w-4" />
384
+ Export as Excel
385
+ </DropdownMenuItem>
386
+ )}
387
+ </DropdownMenuContent>
388
+ </DropdownMenu>
275
389
  )}
276
390
 
277
- <Button variant="outline" size="sm">
278
- <span suppressHydrationWarning><Settings className="mr-2 h-4 w-4" /></span>
279
- Columns
280
- </Button>
391
+ {/* Column visibility toggle */}
392
+ <DataTableColumnToggle table={table} />
281
393
  </div>
282
394
  </div>
283
395
 
@@ -336,80 +448,21 @@ export function DataTable<TData, TValue>({
336
448
  </div>
337
449
  </td>
338
450
  </motion.tr>
339
- ) : table.getRowModel().rows?.length ? (
451
+ ) : rows?.length ? (
340
452
  <>
341
- {table.getRowModel().rows.map((row, index) => {
453
+ {rows.map((row, index) => {
342
454
  const rowId = (row.original as any).id || row.id
343
455
  const isExpanded = enableExpandable && expandedRows.has(rowId)
344
456
 
345
457
  return (
346
- <React.Fragment key={row.id}>
347
- <motion.tr
348
- initial={false}
349
- animate={{ opacity: 1 }}
350
- className={cn(
351
- "border-b transition-colors hover:bg-muted/50",
352
- row.getIsSelected() && "bg-muted",
353
- isExpanded && "border-b-0"
354
- )}
355
- >
356
- {row.getVisibleCells().map((cell) => (
357
- <td key={cell.id} className="moonui-data-table-td p-4 align-middle">
358
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
359
- </td>
360
- ))}
361
- </motion.tr>
362
-
363
- <AnimatePresence initial={false}>
364
- {isExpanded && renderSubComponent && (
365
- <motion.tr
366
- key={`${row.id}-expanded`}
367
- initial={{ height: 0, opacity: 0 }}
368
- animate={{
369
- height: "auto",
370
- opacity: 1,
371
- transition: {
372
- height: {
373
- duration: 0.3,
374
- ease: "easeOut"
375
- },
376
- opacity: {
377
- duration: 0.2,
378
- delay: 0.1
379
- }
380
- }
381
- }}
382
- exit={{
383
- height: 0,
384
- opacity: 0,
385
- transition: {
386
- height: {
387
- duration: 0.3,
388
- ease: "easeIn"
389
- },
390
- opacity: {
391
- duration: 0.2
392
- }
393
- }
394
- }}
395
- style={{ overflow: "hidden" }}
396
- className="border-b"
397
- >
398
- <td colSpan={columns.length} className="p-0">
399
- <motion.div
400
- initial={{ y: -10 }}
401
- animate={{ y: 0 }}
402
- exit={{ y: -10 }}
403
- transition={{ duration: 0.2 }}
404
- className="border-t border-border/50"
405
- >
406
- {renderSubComponent({ row: { original: row.original, id: rowId } })}
407
- </motion.div>
408
- </td>
409
- </motion.tr>
410
- )}
411
- </AnimatePresence>
412
- </React.Fragment>
458
+ <TableRow
459
+ key={rowId}
460
+ row={row}
461
+ columns={columns}
462
+ isExpanded={isExpanded}
463
+ enableExpandable={enableExpandable}
464
+ renderSubComponent={renderSubComponent}
465
+ />
413
466
  );
414
467
  })}
415
468
  </>
@@ -614,5 +667,72 @@ export function useExpandableRows(initialExpanded: Set<string> = new Set()) {
614
667
  };
615
668
  }
616
669
 
670
+ // Memoized table row component
671
+ interface TableRowProps {
672
+ row: Row<any>
673
+ columns: ColumnDef<any, any>[]
674
+ isExpanded: boolean
675
+ enableExpandable: boolean
676
+ renderSubComponent?: (props: { row: { original: any; id: string } }) => React.ReactNode
677
+ }
678
+
679
+ const TableRow = React.memo(({
680
+ row,
681
+ columns,
682
+ isExpanded,
683
+ enableExpandable,
684
+ renderSubComponent
685
+ }: TableRowProps) => {
686
+ const rowId = (row.original as any).id || row.id
687
+
688
+ return (
689
+ <>
690
+ <tr
691
+ className={cn(
692
+ "border-b transition-colors hover:bg-muted/50",
693
+ row.getIsSelected() && "bg-muted",
694
+ isExpanded && "border-b-0"
695
+ )}
696
+ >
697
+ {row.getVisibleCells().map((cell) => (
698
+ <td key={cell.id} className="moonui-data-table-td p-4 align-middle">
699
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
700
+ </td>
701
+ ))}
702
+ </tr>
703
+
704
+ {isExpanded && renderSubComponent && (
705
+ <tr className="border-b">
706
+ <td colSpan={columns.length} className="p-0 overflow-hidden">
707
+ <div
708
+ className="transition-all duration-300 ease-out"
709
+ style={{
710
+ maxHeight: isExpanded ? '1000px' : '0',
711
+ opacity: isExpanded ? 1 : 0,
712
+ }}
713
+ >
714
+ <div className="border-t border-border/50">
715
+ {renderSubComponent({ row: { original: row.original, id: rowId } })}
716
+ </div>
717
+ </div>
718
+ </td>
719
+ </tr>
720
+ )}
721
+ </>
722
+ )
723
+ }, (prevProps, nextProps) => {
724
+ // Custom comparison - only re-render if row data or expanded state changed
725
+ const prevRowId = (prevProps.row.original as any).id || prevProps.row.id
726
+ const nextRowId = (nextProps.row.original as any).id || nextProps.row.id
727
+
728
+ return prevRowId === nextRowId &&
729
+ prevProps.isExpanded === nextProps.isExpanded &&
730
+ prevProps.row.getIsSelected() === nextProps.row.getIsSelected()
731
+ })
732
+
733
+ TableRow.displayName = 'TableRow'
734
+
617
735
  // Re-export types for convenience
618
736
  export { type ColumnDef } from "@tanstack/react-table";
737
+ export type { BulkAction } from './data-table-bulk-actions';
738
+ export type { ExportFormat } from './data-table-export';
@@ -0,0 +1,141 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5
+
6
+ import { cn } from "../../lib/utils"
7
+ import { buttonVariants } from "./button"
8
+
9
+ const AlertDialog = AlertDialogPrimitive.Root
10
+
11
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12
+
13
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
14
+
15
+ const AlertDialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <AlertDialogPrimitive.Overlay
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ ref={ref}
26
+ />
27
+ ))
28
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
+
30
+ const AlertDialogContent = React.forwardRef<
31
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
+ >(({ className, ...props }, ref) => (
34
+ <AlertDialogPortal>
35
+ <AlertDialogOverlay />
36
+ <AlertDialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ </AlertDialogPortal>
45
+ ))
46
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47
+
48
+ const AlertDialogHeader = ({
49
+ className,
50
+ ...props
51
+ }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn(
54
+ "flex flex-col space-y-2 text-center sm:text-left",
55
+ className
56
+ )}
57
+ {...props}
58
+ />
59
+ )
60
+ AlertDialogHeader.displayName = "AlertDialogHeader"
61
+
62
+ const AlertDialogFooter = ({
63
+ className,
64
+ ...props
65
+ }: React.HTMLAttributes<HTMLDivElement>) => (
66
+ <div
67
+ className={cn(
68
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ AlertDialogFooter.displayName = "AlertDialogFooter"
75
+
76
+ const AlertDialogTitle = React.forwardRef<
77
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
78
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
79
+ >(({ className, ...props }, ref) => (
80
+ <AlertDialogPrimitive.Title
81
+ ref={ref}
82
+ className={cn("text-lg font-semibold", className)}
83
+ {...props}
84
+ />
85
+ ))
86
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87
+
88
+ const AlertDialogDescription = React.forwardRef<
89
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
90
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
91
+ >(({ className, ...props }, ref) => (
92
+ <AlertDialogPrimitive.Description
93
+ ref={ref}
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ ))
98
+ AlertDialogDescription.displayName =
99
+ AlertDialogPrimitive.Description.displayName
100
+
101
+ const AlertDialogAction = React.forwardRef<
102
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
103
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
104
+ >(({ className, ...props }, ref) => (
105
+ <AlertDialogPrimitive.Action
106
+ ref={ref}
107
+ className={cn(buttonVariants(), className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112
+
113
+ const AlertDialogCancel = React.forwardRef<
114
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
115
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
116
+ >(({ className, ...props }, ref) => (
117
+ <AlertDialogPrimitive.Cancel
118
+ ref={ref}
119
+ className={cn(
120
+ buttonVariants({ variant: "outline" }),
121
+ "mt-2 sm:mt-0",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ ))
127
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128
+
129
+ export {
130
+ AlertDialog,
131
+ AlertDialogPortal,
132
+ AlertDialogOverlay,
133
+ AlertDialogTrigger,
134
+ AlertDialogContent,
135
+ AlertDialogHeader,
136
+ AlertDialogFooter,
137
+ AlertDialogTitle,
138
+ AlertDialogDescription,
139
+ AlertDialogAction,
140
+ AlertDialogCancel,
141
+ }