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>
326 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|