diff --git a/src/components/filter-sorter.tsx b/src/components/filter-sorter.tsx index d1072f4..a3498c8 100644 --- a/src/components/filter-sorter.tsx +++ b/src/components/filter-sorter.tsx @@ -5,11 +5,14 @@ import { ComponentProps, ReactNode, useCallback, useEffect, useMemo, useRef, use import { XMarkIcon } from '@heroicons/react/16/solid'; import { ArrowLongUpIcon } from '@heroicons/react/24/solid'; import { useDebounceCallback, useIsMounted } from 'usehooks-ts'; -import { usePathname } from 'next/navigation'; +import { ReadonlyURLSearchParams, usePathname, useSearchParams } from 'next/navigation'; import { SearchIcon } from '@nextui-org/shared-icons'; import { DateSelect } from '@/components/date-select'; import { useBreakpoint } from '@/helpers/use-breakpoint'; import { Awaitable } from '@/types/awaitable'; +import { useWindowListener } from '@/helpers/use-window-listener'; +import { DateRange } from 'react-day-picker'; +import { Entries } from 'type-fest'; type ValueType = { @@ -71,9 +74,67 @@ const debounceOptions = { trailing: true, } as const; -const queryDebounceOptions = { leading: false, trailing: true }; +type LocalState = { + sorter?: Set, + ascending?: boolean, + pageSize?: Set, + currentPage?: number, + displayMode?: Set +} & { [K: string]: any; }; -const FilterSorterComponent = ({ defaultData, defaultAscending, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps & { defaultData: any }) => { +const getParamsFromState = (data: LocalState & { query: string }) => { + const params = new URLSearchParams(); + + Object.entries(data).forEach(([key, val]) => { + if (val === undefined || val === null) return; + + if (typeof val === 'boolean' || typeof val === 'number' || typeof val === 'string') { + params.set(key, val.toString()); + } else if (val instanceof Set || Array.isArray(val)) { + params.set(key, [...val].join(',')); + } else if ('from' in val || 'to' in val) { + if (val.from) + params.set(`${key}-begin`, val.from.toISOString()); + if (val.to) + params.set(`${key}-end`, val.to.toISOString()); + } + }); + + params.sort(); + return params; +}; + +const getStateFromParams = (params: ReadonlyURLSearchParams, key: K, val: LocalState[K]): LocalState[K] => { + if (typeof val === 'boolean') { + if (params.has(key)) + return params.get(key) === 'true'; + } else if (typeof val === 'number') { + return +(params.get(key) ?? val); + } else if (typeof val === 'string') { + return params.get(key) ?? val; + } else if ((val as any) instanceof Set) { + if (params.has(key)) + return new Set(params.get(key)?.split(',')?.filter(x => x)); + } else if (Array.isArray(val)) { + if (params.has(key)) + return params.get(key)!.split(','); + } else if (params.has(`${key}-begin`) || params.has(`${key}-end`)) { + const range: DateRange = { + from: new Date(params.get(`${key}-begin`)!) + }; + if (params.has(`${key}-end`)) + range.to = new Date(params.get(`${key}-end`)!); + return range; + } + + return val; +}; + +const getFilterStateFromParams = (params: ReadonlyURLSearchParams, state: S) => Object.fromEntries((Object.entries(state) as Entries).map(([key, val]) => { + return [key, getStateFromParams(params, key as any, val)]; +})) as S; + +const FilterSorterComponent = ({ defaultData, defaultAscending, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps & { defaultData: any; }) => { const defaultFilterState: Record = {} as any; filterers.forEach(filter => { defaultFilterState[filter.name] = filter.value; @@ -82,18 +143,21 @@ const FilterSorterComponent = (Object.keys(payloadFilterState).length ? payloadFilterState : - defaultFilterState); - const [pageSize, _setPageSize] = useState(defaultPageSize ?? new Set([(pageSizes?.[0] ?? 25).toString()])); - const [sorter, _setSorter] = useState(defaultSorter ?? new Set(sorters.length ? [sorters[0].name] : [])); - const [ascending, _setAscending] = useState(storedAscending ?? defaultAscending ?? true); - const [displayMode, _setDisplayMode] = useState(defaultDisplayMode ?? new Set([displayModes?.length ? displayModes[0].name : ''])); - const [query, _setQuery] = useState(''); + const params = useSearchParams(); + const [filterState, _setFilterState] = useState(getFilterStateFromParams(params, Object.keys(payloadFilterState).length ? payloadFilterState : + defaultFilterState)); + const [pageSize, _setPageSize] = useState>(getStateFromParams(params, 'pageSize', defaultPageSize ?? new Set([(pageSizes?.[0] ?? 25).toString()]))!); + const [sorter, _setSorter] = useState>(getStateFromParams(params, 'sorter', defaultSorter ?? new Set(sorters.length ? [sorters[0].name] : []))!); + const [ascending, _setAscending] = useState(getStateFromParams(params, 'ascending', storedAscending ?? defaultAscending ?? true)!); + const [displayMode, _setDisplayMode] = useState>(getStateFromParams(params, 'displayMode', defaultDisplayMode ?? new Set([displayModes?.length ? displayModes[0].name : '']))!); + const [currentPage, _setCurrentPage] = useState(getStateFromParams(params, 'currentPage', 1)!); + const [query, _setQuery] = useState(getStateFromParams(params, 'query', '')); const [processedData, setProcessedData] = useState(Array.isArray(data) ? data : []); const [totalCount, setTotalCount] = useState(Array.isArray(data) ? data.length : -1); - const [currentPage, _setCurrentPage] = useState(defaultCurrentPage ?? 1); const [selectedKeys, setSelectedKeys] = useState(new Set(['1'])); const [loadingRemoteData, setLoadingRemoteData] = useState(false); + const paramsChangedByState = useRef(null); + const lastParams = useRef(params.toString()); const pathname = usePathname(); const prevNonce = useRef(1); const resetPage = useRef(false); @@ -109,6 +173,18 @@ const FilterSorterComponent = { + history.replaceState({}, '', `?${getParamsFromState({ + sorter, + ascending, + pageSize, + currentPage, + displayMode, + ...filterState, + query + })}`); + }, []); const onChange = useDebounceCallback(useCallback(() => { if (!mounted()) return; @@ -122,6 +198,12 @@ const FilterSorterComponent = sorter.has(s.name))!; if (Array.isArray(data)) { const lower = query.toLowerCase(); @@ -135,7 +217,7 @@ const FilterSorterComponent = (filteredSorted.length / pageSizeNum)) + if (!Number.isNaN(pageSizeNum) && currentPage > Math.ceil(filteredSorted.length / pageSizeNum)) setCurrentPage(1) return; @@ -152,7 +234,7 @@ const FilterSorterComponent = setLoadingRemoteData(false)); - }, deps), dataRemote ? 250 : 100, debounceOptions); + }, [data, filterers, pageSize, filterState, currentPage, ascending, searcher, sorters, sorter, mounted, query]), dataRemote ? 250 : 100, debounceOptions); const onQuery = useDebounceCallback(useCallback(() => { onChange(); @@ -193,30 +275,36 @@ const FilterSorterComponent = { + if (ev.code === 'KeyF' && ev.ctrlKey) { + ev.stopPropagation(); + ev.preventDefault(); + setSelectedKeys(new Set(['1'])); + searchRef.current?.focus(); + } + }); + useEffect(() => { - const cb = (ev: KeyboardEvent) => { - if (ev.code === 'KeyF' && ev.ctrlKey) { - ev.stopPropagation(); - ev.preventDefault(); - setSelectedKeys(new Set(['1'])); - searchRef.current?.focus(); - } - }; + if (params.toString() === lastParams.current) + return; + lastParams.current = params.toString(); + if (paramsChangedByState.current === params.toString()) { + return; + } + paramsChangedByState.current = null; + flush.current = true; + initialQuery.current = true; + _setSorter(getStateFromParams(params, 'sorter', sorter)!); + _setAscending(getStateFromParams(params, 'ascending', ascending)!); + _setPageSize(getStateFromParams(params, 'pageSize', pageSize)!); + _setCurrentPage(getStateFromParams(params, 'currentPage', currentPage)!); + _setDisplayMode(getStateFromParams(params, 'displayMode', displayMode)!); + _setFilterState(getFilterStateFromParams(params, filterState)); + _setQuery(getStateFromParams(params, 'query', query)!); + }, [params, sorter, ascending, pageSize, currentPage, displayMode, filterState, query]); - window.addEventListener('keydown', cb); - - return () => window.removeEventListener('keydown', cb); - }, []); - - type LocalState = { sorter?: typeof sorter, - ascending?: typeof ascending, - pageSize?: typeof pageSize, - currentPage?: typeof currentPage, - displayMode?: typeof displayMode - } & { [K: string]: any }; - - const updateLocalState = (payload: Partial) => { - payload = { + const updateLocalState = (payload: Partial) => { + const state = { sorter, ascending, pageSize, @@ -225,13 +313,13 @@ const FilterSorterComponent = v instanceof Set ? { type: 'set', value: [...v] } : v); + const data = JSON.stringify(state, (k, v) => v instanceof Set ? { type: 'set', value: [...v] } : v); localStorage.setItem(localStateKey, data); - } + }; const setCurrentPage = (currentPage: number) => { updateLocalState({ currentPage }); _setCurrentPage(currentPage); - } + }; const setPageSize = (size: typeof pageSize) => { const sizeNum = +[...size][0]; @@ -273,6 +361,7 @@ const FilterSorterComponent = { + updateLocalState({ query: q }); _setQuery(q); resetPage.current = true; } @@ -336,12 +425,12 @@ const FilterSorterComponent = } ref={searchRef} size="sm" label="Search" type="text" isClearable={true} value={query} onValueChange={setQuery} onClear={() => setQuery('')} />
- sel !== 'all' && sel.size && setPageSize(sel as Set)}> { (pageSizes ?? [25, 50, 100]).map(s => {s === Infinity ? 'All' : s.toString()} ) } - sel !== 'all' && sel.size && setSorter(sel as Set)}> { sorters.map(s => {s.name} ) } @@ -364,7 +453,7 @@ const FilterSorterComponent = displayMode.has(m.name))?.icon} - sel !== 'all' && sel.size && setDisplayMode(sel)}> + sel !== 'all' && sel.size && setDisplayMode(sel as Set)}> {displayModes.map(mode => {mode.name} )}