summaryrefslogtreecommitdiff
path: root/components/client-table/client-virtual-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/client-table/client-virtual-table.tsx')
-rw-r--r--components/client-table/client-virtual-table.tsx457
1 files changed, 352 insertions, 105 deletions
diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx
index 4825741f..507057c7 100644
--- a/components/client-table/client-virtual-table.tsx
+++ b/components/client-table/client-virtual-table.tsx
@@ -8,6 +8,11 @@ import {
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
ColumnDef,
SortingState,
ColumnFiltersState,
@@ -21,6 +26,9 @@ import {
Table,
RowSelectionState,
Row,
+ Column,
+ GroupingState,
+ ExpandedState,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import {
@@ -38,23 +46,41 @@ import {
horizontalListSortingStrategy,
} from "@dnd-kit/sortable"
import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
-import { ClientTableToolbar } from "./client-table-toolbar"
-import { exportToExcel } from "./export-utils"
+import { ClientTableToolbar } from "../client-table/client-table-toolbar"
+import { exportToExcel } from "../client-table/export-utils"
import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
import { ClientTableColumnHeader } from "./client-table-column-header"
-import { ClientTableViewOptions } from "./client-table-view-options"
+import { ClientTableViewOptions } from "../client-table/client-table-view-options"
+import { ClientTablePreset } from "./client-table-preset"
+
+// Moved outside for stability (Performance Optimization)
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
export interface ClientVirtualTableProps<TData, TValue> {
data: TData[]
columns: ColumnDef<TData, TValue>[]
height?: string | number
+ estimateRowHeight?: number
className?: string
actions?: React.ReactNode
+ customToolbar?: React.ReactNode
enableExport?: boolean
onExport?: (data: TData[]) => void
-
- // Pagination Props
+ isLoading?: boolean
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
enablePagination?: boolean
manualPagination?: boolean
pageCount?: number
@@ -62,88 +88,184 @@ export interface ClientVirtualTableProps<TData, TValue> {
pagination?: PaginationState
onPaginationChange?: OnChangeFn<PaginationState>
- // Style Props
- getRowClassName?: (originalRow: TData, index: number) => string
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
- // Table Meta
- meta?: any
-
- // Row ID
- getRowId?: (originalRow: TData, index: number, parent?: any) => string
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
- // Selection Props
+ // Order
+ columnOrder?: ColumnOrderState
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>
+
+ // Selection
enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
+
+ // Grouping
+ enableGrouping?: boolean
+ grouping?: GroupingState
+ onGroupingChange?: OnChangeFn<GroupingState>
+ expanded?: ExpandedState
+ onExpandedChange?: OnChangeFn<ExpandedState>
+
+ // --- Event Handlers ---
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void
+
+ // --- Styling ---
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // --- Advanced ---
+ meta?: Record<string, any>
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
}
function ClientVirtualTableInner<TData, TValue>(
{
data,
columns,
- height = "500px", // Default height
+ height = "100%",
+ estimateRowHeight = 40,
className,
actions,
+ customToolbar,
enableExport = true,
onExport,
-
- // Pagination defaults
+ isLoading = false,
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
enablePagination = false,
manualPagination = false,
pageCount,
rowCount,
- pagination: controlledPagination,
+ pagination: propPagination,
onPaginationChange,
+ // Sorting
+ sorting: propSorting,
+ onSortingChange,
+
+ // Filtering
+ columnFilters: propColumnFilters,
+ onColumnFiltersChange,
+ globalFilter: propGlobalFilter,
+ onGlobalFilterChange,
+
+ // Visibility
+ columnVisibility: propColumnVisibility,
+ onColumnVisibilityChange,
+
+ // Pinning
+ columnPinning: propColumnPinning,
+ onColumnPinningChange,
+
+ // Order
+ columnOrder: propColumnOrder,
+ onColumnOrderChange,
+
+ // Selection
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: propRowSelection,
+ onRowSelectionChange,
+
+ // Grouping
+ enableGrouping = false,
+ grouping: propGrouping,
+ onGroupingChange,
+ expanded: propExpanded,
+ onExpandedChange,
+
// Style defaults
getRowClassName,
-
+
// Meta & RowID
meta,
getRowId,
- // Selection defaults
- enableRowSelection,
- enableMultiRowSelection,
- rowSelection: controlledRowSelection,
- onRowSelectionChange,
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
}: ClientVirtualTableProps<TData, TValue>,
ref: React.Ref<Table<TData>>
) {
- // State
- const [sorting, setSorting] = React.useState<SortingState>([])
- const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
- const [globalFilter, setGlobalFilter] = React.useState("")
- const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
- const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
- const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
+ // Internal States (used when props are undefined)
+ const [internalSorting, setInternalSorting] = React.useState<SortingState>([])
+ const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("")
+ const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({})
+ const [internalColumnPinning, setInternalColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [internalColumnOrder, setInternalColumnOrder] = React.useState<ColumnOrderState>(
() => columns.map((c) => c.id || (c as any).accessorKey) as string[]
)
-
- // Internal Pagination State
const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
pageIndex: 0,
- pageSize: 50,
+ pageSize: 10,
})
-
- // Internal Row Selection State
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
- // Fuzzy Filter
- const fuzzyFilter: FilterFn<TData> = (row, columnId, value, addMeta) => {
- const itemRank = rankItem(row.getValue(columnId), value)
- addMeta({ itemRank })
- return itemRank.passed
- }
+ // Effective States
+ const sorting = propSorting ?? internalSorting
+ const setSorting = onSortingChange ?? setInternalSorting
+
+ const columnFilters = propColumnFilters ?? internalColumnFilters
+ const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters
+
+ const globalFilter = propGlobalFilter ?? internalGlobalFilter
+ const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter
+
+ const columnVisibility = propColumnVisibility ?? internalColumnVisibility
+ const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility
- // Combine controlled and uncontrolled states
- const pagination = controlledPagination ?? internalPagination
+ const columnPinning = propColumnPinning ?? internalColumnPinning
+ const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning
+
+ const columnOrder = propColumnOrder ?? internalColumnOrder
+ const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder
+
+ const pagination = propPagination ?? internalPagination
const setPagination = onPaginationChange ?? setInternalPagination
-
- const rowSelection = controlledRowSelection ?? internalRowSelection
+
+ const rowSelection = propRowSelection ?? internalRowSelection
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
// Table Instance
const table = useReactTable({
data,
@@ -157,6 +279,8 @@ function ClientVirtualTableInner<TData, TValue>(
columnPinning,
columnOrder,
rowSelection,
+ grouping,
+ expanded,
},
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
@@ -169,11 +293,28 @@ function ClientVirtualTableInner<TData, TValue>(
onColumnPinningChange: setColumnPinning,
onColumnOrderChange: setColumnOrder,
onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
enableRowSelection,
enableMultiRowSelection,
+ enableGrouping,
getCoreRowModel: getCoreRowModel(),
- getSortedRowModel: getSortedRowModel(),
+
+ // Systematic Order of Operations:
+ // 1. Filtering (Rows are filtered first)
getFilteredRowModel: getFilteredRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ getFacetedMinMaxValues: getFacetedMinMaxValues(),
+
+ // 2. Sorting (Filtered rows are then sorted)
+ getSortedRowModel: getSortedRowModel(),
+
+ // 3. Grouping (Sorted rows are grouped)
+ getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined,
+
+ // 4. Pagination (Final rows are paginated)
getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
columnResizeMode: "onChange",
filterFns: {
@@ -191,7 +332,7 @@ function ClientVirtualTableInner<TData, TValue>(
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
- distance: 8, // 8px movement required to start drag
+ distance: 8,
},
}),
useSensor(KeyboardSensor)
@@ -201,11 +342,29 @@ function ClientVirtualTableInner<TData, TValue>(
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (active && over && active.id !== over.id) {
- setColumnOrder((items) => {
- const oldIndex = items.indexOf(active.id as string)
- const newIndex = items.indexOf(over.id as string)
- return arrayMove(items, oldIndex, newIndex)
- })
+ const activeId = active.id as string
+ const overId = over.id as string
+
+ const activeColumn = table.getColumn(activeId)
+ const overColumn = table.getColumn(overId)
+
+ if (activeColumn && overColumn) {
+ const activePinState = activeColumn.getIsPinned()
+ const overPinState = overColumn.getIsPinned()
+
+ // If dragging between different pin states, update the pin state of the active column
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState)
+ }
+
+ // Reorder the columns
+ setColumnOrder((items) => {
+ const currentItems = Array.isArray(items) ? items : []
+ const oldIndex = items.indexOf(activeId)
+ const newIndex = items.indexOf(overId)
+ return arrayMove(items, oldIndex, newIndex)
+ })
+ }
}
}
@@ -216,7 +375,7 @@ function ClientVirtualTableInner<TData, TValue>(
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
- estimateSize: () => 40, // Estimated row height
+ estimateSize: () => estimateRowHeight,
overscan: 10,
})
@@ -236,30 +395,42 @@ function ClientVirtualTableInner<TData, TValue>(
return
}
const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
- await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
}
return (
- <div className={`flex flex-col gap-4 ${className || ""}`}>
+ <div
+ className={`flex flex-col gap-4 ${className || ""}`}
+ style={{ height }}
+ >
<ClientTableToolbar
globalFilter={globalFilter}
setGlobalFilter={setGlobalFilter}
totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
visibleRows={rows.length}
onExport={enableExport ? handleExport : undefined}
- actions={
+ viewOptions={
<>
- {actions}
<ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
</>
}
+ customToolbar={customToolbar}
+ actions={actions}
/>
<div
ref={tableContainerRef}
- className="relative border rounded-md overflow-auto"
- style={{ height }}
+ className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0"
>
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ </div>
+ )}
+
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -269,11 +440,11 @@ function ClientVirtualTableInner<TData, TValue>(
className="table-fixed border-collapse w-full min-w-full"
style={{ width: table.getTotalSize() }}
>
- <thead className="sticky top-0 z-10 bg-muted">
+ <thead className="sticky top-0 z-40 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<SortableContext
- items={columnOrder}
+ items={headerGroup.headers.map((h) => h.id)}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
@@ -281,6 +452,7 @@ function ClientVirtualTableInner<TData, TValue>(
key={header.id}
header={header}
enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
/>
))}
</SortableContext>
@@ -293,51 +465,126 @@ function ClientVirtualTableInner<TData, TValue>(
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
- {virtualRows.map((virtualRow) => {
- const row = rows[virtualRow.index]
- return (
- <tr
- key={row.id}
- className={cn(
- "hover:bg-muted/50 border-b last:border-0",
- getRowClassName ? getRowClassName(row.original, row.index) : ""
- )}
- style={{ height: `${virtualRow.size}px` }}
- >
- {row.getVisibleCells().map((cell) => {
- // Handle pinned cells
- const isPinned = cell.column.getIsPinned()
- const style: React.CSSProperties = {
- width: cell.column.getSize(),
- }
- if (isPinned === "left") {
- style.position = "sticky"
- style.left = `${cell.column.getStart("left")}px`
- style.zIndex = 10
- style.backgroundColor = "var(--background)" // Ensure opacity
- } else if (isPinned === "right") {
- style.position = "sticky"
- style.right = `${cell.column.getAfter("right")}px`
- style.zIndex = 10
- style.backgroundColor = "var(--background)"
- }
-
- return (
+ {virtualRows.length === 0 && !isLoading ? (
+ <tr>
+ <td colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </td>
+ </tr>
+ ) : (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+
+ // --- Group Header Rendering ---
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? "";
+ const groupingValue = row.getGroupingValue(groupingColumnId);
+
+ return (
+ <tr
+ key={row.id}
+ className="hover:bg-muted/50 border-b bg-muted/30"
+ style={{ height: `${virtualRow.size}px` }}
+ >
<td
- key={cell.id}
- className="px-4 py-2 text-sm truncate border-b"
- style={style}
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
>
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
+ <div className="flex items-center gap-2">
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <span className="flex items-center gap-2">
+ <span className="font-bold capitalize">
+ {groupingColumnId}:
+ </span>
+ <span>
+ {String(groupingValue)}
+ </span>
+ <span className="text-muted-foreground text-sm font-normal">
+ ({row.subRows.length})
+ </span>
+ </span>
+ </div>
</td>
- )
- })}
- </tr>
- )
- })}
+ </tr>
+ )
+ }
+
+ // --- Normal Row Rendering ---
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : "",
+ onRowClick ? "cursor-pointer" : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ onClick={(e) => onRowClick?.(row, e)}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // Handle pinned cells
+ const isPinned = cell.column.getIsPinned()
+ const isGrouped = cell.column.getIsGrouped()
+
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ }
+ if (isPinned === "left") {
+ style.position = "sticky"
+ style.left = `${cell.column.getStart("left")}px`
+ style.zIndex = 20
+ } else if (isPinned === "right") {
+ style.position = "sticky"
+ style.right = `${cell.column.getAfter("right")}px`
+ style.zIndex = 20
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className={cn(
+ "px-2 py-0 text-sm truncate border-b bg-background",
+ isGrouped ? "bg-muted/20" : ""
+ )}
+ style={style}
+ >
+ {cell.getIsGrouped() ? (
+ // If this cell is grouped, usually we don't render it here if we have a group header row,
+ // but if we keep it, it acts as the expander for the next level (if multi-level grouping).
+ // Since we used a full-width row for the group header, this branch might not be hit for the group row itself,
+ // but for nested groups it might?
+ // Wait, row.getIsGrouped() is true for the group row.
+ // The cells inside the group row are not rendered because we return early above.
+ // The cells inside the "leaf" rows (normal rows) are rendered here.
+ // So cell.getIsGrouped() checks if the COLUMN is currently grouped.
+ // If the column is grouped, the cell value is usually redundant or hidden in normal rows.
+ // Standard practice: hide the cell content or dim it.
+ null
+ ) : cell.getIsAggregated() ? (
+ // If this cell is an aggregation of the group
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : (
+ // Normal cell
+ cell.getIsPlaceholder()
+ ? null
+ : flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ )
+ })}
+ </tr>
+ )
+ })
+ )}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
@@ -347,9 +594,9 @@ function ClientVirtualTableInner<TData, TValue>(
</table>
</DndContext>
</div>
-
+
{enablePagination && (
- <ClientDataTablePagination table={table} />
+ <ClientDataTablePagination table={table} />
)}
</div>
)