Files
pn-new-crm/src/hooks/use-paginated-query.ts
Matt 29fb882478 fix(audit): H15 (saved-view sort) + H14 (back/forward URL resync) in usePaginatedQuery
H15: new applyView({filters,sort}) atomic mutator (one URL write) restores a
saved view's sort, threaded through all six list components instead of being
discarded. H14: a guarded effect resyncs page/sort/filters FROM the URL on
Back/Forward; the resync setStates carry a scoped, justified
set-state-in-effect disable (loop-guarded external-URL sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:26:10 +02:00

326 lines
12 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { apiFetch } from '@/lib/api/client';
import type { PaginatedResponse } from '@/types/api';
import {
serializeFiltersToParams,
deserializeFiltersFromParams,
type FilterDefinition,
type FilterValues,
} from '@/components/shared/filter-bar';
interface UsePaginatedQueryOptions {
queryKey: QueryKey;
endpoint: string;
initialPage?: number;
initialPageSize?: number;
/** Default sort applied when the URL has no `sort` param. Lets a list
* surface advertise its preferred ordering (e.g. interests → most-
* recently-updated first) without forcing the rep to click a header
* on each visit. The active sort still serializes to / from the URL,
* so deep-links keep working. */
initialSort?: { field: string; direction: 'asc' | 'desc' };
filterDefinitions?: FilterDefinition[];
}
export function usePaginatedQuery<T>({
queryKey,
endpoint,
initialPage = 1,
initialPageSize = 25,
initialSort,
filterDefinitions = [],
}: UsePaginatedQueryOptions) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
// Read initial state from URL
const pageFromUrl = Number(searchParams.get('page')) || initialPage;
const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize;
const sortFieldFromUrl = searchParams.get('sort') ?? undefined;
const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc';
const [page, setPageState] = useState(pageFromUrl);
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : initialSort,
);
const [filters, setFiltersState] = useState<FilterValues>(() =>
deserializeFiltersFromParams(searchParams, filterDefinitions),
);
// Canonical query string for a given state. Sorted keys so two URLs
// that carry the same params in a different order compare equal - this
// is what lets the back/forward resync effect (H14) tell "the URL the
// browser just restored" apart from "the URL we last wrote ourselves"
// without thrashing on key ordering.
const buildCanonicalQs = useCallback(
(p: number, ps: number, s?: typeof sort, f?: FilterValues): string => {
const params = new URLSearchParams();
if (p !== 1) params.set('page', String(p));
if (ps !== initialPageSize) params.set('limit', String(ps));
if (s) {
params.set('sort', s.field);
params.set('order', s.direction);
}
if (f) {
const filterParams = serializeFiltersToParams(f);
filterParams.forEach((value, key) => params.set(key, value));
}
// Keep existing tab param
const tab = searchParams.get('tab');
if (tab) params.set('tab', tab);
params.sort();
return params.toString();
},
[searchParams, initialPageSize],
);
// Records the query string we last pushed via `router.replace`, so the
// resync effect (H14) can skip params it itself authored and only react
// to genuinely-external URL changes (Back/Forward).
const lastWrittenQsRef = useRef<string | null>(null);
// Sync state to URL
const syncUrl = useCallback(
(p: number, ps: number, s?: typeof sort, f?: FilterValues) => {
const qs = buildCanonicalQs(p, ps, s, f);
lastWrittenQsRef.current = qs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false });
},
[pathname, router, buildCanonicalQs],
);
function setPage(p: number) {
setPageState(p);
syncUrl(p, pageSize, sort, filters);
}
function setPageSize(ps: number) {
setPageSizeState(ps);
setPageState(1);
syncUrl(1, ps, sort, filters);
}
function setSort(field: string, direction: 'asc' | 'desc') {
const newSort = { field, direction };
setSortState(newSort);
setPageState(1);
syncUrl(1, pageSize, newSort, filters);
}
function setFilter(key: string, value: unknown) {
const newFilters = { ...filters };
if (value === undefined || value === null) {
delete newFilters[key];
} else {
newFilters[key] = value;
}
setFiltersState(newFilters);
setPageState(1);
syncUrl(1, pageSize, sort, newFilters);
}
function clearFilters() {
setFiltersState({});
setPageState(1);
syncUrl(1, pageSize, sort, {});
}
/**
* Atomically replace the entire filter set. Used by the saved-views
* apply path - calling `clearFilters()` + N x `setFilter()` in a row
* lost all but the last setFilter because each one reads the stale
* `filters` closure and overwrites with `{...filters, key: val}`.
* setAllFilters writes the whole object in one setState so the view
* lands intact.
*/
function setAllFilters(next: FilterValues) {
setFiltersState(next);
setPageState(1);
syncUrl(1, pageSize, sort, next);
}
/**
* Atomically apply a saved view's filters AND sort in a single URL
* write. The saved-views apply path previously called `setAllFilters`
* only, silently dropping the view's stored sort (H15). Splitting the
* write into `setAllFilters` + `setSort` would also race: each reads a
* stale closure and the second `syncUrl` clobbers the first. `applyView`
* sets both pieces of state and emits ONE `syncUrl`, so the view lands
* intact with its sort.
*
* `sort` is optional - a view saved without an explicit sort clears the
* active sort back to the list's `initialSort` (handled by the consumer
* passing `undefined`). The incoming `direction` is the loose `string`
* shape from `SavedViewsDropdown`; we narrow non-'asc' to 'desc'.
*/
function applyView({
filters: nextFilters,
sort: nextSort,
}: {
filters: FilterValues;
sort?: { field: string; direction: string } | undefined;
}) {
const normalizedSort: { field: string; direction: 'asc' | 'desc' } | undefined = nextSort
? {
field: nextSort.field,
direction: nextSort.direction === 'asc' ? ('asc' as const) : ('desc' as const),
}
: initialSort;
setFiltersState(nextFilters);
setSortState(normalizedSort);
setPageState(1);
syncUrl(1, pageSize, normalizedSort, nextFilters);
}
/**
* H14 - resync state FROM the URL on external navigation (Back/Forward).
*
* The slices above seed from the URL once (useState initializers) then
* drive it one-way via `syncUrl` -> `router.replace`. Nothing pulled the
* URL back into state, so Back/Forward moved the address bar but left the
* list showing the previous page/sort/filters.
*
* Loop safety: every write we make records its canonical query string in
* `lastWrittenQsRef`. Here we re-derive the canonical form of the params
* the router is currently handing us and compare it against that ref. If
* they match, this render was caused by our OWN write - bail before
* touching state. We also compare against the canonical form of CURRENT
* state and only call a setter for a slice that actually differs, so even
* an external change that happens to equal current state is a no-op. Both
* guards mean this effect cannot feed itself: it never calls `syncUrl`,
* and it never setState's a value equal to what state already holds.
*/
useEffect(() => {
const incomingQs = buildCanonicalQs(
Number(searchParams.get('page')) || initialPage,
Number(searchParams.get('limit')) || initialPageSize,
searchParams.get('sort')
? {
field: searchParams.get('sort') as string,
direction: (searchParams.get('order') as 'asc' | 'desc') ?? 'desc',
}
: initialSort,
deserializeFiltersFromParams(searchParams, filterDefinitions),
);
// This params object is the one we just wrote - not an external nav.
if (incomingQs === lastWrittenQsRef.current) return;
// The state already matches the URL - nothing to do (also prevents a
// redundant setState that could schedule an extra render).
const currentQs = buildCanonicalQs(page, pageSize, sort, filters);
if (incomingQs === currentQs) return;
// Genuine external change (Back/Forward): pull each slice from the URL,
// setting only the ones that drifted.
const nextPage = Number(searchParams.get('page')) || initialPage;
const nextPageSize = Number(searchParams.get('limit')) || initialPageSize;
const nextSortField = searchParams.get('sort');
const nextSort = nextSortField
? {
field: nextSortField,
direction: (searchParams.get('order') as 'asc' | 'desc') ?? 'desc',
}
: initialSort;
const nextFilters = deserializeFiltersFromParams(searchParams, filterDefinitions);
/* eslint-disable react-hooks/set-state-in-effect --
Deliberate, guarded URL->state sync on Back/Forward: the URL is the
external source of truth here. The two early-returns above ensure this
runs ONLY on a genuine external navigation (never on our own write), and
each setState is diff-guarded, so there is no cascading-render loop
(audit H14). This is the documented external-store-subscription case the
rule otherwise blocks. */
if (nextPage !== page) setPageState(nextPage);
if (nextPageSize !== pageSize) setPageSizeState(nextPageSize);
if (nextSort?.field !== sort?.field || nextSort?.direction !== sort?.direction) {
setSortState(nextSort);
}
if (JSON.stringify(nextFilters) !== JSON.stringify(filters)) {
setFiltersState(nextFilters);
}
/* eslint-enable react-hooks/set-state-in-effect */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// Build query string for API
const apiParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('limit', String(pageSize));
if (sort) {
params.set('sort', sort.field);
params.set('order', sort.direction);
}
const filterParams = serializeFiltersToParams(filters);
filterParams.forEach((value, key) => params.set(key, value));
return params.toString();
}, [page, pageSize, sort, filters]);
const fullQueryKey = [...queryKey, apiParams];
// Endpoints that already carry a query string (e.g. `/api/v1/documents?tab=eoi_queue`)
// need our pagination params merged with `&`, not a second `?`. Without this guard
// the URL becomes `…?tab=foo?page=1` and the API rejects it as 400.
const separator = endpoint.includes('?') ? '&' : '?';
const { data, isLoading, isFetching } = useQuery<PaginatedResponse<T>>({
queryKey: fullQueryKey,
queryFn: () => apiFetch<PaginatedResponse<T>>(`${endpoint}${separator}${apiParams}`),
});
const pagination = data?.pagination
? {
page: data.pagination.page,
pageSize: data.pagination.pageSize,
total: data.pagination.total,
totalPages: data.pagination.totalPages,
}
: { page, pageSize, total: 0, totalPages: 0 };
/**
* Optimistically removes an item from the cached list (e.g., after archive).
*/
function optimisticRemove(id: string) {
queryClient.setQueryData<PaginatedResponse<T>>(fullQueryKey, (old) => {
if (!old) return old;
return {
...old,
data: old.data.filter((item) => (item as Record<string, unknown>).id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
};
});
}
return {
data: data?.data ?? [],
pagination,
isLoading,
isFetching,
sort,
setSort,
page,
setPage,
pageSize,
setPageSize,
filters,
setFilter,
setAllFilters,
applyView,
clearFilters,
optimisticRemove,
};
}