267 lines
8.2 KiB
TypeScript
Vendored
267 lines
8.2 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import type {
|
|
ColumnFiltersState,
|
|
OnChangeFn,
|
|
PaginationState,
|
|
} from '@tanstack/react-table'
|
|
|
|
type SearchRecord = Record<string, unknown>
|
|
|
|
// Page size persists globally under the classic theme's key (raw number
|
|
// string), so the choice is remembered and carries over from classic.
|
|
const PAGE_SIZE_STORAGE_KEY = 'page-size'
|
|
|
|
function getStoredPageSize(): number | undefined {
|
|
try {
|
|
const n = parseInt(localStorage.getItem(PAGE_SIZE_STORAGE_KEY) ?? '', 10)
|
|
return n > 0 ? n : undefined // n > 0 also rejects NaN
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function setStoredPageSize(size: number) {
|
|
try {
|
|
localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(size))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export type NavigateFn = (opts: {
|
|
search:
|
|
| true
|
|
| SearchRecord
|
|
| ((prev: SearchRecord) => Partial<SearchRecord> | SearchRecord)
|
|
replace?: boolean
|
|
}) => void
|
|
|
|
type UseTableUrlStateParams = {
|
|
search: SearchRecord
|
|
navigate: NavigateFn
|
|
pagination?: {
|
|
pageKey?: string
|
|
pageSizeKey?: string
|
|
defaultPage?: number
|
|
defaultPageSize?: number
|
|
}
|
|
globalFilter?: {
|
|
enabled?: boolean
|
|
key?: string
|
|
trim?: boolean
|
|
}
|
|
columnFilters?: Array<
|
|
| {
|
|
columnId: string
|
|
searchKey: string
|
|
type?: 'string'
|
|
// Optional transformers for custom types
|
|
serialize?: (value: unknown) => unknown
|
|
deserialize?: (value: unknown) => unknown
|
|
}
|
|
| {
|
|
columnId: string
|
|
searchKey: string
|
|
type: 'array'
|
|
serialize?: (value: unknown) => unknown
|
|
deserialize?: (value: unknown) => unknown
|
|
}
|
|
>
|
|
}
|
|
|
|
type UseTableUrlStateReturn = {
|
|
// Global filter
|
|
globalFilter?: string
|
|
onGlobalFilterChange?: OnChangeFn<string>
|
|
// Column filters
|
|
columnFilters: ColumnFiltersState
|
|
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
|
// Pagination
|
|
pagination: PaginationState
|
|
onPaginationChange: OnChangeFn<PaginationState>
|
|
// Helpers
|
|
ensurePageInRange: (
|
|
pageCount: number,
|
|
opts?: { resetTo?: 'first' | 'last' }
|
|
) => void
|
|
}
|
|
|
|
export function useTableUrlState(
|
|
params: UseTableUrlStateParams
|
|
): UseTableUrlStateReturn {
|
|
const {
|
|
search,
|
|
navigate,
|
|
pagination: paginationCfg,
|
|
globalFilter: globalFilterCfg,
|
|
columnFilters: columnFiltersCfg = [],
|
|
} = params
|
|
|
|
const pageKey = paginationCfg?.pageKey ?? ('page' as string)
|
|
const pageSizeKey = paginationCfg?.pageSizeKey ?? ('pageSize' as string)
|
|
const defaultPage = paginationCfg?.defaultPage ?? 1
|
|
const defaultPageSize = paginationCfg?.defaultPageSize ?? 20
|
|
|
|
const globalFilterKey = globalFilterCfg?.key ?? ('filter' as string)
|
|
const globalFilterEnabled = globalFilterCfg?.enabled ?? true
|
|
const trimGlobal = globalFilterCfg?.trim ?? true
|
|
|
|
// Build initial column filters from the current search params
|
|
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
|
const collected: ColumnFiltersState = []
|
|
for (const cfg of columnFiltersCfg) {
|
|
const raw = (search as SearchRecord)[cfg.searchKey]
|
|
const deserialize = cfg.deserialize ?? ((v: unknown) => v)
|
|
if (cfg.type === 'string') {
|
|
const value = (deserialize(raw) as string) ?? ''
|
|
if (typeof value === 'string' && value.trim() !== '') {
|
|
collected.push({ id: cfg.columnId, value })
|
|
}
|
|
} else {
|
|
// default to array type
|
|
const value = (deserialize(raw) as unknown[]) ?? []
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
collected.push({ id: cfg.columnId, value })
|
|
}
|
|
}
|
|
}
|
|
return collected
|
|
}, [columnFiltersCfg, search])
|
|
|
|
const [columnFilters, setColumnFilters] =
|
|
useState<ColumnFiltersState>(initialColumnFilters)
|
|
|
|
// URL 为单一数据源:仅当 search(URL)变化时同步,避免依赖 initialColumnFilters 造成死循环(config 常为内联引用)
|
|
useEffect(() => {
|
|
setColumnFilters(initialColumnFilters)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [search])
|
|
|
|
const pagination: PaginationState = useMemo(() => {
|
|
const rawPage = (search as SearchRecord)[pageKey]
|
|
const rawPageSize = (search as SearchRecord)[pageSizeKey]
|
|
const pageNum = typeof rawPage === 'number' ? rawPage : defaultPage
|
|
const pageSizeNum =
|
|
typeof rawPageSize === 'number'
|
|
? rawPageSize
|
|
: (getStoredPageSize() ?? defaultPageSize)
|
|
return { pageIndex: Math.max(0, pageNum - 1), pageSize: pageSizeNum }
|
|
}, [search, pageKey, pageSizeKey, defaultPage, defaultPageSize])
|
|
|
|
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
const next = typeof updater === 'function' ? updater(pagination) : updater
|
|
const nextPage = next.pageIndex + 1
|
|
const nextPageSize = next.pageSize
|
|
if (nextPageSize !== pagination.pageSize) setStoredPageSize(nextPageSize)
|
|
navigate({
|
|
search: (prev) => ({
|
|
...(prev as SearchRecord),
|
|
[pageKey]: nextPage <= defaultPage ? undefined : nextPage,
|
|
[pageSizeKey]: nextPageSize,
|
|
}),
|
|
})
|
|
}
|
|
|
|
const [globalFilter, setGlobalFilter] = useState<string | undefined>(() => {
|
|
if (!globalFilterEnabled) return undefined
|
|
const raw = (search as SearchRecord)[globalFilterKey]
|
|
return typeof raw === 'string' ? raw : ''
|
|
})
|
|
|
|
const onGlobalFilterChange: OnChangeFn<string> | undefined =
|
|
globalFilterEnabled
|
|
? (updater) => {
|
|
const next =
|
|
typeof updater === 'function'
|
|
? updater(globalFilter ?? '')
|
|
: updater
|
|
const value = trimGlobal ? next.trim() : next
|
|
setGlobalFilter(value)
|
|
navigate({
|
|
search: (prev) => ({
|
|
...(prev as SearchRecord),
|
|
[pageKey]: undefined,
|
|
[globalFilterKey]: value ? value : undefined,
|
|
}),
|
|
})
|
|
}
|
|
: undefined
|
|
|
|
const onColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
|
|
const next =
|
|
typeof updater === 'function' ? updater(columnFilters) : updater
|
|
setColumnFilters(next)
|
|
|
|
const patch: Record<string, unknown> = {}
|
|
|
|
for (const cfg of columnFiltersCfg) {
|
|
const found = next.find((f) => f.id === cfg.columnId)
|
|
const serialize = cfg.serialize ?? ((v: unknown) => v)
|
|
if (cfg.type === 'string') {
|
|
const value =
|
|
typeof found?.value === 'string' ? (found.value as string) : ''
|
|
patch[cfg.searchKey] =
|
|
value.trim() !== '' ? serialize(value) : undefined
|
|
} else {
|
|
const value = Array.isArray(found?.value)
|
|
? (found!.value as unknown[])
|
|
: []
|
|
patch[cfg.searchKey] = value.length > 0 ? serialize(value) : undefined
|
|
}
|
|
}
|
|
|
|
navigate({
|
|
search: (prev) => ({
|
|
...(prev as SearchRecord),
|
|
[pageKey]: undefined,
|
|
...patch,
|
|
}),
|
|
})
|
|
}
|
|
|
|
const ensurePageInRange = (
|
|
pageCount: number,
|
|
opts: { resetTo?: 'first' | 'last' } = { resetTo: 'first' }
|
|
) => {
|
|
const currentPage = (search as SearchRecord)[pageKey]
|
|
const pageNum = typeof currentPage === 'number' ? currentPage : defaultPage
|
|
if (pageCount > 0 && pageNum > pageCount) {
|
|
navigate({
|
|
replace: true,
|
|
search: (prev) => ({
|
|
...(prev as SearchRecord),
|
|
[pageKey]: opts.resetTo === 'last' ? pageCount : undefined,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
return {
|
|
globalFilter: globalFilterEnabled ? (globalFilter ?? '') : undefined,
|
|
onGlobalFilterChange,
|
|
columnFilters,
|
|
onColumnFiltersChange,
|
|
pagination,
|
|
onPaginationChange,
|
|
ensurePageInRange,
|
|
}
|
|
}
|