summaryrefslogtreecommitdiff
path: root/components/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/data-table/data-table-group-list.tsx
initial commit
Diffstat (limited to 'components/data-table/data-table-group-list.tsx')
-rw-r--r--components/data-table/data-table-group-list.tsx317
1 files changed, 317 insertions, 0 deletions
diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx
new file mode 100644
index 00000000..cde1cadd
--- /dev/null
+++ b/components/data-table/data-table-group-list.tsx
@@ -0,0 +1,317 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useQueryState, parseAsArrayOf, parseAsString } from "nuqs"
+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 DataTableGroupListProps<TData> {
+ /** TanStack Table 인스턴스 (grouping을 이미 사용할 수 있어야 함) */
+ table: Table<TData>
+ /** 정렬과 동일하게 URL 쿼리에 grouping을 저장할 때 쓰는 debounce 시간 (ms) */
+ debounceMs: number
+ /** shallow 라우팅 여부 */
+ shallow?: boolean
+}
+
+export function DataTableGroupList<TData>({
+ table,
+ debounceMs,
+ shallow,
+}: DataTableGroupListProps<TData>) {
+ const id = React.useId()
+
+ // ------------------------------------------------------
+ // 1) 초기 그룹핑 상태 + URL Query State 동기화
+ // ------------------------------------------------------
+ const initialGrouping = (table.initialState.grouping ?? []) as string[]
+
+ // group 쿼리 파라미터를 string[]로 파싱
+ // parseAsArrayOf(parseAsString, ',')를 이용
+ const [grouping, setGrouping] = useQueryState(
+ "group",
+ parseAsArrayOf(parseAsString, ",")
+ .withDefault(initialGrouping)
+ .withOptions({
+ clearOnDefault: true,
+ shallow,
+ })
+ )
+
+ // TanStack Table의 `table.setGrouping()`과 동기화
+ // (정렬 모달 예시에서 setSorting()을 쓰듯이 여기서는 setGrouping() 호출)
+ React.useEffect(() => {
+ table.setGrouping(grouping)
+ }, [grouping, table])
+
+ // 이미 중복 추가된 그룹은 제거
+ // (정렬 예시에서도 uniqueSorting 했듯이)
+ const uniqueGrouping = React.useMemo(
+ () => grouping.filter((id, i, self) => self.indexOf(id) === i),
+ [grouping]
+ )
+
+ // ------------------------------------------------------
+ // 2) 그룹핑 가능한 컬럼만 골라내기
+ // ------------------------------------------------------
+ const groupableColumns = React.useMemo(
+ () =>
+ table
+ .getAllColumns()
+ .filter((col) => col.getCanGroup?.() !== false)
+ .map((col) => ({
+ id: col.id,
+ label: toSentenceCase(col.id),
+ })),
+ [table]
+ )
+
+ // 이미 그룹핑 중인 컬럼 제외하고 "추가 가능"한 컬럼들
+ const ungroupedColumns = React.useMemo(() => {
+ return groupableColumns.filter(
+ (column) => !grouping.includes(column.id)
+ )
+ }, [groupableColumns, grouping])
+
+
+
+ // ------------------------------------------------------
+ // 3) 그룹 배열을 업데이트하는 함수들
+ // ------------------------------------------------------
+
+ // 드래그/드롭으로 순서 변경
+ function onGroupOrderChange(newGroups: string[]) {
+ setGrouping(newGroups)
+ }
+
+ // "Add group" : 아직 그룹핑되지 않은 첫 번째 컬럼 추가
+ function addGroup() {
+ const firstAvailable = ungroupedColumns[0]
+ if (!firstAvailable) return
+ setGrouping([...grouping, firstAvailable.id])
+ }
+
+ // 특정 아이템(그룹 컬럼 id) 제거
+ function removeGroup(id: string) {
+ setGrouping((prev) => prev.filter((g) => g !== id))
+ }
+
+ // 전체 그룹핑 초기화
+ function resetGrouping() {
+ setGrouping([])
+ }
+
+ // ------------------------------------------------------
+ // 4) 렌더링
+ // ------------------------------------------------------
+
+ return (
+ <Sortable
+ // sorting 예시처럼 Sortable 컨테이너로 감싸기
+ // 여기선 "grouping"을 바로 value로 넘길 수 없고,
+ // Sortable는 { id: UniqueIdentifier }[] 형태를 요구하므로 변환 필요
+ value={grouping.map((id) => ({ id }))}
+ onValueChange={(items) => {
+ // 드래그 완료 시 string[] 형태로 되돌림
+ onGroupOrderChange(items.map((i) => i.id))
+ }}
+ // overlay : 드래그 중 placeholder UI
+ 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"
+ aria-label="Open grouping"
+ aria-controls={`${id}-group-dialog`}
+ >
+ <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
+ id={`${id}-group-dialog`}
+ align="start"
+ collisionPadding={16}
+ className={cn(
+ "flex w-[calc(100vw-theme(spacing.20))] min-w-72 max-w-[25rem] origin-[var(--radix-popover-content-transform-origin)] flex-col p-4 sm:w-[25rem]",
+ grouping.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">
+ 그룹핑은 불러온 데이터에 한해서 그룹핑이 됩니다.
+ </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>
+ )}
+
+ {/* 그룹 목록 */}
+ <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) => {
+ // SortableItem에 key로 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">
+ {toSentenceCase(colId)}
+ </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) => {
+ // colId -> 새로 선택한 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>
+
+ <div className="flex w-full items-center gap-2">
+ {/* 새 그룹 추가 */}
+ <Button
+ size="sm"
+ className="h-[1.85rem] rounded"
+ onClick={addGroup}
+ disabled={grouping.length >= groupableColumns.length}
+ >
+ Add group
+ </Button>
+ {grouping.length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ className="rounded"
+ onClick={resetGrouping}
+ >
+ Reset grouping
+ </Button>
+ )}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </Sortable>
+ )
+} \ No newline at end of file