diff --git a/src/services/hooks/useTableFilter.tsx b/src/services/hooks/useTableFilter.tsx new file mode 100644 index 00000000..d11d477f --- /dev/null +++ b/src/services/hooks/useTableFilter.tsx @@ -0,0 +1,246 @@ +import { useCallback, useMemo, useReducer } from 'react'; + +/** Core filter shape (page + pageSize) extended by your custom fields */ +export type TableFilterState> = { + page: number; + pageSize: number; +} & TExtra; + +type Action> = + | { type: 'SET_PAGE'; page: number } + | { type: 'SET_PAGE_SIZE'; pageSize: number; resetPage?: boolean } + | { type: 'SET_FILTERS'; filters: Partial; resetPage?: boolean } + | { + type: 'UPDATE_FILTER'; + key: keyof TExtra; + value: TExtra[keyof TExtra]; + resetPage?: boolean; + } + | { type: 'REPLACE_ALL'; next: TableFilterState } + | { type: 'RESET' }; + +export type UseTableFilterOptions> = { + /** Initial state; anything you omit falls back to defaults */ + initial?: Partial>; + /** Called after any state change */ + onChange?: (state: TableFilterState) => void; + /** Default page size (if not provided in initial) */ + defaultPageSize?: number; + /** Optional mapping to rename keys when exporting to URL (e.g., { pageSize: "rows" }) */ + paramMap?: Partial, string>>; + /** If true, `toSearchParams`/`toQueryString` will omit values equal to defaults */ + omitDefaultsInUrl?: boolean; +}; + +function clampToInt(n: number, min = 1) { + const v = Number.isFinite(n) ? Math.floor(n) : min; + return v < min ? min : v; +} + +function createInitialState>( + opts: UseTableFilterOptions | undefined +): TableFilterState { + const defaults = { + page: 1, + pageSize: opts?.defaultPageSize ?? 10, + } as TableFilterState; + + return { + ...defaults, + ...(opts?.initial as object), + } as TableFilterState; +} + +function serializeValue(v: unknown): string | null { + if (v === undefined || v === null) return null; + if (v instanceof Date) return v.toISOString(); + if (Array.isArray(v)) return v.map((x) => x ?? '').join(','); // e.g., ids=1,2,3 + const t = typeof v; + if (t === 'string' || t === 'number' || t === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return null; + } +} + +// function shallowEqual(a: unknown, b: unknown): boolean { +// if (a === b) return true; +// if (!a || !b) return false; +// const ka = Object.keys(a); +// const kb = Object.keys(b); +// if (ka.length !== kb.length) return false; +// for (const k of ka) if (a[k] !== b[k]) return false; +// return true; +// } + +function shallowEqual>( + a: T | undefined | null, + b: T | undefined | null +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const ka = Object.keys(a) as (keyof T)[]; + const kb = Object.keys(b) as (keyof T)[]; + if (ka.length !== kb.length) return false; + for (const k of ka) if (a[k] !== b[k]) return false; + return true; +} + +export function useTableFilter>( + options?: UseTableFilterOptions +) { + const defaults = useMemo( + () => createInitialState(options), + [options] + ); + + const [state, dispatch] = useReducer( + ( + s: TableFilterState, + a: Action + ): TableFilterState => { + switch (a.type) { + case 'SET_PAGE': + return { ...s, page: clampToInt(a.page) }; + case 'SET_PAGE_SIZE': { + const pageSize = clampToInt(a.pageSize); + const page = a.resetPage ? 1 : s.page; + return { ...s, pageSize, page }; + } + case 'SET_FILTERS': { + const page = a.resetPage ? 1 : s.page; + return { ...s, ...a.filters, page }; + } + case 'UPDATE_FILTER': { + const page = a.resetPage ? 1 : s.page; + return { ...s, [a.key]: a.value, page } as TableFilterState; + } + case 'REPLACE_ALL': + return { + ...a.next, + page: clampToInt(a.next.page), + pageSize: clampToInt(a.next.pageSize), + }; + case 'RESET': + return defaults; + default: + return s; + } + }, + defaults + ); + + // Notify consumer on change (stable ref) + const onChange = options?.onChange; + useMemo(() => { + if (onChange) onChange(state); + }, [state, onChange]); + + // Helpers (stable) + const setPage = useCallback((page: number) => { + dispatch({ type: 'SET_PAGE', page }); + }, []); + + const setPageSize = useCallback((pageSize: number, resetPage = true) => { + dispatch({ type: 'SET_PAGE_SIZE', pageSize, resetPage }); + }, []); + + const setFilters = useCallback( + (filters: Partial, resetPage = true) => { + dispatch({ type: 'SET_FILTERS', filters, resetPage }); + }, + [] + ); + + const updateFilter = useCallback( + (key: K, value: TExtra[K], resetPage = true) => { + dispatch({ type: 'UPDATE_FILTER', key, value, resetPage }); + }, + [dispatch] + ); + + const replaceAll = useCallback((next: TableFilterState) => { + dispatch({ type: 'REPLACE_ALL', next }); + }, []); + + const reset = useCallback(() => dispatch({ type: 'RESET' }), []); + + const core = useMemo( + () => ({ page: state.page, pageSize: state.pageSize }), + [state.page, state.pageSize] + ); + + const extras = useMemo(() => { + const { page, pageSize, ...rest } = state as TableFilterState< + Record + >; + return rest as TExtra; + }, [state]); + + /** Map a key using paramMap (if provided) */ + const mapKey = useCallback( + (key: string) => { + const m = options?.paramMap as Record | undefined; + return (m && m[key]) || key; + }, + [options?.paramMap] + ); + + /** Build URLSearchParams from current state */ + const toSearchParams = useCallback(() => { + const params = new URLSearchParams(); + const source = state as Record; + const baseline = options?.omitDefaultsInUrl + ? (defaults as Record) + : null; + + for (const key of Object.keys(source)) { + const value = source[key]; + if (value === undefined || value === null) continue; + + if ( + baseline && + shallowEqual( + value as Record, + baseline[key] as Record + ) + ) { + continue; + } + + const mapped = mapKey(key); + const serialized = serializeValue(value); + if (serialized !== null) params.set(mapped, serialized); + } + return params; + }, [state, defaults, options?.omitDefaultsInUrl, mapKey]); + + /** Build query string (prefixed with '?', or empty string if none) */ + const toQueryString = useCallback(() => { + const sp = toSearchParams(); + const s = sp.toString(); + return s ? `?${s}` : ''; + }, [toSearchParams]); + + return { + /** Full state (page, pageSize, and extras) */ + state, + /** Convenience accessors */ + page: state.page, + pageSize: state.pageSize, + filters: extras, + /** Setters */ + setPage, + setPageSize, + setFilters, + updateFilter, + replaceAll, + reset, + /** Sometimes handy to have just the core pair */ + core, + /** URL helpers */ + toSearchParams, + toQueryString, + }; +}