/* 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 . 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 // 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) 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 // Column filters columnFilters: ColumnFiltersState onColumnFiltersChange: OnChangeFn // Pagination pagination: PaginationState onPaginationChange: OnChangeFn // 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(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 = (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(() => { if (!globalFilterEnabled) return undefined const raw = (search as SearchRecord)[globalFilterKey] return typeof raw === 'string' ? raw : '' }) const onGlobalFilterChange: OnChangeFn | 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 = (updater) => { const next = typeof updater === 'function' ? updater(columnFilters) : updater setColumnFilters(next) const patch: Record = {} 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, } }