initialize

This commit is contained in:
2026-03-26 03:49:05 +03:00
parent e9211a4aba
commit 5dc54ffd00
146 changed files with 12078 additions and 146 deletions
+14
View File
@@ -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 "@repo/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>
)
}
+127
View File
@@ -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>
)
}
+16
View File
@@ -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,19 @@
"use client"
import {
parseAsInteger,
parseAsString,
parseAsStringEnum,
createSearchParamsCache,
} from "nuqs/server"
export const dataTableSearchParams = {
page: parseAsInteger.withDefault(1),
per_page: parseAsInteger.withDefault(10),
sort_by: parseAsString,
sort_order: parseAsStringEnum(["asc", "desc"] as const),
}
export type DataTableSearchParams = typeof dataTableSearchParams
export const dataTableSearchParamsCache = createSearchParamsCache(dataTableSearchParams)
+28
View File
@@ -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 "@repo/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,
}
}