feat: add usePaginatedQuery hook (#10803)

* wip: commit current progress on usePaginatedQuery

* chore: add cacheTime to users query

* chore: update cache logic for UsersPage usersQuery

* wip: commit progress on Pagination

* chore: add function overloads to prepareQuery

* wip: commit progress on usePaginatedQuery

* docs: add clarifying comment about implementation

* chore: remove optional prefetch property from query options

* chore: redefine queryKey

* refactor: consolidate how queryKey/queryFn are called

* refactor: clean up pagination code more

* fix: remove redundant properties

* refactor: clean up code

* wip: commit progress on usePaginatedQuery

* wip: commit current pagination progress

* docs: clean up comments for clarity

* wip: get type signatures compatible (breaks runtime logic slightly)

* refactor: clean up type definitions

* chore: add support for custom onInvalidPage functions

* refactor: clean up type definitions more for clarity reasons

* chore: delete Pagination component (separate PR)

* chore: remove cacheTime fixes (to be resolved in future PR)

* docs: add clarifying/intellisense comments for DX

* refactor: link users queries to same queryKey implementation

* docs: remove misleading comment

* docs: more comments

* chore: update onInvalidPage params for more flexibility

* fix: remove explicit any

* refactor: clean up type definitions

* refactor: rename query params for consistency

* refactor: clean up input validation for page changes

* refactor/fix: update hook to be aware of async data

* chore: add contravariance to dictionary

* refactor: increase type-safety of usePaginatedQuery

* docs: more comments

* chore: move usePaginatedQuery file

* fix: add back cacheTime

* chore: swap in usePaginatedQuery for users table

* chore: add goToFirstPage to usePaginatedQuery

* fix: make page redirects work properly

* refactor: clean up clamp logic

* chore: swap in usePaginatedQuery for Audits table

* refactor: move dependencies around

* fix: remove deprecated properties from hook

* refactor: clean up code more

* docs: add todo comment

* chore: update testing fixtures

* wip: commit current progress for tests

* fix: update useEffectEvent to sync via layout effects

* wip: commit more progress on tests

* wip: stub out all expected test cases

* wip: more test progress

* wip: more test progress

* wip: commit more test progress

* wip: AHHHHHHHH

* chore: finish two more test cases

* wip: add in all tests (still need to investigate prefetching

* refactor: clean up code slightly

* fix: remove math bugs when calculating pages

* fix: wrap up all testing and clean up cases

* docs: update comments for clarity

* fix: update error-handling for invalid page handling

* fix: apply suggestions
This commit is contained in:
Michael Smith 2023-11-30 17:44:03 -05:00 committed by GitHub
parent 329aa45c16
commit d016f93de8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 959 additions and 65 deletions

View File

@ -18,6 +18,7 @@
"coderdenttest",
"coderdtest",
"codersdk",
"contravariance",
"cronstrue",
"databasefake",
"dbmem",

View File

@ -0,0 +1,23 @@
import { getAuditLogs } from "api/api";
import { type AuditLogResponse } from "api/typesGenerated";
import { useFilterParamsKey } from "components/Filter/filter";
import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
export function paginatedAudits(
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AuditLogResponse, string> {
return {
searchParams,
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
queryKey: ({ payload, pageNumber }) => {
return ["auditLogs", payload, pageNumber] as const;
},
queryFn: ({ payload, limit, offset }) => {
return getAuditLogs({
offset,
limit,
q: payload,
});
},
};
}

View File

@ -1,6 +1,6 @@
import { QueryClient, type UseQueryOptions } from "react-query";
import * as API from "api/api";
import {
import type {
AuthorizationRequest,
GetUsersResponse,
UpdateUserPasswordRequest,
@ -10,11 +10,36 @@ import {
} from "api/typesGenerated";
import { getAuthorizationKey } from "./authCheck";
import { getMetadataAsJSON } from "utils/metadata";
import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
import { prepareQuery } from "utils/filters";
export function usersKey(req: UsersRequest) {
return ["users", req] as const;
}
export function paginatedUsers(): UsePaginatedQueryOptions<
GetUsersResponse,
UsersRequest
> {
return {
queryPayload: ({ limit, offset, searchParams }) => {
return {
limit,
offset,
q: prepareQuery(searchParams.get("filter") ?? ""),
};
},
queryKey: ({ payload }) => usersKey(payload),
queryFn: ({ payload, signal }) => API.getUsers(payload, signal),
};
}
export const users = (req: UsersRequest): UseQueryOptions<GetUsersResponse> => {
return {
queryKey: ["users", req],
queryKey: usersKey(req),
queryFn: ({ signal }) => API.getUsers(req, signal),
cacheTime: 5 * 1000 * 60,
};
};

View File

@ -45,7 +45,7 @@ type UseFilterConfig = {
onUpdate?: (newValue: string) => void;
};
const useFilterParamsKey = "filter";
export const useFilterParamsKey = "filter";
export const useFilter = ({
fallbackFilter = "",

View File

@ -6,7 +6,7 @@
* They do not have the same ESLinter exceptions baked in that the official
* hooks do, especially for dependency arrays.
*/
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useLayoutEffect, useRef } from "react";
/**
* A DIY version of useEffectEvent.
@ -35,7 +35,12 @@ export function useEffectEvent<TArgs extends unknown[], TReturn = unknown>(
callback: (...args: TArgs) => TReturn,
) {
const callbackRef = useRef(callback);
useEffect(() => {
// useLayoutEffect should be overkill here 99% of the time, but if this were
// defined as a regular effect, useEffectEvent would not be able to work with
// any layout effects at all; the callback sync here would fire *after* the
// layout effect that needs the useEffectEvent function
useLayoutEffect(() => {
callbackRef.current = callback;
}, [callback]);

View File

@ -0,0 +1,407 @@
import { renderHookWithAuth } from "testHelpers/renderHelpers";
import { waitFor } from "@testing-library/react";
import {
type PaginatedData,
type UsePaginatedQueryOptions,
usePaginatedQuery,
} from "./usePaginatedQuery";
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
function render<
TQueryFnData extends PaginatedData = PaginatedData,
TQueryPayload = never,
>(
options: UsePaginatedQueryOptions<TQueryFnData, TQueryPayload>,
route?: `/?page=${string}`,
) {
return renderHookWithAuth(({ options }) => usePaginatedQuery(options), {
route,
path: "/",
initialProps: { options },
});
}
/**
* There are a lot of test cases in this file. Scoping mocking to inner describe
* function calls to limit the cognitive load of maintaining all this stuff
*/
describe(`${usePaginatedQuery.name}`, () => {
describe("queryPayload method", () => {
const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 }));
it("Passes along an undefined payload if queryPayload is not used", async () => {
const mockQueryKey = jest.fn(() => ["mockQuery"]);
await render({
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
const payloadValueMock = expect.objectContaining({
payload: undefined,
});
expect(mockQueryKey).toHaveBeenCalledWith(payloadValueMock);
expect(mockQueryFn).toHaveBeenCalledWith(payloadValueMock);
});
it("Passes along type-safe payload if queryPayload is provided", async () => {
const mockQueryKey = jest.fn(({ payload }) => {
return ["mockQuery", payload];
});
const testPayloadValues = [1, "Blah", { cool: true }];
for (const payload of testPayloadValues) {
const { unmount } = await render({
queryPayload: () => payload,
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
const matcher = expect.objectContaining({ payload });
expect(mockQueryKey).toHaveBeenCalledWith(matcher);
expect(mockQueryFn).toHaveBeenCalledWith(matcher);
unmount();
}
});
});
describe("Querying for current page", () => {
const mockQueryKey = jest.fn(() => ["mock"]);
const mockQueryFn = jest.fn(() => Promise.resolve({ count: 50 }));
it("Parses page number if it exists in URL params", async () => {
const pageNumbers = [1, 2, 7, 39, 743];
for (const num of pageNumbers) {
const { result, unmount } = await render(
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
`/?page=${num}`,
);
expect(result.current.currentPage).toBe(num);
unmount();
}
});
it("Defaults to page 1 if no page value can be parsed from params", async () => {
const { result } = await render({
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
expect(result.current.currentPage).toBe(1);
});
});
describe("Prefetching", () => {
const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]);
type Context = { pageNumber: number; limit: number };
const mockQueryFnImplementation = ({ pageNumber, limit }: Context) => {
const data: { value: number }[] = [];
if (pageNumber * limit < 75) {
for (let i = 0; i < limit; i++) {
data.push({ value: i });
}
}
return Promise.resolve({ data, count: 75 });
};
const testPrefetch = async (
startingPage: number,
targetPage: number,
shouldMatch: boolean,
) => {
// Have to reinitialize mock function every call to avoid false positives
// from shared mutable tracking state
const mockQueryFn = jest.fn(mockQueryFnImplementation);
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
`/?page=${startingPage}`,
);
const pageMatcher = expect.objectContaining({ pageNumber: targetPage });
if (shouldMatch) {
await waitFor(() => expect(result.current.totalRecords).toBeDefined());
await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher));
} else {
// Can't use waitFor to test this, because the expect call will
// immediately succeed for the not case, even though queryFn needs to be
// called async via React Query
setTimeout(() => {
expect(mockQueryFn).not.toBeCalledWith(pageMatcher);
}, 1000);
jest.runAllTimers();
}
};
it("Prefetches the previous page if it exists", async () => {
await testPrefetch(2, 1, true);
});
it("Prefetches the next page if it exists", async () => {
await testPrefetch(2, 3, true);
});
it("Avoids prefetch for previous page if it doesn't exist", async () => {
await testPrefetch(1, 0, false);
await testPrefetch(6, 5, false);
});
it("Avoids prefetch for next page if it doesn't exist", async () => {
await testPrefetch(3, 4, false);
});
it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => {
const startPage = 2;
const mockQueryFn = jest.fn(mockQueryFnImplementation);
await render(
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
`/?page=${startPage}`,
);
const currentMatcher = expect.objectContaining({ pageNumber: startPage });
expect(mockQueryKey).toBeCalledWith(currentMatcher);
expect(mockQueryFn).toBeCalledWith(currentMatcher);
const prevPageMatcher = expect.objectContaining({
pageNumber: startPage - 1,
});
await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher));
await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher));
const nextPageMatcher = expect.objectContaining({
pageNumber: startPage + 1,
});
await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher));
await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher));
});
});
describe("Safety nets/redirects for invalid pages", () => {
const mockQueryKey = jest.fn(() => ["mock"]);
const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
Promise.resolve({
data: new Array(limit).fill(pageNumber),
count: 100,
}),
);
it("No custom callback: synchronously defaults to page 1 if params are corrupt/invalid", async () => {
const { result } = await render(
{
queryKey: mockQueryKey,
queryFn: mockQueryFn,
},
"/?page=Cat",
);
expect(result.current.currentPage).toBe(1);
});
it("No custom callback: auto-redirects user to last page if requested page overshoots total pages", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
"/?page=35",
);
await waitFor(() => expect(result.current.currentPage).toBe(4));
});
it("No custom callback: auto-redirects user to first page if requested page goes below 1", async () => {
const { result } = await render(
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
"/?page=-9999",
);
await waitFor(() => expect(result.current.currentPage).toBe(1));
});
it("With custom callback: Calls callback and does not update search params automatically", async () => {
const testControl = new URLSearchParams({
page: "1000",
});
const onInvalidPageChange = jest.fn();
await render({
onInvalidPageChange,
queryKey: mockQueryKey,
queryFn: mockQueryFn,
searchParams: testControl,
});
await waitFor(() => {
expect(onInvalidPageChange).toBeCalledWith(
expect.objectContaining({
pageNumber: expect.any(Number),
limit: expect.any(Number),
offset: expect.any(Number),
totalPages: expect.any(Number),
searchParams: expect.any(URLSearchParams),
setSearchParams: expect.any(Function),
}),
);
});
expect(testControl.get("page")).toBe("1000");
});
});
describe("Passing in searchParams property", () => {
const mockQueryKey = jest.fn(() => ["mock"]);
const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
Promise.resolve({
data: new Array(limit).fill(pageNumber),
count: 100,
}),
);
it("Reads from searchParams property if provided", async () => {
const searchParams = new URLSearchParams({
page: "2",
});
const { result } = await render({
searchParams,
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
expect(result.current.currentPage).toBe(2);
});
it("Flushes state changes via provided searchParams property instead of internal searchParams", async () => {
const searchParams = new URLSearchParams({
page: "2",
});
const { result } = await render({
searchParams,
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
result.current.goToFirstPage();
expect(searchParams.get("page")).toBe("1");
});
});
});
describe(`${usePaginatedQuery.name} - Returned properties`, () => {
describe("Page change methods", () => {
const mockQueryKey = jest.fn(() => ["mock"]);
const mockQueryFn = jest.fn(({ pageNumber, limit }) => {
type Data = PaginatedData & { data: readonly number[] };
return new Promise<Data>((resolve) => {
setTimeout(() => {
resolve({
data: new Array(limit).fill(pageNumber),
count: 100,
});
}, 10_000);
});
});
test("goToFirstPage always succeeds regardless of fetch status", async () => {
const queryFns = [mockQueryFn, jest.fn(() => Promise.reject("Too bad"))];
for (const queryFn of queryFns) {
const { result, unmount } = await render(
{ queryFn, queryKey: mockQueryKey },
"/?page=5",
);
expect(result.current.currentPage).toBe(5);
result.current.goToFirstPage();
await waitFor(() => expect(result.current.currentPage).toBe(1));
unmount();
}
});
test("goToNextPage works only if hasNextPage is true", async () => {
const { result } = await render(
{
queryKey: mockQueryKey,
queryFn: mockQueryFn,
},
"/?page=1",
);
expect(result.current.hasNextPage).toBe(false);
result.current.goToNextPage();
expect(result.current.currentPage).toBe(1);
await jest.runAllTimersAsync();
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
result.current.goToNextPage();
await waitFor(() => expect(result.current.currentPage).toBe(2));
});
test("goToPreviousPage works only if hasPreviousPage is true", async () => {
const { result } = await render(
{
queryKey: mockQueryKey,
queryFn: mockQueryFn,
},
"/?page=3",
);
expect(result.current.hasPreviousPage).toBe(false);
result.current.goToPreviousPage();
expect(result.current.currentPage).toBe(3);
await jest.runAllTimersAsync();
await waitFor(() => expect(result.current.hasPreviousPage).toBe(true));
result.current.goToPreviousPage();
await waitFor(() => expect(result.current.currentPage).toBe(2));
});
test("onPageChange accounts for floats and truncates numeric values before navigating", async () => {
const { result } = await render({
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
await jest.runAllTimersAsync();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
result.current.onPageChange(2.5);
await waitFor(() => expect(result.current.currentPage).toBe(2));
});
test("onPageChange rejects impossible numeric values and does nothing", async () => {
const { result } = await render({
queryKey: mockQueryKey,
queryFn: mockQueryFn,
});
await jest.runAllTimersAsync();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
result.current.onPageChange(NaN);
result.current.onPageChange(Infinity);
result.current.onPageChange(-Infinity);
setTimeout(() => {
expect(result.current.currentPage).toBe(1);
}, 1000);
jest.runAllTimers();
});
});
});

View File

@ -0,0 +1,444 @@
import { useEffect } from "react";
import { useEffectEvent } from "./hookPolyfills";
import { type SetURLSearchParams, useSearchParams } from "react-router-dom";
import { clamp } from "lodash";
import {
type QueryFunctionContext,
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueryClient,
useQuery,
} from "react-query";
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;
};
/**
* 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,
...extraOptions
} = options;
const [innerSearchParams, setSearchParams] = useSearchParams();
const searchParams = outerSearchParams ?? innerSearchParams;
const limit = DEFAULT_RECORDS_PER_PAGE;
const currentPage = parsePage(searchParams);
const currentPageOffset = (currentPage - 1) * limit;
const getQueryOptionsFromPage = (pageNumber: number) => {
const pageParams: QueryPageParams = {
pageNumber,
limit,
offset: (pageNumber - 1) * limit,
searchParams: getParamsWithoutPage(searchParams),
};
const payload = queryPayload?.(pageParams) as RuntimePayload<TQueryPayload>;
return {
queryKey: queryKey({ ...pageParams, payload }),
queryFn: (context: QueryFunctionContext<TQueryKey>) => {
return outerQueryFn({ ...context, ...pageParams, payload });
},
} as const;
};
// 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),
keepPreviousData: true,
});
const totalRecords = query.data?.count;
const totalPages =
totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined;
const hasNextPage =
totalRecords !== undefined && limit + currentPageOffset < totalRecords;
const hasPreviousPage =
totalRecords !== undefined &&
currentPage > 1 &&
currentPageOffset - limit < totalRecords;
const queryClient = useQueryClient();
const prefetchPage = useEffectEvent((newPage: number) => {
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);
}
}, [prefetchPage, currentPage, hasNextPage]);
useEffect(() => {
if (hasPreviousPage) {
void prefetchPage(currentPage - 1);
}
}, [prefetchPage, 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) {
withoutPage.set(PAGE_NUMBER_PARAMS_KEY, String(clamped));
setSearchParams(withoutPage);
} else {
const params: InvalidPageParams = {
limit,
setSearchParams,
offset: currentPageOffset,
searchParams: withoutPage,
totalPages: fixedTotalPages,
pageNumber: currentPage,
};
onInvalidPageChange(params);
}
});
useEffect(() => {
if (!query.isFetching && totalPages !== undefined) {
void updatePageIfInvalid(totalPages);
}
}, [updatePageIfInvalid, query.isFetching, totalPages]);
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;
}
const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages ?? 1);
if (Number.isNaN(cleanedInput)) {
return;
}
searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput));
setSearchParams(searchParams);
};
// 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,
}
: {
isSuccess: false,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
}),
};
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.
*/
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;
}
| {
isSuccess: true;
hasNextPage: boolean;
hasPreviousPage: boolean;
totalRecords: number;
totalPages: number;
}
);
/**
* 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 to pull per query. Currently hard-coded based
* off the value from PaginationWidget's utils file
*/
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;
};
/**
* 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;
};

View File

@ -1,7 +1,4 @@
import {
DEFAULT_RECORDS_PER_PAGE,
isNonInitialPage,
} from "components/PaginationWidget/utils";
import { isNonInitialPage } from "components/PaginationWidget/utils";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { FC } from "react";
import { Helmet } from "react-helmet-async";
@ -10,20 +7,27 @@ import { pageTitle } from "utils/page";
import { AuditPageView } from "./AuditPageView";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { useFilter } from "components/Filter/filter";
import { usePagination } from "hooks";
import { useQuery } from "react-query";
import { getAuditLogs } from "api/api";
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
import { paginatedAudits } from "api/queries/audits";
const AuditPage: FC = () => {
const searchParamsResult = useSearchParams();
const pagination = usePagination({ searchParamsResult });
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
/**
* There is an implicit link between auditsQuery and filter via the
* searchParams object
*
* @todo Make link more explicit (probably by making it so that components
* and hooks can share the result of useSearchParams directly)
*/
const [searchParams, setSearchParams] = useSearchParams();
const auditsQuery = usePaginatedQuery(paginatedAudits(searchParams));
const filter = useFilter({
searchParamsResult,
onUpdate: () => {
pagination.goToPage(1);
},
searchParamsResult: [searchParams, setSearchParams],
onUpdate: auditsQuery.goToFirstPage,
});
const userMenu = useUserFilterMenu({
value: filter.values.username,
onChange: (option) =>
@ -32,6 +36,7 @@ const AuditPage: FC = () => {
username: option?.value,
}),
});
const actionMenu = useActionFilterMenu({
value: filter.values.action,
onChange: (option) =>
@ -40,6 +45,7 @@ const AuditPage: FC = () => {
action: option?.value,
}),
});
const resourceTypeMenu = useResourceTypeFilterMenu({
value: filter.values["resource_type"],
onChange: (option) =>
@ -48,37 +54,25 @@ const AuditPage: FC = () => {
resource_type: option?.value,
}),
});
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
const { data, error } = useQuery({
queryKey: ["auditLogs", filter.query, pagination.page],
queryFn: () => {
const limit = DEFAULT_RECORDS_PER_PAGE;
const page = pagination.page;
return getAuditLogs({
offset: page <= 0 ? 0 : (page - 1) * limit,
limit: limit,
q: filter.query,
});
},
});
return (
<>
<Helmet>
<title>{pageTitle("Audit")}</title>
</Helmet>
<AuditPageView
auditLogs={data?.audit_logs}
count={data?.count}
page={pagination.page}
limit={pagination.limit}
onPageChange={pagination.goToPage}
isNonInitialPage={isNonInitialPage(searchParamsResult[0])}
auditLogs={auditsQuery.data?.audit_logs}
count={auditsQuery.totalRecords}
page={auditsQuery.currentPage}
limit={auditsQuery.limit}
onPageChange={auditsQuery.onPageChange}
isNonInitialPage={isNonInitialPage(searchParams)}
isAuditLogVisible={isAuditLogVisible}
error={error}
error={auditsQuery.error}
filterProps={{
filter,
error,
error: auditsQuery.error,
menus: {
user: userMenu,
action: actionMenu,

View File

@ -6,7 +6,7 @@ import { groupsByUserId } from "api/queries/groups";
import { getErrorMessage } from "api/errors";
import { deploymentConfig } from "api/queries/deployment";
import {
users,
paginatedUsers,
suspendUser,
activateUser,
deleteUser,
@ -17,14 +17,13 @@ import {
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useOrganizationId, usePagination } from "hooks";
import { useOrganizationId } from "hooks";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { useStatusFilterMenu } from "./UsersFilter";
import { useFilter } from "components/Filter/filter";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { generateRandomString } from "utils/random";
import { prepareQuery } from "utils/filters";
import { Helmet } from "react-helmet-async";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
@ -34,6 +33,7 @@ import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { pageTitle } from "utils/page";
import { UsersPageView } from "./UsersPageView";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
export const UsersPage: FC<{ children?: ReactNode }> = () => {
const queryClient = useQueryClient();
@ -43,19 +43,11 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const { entitlements } = useDashboard();
const [searchParams] = searchParamsResult;
const pagination = usePagination({ searchParamsResult });
const usersQuery = useQuery(
users({
q: prepareQuery(searchParams.get("filter") ?? ""),
limit: pagination.limit,
offset: pagination.offset,
}),
);
const organizationId = useOrganizationId();
const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId));
const authMethodsQuery = useQuery(authMethods());
const me = useMe();
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
const rolesQuery = useQuery(roles());
const { data: deploymentValues } = useQuery({
@ -63,13 +55,12 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
enabled: viewDeploymentValues,
});
const me = useMe();
const usersQuery = usePaginatedQuery(paginatedUsers());
const useFilterResult = useFilter({
searchParamsResult,
onUpdate: () => {
pagination.goToPage(1);
},
onUpdate: usersQuery.goToFirstPage,
});
const statusMenu = useStatusFilterMenu({
value: useFilterResult.values.status,
onChange: (option) =>
@ -164,10 +155,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
error: usersQuery.error,
menus: { status: statusMenu },
}}
count={usersQuery.data?.count}
page={pagination.page}
limit={pagination.limit}
onPageChange={pagination.goToPage}
count={usersQuery.totalRecords}
page={usersQuery.currentPage}
limit={usersQuery.limit}
onPageChange={usersQuery.onPageChange}
/>
<DeleteDialog

View File

@ -125,6 +125,7 @@ export async function renderHookWithAuth<Result, Props>(
{
initialProps,
path = "/",
route = "/",
extraRoutes = [],
}: RenderHookWithAuthOptions<Props> = {},
) {
@ -144,10 +145,10 @@ export async function renderHookWithAuth<Result, Props>(
*/
// eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
const [readonlyStatefulRouter] = useState(() => {
return createMemoryRouter([
{ path, element: <>{children}</> },
...extraRoutes,
]);
return createMemoryRouter(
[{ path, element: <>{children}</> }, ...extraRoutes],
{ initialEntries: [route] },
);
});
/**

View File

@ -1,3 +1,6 @@
export const prepareQuery = (query?: string) => {
export function prepareQuery(query: string): string;
export function prepareQuery(query: undefined): undefined;
export function prepareQuery(query: string | undefined): string | undefined;
export function prepareQuery(query?: string): string | undefined {
return query?.trim().replace(/ +/g, " ");
};
}