init
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table"
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
export type ActionsColumnOptions<TData> = {
|
||||
onEdit?: (row: TData) => void
|
||||
onDelete?: (row: TData) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function createActionsColumn<TData extends { id: string | number }>(
|
||||
options: ActionsColumnOptions<TData>,
|
||||
): ColumnDef<TData, unknown> {
|
||||
return {
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => <ActionsCell row={row} options={options} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
}
|
||||
}
|
||||
|
||||
function ActionsCell<TData extends { id: string | number }>({
|
||||
row,
|
||||
options,
|
||||
}: {
|
||||
row: Row<TData>
|
||||
options: ActionsColumnOptions<TData>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{options.onEdit && (
|
||||
<DropdownMenuItem onClick={() => options.onEdit!(row.original)}>
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{options.onDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => options.onDelete!(row.original)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff, X } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
|
||||
interface ColumnHeaderProps<TData, TValue> extends React.ComponentProps<"div"> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function ColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: ColumnHeaderProps<TData, TValue>) {
|
||||
const isSortable = column.getCanSort()
|
||||
const hasDropdown = isSortable
|
||||
|
||||
return (
|
||||
<div data-slot="column-header" className={cn("flex items-center gap-1", className)}>
|
||||
{hasDropdown ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-2 h-8 gap-1">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowDown className="size-3.5" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUp className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{isSortable && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp className="size-3.5 text-muted-foreground" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown className="size-3.5 text-muted-foreground" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
{column.getIsSorted() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.clearSorting()}>
|
||||
<X className="size-3.5 text-muted-foreground" />
|
||||
Clear
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeOff className="size-3.5 text-muted-foreground" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { DataViewProvider } from "./data-view-context"
|
||||
import type { DataViewProps } from "./types"
|
||||
import { DataViewPagination } from "./data-view-pagination"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export function DataTable<TData>({
|
||||
columns,
|
||||
data,
|
||||
pagination,
|
||||
sorting = [],
|
||||
onChange,
|
||||
isLoading = false,
|
||||
}: DataViewProps<TData>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: columns as ColumnDef<TData, unknown>[],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
pageCount: pagination.pageCount,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: {
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(sorting) : updater
|
||||
onChange({ type: "sorting", sorting: next })
|
||||
},
|
||||
onPaginationChange: (updater) => {
|
||||
const current = { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
|
||||
const next = typeof updater === "function" ? updater(current) : updater
|
||||
onChange({
|
||||
type: "pagination",
|
||||
pagination: {
|
||||
page: next.pageIndex + 1,
|
||||
pageSize: next.pageSize,
|
||||
pageCount: pagination.pageCount,
|
||||
total: pagination.total,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DataViewProvider
|
||||
pagination={pagination}
|
||||
sorting={sorting}
|
||||
onChange={onChange}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<div data-slot="data-view" className="flex flex-col gap-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
{columns.map((_, j) => (
|
||||
<TableCell key={`skeleton-${i}-${j}`}>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataViewPagination table={table} />
|
||||
</div>
|
||||
</DataViewProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext } from "react"
|
||||
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "./types"
|
||||
|
||||
type DataViewContextValue = {
|
||||
pagination: DataViewPaginationState
|
||||
sorting: DataViewSorting
|
||||
onChange: (event: DataViewChangeEvent) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const DataViewContext = createContext<DataViewContextValue | null>(null)
|
||||
|
||||
export function DataViewProvider({
|
||||
children,
|
||||
...value
|
||||
}: DataViewContextValue & { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataViewContext.Provider value={value}>
|
||||
{children}
|
||||
</DataViewContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDataView() {
|
||||
const ctx = useContext(DataViewContext)
|
||||
if (!ctx) throw new Error("useDataView must be used within a DataViewProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { useDataView } from "./data-view-context"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
|
||||
|
||||
interface DataViewPaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataViewPagination<TData>({ table }: DataViewPaginationProps<TData>) {
|
||||
const { pagination } = useDataView()
|
||||
|
||||
return (
|
||||
<div data-slot="data-view-pagination" className="flex items-center justify-between px-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.pageCount}
|
||||
{" "}({pagination.total} total)
|
||||
</div>
|
||||
<div className="flex items-center gap-6 lg:gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-17.5">
|
||||
<SelectValue placeholder={String(pagination.pageSize)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { DataTable } from "./data-table"
|
||||
export { ColumnHeader } from "./column-header"
|
||||
export { createActionsColumn } from "./actions-column"
|
||||
export { DataViewProvider, useDataView } from "./data-view-context"
|
||||
export { DataViewPagination } from "./data-view-pagination"
|
||||
export { useDataTableQuery } from "./use-data-table-query"
|
||||
export { dataTableSearchParams, dataTableSearchParamsCache } from "./search-params"
|
||||
export type {
|
||||
DataViewProps,
|
||||
DataViewPaginationState,
|
||||
DataViewSorting,
|
||||
DataViewState,
|
||||
DataViewChangeEvent,
|
||||
} from "./types"
|
||||
export type { DataTableSearchParams } from "./search-params"
|
||||
export type { ActionsColumnOptions } from "./actions-column"
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
createSearchParamsCache,
|
||||
} from "nuqs/server"
|
||||
import { DEFAULT_PER_PAGE } from "@garage/api"
|
||||
|
||||
export const dataTableSearchParams = {
|
||||
page: parseAsInteger.withDefault(1),
|
||||
per_page: parseAsInteger.withDefault(DEFAULT_PER_PAGE),
|
||||
sort_by: parseAsString,
|
||||
sort_order: parseAsStringEnum(["asc", "desc"] as const),
|
||||
}
|
||||
|
||||
export type DataTableSearchParams = typeof dataTableSearchParams
|
||||
|
||||
export const dataTableSearchParamsCache = createSearchParamsCache(dataTableSearchParams)
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table"
|
||||
|
||||
export type DataViewPaginationState = {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageCount: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type DataViewSorting = SortingState
|
||||
|
||||
export type DataViewState = {
|
||||
pagination: DataViewPaginationState
|
||||
sorting: DataViewSorting
|
||||
}
|
||||
|
||||
export type DataViewChangeEvent =
|
||||
| { type: "pagination"; pagination: DataViewPaginationState }
|
||||
| { type: "sorting"; sorting: DataViewSorting }
|
||||
|
||||
export type DataViewProps<TData> = {
|
||||
columns: ColumnDef<TData, any>[]
|
||||
data: TData[]
|
||||
pagination: DataViewPaginationState
|
||||
sorting?: DataViewSorting
|
||||
onChange: (event: DataViewChangeEvent) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryStates } from "nuqs"
|
||||
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"
|
||||
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "./types"
|
||||
import { dataTableSearchParams } from "./search-params"
|
||||
import { type CrudListResponse } from "@garage/api"
|
||||
|
||||
type DataTableClient = {
|
||||
list(query?: any): Promise<any>
|
||||
}
|
||||
|
||||
type UseDataTableQueryOptions<C extends DataTableClient> = {
|
||||
queryKey: string[]
|
||||
client: C
|
||||
queryOptions?: Omit<UseQueryOptions<CrudListResponse<C>>, "queryKey" | "queryFn">
|
||||
}
|
||||
|
||||
export function useDataTableQuery<C extends DataTableClient>({
|
||||
queryKey,
|
||||
client,
|
||||
queryOptions,
|
||||
}: UseDataTableQueryOptions<C>) {
|
||||
const [params, setParams] = useQueryStates(dataTableSearchParams)
|
||||
const _queryKey = [...queryKey, params]
|
||||
const query = useQuery<CrudListResponse<C>>({
|
||||
queryKey: _queryKey,
|
||||
queryFn: () => {
|
||||
const apiParams: Record<string, unknown> = {
|
||||
page: params.page,
|
||||
per_page: params.per_page,
|
||||
}
|
||||
if (params.sort_by) apiParams.sort_by = params.sort_by
|
||||
if (params.sort_order) apiParams.sort_order = params.sort_order
|
||||
|
||||
return client.list(apiParams) as Promise<CrudListResponse<C>>
|
||||
},
|
||||
...queryOptions,
|
||||
})
|
||||
|
||||
const pagination: DataViewPaginationState = {
|
||||
page: params.page,
|
||||
pageSize: params.per_page,
|
||||
pageCount: (query.data as any)?.meta?.last_page ?? 1,
|
||||
total: (query.data as any)?.meta?.total ?? 0,
|
||||
}
|
||||
|
||||
const sorting: DataViewSorting = params.sort_by
|
||||
? [{ id: params.sort_by, desc: params.sort_order === "desc" }]
|
||||
: []
|
||||
|
||||
const handleChange = (event: DataViewChangeEvent) => {
|
||||
switch (event.type) {
|
||||
case "pagination":
|
||||
setParams({
|
||||
page: event.pagination.page,
|
||||
per_page: event.pagination.pageSize,
|
||||
})
|
||||
break
|
||||
case "sorting": {
|
||||
const sort = event.sorting[0]
|
||||
setParams({
|
||||
sort_by: sort?.id ?? null,
|
||||
sort_order: sort ? (sort.desc ? "desc" : "asc") : null,
|
||||
page: 1,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateQuery = () => {
|
||||
queryClient.invalidateQueries({ queryKey: _queryKey })
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
pagination,
|
||||
sorting,
|
||||
params,
|
||||
setParams,
|
||||
handleChange,
|
||||
invalidateQuery,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user