mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
feat(FE-43): create useTableFilter hooks
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user