chore(ui): удалить старую основу Data Grid и TanStack v8
This commit is contained in:
1 parent
bdba83b2b6
commit
a475b5c6df
20 files changed
+22
-4272
No files matched your search
@@ -1,34 +0,0 @@
|
|||||||
import type { DataGridProps } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
|
|
||||||
const DATA_GRID_CELL_PAD = "py-3"
|
|
||||||
const DATA_GRID_CELL_PAD_FIRST = "pl-5 py-3"
|
|
||||||
const DATA_GRID_CELL_PAD_LAST = "pr-4 py-3"
|
|
||||||
|
|
||||||
const DEFAULT_DATA_GRID_LAYOUT: NonNullable<DataGridProps<object>["tableLayout"]> = {
|
|
||||||
rowBorder: true,
|
|
||||||
headerBackground: true,
|
|
||||||
headerBorder: true,
|
|
||||||
columnsResizable: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableClassNames"]> = {
|
|
||||||
headerRow: "border-b border-border",
|
|
||||||
bodyRow: "group/row",
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATA_GRID_CONTAINER_CLASS = "w-full rounded-none border-0"
|
|
||||||
|
|
||||||
const DATA_PAGE_CARD_CLASS = "flex w-full flex-col overflow-hidden py-0 gap-0"
|
|
||||||
|
|
||||||
const DATA_PAGE_TOOLBAR_CLASS = "flex items-center gap-3 px-5 py-3 border-b flex-wrap"
|
|
||||||
|
|
||||||
export {
|
|
||||||
DATA_GRID_CELL_PAD,
|
|
||||||
DATA_GRID_CELL_PAD_FIRST,
|
|
||||||
DATA_GRID_CELL_PAD_LAST,
|
|
||||||
DEFAULT_DATA_GRID_LAYOUT,
|
|
||||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
|
||||||
DATA_GRID_CONTAINER_CLASS,
|
|
||||||
DATA_PAGE_CARD_CLASS,
|
|
||||||
DATA_PAGE_TOOLBAR_CLASS,
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import type { Table } from "@tanstack/react-table-v8"
|
|
||||||
import {
|
|
||||||
DataGrid,
|
|
||||||
DataGridContainer,
|
|
||||||
DataGridPagination,
|
|
||||||
DataGridTable,
|
|
||||||
type DataGridProps,
|
|
||||||
} from "@/components/reui/legacy-data-grid"
|
|
||||||
import {
|
|
||||||
DATA_GRID_CONTAINER_CLASS,
|
|
||||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
|
||||||
DEFAULT_DATA_GRID_LAYOUT,
|
|
||||||
} from "@/components/data-grids/shared/legacy-data-grid-layout"
|
|
||||||
|
|
||||||
interface DataGridShellProps<TData extends object> {
|
|
||||||
table: Table<TData>
|
|
||||||
recordCount: number
|
|
||||||
children?: ReactNode
|
|
||||||
pagination?: boolean
|
|
||||||
isLoading?: boolean
|
|
||||||
loadingMode?: DataGridProps<TData>["loadingMode"]
|
|
||||||
emptyMessage?: ReactNode
|
|
||||||
onRowClick?: DataGridProps<TData>["onRowClick"]
|
|
||||||
tableLayout?: DataGridProps<TData>["tableLayout"]
|
|
||||||
tableClassNames?: DataGridProps<TData>["tableClassNames"]
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridShell<TData extends object>({
|
|
||||||
table,
|
|
||||||
recordCount,
|
|
||||||
children,
|
|
||||||
pagination = false,
|
|
||||||
isLoading,
|
|
||||||
loadingMode,
|
|
||||||
emptyMessage,
|
|
||||||
onRowClick,
|
|
||||||
tableLayout = DEFAULT_DATA_GRID_LAYOUT,
|
|
||||||
tableClassNames = DEFAULT_DATA_GRID_CLASS_NAMES,
|
|
||||||
}: DataGridShellProps<TData>) {
|
|
||||||
return (
|
|
||||||
<DataGrid
|
|
||||||
className="w-full"
|
|
||||||
table={table}
|
|
||||||
recordCount={recordCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMode={loadingMode}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
onRowClick={onRowClick}
|
|
||||||
tableLayout={tableLayout}
|
|
||||||
tableClassNames={tableClassNames}
|
|
||||||
>
|
|
||||||
{children ?? (
|
|
||||||
<>
|
|
||||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
|
||||||
<DataGridTable />
|
|
||||||
</DataGridContainer>
|
|
||||||
{pagination && recordCount > 0 && (
|
|
||||||
<DataGridPagination className="px-5 pb-3" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DataGrid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridShell, type DataGridShellProps }
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { Column } from "@tanstack/react-table-v8"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridSortHeaderProps<TData> {
|
|
||||||
column: Column<TData, unknown>
|
|
||||||
title: string
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridSortHeader<TData>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
className,
|
|
||||||
}: DataGridSortHeaderProps<TData>) {
|
|
||||||
const sorted = column.getIsSorted()
|
|
||||||
const canSort = column.getCanSort()
|
|
||||||
|
|
||||||
if (!canSort) {
|
|
||||||
return (
|
|
||||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
|
||||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
onClick={column.getToggleSortingHandler()}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
{sorted === "asc" ? (
|
|
||||||
<ArrowUpIcon className="size-3 text-foreground" />
|
|
||||||
) : sorted === "desc" ? (
|
|
||||||
<ArrowDownIcon className="size-3 text-foreground" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDownIcon className="size-3 opacity-35" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridSortHeader, type DataGridSortHeaderProps }
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/legacy-data-grid-layout"
|
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface DataPageCardProps {
|
interface DataPageCardProps {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
} from "@/components/reui/filters"
|
} from "@/components/reui/filters"
|
||||||
import { SegmentedControl } from "@/components/form-kit"
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/legacy-data-grid-layout"
|
import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
|
||||||
function DataPageToolbarFrame({
|
function DataPageToolbarFrame({
|
||||||
children,
|
children,
|
||||||
|
|||||||
+12
-14
@@ -1,20 +1,19 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
useTable,
|
||||||
getPaginationRowModel,
|
} from "@tanstack/react-table"
|
||||||
getSortedRowModel,
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table-v8"
|
|
||||||
import { DataGridShell } from "@/components/data-grids/shared/legacy-data-grid-shell"
|
|
||||||
import {
|
import {
|
||||||
DATA_GRID_CELL_PAD,
|
DATA_GRID_CELL_PAD,
|
||||||
DATA_GRID_CELL_PAD_FIRST,
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
DATA_GRID_CELL_PAD_LAST,
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
} from "@/components/data-grids/shared/legacy-data-grid-layout"
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { DataGridSortHeader } from "@/components/data-grids/shared/legacy-data-grid-sort-header"
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
@@ -60,9 +59,9 @@ export function DataTable<T extends { id: string }>({
|
|||||||
)
|
)
|
||||||
}, [data, search, searchKeys])
|
}, [data, search, searchKeys])
|
||||||
|
|
||||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
const columnDefs = useMemo<ColumnDef<DataGridFeatures, T>[]>(
|
||||||
() => {
|
() => {
|
||||||
const defs: ColumnDef<T>[] = columns.map((col, index) => ({
|
const defs: ColumnDef<DataGridFeatures, T>[] = columns.map((col, index) => ({
|
||||||
id: col.key,
|
id: col.key,
|
||||||
accessorKey: col.key,
|
accessorKey: col.key,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -91,12 +90,11 @@ export function DataTable<T extends { id: string }>({
|
|||||||
[columns],
|
[columns],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
|
manualPagination: !pagination,
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns: columnDefs,
|
columns: columnDefs,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
|
||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { Column } from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridColumnFilterProps<TData, TValue> {
|
|
||||||
column?: Column<TData, TValue>
|
|
||||||
title?: string
|
|
||||||
options: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
icon?: React.ComponentType<{ className?: string }>
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridColumnFilter<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
options,
|
|
||||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
|
||||||
const facets = column?.getFacetedUniqueValues()
|
|
||||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
|
||||||
if (!searchQuery) return options
|
|
||||||
return options.filter((option) =>
|
|
||||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
}, [options, searchQuery])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<CirclePlusIcon className="size-4" />
|
|
||||||
{title}
|
|
||||||
{selectedValues?.size > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-sm px-1 font-normal lg:hidden"
|
|
||||||
>
|
|
||||||
{selectedValues.size}
|
|
||||||
</Badge>
|
|
||||||
<div className="hidden space-x-1 lg:flex">
|
|
||||||
{selectedValues.size > 2 ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="rounded-sm px-1 font-normal"
|
|
||||||
>
|
|
||||||
{selectedValues.size} selected
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
options
|
|
||||||
.filter((option) => selectedValues.has(option.value))
|
|
||||||
.map((option) => (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
key={option.value}
|
|
||||||
className="rounded-sm px-1 font-normal"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
|
||||||
<div className="p-2">
|
|
||||||
<Input
|
|
||||||
placeholder={title}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
|
||||||
{filteredOptions.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
|
||||||
No results found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-1">
|
|
||||||
{filteredOptions.map((option) => {
|
|
||||||
const isSelected = selectedValues.has(option.value)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => {
|
|
||||||
if (isSelected) {
|
|
||||||
selectedValues.delete(option.value)
|
|
||||||
} else {
|
|
||||||
selectedValues.add(option.value)
|
|
||||||
}
|
|
||||||
const filterValues = Array.from(selectedValues)
|
|
||||||
column?.setFilterValue(
|
|
||||||
filterValues.length ? filterValues : undefined
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "opacity-50 [&_svg]:invisible"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
{option.icon && (
|
|
||||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{facets?.get(option.value) && (
|
|
||||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
|
||||||
{facets.get(option.value)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedValues.size > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="bg-border -mx-1 my-1 h-px" />
|
|
||||||
<div className="p-1">
|
|
||||||
<div
|
|
||||||
onClick={() => column?.setFilterValue(undefined)}
|
|
||||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
|
||||||
import {
|
|
||||||
getColumnHeaderLabel,
|
|
||||||
useDataGrid,
|
|
||||||
} from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import { Column } from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridColumnHeaderProps<
|
|
||||||
TData,
|
|
||||||
TValue,
|
|
||||||
> extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
column: Column<TData, TValue>
|
|
||||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
|
||||||
title?: string
|
|
||||||
icon?: ReactNode
|
|
||||||
pinnable?: boolean
|
|
||||||
filter?: ReactNode
|
|
||||||
visibility?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridColumnHeaderInner<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
className,
|
|
||||||
filter,
|
|
||||||
visibility = false,
|
|
||||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
|
||||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
|
||||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
|
||||||
|
|
||||||
const columnOrder = table.getState().columnOrder
|
|
||||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
const isPinned = column.getIsPinned()
|
|
||||||
const canSort = column.getCanSort()
|
|
||||||
const canPin = column.getCanPin()
|
|
||||||
const canResize = column.getCanResize()
|
|
||||||
|
|
||||||
const columnIndex = columnOrder.indexOf(column.id)
|
|
||||||
const canMoveLeft = columnIndex > 0
|
|
||||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
|
||||||
|
|
||||||
const handleSort = () => {
|
|
||||||
if (isSorted === "asc") {
|
|
||||||
column.toggleSorting(true)
|
|
||||||
} else if (isSorted === "desc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerLabelClassName = cn(
|
|
||||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
const headerButtonClassName = cn(
|
|
||||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
|
||||||
className
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortIcon =
|
|
||||||
canSort &&
|
|
||||||
(isSorted === "desc" ? (
|
|
||||||
<ArrowDownIcon className="size-3.25" />
|
|
||||||
) : isSorted === "asc" ? (
|
|
||||||
<ArrowUpIcon className="size-3.25" />
|
|
||||||
) : (
|
|
||||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
|
||||||
))
|
|
||||||
|
|
||||||
const hasControls =
|
|
||||||
props.tableLayout?.columnsMovable ||
|
|
||||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
|
||||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
|
||||||
filter
|
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
|
||||||
const items: ReactNode[] = []
|
|
||||||
let hasPreviousSection = false
|
|
||||||
|
|
||||||
// Filter section
|
|
||||||
if (filter) {
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuGroup key="group-filter">
|
|
||||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort section
|
|
||||||
if (canSort) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="sort-asc"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSorted === "asc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canSort}
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="size-3.5!" />
|
|
||||||
<span className="grow">Asc</span>
|
|
||||||
{isSorted === "asc" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="sort-desc"
|
|
||||||
onClick={() => {
|
|
||||||
if (isSorted === "desc") {
|
|
||||||
column.clearSorting()
|
|
||||||
} else {
|
|
||||||
column.toggleSorting(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canSort}
|
|
||||||
>
|
|
||||||
<ArrowDownIcon className="size-3.5!" />
|
|
||||||
<span className="grow">Desc</span>
|
|
||||||
{isSorted === "desc" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin section
|
|
||||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="pin-left"
|
|
||||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
|
||||||
>
|
|
||||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span className="grow">Pin to left</span>
|
|
||||||
{isPinned === "left" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="pin-right"
|
|
||||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
|
||||||
>
|
|
||||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span className="grow">Pin to right</span>
|
|
||||||
{isPinned === "right" && (
|
|
||||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move section
|
|
||||||
if (props.tableLayout?.columnsMovable) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="move-left"
|
|
||||||
onClick={() => {
|
|
||||||
if (columnIndex > 0) {
|
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canMoveLeft || isPinned !== false}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span>Move to Left</span>
|
|
||||||
</DropdownMenuItem>,
|
|
||||||
<DropdownMenuItem
|
|
||||||
key="move-right"
|
|
||||||
onClick={() => {
|
|
||||||
if (columnIndex < columnOrder.length - 1) {
|
|
||||||
const newOrder = [...columnOrder]
|
|
||||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
|
||||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
|
||||||
table.setColumnOrder(newOrder)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canMoveRight || isPinned !== false}
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
|
||||||
<span>Move to Right</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
hasPreviousSection = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visibility section
|
|
||||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
|
||||||
if (hasPreviousSection) {
|
|
||||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
|
||||||
}
|
|
||||||
items.push(
|
|
||||||
<DropdownMenuSub key="visibility">
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<Settings2Icon className="size-3.5!" />
|
|
||||||
<span>Columns</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent side="right">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((col) => col.getCanHide())
|
|
||||||
.map((col) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={col.id}
|
|
||||||
checked={col.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(col)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
|
||||||
filter,
|
|
||||||
canSort,
|
|
||||||
isSorted,
|
|
||||||
column,
|
|
||||||
props.tableLayout?.columnsPinnable,
|
|
||||||
props.tableLayout?.columnsMovable,
|
|
||||||
props.tableLayout?.columnsVisibility,
|
|
||||||
canPin,
|
|
||||||
isPinned,
|
|
||||||
canMoveLeft,
|
|
||||||
canMoveRight,
|
|
||||||
visibility,
|
|
||||||
table,
|
|
||||||
columnIndex,
|
|
||||||
columnOrder,
|
|
||||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
|
||||||
])
|
|
||||||
|
|
||||||
if (hasControls) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-between gap-1.5">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={headerButtonClassName}
|
|
||||||
disabled={isLoading || recordCount === 0}
|
|
||||||
>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
{sortIcon}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenuContent className="w-40" align="start">
|
|
||||||
{menuItems}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="-me-1 size-7 rounded-md"
|
|
||||||
onClick={() => column.pin(false)}
|
|
||||||
aria-label={`Unpin ${resolvedTitle} column`}
|
|
||||||
title={`Unpin ${resolvedTitle} column`}
|
|
||||||
>
|
|
||||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={headerButtonClassName}
|
|
||||||
disabled={isLoading || recordCount === 0}
|
|
||||||
onClick={handleSort}
|
|
||||||
>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
{sortIcon}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={headerLabelClassName}>
|
|
||||||
{icon && icon}
|
|
||||||
{resolvedTitle}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataGridColumnHeader = memo(
|
|
||||||
DataGridColumnHeaderInner
|
|
||||||
) as typeof DataGridColumnHeaderInner
|
|
||||||
|
|
||||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { ReactElement } from "react"
|
|
||||||
import { getColumnHeaderLabel } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import { Table } from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
|
|
||||||
function DataGridColumnVisibility<TData>({
|
|
||||||
table,
|
|
||||||
trigger,
|
|
||||||
}: {
|
|
||||||
table: Table<TData>
|
|
||||||
trigger: ReactElement<Record<string, unknown>>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={trigger} />
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-medium">
|
|
||||||
Toggle Columns
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onSelect={(event) => event.preventDefault()}
|
|
||||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
||||||
>
|
|
||||||
{getColumnHeaderLabel(column)}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridColumnVisibility }
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import React, { ReactNode } from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
interface DataGridPaginationProps {
|
|
||||||
sizes?: number[]
|
|
||||||
sizesInfo?: string
|
|
||||||
sizesLabel?: string
|
|
||||||
sizesDescription?: string
|
|
||||||
sizesSkeleton?: ReactNode
|
|
||||||
more?: boolean
|
|
||||||
moreLimit?: number
|
|
||||||
info?: string
|
|
||||||
infoSkeleton?: ReactNode
|
|
||||||
className?: string
|
|
||||||
rowsPerPageLabel?: string
|
|
||||||
previousPageLabel?: string
|
|
||||||
nextPageLabel?: string
|
|
||||||
ellipsisText?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|
||||||
const { table, recordCount, isLoading } = useDataGrid()
|
|
||||||
|
|
||||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
|
||||||
sizes: [5, 10, 25, 50, 100],
|
|
||||||
sizesLabel: "Show",
|
|
||||||
sizesDescription: "per page",
|
|
||||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
|
||||||
moreLimit: 5,
|
|
||||||
more: false,
|
|
||||||
info: "{from} - {to} of {count}",
|
|
||||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
|
||||||
rowsPerPageLabel: "Rows per page",
|
|
||||||
previousPageLabel: "Go to previous page",
|
|
||||||
nextPageLabel: "Go to next page",
|
|
||||||
ellipsisText: "...",
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
|
||||||
|
|
||||||
const btnBaseClasses = "size-7 p-0 text-sm"
|
|
||||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
|
||||||
const pageIndex = table.getState().pagination.pageIndex
|
|
||||||
const pageSize = table.getState().pagination.pageSize
|
|
||||||
const from = pageIndex * pageSize + 1
|
|
||||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
|
||||||
const pageCount = table.getPageCount()
|
|
||||||
|
|
||||||
// Replace placeholders in paginationInfo
|
|
||||||
const paginationInfo = mergedProps?.info
|
|
||||||
? mergedProps.info
|
|
||||||
.replace("{from}", from.toString())
|
|
||||||
.replace("{to}", to.toString())
|
|
||||||
.replace("{count}", recordCount.toString())
|
|
||||||
: `${from} - ${to} of ${recordCount}`
|
|
||||||
|
|
||||||
// Pagination limit logic
|
|
||||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
|
||||||
|
|
||||||
// Determine the start and end of the pagination group
|
|
||||||
const currentGroupStart =
|
|
||||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
|
||||||
const currentGroupEnd = Math.min(
|
|
||||||
currentGroupStart + paginationMoreLimit,
|
|
||||||
pageCount
|
|
||||||
)
|
|
||||||
|
|
||||||
// Render page buttons based on the current group
|
|
||||||
const renderPageButtons = () => {
|
|
||||||
const buttons = []
|
|
||||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
|
||||||
buttons.push(
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
|
||||||
"bg-accent text-accent-foreground": pageIndex === i,
|
|
||||||
})}
|
|
||||||
onClick={() => {
|
|
||||||
if (pageIndex !== i) {
|
|
||||||
table.setPageIndex(i)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "previous" ellipsis button if there are previous pages to show
|
|
||||||
const renderEllipsisPrevButton = () => {
|
|
||||||
if (currentGroupStart > 0) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
|
||||||
const renderEllipsisNextButton = () => {
|
|
||||||
if (currentGroupEnd < pageCount) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={btnBaseClasses}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
|
||||||
>
|
|
||||||
{mergedProps.ellipsisText}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="data-grid-pagination"
|
|
||||||
className={cn(
|
|
||||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
|
||||||
mergedProps?.className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
|
||||||
{isLoading ? (
|
|
||||||
mergedProps?.sizesSkeleton
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
{mergedProps.rowsPerPageLabel}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={`${pageSize}`}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newPageSize = Number(value)
|
|
||||||
table.setPageSize(newPageSize)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-14" size="sm">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top" className="min-w-18">
|
|
||||||
{mergedProps?.sizes?.map((size: number) => (
|
|
||||||
<SelectItem key={size} value={`${size}`}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
|
||||||
{isLoading ? (
|
|
||||||
mergedProps?.infoSkeleton
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
|
||||||
{paginationInfo}
|
|
||||||
</div>
|
|
||||||
{pageCount > 1 && (
|
|
||||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={btnArrowClasses}
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
{mergedProps.previousPageLabel}
|
|
||||||
</span>
|
|
||||||
<ChevronLeftIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{renderEllipsisPrevButton()}
|
|
||||||
|
|
||||||
{renderPageButtons()}
|
|
||||||
|
|
||||||
{renderEllipsisNextButton()}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={btnArrowClasses}
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
|
||||||
<ChevronRightIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridPagination, type DataGridPaginationProps }
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
PointerEvent,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const MIN_THUMB_SIZE = 24
|
|
||||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
|
||||||
|
|
||||||
const INITIAL_METRICS = {
|
|
||||||
hasVerticalOverflow: false,
|
|
||||||
headerHeight: 0,
|
|
||||||
horizontalScrollbarSize: 0,
|
|
||||||
thumbHeight: 0,
|
|
||||||
thumbTop: 0,
|
|
||||||
trackHeight: 0,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
|
||||||
|
|
||||||
type ScrollbarMetrics = {
|
|
||||||
hasVerticalOverflow: boolean
|
|
||||||
headerHeight: number
|
|
||||||
horizontalScrollbarSize: number
|
|
||||||
thumbHeight: number
|
|
||||||
thumbTop: number
|
|
||||||
trackHeight: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ObservedElements = {
|
|
||||||
header: HTMLElement | null
|
|
||||||
horizontalScrollbar: HTMLElement | null
|
|
||||||
table: HTMLElement | null
|
|
||||||
tableViewport: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridScrollAreaProps = Omit<
|
|
||||||
ScrollAreaPrimitive.Root.Props,
|
|
||||||
"children"
|
|
||||||
> & {
|
|
||||||
children: ReactNode
|
|
||||||
orientation?: DataGridScrollAreaOrientation
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(max, Math.max(min, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
|
||||||
return (
|
|
||||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
|
||||||
next.headerHeight === prev.headerHeight &&
|
|
||||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
|
||||||
next.thumbHeight === prev.thumbHeight &&
|
|
||||||
next.thumbTop === prev.thumbTop &&
|
|
||||||
next.trackHeight === prev.trackHeight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-header-height",
|
|
||||||
`${metrics.headerHeight}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-thumb-height",
|
|
||||||
`${metrics.thumbHeight}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-thumb-top",
|
|
||||||
`${metrics.thumbTop}px`
|
|
||||||
)
|
|
||||||
element.style.setProperty(
|
|
||||||
"--data-grid-scrollbar-track-height",
|
|
||||||
`${metrics.trackHeight}px`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridScrollArea({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
orientation = "both",
|
|
||||||
...props
|
|
||||||
}: DataGridScrollAreaProps) {
|
|
||||||
const { props: dataGridProps } = useDataGrid()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const dragRef = useRef<{
|
|
||||||
pointerId: number
|
|
||||||
startScrollTop: number
|
|
||||||
startY: number
|
|
||||||
} | null>(null)
|
|
||||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
|
||||||
const observedElementsRef = useRef<ObservedElements>({
|
|
||||||
header: null,
|
|
||||||
horizontalScrollbar: null,
|
|
||||||
table: null,
|
|
||||||
tableViewport: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const showHorizontal = orientation !== "vertical"
|
|
||||||
const showVertical = orientation !== "horizontal"
|
|
||||||
const usesCustomVerticalScrollbar =
|
|
||||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
|
||||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
|
||||||
useState(false)
|
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
|
||||||
dragRef.current = null
|
|
||||||
document.body.style.userSelect = ""
|
|
||||||
document.body.style.webkitUserSelect = ""
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resetMetrics = useCallback(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
|
|
||||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
|
||||||
applyMetrics(container, INITIAL_METRICS)
|
|
||||||
metricsRef.current = INITIAL_METRICS
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
|
||||||
resetMetrics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
|
||||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
|
||||||
const viewportHeight = viewport.clientHeight
|
|
||||||
const viewportWidth = viewport.clientWidth
|
|
||||||
const scrollHeight = viewport.scrollHeight
|
|
||||||
const scrollWidth = viewport.scrollWidth
|
|
||||||
const hasHorizontalOverflow =
|
|
||||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
|
||||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
|
||||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
|
||||||
: 0
|
|
||||||
const trackHeight = Math.max(
|
|
||||||
0,
|
|
||||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
|
||||||
)
|
|
||||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
|
||||||
|
|
||||||
let nextMetrics: ScrollbarMetrics
|
|
||||||
|
|
||||||
if (trackHeight === 0 || maxScroll === 0) {
|
|
||||||
nextMetrics = {
|
|
||||||
hasVerticalOverflow: false,
|
|
||||||
headerHeight,
|
|
||||||
horizontalScrollbarSize,
|
|
||||||
thumbHeight: trackHeight,
|
|
||||||
thumbTop: 0,
|
|
||||||
trackHeight,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const bodyContentHeight = Math.max(
|
|
||||||
trackHeight,
|
|
||||||
scrollHeight - headerHeight
|
|
||||||
)
|
|
||||||
const thumbHeight = clamp(
|
|
||||||
trackHeight * (trackHeight / bodyContentHeight),
|
|
||||||
MIN_THUMB_SIZE,
|
|
||||||
trackHeight
|
|
||||||
)
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
const thumbTop =
|
|
||||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
|
||||||
|
|
||||||
nextMetrics = {
|
|
||||||
hasVerticalOverflow: true,
|
|
||||||
headerHeight,
|
|
||||||
horizontalScrollbarSize,
|
|
||||||
thumbHeight,
|
|
||||||
thumbTop,
|
|
||||||
trackHeight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
|
||||||
applyMetrics(container, nextMetrics)
|
|
||||||
metricsRef.current = nextMetrics
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCustomVerticalOverflow((prev) =>
|
|
||||||
prev === nextMetrics.hasVerticalOverflow
|
|
||||||
? prev
|
|
||||||
: nextMetrics.hasVerticalOverflow
|
|
||||||
)
|
|
||||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!container || !viewport) return
|
|
||||||
|
|
||||||
if (!usesCustomVerticalScrollbar) {
|
|
||||||
resetMetrics()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
observedElementsRef.current = {
|
|
||||||
header: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"] thead'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
horizontalScrollbar: container.querySelector(
|
|
||||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
table: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
tableViewport: container.querySelector(
|
|
||||||
'[data-slot="data-grid-table-viewport"]'
|
|
||||||
) as HTMLElement | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame = 0
|
|
||||||
|
|
||||||
const scheduleSync = () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSync()
|
|
||||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
|
||||||
|
|
||||||
const observer =
|
|
||||||
typeof ResizeObserver === "undefined"
|
|
||||||
? null
|
|
||||||
: new ResizeObserver(scheduleSync)
|
|
||||||
|
|
||||||
observer?.observe(viewport)
|
|
||||||
observedElementsRef.current.header &&
|
|
||||||
observer?.observe(observedElementsRef.current.header)
|
|
||||||
observedElementsRef.current.table &&
|
|
||||||
observer?.observe(observedElementsRef.current.table)
|
|
||||||
observedElementsRef.current.tableViewport &&
|
|
||||||
observer?.observe(observedElementsRef.current.tableViewport)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
observer?.disconnect()
|
|
||||||
viewport.removeEventListener("scroll", scheduleSync)
|
|
||||||
clearDragState()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
clearDragState,
|
|
||||||
resetMetrics,
|
|
||||||
syncCustomVerticalScrollbar,
|
|
||||||
usesCustomVerticalScrollbar,
|
|
||||||
])
|
|
||||||
|
|
||||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
const { thumbHeight, trackHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (!viewport) return
|
|
||||||
|
|
||||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
|
|
||||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
|
||||||
viewport.scrollTop = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
|
||||||
viewport.scrollTop = ratio * maxScroll
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
|
|
||||||
if (!viewport) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
|
||||||
|
|
||||||
dragRef.current = {
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
startScrollTop: viewport.scrollTop,
|
|
||||||
startY: event.clientY,
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.style.userSelect = "none"
|
|
||||||
document.body.style.webkitUserSelect = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const viewport = viewportRef.current
|
|
||||||
const dragState = dragRef.current
|
|
||||||
const { thumbHeight, trackHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
|
||||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
|
||||||
|
|
||||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
|
||||||
|
|
||||||
const deltaY = event.clientY - dragState.startY
|
|
||||||
const nextScrollTop =
|
|
||||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
|
||||||
|
|
||||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
|
||||||
clearDragState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
||||||
const { thumbHeight } = metricsRef.current
|
|
||||||
|
|
||||||
if (event.target !== event.currentTarget) return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect()
|
|
||||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
|
||||||
|
|
||||||
scrollToThumbOffset(offsetY)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative">
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
data-slot="data-grid-scroll-area"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport
|
|
||||||
ref={viewportRef}
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
className="size-full"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Content>
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
|
|
||||||
{showHorizontal && (
|
|
||||||
<ScrollAreaPrimitive.Scrollbar
|
|
||||||
data-slot="data-grid-scrollbar"
|
|
||||||
data-orientation="horizontal"
|
|
||||||
orientation="horizontal"
|
|
||||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Thumb
|
|
||||||
data-slot="data-grid-thumb"
|
|
||||||
className="bg-border rounded-full relative flex-1"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showVertical && !usesCustomVerticalScrollbar && (
|
|
||||||
<ScrollAreaPrimitive.Scrollbar
|
|
||||||
data-slot="data-grid-scrollbar"
|
|
||||||
data-orientation="vertical"
|
|
||||||
orientation="vertical"
|
|
||||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Thumb
|
|
||||||
data-slot="data-grid-thumb"
|
|
||||||
className="bg-border rounded-full relative flex-1"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
|
||||||
)}
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
|
|
||||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
|
||||||
onPointerDown={handleTrackPointerDown}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-border absolute end-px w-2",
|
|
||||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
|
||||||
"rounded-full"
|
|
||||||
)}
|
|
||||||
onLostPointerCapture={clearDragState}
|
|
||||||
onPointerCancel={handleThumbPointerUp}
|
|
||||||
onPointerDown={handleThumbPointerDown}
|
|
||||||
onPointerMove={handleThumbPointerMove}
|
|
||||||
onPointerUp={handleThumbPointerUp}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridScrollArea }
|
|
||||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
CSSProperties,
|
|
||||||
ReactNode,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
UniqueIdentifier,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
type Modifier,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { GripHorizontalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// Context to share sortable listeners from row to handle
|
|
||||||
type SortableContextValue = ReturnType<typeof useSortable>
|
|
||||||
const SortableRowContext = createContext<Pick<
|
|
||||||
SortableContextValue,
|
|
||||||
"attributes" | "listeners"
|
|
||||||
> | null>(null)
|
|
||||||
|
|
||||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
|
||||||
const context = useContext(SortableRowContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className={cn(
|
|
||||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...context.attributes}
|
|
||||||
{...context.listeners}
|
|
||||||
>
|
|
||||||
<GripHorizontalIcon
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
|
||||||
const {
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
setNodeRef,
|
|
||||||
isDragging,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
} = useSortable({
|
|
||||||
id: row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition: transition,
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
position: "relative",
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
|
||||||
<DataGridTableBodyRow
|
|
||||||
row={row}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
dndStyle={style}
|
|
||||||
key={row.id}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
</SortableRowContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndRows<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
dataIds,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
dataIds: UniqueIdentifier[]
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
useSensor(KeyboardSensor, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingRow) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingRow])
|
|
||||||
|
|
||||||
const modifiers = useMemo(() => {
|
|
||||||
const restrictToTableContainer: Modifier = ({
|
|
||||||
transform,
|
|
||||||
draggingNodeRect,
|
|
||||||
}) => {
|
|
||||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
|
||||||
return transform
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
|
||||||
const { x, y } = transform
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left
|
|
||||||
const maxX = containerRect.right - draggingNodeRect.right
|
|
||||||
const minY = containerRect.top - draggingNodeRect.top
|
|
||||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.max(minX, Math.min(maxX, x)),
|
|
||||||
y: Math.max(minY, Math.min(maxY, y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
id={useId()}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={modifiers}
|
|
||||||
onDragCancel={() => setIsDraggingRow(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingRow(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingRow(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={tableContainerRef}
|
|
||||||
className={
|
|
||||||
isDraggingRow
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
{headerGroup.headers.map((header, index) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={index}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
{props.loadingMode === "skeleton" &&
|
|
||||||
isLoading &&
|
|
||||||
pagination?.pageSize ? (
|
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
<SortableContext
|
|
||||||
items={dataIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return <DataGridTableDndRow row={row} key={row.id} />
|
|
||||||
})}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
Fragment,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableBodyRowSkeleton,
|
|
||||||
DataGridTableBodyRowSkeletonCell,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
KeyboardSensor,
|
|
||||||
Modifier,
|
|
||||||
MouseSensor,
|
|
||||||
TouchSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core"
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
} from "@dnd-kit/sortable"
|
|
||||||
import { CSS } from "@dnd-kit/utilities"
|
|
||||||
import {
|
|
||||||
Cell,
|
|
||||||
flexRender,
|
|
||||||
Header,
|
|
||||||
HeaderGroup,
|
|
||||||
Row,
|
|
||||||
} from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { GripVerticalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DataGridTableDndHeader<TData>({
|
|
||||||
header,
|
|
||||||
}: {
|
|
||||||
header: Header<TData, unknown>
|
|
||||||
}) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
// Check if column ordering is enabled for this column
|
|
||||||
const canOrder =
|
|
||||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
|
||||||
.enableColumnOrdering !== false
|
|
||||||
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isDragging,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({
|
|
||||||
id: header.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
|
||||||
: header.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell
|
|
||||||
header={header}
|
|
||||||
dndStyle={style}
|
|
||||||
dndRef={setNodeRef}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-0.5">
|
|
||||||
{canOrder && (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
aria-label="Drag to reorder"
|
|
||||||
>
|
|
||||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<span className="grow truncate">
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
</span>
|
|
||||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
|
||||||
const { props } = useDataGrid()
|
|
||||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
|
||||||
id: cell.column.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.8 : 1,
|
|
||||||
position: "relative",
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
cursor: isDragging ? "grabbing" : undefined,
|
|
||||||
width: props.tableLayout?.columnsResizable
|
|
||||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
|
||||||
: cell.column.getSize(),
|
|
||||||
zIndex: isDragging ? 1 : 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</DataGridTableBodyRowCell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableDnd<TData>({
|
|
||||||
handleDragEnd,
|
|
||||||
footerContent,
|
|
||||||
}: {
|
|
||||||
handleDragEnd: (event: DragEndEvent) => void
|
|
||||||
footerContent?: ReactNode
|
|
||||||
}) {
|
|
||||||
const { table, isLoading, props } = useDataGrid()
|
|
||||||
const pagination = table.getState().pagination
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(MouseSensor, {}),
|
|
||||||
useSensor(TouchSensor, {}),
|
|
||||||
useSensor(KeyboardSensor, {})
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDraggingColumn) return
|
|
||||||
|
|
||||||
const { body, documentElement } = document
|
|
||||||
const previousBodyCursor = body.style.cursor
|
|
||||||
const previousDocumentCursor = documentElement.style.cursor
|
|
||||||
|
|
||||||
body.style.cursor = "grabbing"
|
|
||||||
documentElement.style.cursor = "grabbing"
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
body.style.cursor = previousBodyCursor
|
|
||||||
documentElement.style.cursor = previousDocumentCursor
|
|
||||||
}
|
|
||||||
}, [isDraggingColumn])
|
|
||||||
|
|
||||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
|
||||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
|
||||||
if (!draggingNodeRect || !containerRef.current) {
|
|
||||||
return { ...transform, y: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = containerRef.current.getBoundingClientRect()
|
|
||||||
const edgeOffset = 0
|
|
||||||
|
|
||||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
|
||||||
const maxX =
|
|
||||||
containerRect.right -
|
|
||||||
draggingNodeRect.left -
|
|
||||||
draggingNodeRect.width +
|
|
||||||
edgeOffset
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
|
||||||
y: 0, // Lock vertical movement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
id={useId()}
|
|
||||||
modifiers={[restrictToTableBounds]}
|
|
||||||
onDragCancel={() => setIsDraggingColumn(false)}
|
|
||||||
onDragEnd={(event) => {
|
|
||||||
setIsDraggingColumn(false)
|
|
||||||
handleDragEnd(event)
|
|
||||||
}}
|
|
||||||
onDragStart={() => setIsDraggingColumn(true)}
|
|
||||||
sensors={sensors}
|
|
||||||
>
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={containerRef}
|
|
||||||
className={
|
|
||||||
isDraggingColumn
|
|
||||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
|
||||||
: "relative"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
<SortableContext
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataGridTableDndHeader
|
|
||||||
header={header}
|
|
||||||
key={header.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHead>
|
|
||||||
|
|
||||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
{props.loadingMode === "skeleton" &&
|
|
||||||
isLoading &&
|
|
||||||
pagination?.pageSize ? (
|
|
||||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
|
||||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
|
||||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
|
||||||
return (
|
|
||||||
<DataGridTableBodyRowSkeletonCell
|
|
||||||
column={column}
|
|
||||||
key={colIndex}
|
|
||||||
>
|
|
||||||
{column.columnDef.meta?.skeleton}
|
|
||||||
</DataGridTableBodyRowSkeletonCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRowSkeleton>
|
|
||||||
))
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.id}>
|
|
||||||
<DataGridTableBodyRow row={row}>
|
|
||||||
{row
|
|
||||||
.getVisibleCells()
|
|
||||||
.map((cell: Cell<TData, unknown>) => {
|
|
||||||
return (
|
|
||||||
<SortableContext
|
|
||||||
key={cell.id}
|
|
||||||
items={table.getState().columnOrder}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<DataGridTableDndCell cell={cell} />
|
|
||||||
</SortableContext>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableBodyRow>
|
|
||||||
{row.getIsExpanded() && (
|
|
||||||
<DataGridTableBodyRowExpandded row={row} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<DataGridTableEmpty />
|
|
||||||
)}
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
</DndContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableDnd }
|
|
||||||
@@ -1,492 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import {
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
import { useDataGrid } from "@/components/reui/legacy-data-grid/data-grid"
|
|
||||||
import {
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableHeadRowCellResize,
|
|
||||||
DataGridTableRenderedRow,
|
|
||||||
DataGridTableRowSpacer,
|
|
||||||
DataGridTableViewport,
|
|
||||||
getDataGridTableRowSections,
|
|
||||||
} from "@/components/reui/legacy-data-grid/data-grid-table"
|
|
||||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table-v8"
|
|
||||||
import {
|
|
||||||
useVirtualizer,
|
|
||||||
VirtualItem,
|
|
||||||
Virtualizer,
|
|
||||||
VirtualizerOptions,
|
|
||||||
} from "@tanstack/react-virtual"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
|
||||||
|
|
||||||
type DataGridTableVirtualScrollElements = {
|
|
||||||
containerElement: HTMLDivElement | null
|
|
||||||
scrollElement: HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
|
||||||
HTMLElement,
|
|
||||||
HTMLTableRowElement
|
|
||||||
>
|
|
||||||
|
|
||||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
|
||||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
|
||||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
|
||||||
> & {
|
|
||||||
estimateSize?: (index: number, row: Row<TData>) => number
|
|
||||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
|
||||||
getScrollElement?: (
|
|
||||||
elements: DataGridTableVirtualScrollElements
|
|
||||||
) => HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DataGridTableVirtualProps<TData> {
|
|
||||||
height?: number | string
|
|
||||||
estimateSize?: number
|
|
||||||
overscan?: number
|
|
||||||
footerContent?: ReactNode
|
|
||||||
renderHeader?: boolean
|
|
||||||
onFetchMore?: () => void
|
|
||||||
isFetchingMore?: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
fetchMoreOffset?: number
|
|
||||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualBodyProps<TData> {
|
|
||||||
table: Table<TData>
|
|
||||||
columnCount: number
|
|
||||||
topRows: Row<TData>[]
|
|
||||||
centerRows: Row<TData>[]
|
|
||||||
bottomRows: Row<TData>[]
|
|
||||||
virtualItems: VirtualItem[]
|
|
||||||
totalSize: number
|
|
||||||
isVirtualizationEnabled: boolean
|
|
||||||
isInfiniteMode: boolean
|
|
||||||
isFetchingMore: boolean
|
|
||||||
hasMore?: boolean
|
|
||||||
loadingMoreMessage: ReactNode
|
|
||||||
allRowsLoadedMessage: ReactNode
|
|
||||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualSpacer({
|
|
||||||
columnCount,
|
|
||||||
height,
|
|
||||||
}: {
|
|
||||||
columnCount: number
|
|
||||||
height: number
|
|
||||||
}) {
|
|
||||||
if (height <= 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr aria-hidden="true">
|
|
||||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualStatusRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
columnCount,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
columnCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={columnCount}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground py-4 text-center text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridTableVirtualBody<TData>({
|
|
||||||
table,
|
|
||||||
columnCount,
|
|
||||||
topRows,
|
|
||||||
centerRows,
|
|
||||||
bottomRows,
|
|
||||||
virtualItems,
|
|
||||||
totalSize,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
isInfiniteMode,
|
|
||||||
isFetchingMore,
|
|
||||||
hasMore,
|
|
||||||
loadingMoreMessage,
|
|
||||||
allRowsLoadedMessage,
|
|
||||||
measureRowRef,
|
|
||||||
}: VirtualBodyProps<TData>) {
|
|
||||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
|
||||||
|
|
||||||
if (!totalRows) return <DataGridTableEmpty />
|
|
||||||
|
|
||||||
const hasCenterRows = centerRows.length > 0
|
|
||||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
|
||||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
|
||||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
|
||||||
const leadingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? (virtualItems[0]?.start ?? 0)
|
|
||||||
: 0
|
|
||||||
const trailingSpacerHeight =
|
|
||||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
|
||||||
? Math.max(
|
|
||||||
0,
|
|
||||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
|
||||||
)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const renderedRows: ReactNode[] = []
|
|
||||||
|
|
||||||
topRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isVirtualizationEnabled) {
|
|
||||||
if (leadingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-start"
|
|
||||||
columnCount={columnCount}
|
|
||||||
height={leadingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
virtualItems.forEach((virtualRow) => {
|
|
||||||
const row = centerRows[virtualRow.index]
|
|
||||||
|
|
||||||
if (!row) return
|
|
||||||
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
rowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (trailingSpacerHeight > 0) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualSpacer
|
|
||||||
key="virtual-spacer-end"
|
|
||||||
columnCount={columnCount}
|
|
||||||
height={trailingSpacerHeight}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
centerRows.forEach((row) => {
|
|
||||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showFetchingRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-loading"
|
|
||||||
columnCount={columnCount}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Spinner className="size-4 opacity-60" />
|
|
||||||
{loadingMoreMessage}
|
|
||||||
</div>
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCompleteRow) {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableVirtualStatusRow
|
|
||||||
key="virtual-status-complete"
|
|
||||||
columnCount={columnCount}
|
|
||||||
className="py-3 text-xs"
|
|
||||||
>
|
|
||||||
{allRowsLoadedMessage}
|
|
||||||
</DataGridTableVirtualStatusRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
bottomRows.forEach((row, index) => {
|
|
||||||
renderedRows.push(
|
|
||||||
<DataGridTableRenderedRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
pinnedBoundary={
|
|
||||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
|
||||||
? "bottom"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return <>{renderedRows}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memoized virtual body: skip re-renders during active column resize.
|
|
||||||
* Column widths update via CSS variables on the <table> element,
|
|
||||||
* so the browser handles width changes without React re-renders.
|
|
||||||
*/
|
|
||||||
const MemoizedVirtualBody = memo(
|
|
||||||
DataGridTableVirtualBody,
|
|
||||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
|
||||||
) as typeof DataGridTableVirtualBody
|
|
||||||
|
|
||||||
function DataGridTableVirtual<TData>({
|
|
||||||
height,
|
|
||||||
estimateSize = 48,
|
|
||||||
overscan = 10,
|
|
||||||
footerContent,
|
|
||||||
renderHeader = true,
|
|
||||||
onFetchMore,
|
|
||||||
isFetchingMore = false,
|
|
||||||
hasMore,
|
|
||||||
fetchMoreOffset = 0,
|
|
||||||
virtualizerOptions,
|
|
||||||
}: DataGridTableVirtualProps<TData>) {
|
|
||||||
const { table, props } = useDataGrid()
|
|
||||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
|
||||||
table,
|
|
||||||
props.tableLayout?.rowsPinnable
|
|
||||||
)
|
|
||||||
const columnCount =
|
|
||||||
table.getVisibleFlatColumns().length +
|
|
||||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
|
||||||
const isInfiniteMode = typeof onFetchMore === "function"
|
|
||||||
const [viewportElements, setViewportElements] =
|
|
||||||
useState<DataGridTableVirtualScrollElements>({
|
|
||||||
containerElement: null,
|
|
||||||
scrollElement: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
estimateSize: customEstimateSize,
|
|
||||||
getItemKey: customGetItemKey,
|
|
||||||
getScrollElement: customGetScrollElement,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
overscan: customOverscan,
|
|
||||||
...virtualizerOptionsRest
|
|
||||||
} = virtualizerOptions ?? {}
|
|
||||||
|
|
||||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
|
||||||
const loadingMoreMessage =
|
|
||||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
|
||||||
const allRowsLoadedMessage =
|
|
||||||
props.allRowsLoadedMessage || "All records loaded"
|
|
||||||
|
|
||||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
|
||||||
setViewportElements({
|
|
||||||
containerElement: node,
|
|
||||||
scrollElement:
|
|
||||||
(node?.closest(
|
|
||||||
'[data-slot="scroll-area-viewport"]'
|
|
||||||
) as HTMLElement | null) ?? node,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const usesExternalScrollArea =
|
|
||||||
viewportElements.scrollElement !== null &&
|
|
||||||
viewportElements.scrollElement !== viewportElements.containerElement
|
|
||||||
|
|
||||||
const resolveScrollElement = useCallback(() => {
|
|
||||||
if (customGetScrollElement) {
|
|
||||||
return customGetScrollElement(viewportElements)
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewportElements.scrollElement
|
|
||||||
}, [customGetScrollElement, viewportElements])
|
|
||||||
|
|
||||||
const resolveItemKey = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
if (!row) return index
|
|
||||||
|
|
||||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
|
||||||
},
|
|
||||||
[centerRows, customGetItemKey]
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolveEstimateSize = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const row = centerRows[index]
|
|
||||||
|
|
||||||
return row
|
|
||||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
|
||||||
: estimateSize
|
|
||||||
},
|
|
||||||
[centerRows, customEstimateSize, estimateSize]
|
|
||||||
)
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: centerRows.length,
|
|
||||||
getScrollElement: resolveScrollElement,
|
|
||||||
getItemKey: resolveItemKey,
|
|
||||||
estimateSize: resolveEstimateSize,
|
|
||||||
overscan: customOverscan ?? overscan,
|
|
||||||
measureElement: customMeasureElement,
|
|
||||||
...virtualizerOptionsRest,
|
|
||||||
}) as DataGridTableVirtualizerInstance
|
|
||||||
|
|
||||||
const virtualItems = isVirtualizationEnabled
|
|
||||||
? virtualizer.getVirtualItems()
|
|
||||||
: []
|
|
||||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
|
||||||
const measureRowRef =
|
|
||||||
isVirtualizationEnabled && customMeasureElement
|
|
||||||
? virtualizer.measureElement
|
|
||||||
: undefined
|
|
||||||
const resolvedFetchMoreOffset = useMemo(
|
|
||||||
() => Math.max(0, fetchMoreOffset),
|
|
||||||
[fetchMoreOffset]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isVirtualizationEnabled ||
|
|
||||||
!isInfiniteMode ||
|
|
||||||
hasMore === false ||
|
|
||||||
isFetchingMore
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastItem = virtualItems[virtualItems.length - 1]
|
|
||||||
if (!lastItem) return
|
|
||||||
|
|
||||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
|
||||||
onFetchMore?.()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
centerRows.length,
|
|
||||||
hasMore,
|
|
||||||
isFetchingMore,
|
|
||||||
isInfiniteMode,
|
|
||||||
isVirtualizationEnabled,
|
|
||||||
onFetchMore,
|
|
||||||
resolvedFetchMoreOffset,
|
|
||||||
virtualItems,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableViewport
|
|
||||||
viewportRef={handleViewportRef}
|
|
||||||
className={!usesExternalScrollArea ? "block" : undefined}
|
|
||||||
style={
|
|
||||||
usesExternalScrollArea
|
|
||||||
? undefined
|
|
||||||
: { height, overflow: "auto", position: "relative" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DataGridTableBase>
|
|
||||||
{renderHeader && (
|
|
||||||
<DataGridTableHead>
|
|
||||||
{table
|
|
||||||
.getHeaderGroups()
|
|
||||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
|
||||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
|
||||||
{headerGroup.headers.map((header, hIndex) => {
|
|
||||||
const { column } = header
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
|
||||||
{header.isPlaceholder ? null : props.tableLayout
|
|
||||||
?.columnsResizable && column.getCanResize() ? (
|
|
||||||
<div className="truncate">
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{props.tableLayout?.columnsResizable &&
|
|
||||||
column.getCanResize() && (
|
|
||||||
<DataGridTableHeadRowCellResize header={header} />
|
|
||||||
)}
|
|
||||||
</DataGridTableHeadRowCell>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DataGridTableHeadRow>
|
|
||||||
))}
|
|
||||||
</DataGridTableHead>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderHeader &&
|
|
||||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
|
||||||
<DataGridTableRowSpacer />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGridTableBody>
|
|
||||||
<MemoizedVirtualBody
|
|
||||||
table={table}
|
|
||||||
columnCount={columnCount}
|
|
||||||
topRows={topRows}
|
|
||||||
centerRows={centerRows}
|
|
||||||
bottomRows={bottomRows}
|
|
||||||
virtualItems={virtualItems}
|
|
||||||
totalSize={totalSize}
|
|
||||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
|
||||||
isInfiniteMode={isInfiniteMode}
|
|
||||||
isFetchingMore={isFetchingMore}
|
|
||||||
hasMore={hasMore}
|
|
||||||
loadingMoreMessage={loadingMoreMessage}
|
|
||||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
|
||||||
measureRowRef={measureRowRef}
|
|
||||||
/>
|
|
||||||
</DataGridTableBody>
|
|
||||||
|
|
||||||
{footerContent && (
|
|
||||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
|
||||||
)}
|
|
||||||
</DataGridTableBase>
|
|
||||||
</DataGridTableViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DataGridTableVirtual }
|
|
||||||
export type {
|
|
||||||
DataGridTableVirtualProps,
|
|
||||||
DataGridTableVirtualScrollElements,
|
|
||||||
DataGridTableVirtualizerOptions,
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,270 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
|
||||||
import {
|
|
||||||
Column,
|
|
||||||
ColumnFiltersState,
|
|
||||||
RowData,
|
|
||||||
SortingState,
|
|
||||||
Table,
|
|
||||||
} from "@tanstack/react-table-v8"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
declare module "@tanstack/react-table-v8" {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
interface ColumnMeta<TData extends RowData, TValue> {
|
|
||||||
headerTitle?: string
|
|
||||||
headerClassName?: string
|
|
||||||
cellClassName?: string
|
|
||||||
skeleton?: ReactNode
|
|
||||||
expandedContent?: (row: TData) => ReactNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
|
||||||
export function getColumnHeaderLabel<TData, TValue>(
|
|
||||||
column: Column<TData, TValue>
|
|
||||||
): string {
|
|
||||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
|
||||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
|
||||||
const defHeader = column.columnDef.header
|
|
||||||
if (typeof defHeader === "string") return defHeader
|
|
||||||
return String(column.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridApiFetchParams = {
|
|
||||||
pageIndex: number
|
|
||||||
pageSize: number
|
|
||||||
sorting?: SortingState
|
|
||||||
filters?: ColumnFiltersState
|
|
||||||
searchQuery?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridApiResponse<T> = {
|
|
||||||
data: T[]
|
|
||||||
empty: boolean
|
|
||||||
pagination: {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataGridContextProps<TData extends object> {
|
|
||||||
props: DataGridProps<TData>
|
|
||||||
table: Table<TData>
|
|
||||||
recordCount: number
|
|
||||||
isLoading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DataGridRequestParams = {
|
|
||||||
pageIndex: number
|
|
||||||
pageSize: number
|
|
||||||
sorting?: SortingState
|
|
||||||
columnFilters?: ColumnFiltersState
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataGridProps<TData extends object> {
|
|
||||||
className?: string
|
|
||||||
table?: Table<TData>
|
|
||||||
recordCount: number
|
|
||||||
children?: ReactNode
|
|
||||||
onRowClick?: (row: TData) => void
|
|
||||||
isLoading?: boolean
|
|
||||||
loadingMode?: "skeleton" | "spinner"
|
|
||||||
loadingMessage?: ReactNode | string
|
|
||||||
fetchingMoreMessage?: ReactNode | string
|
|
||||||
allRowsLoadedMessage?: ReactNode | string
|
|
||||||
emptyMessage?: ReactNode | string
|
|
||||||
tableLayout?: {
|
|
||||||
dense?: boolean
|
|
||||||
cellBorder?: boolean
|
|
||||||
rowBorder?: boolean
|
|
||||||
rowRounded?: boolean
|
|
||||||
stripped?: boolean
|
|
||||||
headerBackground?: boolean
|
|
||||||
headerBorder?: boolean
|
|
||||||
headerSticky?: boolean
|
|
||||||
width?: "auto" | "fixed"
|
|
||||||
columnsVisibility?: boolean
|
|
||||||
columnsResizable?: boolean
|
|
||||||
columnsResizeMode?: "onChange" | "onEnd"
|
|
||||||
columnsPinnable?: boolean
|
|
||||||
columnsMovable?: boolean
|
|
||||||
columnsDraggable?: boolean
|
|
||||||
rowsDraggable?: boolean
|
|
||||||
rowsPinnable?: boolean
|
|
||||||
}
|
|
||||||
tableClassNames?: {
|
|
||||||
base?: string
|
|
||||||
header?: string
|
|
||||||
headerRow?: string
|
|
||||||
headerSticky?: string
|
|
||||||
body?: string
|
|
||||||
bodyRow?: string
|
|
||||||
footer?: string
|
|
||||||
edgeCell?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DataGridContext = createContext<
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
DataGridContextProps<any> | undefined
|
|
||||||
>(undefined)
|
|
||||||
|
|
||||||
function useDataGrid() {
|
|
||||||
const context = useContext(DataGridContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridProvider<TData extends object>({
|
|
||||||
children,
|
|
||||||
table,
|
|
||||||
...props
|
|
||||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
|
||||||
const tableState = table.getState()
|
|
||||||
const resolvedColumnsResizeMode =
|
|
||||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
|
||||||
|
|
||||||
// Keep resize mode aligned with the DataGrid contract every render so
|
|
||||||
// consumer-level useReactTable options cannot flip it back between drags.
|
|
||||||
if (props.tableLayout?.columnsResizable) {
|
|
||||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoize context value so consumers don't re-render during column resize.
|
|
||||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
|
||||||
// on the <table> element handle width updates without React re-renders.
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
props,
|
|
||||||
table,
|
|
||||||
recordCount: props.recordCount,
|
|
||||||
isLoading: props.isLoading || false,
|
|
||||||
}),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[
|
|
||||||
table,
|
|
||||||
props.recordCount,
|
|
||||||
props.isLoading,
|
|
||||||
props.loadingMode,
|
|
||||||
props.loadingMessage,
|
|
||||||
props.fetchingMoreMessage,
|
|
||||||
props.allRowsLoadedMessage,
|
|
||||||
props.emptyMessage,
|
|
||||||
props.onRowClick,
|
|
||||||
props.className,
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
JSON.stringify(props.tableLayout),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
JSON.stringify(props.tableClassNames),
|
|
||||||
tableState.sorting,
|
|
||||||
tableState.pagination,
|
|
||||||
tableState.columnFilters,
|
|
||||||
tableState.rowSelection,
|
|
||||||
tableState.expanded,
|
|
||||||
tableState.columnVisibility,
|
|
||||||
tableState.columnOrder,
|
|
||||||
tableState.columnPinning,
|
|
||||||
tableState.globalFilter,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</DataGridContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGrid<TData extends object>({
|
|
||||||
children,
|
|
||||||
table,
|
|
||||||
...props
|
|
||||||
}: DataGridProps<TData>) {
|
|
||||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
|
||||||
loadingMode: "skeleton",
|
|
||||||
tableLayout: {
|
|
||||||
dense: false,
|
|
||||||
cellBorder: false,
|
|
||||||
rowBorder: true,
|
|
||||||
rowRounded: false,
|
|
||||||
stripped: false,
|
|
||||||
headerSticky: false,
|
|
||||||
headerBackground: true,
|
|
||||||
headerBorder: true,
|
|
||||||
width: "fixed",
|
|
||||||
columnsVisibility: false,
|
|
||||||
columnsResizable: false,
|
|
||||||
columnsResizeMode: "onEnd",
|
|
||||||
columnsPinnable: false,
|
|
||||||
columnsMovable: false,
|
|
||||||
columnsDraggable: false,
|
|
||||||
rowsDraggable: false,
|
|
||||||
rowsPinnable: false,
|
|
||||||
},
|
|
||||||
tableClassNames: {
|
|
||||||
base: "",
|
|
||||||
header: "",
|
|
||||||
headerRow: "",
|
|
||||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
|
||||||
body: "",
|
|
||||||
bodyRow: "",
|
|
||||||
footer: "",
|
|
||||||
edgeCell: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedProps: DataGridProps<TData> = {
|
|
||||||
...defaultProps,
|
|
||||||
...props,
|
|
||||||
tableLayout: {
|
|
||||||
...defaultProps.tableLayout,
|
|
||||||
...(props.tableLayout || {}),
|
|
||||||
},
|
|
||||||
tableClassNames: {
|
|
||||||
...defaultProps.tableClassNames,
|
|
||||||
...(props.tableClassNames || {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure table is provided
|
|
||||||
if (!table) {
|
|
||||||
throw new Error('DataGrid requires a "table" prop')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridProvider table={table} {...mergedProps}>
|
|
||||||
{children}
|
|
||||||
</DataGridProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataGridContainer({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
border = true,
|
|
||||||
}: {
|
|
||||||
children: ReactNode
|
|
||||||
className?: string
|
|
||||||
border?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="data-grid"
|
|
||||||
className={cn(
|
|
||||||
"w-full overflow-hidden",
|
|
||||||
border &&
|
|
||||||
"border-border rounded-lg border",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
export {
|
|
||||||
DataGrid,
|
|
||||||
DataGridContainer,
|
|
||||||
DataGridProvider,
|
|
||||||
useDataGrid,
|
|
||||||
getColumnHeaderLabel,
|
|
||||||
type DataGridProps,
|
|
||||||
type DataGridApiFetchParams,
|
|
||||||
type DataGridApiResponse,
|
|
||||||
} from "./data-grid"
|
|
||||||
export { DataGridColumnFilter } from "./data-grid-column-filter"
|
|
||||||
export { DataGridColumnHeader } from "./data-grid-column-header"
|
|
||||||
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
|
|
||||||
export { DataGridPagination } from "./data-grid-pagination"
|
|
||||||
export { DataGridScrollArea } from "./data-grid-scroll-area"
|
|
||||||
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
|
|
||||||
export { DataGridTableDnd } from "./data-grid-table-dnd"
|
|
||||||
export { DataGridTableVirtual } from "./data-grid-table-virtual"
|
|
||||||
export {
|
|
||||||
DataGridTable,
|
|
||||||
DataGridTableBase,
|
|
||||||
DataGridTableBody,
|
|
||||||
DataGridTableBodyRow,
|
|
||||||
DataGridTableBodyRowCell,
|
|
||||||
DataGridTableBodyRowExpandded,
|
|
||||||
DataGridTableEmpty,
|
|
||||||
DataGridTableFoot,
|
|
||||||
DataGridTableFootRow,
|
|
||||||
DataGridTableFootRowCell,
|
|
||||||
DataGridTableHead,
|
|
||||||
DataGridTableHeadRow,
|
|
||||||
DataGridTableHeadRowCell,
|
|
||||||
DataGridTableRowSelect,
|
|
||||||
DataGridTableRowSelectAll,
|
|
||||||
getDataGridTableRowSections,
|
|
||||||
} from "./data-grid-table"
|
|
||||||
@@ -312,6 +312,14 @@ Production build блока 7: `npm run build` прошёл, 34 статичес
|
|||||||
|
|
||||||
Квота: новое окно 0% на старте, 13% использовано после проверок, недельная — 91%. Остаток Data Grid: `components/data-table.tsx`, layout-импорты `DataPageCard`/`DataPageToolbar` и затем удаление общей legacy-основы/v8. Старые Filters остаются у BGP, statistics, backups, backups-history и в fallback DataPageToolbar. Этап 4 не завершён; сохранить блок локальным коммитом без push.
|
Квота: новое окно 0% на старте, 13% использовано после проверок, недельная — 91%. Остаток Data Grid: `components/data-table.tsx`, layout-импорты `DataPageCard`/`DataPageToolbar` и затем удаление общей legacy-основы/v8. Старые Filters остаются у BGP, statistics, backups, backups-history и в fallback DataPageToolbar. Этап 4 не завершён; сохранить блок локальным коммитом без push.
|
||||||
|
|
||||||
|
## Этап 4 — блок 11: удаление старого Data Grid (2026-09-14)
|
||||||
|
|
||||||
|
Мигрирована общая `DataTable` с сохранением опциональной клиентской пагинации (`manualPagination: !pagination`). `DataPageCard` и `DataPageToolbar` используют современный layout; значения CSS-классов совпадают с прежними. Удалены 14 файлов неиспользуемой legacy-копии и обёрток, а также alias `@tanstack/react-table-v8` и его вложенная зависимость из package.json/lockfile. Версия v9 остаётся 9.2.4. Поиск по app/components/lib/package.json/package-lock.json больше не находит импортов legacy Data Grid/v8.
|
||||||
|
|
||||||
|
Проверки после удаления общей основы: TypeScript и ESLint изменённых обёрток — успешно; вся браузерная регрессия — **49 passed**; production build — успешно, 34 статические страницы. У `DataTable` сейчас нет потребителей, её generic API проверен компилятором; пагинация рабочих таблиц покрыта регрессией справочников. Next.js сообщил о восстановлении SWC lockfile, итоговый diff lockfile содержит только удаление v8 и вложенного table-core. Старый Filters ещё не удалён: этап 4 остаётся открытым, ветку не выпускать.
|
||||||
|
|
||||||
|
Следующий блок — BGP и backups/backups-history на FilterQuery, затем statistics с явным ограничением на поддерживаемые API-срезы (AND равенств) и удаление старого Filters/fallback. Сохранить текущий блок отдельным локальным коммитом без push.
|
||||||
|
|
||||||
## Карта миграции (исходная инвентаризация этапа 0)
|
## Карта миграции (исходная инвентаризация этапа 0)
|
||||||
|
|
||||||
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
|
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
|
||||||
|
|||||||
Generated
-35
@@ -20,7 +20,6 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
"@tanstack/react-table": "9.2.4",
|
"@tanstack/react-table": "9.2.4",
|
||||||
"@tanstack/react-table-v8": "npm:@tanstack/react-table@8.21.3",
|
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -4552,40 +4551,6 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-table-v8": {
|
|
||||||
"name": "@tanstack/react-table",
|
|
||||||
"version": "8.21.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
|
||||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/table-core": "8.21.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8",
|
|
||||||
"react-dom": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/react-table-v8/node_modules/@tanstack/table-core": {
|
|
||||||
"version": "8.21.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
|
||||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": {
|
"node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": {
|
||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
"@tanstack/react-table": "9.2.4",
|
"@tanstack/react-table": "9.2.4",
|
||||||
"@tanstack/react-table-v8": "npm:@tanstack/react-table@8.21.3",
|
|
||||||
"@tanstack/react-virtual": "^3.14.4",
|
"@tanstack/react-virtual": "^3.14.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Reference in new issue
Block a user