diff --git a/.vscode/settings.json b/.vscode/settings.json index 890e561520ff7..bcbdb7baeb9fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "coderdenttest", "coderdtest", "codersdk", + "contravariance", "cronstrue", "databasefake", "dbmem", diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts new file mode 100644 index 0000000000000..3479c183b1b5c --- /dev/null +++ b/site/src/api/queries/audits.ts @@ -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, + }); + }, + }; +} diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 2b6900df13ac8..6829cd1dd5fba 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -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, }; }; diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 1824554721b13..e0cee67d7241d 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -45,7 +45,7 @@ type UseFilterConfig = { onUpdate?: (newValue: string) => void; }; -const useFilterParamsKey = "filter"; +export const useFilterParamsKey = "filter"; export const useFilter = ({ fallbackFilter = "", diff --git a/site/src/hooks/hookPolyfills.ts b/site/src/hooks/hookPolyfills.ts index e5ba705296229..94d8052b349b5 100644 --- a/site/src/hooks/hookPolyfills.ts +++ b/site/src/hooks/hookPolyfills.ts @@ -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]); diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts new file mode 100644 index 0000000000000..2ea3ea57a874f --- /dev/null +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -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(); + }); + }); +}); diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts new file mode 100644 index 0000000000000..46a4b9b487f43 --- /dev/null +++ b/site/src/hooks/usePaginatedQuery.ts @@ -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; +}; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 174bf517a480c..63674b071629c 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -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, diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index dd60039056f02..ef366b8787d5d 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index b07d4921bee0d..9b1f0cb27ef3f 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -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] }, + ); }); /** diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index beb850a65e218..164ef633b5244 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -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, " "); -}; +}