This commit is contained in:
2026-04-01 15:34:50 +03:00
parent 13b56d4960
commit 5f3d208158
120 changed files with 36338 additions and 21265 deletions
@@ -10,5 +10,7 @@ export type {
export type {
ResourceFormProps,
ResourcePageColumnHelpers,
ResourcePageHeaderHelpers,
ResourcePageContext,
ResourcePageProps,
} from "./resource-page"
@@ -1,9 +1,8 @@
"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 type { DashboardHeaderProps } from "@/base/components/layout/dashboard"
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"
@@ -15,32 +14,60 @@ export type ResourceFormProps<TClient extends ResourcePageClient> = {
onSuccess: () => void
}
export type ResourcePageHeaderHelpers<TClient extends ResourcePageClient> = {
selectedItem: ResourceItem<TClient> | null
invalidateQuery: () => 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 ResourcePageContext<TClient extends ResourcePageClient> = {
selectedItem: ResourceItem<TClient> | null
isDialogOpen: boolean
dialogResourceId: string | null
isLoading: boolean
data: ResourceItem<TClient>[]
openCreate: () => void
openEdit: (row: ResourceItem<TClient>) => void
closeDialog: () => void
deleteItem: (id: string) => Promise<unknown>
invalidateQuery: () => void
}
type ReactNodeOrRender<TClient extends ResourcePageClient> =
| React.ReactNode
| ((context: ResourcePageContext<TClient>) => React.ReactNode)
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
headerProps?: DashboardHeaderProps | ((helpers: ResourcePageHeaderHelpers<TClient>) => DashboardHeaderProps)
header?: ReactNodeOrRender<TClient> | null
pageTitle?: string
paramKey?: string
onRowClick?: (row: ResourceItem<TClient>) => void
toolbar?: ReactNodeOrRender<TClient>
}
export function ResourcePage<TClient extends ResourcePageClient>({
title,
columns: columnsProp,
renderForm,
headerProps: headerPropsProp,
header,
pageTitle,
routeKey,
getClient,
queryOptions,
paramKey,
onRowClick,
toolbar,
extraParams,
}: ResourcePageProps<TClient>) {
type TItem = ResourceItem<TClient>
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey })
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
const columns = typeof columnsProp === "function"
? columnsProp({
@@ -52,35 +79,48 @@ export function ResourcePage<TClient extends ResourcePageClient>({
type ListResponse = { data?: TItem[] }
const responseData = page.data as ListResponse | undefined
const items = (responseData?.data ?? []) as TItem[]
const context: ResourcePageContext<TClient> = {
selectedItem: page.selectedItem,
isDialogOpen: page.isDialogOpen,
dialogResourceId: page.dialogResourceId,
isLoading: page.isLoading,
data: items,
openCreate: page.openCreate,
openEdit: page.openEdit,
closeDialog: page.closeDialog,
deleteItem: page.deleteItem,
invalidateQuery: () => page.invalidateQuery(),
}
const resolvedHeaderProps = typeof headerPropsProp === "function"
? headerPropsProp({
selectedItem: page.selectedItem,
invalidateQuery: () => page.invalidateQuery(),
})
: headerPropsProp
const resolvedHeader = typeof header === "function" ? header(context) : header
const resolvedToolbar = typeof toolbar === "function" ? toolbar(context) : toolbar
return (
<DashboardPage
header={
<DashboardHeader
actions={
<FormDialog title={title} paramKey={paramKey}>
{(resourceId) =>
renderForm({
resourceId,
initialData: page.selectedItem,
onSuccess: () => page.invalidateQuery(),
})
}
</FormDialog>
}
/>
}
header={resolvedHeader}
headerProps={resolvedHeaderProps}
title={pageTitle}
toolbar={resolvedToolbar}
>
<Card>
<CardContent>
<DataTable
columns={columns}
data={(responseData?.data ?? []) as TItem[]}
data={items}
pagination={page.pagination}
sorting={page.sorting}
onChange={page.handleChange}
isLoading={page.isLoading}
onRowClick={onRowClick}
/>
</CardContent>
</Card>
@@ -25,6 +25,7 @@ export type UseResourcePageOptions<TClient extends ResourcePageClient> = {
getClient: (api: ApiInstance) => TClient
queryOptions?: Omit<UseQueryOptions<CrudListResponse<TClient>>, "queryKey" | "queryFn">
paramKey?: string
extraParams?: Record<string, unknown>
}
export function useResourcePage<TClient extends ResourcePageClient>({
@@ -32,6 +33,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
getClient,
queryOptions,
paramKey,
extraParams,
}: UseResourcePageOptions<TClient>) {
type TItem = ResourceItem<TClient>
@@ -44,6 +46,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
queryKey: [routeKey],
client,
queryOptions,
extraParams,
})
const { mutateAsync: deleteItem } = useMutation({
@@ -10,6 +10,7 @@ import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
@@ -26,6 +27,8 @@ export function DataTable<TData>({
sorting = [],
onChange,
isLoading = false,
onRowClick,
slots,
}: DataViewProps<TData>) {
const table = useReactTable({
data,
@@ -67,9 +70,12 @@ export function DataTable<TData>({
onChange={onChange}
isLoading={isLoading}
>
<div data-slot="data-view" className="flex flex-col gap-4">
<div className="rounded-md border">
<Table>
<div data-slot="data-view" className="flex flex-col gap-2">
{slots?.actions && (
<div data-slot="data-view-actions">{slots.actions}</div>
)}
<div className="rounded-md border overflow-auto">
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -85,6 +91,7 @@ export function DataTable<TData>({
))}
</TableRow>
))}
{slots?.extraHeader}
</TableHeader>
<TableBody>
{isLoading ? (
@@ -99,7 +106,12 @@ export function DataTable<TData>({
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={onRowClick ? "cursor-pointer" : undefined}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -117,7 +129,11 @@ export function DataTable<TData>({
</TableCell>
</TableRow>
)}
{slots?.extraBody}
</TableBody>
{slots?.footer && (
<TableFooter>{slots.footer}</TableFooter>
)}
</Table>
</div>
<DataViewPagination table={table} />
@@ -11,6 +11,7 @@ export type {
DataViewSorting,
DataViewState,
DataViewChangeEvent,
DataViewSlots,
} from "./types"
export type { DataTableSearchParams } from "./search-params"
export type { ActionsColumnOptions } from "./actions-column"
@@ -1,4 +1,5 @@
import type { ColumnDef, SortingState } from "@tanstack/react-table"
import type { ReactNode } from "react"
export type DataViewPaginationState = {
page: number
@@ -18,6 +19,13 @@ export type DataViewChangeEvent =
| { type: "pagination"; pagination: DataViewPaginationState }
| { type: "sorting"; sorting: DataViewSorting }
export type DataViewSlots = {
actions?: ReactNode
extraHeader?: ReactNode
extraBody?: ReactNode
footer?: ReactNode
}
export type DataViewProps<TData> = {
columns: ColumnDef<TData, any>[]
data: TData[]
@@ -25,4 +33,6 @@ export type DataViewProps<TData> = {
sorting?: DataViewSorting
onChange: (event: DataViewChangeEvent) => void
isLoading?: boolean
onRowClick?: (row: TData) => void
slots?: DataViewSlots
}
@@ -14,21 +14,24 @@ type UseDataTableQueryOptions<C extends DataTableClient> = {
queryKey: string[]
client: C
queryOptions?: Omit<UseQueryOptions<CrudListResponse<C>>, "queryKey" | "queryFn">
extraParams?: Record<string, unknown>
}
export function useDataTableQuery<C extends DataTableClient>({
queryKey,
client,
queryOptions,
extraParams,
}: UseDataTableQueryOptions<C>) {
const [params, setParams] = useQueryStates(dataTableSearchParams)
const _queryKey = [...queryKey, params]
const _queryKey = [...queryKey, params, ...(extraParams ? [extraParams] : [])]
const query = useQuery<CrudListResponse<C>>({
queryKey: _queryKey,
queryFn: () => {
const apiParams: Record<string, unknown> = {
page: params.page,
per_page: params.per_page,
...extraParams,
}
if (params.sort_by) apiParams.sort_by = params.sort_by
if (params.sort_order) apiParams.sort_order = params.sort_order