add query params navigation to filter sorter

This commit is contained in:
sk1982 2024-04-07 02:46:46 -04:00
parent 3f486b81fd
commit b1ea855986
1 changed files with 130 additions and 41 deletions

View File

@ -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<string>,
ascending?: boolean,
pageSize?: Set<string>,
currentPage?: number,
displayMode?: Set<string>
} & { [K: string]: any; };
const FilterSorterComponent = <D, M extends string, N extends string, S extends string>({ defaultData, defaultAscending, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps<D, M, N, S> & { 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 = <K extends keyof LocalState & string>(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 = <S extends object>(params: ReadonlyURLSearchParams, state: S) => Object.fromEntries((Object.entries(state) as Entries<S>).map(([key, val]) => {
return [key, getStateFromParams(params, key as any, val)];
})) as S;
const FilterSorterComponent = <D, M extends string, N extends string, S extends string>({ defaultData, defaultAscending, filterers, data, pageSizes, sorters, displayModes, searcher, className, children }: FilterSorterProps<D, M, N, S> & { defaultData: any; }) => {
const defaultFilterState: Record<N, any> = {} as any;
filterers.forEach(filter => {
defaultFilterState[filter.name] = filter.value;
@ -82,18 +143,21 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
const { sorter: defaultSorter, ascending: storedAscending, pageSize: defaultPageSize, currentPage: defaultCurrentPage,
displayMode: defaultDisplayMode, ...payloadFilterState } = defaultData;
const [filterState, _setFilterState] = useState<typeof defaultFilterState>(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<boolean>(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<typeof defaultFilterState>(getFilterStateFromParams(params, Object.keys(payloadFilterState).length ? payloadFilterState :
defaultFilterState));
const [pageSize, _setPageSize] = useState<Set<string>>(getStateFromParams(params, 'pageSize', defaultPageSize ?? new Set([(pageSizes?.[0] ?? 25).toString()]))!);
const [sorter, _setSorter] = useState<Set<string>>(getStateFromParams(params, 'sorter', defaultSorter ?? new Set(sorters.length ? [sorters[0].name] : []))!);
const [ascending, _setAscending] = useState<boolean>(getStateFromParams(params, 'ascending', storedAscending ?? defaultAscending ?? true)!);
const [displayMode, _setDisplayMode] = useState<Set<string>>(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<string | null>(null);
const lastParams = useRef(params.toString());
const pathname = usePathname();
const prevNonce = useRef(1);
const resetPage = useRef(false);
@ -109,6 +173,18 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
const deps = dataRemote ? [data, filterers, pageSize, filterState, currentPage, ascending, searcher, sorters, sorter, mounted, query] :
[data, filterers, filterState, ascending, searcher, sorters, sorter, mounted, query];
useEffect(() => {
history.replaceState({}, '', `?${getParamsFromState({
sorter,
ascending,
pageSize,
currentPage,
displayMode,
...filterState,
query
})}`);
}, []);
const onChange = useDebounceCallback(useCallback(() => {
if (!mounted()) return;
@ -122,6 +198,12 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
setTotalCount(-1);
}
const newParams = getParamsFromState({ sorter, ascending, pageSize, currentPage: page, displayMode, query, ...filterState });
const paramUrlString = `?${newParams}`;
paramsChangedByState.current = newParams.toString();
if (paramUrlString !== location.search)
history.pushState({}, '', paramUrlString);
const sort = sorters.find(s => sorter.has(s.name))!;
if (Array.isArray(data)) {
const lower = query.toLowerCase();
@ -135,7 +217,7 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
setProcessedData(filteredSorted.reverse());
setTotalCount(filteredSorted.length);
if (!Number.isNaN(pageSizeNum) && currentPage > (filteredSorted.length / pageSizeNum))
if (!Number.isNaN(pageSizeNum) && currentPage > Math.ceil(filteredSorted.length / pageSizeNum))
setCurrentPage(1)
return;
@ -152,7 +234,7 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
}
})
.finally(() => 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 = <D, M extends string, N extends string, S extends
}
}, [pageSize, dataRemote, currentPage]);
useWindowListener('keydown', ev => {
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<LocalState>) => {
payload = {
const updateLocalState = (payload: Partial<LocalState & { query: string; }>) => {
const state = {
sorter,
ascending,
pageSize,
@ -225,13 +313,13 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
...filterState,
...payload,
};
const data = JSON.stringify(payload, (k, v) => 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 = <D, M extends string, N extends string, S extends
}
const setQuery = (q: string) => {
updateLocalState({ query: q });
_setQuery(q);
resetPage.current = true;
}
@ -336,12 +425,12 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
<Input startContent={<SearchIcon />} ref={searchRef} size="sm" label="Search" type="text" isClearable={true} value={query} onValueChange={setQuery} onClear={() => setQuery('')} />
</div>
<div className="flex gap-2 sm:w-1/3 flex-grow sm:flex-grow-0 sm:max-w-80 items-center">
<Select name="page" label="Per Page" size="sm" className="w-1/2" selectedKeys={pageSize} onSelectionChange={sel => sel !== 'all' && sel.size && setPageSize(sel)}>
<Select name="page" label="Per Page" size="sm" className="w-1/2" selectedKeys={pageSize} onSelectionChange={sel => sel !== 'all' && sel.size && setPageSize(sel as Set<string>)}>
{ (pageSizes ?? [25, 50, 100]).map(s => <SelectItem key={s === Infinity ? 'all' : s.toString()} value={s === Infinity ? 'all' : s.toString()}>
{s === Infinity ? 'All' : s.toString()}
</SelectItem>) }
</Select>
<Select name="sort" label="Sort" size="sm" className="w-1/2" selectedKeys={sorter} onSelectionChange={sel => sel !== 'all' && sel.size && setSorter(sel)}>
<Select name="sort" label="Sort" size="sm" className="w-1/2" selectedKeys={sorter} onSelectionChange={sel => sel !== 'all' && sel.size && setSorter(sel as Set<string>)}>
{ sorters.map(s => <SelectItem key={s.name} value={s.name}>
{s.name}
</SelectItem>) }
@ -364,7 +453,7 @@ const FilterSorterComponent = <D, M extends string, N extends string, S extends
{displayModes.find(m => displayMode.has(m.name))?.icon}
</Button>
</DropdownTrigger>
<DropdownMenu selectionMode="single" selectedKeys={displayMode} onSelectionChange={sel => sel !== 'all' && sel.size && setDisplayMode(sel)}>
<DropdownMenu selectionMode="single" selectedKeys={displayMode} onSelectionChange={sel => sel !== 'all' && sel.size && setDisplayMode(sel as Set<string>)}>
{displayModes.map(mode => <DropdownItem key={mode.name}>
{mode.name}
</DropdownItem>)}