From 71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 8 Dec 2025 12:08:00 +0900 Subject: (김준회) 테이블 커스텀 훅 버전 생성 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/client-table-v3/GUIDE.md | 99 +++++++ .../client-table-v3/client-table-column-header.tsx | 237 ++++++++++++++++ components/client-table-v3/client-table-filter.tsx | 103 +++++++ components/client-table-v3/client-table-preset.tsx | 189 +++++++++++++ .../client-table-v3/client-virtual-table.tsx | 309 +++++++++++++++++++++ components/client-table-v3/index.ts | 9 + components/client-table-v3/preset-actions.ts | 84 ++++++ components/client-table-v3/preset-types.ts | 15 + components/client-table-v3/types.ts | 84 ++++++ components/client-table-v3/use-client-table.ts | 283 +++++++++++++++++++ 10 files changed, 1412 insertions(+) create mode 100644 components/client-table-v3/GUIDE.md create mode 100644 components/client-table-v3/client-table-column-header.tsx create mode 100644 components/client-table-v3/client-table-filter.tsx create mode 100644 components/client-table-v3/client-table-preset.tsx create mode 100644 components/client-table-v3/client-virtual-table.tsx create mode 100644 components/client-table-v3/index.ts create mode 100644 components/client-table-v3/preset-actions.ts create mode 100644 components/client-table-v3/preset-types.ts create mode 100644 components/client-table-v3/types.ts create mode 100644 components/client-table-v3/use-client-table.ts (limited to 'components/client-table-v3') diff --git a/components/client-table-v3/GUIDE.md b/components/client-table-v3/GUIDE.md new file mode 100644 index 00000000..05d7455e --- /dev/null +++ b/components/client-table-v3/GUIDE.md @@ -0,0 +1,99 @@ +# ClientVirtualTable V3 Guide + +This version introduces the `useClientTable` hook to drastically reduce boilerplate code and improve Developer Experience (DX). + +## Key Changes from V2 +- **`useClientTable` Hook**: Manages all state (sorting, filtering, pagination, grouping) and data fetching (Client or Server). +- **Cleaner Component**: `ClientVirtualTable` now accepts a `table` instance prop, making it a pure renderer. +- **Better Separation**: Logic is in the hook; UI is in the component. + +## Usage + +### 1. Client-Side Mode +Load all data once, let the hook handle the rest. + +```tsx +import { useClientTable, ClientVirtualTable } from "@/components/client-table-v3"; + +function MyTable() { + const [data, setData] = useState([]); + + // Load data... + + const { table, isLoading } = useClientTable({ + fetchMode: "client", + data, + columns, + enablePagination: true, // Auto-enabled + }); + + return ; +} +``` + +### 2. Server-Side Mode (Factory Service) +Pass your server action as the `fetcher`. The hook handles debouncing and refetching. + +```tsx +import { useClientTable, ClientVirtualTable } from "@/components/client-table-v3"; +import { myServerAction } from "./actions"; + +function MyServerTable() { + const { table, isLoading } = useClientTable({ + fetchMode: "server", + fetcher: myServerAction, // Must accept TableState + columns, + enablePagination: true, + }); + + return ; +} +``` + +### 3. Server Grouping (Pattern 2-B) +The hook detects server-side grouping responses and provides them separately. + +```tsx +const { table, isLoading, isServerGrouped, serverGroups } = useClientTable({ + fetchMode: "server", + fetcher: myGroupFetcher, + columns, + enableGrouping: true, +}); + +if (isServerGrouped) { + return ; +} + +return ; +``` + +## Hook Options (`useClientTable`) + +| Option | Type | Description | +|--------|------|-------------| +| `fetchMode` | `'client' \| 'server'` | Default `'client'`. | +| `data` | `TData[]` | Data for client mode. | +| `fetcher` | `(state) => Promise` | Server action for server mode. | +| `columns` | `ColumnDef[]` | Column definitions. | +| `initialState` | `object` | Initial sorting, filters, etc. | +| `enablePagination` | `boolean` | Enable pagination logic. | +| `enableGrouping` | `boolean` | Enable grouping logic. | + +## Component Props (`ClientVirtualTable`) + +| Prop | Type | Description | +|------|------|-------------| +| `table` | `Table` | The table instance from the hook. | +| `isLoading` | `boolean` | Shows loading overlay. | +| `height` | `string` | Table height (required for virtualization). | +| `enableUserPreset` | `boolean` | Enable saving/loading view presets. | +| `tableKey` | `string` | Unique key for presets. | + +## Migration from V2 + +1. Replace `` with `const { table } = useClientTable({...}); `. +2. Remove local state (`sorting`, `pagination`, `useEffect` for fetching) from your page component. +3. Pass `fetcher` directly to the hook. + + diff --git a/components/client-table-v3/client-table-column-header.tsx b/components/client-table-v3/client-table-column-header.tsx new file mode 100644 index 00000000..3be20565 --- /dev/null +++ b/components/client-table-v3/client-table-column-header.tsx @@ -0,0 +1,237 @@ +"use client" + +import * as React from "react" +import { Header, Column } from "@tanstack/react-table" +import { useSortable } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { flexRender } from "@tanstack/react-table" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { + ArrowDown, + ArrowUp, + ChevronsUpDown, + EyeOff, + PinOff, + MoveLeft, + MoveRight, + Group, + Ungroup, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { ClientTableFilter } from "./client-table-filter" + +interface ClientTableColumnHeaderProps + extends React.HTMLAttributes { + header: Header + enableReordering?: boolean + renderHeaderVisualFeedback?: (props: { + column: Column + isPinned: boolean | string + isSorted: boolean | string + isFiltered: boolean + isGrouped: boolean + }) => React.ReactNode +} + +export function ClientTableColumnHeader({ + header, + enableReordering = true, + renderHeaderVisualFeedback, + className, + ...props +}: ClientTableColumnHeaderProps) { + const column = header.column + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: header.id, + disabled: !enableReordering || column.getIsResizing(), + }) + + // -- Styles -- + const style: React.CSSProperties = { + // Apply transform only if reordering is enabled and active + transform: enableReordering ? CSS.Translate.toString(transform) : undefined, + transition: enableReordering ? transition : undefined, + width: header.getSize(), + zIndex: isDragging ? 100 : 0, + position: "relative", + ...props.style, + } + + // Pinning Styles + const isPinned = column.getIsPinned() + const isSorted = column.getIsSorted() + const isFiltered = column.getFilterValue() !== undefined + const isGrouped = column.getIsGrouped() + + if (isPinned === "left") { + style.left = `${column.getStart("left")}px` + style.position = "sticky" + style.zIndex = 30 // Pinned columns needs to be higher than normal headers + } else if (isPinned === "right") { + style.right = `${column.getAfter("right")}px` + style.position = "sticky" + style.zIndex = 30 // Pinned columns needs to be higher than normal headers + } + + // -- Handlers -- + const handleHide = () => column.toggleVisibility(false) + const handlePinLeft = () => column.pin("left") + const handlePinRight = () => column.pin("right") + const handleUnpin = () => column.pin(false) + const handleToggleGrouping = () => column.toggleGrouping() + + // -- Content -- + const content = ( + <> +
+ {flexRender(column.columnDef.header, header.getContext())} + {column.getCanSort() && ( + + {column.getIsSorted() === "desc" ? ( + + ) : column.getIsSorted() === "asc" ? ( + + ) : ( + + )} + + )} + {isGrouped && } +
+ + {/* Resize Handle */} +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} // Prevent sort trigger + className={cn( + "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10", + "after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-border", // 시각적 구분선 + "hover:bg-primary/20 hover:w-4 hover:-right-2", // 호버 시 클릭 영역 확장 + header.column.getIsResizing() ? "bg-primary/50 w-1" : "bg-transparent" + )} + /> + + {/* Filter */} + {column.getCanFilter() && } + + {/* Visual Feedback Indicators */} + {renderHeaderVisualFeedback ? ( + renderHeaderVisualFeedback({ + column, + isPinned, + isSorted, + isFiltered, + isGrouped, + }) + ) : ( + (isPinned || isFiltered || isGrouped) && ( +
+ {isPinned &&
} + {isFiltered &&
} + {isGrouped &&
} +
+ ) + )} + + ) + + if (header.isPlaceholder) { + return ( + + {null} + + ) + } + + return ( + + + + {content} + + + + + + Hide Column + + + {column.getCanGroup() && ( + <> + + + {isGrouped ? ( + <> + + Ungroup + + ) : ( + <> + + Group by {column.id} + + )} + + + )} + + + + + Pin Left + + + + Pin Right + + {isPinned && ( + + + Unpin + + )} + + + ) +} + + diff --git a/components/client-table-v3/client-table-filter.tsx b/components/client-table-v3/client-table-filter.tsx new file mode 100644 index 00000000..eaf6b31e --- /dev/null +++ b/components/client-table-v3/client-table-filter.tsx @@ -0,0 +1,103 @@ +"use client" + +import * as React from "react" +import { Column } from "@tanstack/react-table" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ClientTableColumnMeta } from "./types" + +interface ClientTableFilterProps { + column: Column +} + +export function ClientTableFilter({ + column, +}: ClientTableFilterProps) { + const columnFilterValue = column.getFilterValue() + // Cast meta to our local type + const meta = column.columnDef.meta as ClientTableColumnMeta | undefined + + // Handle Boolean Filter + if (meta?.filterType === "boolean") { + return ( +
e.stopPropagation()} className="mt-2"> + +
+ ) + } + + // Handle Select Filter (for specific options) + if (meta?.filterType === "select" && meta.filterOptions) { + return ( +
e.stopPropagation()} className="mt-2"> + +
+ ) + } + + // Default Text Filter + const [value, setValue] = React.useState(columnFilterValue) + + React.useEffect(() => { + setValue(columnFilterValue) + }, [columnFilterValue]) + + React.useEffect(() => { + const timeout = setTimeout(() => { + column.setFilterValue(value) + }, 500) + + return () => clearTimeout(timeout) + }, [value, column]) + + return ( +
e.stopPropagation()} className="mt-2"> + setValue(e.target.value)} + placeholder="Search..." + className="h-8 w-full font-normal bg-background" + /> +
+ ) +} + + diff --git a/components/client-table-v3/client-table-preset.tsx b/components/client-table-v3/client-table-preset.tsx new file mode 100644 index 00000000..557e8493 --- /dev/null +++ b/components/client-table-v3/client-table-preset.tsx @@ -0,0 +1,189 @@ +"use client"; + +import * as React from "react"; +import { Table } from "@tanstack/react-table"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Bookmark, Save, Trash2 } from "lucide-react"; +import { + getPresets, + savePreset, + deletePreset, +} from "./preset-actions"; +import { Preset } from "./preset-types"; +import { toast } from "sonner"; + +interface ClientTablePresetProps { + table: Table; + tableKey: string; +} + +export function ClientTablePreset({ + table, + tableKey, +}: ClientTablePresetProps) { + const { data: session } = useSession(); + const [savedPresets, setSavedPresets] = React.useState([]); + const [isPresetDialogOpen, setIsPresetDialogOpen] = React.useState(false); + const [newPresetName, setNewPresetName] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchSettings = React.useCallback(async () => { + const userIdVal = session?.user?.id; + if (!userIdVal) return; + + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + const res = await getPresets(tableKey, userId); + if (res.success && res.data) { + setSavedPresets(res.data); + } + }, [session, tableKey]); + + React.useEffect(() => { + if (session) { + fetchSettings(); + } + }, [fetchSettings, session]); + + const handleSavePreset = async () => { + const userIdVal = session?.user?.id; + if (!newPresetName.trim() || !userIdVal) return; + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + setIsLoading(true); + const state = table.getState(); + const settingToSave = { + sorting: state.sorting, + columnFilters: state.columnFilters, + globalFilter: state.globalFilter, + columnVisibility: state.columnVisibility, + columnPinning: state.columnPinning, + columnOrder: state.columnOrder, + grouping: state.grouping, + pagination: { pageSize: state.pagination.pageSize }, + }; + + const res = await savePreset(userId, tableKey, newPresetName, settingToSave); + setIsLoading(false); + + if (res.success) { + toast.success("Preset saved successfully"); + setIsPresetDialogOpen(false); + setNewPresetName(""); + fetchSettings(); + } else { + toast.error("Failed to save preset"); + } + }; + + const handleLoadPreset = (preset: Preset) => { + const s = preset.setting as Record; + if (!s) return; + + if (s.sorting) table.setSorting(s.sorting); + if (s.columnFilters) table.setColumnFilters(s.columnFilters); + if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter); + if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility); + if (s.columnPinning) table.setColumnPinning(s.columnPinning); + if (s.columnOrder) table.setColumnOrder(s.columnOrder); + if (s.grouping) table.setGrouping(s.grouping); + if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize); + // Reset page index to avoid loading an out-of-range page after applying a preset + table.setPageIndex(0); + + toast.success(`Preset "${preset.name}" loaded`); + }; + + const handleDeletePreset = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (!confirm("Are you sure you want to delete this preset?")) return; + + const res = await deletePreset(id); + if (res.success) { + toast.success("Preset deleted"); + fetchSettings(); + } else { + toast.error("Failed to delete preset"); + } + }; + + if (!session) return null; + + return ( + <> + + + + + + Saved Presets + + {savedPresets.length === 0 ? ( +
No saved presets
+ ) : ( + savedPresets.map((preset) => ( + handleLoadPreset(preset)} className="flex justify-between cursor-pointer"> + {preset.name} + + + )) + )} + + setIsPresetDialogOpen(true)} className="cursor-pointer"> + + Save Current Preset + +
+
+ + + + + Save Preset + + Save the current table configuration as a preset. + + +
+ setNewPresetName(e.target.value)} + /> +
+ + + + +
+
+ + ); +} + + diff --git a/components/client-table-v3/client-virtual-table.tsx b/components/client-table-v3/client-virtual-table.tsx new file mode 100644 index 00000000..7a092326 --- /dev/null +++ b/components/client-table-v3/client-virtual-table.tsx @@ -0,0 +1,309 @@ +"use client"; + +import * as React from "react"; +import { Table, flexRender } from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { cn } from "@/lib/utils"; +import { Loader2, ChevronRight, ChevronDown } from "lucide-react"; + +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 { ClientTableViewOptions } from "../client-table/client-table-view-options"; + +import { ClientTableColumnHeader } from "./client-table-column-header"; +import { ClientTablePreset } from "./client-table-preset"; +import { ClientVirtualTableProps } from "./types"; + +export function ClientVirtualTable({ + table, + isLoading = false, + height = "100%", + estimateRowHeight = 40, + className, + actions, + customToolbar, + enableExport = true, + onExport, + enableUserPreset = false, + tableKey, + getRowClassName, + onRowClick, + renderHeaderVisualFeedback, +}: ClientVirtualTableProps) { + // --- DnD Sensors --- + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor) + ); + + // --- Drag Handler --- + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (active && over && active.id !== over.id) { + 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 + if (activePinState !== overPinState) { + activeColumn.pin(overPinState); + } + + // Reorder + const currentOrder = table.getState().columnOrder; + const oldIndex = currentOrder.indexOf(activeId); + const newIndex = currentOrder.indexOf(overId); + + if (oldIndex !== -1 && newIndex !== -1) { + table.setColumnOrder(arrayMove(currentOrder, oldIndex, newIndex)); + } + } + } + }; + + // --- Virtualization --- + const tableContainerRef = React.useRef(null); + const { rows } = table.getRowModel(); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => estimateRowHeight, + overscan: 10, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; + + // --- Export --- + const handleExport = async () => { + if (onExport) { + onExport(table.getFilteredRowModel().rows.map((r) => r.original)); + return; + } + const currentData = table.getFilteredRowModel().rows.map((row) => row.original); + // Note: exportToExcel needs columns definition. table.getAllColumns() or visible columns? + // Using table.getAllLeafColumns() usually. + await exportToExcel(currentData, table.getAllLeafColumns(), `export-${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + + const columns = table.getVisibleLeafColumns(); + const data = table.getFilteredRowModel().rows; // or just rows which is from getRowModel + + return ( +
+ + + {enableUserPreset && tableKey && ( + + )} + + } + customToolbar={customToolbar} + actions={actions} + /> + +
+ {isLoading && ( +
+ +
+ )} + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + h.id)} + strategy={horizontalListSortingStrategy} + > + {headerGroup.headers.map((header) => ( + + ))} + + + ))} + + + {paddingTop > 0 && ( + + + )} + {virtualRows.length === 0 && !isLoading ? ( + + + + ) : ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + + // --- Group Header Rendering --- + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? ""; + const groupingValue = row.getGroupingValue(groupingColumnId); + + return ( + + + + ); + } + + // --- Normal Row Rendering --- + return ( + onRowClick?.(row, e)} + > + {row.getVisibleCells().map((cell) => { + 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 ( + + ); + })} + + ); + }) + )} + {paddingBottom > 0 && ( + + + )} + +
+
+ No results. +
+
+ {row.getIsExpanded() ? ( + + ) : ( + + )} + + + {groupingColumnId}: + + + {String(groupingValue)} + + + ({row.subRows.length}) + + +
+
+ {cell.getIsGrouped() ? null : cell.getIsAggregated() ? ( + flexRender( + cell.column.columnDef.aggregatedCell ?? + cell.column.columnDef.cell, + cell.getContext() + ) + ) : cell.getIsPlaceholder() ? null : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} +
+
+
+
+ + +
+ ); +} + + diff --git a/components/client-table-v3/index.ts b/components/client-table-v3/index.ts new file mode 100644 index 00000000..678a4757 --- /dev/null +++ b/components/client-table-v3/index.ts @@ -0,0 +1,9 @@ +export * from "./client-virtual-table"; +export * from "./use-client-table"; +export * from "./types"; +export * from "./client-table-column-header"; +export * from "./client-table-filter"; +export * from "./client-table-preset"; +export * from "./preset-types"; + + diff --git a/components/client-table-v3/preset-actions.ts b/components/client-table-v3/preset-actions.ts new file mode 100644 index 00000000..3ef4d239 --- /dev/null +++ b/components/client-table-v3/preset-actions.ts @@ -0,0 +1,84 @@ +"use server"; + +import db from "@/db/db"; +import { userCustomData } from "@/db/schema/user-custom-data/userCustomData"; +import { eq, and } from "drizzle-orm"; +import { Preset } from "./preset-types"; + +export async function getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }> { + try { + const settings = await db + .select() + .from(userCustomData) + .where( + and( + eq(userCustomData.tableKey, tableKey), + eq(userCustomData.userId, userId) + ) + ) + .orderBy(userCustomData.createdDate); + + const data: Preset[] = settings.map(s => ({ + id: s.id, + name: s.customSettingName, + setting: s.customSetting, + createdAt: s.createdDate, + updatedAt: s.updatedDate, + })); + + return { success: true, data }; + } catch (error) { + console.error("Failed to fetch presets:", error); + return { success: false, error: "Failed to fetch presets" }; + } +} + +export async function savePreset( + userId: number, + tableKey: string, + name: string, + setting: any +): Promise<{ success: boolean; error?: string }> { + try { + const existing = await db.query.userCustomData.findFirst({ + where: and( + eq(userCustomData.userId, userId), + eq(userCustomData.tableKey, tableKey), + eq(userCustomData.customSettingName, name) + ) + }); + + if (existing) { + await db.update(userCustomData) + .set({ + customSetting: setting, + updatedDate: new Date() + }) + .where(eq(userCustomData.id, existing.id)); + } else { + await db.insert(userCustomData).values({ + userId, + tableKey, + customSettingName: name, + customSetting: setting, + }); + } + + return { success: true }; + } catch (error) { + console.error("Failed to save preset:", error); + return { success: false, error: "Failed to save preset" }; + } +} + +export async function deletePreset(id: string): Promise<{ success: boolean; error?: string }> { + try { + await db.delete(userCustomData).where(eq(userCustomData.id, id)); + return { success: true }; + } catch (error) { + console.error("Failed to delete preset:", error); + return { success: false, error: "Failed to delete preset" }; + } +} + + diff --git a/components/client-table-v3/preset-types.ts b/components/client-table-v3/preset-types.ts new file mode 100644 index 00000000..37177cff --- /dev/null +++ b/components/client-table-v3/preset-types.ts @@ -0,0 +1,15 @@ +export interface Preset { + id: string; + name: string; + setting: any; // JSON object for table state + createdAt: Date; + updatedAt: Date; +} + +export interface PresetRepository { + getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }>; + savePreset(userId: number, tableKey: string, name: string, setting: any): Promise<{ success: boolean; error?: string }>; + deletePreset(id: string): Promise<{ success: boolean; error?: string }>; +} + + diff --git a/components/client-table-v3/types.ts b/components/client-table-v3/types.ts new file mode 100644 index 00000000..4f2d8c82 --- /dev/null +++ b/components/client-table-v3/types.ts @@ -0,0 +1,84 @@ +import { + ColumnDef, + RowData, + Table, + PaginationState, + SortingState, + ColumnFiltersState, + VisibilityState, + ColumnPinningState, + ColumnOrderState, + GroupingState, + ExpandedState, + RowSelectionState, + OnChangeFn, + Row, + Column, +} from "@tanstack/react-table"; + +// --- Column Meta --- +export interface ClientTableColumnMeta { + filterType?: "text" | "select" | "boolean" | "date-range"; // Added date-range + filterOptions?: { label: string; value: string }[]; + serverGroupable?: boolean; // For Pattern 2-B +} + +export type ClientTableColumnDef = ColumnDef & { + meta?: ClientTableColumnMeta; +}; + +// --- Fetcher Types --- +export interface TableState { + pagination: PaginationState; + sorting: SortingState; + columnFilters: ColumnFiltersState; + globalFilter: string; + grouping: GroupingState; + expanded: ExpandedState; +} + +export interface FetcherResult { + data: TData[]; + totalRows: number; + pageCount?: number; + groups?: any[]; // For grouping response +} + +export type TableFetcher = ( + state: TableState, + additionalArgs?: any +) => Promise>; + +// --- Component Props --- +export interface ClientVirtualTableProps { + table: Table; + isLoading?: boolean; + height?: string | number; + estimateRowHeight?: number; + className?: string; + + // UI Features + actions?: React.ReactNode; + customToolbar?: React.ReactNode; + enableExport?: boolean; + onExport?: (data: TData[]) => void; + + // Preset + enableUserPreset?: boolean; + tableKey?: string; + + // Styling + getRowClassName?: (originalRow: TData, index: number) => string; + onRowClick?: (row: Row, event: React.MouseEvent) => void; + + // Visuals + renderHeaderVisualFeedback?: (props: { + column: Column; + isPinned: boolean | string; + isSorted: boolean | string; + isFiltered: boolean; + isGrouped: boolean; + }) => React.ReactNode; +} + + diff --git a/components/client-table-v3/use-client-table.ts b/components/client-table-v3/use-client-table.ts new file mode 100644 index 00000000..87ce8a78 --- /dev/null +++ b/components/client-table-v3/use-client-table.ts @@ -0,0 +1,283 @@ +import * as React from "react"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, + getGroupedRowModel, + getExpandedRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, + Table, + ColumnDef, + SortingState, + ColumnFiltersState, + PaginationState, + VisibilityState, + ColumnPinningState, + ColumnOrderState, + GroupingState, + ExpandedState, + RowSelectionState, + OnChangeFn, + FilterFn, +} from "@tanstack/react-table"; +import { rankItem } from "@tanstack/match-sorter-utils"; +import { TableFetcher, FetcherResult } from "./types"; + +// --- Utils --- +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value); + addMeta({ itemRank }); + return itemRank.passed; +}; + +// Simple debounce hook +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => clearTimeout(handler); + }, [value, delay]); + return debouncedValue; +} + +// --- Props --- +export interface UseClientTableProps { + // Data Source + data?: TData[]; // For client mode + fetcher?: TableFetcher; // For server mode + fetchMode?: "client" | "server"; + + // Columns + columns: ColumnDef[]; + + // Options + enableGrouping?: boolean; + enablePagination?: boolean; + enableRowSelection?: boolean | ((row: any) => boolean); + enableMultiRowSelection?: boolean | ((row: any) => boolean); + + // Initial State (Optional overrides) + initialState?: { + pagination?: PaginationState; + sorting?: SortingState; + columnFilters?: ColumnFiltersState; + globalFilter?: string; + columnVisibility?: VisibilityState; + columnPinning?: ColumnPinningState; + columnOrder?: ColumnOrderState; + grouping?: GroupingState; + expanded?: ExpandedState; + rowSelection?: RowSelectionState; + }; + + // Callbacks + onDataChange?: (data: TData[]) => void; + onError?: (error: any) => void; + + // Custom Row ID + getRowId?: (originalRow: TData, index: number, parent?: any) => string; +} + +// --- Hook --- +export function useClientTable({ + data: initialData = [], + fetcher, + fetchMode = "client", + columns, + enableGrouping = false, + enablePagination = true, + enableRowSelection, + enableMultiRowSelection, + initialState, + onDataChange, + onError, + getRowId, +}: UseClientTableProps) { + // 1. State Definitions + const [sorting, setSorting] = React.useState(initialState?.sorting ?? []); + const [columnFilters, setColumnFilters] = React.useState(initialState?.columnFilters ?? []); + const [globalFilter, setGlobalFilter] = React.useState(initialState?.globalFilter ?? ""); + const [pagination, setPagination] = React.useState( + initialState?.pagination ?? { pageIndex: 0, pageSize: 10 } + ); + const [grouping, setGrouping] = React.useState(initialState?.grouping ?? []); + const [expanded, setExpanded] = React.useState(initialState?.expanded ?? {}); + const [rowSelection, setRowSelection] = React.useState(initialState?.rowSelection ?? {}); + const [columnVisibility, setColumnVisibility] = React.useState(initialState?.columnVisibility ?? {}); + const [columnPinning, setColumnPinning] = React.useState( + initialState?.columnPinning ?? { left: [], right: [] } + ); + const [columnOrder, setColumnOrder] = React.useState( + initialState?.columnOrder ?? columns.map((c) => c.id || (c as any).accessorKey) as string[] + ); + + // 2. Data State + const [data, setData] = React.useState(initialData); + const [totalRows, setTotalRows] = React.useState(initialData.length); + const [pageCount, setPageCount] = React.useState(-1); + const [isLoading, setIsLoading] = React.useState(false); + + // Grouping specific data + // In Pattern 2-B, the server returns "groups" instead of flat data. + // We might need to store that separately or handle it within data if using TanStack's mix. + // For now, let's assume the fetcher returns a flat array or we handle groups manually in the component. + // But wait, client-virtual-table V2 handles groups by checking row.getIsGrouped(). + // If fetchMode is server, and grouping is active, the server might return "groups". + // The V2 implementation handled this by setting `groups` state in the consumer and switching rendering. + // We want to encapsulate this. + const [serverGroups, setServerGroups] = React.useState([]); + const [isServerGrouped, setIsServerGrouped] = React.useState(false); + + const isServer = fetchMode === "server"; + + // Debounced states for fetching to avoid rapid-fire requests + const debouncedGlobalFilter = useDebounce(globalFilter, 300); + const debouncedColumnFilters = useDebounce(columnFilters, 300); + // Pagination and Sorting don't need debounce usually, but grouping might. + + // 3. Data Fetching (Server Mode) + const refresh = React.useCallback(async () => { + if (!isServer || !fetcher) return; + + setIsLoading(true); + try { + const result = await fetcher({ + pagination, + sorting, + columnFilters: debouncedColumnFilters, + globalFilter: debouncedGlobalFilter, + grouping, + expanded, + }); + + if (result.groups) { + setServerGroups(result.groups); + setIsServerGrouped(true); + setData([]); // Clear flat data + } else { + setData(result.data); + setTotalRows(result.totalRows); + setPageCount(result.pageCount ?? -1); + setServerGroups([]); + setIsServerGrouped(false); + if (onDataChange) onDataChange(result.data); + } + } catch (err) { + console.error("Failed to fetch table data:", err); + if (onError) onError(err); + } finally { + setIsLoading(false); + } + }, [ + isServer, + fetcher, + pagination, + sorting, + debouncedColumnFilters, + debouncedGlobalFilter, + grouping, + expanded, + onDataChange, + onError, + ]); + + // Initial fetch and refetch on state change + React.useEffect(() => { + if (isServer) { + refresh(); + } + }, [refresh, isServer]); + + // Update data when props change in Client Mode + React.useEffect(() => { + if (!isServer) { + setData(initialData); + setTotalRows(initialData.length); + } + }, [initialData, isServer]); + + // 4. TanStack Table Instance + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + columnVisibility, + columnPinning, + columnOrder, + rowSelection, + grouping, + expanded, + }, + // Server-side Flags + manualPagination: isServer, + manualSorting: isServer, + manualFiltering: isServer, + manualGrouping: isServer, + + // Counts + pageCount: isServer ? pageCount : undefined, + rowCount: isServer ? totalRows : undefined, + + // Handlers + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + onColumnVisibilityChange: setColumnVisibility, + onColumnPinningChange: setColumnPinning, + onColumnOrderChange: setColumnOrder, + onRowSelectionChange: setRowSelection, + onGroupingChange: setGrouping, + onExpandedChange: setExpanded, + + // Configs + enableRowSelection, + enableMultiRowSelection, + enableGrouping, + getCoreRowModel: getCoreRowModel(), + + // Conditional Models (Client vs Server) + getFilteredRowModel: !isServer ? getFilteredRowModel() : undefined, + getFacetedRowModel: !isServer ? getFacetedRowModel() : undefined, + getFacetedUniqueValues: !isServer ? getFacetedUniqueValues() : undefined, + getFacetedMinMaxValues: !isServer ? getFacetedMinMaxValues() : undefined, + getSortedRowModel: !isServer ? getSortedRowModel() : undefined, + getGroupedRowModel: (!isServer && enableGrouping) ? getGroupedRowModel() : undefined, + getExpandedRowModel: (!isServer && enableGrouping) ? getExpandedRowModel() : undefined, + getPaginationRowModel: (!isServer && enablePagination) ? getPaginationRowModel() : undefined, + + columnResizeMode: "onChange", + filterFns: { + fuzzy: fuzzyFilter, + }, + globalFilterFn: fuzzyFilter, + getRowId, + }); + + return { + table, + data, + totalRows, + isLoading, + isServerGrouped, + serverGroups, + refresh, + // State setters if needed manually + setSorting, + setColumnFilters, + setPagination, + setGlobalFilter, + }; +} + + -- cgit v1.2.3