init
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
export { useResourcePage } from "./use-resource-page"
|
||||
export { ResourcePage } from "./resource-page"
|
||||
|
||||
export type {
|
||||
ResourcePageClient,
|
||||
ResourceItem,
|
||||
UseResourcePageOptions,
|
||||
} from "./use-resource-page"
|
||||
|
||||
export type {
|
||||
ResourceFormProps,
|
||||
ResourcePageColumnHelpers,
|
||||
ResourcePageProps,
|
||||
} from "./resource-page"
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { DashboardHeader } from "@/base/components/layout/dashboard"
|
||||
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
|
||||
import FormDialog from "@/shared/components/form-dialog"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { DataTable, type ActionsColumnOptions } from "@/shared/data-view/table-view"
|
||||
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
export type ResourceFormProps<TClient extends ResourcePageClient> = {
|
||||
resourceId: string | null
|
||||
initialData: ResourceItem<TClient> | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export type ResourcePageColumnHelpers<TClient extends ResourcePageClient> = {
|
||||
actionsColumn: (options?: Partial<ActionsColumnOptions<ResourceItem<TClient>>>) => ColumnDef<ResourceItem<TClient>, unknown>
|
||||
openEdit: (row: ResourceItem<TClient>) => void
|
||||
deleteItem: (id: string) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type ResourcePageProps<TClient extends ResourcePageClient> = UseResourcePageOptions<TClient> & {
|
||||
title: string
|
||||
columns: ColumnDef<ResourceItem<TClient>>[] | ((helpers: ResourcePageColumnHelpers<TClient>) => ColumnDef<ResourceItem<TClient>>[])
|
||||
renderForm: (props: ResourceFormProps<TClient>) => React.ReactNode
|
||||
pageTitle?: string
|
||||
paramKey?: string
|
||||
}
|
||||
|
||||
export function ResourcePage<TClient extends ResourcePageClient>({
|
||||
title,
|
||||
columns: columnsProp,
|
||||
renderForm,
|
||||
pageTitle,
|
||||
routeKey,
|
||||
getClient,
|
||||
queryOptions,
|
||||
paramKey,
|
||||
}: ResourcePageProps<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey })
|
||||
|
||||
const columns = typeof columnsProp === "function"
|
||||
? columnsProp({
|
||||
actionsColumn: page.actionsColumn,
|
||||
openEdit: page.openEdit,
|
||||
deleteItem: page.deleteItem,
|
||||
})
|
||||
: columnsProp
|
||||
|
||||
type ListResponse = { data?: TItem[] }
|
||||
const responseData = page.data as ListResponse | undefined
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
header={
|
||||
<DashboardHeader
|
||||
actions={
|
||||
<FormDialog title={title} paramKey={paramKey}>
|
||||
{(resourceId) =>
|
||||
renderForm({
|
||||
resourceId,
|
||||
initialData: page.selectedItem,
|
||||
onSuccess: () => page.invalidateQuery(),
|
||||
})
|
||||
}
|
||||
</FormDialog>
|
||||
}
|
||||
/>
|
||||
}
|
||||
title={pageTitle}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={(responseData?.data ?? []) as TItem[]}
|
||||
pagination={page.pagination}
|
||||
sorting={page.sorting}
|
||||
onChange={page.handleChange}
|
||||
isLoading={page.isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { confirm } from "@/shared/components/confirm-dialog"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { useDataTableQuery, createActionsColumn, type ActionsColumnOptions } from "@/shared/data-view/table-view"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import type { CrudListItem, BaseCrudItem, CrudListResponse } from "@garage/api"
|
||||
import type { UseQueryOptions } from "@tanstack/react-query"
|
||||
|
||||
type ApiInstance = ReturnType<typeof useAuthApi>
|
||||
|
||||
export type ResourcePageClient = {
|
||||
list(query?: any): Promise<any>
|
||||
destroy(id: string): Promise<any>
|
||||
}
|
||||
|
||||
export type ResourceItem<TClient> = CrudListItem<TClient> & BaseCrudItem
|
||||
|
||||
export type UseResourcePageOptions<TClient extends ResourcePageClient> = {
|
||||
routeKey: string
|
||||
getClient: (api: ApiInstance) => TClient
|
||||
queryOptions?: Omit<UseQueryOptions<CrudListResponse<TClient>>, "queryKey" | "queryFn">
|
||||
paramKey?: string
|
||||
}
|
||||
|
||||
export function useResourcePage<TClient extends ResourcePageClient>({
|
||||
routeKey,
|
||||
getClient,
|
||||
queryOptions,
|
||||
paramKey,
|
||||
}: UseResourcePageOptions<TClient>) {
|
||||
type TItem = ResourceItem<TClient>
|
||||
|
||||
const api = useAuthApi()
|
||||
const client = getClient(api)
|
||||
const { open: openDialog, close: closeDialog, isOpen, resourceId } = useFormDialog(paramKey)
|
||||
const [selectedItem, setSelectedItem] = useState<TItem | null>(null)
|
||||
|
||||
const tableQuery = useDataTableQuery({
|
||||
queryKey: [routeKey],
|
||||
client,
|
||||
queryOptions,
|
||||
})
|
||||
|
||||
const { mutateAsync: deleteItem } = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
const promise = client.destroy(id)
|
||||
toast.promise(promise, {
|
||||
loading: "Deleting...",
|
||||
success: "Deleted successfully",
|
||||
error: "Failed to delete",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => tableQuery.invalidateQuery(),
|
||||
})
|
||||
|
||||
const openEdit = (row: TItem) => {
|
||||
setSelectedItem(row)
|
||||
openDialog(String(row.id))
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setSelectedItem(null)
|
||||
openDialog()
|
||||
}
|
||||
|
||||
const actionsColumn = (options?: Partial<ActionsColumnOptions<TItem>>): ColumnDef<TItem, unknown> =>
|
||||
createActionsColumn<TItem>({
|
||||
onEdit: (row) => openEdit(row),
|
||||
onDelete: async (row) => {
|
||||
const confirmed = await confirm({
|
||||
title: "Delete this item?",
|
||||
description: "This action cannot be undone.",
|
||||
confirmLabel: "Delete",
|
||||
variant: "destructive",
|
||||
})
|
||||
if (confirmed) await deleteItem(String(row.id))
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
return {
|
||||
api,
|
||||
client,
|
||||
isDialogOpen: isOpen,
|
||||
dialogResourceId: resourceId,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
openEdit,
|
||||
openCreate,
|
||||
openDialog,
|
||||
closeDialog,
|
||||
...tableQuery,
|
||||
deleteItem,
|
||||
actionsColumn,
|
||||
}
|
||||
}
|
||||
@@ -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