refactor: replace DashboardPage and DashboardTableShell with DashboardListPage for notifications and permissions
- Updated DashboardNotificationsPage to utilize DashboardListPage for improved structure and functionality. - Simplified state management and data fetching in DashboardNotificationsPage. - Refactored DashboardPermissionsPage to use DashboardListPage, enhancing filter and pagination handling. - Introduced DashboardListPage component to standardize list rendering with filters and pagination. - Added utility functions for managing filters and pagination in the new DashboardListPage component. - Improved code readability and maintainability by consolidating common logic into reusable components.
This commit is contained in:
@@ -209,6 +209,12 @@ export function DashboardCrudPage<TItem, TPayload>({
|
||||
)
|
||||
const { draftFilters, appliedFilters, setDraftFilter, applyFilters } =
|
||||
useDashboardCrudFilters(filters)
|
||||
const filtersKey = filters
|
||||
.map(
|
||||
(filter) =>
|
||||
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
|
||||
)
|
||||
.join("|")
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(pageSize)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -238,7 +244,8 @@ export function DashboardCrudPage<TItem, TPayload>({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [appliedFilters, fetchList, filters, labels.loadFailed, limit, page])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedFilters, fetchList, filtersKey, labels.loadFailed, limit, page])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { DashboardCrudPage } from "./dashboard-crud-page"
|
||||
export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog"
|
||||
export {
|
||||
buildDashboardCrudQuery,
|
||||
normalizeDashboardCrudPageResult,
|
||||
} from "./dashboard-crud-utils"
|
||||
export {
|
||||
createDashboardStatusColumn,
|
||||
createDashboardStatusToggleAction,
|
||||
@@ -15,6 +19,8 @@ export type {
|
||||
} from "./dashboard-crud-page"
|
||||
export type {
|
||||
DashboardCrudFormField,
|
||||
DashboardCrudFilterStateConfig,
|
||||
DashboardCrudPageResult,
|
||||
DashboardCrudQueryFilter,
|
||||
DashboardCrudQueryValue,
|
||||
} from "./dashboard-crud-utils"
|
||||
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
export function useDashboardCrudFilters(
|
||||
filters: ReadonlyArray<DashboardCrudFilterStateConfig>
|
||||
) {
|
||||
const defaultsKey = filters
|
||||
.map((filter) => `${filter.name}:${String(filter.defaultValue)}`)
|
||||
.join("|")
|
||||
const initialFilters = useMemo(
|
||||
() => buildDashboardCrudInitialFilters(filters),
|
||||
[filters]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[defaultsKey]
|
||||
)
|
||||
const [draftFilters, setDraftFilters] = useState(initialFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState(initialFilters)
|
||||
@@ -33,11 +37,23 @@ export function useDashboardCrudFilters(
|
||||
setAppliedFilters(draftFilters)
|
||||
}
|
||||
|
||||
function applyFilter(name: string, value: string | number | undefined) {
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}))
|
||||
setAppliedFilters((current) => ({
|
||||
...current,
|
||||
[name]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
draftFilters,
|
||||
appliedFilters,
|
||||
setDraftFilter,
|
||||
setDraftFilters,
|
||||
applyFilter,
|
||||
applyFilters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import type { KeyboardEvent, ReactNode } from "react"
|
||||
import { RefreshCwIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
DashboardPage,
|
||||
DashboardTableShell,
|
||||
DashboardTableStateRow,
|
||||
DashboardToolbar,
|
||||
} from "@/components/dashboard-page"
|
||||
import { ListPagination } from "@/components/list-pagination"
|
||||
import { OptionCombobox } from "@/components/option-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import type {
|
||||
DashboardCrudPageResult,
|
||||
DashboardCrudQueryFilter,
|
||||
} from "@/components/dashboard/crud"
|
||||
import {
|
||||
useDashboardPagedList,
|
||||
type DashboardPagedListOptions,
|
||||
} from "./use-dashboard-paged-list"
|
||||
|
||||
export type DashboardListFilter = DashboardCrudQueryFilter & {
|
||||
label: string
|
||||
placeholder?: string
|
||||
defaultValue: string | number
|
||||
type?: "text" | "select" | "segment"
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
options?: ReadonlyArray<{ value: string; label: string }>
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export type DashboardListColumn<TItem> = {
|
||||
key: string
|
||||
label: ReactNode
|
||||
className?: string
|
||||
render: (item: TItem, context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
}
|
||||
|
||||
export type DashboardListRenderContext<TItem> = {
|
||||
result: DashboardCrudPageResult<TItem>
|
||||
loading: boolean
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
export type DashboardListPageProps<TItem> = {
|
||||
filters?: DashboardListFilter[]
|
||||
fetchList: DashboardPagedListOptions<TItem>["fetchList"]
|
||||
columns?: DashboardListColumn<TItem>[]
|
||||
getItemId?: (item: TItem) => string | number
|
||||
renderContent?: (context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
renderToolbarActions?: (context: DashboardListRenderContext<TItem>) => ReactNode
|
||||
getRowClassName?: (item: TItem) => string | undefined
|
||||
onRowClick?: (item: TItem) => void
|
||||
pageSize?: number
|
||||
enabled?: boolean
|
||||
layout?: "page" | "fragment"
|
||||
tableShellClassName?: string
|
||||
labels: {
|
||||
refresh?: string
|
||||
query?: string
|
||||
loading: string
|
||||
empty: string
|
||||
loadFailed: string
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardListPage<TItem>({
|
||||
filters = [],
|
||||
fetchList,
|
||||
columns,
|
||||
getItemId,
|
||||
renderContent,
|
||||
renderToolbarActions,
|
||||
getRowClassName,
|
||||
onRowClick,
|
||||
pageSize,
|
||||
enabled,
|
||||
layout = "page",
|
||||
tableShellClassName,
|
||||
labels,
|
||||
}: DashboardListPageProps<TItem>) {
|
||||
const list = useDashboardPagedList<TItem>({
|
||||
filters,
|
||||
fetchList,
|
||||
pageSize,
|
||||
enabled,
|
||||
loadFailed: labels.loadFailed,
|
||||
})
|
||||
const renderContext: DashboardListRenderContext<TItem> = {
|
||||
result: list.result,
|
||||
loading: list.loading,
|
||||
reload: list.loadData,
|
||||
}
|
||||
|
||||
function handleFilterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
list.applyFilters()
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<DashboardToolbar
|
||||
actions={
|
||||
<>
|
||||
{labels.refresh ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void list.loadData()}
|
||||
disabled={list.loading}
|
||||
>
|
||||
<RefreshCwIcon className={list.loading ? "animate-spin" : undefined} />
|
||||
{labels.refresh}
|
||||
</Button>
|
||||
) : null}
|
||||
{renderToolbarActions?.(renderContext)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{filters.map((filter) => {
|
||||
const value = list.draftFilters[filter.name]
|
||||
if (filter.type === "segment") {
|
||||
return (
|
||||
<div
|
||||
key={filter.name}
|
||||
className={filter.className ?? "flex flex-wrap gap-2"}
|
||||
>
|
||||
{(filter.options ?? []).map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={String(value) === option.value ? "default" : "outline"}
|
||||
onClick={() => list.applyFilter(filter.name, option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (filter.type === "select") {
|
||||
return (
|
||||
<div key={filter.name} className={filter.className ?? "w-full sm:w-40"}>
|
||||
<OptionCombobox
|
||||
value={String(value ?? "")}
|
||||
onChange={(nextValue) =>
|
||||
list.setDraftFilter(filter.name, nextValue || filter.defaultValue)
|
||||
}
|
||||
placeholder={filter.placeholder ?? filter.label}
|
||||
searchPlaceholder={filter.searchPlaceholder}
|
||||
emptyText={filter.emptyText}
|
||||
options={[...(filter.options ?? [])]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
|
||||
<div className={filter.icon ? "relative" : undefined}>
|
||||
{filter.icon ? (
|
||||
<div className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground">
|
||||
{filter.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
value={String(value ?? "")}
|
||||
onChange={(event) =>
|
||||
list.setDraftFilter(filter.name, event.target.value)
|
||||
}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
placeholder={filter.placeholder ?? filter.label}
|
||||
className={filter.inputClassName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filters.some((filter) => filter.type !== "segment") ? (
|
||||
<Button variant="outline" onClick={list.applyFilters} disabled={list.loading}>
|
||||
<SearchIcon />
|
||||
{labels.query}
|
||||
</Button>
|
||||
) : null}
|
||||
</DashboardToolbar>
|
||||
|
||||
<DashboardTableShell
|
||||
className={tableShellClassName}
|
||||
pagination={
|
||||
<ListPagination
|
||||
page={list.result.page.page}
|
||||
total={list.result.page.total}
|
||||
limit={list.result.page.limit}
|
||||
loading={list.loading}
|
||||
onPageChange={list.handlePageChange}
|
||||
onLimitChange={list.handleLimitChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderContent ? (
|
||||
renderContent(renderContext)
|
||||
) : columns ? (
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} className={column.className}>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.result.results.map((item, index) => {
|
||||
const key = getItemId ? getItemId(item) : index
|
||||
return (
|
||||
<TableRow
|
||||
key={key}
|
||||
className={getRowClassName?.(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key} className={column.className}>
|
||||
{column.render(item, renderContext)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{list.loading || list.result.results.length === 0 ? (
|
||||
<DashboardTableStateRow
|
||||
colSpan={columns.length}
|
||||
loading={list.loading}
|
||||
loadingText={labels.loading}
|
||||
emptyText={labels.empty}
|
||||
/>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</DashboardTableShell>
|
||||
</>
|
||||
)
|
||||
|
||||
if (layout === "fragment") {
|
||||
return content
|
||||
}
|
||||
|
||||
return <DashboardPage>{content}</DashboardPage>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { DashboardListPage } from "./dashboard-list-page"
|
||||
export { useDashboardPagedList } from "./use-dashboard-paged-list"
|
||||
export type {
|
||||
DashboardListColumn,
|
||||
DashboardListFilter,
|
||||
DashboardListPageProps,
|
||||
DashboardListRenderContext,
|
||||
} from "./dashboard-list-page"
|
||||
export type { DashboardPagedListOptions } from "./use-dashboard-paged-list"
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
buildDashboardCrudQuery,
|
||||
normalizeDashboardCrudPageResult,
|
||||
type DashboardCrudPageResult,
|
||||
type DashboardCrudFilterStateConfig,
|
||||
type DashboardCrudQueryFilter,
|
||||
type DashboardCrudQueryValue,
|
||||
} from "@/components/dashboard/crud"
|
||||
import { useDashboardCrudFilters } from "@/components/dashboard/crud"
|
||||
|
||||
export type DashboardPagedListFilter = DashboardCrudQueryFilter &
|
||||
DashboardCrudFilterStateConfig
|
||||
|
||||
export type DashboardPagedListOptions<TItem> = {
|
||||
filters: DashboardPagedListFilter[]
|
||||
fetchList: (
|
||||
query: Record<string, DashboardCrudQueryValue>
|
||||
) => Promise<DashboardCrudPageResult<TItem>>
|
||||
pageSize?: number
|
||||
loadFailed: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useDashboardPagedList<TItem>({
|
||||
filters,
|
||||
fetchList,
|
||||
pageSize = 20,
|
||||
loadFailed,
|
||||
enabled = true,
|
||||
}: DashboardPagedListOptions<TItem>) {
|
||||
const filtersKey = filters
|
||||
.map(
|
||||
(filter) =>
|
||||
`${filter.name}:${String(filter.defaultValue)}:${String(filter.allValue)}:${filter.trim ? "1" : "0"}:${filter.valueType ?? ""}`
|
||||
)
|
||||
.join("|")
|
||||
const { draftFilters, appliedFilters, setDraftFilter, applyFilter, applyFilters } =
|
||||
useDashboardCrudFilters(filters)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit, setLimit] = useState(pageSize)
|
||||
const [loading, setLoading] = useState(enabled)
|
||||
const [result, setResult] = useState<DashboardCrudPageResult<TItem>>({
|
||||
results: [],
|
||||
page: { page: 1, limit: pageSize, total: 0 },
|
||||
})
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!enabled) {
|
||||
setLoading(false)
|
||||
setResult({ results: [], page: { page: 1, limit, total: 0 } })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchList(
|
||||
buildDashboardCrudQuery({
|
||||
values: appliedFilters,
|
||||
filters,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
)
|
||||
setResult(normalizeDashboardCrudPageResult(data, page, limit))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : loadFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appliedFilters, enabled, fetchList, filtersKey, limit, loadFailed, page])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
function applyDraftFilters() {
|
||||
applyFilters()
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function applyDraftFilter(name: string, value: string | number | undefined) {
|
||||
applyFilter(name, value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
if (nextPage < 1 || nextPage === page) return
|
||||
setPage(nextPage)
|
||||
}
|
||||
|
||||
function handleLimitChange(nextLimit: number) {
|
||||
if (nextLimit <= 0 || nextLimit === limit) return
|
||||
setLimit(nextLimit)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return {
|
||||
draftFilters,
|
||||
setDraftFilter,
|
||||
applyFilter: applyDraftFilter,
|
||||
applyFilters: applyDraftFilters,
|
||||
page,
|
||||
setPage,
|
||||
limit,
|
||||
setLimit,
|
||||
loading,
|
||||
result,
|
||||
setResult,
|
||||
loadData,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user