updates
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState, useEffect } from "react"
|
||||
import { FileUp, X, FileText, FileImage, FileSpreadsheet, File } from "lucide-react"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export type DocumentInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"]
|
||||
|
||||
function getFileIcon(file: File) {
|
||||
if (IMAGE_TYPES.includes(file.type)) return FileImage
|
||||
if (file.type === "application/pdf") return FileText
|
||||
if (
|
||||
file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
|
||||
file.type === "application/vnd.ms-excel" ||
|
||||
file.type === "text/csv"
|
||||
) return FileSpreadsheet
|
||||
return File
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function DocumentInputField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
accept = "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt",
|
||||
}: DocumentInputFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const isImage = value && IMAGE_TYPES.includes(value.type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value || !isImage) {
|
||||
setPreview(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(value)
|
||||
setPreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [value, isImage])
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | null) => {
|
||||
onChange(file)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (disabled) return
|
||||
const file = e.dataTransfer.files?.[0] ?? null
|
||||
handleFile(file)
|
||||
},
|
||||
[disabled, handleFile],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setIsDragging(true)
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback(() => setIsDragging(false), [])
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange(null)
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const FileIcon = value ? getFileIcon(value) : FileUp
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"relative flex min-h-35 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-background p-4 text-sm transition-colors",
|
||||
isDragging && "border-primary bg-primary/5",
|
||||
invalid && "border-destructive",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<>
|
||||
{isImage && preview ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-30 max-w-full rounded-md object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<FileIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="max-w-48 truncate font-medium text-foreground">
|
||||
{value.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(value.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-2 rounded-full bg-destructive p-1 text-destructive-foreground shadow-sm hover:bg-destructive/90"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Click or drag & drop a file
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Images, PDF, Word, Excel, CSV, or text files
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState, useEffect } from "react"
|
||||
import { ImagePlus, X } from "lucide-react"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
export type ImageInputFieldProps = BaseFieldControlProps<File | null> & {
|
||||
accept?: string
|
||||
}
|
||||
|
||||
export function ImageInputField({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
disabled,
|
||||
invalid,
|
||||
accept = "image/*",
|
||||
}: ImageInputFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPreview(null)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(value)
|
||||
setPreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [value])
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File | null) => {
|
||||
if (file && !file.type.startsWith("image/")) return
|
||||
onChange(file)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
if (disabled) return
|
||||
const file = e.dataTransfer.files?.[0] ?? null
|
||||
handleFile(file)
|
||||
},
|
||||
[disabled, handleFile],
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) setIsDragging(true)
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback(() => setIsDragging(false), [])
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange(null)
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
inputRef.current?.click()
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onBlur={onBlur}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"relative flex min-h-35 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-background p-4 text-sm transition-colors",
|
||||
isDragging && "border-primary bg-primary/5",
|
||||
invalid && "border-destructive",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="max-h-30 max-w-full rounded-md object-contain"
|
||||
/>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-2 rounded-full bg-destructive p-1 text-destructive-foreground shadow-sm hover:bg-destructive/90"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Click or drag & drop an image
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { DocumentInputField, type DocumentInputFieldProps } from "../controls/document-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfDocumentFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<DocumentInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfDocumentField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfDocumentFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={DocumentInputField} />
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { FieldValues, FieldPath } from "react-hook-form"
|
||||
import { RhfField } from "../rhf-field"
|
||||
import { ImageInputField, type ImageInputFieldProps } from "../controls/image-input-field"
|
||||
import type { BaseFieldControlProps } from "../types"
|
||||
|
||||
type RhfImageFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
} & Omit<ImageInputFieldProps, keyof BaseFieldControlProps<File | null>>
|
||||
|
||||
export function RhfImageField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>(props: RhfImageFieldProps<TValues, TName>) {
|
||||
return <RhfField {...props} component={ImageInputField} />
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export {
|
||||
AsyncMultiSelectField, type AsyncMultiSelectFieldProps,
|
||||
} from "./controls/async-select-field"
|
||||
export { FileInputField, type FileInputFieldProps } from "./controls/file-input-field"
|
||||
export { ImageInputField, type ImageInputFieldProps } from "./controls/image-input-field"
|
||||
export { DocumentInputField, type DocumentInputFieldProps } from "./controls/document-input-field"
|
||||
|
||||
// ── RHF Field Wrappers ──
|
||||
export { RhfTextField } from "./fields/rhf-text-field"
|
||||
@@ -29,5 +31,7 @@ export { RhfTextareaField } from "./fields/rhf-textarea-field"
|
||||
export { RhfCheckboxField } from "./fields/rhf-checkbox-field"
|
||||
export { RhfSelectField } from "./fields/rhf-select-field"
|
||||
export { RhfFileField } from "./fields/rhf-file-field"
|
||||
export { RhfImageField } from "./fields/rhf-image-field"
|
||||
export { RhfDocumentField } from "./fields/rhf-document-field"
|
||||
export { RhfAsyncSelectField, RhfAsyncMultiSelectField, type InlineCreateFormProps, type InlineCreateConfig } from "./fields/rhf-async-select-field"
|
||||
export { SimpleTitleForm, type SimpleTitleFormProps } from "./fields/simple-title-form"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,14 +48,18 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
form.reset(defaultValues)
|
||||
if (initialData) {
|
||||
form.reset({ ...defaultValues, ...initialData } as any)
|
||||
} else {
|
||||
form.reset(defaultValues)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (resolvedData) {
|
||||
form.reset(mapToFormValues(resolvedData) as any)
|
||||
}
|
||||
}, [isEditing, resolvedData]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [isEditing, resolvedData, initialData]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user