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:
mlogclub
2026-05-28 09:36:48 +08:00
parent b3f5824fd6
commit d64b0c36f3
10 changed files with 815 additions and 679 deletions
@@ -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()
+6
View File
@@ -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>
}
+9
View File
@@ -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,
}
}