mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +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