diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /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.tsx | 317 |
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 |
