mirror of
https://github.com/coder/coder.git
synced 2026-11-03 22:25:31 +08:00
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import clamp from "lodash/clamp";
|
|
import { useEffect, useEffectEvent } from "react";
|
|
import {
|
|
keepPreviousData,
|
|
type QueryFunctionContext,
|
|
type QueryKey,
|
|
type UseQueryOptions,
|
|
type UseQueryResult,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "react-query";
|
|
import { type SetURLSearchParams, useSearchParams } from "react-router";
|
|
|
|
const DEFAULT_RECORDS_PER_PAGE = 25;
|
|
|
|
/**
|
|
* The key to use for getting/setting the page number from the search params
|
|
*/
|
|
const PAGE_NUMBER_PARAMS_KEY = "page";
|
|
|
|
/**
|
|
* A more specialized version of UseQueryOptions built specifically for
|
|
* paginated queries.
|
|
*/
|
|
export type UsePaginatedQueryOptions<
|
|
// Aside from TQueryPayload, all type parameters come from the base React
|
|
// Query type definition, and are here for compatibility
|
|
TQueryFnData extends PaginatedData = PaginatedData,
|
|
TQueryPayload = never,
|
|
TError = unknown,
|
|
TData = TQueryFnData,
|
|
TQueryKey extends QueryKey = QueryKey,
|
|
> = BasePaginationOptions<TQueryFnData, TError, TData, TQueryKey> &
|
|
QueryPayloadExtender<TQueryPayload> & {
|
|
/**
|
|
* An optional dependency for React Router's URLSearchParams. If this is
|
|
* provided, all URL state changes will go through this object instead of
|
|
* an internal value.
|
|
*/
|
|
searchParams?: URLSearchParams;
|
|
|
|
/**
|
|
* A function that takes pagination information and produces a full query
|
|
* key.
|
|
*
|
|
* Must be a function so that it can be used for the active query, and then
|
|
* reused for any prefetching queries (swapping the page number out).
|
|
*/
|
|
queryKey: (params: QueryPageParamsWithPayload<TQueryPayload>) => TQueryKey;
|
|
|
|
/**
|
|
* A version of queryFn that is required and that exposes the pagination
|
|
* information through its query function context argument
|
|
*/
|
|
queryFn: (
|
|
context: PaginatedQueryFnContext<TQueryKey, TQueryPayload>,
|
|
) => TQueryFnData | Promise<TQueryFnData>;
|
|
|
|
/**
|
|
* A custom, optional function for handling what happens if the user
|
|
* navigates to a page that doesn't exist for the paginated data.
|
|
*
|
|
* If this function is not defined/provided when an invalid page is
|
|
* encountered, usePaginatedQuery will default to navigating the user to the
|
|
* closest valid page.
|
|
*/
|
|
onInvalidPageChange?: (params: InvalidPageParams) => void;
|
|
|
|
/**
|
|
* Defaults to 25 records per page.
|
|
*/
|
|
recordsPerPage?: number;
|
|
|
|
/**
|
|
* Defaults to false. Preserves scroll position when pagination updates the URL.
|
|
*/
|
|
preventScrollReset?: boolean;
|
|
|
|
/**
|
|
* Defaults to true. Allows you to disable prefetches for pages where making
|
|
* a request is very expensive.
|
|
*/
|
|
prefetch?: boolean;
|
|
};
|
|
|
|
/**
|
|
* The result of calling usePaginatedQuery. Mirrors the result of the base
|
|
* useQuery as closely as possible, while adding extra pagination properties
|
|
*/
|
|
export type UsePaginatedQueryResult<
|
|
TData = unknown,
|
|
TError = unknown,
|
|
> = UseQueryResult<TData, TError> & PaginationResultInfo;
|
|
|
|
export function usePaginatedQuery<
|
|
TQueryFnData extends PaginatedData = PaginatedData,
|
|
TQueryPayload = never,
|
|
TError = unknown,
|
|
TData extends PaginatedData = TQueryFnData,
|
|
TQueryKey extends QueryKey = QueryKey,
|
|
>(
|
|
options: UsePaginatedQueryOptions<
|
|
TQueryFnData,
|
|
TQueryPayload,
|
|
TError,
|
|
TData,
|
|
TQueryKey
|
|
>,
|
|
): UsePaginatedQueryResult<TData, TError> {
|
|
const {
|
|
queryKey,
|
|
queryPayload,
|
|
onInvalidPageChange,
|
|
searchParams: outerSearchParams,
|
|
queryFn: outerQueryFn,
|
|
recordsPerPage = DEFAULT_RECORDS_PER_PAGE,
|
|
preventScrollReset = false,
|
|
prefetch = true,
|
|
staleTime = 60 * 1000, // One minute
|
|
...extraOptions
|
|
} = options;
|
|
|
|
const [innerSearchParams, setSearchParams] = useSearchParams();
|
|
const searchParams = outerSearchParams ?? innerSearchParams;
|
|
|
|
const limit = recordsPerPage;
|
|
const currentPage = parsePage(searchParams);
|
|
const currentPageOffset = (currentPage - 1) * limit;
|
|
|
|
type Options = UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>;
|
|
const getQueryOptionsFromPage = (pageNumber: number): Options => {
|
|
const pageParams: QueryPageParams = {
|
|
pageNumber,
|
|
limit,
|
|
offset: (pageNumber - 1) * limit,
|
|
searchParams: getParamsWithoutPage(searchParams),
|
|
};
|
|
|
|
const payload = queryPayload?.(pageParams) as RuntimePayload<TQueryPayload>;
|
|
return {
|
|
staleTime,
|
|
queryKey: queryKey({ ...pageParams, payload }),
|
|
queryFn: (context: QueryFunctionContext<TQueryKey>) => {
|
|
return outerQueryFn({ ...context, ...pageParams, payload });
|
|
},
|
|
};
|
|
};
|
|
|
|
// Not using infinite query right now because that requires a fair bit of list
|
|
// virtualization as the lists get bigger (especially for the audit logs).
|
|
// Keeping initial implementation simple.
|
|
const query = useQuery<TQueryFnData, TError, TData, TQueryKey>({
|
|
...extraOptions,
|
|
...getQueryOptionsFromPage(currentPage),
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
|
|
const count = query.data?.count;
|
|
const countCap = query.data?.count_cap;
|
|
const countIsCapped =
|
|
countCap !== undefined &&
|
|
countCap > 0 &&
|
|
count !== undefined &&
|
|
count > countCap;
|
|
const totalRecords = countIsCapped ? countCap : count;
|
|
let totalPages =
|
|
totalRecords !== undefined
|
|
? Math.max(
|
|
Math.ceil(totalRecords / limit),
|
|
// True count is not known; let them navigate forward
|
|
// until they hit an empty page (checked below).
|
|
countIsCapped ? currentPage : 0,
|
|
)
|
|
: undefined;
|
|
|
|
// When the true count is unknown, the user can navigate past
|
|
// all actual data. If that happens, we need to redirect (via
|
|
// updatePageIfInvalid) to the last page guaranteed to be not
|
|
// empty.
|
|
const pageIsEmpty =
|
|
query.data != null &&
|
|
!Object.values(query.data).some((v) => Array.isArray(v) && v.length > 0);
|
|
if (pageIsEmpty) {
|
|
totalPages = count !== undefined ? Math.ceil(count / limit) : 1;
|
|
}
|
|
|
|
const hasNextPage =
|
|
totalRecords !== undefined &&
|
|
((countIsCapped && !pageIsEmpty) ||
|
|
limit + currentPageOffset < totalRecords);
|
|
const hasPreviousPage =
|
|
totalRecords !== undefined &&
|
|
currentPage > 1 &&
|
|
((countIsCapped && !pageIsEmpty) ||
|
|
currentPageOffset - limit < totalRecords);
|
|
|
|
const queryClient = useQueryClient();
|
|
const prefetchPage = useEffectEvent((newPage: number) => {
|
|
if (!prefetch) {
|
|
return;
|
|
}
|
|
|
|
const options = getQueryOptionsFromPage(newPage);
|
|
return queryClient.prefetchQuery(options);
|
|
});
|
|
|
|
// Have to split hairs and sync on both the current page and the hasXPage
|
|
// variables, because the page can change immediately client-side, but the
|
|
// hasXPage values are derived from the server and won't always be immediately
|
|
// ready on the initial render
|
|
useEffect(() => {
|
|
if (hasNextPage) {
|
|
void prefetchPage(currentPage + 1);
|
|
}
|
|
}, [currentPage, hasNextPage]);
|
|
|
|
useEffect(() => {
|
|
if (hasPreviousPage) {
|
|
void prefetchPage(currentPage - 1);
|
|
}
|
|
}, [currentPage, hasPreviousPage]);
|
|
|
|
// Mainly here to catch user if they navigate to a page directly via URL;
|
|
// totalPages parameterized to insulate function from fetch status changes
|
|
const updatePageIfInvalid = useEffectEvent(async (totalPages: number) => {
|
|
// If totalPages is 0, that's a sign that the currentPage overshot, and the
|
|
// API returned a count of 0 because it didn't know how to process the query
|
|
let fixedTotalPages: number;
|
|
if (totalPages !== 0) {
|
|
fixedTotalPages = totalPages;
|
|
} else {
|
|
const firstPageOptions = getQueryOptionsFromPage(1);
|
|
try {
|
|
const firstPageResult = await queryClient.fetchQuery(firstPageOptions);
|
|
const rounded = Math.ceil(firstPageResult?.count ?? 0 / limit);
|
|
fixedTotalPages = Math.max(rounded, 1);
|
|
} catch {
|
|
fixedTotalPages = 1;
|
|
}
|
|
}
|
|
|
|
const clamped = clamp(currentPage, 1, fixedTotalPages);
|
|
if (currentPage === clamped) {
|
|
return;
|
|
}
|
|
|
|
const withoutPage = getParamsWithoutPage(searchParams);
|
|
if (onInvalidPageChange === undefined) {
|
|
const nextSearchParams = outerSearchParams ?? withoutPage;
|
|
nextSearchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped));
|
|
setSearchParams(nextSearchParams, { preventScrollReset });
|
|
} else {
|
|
const params: InvalidPageParams = {
|
|
limit,
|
|
setSearchParams,
|
|
offset: currentPageOffset,
|
|
searchParams: withoutPage,
|
|
totalPages: fixedTotalPages,
|
|
pageNumber: currentPage,
|
|
};
|
|
|
|
onInvalidPageChange(params);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!query.isFetching &&
|
|
totalPages !== undefined &&
|
|
currentPage > totalPages
|
|
) {
|
|
void updatePageIfInvalid(totalPages);
|
|
}
|
|
}, [query.isFetching, totalPages, currentPage]);
|
|
|
|
const onPageChange = (newPage: number) => {
|
|
// Page 1 is the only page that can be safely navigated to without knowing
|
|
// totalPages; no reliance on server data for math calculations
|
|
if (totalPages === undefined && newPage !== 1) {
|
|
return;
|
|
}
|
|
|
|
// If the true count is unknown, we allow navigating past the
|
|
// known page range.
|
|
const upperBound = countIsCapped
|
|
? Number.MAX_SAFE_INTEGER
|
|
: (totalPages ?? 1);
|
|
const cleanedInput = clamp(Math.trunc(newPage), 1, upperBound);
|
|
if (Number.isNaN(cleanedInput)) {
|
|
return;
|
|
}
|
|
|
|
const nextSearchParams =
|
|
outerSearchParams ?? new URLSearchParams(searchParams);
|
|
nextSearchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput));
|
|
setSearchParams(nextSearchParams, { preventScrollReset });
|
|
};
|
|
|
|
// Have to do a type assertion for final return type to make React Query's
|
|
// internal types happy; splitting type definitions up to limit risk of the
|
|
// type assertion silencing type warnings we actually want to pay attention to
|
|
const info: PaginationResultInfo = {
|
|
limit,
|
|
currentPage,
|
|
onPageChange,
|
|
goToFirstPage: () => onPageChange(1),
|
|
|
|
goToPreviousPage: () => {
|
|
if (hasPreviousPage) {
|
|
onPageChange(currentPage - 1);
|
|
}
|
|
},
|
|
|
|
goToNextPage: () => {
|
|
if (hasNextPage) {
|
|
onPageChange(currentPage + 1);
|
|
}
|
|
},
|
|
|
|
...(query.isSuccess
|
|
? {
|
|
isSuccess: true,
|
|
hasNextPage,
|
|
hasPreviousPage,
|
|
totalRecords: totalRecords as number,
|
|
totalPages: totalPages as number,
|
|
currentOffsetStart: currentPageOffset + 1,
|
|
countIsCapped,
|
|
}
|
|
: {
|
|
isSuccess: false,
|
|
hasNextPage: false,
|
|
hasPreviousPage: false,
|
|
totalRecords: undefined,
|
|
totalPages: undefined,
|
|
currentOffsetStart: undefined,
|
|
countIsCapped: false as const,
|
|
}),
|
|
};
|
|
|
|
return { ...query, ...info } as UsePaginatedQueryResult<TData, TError>;
|
|
}
|
|
|
|
function parsePage(params: URLSearchParams): number {
|
|
const parsed = Number(params.get("page"));
|
|
return Number.isInteger(parsed) && parsed > 1 ? parsed : 1;
|
|
}
|
|
|
|
/**
|
|
* Strips out the page number from a query so that there aren't mismatches
|
|
* between it and usePaginatedQuery's currentPage property (especially for
|
|
* prefetching)
|
|
*/
|
|
function getParamsWithoutPage(params: URLSearchParams): URLSearchParams {
|
|
const withoutPage = new URLSearchParams(params);
|
|
withoutPage.delete(PAGE_NUMBER_PARAMS_KEY);
|
|
return withoutPage;
|
|
}
|
|
|
|
/**
|
|
* All the pagination-properties for UsePaginatedQueryResult. Split up so that
|
|
* the types can be used separately in multiple spots.
|
|
*/
|
|
export type PaginationResultInfo = {
|
|
currentPage: number;
|
|
limit: number;
|
|
onPageChange: (newPage: number) => void;
|
|
goToPreviousPage: () => void;
|
|
goToNextPage: () => void;
|
|
goToFirstPage: () => void;
|
|
} & (
|
|
| {
|
|
isSuccess: false;
|
|
hasNextPage: false;
|
|
hasPreviousPage: false;
|
|
totalRecords: undefined;
|
|
totalPages: undefined;
|
|
currentOffsetStart: undefined;
|
|
countIsCapped: false;
|
|
}
|
|
| {
|
|
isSuccess: true;
|
|
hasNextPage: boolean;
|
|
hasPreviousPage: boolean;
|
|
totalRecords: number;
|
|
totalPages: number;
|
|
currentOffsetStart: number;
|
|
countIsCapped: boolean;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Papers over how the queryPayload function is defined at the type level, so
|
|
* that UsePaginatedQueryOptions doesn't look as scary.
|
|
*
|
|
* You're going to see these tuple types in a few different spots in this file;
|
|
* it's a "hack" to get around the function contravariance that pops up when you
|
|
* normally try to share the TQueryPayload between queryPayload, queryKey, and
|
|
* queryFn via the direct/"obvious" way. By throwing the types into tuples
|
|
* (which are naturally covariant), it's a lot easier to share the types without
|
|
* TypeScript complaining all the time or getting so confused that it degrades
|
|
* the type definitions into a bunch of "any" types
|
|
*/
|
|
type QueryPayloadExtender<TQueryPayload = never> = [TQueryPayload] extends [
|
|
never,
|
|
]
|
|
? { queryPayload?: never }
|
|
: {
|
|
/**
|
|
* An optional function for defining reusable "patterns" for taking
|
|
* pagination data (current page, etc.), which will be evaluated and
|
|
* passed to queryKey and queryFn for active queries and prefetch queries.
|
|
*
|
|
* queryKey and queryFn can each access the result of queryPayload
|
|
* by accessing the "payload" property from their main function argument
|
|
*/
|
|
queryPayload: (params: QueryPageParams) => TQueryPayload;
|
|
};
|
|
|
|
/**
|
|
* Information about a paginated request. This information is passed into the
|
|
* queryPayload, queryKey, and queryFn properties of the hook.
|
|
*/
|
|
type QueryPageParams = {
|
|
/**
|
|
* The page number used when evaluating queryKey and queryFn. pageNumber will
|
|
* be the current page during rendering, but will be the next/previous pages
|
|
* for any prefetching.
|
|
*/
|
|
pageNumber: number;
|
|
|
|
/**
|
|
* The number of data records requested for each page. Defaults to
|
|
* DEFAULT_RECORDS_PER_PAGE and can be overridden with recordsPerPage.
|
|
*/
|
|
limit: number;
|
|
|
|
/**
|
|
* The page offset to use for querying. Just here for convenience; can also be
|
|
* derived from pageNumber and limit
|
|
*/
|
|
offset: number;
|
|
|
|
/**
|
|
* The current URL search params. Useful for letting you grab certain search
|
|
* terms from the URL
|
|
*/
|
|
searchParams: URLSearchParams;
|
|
};
|
|
|
|
/**
|
|
* Weird, hard-to-describe type definition, but it's necessary for making sure
|
|
* that the type information involving the queryPayload function narrows
|
|
* properly.
|
|
*/
|
|
type RuntimePayload<TPayload = never> = [TPayload] extends [never]
|
|
? undefined
|
|
: TPayload;
|
|
|
|
/**
|
|
* The query page params, appended with the result of the queryPayload function.
|
|
* This type is passed to both queryKey and queryFn. If queryPayload is
|
|
* undefined, payload will always be undefined
|
|
*/
|
|
type QueryPageParamsWithPayload<TPayload = never> = QueryPageParams & {
|
|
payload: RuntimePayload<TPayload>;
|
|
};
|
|
|
|
/**
|
|
* Any JSON-serializable object returned by the API that exposes the total
|
|
* number of records that match a query
|
|
*/
|
|
export type PaginatedData = {
|
|
count: number;
|
|
count_cap?: number;
|
|
};
|
|
|
|
/**
|
|
* React Query's QueryFunctionContext (minus pageParam, which is weird and
|
|
* defaults to type any anyway), plus all properties from
|
|
* QueryPageParamsWithPayload.
|
|
*/
|
|
type PaginatedQueryFnContext<
|
|
TQueryKey extends QueryKey = QueryKey,
|
|
TPayload = never,
|
|
> = Omit<QueryFunctionContext<TQueryKey>, "pageParam"> &
|
|
QueryPageParamsWithPayload<TPayload>;
|
|
|
|
/**
|
|
* The set of React Query properties that UsePaginatedQueryOptions derives from.
|
|
*
|
|
* Three properties are stripped from it:
|
|
* - keepPreviousData - The value must always be true to keep pagination feeling
|
|
* nice, so better to prevent someone from trying to touch it at all
|
|
* - queryFn - Removed to make it easier to swap in a custom queryFn type
|
|
* definition with a custom context argument
|
|
* - queryKey - Removed so that it can be replaced with the function form of
|
|
* queryKey
|
|
* - onSuccess/onError - APIs are deprecated and removed in React Query v5
|
|
*/
|
|
type BasePaginationOptions<
|
|
TQueryFnData extends PaginatedData = PaginatedData,
|
|
TError = unknown,
|
|
TData = TQueryFnData,
|
|
TQueryKey extends QueryKey = QueryKey,
|
|
> = Omit<
|
|
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
|
"keepPreviousData" | "queryKey" | "queryFn" | "onSuccess" | "onError"
|
|
>;
|
|
|
|
/**
|
|
* The argument passed to a custom onInvalidPageChange callback.
|
|
*/
|
|
type InvalidPageParams = QueryPageParams & {
|
|
totalPages: number;
|
|
setSearchParams: SetURLSearchParams;
|
|
};
|