This commit is contained in:
2026-03-27 16:20:46 +03:00
commit 13b56d4960
260 changed files with 111131 additions and 0 deletions
@@ -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,
}
}