summaryrefslogtreecommitdiff
path: root/components/client-data-table/data-table-group-list.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /components/client-data-table/data-table-group-list.tsx
initial commit
Diffstat (limited to 'components/client-data-table/data-table-group-list.tsx')
-rw-r--r--components/client-data-table/data-table-group-list.tsx279
1 files changed, 279 insertions, 0 deletions
diff --git a/components/client-data-table/data-table-group-list.tsx b/components/client-data-table/data-table-group-list.tsx
new file mode 100644
index 00000000..519b7327
--- /dev/null
+++ b/components/client-data-table/data-table-group-list.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Layers, Check, ChevronsUpDown, GripVertical, XCircle } from "lucide-react"
+
+import { toSentenceCase, cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import {
+ Command,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandInput,
+ CommandEmpty,
+} from "@/components/ui/command"
+import {
+ Sortable,
+ SortableItem,
+ SortableDragHandle,
+} from "@/components/ui/sortable"
+
+interface DataTableGroupListLocalProps<TData> {
+ /** TanStack Table 인스턴스 (grouping을 사용할 수 있어야 함) */
+ table: Table<TData>
+}
+
+export function ClientDataTableGroupList<TData>({
+ table,
+}: DataTableGroupListLocalProps<TData>) {
+ // -----------------------------
+ // 1) Local grouping state
+ // -----------------------------
+ const [grouping, setGrouping] = React.useState<string[]>(
+ (table.initialState.grouping as string[]) ?? []
+ )
+
+ // Keep the table grouping in sync
+ React.useEffect(() => {
+ table.setGrouping(grouping)
+ }, [grouping, table])
+
+ // Avoid duplicates (just in case)
+ const uniqueGrouping = React.useMemo(
+ () => [...new Set(grouping)],
+ [grouping]
+ )
+
+ // -----------------------------
+ // 2) Groupable columns
+ // -----------------------------
+ const groupableColumns = React.useMemo(
+ () =>
+ table
+ .getAllColumns()
+ .filter((col) => col.getCanGroup?.() !== false)
+ .map((col) => {
+ // If meta?.excelHeader is missing or undefined, fall back to `col.id`
+ const friendlyName =
+ typeof col.columnDef?.meta?.excelHeader === "string"
+ ? col.columnDef.meta.excelHeader
+ : col.id
+
+ return {
+ id: col.id,
+ // Ensure it's always a string, so no type error:
+ label: toSentenceCase(friendlyName),
+ }
+ }),
+ [table]
+ )
+
+ const ungroupedColumns = React.useMemo(
+ () => groupableColumns.filter((c) => !uniqueGrouping.includes(c.id)),
+ [groupableColumns, uniqueGrouping]
+ )
+
+ // -----------------------------
+ // 3) Handlers
+ // -----------------------------
+ // Add the first ungrouped column
+ function addGroup() {
+ const firstAvailable = ungroupedColumns[0]
+ if (!firstAvailable) return
+ setGrouping((prev) => [...prev, firstAvailable.id])
+ }
+
+ // Remove a group
+ function removeGroup(colId: string) {
+ setGrouping((prev) => prev.filter((g) => g !== colId))
+ }
+
+ // Reset grouping entirely
+ function resetGrouping() {
+ setGrouping([])
+ }
+
+ // Reorder groups via Sortable
+ function onGroupOrderChange(newGroups: string[]) {
+ setGrouping(newGroups)
+ }
+
+ // -----------------------------
+ // 4) Render
+ // -----------------------------
+ return (
+ <Sortable
+ value={uniqueGrouping.map((id) => ({ id }))}
+ onValueChange={(items) => onGroupOrderChange(items.map((i) => i.id))}
+ overlay={
+ <div className="flex items-center gap-2">
+ <div className="h-8 w-[11.25rem] rounded-sm bg-primary/10" />
+ <div className="h-8 w-24 rounded-sm bg-primary/10" />
+ <div className="size-8 shrink-0 rounded-sm bg-primary/10" />
+ </div>
+ }
+ >
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Layers className="size-3" aria-hidden="true" />
+ <span className="hidden sm:inline">Group</span>
+ {uniqueGrouping.length > 0 && (
+ <Badge
+ variant="secondary"
+ className="h-[1.14rem] rounded-[0.2rem] px-[0.32rem] font-mono text-[0.65rem] font-normal"
+ >
+ {uniqueGrouping.length}
+ </Badge>
+ )}
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] flex-col p-4 sm:w-[25rem]",
+ uniqueGrouping.length > 0 ? "gap-3.5" : "gap-2"
+ )}
+ >
+ {uniqueGrouping.length > 0 ? (
+ <>
+ <h4 className="font-medium leading-none">Group by</h4>
+ <p className="text-sm text-muted-foreground">
+ Grouping is applied to the currently loaded data only.
+ </p>
+ </>
+ ) : (
+ <div className="flex flex-col gap-1">
+ <h4 className="font-medium leading-none">No grouping applied</h4>
+ <p className="text-sm text-muted-foreground">
+ Add grouping to organize your results.
+ </p>
+ </div>
+ )}
+
+ {/* Current groups */}
+ <div className="flex max-h-40 flex-col gap-2 overflow-y-auto p-0.5">
+ <div className="flex w-full flex-col gap-2">
+ {uniqueGrouping.map((colId) => {
+ // Find the column's friendly label
+ const colDef = groupableColumns.find((c) => c.id === colId)
+ const label = colDef?.label ?? toSentenceCase(colId)
+
+ return (
+ <SortableItem key={colId} value={colId} asChild>
+ <div className="flex items-center gap-2">
+ <Popover modal>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ role="combobox"
+ className="h-8 w-44 justify-between gap-2 rounded focus:outline-none focus:ring-1 focus:ring-ring"
+ aria-label={`Select column for group ${colId}`}
+ >
+ <span className="truncate">{label}</span>
+ <div className="ml-auto flex items-center gap-1">
+ <ChevronsUpDown
+ className="size-4 shrink-0 opacity-50"
+ aria-hidden="true"
+ />
+ </div>
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-[var(--radix-popover-trigger-width)] p-0"
+ >
+ <Command>
+ <CommandInput placeholder="Search columns..." />
+ <CommandList>
+ <CommandEmpty>No columns found.</CommandEmpty>
+ <CommandGroup>
+ {ungroupedColumns.map((column) => (
+ <CommandItem
+ key={column.id}
+ value={column.id}
+ onSelect={(value) => {
+ // Replace colId with new value
+ setGrouping((prev) =>
+ prev.map((g) => (g === colId ? value : g))
+ )
+ }}
+ >
+ <span className="mr-1.5 truncate">
+ {column.label}
+ </span>
+ <Check
+ className={cn(
+ "ml-auto size-4 shrink-0",
+ column.id === colId
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ aria-hidden="true"
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* remove group */}
+ <Button
+ variant="outline"
+ size="icon"
+ aria-label={`Remove group ${colId}`}
+ className="size-8 shrink-0 rounded"
+ onClick={() => removeGroup(colId)}
+ >
+ <XCircle className="size-3.5" aria-hidden="true" />
+ </Button>
+
+ {/* drag handle */}
+ <SortableDragHandle
+ variant="outline"
+ size="icon"
+ className="size-8 shrink-0 rounded"
+ >
+ <GripVertical className="size-3.5" aria-hidden="true" />
+ </SortableDragHandle>
+ </div>
+ </SortableItem>
+ )
+ })}
+ </div>
+ </div>
+
+ {/* Footer: "Add group" & "Reset grouping" */}
+ <div className="flex w-full items-center gap-2">
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addGroup}
+ disabled={uniqueGrouping.length >= groupableColumns.length}
+ >
+ Add group
+ </Button>
+ {uniqueGrouping.length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ className="rounded"
+ onClick={resetGrouping}
+ >
+ Reset grouping
+ </Button>
+ )}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+} \ No newline at end of file