feat(FE-43): create useTableFilter hooks

This commit is contained in:
ValdiANS
2025-10-04 12:13:49 +07:00
parent 20f6686afc
commit df1b4c29e5
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useMemo, useReducer } from 'react';
/** Core filter shape (page + pageSize) extended by your custom fields */
export type TableFilterState<TExtra extends Record<string, unknown>> = {
page: number;
pageSize: number;
} & TExtra;
type Action<TExtra extends Record<string, unknown>> =
| { type: 'SET_PAGE'; page: number }
| { type: 'SET_PAGE_SIZE'; pageSize: number; resetPage?: boolean }
| { type: 'SET_FILTERS'; filters: Partial<TExtra>; resetPage?: boolean }
| {
type: 'UPDATE_FILTER';
key: keyof TExtra;
value: TExtra[keyof TExtra];
resetPage?: boolean;
}
| { type: 'REPLACE_ALL'; next: TableFilterState<TExtra> }
| { type: 'RESET' };
export type UseTableFilterOptions<TExtra extends Record<string, unknown>> = {
/** Initial state; anything you omit falls back to defaults */
initial?: Partial<TableFilterState<TExtra>>;
/** Called after any state change */
onChange?: (state: TableFilterState<TExtra>) => 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<Record<keyof TableFilterState<TExtra>, 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<TExtra extends Record<string, unknown>>(
opts: UseTableFilterOptions<TExtra> | undefined
): TableFilterState<TExtra> {
const defaults = {
page: 1,
pageSize: opts?.defaultPageSize ?? 10,
} as TableFilterState<TExtra>;
return {
...defaults,
...(opts?.initial as object),
} as TableFilterState<TExtra>;
}
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<T extends Record<string, unknown>>(
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<TExtra extends Record<string, unknown>>(
options?: UseTableFilterOptions<TExtra>
) {
const defaults = useMemo(
() => createInitialState<TExtra>(options),
[options]
);
const [state, dispatch] = useReducer(
(
s: TableFilterState<TExtra>,
a: Action<TExtra>
): TableFilterState<TExtra> => {
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<TExtra>;
}
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<TExtra>, resetPage = true) => {
dispatch({ type: 'SET_FILTERS', filters, resetPage });
},
[]
);
const updateFilter = useCallback(
<K extends keyof TExtra>(key: K, value: TExtra[K], resetPage = true) => {
dispatch({ type: 'UPDATE_FILTER', key, value, resetPage });
},
[dispatch]
);
const replaceAll = useCallback((next: TableFilterState<TExtra>) => {
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<string, unknown>
>;
return rest as TExtra;
}, [state]);
/** Map a key using paramMap (if provided) */
const mapKey = useCallback(
(key: string) => {
const m = options?.paramMap as Record<string, string> | 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<string, unknown>;
const baseline = options?.omitDefaultsInUrl
? (defaults as Record<string, unknown>)
: null;
for (const key of Object.keys(source)) {
const value = source[key];
if (value === undefined || value === null) continue;
if (
baseline &&
shallowEqual(
value as Record<string, unknown>,
baseline[key] as Record<string, unknown>
)
) {
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,
};
}