From b4211f389e3294bed32437f1ecdd3697facd8e4e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 13 Nov 2023 21:52:37 +0000 Subject: [PATCH 01/91] wip: commit current progress on usePaginatedQuery --- .../PaginationWidget/usePaginatedQuery.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 site/src/components/PaginationWidget/usePaginatedQuery.ts diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts new file mode 100644 index 0000000000000..51169783b0369 --- /dev/null +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -0,0 +1,126 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; + +import { + type QueryKey, + type UseQueryOptions, + useQueryClient, + useQuery, +} from "react-query"; + +const PAGE_PARAMS_KEY = "page"; + +// Any JSON-serializable object with a count property representing the total +// number of records for a given resource +type PaginatedData = { + count: number; +}; + +// All the type parameters just mirror the ones used by React Query +type PaginatedOptions< + TQueryFnData extends PaginatedData = PaginatedData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + "keepPreviousData" | "queryKey" +> & { + /** + * A function that takes a page number and produces a full query key. Must be + * a function so that it can be used for the active query, as well as + * prefetching + */ + queryKey: (pageNumber: number) => TQueryKey; + + searchParamsResult: ReturnType; + prefetchNextPage?: boolean; +}; + +export function usePaginatedQuery< + TQueryFnData extends PaginatedData = PaginatedData, + TError = unknown, + TData extends PaginatedData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(options: PaginatedOptions) { + const { searchParamsResult, queryKey, prefetchNextPage = false } = options; + const [searchParams, setSearchParams] = searchParamsResult; + const currentPage = parsePage(searchParams); + + // Can't use useInfiniteQuery because that hook is designed to work with data + // that streams in and can't be cut up into pages easily + const query = useQuery({ + ...options, + queryKey: queryKey(currentPage), + keepPreviousData: true, + }); + + const pageSize = DEFAULT_RECORDS_PER_PAGE; + const pageOffset = (currentPage - 1) * pageSize; + const totalRecords = query.data?.count ?? 0; + const totalPages = Math.ceil(totalRecords / pageSize); + const hasPreviousPage = currentPage > 1; + const hasNextPage = pageSize * pageOffset < totalRecords; + + const queryClient = useQueryClient(); + const prefetch = useEffectEvent((newPage: number) => { + if (!prefetchNextPage) { + return; + } + + const newKey = queryKey(newPage); + void queryClient.prefetchQuery(newKey); + }); + + useEffect(() => { + if (hasPreviousPage) { + prefetch(currentPage - 1); + } + + if (hasNextPage) { + prefetch(currentPage + 1); + } + }, [prefetch, currentPage, hasNextPage, hasPreviousPage]); + + // Tries to redirect a user if they navigate to a page via invalid URL + const navigateIfInvalidPage = useEffectEvent( + (currentPage: number, totalPages: number) => { + const clamped = Math.max(1, Math.min(currentPage, totalPages)); + + if (currentPage !== clamped) { + searchParams.set(PAGE_PARAMS_KEY, String(clamped)); + setSearchParams(searchParams); + } + }, + ); + + useEffect(() => { + navigateIfInvalidPage(currentPage, totalPages); + }, [navigateIfInvalidPage, currentPage, totalPages]); + + const onPageChange = (newPage: number) => { + const safePage = Number.isInteger(newPage) + ? Math.max(1, Math.min(newPage)) + : 1; + + searchParams.set(PAGE_PARAMS_KEY, String(safePage)); + setSearchParams(searchParams); + }; + + return { + ...query, + onPageChange, + currentPage, + totalRecords, + hasNextPage, + pageSize, + isLoading: query.isLoading || query.isFetching, + } as const; +} + +function parsePage(params: URLSearchParams): number { + const parsed = Number(params.get("page")); + return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; +} From 173e823bf3e060c3e3af2fb2e814904b760b4b08 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 13 Nov 2023 21:55:44 +0000 Subject: [PATCH 02/91] chore: add cacheTime to users query --- site/src/api/queries/users.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 2b6900df13ac8..991cd4ab06aa7 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -15,6 +15,7 @@ export const users = (req: UsersRequest): UseQueryOptions => { return { queryKey: ["users", req], queryFn: ({ signal }) => API.getUsers(req, signal), + cacheTime: 5 * 60 * 1000, }; }; From d715d738850e0a17a62f3cf5c08e50876ef653a8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 13 Nov 2023 21:56:21 +0000 Subject: [PATCH 03/91] chore: update cache logic for UsersPage usersQuery --- site/src/pages/UsersPage/UsersPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index dd60039056f02..83f271cbe089b 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -44,13 +44,14 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const [searchParams] = searchParamsResult; const pagination = usePagination({ searchParamsResult }); - const usersQuery = useQuery( - users({ + const usersQuery = useQuery({ + ...users({ q: prepareQuery(searchParams.get("filter") ?? ""), limit: pagination.limit, offset: pagination.offset, }), - ); + keepPreviousData: true, + }); const organizationId = useOrganizationId(); const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId)); From 8a199b737785442ad74df344024c52fe0fa56bf5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 13 Nov 2023 21:56:38 +0000 Subject: [PATCH 04/91] wip: commit progress on Pagination --- .../PaginationWidget/Pagination.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 site/src/components/PaginationWidget/Pagination.tsx diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx new file mode 100644 index 0000000000000..7fc882345f316 --- /dev/null +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -0,0 +1,63 @@ +import { type PropsWithChildren, useEffect, useRef } from "react"; +import { PaginationWidgetBase } from "./PaginationWidgetBase"; + +type PaginationProps = PropsWithChildren<{ + fetching: boolean; + currentPage: number; + pageSize: number; + totalRecords: number; + onPageChange: (newPage: number) => void; +}>; + +export function Pagination({ + children, + fetching, + currentPage, + totalRecords, + pageSize, + onPageChange, +}: PaginationProps) { + const scrollAfterPageChangeRef = useRef(false); + useEffect(() => { + const onScroll = () => { + scrollAfterPageChangeRef.current = false; + }; + + document.addEventListener("scroll", onScroll); + return () => document.removeEventListener("scroll", onScroll); + }, []); + + const previousPageRef = useRef(undefined); + const paginationTopRef = useRef(null); + useEffect(() => { + const paginationTop = paginationTopRef.current; + const isInitialRender = previousPageRef.current === undefined; + + const skipScroll = + isInitialRender || + paginationTop === null || + !scrollAfterPageChangeRef.current; + + previousPageRef.current = currentPage; + if (!skipScroll) { + paginationTop.scrollIntoView(); + } + }, [currentPage]); + + return ( + <> +
+ {children} + + { + scrollAfterPageChangeRef.current = true; + onPageChange(newPage); + }} + /> + + ); +} From 98ae96d46cd97cb8b65d6be96115e0bc74c72aad Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 13 Nov 2023 23:28:17 +0000 Subject: [PATCH 05/91] chore: add function overloads to prepareQuery --- site/src/utils/filters.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index beb850a65e218..389b866d0e111 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -1,3 +1,5 @@ -export const prepareQuery = (query?: string) => { +export function prepareQuery(query: undefined): undefined; +export function prepareQuery(query: string): string; +export function prepareQuery(query?: string): string | undefined { return query?.trim().replace(/ +/g, " "); -}; +} From 37ea50b42b88fa32ebdbc329fbdacde3b50d4165 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 14 Nov 2023 22:45:27 +0000 Subject: [PATCH 06/91] wip: commit progress on usePaginatedQuery --- .../PaginationWidget/usePaginatedQuery.ts | 156 ++++++++++++------ 1 file changed, 108 insertions(+), 48 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 51169783b0369..975aa25219793 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -1,9 +1,14 @@ import { useEffect } from "react"; import { useSearchParams } from "react-router-dom"; import { useEffectEvent } from "hooks/hookPolyfills"; + +import { type Pagination } from "api/typesGenerated"; import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; +import { prepareQuery } from "utils/filters"; +import { clamp } from "lodash"; import { + type QueryFunction, type QueryKey, type UseQueryOptions, useQueryClient, @@ -11,15 +16,30 @@ import { } from "react-query"; const PAGE_PARAMS_KEY = "page"; +const PAGE_FILTER_KEY = "filter"; + +// Only omitting after_id for simplifying initial implementation; property +// should probably be added back in down the line +type PaginationInput = Omit & { + q: string; + limit: number; + offset: number; +}; -// Any JSON-serializable object with a count property representing the total -// number of records for a given resource -type PaginatedData = { +/** + * Any JSON-serializable object returned by the API that exposes the total + * number of records that match a query + */ +interface PaginatedData { count: number; -}; +} +/** + * A more specialized version of UseQueryOptions built specifically for + * paginated queries. + */ // All the type parameters just mirror the ones used by React Query -type PaginatedOptions< +export type UsePaginatedQueryOptions< TQueryFnData extends PaginatedData = PaginatedData, TError = unknown, TData = TQueryFnData, @@ -28,15 +48,21 @@ type PaginatedOptions< UseQueryOptions, "keepPreviousData" | "queryKey" > & { + prefetch?: boolean; + /** - * A function that takes a page number and produces a full query key. Must be - * a function so that it can be used for the active query, as well as - * prefetching + * 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, as well as + * any prefetching. */ - queryKey: (pageNumber: number) => TQueryKey; + queryKey: (pagination: PaginationInput) => TQueryKey; - searchParamsResult: ReturnType; - prefetchNextPage?: boolean; + /** + * A version of queryFn that is required and that has access to the current + * page via the pageParams context property + */ + queryFn: QueryFunction; }; export function usePaginatedQuery< @@ -44,65 +70,81 @@ export function usePaginatedQuery< TError = unknown, TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(options: PaginatedOptions) { - const { searchParamsResult, queryKey, prefetchNextPage = false } = options; - const [searchParams, setSearchParams] = searchParamsResult; +>(options: UsePaginatedQueryOptions) { + const { queryKey, queryFn, prefetch = false, ...otherOptions } = options; + + const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); - // Can't use useInfiniteQuery because that hook is designed to work with data - // that streams in and can't be cut up into pages easily + const pageSize = DEFAULT_RECORDS_PER_PAGE; + const pageOffset = (currentPage - 1) * pageSize; + const query = useQuery({ - ...options, - queryKey: queryKey(currentPage), + ...otherOptions, + queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: currentPage }), + queryKey: queryKey({ + q: preparePageQuery(searchParams, currentPage), + limit: pageSize, + offset: pageOffset, + }), keepPreviousData: true, }); - const pageSize = DEFAULT_RECORDS_PER_PAGE; - const pageOffset = (currentPage - 1) * pageSize; - const totalRecords = query.data?.count ?? 0; - const totalPages = Math.ceil(totalRecords / pageSize); - const hasPreviousPage = currentPage > 1; - const hasNextPage = pageSize * pageOffset < totalRecords; - const queryClient = useQueryClient(); - const prefetch = useEffectEvent((newPage: number) => { - if (!prefetchNextPage) { + const prefetchPage = useEffectEvent((newPage: number) => { + if (!prefetch) { return; } - const newKey = queryKey(newPage); - void queryClient.prefetchQuery(newKey); + void queryClient.prefetchQuery({ + queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: newPage }), + queryKey: queryKey({ + q: preparePageQuery(searchParams, newPage), + limit: pageSize, + offset: pageOffset, + }), + }); }); + const totalRecords = query.data?.count ?? 0; + const totalPages = Math.ceil(totalRecords / pageSize); + const hasNextPage = pageSize * pageOffset < totalRecords; + const hasPreviousPage = currentPage > 1; + + // Have to split hairs and sync on both the current page and the hasXPage + // variables because hasXPage values are derived from server values and aren't + // immediately ready on each render useEffect(() => { - if (hasPreviousPage) { - prefetch(currentPage - 1); + if (hasNextPage) { + prefetchPage(currentPage + 1); } + }, [prefetchPage, currentPage, hasNextPage]); - if (hasNextPage) { - prefetch(currentPage + 1); + useEffect(() => { + if (hasPreviousPage) { + prefetchPage(currentPage - 1); } - }, [prefetch, currentPage, hasNextPage, hasPreviousPage]); + }, [prefetchPage, currentPage, hasPreviousPage]); - // Tries to redirect a user if they navigate to a page via invalid URL - const navigateIfInvalidPage = useEffectEvent( - (currentPage: number, totalPages: number) => { - const clamped = Math.max(1, Math.min(currentPage, totalPages)); + // Mainly here to catch user if they navigate to a page directly via URL + const updatePageIfInvalid = useEffectEvent(() => { + const clamped = clamp(currentPage, 1, totalPages); - if (currentPage !== clamped) { - searchParams.set(PAGE_PARAMS_KEY, String(clamped)); - setSearchParams(searchParams); - } - }, - ); + if (currentPage !== clamped) { + searchParams.set(PAGE_PARAMS_KEY, String(clamped)); + setSearchParams(searchParams); + } + }); useEffect(() => { - navigateIfInvalidPage(currentPage, totalPages); - }, [navigateIfInvalidPage, currentPage, totalPages]); + if (!query.isFetching) { + updatePageIfInvalid(); + } + }, [updatePageIfInvalid, query.isFetching]); const onPageChange = (newPage: number) => { const safePage = Number.isInteger(newPage) - ? Math.max(1, Math.min(newPage)) + ? clamp(newPage, 1, totalPages) : 1; searchParams.set(PAGE_PARAMS_KEY, String(safePage)); @@ -112,10 +154,13 @@ export function usePaginatedQuery< return { ...query, onPageChange, + goToPreviousPage: () => onPageChange(currentPage - 1), + goToNextPage: () => onPageChange(currentPage + 1), currentPage, + pageSize, totalRecords, hasNextPage, - pageSize, + hasPreviousPage, isLoading: query.isLoading || query.isFetching, } as const; } @@ -124,3 +169,18 @@ function parsePage(params: URLSearchParams): number { const parsed = Number(params.get("page")); return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } + +function preparePageQuery(searchParams: URLSearchParams, page: number) { + const paramsPage = Number(searchParams.get(PAGE_FILTER_KEY)); + + let queryText: string; + if (paramsPage === page) { + queryText = searchParams.toString(); + } else { + const newParams = new URLSearchParams(searchParams); + newParams.set(PAGE_FILTER_KEY, String(page)); + queryText = newParams.toString(); + } + + return prepareQuery(queryText); +} From 2c1e9e31030a8a3846fb9a42ff9e3445dd164d13 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 14 Nov 2023 22:55:52 +0000 Subject: [PATCH 07/91] docs: add clarifying comment about implementation --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 975aa25219793..3aeb6e99bfd08 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -79,6 +79,8 @@ export function usePaginatedQuery< const pageSize = DEFAULT_RECORDS_PER_PAGE; const pageOffset = (currentPage - 1) * pageSize; + // 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) const query = useQuery({ ...otherOptions, queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: currentPage }), From b3a9ab44a03f02a5785974f9ec9cca95697c4157 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 03:58:43 +0000 Subject: [PATCH 08/91] chore: remove optional prefetch property from query options --- .../PaginationWidget/usePaginatedQuery.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 3aeb6e99bfd08..2417eb158d1a8 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -48,8 +48,6 @@ export type UsePaginatedQueryOptions< UseQueryOptions, "keepPreviousData" | "queryKey" > & { - prefetch?: boolean; - /** * A function that takes pagination information and produces a full query key. * @@ -71,8 +69,7 @@ export function usePaginatedQuery< TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: UsePaginatedQueryOptions) { - const { queryKey, queryFn, prefetch = false, ...otherOptions } = options; - + const { queryKey, queryFn, ...otherOptions } = options; const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); @@ -94,10 +91,6 @@ export function usePaginatedQuery< const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { - if (!prefetch) { - return; - } - void queryClient.prefetchQuery({ queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: newPage }), queryKey: queryKey({ @@ -163,6 +156,10 @@ export function usePaginatedQuery< totalRecords, hasNextPage, hasPreviousPage, + + // Have to hijack the isLoading property slightly because keepPreviousData + // is true; by default, isLoading will be false after the initial page + // loads, even if new pages are loading in isLoading: query.isLoading || query.isFetching, } as const; } From 39a2cedc020e6733256d3151be706b18575712d6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 04:12:28 +0000 Subject: [PATCH 09/91] chore: redefine queryKey --- .../PaginationWidget/usePaginatedQuery.ts | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 2417eb158d1a8..ba4f85ee06a27 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -2,9 +2,7 @@ import { useEffect } from "react"; import { useSearchParams } from "react-router-dom"; import { useEffectEvent } from "hooks/hookPolyfills"; -import { type Pagination } from "api/typesGenerated"; import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; -import { prepareQuery } from "utils/filters"; import { clamp } from "lodash"; import { @@ -15,15 +13,19 @@ import { useQuery, } from "react-query"; -const PAGE_PARAMS_KEY = "page"; -const PAGE_FILTER_KEY = "filter"; +/** + * The key to use for getting/setting the page number from the search params + */ +const PAGE_NUMBER_PARAMS_KEY = "page"; -// Only omitting after_id for simplifying initial implementation; property -// should probably be added back in down the line -type PaginationInput = Omit & { - q: string; - limit: number; - offset: number; +/** + * All arguments passed into the queryKey functions. + */ +type QueryKeyFnArgs = { + pageNumber: number; + pageSize: number; + pageOffset: number; + extraQuery?: string; }; /** @@ -54,11 +56,11 @@ export type UsePaginatedQueryOptions< * Must be a function so that it can be used for the active query, as well as * any prefetching. */ - queryKey: (pagination: PaginationInput) => TQueryKey; + queryKey: (args: QueryKeyFnArgs) => TQueryKey; /** - * A version of queryFn that is required and that has access to the current - * page via the pageParams context property + * A version of queryFn that is required and that exposes page numbers through + * the pageParams context property */ queryFn: QueryFunction; }; @@ -82,9 +84,9 @@ export function usePaginatedQuery< ...otherOptions, queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: currentPage }), queryKey: queryKey({ - q: preparePageQuery(searchParams, currentPage), - limit: pageSize, - offset: pageOffset, + pageNumber: currentPage, + pageSize, + pageOffset, }), keepPreviousData: true, }); @@ -94,9 +96,9 @@ export function usePaginatedQuery< void queryClient.prefetchQuery({ queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: newPage }), queryKey: queryKey({ - q: preparePageQuery(searchParams, newPage), - limit: pageSize, - offset: pageOffset, + pageNumber: newPage, + pageSize, + pageOffset, }), }); }); @@ -126,7 +128,7 @@ export function usePaginatedQuery< const clamped = clamp(currentPage, 1, totalPages); if (currentPage !== clamped) { - searchParams.set(PAGE_PARAMS_KEY, String(clamped)); + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); setSearchParams(searchParams); } }); @@ -142,7 +144,7 @@ export function usePaginatedQuery< ? clamp(newPage, 1, totalPages) : 1; - searchParams.set(PAGE_PARAMS_KEY, String(safePage)); + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(safePage)); setSearchParams(searchParams); }; @@ -168,18 +170,3 @@ function parsePage(params: URLSearchParams): number { const parsed = Number(params.get("page")); return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } - -function preparePageQuery(searchParams: URLSearchParams, page: number) { - const paramsPage = Number(searchParams.get(PAGE_FILTER_KEY)); - - let queryText: string; - if (paramsPage === page) { - queryText = searchParams.toString(); - } else { - const newParams = new URLSearchParams(searchParams); - newParams.set(PAGE_FILTER_KEY, String(page)); - queryText = newParams.toString(); - } - - return prepareQuery(queryText); -} From 4dcfd29e1b824396882f94785dcb10c3c5035cff Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 04:28:09 +0000 Subject: [PATCH 10/91] refactor: consolidate how queryKey/queryFn are called --- site/src/api/queries/users.ts | 13 +++++++ .../PaginationWidget/usePaginatedQuery.ts | 36 ++++++++++--------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 991cd4ab06aa7..40e9afa7479e2 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -10,6 +10,19 @@ import { } from "api/typesGenerated"; import { getAuthorizationKey } from "./authCheck"; import { getMetadataAsJSON } from "utils/metadata"; +import { UsePaginatedQueryOptions } from "components/PaginationWidget/usePaginatedQuery"; + +export function usersKey(req: UsersRequest) { + return ["users", req] as const; +} + +export function paginatedUsers(req: UsersRequest) { + return { + queryKey: (pagination) => usersKey(pagination), + queryFn: () => API.getUsers(req), + cacheTime: 5 * 60 * 1000, + } as const satisfies UsePaginatedQueryOptions; +} export const users = (req: UsersRequest): UseQueryOptions => { return { diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index ba4f85ee06a27..527b7f9fd8668 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -7,6 +7,7 @@ import { clamp } from "lodash"; import { type QueryFunction, + type QueryFunctionContext, type QueryKey, type UseQueryOptions, useQueryClient, @@ -73,34 +74,35 @@ export function usePaginatedQuery< >(options: UsePaginatedQueryOptions) { const { queryKey, queryFn, ...otherOptions } = options; const [searchParams, setSearchParams] = useSearchParams(); - const currentPage = parsePage(searchParams); + const currentPage = parsePage(searchParams); const pageSize = DEFAULT_RECORDS_PER_PAGE; const pageOffset = (currentPage - 1) * pageSize; + const queryOptionsFromPage = (pageNumber: number) => { + return { + queryFn: (queryCxt: QueryFunctionContext) => { + return queryFn({ ...queryCxt, pageParam: pageNumber }); + }, + queryKey: queryKey({ + pageNumber: currentPage, + pageSize, + pageOffset, + }), + } 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) const query = useQuery({ + ...queryOptionsFromPage(currentPage), ...otherOptions, - queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: currentPage }), - queryKey: queryKey({ - pageNumber: currentPage, - pageSize, - pageOffset, - }), keepPreviousData: true, }); const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { - void queryClient.prefetchQuery({ - queryFn: (queryCxt) => queryFn({ ...queryCxt, pageParam: newPage }), - queryKey: queryKey({ - pageNumber: newPage, - pageSize, - pageOffset, - }), - }); + return queryClient.prefetchQuery(queryOptionsFromPage(newPage)); }); const totalRecords = query.data?.count ?? 0; @@ -113,13 +115,13 @@ export function usePaginatedQuery< // immediately ready on each render useEffect(() => { if (hasNextPage) { - prefetchPage(currentPage + 1); + void prefetchPage(currentPage + 1); } }, [prefetchPage, currentPage, hasNextPage]); useEffect(() => { if (hasPreviousPage) { - prefetchPage(currentPage - 1); + void prefetchPage(currentPage - 1); } }, [prefetchPage, currentPage, hasPreviousPage]); From 86f8437a8a556d5e0b61646648e30dfaf6156020 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 13:51:40 +0000 Subject: [PATCH 11/91] refactor: clean up pagination code more --- .../PaginationWidget/usePaginatedQuery.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 527b7f9fd8668..6863596bd0053 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -100,19 +100,20 @@ export function usePaginatedQuery< keepPreviousData: true, }); - const queryClient = useQueryClient(); - const prefetchPage = useEffectEvent((newPage: number) => { - return queryClient.prefetchQuery(queryOptionsFromPage(newPage)); - }); - const totalRecords = query.data?.count ?? 0; const totalPages = Math.ceil(totalRecords / pageSize); const hasNextPage = pageSize * pageOffset < totalRecords; const hasPreviousPage = currentPage > 1; + const queryClient = useQueryClient(); + const prefetchPage = useEffectEvent((newPage: number) => { + return queryClient.prefetchQuery(queryOptionsFromPage(newPage)); + }); + // Have to split hairs and sync on both the current page and the hasXPage - // variables because hasXPage values are derived from server values and aren't - // immediately ready on each render + // variables, because the page can change immediately client-side, but the + // hasXPage values are derived from the server and won't be immediately ready + // on the initial render useEffect(() => { if (hasNextPage) { void prefetchPage(currentPage + 1); From f612d8f3b29e6bea1dc5c894228fdbbc6dc4d06b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 15:35:31 +0000 Subject: [PATCH 12/91] fix: remove redundant properties --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 6863596bd0053..cfcc3b6a75dcb 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -72,7 +72,7 @@ export function usePaginatedQuery< TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: UsePaginatedQueryOptions) { - const { queryKey, queryFn, ...otherOptions } = options; + const { queryKey, queryFn } = options; const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); @@ -96,7 +96,6 @@ export function usePaginatedQuery< // virtualization as the lists get bigger (especially for the audit logs) const query = useQuery({ ...queryOptionsFromPage(currentPage), - ...otherOptions, keepPreviousData: true, }); From 5878326b1a6491f5ed26952c69032a7c9e28af30 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 15:48:21 +0000 Subject: [PATCH 13/91] refactor: clean up code --- .../PaginationWidget/usePaginatedQuery.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index cfcc3b6a75dcb..a2d7d0e7dbc18 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -22,7 +22,7 @@ const PAGE_NUMBER_PARAMS_KEY = "page"; /** * All arguments passed into the queryKey functions. */ -type QueryKeyFnArgs = { +type QueryPageParams = { pageNumber: number; pageSize: number; pageOffset: number; @@ -57,13 +57,13 @@ export type UsePaginatedQueryOptions< * Must be a function so that it can be used for the active query, as well as * any prefetching. */ - queryKey: (args: QueryKeyFnArgs) => TQueryKey; + queryKey: (args: QueryPageParams) => TQueryKey; /** * A version of queryFn that is required and that exposes page numbers through * the pageParams context property */ - queryFn: QueryFunction; + queryFn: QueryFunction; }; export function usePaginatedQuery< @@ -72,7 +72,7 @@ export function usePaginatedQuery< TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: UsePaginatedQueryOptions) { - const { queryKey, queryFn } = options; + const { queryKey, queryFn, ...extraReactQueryOptions } = options; const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); @@ -80,21 +80,24 @@ export function usePaginatedQuery< const pageOffset = (currentPage - 1) * pageSize; const queryOptionsFromPage = (pageNumber: number) => { + const pageParam: QueryPageParams = { + pageNumber, + pageOffset, + pageSize, + }; + return { - queryFn: (queryCxt: QueryFunctionContext) => { - return queryFn({ ...queryCxt, pageParam: pageNumber }); + queryKey: queryKey(pageParam), + queryFn: (qfc: QueryFunctionContext) => { + return queryFn({ ...qfc, pageParam }); }, - queryKey: queryKey({ - pageNumber: currentPage, - pageSize, - pageOffset, - }), } 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) const query = useQuery({ + ...extraReactQueryOptions, ...queryOptionsFromPage(currentPage), keepPreviousData: true, }); From 5cc1c2de4d96cafcc083758a7151dddbe8166437 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 17:42:03 +0000 Subject: [PATCH 14/91] wip: commit progress on usePaginatedQuery --- site/src/api/queries/users.ts | 26 +++++-- .../PaginationWidget/usePaginatedQuery.ts | 67 +++++++++++++------ 2 files changed, 67 insertions(+), 26 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 40e9afa7479e2..2fbb7d1518061 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, @@ -11,15 +11,33 @@ import { import { getAuthorizationKey } from "./authCheck"; import { getMetadataAsJSON } from "utils/metadata"; import { UsePaginatedQueryOptions } from "components/PaginationWidget/usePaginatedQuery"; +import { prepareQuery } from "utils/filters"; export function usersKey(req: UsersRequest) { return ["users", req] as const; } -export function paginatedUsers(req: UsersRequest) { +export function paginatedUsers() { return { - queryKey: (pagination) => usersKey(pagination), - queryFn: () => API.getUsers(req), + searchParamsKey: "filter", + + queryKey: ({ pageSize, pageOffset, searchParamsQuery }) => { + return usersKey({ + q: prepareQuery(searchParamsQuery ?? ""), + limit: pageSize, + offset: pageOffset, + }); + }, + queryFn: ({ pageSize, pageOffset, searchParamsQuery, signal }) => { + return API.getUsers( + { + q: prepareQuery(searchParamsQuery ?? ""), + limit: pageSize, + offset: pageOffset, + }, + signal, + ); + }, cacheTime: 5 * 60 * 1000, } as const satisfies UsePaginatedQueryOptions; } diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index a2d7d0e7dbc18..9c7df260c2de4 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -6,7 +6,6 @@ import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; import { clamp } from "lodash"; import { - type QueryFunction, type QueryFunctionContext, type QueryKey, type UseQueryOptions, @@ -20,22 +19,26 @@ import { const PAGE_NUMBER_PARAMS_KEY = "page"; /** - * All arguments passed into the queryKey functions. + * Information about a paginated request. Passed into both the queryKey and + * queryFn functions on each render */ type QueryPageParams = { pageNumber: number; pageSize: number; pageOffset: number; - extraQuery?: string; + searchParamsQuery?: string; }; /** * Any JSON-serializable object returned by the API that exposes the total * number of records that match a query */ -interface PaginatedData { +type PaginatedData = { count: number; -} +}; + +type QueryFnContext = QueryPageParams & + Omit, "pageParam">; /** * A more specialized version of UseQueryOptions built specifically for @@ -49,21 +52,28 @@ export type UsePaginatedQueryOptions< TQueryKey extends QueryKey = QueryKey, > = Omit< UseQueryOptions, - "keepPreviousData" | "queryKey" + "keepPreviousData" | "queryKey" | "queryFn" > & { + /** + * The key to use for parsing additional query information + */ + searchParamsKey?: string; + /** * 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, as well as * any prefetching. */ - queryKey: (args: QueryPageParams) => TQueryKey; + queryKey: (params: QueryPageParams) => TQueryKey; /** - * A version of queryFn that is required and that exposes page numbers through - * the pageParams context property + * A version of queryFn that is required and that exposes the pagination + * information through the pageParams context property */ - queryFn: QueryFunction; + queryFn: ( + context: QueryFnContext, + ) => TQueryFnData | Promise; }; export function usePaginatedQuery< @@ -72,33 +82,44 @@ export function usePaginatedQuery< TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: UsePaginatedQueryOptions) { - const { queryKey, queryFn, ...extraReactQueryOptions } = options; - const [searchParams, setSearchParams] = useSearchParams(); + const { + queryKey, + searchParamsKey, + queryFn: outerQueryFn, + ...extraOptions + } = options; + const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); const pageSize = DEFAULT_RECORDS_PER_PAGE; const pageOffset = (currentPage - 1) * pageSize; - const queryOptionsFromPage = (pageNumber: number) => { + const getQueryOptionsFromPage = (pageNumber: number) => { + const searchParamsQuery = + searchParamsKey !== undefined + ? searchParams.get(searchParamsKey) ?? undefined + : undefined; + const pageParam: QueryPageParams = { pageNumber, pageOffset, pageSize, + searchParamsQuery, }; return { queryKey: queryKey(pageParam), - queryFn: (qfc: QueryFunctionContext) => { - return queryFn({ ...qfc, pageParam }); + queryFn: (context: QueryFunctionContext) => { + return outerQueryFn({ ...context, ...pageParam }); }, } 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) - const query = useQuery({ - ...extraReactQueryOptions, - ...queryOptionsFromPage(currentPage), + const query = useQuery({ + ...extraOptions, + ...getQueryOptionsFromPage(currentPage), keepPreviousData: true, }); @@ -109,7 +130,7 @@ export function usePaginatedQuery< const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { - return queryClient.prefetchQuery(queryOptionsFromPage(newPage)); + return queryClient.prefetchQuery(getQueryOptionsFromPage(newPage)); }); // Have to split hairs and sync on both the current page and the hasXPage @@ -164,9 +185,11 @@ export function usePaginatedQuery< hasNextPage, hasPreviousPage, - // Have to hijack the isLoading property slightly because keepPreviousData - // is true; by default, isLoading will be false after the initial page - // loads, even if new pages are loading in + // Hijacking the isLoading property slightly because keepPreviousData is + // true; by default, isLoading will always be false after the initial page + // loads, even if new pages are loading in. Especially since + // keepPreviousData is an implementation detail, simplifying the API felt + // like the better option, at the risk of it becoming more "magical" isLoading: query.isLoading || query.isFetching, } as const; } From 0138f214bea134d85509e1830d3a8ef372df1651 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 15 Nov 2023 19:26:14 +0000 Subject: [PATCH 15/91] wip: commit current pagination progress --- site/src/api/queries/users.ts | 23 ++++------ .../PaginationWidget/usePaginatedQuery.ts | 42 ++++++++++++------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 2fbb7d1518061..301d37e13a8b5 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -19,25 +19,16 @@ export function usersKey(req: UsersRequest) { export function paginatedUsers() { return { - searchParamsKey: "filter", - - queryKey: ({ pageSize, pageOffset, searchParamsQuery }) => { - return usersKey({ - q: prepareQuery(searchParamsQuery ?? ""), + queryPayload: ({ pageSize, pageOffset, searchParams }) => { + return { + q: prepareQuery(searchParams.get("filter") ?? ""), limit: pageSize, offset: pageOffset, - }); - }, - queryFn: ({ pageSize, pageOffset, searchParamsQuery, signal }) => { - return API.getUsers( - { - q: prepareQuery(searchParamsQuery ?? ""), - limit: pageSize, - offset: pageOffset, - }, - signal, - ); + }; }, + + queryKey: ({ payload }) => usersKey(payload), + queryFn: ({ payload, signal }) => API.getUsers(payload, signal), cacheTime: 5 * 60 * 1000, } as const satisfies UsePaginatedQueryOptions; } diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 9c7df260c2de4..7ef8895508e4b 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { useSearchParams } from "react-router-dom"; import { useEffectEvent } from "hooks/hookPolyfills"; +import { useSearchParams } from "react-router-dom"; import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; import { clamp } from "lodash"; @@ -26,7 +26,11 @@ type QueryPageParams = { pageNumber: number; pageSize: number; pageOffset: number; - searchParamsQuery?: string; + searchParams: URLSearchParams; +}; + +type QueryPageParamsWithPayload = QueryPageParams & { + payload: T; }; /** @@ -37,8 +41,9 @@ type PaginatedData = { count: number; }; -type QueryFnContext = QueryPageParams & - Omit, "pageParam">; +type QueryFnContext = + QueryPageParamsWithPayload & + Omit, "pageParam">; /** * A more specialized version of UseQueryOptions built specifically for @@ -50,14 +55,19 @@ export type UsePaginatedQueryOptions< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TQueryPayload = unknown, > = Omit< UseQueryOptions, "keepPreviousData" | "queryKey" | "queryFn" > & { /** - * The key to use for parsing additional query information + * A function for defining values that should be shared between queryKey and + * queryFn. The value will be exposed via the "payload" property in + * QueryPageParams. + * + * Mainly here for convenience and minimizing copy-and-pasting. */ - searchParamsKey?: string; + queryPayload?: (params: QueryPageParams) => TQueryPayload; /** * A function that takes pagination information and produces a full query key. @@ -65,7 +75,7 @@ export type UsePaginatedQueryOptions< * Must be a function so that it can be used for the active query, as well as * any prefetching. */ - queryKey: (params: QueryPageParams) => TQueryKey; + queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; /** * A version of queryFn that is required and that exposes the pagination @@ -84,7 +94,7 @@ export function usePaginatedQuery< >(options: UsePaginatedQueryOptions) { const { queryKey, - searchParamsKey, + queryPayload, queryFn: outerQueryFn, ...extraOptions } = options; @@ -95,22 +105,22 @@ export function usePaginatedQuery< const pageOffset = (currentPage - 1) * pageSize; const getQueryOptionsFromPage = (pageNumber: number) => { - const searchParamsQuery = - searchParamsKey !== undefined - ? searchParams.get(searchParamsKey) ?? undefined - : undefined; - const pageParam: QueryPageParams = { pageNumber, pageOffset, pageSize, - searchParamsQuery, + searchParams, + }; + + const withPayload: QueryPageParamsWithPayload = { + ...pageParam, + payload: queryPayload?.(pageParam), }; return { - queryKey: queryKey(pageParam), + queryKey: queryKey(withPayload), queryFn: (context: QueryFunctionContext) => { - return outerQueryFn({ ...context, ...pageParam }); + return outerQueryFn({ ...context, ...withPayload }); }, } as const; }; From a38fd308d367f9d17a6b3c7b550d1cacfd2fc18d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 17 Nov 2023 14:54:40 +0000 Subject: [PATCH 16/91] docs: clean up comments for clarity --- .../PaginationWidget/usePaginatedQuery.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 7ef8895508e4b..e86dbc718942d 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -19,8 +19,8 @@ import { const PAGE_NUMBER_PARAMS_KEY = "page"; /** - * Information about a paginated request. Passed into both the queryKey and - * queryFn functions on each render + * Information about a paginated request. This information is passed into the + * queryPayload, queryKey, and queryFn properties of the hook. */ type QueryPageParams = { pageNumber: number; @@ -41,7 +41,7 @@ type PaginatedData = { count: number; }; -type QueryFnContext = +type PaginatedQueryFnContext = QueryPageParamsWithPayload & Omit, "pageParam">; @@ -65,7 +65,8 @@ export type UsePaginatedQueryOptions< * queryFn. The value will be exposed via the "payload" property in * QueryPageParams. * - * Mainly here for convenience and minimizing copy-and-pasting. + * Mainly here for convenience and minimizing copy-and-pasting between + * queryKey and queryFn. */ queryPayload?: (params: QueryPageParams) => TQueryPayload; @@ -82,7 +83,7 @@ export type UsePaginatedQueryOptions< * information through the pageParams context property */ queryFn: ( - context: QueryFnContext, + context: PaginatedQueryFnContext, ) => TQueryFnData | Promise; }; @@ -126,7 +127,8 @@ export function usePaginatedQuery< }; // 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) + // virtualization as the lists get bigger (especially for the audit logs). + // Keeping initial implementation simple. const query = useQuery({ ...extraOptions, ...getQueryOptionsFromPage(currentPage), From 22d6c243f414273a92a9c34ba78b9258717f19f8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 16:40:47 +0000 Subject: [PATCH 17/91] wip: get type signatures compatible (breaks runtime logic slightly) --- site/src/api/queries/users.ts | 2 +- .../PaginationWidget/usePaginatedQuery.ts | 90 +++++++++++-------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 301d37e13a8b5..6f1dd378e56e3 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -30,7 +30,7 @@ export function paginatedUsers() { queryKey: ({ payload }) => usersKey(payload), queryFn: ({ payload, signal }) => API.getUsers(payload, signal), cacheTime: 5 * 60 * 1000, - } as const satisfies UsePaginatedQueryOptions; + } as const satisfies UsePaginatedQueryOptions; } export const users = (req: UsersRequest): UseQueryOptions => { diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index e86dbc718942d..8135b38524266 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -29,8 +29,12 @@ type QueryPageParams = { searchParams: URLSearchParams; }; -type QueryPageParamsWithPayload = QueryPageParams & { - payload: T; +/** + * Query page params, plus the result of the queryPayload function. + * This type is passed to both queryKey and queryFn. + */ +type QueryPageParamsWithPayload = QueryPageParams & { + payload: [TPayload] extends [never] ? undefined : TPayload; }; /** @@ -41,9 +45,21 @@ type PaginatedData = { count: number; }; -type PaginatedQueryFnContext = - QueryPageParamsWithPayload & - Omit, "pageParam">; +type PaginatedQueryFnContext< + TQueryKey extends QueryKey = QueryKey, + TPayload = never, +> = Omit, "pageParam"> & + QueryPageParamsWithPayload; + +type BasePaginationOptions< + TQueryFnData extends PaginatedData = PaginatedData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + "keepPreviousData" | "queryKey" | "queryFn" +>; /** * A more specialized version of UseQueryOptions built specifically for @@ -52,47 +68,47 @@ type PaginatedQueryFnContext = // All the type parameters just mirror the ones used by React Query export type UsePaginatedQueryOptions< TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, - TQueryPayload = unknown, -> = Omit< - UseQueryOptions, - "keepPreviousData" | "queryKey" | "queryFn" -> & { - /** - * A function for defining values that should be shared between queryKey and - * queryFn. The value will be exposed via the "payload" property in - * QueryPageParams. - * - * Mainly here for convenience and minimizing copy-and-pasting between - * queryKey and queryFn. - */ - queryPayload?: (params: QueryPageParams) => TQueryPayload; - - /** - * 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, as well as - * any prefetching. - */ - queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; - - /** - * A version of queryFn that is required and that exposes the pagination - * information through the pageParams context property - */ - queryFn: ( - context: PaginatedQueryFnContext, - ) => TQueryFnData | Promise; -}; +> = BasePaginationOptions & + ([TQueryPayload] extends [never] + ? { queryPayload?: never } + : { queryPayload: (params: QueryPageParams) => TQueryPayload }) & { + /** + * 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, as well + * as any prefetching. + */ + queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; + + /** + * A version of queryFn that is required and that exposes the pagination + * information through the pageParams context property + */ + queryFn: ( + context: PaginatedQueryFnContext, + ) => TQueryFnData | Promise; + }; export function usePaginatedQuery< TQueryFnData extends PaginatedData = PaginatedData, TError = unknown, TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(options: UsePaginatedQueryOptions) { + TPayload = never, +>( + options: UsePaginatedQueryOptions< + TQueryFnData, + TPayload, + TError, + TData, + TQueryKey + >, +) { const { queryKey, queryPayload, From e717da1dff1844f30e91b43c48f8056e780b517f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 18:45:36 +0000 Subject: [PATCH 18/91] refactor: clean up type definitions --- .../PaginationWidget/usePaginatedQuery.ts | 116 ++++++++++-------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 8135b38524266..8b8cce74e5340 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -18,49 +18,6 @@ import { */ const PAGE_NUMBER_PARAMS_KEY = "page"; -/** - * Information about a paginated request. This information is passed into the - * queryPayload, queryKey, and queryFn properties of the hook. - */ -type QueryPageParams = { - pageNumber: number; - pageSize: number; - pageOffset: number; - searchParams: URLSearchParams; -}; - -/** - * Query page params, plus the result of the queryPayload function. - * This type is passed to both queryKey and queryFn. - */ -type QueryPageParamsWithPayload = QueryPageParams & { - payload: [TPayload] extends [never] ? undefined : TPayload; -}; - -/** - * Any JSON-serializable object returned by the API that exposes the total - * number of records that match a query - */ -type PaginatedData = { - count: number; -}; - -type PaginatedQueryFnContext< - TQueryKey extends QueryKey = QueryKey, - TPayload = never, -> = Omit, "pageParam"> & - QueryPageParamsWithPayload; - -type BasePaginationOptions< - TQueryFnData extends PaginatedData = PaginatedData, - TError = unknown, - TData = TQueryFnData, - TQueryKey extends QueryKey = QueryKey, -> = Omit< - UseQueryOptions, - "keepPreviousData" | "queryKey" | "queryFn" ->; - /** * A more specialized version of UseQueryOptions built specifically for * paginated queries. @@ -80,8 +37,8 @@ export type UsePaginatedQueryOptions< * 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, as well - * as any prefetching. + * Must be a function so that it can be used for the active query, and then + * reused for any prefetching queries. */ queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; @@ -129,15 +86,13 @@ export function usePaginatedQuery< searchParams, }; - const withPayload: QueryPageParamsWithPayload = { - ...pageParam, - payload: queryPayload?.(pageParam), - }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Have to do this because proving the type's soundness to the compiler will make the code so much more convoluted and harder to maintain + const payload = queryPayload?.(pageParam) as any; return { - queryKey: queryKey(withPayload), + queryKey: queryKey({ ...pageParam, payload }), queryFn: (context: QueryFunctionContext) => { - return outerQueryFn({ ...context, ...withPayload }); + return outerQueryFn({ ...context, ...pageParam, payload }); }, } as const; }; @@ -226,3 +181,62 @@ function parsePage(params: URLSearchParams): number { const parsed = Number(params.get("page")); return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } + +/** + * Information about a paginated request. This information is passed into the + * queryPayload, queryKey, and queryFn properties of the hook. + */ +type QueryPageParams = { + pageNumber: number; + pageSize: number; + pageOffset: number; + searchParams: URLSearchParams; +}; + +/** + * 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 = QueryPageParams & { + payload: [TPayload] extends [never] ? undefined : TPayload; +}; + +/** + * Any JSON-serializable object returned by the API that exposes the total + * number of records that match a query + */ +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, "pageParam"> & + QueryPageParamsWithPayload; + +/** + * 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 simplify replacing the type of its context argument + * - queryKey - Removed so that it can be replaced with the function form of + * queryKey + */ +type BasePaginationOptions< + TQueryFnData extends PaginatedData = PaginatedData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + "keepPreviousData" | "queryKey" | "queryFn" +>; From 7624d94f7aaccc9ee5fd004b4aed2fc47fd334d8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 18:54:51 +0000 Subject: [PATCH 19/91] chore: add support for custom onInvalidPage functions --- .../PaginationWidget/usePaginatedQuery.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 8b8cce74e5340..75ef54fc67a04 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -49,6 +49,15 @@ export type UsePaginatedQueryOptions< queryFn: ( context: PaginatedQueryFnContext, ) => TQueryFnData | Promise; + + /** + * 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, usePaginatedQuery will navigate + * the user to the closest valid page. + */ + onInvalidPage?: (currentPage: number, totalPages: number) => void; }; export function usePaginatedQuery< @@ -69,6 +78,7 @@ export function usePaginatedQuery< const { queryKey, queryPayload, + onInvalidPage, queryFn: outerQueryFn, ...extraOptions } = options; @@ -134,8 +144,12 @@ export function usePaginatedQuery< // Mainly here to catch user if they navigate to a page directly via URL const updatePageIfInvalid = useEffectEvent(() => { - const clamped = clamp(currentPage, 1, totalPages); + if (onInvalidPage !== undefined) { + onInvalidPage(currentPage, totalPages); + return; + } + const clamped = clamp(currentPage, 1, totalPages); if (currentPage !== clamped) { searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); setSearchParams(searchParams); From 26231312d70e5d80a6cb254d87ae0628586ec08c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 19:12:17 +0000 Subject: [PATCH 20/91] refactor: clean up type definitions more for clarity reasons --- .../PaginationWidget/usePaginatedQuery.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 75ef54fc67a04..b47acccba502e 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -30,9 +30,7 @@ export type UsePaginatedQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = BasePaginationOptions & - ([TQueryPayload] extends [never] - ? { queryPayload?: never } - : { queryPayload: (params: QueryPageParams) => TQueryPayload }) & { + QueryPayloadExtender & { /** * A function that takes pagination information and produces a full query * key. @@ -144,13 +142,14 @@ export function usePaginatedQuery< // Mainly here to catch user if they navigate to a page directly via URL const updatePageIfInvalid = useEffectEvent(() => { - if (onInvalidPage !== undefined) { - onInvalidPage(currentPage, totalPages); + const clamped = clamp(currentPage, 1, totalPages); + if (currentPage === clamped) { return; } - const clamped = clamp(currentPage, 1, totalPages); - if (currentPage !== clamped) { + if (onInvalidPage !== undefined) { + onInvalidPage(currentPage, totalPages); + } else { searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); setSearchParams(searchParams); } @@ -196,6 +195,24 @@ function parsePage(params: URLSearchParams): number { return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } +/** + * 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] extends [ + never, +] + ? { queryPayload?: never } + : { queryPayload: (params: QueryPageParams) => TQueryPayload }; + /** * Information about a paginated request. This information is passed into the * queryPayload, queryKey, and queryFn properties of the hook. From 29242e9f194b7eb91cbdca3500055366d403734c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 19:28:34 +0000 Subject: [PATCH 21/91] chore: delete Pagination component (separate PR) --- .../PaginationWidget/Pagination.tsx | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 site/src/components/PaginationWidget/Pagination.tsx diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx deleted file mode 100644 index 7fc882345f316..0000000000000 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { type PropsWithChildren, useEffect, useRef } from "react"; -import { PaginationWidgetBase } from "./PaginationWidgetBase"; - -type PaginationProps = PropsWithChildren<{ - fetching: boolean; - currentPage: number; - pageSize: number; - totalRecords: number; - onPageChange: (newPage: number) => void; -}>; - -export function Pagination({ - children, - fetching, - currentPage, - totalRecords, - pageSize, - onPageChange, -}: PaginationProps) { - const scrollAfterPageChangeRef = useRef(false); - useEffect(() => { - const onScroll = () => { - scrollAfterPageChangeRef.current = false; - }; - - document.addEventListener("scroll", onScroll); - return () => document.removeEventListener("scroll", onScroll); - }, []); - - const previousPageRef = useRef(undefined); - const paginationTopRef = useRef(null); - useEffect(() => { - const paginationTop = paginationTopRef.current; - const isInitialRender = previousPageRef.current === undefined; - - const skipScroll = - isInitialRender || - paginationTop === null || - !scrollAfterPageChangeRef.current; - - previousPageRef.current = currentPage; - if (!skipScroll) { - paginationTop.scrollIntoView(); - } - }, [currentPage]); - - return ( - <> -
- {children} - - { - scrollAfterPageChangeRef.current = true; - onPageChange(newPage); - }} - /> - - ); -} From 5a9aa2dd9bd560f532170d8b55f699cfb99d1ac8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 19:29:20 +0000 Subject: [PATCH 22/91] chore: remove cacheTime fixes (to be resolved in future PR) --- site/src/api/queries/users.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 6f1dd378e56e3..712262499fb4a 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -29,7 +29,6 @@ export function paginatedUsers() { queryKey: ({ payload }) => usersKey(payload), queryFn: ({ payload, signal }) => API.getUsers(payload, signal), - cacheTime: 5 * 60 * 1000, } as const satisfies UsePaginatedQueryOptions; } @@ -37,7 +36,6 @@ export const users = (req: UsersRequest): UseQueryOptions => { return { queryKey: ["users", req], queryFn: ({ signal }) => API.getUsers(req, signal), - cacheTime: 5 * 60 * 1000, }; }; From 3d673042a0c6c60a987c7bab0607f37d410b9ffb Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 19:43:42 +0000 Subject: [PATCH 23/91] docs: add clarifying/intellisense comments for DX --- .../PaginationWidget/usePaginatedQuery.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index b47acccba502e..7db9aec4315b6 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -36,7 +36,7 @@ export type UsePaginatedQueryOptions< * key. * * Must be a function so that it can be used for the active query, and then - * reused for any prefetching queries. + * reused for any prefetching queries (swapping the page number out). */ queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; @@ -211,7 +211,17 @@ type QueryPayloadExtender = [TQueryPayload] extends [ never, ] ? { queryPayload?: never } - : { queryPayload: (params: QueryPageParams) => TQueryPayload }; + : { + /** + * 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 @@ -258,7 +268,8 @@ type PaginatedQueryFnContext< * 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 simplify replacing the type of its context argument + * - 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 */ From d97706055b315d025268f281101cdc58a2ae913d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 20:23:40 +0000 Subject: [PATCH 24/91] refactor: link users queries to same queryKey implementation --- site/src/api/queries/users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 712262499fb4a..f7b5d871fb485 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -34,7 +34,7 @@ export function paginatedUsers() { export const users = (req: UsersRequest): UseQueryOptions => { return { - queryKey: ["users", req], + queryKey: usersKey(req), queryFn: ({ signal }) => API.getUsers(req, signal), }; }; From 9224624b50502c2493324f7f50c5efee7e048856 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 20:26:21 +0000 Subject: [PATCH 25/91] docs: remove misleading comment --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 7db9aec4315b6..892d2b4f117de 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -42,7 +42,7 @@ export type UsePaginatedQueryOptions< /** * A version of queryFn that is required and that exposes the pagination - * information through the pageParams context property + * information through its query function context argument */ queryFn: ( context: PaginatedQueryFnContext, From 540c779c731a4cda7c1b7eeef6be2af5cc5affb0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 20:29:15 +0000 Subject: [PATCH 26/91] docs: more comments --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 892d2b4f117de..b15bfc83fb719 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -52,8 +52,9 @@ export type UsePaginatedQueryOptions< * 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, usePaginatedQuery will navigate - * the user to the closest valid page. + * 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. */ onInvalidPage?: (currentPage: number, totalPages: number) => void; }; From 4b42b6f3a7e9614eeb214d7feebea734f5454f19 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 20:39:33 +0000 Subject: [PATCH 27/91] chore: update onInvalidPage params for more flexibility --- .../PaginationWidget/usePaginatedQuery.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index b15bfc83fb719..3b0b442455941 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useEffectEvent } from "hooks/hookPolyfills"; -import { useSearchParams } from "react-router-dom"; +import { type SetURLSearchParams, useSearchParams } from "react-router-dom"; import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; import { clamp } from "lodash"; @@ -22,7 +22,6 @@ const PAGE_NUMBER_PARAMS_KEY = "page"; * A more specialized version of UseQueryOptions built specifically for * paginated queries. */ -// All the type parameters just mirror the ones used by React Query export type UsePaginatedQueryOptions< TQueryFnData extends PaginatedData = PaginatedData, TQueryPayload = never, @@ -56,19 +55,19 @@ export type UsePaginatedQueryOptions< * encountered, usePaginatedQuery will default to navigating the user to the * closest valid page. */ - onInvalidPage?: (currentPage: number, totalPages: number) => void; + onInvalidPage?: (params: InvalidPageParams) => void; }; export function usePaginatedQuery< TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, TError = unknown, TData extends PaginatedData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, - TPayload = never, >( options: UsePaginatedQueryOptions< TQueryFnData, - TPayload, + TQueryPayload, TError, TData, TQueryKey @@ -88,20 +87,20 @@ export function usePaginatedQuery< const pageOffset = (currentPage - 1) * pageSize; const getQueryOptionsFromPage = (pageNumber: number) => { - const pageParam: QueryPageParams = { + const pageParams: QueryPageParams = { pageNumber, pageOffset, pageSize, - searchParams, + searchParams: searchParams, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Have to do this because proving the type's soundness to the compiler will make the code so much more convoluted and harder to maintain - const payload = queryPayload?.(pageParam) as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Have to do this because proving the type's soundness to the compiler will make this file even more convoluted than it is now + const payload = queryPayload?.(pageParams) as any; return { - queryKey: queryKey({ ...pageParam, payload }), + queryKey: queryKey({ ...pageParams, payload }), queryFn: (context: QueryFunctionContext) => { - return outerQueryFn({ ...context, ...pageParam, payload }); + return outerQueryFn({ ...context, ...pageParams, payload }); }, } as const; }; @@ -149,7 +148,14 @@ export function usePaginatedQuery< } if (onInvalidPage !== undefined) { - onInvalidPage(currentPage, totalPages); + onInvalidPage({ + pageOffset, + pageSize, + totalPages, + setSearchParams, + pageNumber: currentPage, + searchParams: searchParams, + }); } else { searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); setSearchParams(searchParams); @@ -283,3 +289,11 @@ type BasePaginationOptions< UseQueryOptions, "keepPreviousData" | "queryKey" | "queryFn" >; + +/** + * The argument passed to a custom onInvalidPage callback. + */ +type InvalidPageParams = QueryPageParams & { + totalPages: number; + setSearchParams: SetURLSearchParams; +}; From 5bc43c9399b4a6f493d3ff39d21f054a32557506 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 21:08:24 +0000 Subject: [PATCH 28/91] fix: remove explicit any --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 3b0b442455941..f3d3ca7d81e9f 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -94,8 +94,11 @@ export function usePaginatedQuery< searchParams: searchParams, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Have to do this because proving the type's soundness to the compiler will make this file even more convoluted than it is now - const payload = queryPayload?.(pageParams) as any; + type RuntimePayload = [TQueryPayload] extends [never] + ? undefined + : TQueryPayload; + + const payload = queryPayload?.(pageParams) as RuntimePayload; return { queryKey: queryKey({ ...pageParams, payload }), From bd146a19422e9ca1c7563e0969c1a65bba9d1188 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 21:31:36 +0000 Subject: [PATCH 29/91] refactor: clean up type definitions --- .../PaginationWidget/usePaginatedQuery.ts | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index f3d3ca7d81e9f..719047484d8a7 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -11,6 +11,7 @@ import { type UseQueryOptions, useQueryClient, useQuery, + UseQueryResult, } from "react-query"; /** @@ -58,6 +59,23 @@ export type UsePaginatedQueryOptions< onInvalidPage?: (params: InvalidPageParams) => void; }; +export type UsePaginatedQueryResult = Omit< + UseQueryResult, + "isLoading" +> & { + isLoading: boolean; + hasNextPage: boolean; + hasPreviousPage: boolean; + + currentPage: number; + pageSize: number; + totalRecords: number; + + onPageChange: (newPage: number) => void; + goToPreviousPage: () => void; + goToNextPage: () => void; +}; + export function usePaginatedQuery< TQueryFnData extends PaginatedData = PaginatedData, TQueryPayload = never, @@ -72,7 +90,7 @@ export function usePaginatedQuery< TData, TQueryKey >, -) { +): UsePaginatedQueryResult { const { queryKey, queryPayload, @@ -94,11 +112,7 @@ export function usePaginatedQuery< searchParams: searchParams, }; - type RuntimePayload = [TQueryPayload] extends [never] - ? undefined - : TQueryPayload; - - const payload = queryPayload?.(pageParams) as RuntimePayload; + const payload = queryPayload?.(pageParams) as RuntimePayload; return { queryKey: queryKey({ ...pageParams, payload }), @@ -150,18 +164,20 @@ export function usePaginatedQuery< return; } - if (onInvalidPage !== undefined) { - onInvalidPage({ + if (onInvalidPage === undefined) { + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); + setSearchParams(searchParams); + } else { + const params: InvalidPageParams = { pageOffset, pageSize, totalPages, setSearchParams, pageNumber: currentPage, searchParams: searchParams, - }); - } else { - searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); - setSearchParams(searchParams); + }; + + onInvalidPage(params); } }); @@ -244,13 +260,22 @@ type QueryPageParams = { 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] 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 = QueryPageParams & { - payload: [TPayload] extends [never] ? undefined : TPayload; + payload: RuntimePayload; }; /** From 983b83f1da225a70caefc5b2db8e64e540098c3a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 21:44:38 +0000 Subject: [PATCH 30/91] refactor: rename query params for consistency --- site/src/api/queries/users.ts | 6 +- site/src/components/PaginationWidget/temp.ts | 112 ++++++++++++++++++ .../PaginationWidget/usePaginatedQuery.ts | 44 +++++-- 3 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 site/src/components/PaginationWidget/temp.ts diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index f7b5d871fb485..3db52dab06cf8 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -19,11 +19,11 @@ export function usersKey(req: UsersRequest) { export function paginatedUsers() { return { - queryPayload: ({ pageSize, pageOffset, searchParams }) => { + queryPayload: ({ limit, offset, searchParams }) => { return { + limit, + offset, q: prepareQuery(searchParams.get("filter") ?? ""), - limit: pageSize, - offset: pageOffset, }; }, diff --git a/site/src/components/PaginationWidget/temp.ts b/site/src/components/PaginationWidget/temp.ts new file mode 100644 index 0000000000000..03025d8e2fe4b --- /dev/null +++ b/site/src/components/PaginationWidget/temp.ts @@ -0,0 +1,112 @@ +export function usePaginatedQuery(options: any) { + const { + queryKey, + queryPayload, + onInvalidPage, + queryFn: outerQueryFn, + ...extraOptions + } = options; + + const [searchParams, setSearchParams] = useSearchParams(); + const currentPage = parsePage(searchParams); + const pageSize = DEFAULT_RECORDS_PER_PAGE; + const pageOffset = (currentPage - 1) * pageSize; + + const getQueryOptionsFromPage = (pageNumber: number) => { + const pageParams = { + pageNumber, + offset: pageOffset, + limit: pageSize, + searchParams: searchParams, + }; + + const payload = queryPayload?.(pageParams); + + return { + queryKey: queryKey({ ...pageParams, payload }), + queryFn: (context) => { + return outerQueryFn({ ...context, ...pageParams, payload }); + }, + } as const; + }; + + const query = useQuery({ + ...extraOptions, + ...getQueryOptionsFromPage(currentPage), + keepPreviousData: true, + }); + + const totalRecords = query.data?.count ?? 0; + const totalPages = Math.ceil(totalRecords / pageSize); + const hasNextPage = pageSize * pageOffset < totalRecords; + const hasPreviousPage = currentPage > 1; + + const queryClient = useQueryClient(); + const prefetchPage = useEffectEvent((newPage: number) => { + return queryClient.prefetchQuery(getQueryOptionsFromPage(newPage)); + }); + + 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 + const updatePageIfInvalid = useEffectEvent(() => { + const clamped = clamp(currentPage, 1, totalPages); + if (currentPage === clamped) { + return; + } + + if (onInvalidPage === undefined) { + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); + setSearchParams(searchParams); + } else { + const params = { + offset: pageOffset, + limit: pageSize, + totalPages, + setSearchParams, + pageNumber: currentPage, + searchParams: searchParams, + }; + + onInvalidPage(params); + } + }); + + useEffect(() => { + if (!query.isFetching) { + updatePageIfInvalid(); + } + }, [updatePageIfInvalid, query.isFetching]); + + const onPageChange = (newPage: number) => { + const safePage = Number.isInteger(newPage) + ? clamp(newPage, 1, totalPages) + : 1; + + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(safePage)); + setSearchParams(searchParams); + }; + + return { + ...query, + onPageChange, + goToPreviousPage: () => onPageChange(currentPage - 1), + goToNextPage: () => onPageChange(currentPage + 1), + currentPage, + pageSize, + totalRecords, + hasNextPage, + hasPreviousPage, + isLoading: query.isLoading || query.isFetching, + } as const; +} diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 719047484d8a7..7b84be813b11a 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -101,15 +101,15 @@ export function usePaginatedQuery< const [searchParams, setSearchParams] = useSearchParams(); const currentPage = parsePage(searchParams); - const pageSize = DEFAULT_RECORDS_PER_PAGE; - const pageOffset = (currentPage - 1) * pageSize; + const limit = DEFAULT_RECORDS_PER_PAGE; + const offset = (currentPage - 1) * limit; const getQueryOptionsFromPage = (pageNumber: number) => { const pageParams: QueryPageParams = { pageNumber, - pageOffset, - pageSize, - searchParams: searchParams, + offset, + limit, + searchParams, }; const payload = queryPayload?.(pageParams) as RuntimePayload; @@ -132,8 +132,8 @@ export function usePaginatedQuery< }); const totalRecords = query.data?.count ?? 0; - const totalPages = Math.ceil(totalRecords / pageSize); - const hasNextPage = pageSize * pageOffset < totalRecords; + const totalPages = Math.ceil(totalRecords / limit); + const hasNextPage = limit * offset < totalRecords; const hasPreviousPage = currentPage > 1; const queryClient = useQueryClient(); @@ -169,8 +169,8 @@ export function usePaginatedQuery< setSearchParams(searchParams); } else { const params: InvalidPageParams = { - pageOffset, - pageSize, + offset: offset, + limit: limit, totalPages, setSearchParams, pageNumber: currentPage, @@ -202,7 +202,7 @@ export function usePaginatedQuery< goToPreviousPage: () => onPageChange(currentPage - 1), goToNextPage: () => onPageChange(currentPage + 1), currentPage, - pageSize, + pageSize: limit, totalRecords, hasNextPage, hasPreviousPage, @@ -254,9 +254,29 @@ type QueryPayloadExtender = [TQueryPayload] extends [ * 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; - pageSize: number; - pageOffset: 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; }; From a43e29456006bb3083f415653432eb74f45a59b6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 20 Nov 2023 23:49:45 +0000 Subject: [PATCH 31/91] refactor: clean up input validation for page changes --- .../PaginationWidget/usePaginatedQuery.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 7b84be813b11a..9cdb09cc7a075 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -59,6 +59,10 @@ export type UsePaginatedQueryOptions< onInvalidPage?: (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 = Omit< UseQueryResult, "isLoading" @@ -188,11 +192,12 @@ export function usePaginatedQuery< }, [updatePageIfInvalid, query.isFetching]); const onPageChange = (newPage: number) => { - const safePage = Number.isInteger(newPage) - ? clamp(newPage, 1, totalPages) - : 1; + const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages); + if (!Number.isInteger(cleanedInput) || cleanedInput <= 0) { + return; + } - searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(safePage)); + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput)); setSearchParams(searchParams); }; From db03f3e870f2d807be3abff06930cf51ce73a367 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 01:03:19 +0000 Subject: [PATCH 32/91] refactor/fix: update hook to be aware of async data --- .../PaginationWidget/usePaginatedQuery.ts | 126 +++++++++++------- 1 file changed, 81 insertions(+), 45 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 9cdb09cc7a075..edae14dbd20e1 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -1,8 +1,6 @@ import { useEffect } from "react"; import { useEffectEvent } from "hooks/hookPolyfills"; import { type SetURLSearchParams, useSearchParams } from "react-router-dom"; - -import { DEFAULT_RECORDS_PER_PAGE } from "./utils"; import { clamp } from "lodash"; import { @@ -14,6 +12,8 @@ import { UseQueryResult, } from "react-query"; +const DEFAULT_RECORDS_PER_PAGE = 25; + /** * The key to use for getting/setting the page number from the search params */ @@ -56,29 +56,38 @@ export type UsePaginatedQueryOptions< * encountered, usePaginatedQuery will default to navigating the user to the * closest valid page. */ - onInvalidPage?: (params: InvalidPageParams) => void; + 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 = Omit< - UseQueryResult, - "isLoading" -> & { - isLoading: boolean; - hasNextPage: boolean; - hasPreviousPage: boolean; - +export type UsePaginatedQueryResult< + TData = unknown, + TError = unknown, +> = UseQueryResult & { currentPage: number; - pageSize: number; - totalRecords: number; - + limit: number; onPageChange: (newPage: number) => void; goToPreviousPage: () => void; goToNextPage: () => void; -}; +} & ( + | { + isSuccess: true; + hasNextPage: false; + hasPreviousPage: false; + totalRecords: undefined; + totalPages: undefined; + } + | { + isSuccess: false; + hasNextPage: boolean; + hasPreviousPage: boolean; + totalRecords: number; + totalPages: number; + } + ); export function usePaginatedQuery< TQueryFnData extends PaginatedData = PaginatedData, @@ -98,7 +107,7 @@ export function usePaginatedQuery< const { queryKey, queryPayload, - onInvalidPage, + onInvalidPageChange, queryFn: outerQueryFn, ...extraOptions } = options; @@ -135,10 +144,13 @@ export function usePaginatedQuery< keepPreviousData: true, }); - const totalRecords = query.data?.count ?? 0; - const totalPages = Math.ceil(totalRecords / limit); - const hasNextPage = limit * offset < totalRecords; - const hasPreviousPage = currentPage > 1; + const totalRecords = query.data?.count; + const totalPages = + totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined; + + const hasPreviousPage = totalPages !== undefined && currentPage > 1; + const hasNextPage = + totalRecords !== undefined && limit * offset < totalRecords; const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { @@ -162,36 +174,40 @@ export function usePaginatedQuery< }, [prefetchPage, currentPage, hasPreviousPage]); // Mainly here to catch user if they navigate to a page directly via URL - const updatePageIfInvalid = useEffectEvent(() => { + const updatePageIfInvalid = useEffectEvent((totalPages: number) => { const clamped = clamp(currentPage, 1, totalPages); if (currentPage === clamped) { return; } - if (onInvalidPage === undefined) { + if (onInvalidPageChange === undefined) { searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); setSearchParams(searchParams); } else { const params: InvalidPageParams = { - offset: offset, - limit: limit, + offset, + limit, totalPages, + searchParams, setSearchParams, pageNumber: currentPage, - searchParams: searchParams, }; - onInvalidPage(params); + onInvalidPageChange(params); } }); useEffect(() => { - if (!query.isFetching) { - updatePageIfInvalid(); + if (!query.isFetching && totalPages !== undefined) { + updatePageIfInvalid(totalPages); } - }, [updatePageIfInvalid, query.isFetching]); + }, [updatePageIfInvalid, query.isFetching, totalPages]); const onPageChange = (newPage: number) => { + if (totalPages === undefined) { + return; + } + const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages); if (!Number.isInteger(cleanedInput) || cleanedInput <= 0) { return; @@ -201,24 +217,44 @@ export function usePaginatedQuery< setSearchParams(searchParams); }; + const goToPreviousPage = () => { + if (hasPreviousPage) { + onPageChange(currentPage - 1); + } + }; + + const goToNextPage = () => { + if (hasNextPage) { + onPageChange(currentPage + 1); + } + }; + return { ...query, - onPageChange, - goToPreviousPage: () => onPageChange(currentPage - 1), - goToNextPage: () => onPageChange(currentPage + 1), + limit, currentPage, - pageSize: limit, - totalRecords, - hasNextPage, - hasPreviousPage, - - // Hijacking the isLoading property slightly because keepPreviousData is - // true; by default, isLoading will always be false after the initial page - // loads, even if new pages are loading in. Especially since - // keepPreviousData is an implementation detail, simplifying the API felt - // like the better option, at the risk of it becoming more "magical" - isLoading: query.isLoading || query.isFetching, - } as const; + onPageChange, + goToPreviousPage, + goToNextPage, + + ...(query.isSuccess + ? { + hasNextPage, + hasPreviousPage, + totalRecords: totalRecords as number, + totalPages: totalPages as number, + } + : { + hasNextPage: false, + hasPreviousPage: false, + totalRecords: undefined, + totalPages: undefined, + }), + + // Have to do assertion to make TypeScript happy with React Query internal + // type, but this means that you won't get feedback from the compiler if you + // set up a property the wrong way + } as UsePaginatedQueryResult; } function parsePage(params: URLSearchParams): number { @@ -344,7 +380,7 @@ type BasePaginationOptions< >; /** - * The argument passed to a custom onInvalidPage callback. + * The argument passed to a custom onInvalidPageChange callback. */ type InvalidPageParams = QueryPageParams & { totalPages: number; From 1b825f1fea9f5edb07ffd11b64872999c16511c0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 13:42:09 +0000 Subject: [PATCH 33/91] chore: add contravariance to dictionary --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6f726162d260a..8af33c71cac6a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "coderdenttest", "coderdtest", "codersdk", + "contravariance", "cronstrue", "databasefake", "dbmem", From 9631a27f16e3d173736e4ce19f0cd56d530e1374 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 13:42:43 +0000 Subject: [PATCH 34/91] refactor: increase type-safety of usePaginatedQuery --- .../PaginationWidget/usePaginatedQuery.ts | 61 ++++++++++--------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index edae14dbd20e1..4a821dc6883ab 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -66,28 +66,7 @@ export type UsePaginatedQueryOptions< export type UsePaginatedQueryResult< TData = unknown, TError = unknown, -> = UseQueryResult & { - currentPage: number; - limit: number; - onPageChange: (newPage: number) => void; - goToPreviousPage: () => void; - goToNextPage: () => void; -} & ( - | { - isSuccess: true; - hasNextPage: false; - hasPreviousPage: false; - totalRecords: undefined; - totalPages: undefined; - } - | { - isSuccess: false; - hasNextPage: boolean; - hasPreviousPage: boolean; - totalRecords: number; - totalPages: number; - } - ); +> = UseQueryResult & PaginationResultInfo; export function usePaginatedQuery< TQueryFnData extends PaginatedData = PaginatedData, @@ -229,32 +208,33 @@ export function usePaginatedQuery< } }; - return { - ...query, + // Have to do a type assertion at the end 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, goToPreviousPage, goToNextPage, - ...(query.isSuccess ? { + isSuccess: true, hasNextPage, hasPreviousPage, totalRecords: totalRecords as number, totalPages: totalPages as number, } : { + isSuccess: false, hasNextPage: false, hasPreviousPage: false, totalRecords: undefined, totalPages: undefined, }), + }; - // Have to do assertion to make TypeScript happy with React Query internal - // type, but this means that you won't get feedback from the compiler if you - // set up a property the wrong way - } as UsePaginatedQueryResult; + return { ...query, ...info } as UsePaginatedQueryResult; } function parsePage(params: URLSearchParams): number { @@ -262,6 +242,29 @@ function parsePage(params: URLSearchParams): number { return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } +type PaginationResultInfo = { + currentPage: number; + limit: number; + onPageChange: (newPage: number) => void; + goToPreviousPage: () => void; + goToNextPage: () => 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. From 4ea0cba403f1c865a4a32b9f0b6b9441cd09a30e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 14:03:33 +0000 Subject: [PATCH 35/91] docs: more comments --- site/src/components/PaginationWidget/usePaginatedQuery.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/components/PaginationWidget/usePaginatedQuery.ts index 4a821dc6883ab..f2596e17d0f66 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/components/PaginationWidget/usePaginatedQuery.ts @@ -7,9 +7,9 @@ import { type QueryFunctionContext, type QueryKey, type UseQueryOptions, + type UseQueryResult, useQueryClient, useQuery, - UseQueryResult, } from "react-query"; const DEFAULT_RECORDS_PER_PAGE = 25; @@ -24,6 +24,8 @@ const PAGE_NUMBER_PARAMS_KEY = "page"; * 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, @@ -242,6 +244,10 @@ function parsePage(params: URLSearchParams): number { return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; } +/** + * 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; From 3f63ec7065f53aa75c050a9e4fb88cdfdc620b74 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 14:06:12 +0000 Subject: [PATCH 36/91] chore: move usePaginatedQuery file --- .../{components/PaginationWidget => hooks}/usePaginatedQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename site/src/{components/PaginationWidget => hooks}/usePaginatedQuery.ts (99%) diff --git a/site/src/components/PaginationWidget/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts similarity index 99% rename from site/src/components/PaginationWidget/usePaginatedQuery.ts rename to site/src/hooks/usePaginatedQuery.ts index f2596e17d0f66..46a1f2ffea4c1 100644 --- a/site/src/components/PaginationWidget/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useEffectEvent } from "hooks/hookPolyfills"; +import { useEffectEvent } from "./hookPolyfills"; import { type SetURLSearchParams, useSearchParams } from "react-router-dom"; import { clamp } from "lodash"; From 614e4ffa40ead22f18b06527330fd4e4a61cba56 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 14:07:49 +0000 Subject: [PATCH 37/91] fix: add back cacheTime --- site/src/api/queries/users.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 3db52dab06cf8..2d490df2de38e 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -10,7 +10,7 @@ import type { } from "api/typesGenerated"; import { getAuthorizationKey } from "./authCheck"; import { getMetadataAsJSON } from "utils/metadata"; -import { UsePaginatedQueryOptions } from "components/PaginationWidget/usePaginatedQuery"; +import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; import { prepareQuery } from "utils/filters"; export function usersKey(req: UsersRequest) { @@ -29,6 +29,7 @@ export function paginatedUsers() { queryKey: ({ payload }) => usersKey(payload), queryFn: ({ payload, signal }) => API.getUsers(payload, signal), + cacheTime: 5 * 1000 * 60, } as const satisfies UsePaginatedQueryOptions; } @@ -36,6 +37,7 @@ export const users = (req: UsersRequest): UseQueryOptions => { return { queryKey: usersKey(req), queryFn: ({ signal }) => API.getUsers(req, signal), + cacheTime: 5 * 1000 * 60, }; }; From e994532a038dac7a0050e058d15a4d8aaca10277 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 14:20:25 +0000 Subject: [PATCH 38/91] chore: swap in usePaginatedQuery for users table --- site/src/components/PaginationWidget/temp.ts | 112 ------------------- site/src/hooks/usePaginatedQuery.ts | 6 +- site/src/pages/UsersPage/UsersPage.tsx | 32 ++---- 3 files changed, 15 insertions(+), 135 deletions(-) delete mode 100644 site/src/components/PaginationWidget/temp.ts diff --git a/site/src/components/PaginationWidget/temp.ts b/site/src/components/PaginationWidget/temp.ts deleted file mode 100644 index 03025d8e2fe4b..0000000000000 --- a/site/src/components/PaginationWidget/temp.ts +++ /dev/null @@ -1,112 +0,0 @@ -export function usePaginatedQuery(options: any) { - const { - queryKey, - queryPayload, - onInvalidPage, - queryFn: outerQueryFn, - ...extraOptions - } = options; - - const [searchParams, setSearchParams] = useSearchParams(); - const currentPage = parsePage(searchParams); - const pageSize = DEFAULT_RECORDS_PER_PAGE; - const pageOffset = (currentPage - 1) * pageSize; - - const getQueryOptionsFromPage = (pageNumber: number) => { - const pageParams = { - pageNumber, - offset: pageOffset, - limit: pageSize, - searchParams: searchParams, - }; - - const payload = queryPayload?.(pageParams); - - return { - queryKey: queryKey({ ...pageParams, payload }), - queryFn: (context) => { - return outerQueryFn({ ...context, ...pageParams, payload }); - }, - } as const; - }; - - const query = useQuery({ - ...extraOptions, - ...getQueryOptionsFromPage(currentPage), - keepPreviousData: true, - }); - - const totalRecords = query.data?.count ?? 0; - const totalPages = Math.ceil(totalRecords / pageSize); - const hasNextPage = pageSize * pageOffset < totalRecords; - const hasPreviousPage = currentPage > 1; - - const queryClient = useQueryClient(); - const prefetchPage = useEffectEvent((newPage: number) => { - return queryClient.prefetchQuery(getQueryOptionsFromPage(newPage)); - }); - - 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 - const updatePageIfInvalid = useEffectEvent(() => { - const clamped = clamp(currentPage, 1, totalPages); - if (currentPage === clamped) { - return; - } - - if (onInvalidPage === undefined) { - searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); - setSearchParams(searchParams); - } else { - const params = { - offset: pageOffset, - limit: pageSize, - totalPages, - setSearchParams, - pageNumber: currentPage, - searchParams: searchParams, - }; - - onInvalidPage(params); - } - }); - - useEffect(() => { - if (!query.isFetching) { - updatePageIfInvalid(); - } - }, [updatePageIfInvalid, query.isFetching]); - - const onPageChange = (newPage: number) => { - const safePage = Number.isInteger(newPage) - ? clamp(newPage, 1, totalPages) - : 1; - - searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(safePage)); - setSearchParams(searchParams); - }; - - return { - ...query, - onPageChange, - goToPreviousPage: () => onPageChange(currentPage - 1), - goToNextPage: () => onPageChange(currentPage + 1), - currentPage, - pageSize, - totalRecords, - hasNextPage, - hasPreviousPage, - isLoading: query.isLoading || query.isFetching, - } as const; -} diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 46a1f2ffea4c1..304124f21e9b5 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -185,11 +185,13 @@ export function usePaginatedQuery< }, [updatePageIfInvalid, query.isFetching, totalPages]); const onPageChange = (newPage: number) => { - if (totalPages === undefined) { + // 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); + const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages ?? 1); if (!Number.isInteger(cleanedInput) || cleanedInput <= 0) { return; } diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 83f271cbe089b..48d3130915125 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -6,25 +6,24 @@ import { groupsByUserId } from "api/queries/groups"; import { getErrorMessage } from "api/errors"; import { deploymentConfig } from "api/queries/deployment"; import { - users, suspendUser, activateUser, deleteUser, updatePassword, updateRoles, authMethods, + paginatedUsers, } from "api/queries/users"; 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,20 +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, - }), - keepPreviousData: true, - }); - 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({ @@ -64,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.onPageChange(1), }); + const statusMenu = useStatusFilterMenu({ value: useFilterResult.values.status, onChange: (option) => @@ -165,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} /> Date: Tue, 21 Nov 2023 14:47:24 +0000 Subject: [PATCH 39/91] chore: add goToFirstPage to usePaginatedQuery --- site/src/hooks/usePaginatedQuery.ts | 35 +++++++++++++------------- site/src/pages/UsersPage/UsersPage.tsx | 4 +-- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 304124f21e9b5..d2d9803a0eb5b 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -200,27 +200,27 @@ export function usePaginatedQuery< setSearchParams(searchParams); }; - const goToPreviousPage = () => { - if (hasPreviousPage) { - onPageChange(currentPage - 1); - } - }; - - const goToNextPage = () => { - if (hasNextPage) { - onPageChange(currentPage + 1); - } - }; - - // Have to do a type assertion at the end 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 + // 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, - goToPreviousPage, - goToNextPage, + goToFirstPage: () => onPageChange(1), + + goToPreviousPage: () => { + if (hasPreviousPage) { + onPageChange(currentPage - 1); + } + }, + + goToNextPage: () => { + if (hasNextPage) { + onPageChange(currentPage + 1); + } + }, + ...(query.isSuccess ? { isSuccess: true, @@ -256,6 +256,7 @@ type PaginationResultInfo = { onPageChange: (newPage: number) => void; goToPreviousPage: () => void; goToNextPage: () => void; + goToFirstPage: () => void; } & ( | { isSuccess: false; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 48d3130915125..ef366b8787d5d 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -6,13 +6,13 @@ import { groupsByUserId } from "api/queries/groups"; import { getErrorMessage } from "api/errors"; import { deploymentConfig } from "api/queries/deployment"; import { + paginatedUsers, suspendUser, activateUser, deleteUser, updatePassword, updateRoles, authMethods, - paginatedUsers, } from "api/queries/users"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -58,7 +58,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const usersQuery = usePaginatedQuery(paginatedUsers()); const useFilterResult = useFilter({ searchParamsResult, - onUpdate: () => usersQuery.onPageChange(1), + onUpdate: usersQuery.goToFirstPage, }); const statusMenu = useStatusFilterMenu({ From 8dbbfd37e39ed414da4039d2b92bc98ef2f5a75b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 15:30:17 +0000 Subject: [PATCH 40/91] fix: make page redirects work properly --- site/src/hooks/usePaginatedQuery.ts | 41 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index d2d9803a0eb5b..257e8d2bfa8f3 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -101,9 +101,9 @@ export function usePaginatedQuery< const getQueryOptionsFromPage = (pageNumber: number) => { const pageParams: QueryPageParams = { pageNumber, - offset, limit, - searchParams, + offset: (pageNumber - 1) * limit, + searchParams: getParamsWithoutPage(searchParams), }; const payload = queryPayload?.(pageParams) as RuntimePayload; @@ -155,22 +155,34 @@ export function usePaginatedQuery< }, [prefetchPage, currentPage, hasPreviousPage]); // Mainly here to catch user if they navigate to a page directly via URL - const updatePageIfInvalid = useEffectEvent((totalPages: number) => { - const clamped = clamp(currentPage, 1, totalPages); + 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); + const firstPageResult = await queryClient.fetchQuery(firstPageOptions); + fixedTotalPages = Math.ceil(firstPageResult.count / limit); + } + + const clamped = clamp(currentPage, 1, fixedTotalPages || 1); if (currentPage === clamped) { return; } + const withoutPage = getParamsWithoutPage(searchParams); if (onInvalidPageChange === undefined) { - searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); - setSearchParams(searchParams); + withoutPage.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); + setSearchParams(withoutPage); } else { const params: InvalidPageParams = { offset, limit, - totalPages, - searchParams, setSearchParams, + searchParams: withoutPage, + totalPages: fixedTotalPages, pageNumber: currentPage, }; @@ -180,7 +192,7 @@ export function usePaginatedQuery< useEffect(() => { if (!query.isFetching && totalPages !== undefined) { - updatePageIfInvalid(totalPages); + void updatePageIfInvalid(totalPages); } }, [updatePageIfInvalid, query.isFetching, totalPages]); @@ -246,6 +258,17 @@ function parsePage(params: URLSearchParams): number { 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. From 88d0a5fd342c83116c4187349961f074ecb62781 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 15:40:19 +0000 Subject: [PATCH 41/91] refactor: clean up clamp logic --- site/src/hooks/usePaginatedQuery.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 257e8d2bfa8f3..a21a44f69213b 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -154,7 +154,8 @@ export function usePaginatedQuery< } }, [prefetchPage, currentPage, hasPreviousPage]); - // Mainly here to catch user if they navigate to a page directly via URL + // 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 @@ -167,7 +168,7 @@ export function usePaginatedQuery< fixedTotalPages = Math.ceil(firstPageResult.count / limit); } - const clamped = clamp(currentPage, 1, fixedTotalPages || 1); + const clamped = clamp(currentPage, 1, fixedTotalPages); if (currentPage === clamped) { return; } From 132f5b157447bd58e1fdb236c6c441b2f283b6ef Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 16:56:48 +0000 Subject: [PATCH 42/91] chore: swap in usePaginatedQuery for Audits table --- site/src/api/queries/audits.ts | 27 +++++++++++ site/src/components/Filter/filter.tsx | 2 +- site/src/hooks/usePaginatedQuery.ts | 13 ++++- site/src/pages/AuditPage/AuditPage.tsx | 67 ++++++++++++-------------- 4 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 site/src/api/queries/audits.ts diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts new file mode 100644 index 0000000000000..4590660996fc2 --- /dev/null +++ b/site/src/api/queries/audits.ts @@ -0,0 +1,27 @@ +import { getAuditLogs } from "api/api"; +import { type AuditLogResponse } from "api/typesGenerated"; +import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; + +export function paginatedAudits( + searchParams: URLSearchParams, + filterParamsKey: string, +) { + return { + searchParams, + queryPayload: ({ searchParams }) => { + return searchParams.get(filterParamsKey) ?? ""; + }, + queryKey: ({ payload, pageNumber }) => { + return ["auditLogs", payload, pageNumber] as const; + }, + queryFn: ({ payload, limit, offset }) => { + return getAuditLogs({ + offset, + limit, + q: payload, + }); + }, + + cacheTime: 5 * 1000 * 60, + } as const satisfies UsePaginatedQueryOptions; +} 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/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index a21a44f69213b..e953b84bb5cb0 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -33,6 +33,14 @@ export type UsePaginatedQueryOptions< TQueryKey extends QueryKey = QueryKey, > = BasePaginationOptions & QueryPayloadExtender & { + /** + * An optional dependency for React Router's URLSearchParams. + * + * It's annoying that this is necessary, but this helps avoid searchParams + * from other parts of a component from de-syncing + */ + searchParams?: URLSearchParams; + /** * A function that takes pagination information and produces a full query * key. @@ -89,11 +97,14 @@ export function usePaginatedQuery< queryKey, queryPayload, onInvalidPageChange, + searchParams: outerSearchParams, queryFn: outerQueryFn, ...extraOptions } = options; - const [searchParams, setSearchParams] = useSearchParams(); + const [innerSearchParams, setSearchParams] = useSearchParams(); + const searchParams = outerSearchParams ?? innerSearchParams; + const currentPage = parsePage(searchParams); const limit = DEFAULT_RECORDS_PER_PAGE; const offset = (currentPage - 1) * limit; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 174bf517a480c..649b32553f545 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"; @@ -9,21 +6,31 @@ import { useSearchParams } from "react-router-dom"; 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 { useFilter, useFilterParamsKey } from "components/Filter/filter"; 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, useFilterParamsKey), + ); + 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 +39,7 @@ const AuditPage: FC = () => { username: option?.value, }), }); + const actionMenu = useActionFilterMenu({ value: filter.values.action, onChange: (option) => @@ -40,6 +48,7 @@ const AuditPage: FC = () => { action: option?.value, }), }); + const resourceTypeMenu = useResourceTypeFilterMenu({ value: filter.values["resource_type"], onChange: (option) => @@ -48,37 +57,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 ( <> Codestin Search App + Date: Tue, 21 Nov 2023 17:00:29 +0000 Subject: [PATCH 43/91] refactor: move dependencies around --- site/src/api/queries/audits.ts | 8 +++----- site/src/pages/AuditPage/AuditPage.tsx | 7 ++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts index 4590660996fc2..00d0f45e67abb 100644 --- a/site/src/api/queries/audits.ts +++ b/site/src/api/queries/audits.ts @@ -1,15 +1,13 @@ 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, - filterParamsKey: string, -) { +export function paginatedAudits(searchParams: URLSearchParams) { return { searchParams, queryPayload: ({ searchParams }) => { - return searchParams.get(filterParamsKey) ?? ""; + return searchParams.get(useFilterParamsKey) ?? ""; }, queryKey: ({ payload, pageNumber }) => { return ["auditLogs", payload, pageNumber] as const; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 649b32553f545..0be62a7e69f2b 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { AuditPageView } from "./AuditPageView"; import { useUserFilterMenu } from "components/Filter/UserFilter"; -import { useFilter, useFilterParamsKey } from "components/Filter/filter"; +import { useFilter } from "components/Filter/filter"; import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; import { paginatedAudits } from "api/queries/audits"; @@ -22,10 +22,7 @@ const AuditPage: FC = () => { * and hooks can share the result of useSearchParams directly) */ const [searchParams, setSearchParams] = useSearchParams(); - const auditsQuery = usePaginatedQuery( - paginatedAudits(searchParams, useFilterParamsKey), - ); - + const auditsQuery = usePaginatedQuery(paginatedAudits(searchParams)); const filter = useFilter({ searchParamsResult: [searchParams, setSearchParams], onUpdate: auditsQuery.goToFirstPage, From 218da68c66d6277449a7a24f5f0f931dfe26bd6d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 18:05:14 +0000 Subject: [PATCH 44/91] fix: remove deprecated properties from hook --- site/src/hooks/usePaginatedQuery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index e953b84bb5cb0..3b257c3ab19c1 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -415,6 +415,7 @@ type PaginatedQueryFnContext< * 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, @@ -423,7 +424,7 @@ type BasePaginationOptions< TQueryKey extends QueryKey = QueryKey, > = Omit< UseQueryOptions, - "keepPreviousData" | "queryKey" | "queryFn" + "keepPreviousData" | "queryKey" | "queryFn" | "onSuccess" | "onError" >; /** From 54f01f1947dd87ef15dc2a534344d18bfc8eae8b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 18:07:45 +0000 Subject: [PATCH 45/91] refactor: clean up code more --- site/src/api/queries/audits.ts | 4 +--- site/src/hooks/usePaginatedQuery.ts | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts index 00d0f45e67abb..c0a758619f32b 100644 --- a/site/src/api/queries/audits.ts +++ b/site/src/api/queries/audits.ts @@ -6,9 +6,7 @@ import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; export function paginatedAudits(searchParams: URLSearchParams) { return { searchParams, - queryPayload: ({ searchParams }) => { - return searchParams.get(useFilterParamsKey) ?? ""; - }, + queryPayload: () => searchParams.get(useFilterParamsKey) ?? "", queryKey: ({ payload, pageNumber }) => { return ["auditLogs", payload, pageNumber] as const; }, diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 3b257c3ab19c1..880ab1d870d59 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -36,8 +36,9 @@ export type UsePaginatedQueryOptions< /** * An optional dependency for React Router's URLSearchParams. * - * It's annoying that this is necessary, but this helps avoid searchParams - * from other parts of a component from de-syncing + * It's annoying that this is necessary, but it helps avoid URL de-syncs if + * useSearchParams is called multiple times in the same component (likely in + * multiple custom hooks) */ searchParams?: URLSearchParams; From ede2abc93110330349e259783b04c6dab3761cfb Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 18:14:04 +0000 Subject: [PATCH 46/91] docs: add todo comment --- site/src/hooks/usePaginatedQuery.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 880ab1d870d59..362cf1aea7d39 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -39,6 +39,10 @@ export type UsePaginatedQueryOptions< * It's annoying that this is necessary, but it helps avoid URL de-syncs if * useSearchParams is called multiple times in the same component (likely in * multiple custom hooks) + * + * @todo Wrangle React Router's useSearchParams so that URL state can be + * shared between multiple components/hooks more directly without making you + * jump through so many hoops (it's affecting our filter logic, too) */ searchParams?: URLSearchParams; From 9ecab16573c70afce2c7ea6697bc70892083d43c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 22:08:46 +0000 Subject: [PATCH 47/91] chore: update testing fixtures --- site/src/hooks/usePaginatedQuery.ts | 2 +- site/src/testHelpers/renderHelpers.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 362cf1aea7d39..317c885ff2be1 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -395,7 +395,7 @@ type QueryPageParamsWithPayload = QueryPageParams & { * Any JSON-serializable object returned by the API that exposes the total * number of records that match a query */ -type PaginatedData = { +export type PaginatedData = { count: number; }; 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( { initialProps, path = "/", + route = "/", extraRoutes = [], }: RenderHookWithAuthOptions = {}, ) { @@ -144,10 +145,10 @@ export async function renderHookWithAuth( */ // 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] }, + ); }); /** From 0c81d1c965c15c48284ce40191df86638a7bc25d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 22:40:17 +0000 Subject: [PATCH 48/91] wip: commit current progress for tests --- site/src/hooks/usePaginatedQuery.test.ts | 197 +++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 site/src/hooks/usePaginatedQuery.test.ts diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts new file mode 100644 index 0000000000000..14c7b27da9253 --- /dev/null +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -0,0 +1,197 @@ +import { renderHookWithAuth } from "testHelpers/renderHelpers"; +import { + type PaginatedData, + type UsePaginatedQueryOptions, + usePaginatedQuery, +} from "./usePaginatedQuery"; +import { waitFor } from "@testing-library/react"; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +function render< + TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, +>( + queryOptions: UsePaginatedQueryOptions, + route?: `/${string}`, +) { + type Props = { options: typeof queryOptions }; + + return renderHookWithAuth( + ({ options }: Props) => usePaginatedQuery(options), + { + route, + path: "/", + initialProps: { + options: queryOptions, + }, + }, + ); +} + +describe(usePaginatedQuery.name, () => { + describe("queryPayload method", () => { + const mockQueryFn = jest.fn(() => { + return { 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 noPrefetchTimeout = 1000; + const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => { + return Promise.resolve({ + data: new Array(limit).fill(pageNumber), + count: 50, + }); + }); + + it("Prefetches the previous page if it exists", async () => { + const startingPage = 2; + await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startingPage}`, + ); + + const pageMatcher = expect.objectContaining({ + pageNumber: 1, + }); + + await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + }); + + it("Prefetches the next page if it exists", async () => { + const startingPage = 1; + await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startingPage}`, + ); + + const pageMatcher = expect.objectContaining({ + pageNumber: 2, + }); + + await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + }); + + it("Avoids prefetch for previous page if it doesn't exist", async () => { + const startingPage = 1; + await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startingPage}`, + ); + + const pageMatcher = expect.objectContaining({ + pageNumber: 0, + }); + + // 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); + }, noPrefetchTimeout); + + jest.runAllTimers(); + }); + + it("Avoids prefetch for next page if it doesn't exist", async () => { + const startingPage = 2; + await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startingPage}`, + ); + + const pageMatcher = expect.objectContaining({ + pageNumber: 3, + }); + + setTimeout(() => { + expect(mockQueryFn).not.toBeCalledWith(pageMatcher); + }, noPrefetchTimeout); + + jest.runAllTimers(); + }); + + it("Reuses the same queryKey and queryFn methods for the current page and all prefetching", async () => { + // + }); + }); + + describe("Invalid page safety nets/redirects", () => {}); + + describe("Returned properties", () => {}); + + describe("Passing outside value for URLSearchParams", () => {}); +}); From 3aa714c91c40b2dbdcd0080f1133788e6cbd6030 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 22:56:52 +0000 Subject: [PATCH 49/91] fix: update useEffectEvent to sync via layout effects --- site/src/hooks/hookPolyfills.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/hooks/hookPolyfills.ts b/site/src/hooks/hookPolyfills.ts index e5ba705296229..40ca8629c9d27 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,10 @@ export function useEffectEvent( callback: (...args: TArgs) => TReturn, ) { const callbackRef = useRef(callback); - useEffect(() => { + + // useLayoutEffect should be overkill here 99% of the time, but it ensures it + // will run before any other layout effects that need this custom hook + useLayoutEffect(() => { callbackRef.current = callback; }, [callback]); From 289363050d99aec15d59a6974a764558bec9bafe Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 22:58:16 +0000 Subject: [PATCH 50/91] wip: commit more progress on tests --- site/src/hooks/usePaginatedQuery.test.ts | 101 +++++++++++------------ 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 14c7b27da9253..1652223552b0d 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -108,7 +108,6 @@ describe(usePaginatedQuery.name, () => { }); describe("Prefetching", () => { - const noPrefetchTimeout = 1000; const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); const mockQueryFn = jest.fn(({ pageNumber, limit }) => { return Promise.resolve({ @@ -117,81 +116,79 @@ describe(usePaginatedQuery.name, () => { }); }); - it("Prefetches the previous page if it exists", async () => { - const startingPage = 2; + const testPrefetch = async ( + startingPage: number, + targetPage: number, + shouldMatch: boolean, + ) => { await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, `/?page=${startingPage}`, ); - const pageMatcher = expect.objectContaining({ - pageNumber: 1, - }); + const pageMatcher = expect.objectContaining({ pageNumber: targetPage }); + if (shouldMatch) { + 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(); + } + }; - await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + it("Prefetches the previous page if it exists", async () => { + await testPrefetch(2, 1, true); }); it("Prefetches the next page if it exists", async () => { - const startingPage = 1; - await render( - { queryKey: mockQueryKey, queryFn: mockQueryFn }, - `/?page=${startingPage}`, - ); - - const pageMatcher = expect.objectContaining({ - pageNumber: 2, - }); - - await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + await testPrefetch(1, 2, true); }); it("Avoids prefetch for previous page if it doesn't exist", async () => { - const startingPage = 1; - await render( - { queryKey: mockQueryKey, queryFn: mockQueryFn }, - `/?page=${startingPage}`, - ); + await testPrefetch(1, 0, false); + }); - const pageMatcher = expect.objectContaining({ - pageNumber: 0, - }); + it("Avoids prefetch for next page if it doesn't exist", async () => { + await testPrefetch(2, 3, false); + }); - // 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); - }, noPrefetchTimeout); + it("Reuses the same queryKey and queryFn methods for the current page and all prefetching", async () => { + expect.hasAssertions(); + }); + }); - jest.runAllTimers(); + describe("Invalid page safety nets/redirects", () => { + it("Auto-redirects user to page 1 if params are corrupt/invalid", async () => { + expect.hasAssertions(); }); - it("Avoids prefetch for next page if it doesn't exist", async () => { - const startingPage = 2; - await render( - { queryKey: mockQueryKey, queryFn: mockQueryFn }, - `/?page=${startingPage}`, - ); + it("Auto-redirects user to closest page if request page overshoots", async () => { + expect.hasAssertions(); + }); - const pageMatcher = expect.objectContaining({ - pageNumber: 3, - }); + it("Auto-redirects user to first page if request page goes below 1", async () => { + expect.hasAssertions(); + }); - setTimeout(() => { - expect(mockQueryFn).not.toBeCalledWith(pageMatcher); - }, noPrefetchTimeout); + it("Calls the custom onInvalidPageChange callback if provided", async () => { + expect.hasAssertions(); + }); + }); - jest.runAllTimers(); + describe("Passing outside value for URLSearchParams", () => { + it("Reads from searchParams property if provided", async () => { + expect.hasAssertions(); }); - it("Reuses the same queryKey and queryFn methods for the current page and all prefetching", async () => { - // + it("Flushes state changes via provided searchParams property", async () => { + expect.hasAssertions(); }); }); - describe("Invalid page safety nets/redirects", () => {}); - describe("Returned properties", () => {}); - - describe("Passing outside value for URLSearchParams", () => {}); }); From ef900d4fa03cb8047934bc01880bbbd734995933 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 21 Nov 2023 23:19:01 +0000 Subject: [PATCH 51/91] wip: stub out all expected test cases --- site/src/hooks/usePaginatedQuery.test.ts | 51 ++++++++++++++++++++---- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 1652223552b0d..4582faf38a607 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -1,10 +1,11 @@ import { renderHookWithAuth } from "testHelpers/renderHelpers"; +import { waitFor } from "@testing-library/react"; + import { type PaginatedData, type UsePaginatedQueryOptions, usePaginatedQuery, } from "./usePaginatedQuery"; -import { waitFor } from "@testing-library/react"; beforeAll(() => { jest.useFakeTimers(); @@ -36,11 +37,13 @@ function render< ); } -describe(usePaginatedQuery.name, () => { +/** + * There are a lot of test cases in this file. Scoping mocking to inner describe + * function calls to limit cognitive load of maintaining this file. + */ +describe(`${usePaginatedQuery.name} - Overall functionality`, () => { describe("queryPayload method", () => { - const mockQueryFn = jest.fn(() => { - return { count: 0 }; - }); + 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"]); @@ -167,11 +170,11 @@ describe(usePaginatedQuery.name, () => { expect.hasAssertions(); }); - it("Auto-redirects user to closest page if request page overshoots", async () => { + it("Auto-redirects user to closest page if requested page overshoots", async () => { expect.hasAssertions(); }); - it("Auto-redirects user to first page if request page goes below 1", async () => { + it("Auto-redirects user to first page if requested page goes below 1", async () => { expect.hasAssertions(); }); @@ -189,6 +192,38 @@ describe(usePaginatedQuery.name, () => { expect.hasAssertions(); }); }); +}); + +describe(`${usePaginatedQuery.name} - Returned properties`, () => { + describe("Conditional render output", () => { + it("Always has select properties be defined regardless of fetch status", async () => { + expect.hasAssertions(); + }); + + it("Flips other properties to be defined after on-mount fetch succeeds", async () => { + expect.hasAssertions(); + }); + }); - describe("Returned properties", () => {}); + describe("Page change methods", () => { + test("goToFirstPage always succeeds regardless of fetch status", async () => { + expect.hasAssertions(); + }); + + test("goToNextPage works only if hasNextPage is true", async () => { + expect.hasAssertions(); + }); + + test("goToPreviousPage works only if hasPreviousPage is true", async () => { + expect.hasAssertions(); + }); + + test("onPageChange cleans 'corrupt' numeric values before navigating", async () => { + expect.hasAssertions(); + }); + + test("onPageChange rejects impossible numeric values and does nothing", async () => { + expect.hasAssertions(); + }); + }); }); From 23dc5830015a6b65ef9747558076d156087b0414 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 00:04:15 +0000 Subject: [PATCH 52/91] wip: more test progress --- site/src/hooks/usePaginatedQuery.test.ts | 93 ++++++++++++++++++------ 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 4582faf38a607..5555d3e9a8598 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -21,7 +21,7 @@ function render< TQueryPayload = never, >( queryOptions: UsePaginatedQueryOptions, - route?: `/${string}`, + route?: `/?page=${string}`, ) { type Props = { options: typeof queryOptions }; @@ -115,7 +115,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { const mockQueryFn = jest.fn(({ pageNumber, limit }) => { return Promise.resolve({ data: new Array(limit).fill(pageNumber), - count: 50, + count: 75, }); }); @@ -148,47 +148,94 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await testPrefetch(2, 1, true); }); - it("Prefetches the next page if it exists", async () => { - await testPrefetch(1, 2, true); + it.skip("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 () => { + it.skip("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(2, 3, false); + await testPrefetch(3, 4, false); }); - it("Reuses the same queryKey and queryFn methods for the current page and all prefetching", async () => { - expect.hasAssertions(); + it.skip("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { + const startPage = 2; + 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, + }); + const nextPageMatcher = expect.objectContaining({ + pageNumber: startPage + 1, + }); + + await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); + await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); + await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); + await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); }); }); describe("Invalid page safety nets/redirects", () => { - it("Auto-redirects user to page 1 if params are corrupt/invalid", async () => { - expect.hasAssertions(); + const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => + Promise.resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, + }), + ); + + it("Immediately/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("Auto-redirects user to closest page if requested page overshoots", async () => { - expect.hasAssertions(); + it("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("Auto-redirects user to first page if requested page goes below 1", async () => { - expect.hasAssertions(); + const { result } = await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + "/?page=-9999", + ); + + await waitFor(() => expect(result.current.currentPage).toBe(1)); }); - it("Calls the custom onInvalidPageChange callback if provided", async () => { + it.skip("Calls the custom onInvalidPageChange callback if provided", async () => { expect.hasAssertions(); }); }); describe("Passing outside value for URLSearchParams", () => { - it("Reads from searchParams property if provided", async () => { + it.skip("Reads from searchParams property if provided", async () => { expect.hasAssertions(); }); - it("Flushes state changes via provided searchParams property", async () => { + it.skip("Flushes state changes via provided searchParams property", async () => { expect.hasAssertions(); }); }); @@ -196,33 +243,33 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { describe(`${usePaginatedQuery.name} - Returned properties`, () => { describe("Conditional render output", () => { - it("Always has select properties be defined regardless of fetch status", async () => { + it.skip("Always has select properties be defined regardless of fetch status", async () => { expect.hasAssertions(); }); - it("Flips other properties to be defined after on-mount fetch succeeds", async () => { + it.skip("Flips other properties to be defined after on-mount fetch succeeds", async () => { expect.hasAssertions(); }); }); describe("Page change methods", () => { - test("goToFirstPage always succeeds regardless of fetch status", async () => { + test.skip("goToFirstPage always succeeds regardless of fetch status", async () => { expect.hasAssertions(); }); - test("goToNextPage works only if hasNextPage is true", async () => { + test.skip("goToNextPage works only if hasNextPage is true", async () => { expect.hasAssertions(); }); - test("goToPreviousPage works only if hasPreviousPage is true", async () => { + test.skip("goToPreviousPage works only if hasPreviousPage is true", async () => { expect.hasAssertions(); }); - test("onPageChange cleans 'corrupt' numeric values before navigating", async () => { + test.skip("onPageChange cleans 'corrupt' numeric values before navigating", async () => { expect.hasAssertions(); }); - test("onPageChange rejects impossible numeric values and does nothing", async () => { + test.skip("onPageChange rejects impossible numeric values and does nothing", async () => { expect.hasAssertions(); }); }); From 24361d14ebfe3ec6b0a2c37fb58bb8acc25f86aa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 00:14:38 +0000 Subject: [PATCH 53/91] wip: more test progress --- site/src/hooks/usePaginatedQuery.test.ts | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 5555d3e9a8598..ba85a3e666e3d 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -110,7 +110,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }); }); - describe("Prefetching", () => { + describe.skip("Prefetching", () => { const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); const mockQueryFn = jest.fn(({ pageNumber, limit }) => { return Promise.resolve({ @@ -148,11 +148,11 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await testPrefetch(2, 1, true); }); - it.skip("Prefetches the next page if it exists", async () => { + it("Prefetches the next page if it exists", async () => { await testPrefetch(2, 3, true); }); - it.skip("Avoids prefetch for previous page if it doesn't exist", async () => { + it("Avoids prefetch for previous page if it doesn't exist", async () => { await testPrefetch(1, 0, false); await testPrefetch(6, 5, false); }); @@ -161,7 +161,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await testPrefetch(3, 4, false); }); - it.skip("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { + it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { const startPage = 2; await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, @@ -186,7 +186,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }); }); - describe("Invalid page safety nets/redirects", () => { + describe("Safety nets/redirects for invalid pages", () => { const mockQueryKey = jest.fn(() => ["mock"]); const mockQueryFn = jest.fn(({ pageNumber, limit }) => Promise.resolve({ @@ -225,8 +225,29 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(1)); }); - it.skip("Calls the custom onInvalidPageChange callback if provided", async () => { - expect.hasAssertions(); + it("Calls the custom onInvalidPageChange callback if provided", async () => { + const onInvalidPageChange = jest.fn(); + await render( + { + onInvalidPageChange, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }, + "/?page=900", + ); + + 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), + }), + ); + }); }); }); From 755f8c0c7295b81840d355c8383358c69cad4e30 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 00:26:58 +0000 Subject: [PATCH 54/91] wip: commit more test progress --- site/src/hooks/usePaginatedQuery.test.ts | 59 ++++++++++++++++++------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index ba85a3e666e3d..b8f51183549e4 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -225,16 +225,18 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(1)); }); - it("Calls the custom onInvalidPageChange callback if provided", async () => { + it("Calls the custom onInvalidPageChange callback if provided (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, - }, - "/?page=900", - ); + await render({ + onInvalidPageChange, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + searchParams: testControl, + }); await waitFor(() => { expect(onInvalidPageChange).toBeCalledWith( @@ -248,16 +250,47 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }), ); }); + + expect(testControl.get("page")).toBe("1000"); }); }); describe("Passing outside value for URLSearchParams", () => { - it.skip("Reads from searchParams property if provided", async () => { - expect.hasAssertions(); + 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.skip("Flushes state changes via provided searchParams property", async () => { - expect.hasAssertions(); + it("Flushes state changes via provided searchParams property", 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"); }); }); }); From 8bff0d38c177fea34546d1484e53133c6dd53eba Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 00:32:40 +0000 Subject: [PATCH 55/91] wip: AHHHHHHHH --- site/src/hooks/usePaginatedQuery.test.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index b8f51183549e4..ac98363b8ba7a 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -195,7 +195,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }), ); - it("Immediately/synchronously defaults to page 1 if params are corrupt/invalid", async () => { + it("Synchronously defaults to page 1 if params are corrupt/invalid (no custom callback)", async () => { const { result } = await render( { queryKey: mockQueryKey, @@ -207,7 +207,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { expect(result.current.currentPage).toBe(1); }); - it("Auto-redirects user to last page if requested page overshoots total pages", async () => { + it("Auto-redirects user to last page if requested page overshoots total pages (no custom callback)", async () => { const { result } = await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, "/?page=35", @@ -216,7 +216,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(4)); }); - it("Auto-redirects user to first page if requested page goes below 1", async () => { + it("Auto-redirects user to first page if requested page goes below 1 (no custom callback)", async () => { const { result } = await render( { queryKey: mockQueryKey, queryFn: mockQueryFn }, "/?page=-9999", @@ -296,16 +296,6 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }); describe(`${usePaginatedQuery.name} - Returned properties`, () => { - describe("Conditional render output", () => { - it.skip("Always has select properties be defined regardless of fetch status", async () => { - expect.hasAssertions(); - }); - - it.skip("Flips other properties to be defined after on-mount fetch succeeds", async () => { - expect.hasAssertions(); - }); - }); - describe("Page change methods", () => { test.skip("goToFirstPage always succeeds regardless of fetch status", async () => { expect.hasAssertions(); From 1fae773e1725028f86576a25b41e10bf7e5b1a61 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 14:52:33 +0000 Subject: [PATCH 56/91] chore: finish two more test cases --- site/src/hooks/usePaginatedQuery.test.ts | 75 +++++++++++++++++------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index ac98363b8ba7a..9ac22d6839463 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -20,26 +20,19 @@ function render< TQueryFnData extends PaginatedData = PaginatedData, TQueryPayload = never, >( - queryOptions: UsePaginatedQueryOptions, + options: UsePaginatedQueryOptions, route?: `/?page=${string}`, ) { - type Props = { options: typeof queryOptions }; - - return renderHookWithAuth( - ({ options }: Props) => usePaginatedQuery(options), - { - route, - path: "/", - initialProps: { - options: queryOptions, - }, - }, - ); + 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 cognitive load of maintaining this file. + * function calls to limit the cognitive load of maintaining all this stuff */ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { describe("queryPayload method", () => { @@ -225,7 +218,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(1)); }); - it("Calls the custom onInvalidPageChange callback if provided (and does not update search params automatically)", async () => { + it("Calls the custom onInvalidPageChange callback if provided (instead of updating search params automatically)", async () => { const testControl = new URLSearchParams({ page: "1000", }); @@ -255,7 +248,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }); }); - describe("Passing outside value for URLSearchParams", () => { + describe("Passing in searchParams property", () => { const mockQueryKey = jest.fn(() => ["mock"]); const mockQueryFn = jest.fn(({ pageNumber, limit }) => Promise.resolve({ @@ -278,7 +271,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { expect(result.current.currentPage).toBe(2); }); - it("Flushes state changes via provided searchParams property", async () => { + it("Flushes state changes via provided searchParams property instead of internal searchParams", async () => { const searchParams = new URLSearchParams({ page: "2", }); @@ -297,12 +290,52 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { describe(`${usePaginatedQuery.name} - Returned properties`, () => { describe("Page change methods", () => { - test.skip("goToFirstPage always succeeds regardless of fetch status", async () => { - expect.hasAssertions(); + type Data = PaginatedData & { + data: readonly number[]; + }; + + const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, + }); + }, 10_000); + }); }); - test.skip("goToNextPage works only if hasNextPage is true", async () => { - expect.hasAssertions(); + 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, + }); + + 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.skip("goToPreviousPage works only if hasPreviousPage is true", async () => { From be8272894cd99f5cabfcaed78c53e2751f1d5f4f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 15:08:20 +0000 Subject: [PATCH 57/91] wip: add in all tests (still need to investigate prefetching --- site/src/hooks/usePaginatedQuery.test.ts | 70 +++++++++++++++++++----- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 9ac22d6839463..37655c90edd56 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -290,12 +290,11 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { describe(`${usePaginatedQuery.name} - Returned properties`, () => { describe("Page change methods", () => { - type Data = PaginatedData & { - data: readonly number[]; - }; - const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => { + type Data = PaginatedData & { data: readonly number[] }; + return new Promise((resolve) => { setTimeout(() => { resolve({ @@ -323,10 +322,13 @@ describe(`${usePaginatedQuery.name} - Returned properties`, () => { }); test("goToNextPage works only if hasNextPage is true", async () => { - const { result } = await render({ - queryKey: mockQueryKey, - queryFn: mockQueryFn, - }); + const { result } = await render( + { + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }, + "/?page=1", + ); expect(result.current.hasNextPage).toBe(false); result.current.goToNextPage(); @@ -338,16 +340,56 @@ describe(`${usePaginatedQuery.name} - Returned properties`, () => { await waitFor(() => expect(result.current.currentPage).toBe(2)); }); - test.skip("goToPreviousPage works only if hasPreviousPage is true", async () => { - expect.hasAssertions(); + 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.skip("onPageChange cleans 'corrupt' numeric values before navigating", async () => { - expect.hasAssertions(); + test("onPageChange cleans 'corrupt' 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.skip("onPageChange rejects impossible numeric values and does nothing", async () => { - expect.hasAssertions(); + 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(); }); }); }); From 848fa0fc9f1715bd83580272c40b04c50cfbeba9 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 16:29:26 +0000 Subject: [PATCH 58/91] refactor: clean up code slightly --- site/src/hooks/usePaginatedQuery.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 317c885ff2be1..43c92952cb1f6 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -151,13 +151,14 @@ export function usePaginatedQuery< const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { - return queryClient.prefetchQuery(getQueryOptionsFromPage(newPage)); + 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 be immediately ready - // on the initial render + // 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); @@ -181,7 +182,7 @@ export function usePaginatedQuery< } else { const firstPageOptions = getQueryOptionsFromPage(1); const firstPageResult = await queryClient.fetchQuery(firstPageOptions); - fixedTotalPages = Math.ceil(firstPageResult.count / limit); + fixedTotalPages = Math.ceil(firstPageResult.count / limit) || 1; } const clamped = clamp(currentPage, 1, fixedTotalPages); From c89e8e33f402ca4d5c5cdb154e0dbb9d7af8612d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 17:25:12 +0000 Subject: [PATCH 59/91] fix: remove math bugs when calculating pages --- site/src/hooks/usePaginatedQuery.test.ts | 67 +++++++++++++----------- site/src/hooks/usePaginatedQuery.ts | 13 +++-- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 37655c90edd56..9f7a2e3a32aed 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -103,27 +103,37 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }); }); - describe.skip("Prefetching", () => { + describe("Prefetching", () => { const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); - const mockQueryFn = jest.fn(({ pageNumber, limit }) => { - return Promise.resolve({ - data: new Array(limit).fill(pageNumber), - count: 75, - }); - }); + + 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, ) => { - await render( + // 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 @@ -154,28 +164,25 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { 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; - 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, - }); - const nextPageMatcher = expect.objectContaining({ - pageNumber: startPage + 1, - }); - - await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); - await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); - await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); - await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); + it.skip("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { + // const startPage = 2; + // 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, + // }); + // const nextPageMatcher = expect.objectContaining({ + // pageNumber: startPage + 1, + // }); + // await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); + // await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); + // await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); + // await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); }); }); diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 43c92952cb1f6..8045c4cb62077 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -110,9 +110,9 @@ export function usePaginatedQuery< const [innerSearchParams, setSearchParams] = useSearchParams(); const searchParams = outerSearchParams ?? innerSearchParams; - const currentPage = parsePage(searchParams); const limit = DEFAULT_RECORDS_PER_PAGE; - const offset = (currentPage - 1) * limit; + const currentPage = parsePage(searchParams); + const currentPageOffset = (currentPage - 1) * limit; const getQueryOptionsFromPage = (pageNumber: number) => { const pageParams: QueryPageParams = { @@ -145,9 +145,12 @@ export function usePaginatedQuery< const totalPages = totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined; - const hasPreviousPage = totalPages !== undefined && currentPage > 1; const hasNextPage = - totalRecords !== undefined && limit * offset < totalRecords; + totalRecords !== undefined && limit + currentPageOffset < totalRecords; + const hasPreviousPage = + totalRecords !== undefined && + currentPage > 1 && + currentPageOffset - limit < totalRecords; const queryClient = useQueryClient(); const prefetchPage = useEffectEvent((newPage: number) => { @@ -196,7 +199,7 @@ export function usePaginatedQuery< setSearchParams(withoutPage); } else { const params: InvalidPageParams = { - offset, + offset: currentPageOffset, limit, setSearchParams, searchParams: withoutPage, From 3624a6d620dca8dc8fc539f81681203230196463 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 17:34:35 +0000 Subject: [PATCH 60/91] fix: wrap up all testing and clean up cases --- site/src/hooks/usePaginatedQuery.test.ts | 53 +++++++++++++----------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts index 9f7a2e3a32aed..0ccf5574a13b4 100644 --- a/site/src/hooks/usePaginatedQuery.test.ts +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -164,25 +164,30 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await testPrefetch(3, 4, false); }); - it.skip("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { - // const startPage = 2; - // 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, - // }); - // const nextPageMatcher = expect.objectContaining({ - // pageNumber: startPage + 1, - // }); - // await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); - // await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); - // await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); - // await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); + 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)); }); }); @@ -195,7 +200,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { }), ); - it("Synchronously defaults to page 1 if params are corrupt/invalid (no custom callback)", async () => { + it("No custom callback: synchronously defaults to page 1 if params are corrupt/invalid", async () => { const { result } = await render( { queryKey: mockQueryKey, @@ -207,7 +212,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { expect(result.current.currentPage).toBe(1); }); - it("Auto-redirects user to last page if requested page overshoots total pages (no custom callback)", async () => { + 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", @@ -216,7 +221,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(4)); }); - it("Auto-redirects user to first page if requested page goes below 1 (no custom callback)", async () => { + 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", @@ -225,7 +230,7 @@ describe(`${usePaginatedQuery.name} - Overall functionality`, () => { await waitFor(() => expect(result.current.currentPage).toBe(1)); }); - it("Calls the custom onInvalidPageChange callback if provided (instead of updating search params automatically)", async () => { + it("With custom callback: Calls callback and does not update search params automatically", async () => { const testControl = new URLSearchParams({ page: "1000", }); @@ -366,7 +371,7 @@ describe(`${usePaginatedQuery.name} - Returned properties`, () => { await waitFor(() => expect(result.current.currentPage).toBe(2)); }); - test("onPageChange cleans 'corrupt' numeric values before navigating", async () => { + test("onPageChange accounts for floats and truncates numeric values before navigating", async () => { const { result } = await render({ queryKey: mockQueryKey, queryFn: mockQueryFn, From 13e2f30d8ff1c32c7fa720b68e7d2df832435f7f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 22 Nov 2023 17:53:17 +0000 Subject: [PATCH 61/91] docs: update comments for clarity --- site/src/hooks/usePaginatedQuery.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 8045c4cb62077..73e4562aab66e 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -34,15 +34,9 @@ export type UsePaginatedQueryOptions< > = BasePaginationOptions & QueryPayloadExtender & { /** - * An optional dependency for React Router's URLSearchParams. - * - * It's annoying that this is necessary, but it helps avoid URL de-syncs if - * useSearchParams is called multiple times in the same component (likely in - * multiple custom hooks) - * - * @todo Wrangle React Router's useSearchParams so that URL state can be - * shared between multiple components/hooks more directly without making you - * jump through so many hoops (it's affecting our filter logic, too) + * 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; @@ -199,9 +193,9 @@ export function usePaginatedQuery< setSearchParams(withoutPage); } else { const params: InvalidPageParams = { - offset: currentPageOffset, limit, setSearchParams, + offset: currentPageOffset, searchParams: withoutPage, totalPages: fixedTotalPages, pageNumber: currentPage, From 8f83673faef1773ca39065873433c301ce6c1d12 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 Nov 2023 16:08:07 +0000 Subject: [PATCH 62/91] fix: update error-handling for invalid page handling --- site/src/hooks/usePaginatedQuery.ts | 8 ++++++-- site/src/pages/AuditPage/AuditPage.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index 73e4562aab66e..aa16e1c92c448 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -178,8 +178,12 @@ export function usePaginatedQuery< fixedTotalPages = totalPages; } else { const firstPageOptions = getQueryOptionsFromPage(1); - const firstPageResult = await queryClient.fetchQuery(firstPageOptions); - fixedTotalPages = Math.ceil(firstPageResult.count / limit) || 1; + try { + const firstPageResult = await queryClient.fetchQuery(firstPageOptions); + fixedTotalPages = Math.ceil(firstPageResult?.count ?? 0 / limit) || 1; + } catch (err) { + fixedTotalPages = 1; + } } const clamped = clamp(currentPage, 1, fixedTotalPages); diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 0be62a7e69f2b..63674b071629c 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -63,7 +63,7 @@ const AuditPage: FC = () => { Date: Fri, 24 Nov 2023 22:54:39 +0000 Subject: [PATCH 63/91] wip: commit progress for auto-scroll container --- .../PaginationWidget/Pagination.tsx | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 site/src/components/PaginationWidget/Pagination.tsx diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx new file mode 100644 index 0000000000000..66185698c6781 --- /dev/null +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -0,0 +1,121 @@ +import { + type FC, + type PropsWithChildren, + useEffect, + useLayoutEffect, + useRef, +} from "react"; + +import { PaginationWidgetBase } from "./PaginationWidgetBase"; + +type PaginationProps = PropsWithChildren<{ + currentPage: number; + pageSize: number; + totalRecords: number | undefined; + onPageChange: (newPage: number) => void; + autoScroll?: boolean; + + /** + * Meant to interface with useQuery's isPreviousData property + * + * Indicates whether data for a previous query is being shown while a new + * query is loading in + */ + showingPreviousData?: boolean; +}>; + +const userInteractionEvents: (keyof WindowEventMap)[] = [ + "click", + "scroll", + "pointerenter", + "touchstart", + "keydown", +]; + +export const Pagination: FC = ({ + children, + currentPage, + pageSize, + totalRecords, + onPageChange, + autoScroll = true, + showingPreviousData = false, +}) => { + const scrollContainerRef = useRef(null); + const scrollAfterDataLoadsRef = useRef(false); + + // Manages event handlers for canceling scrolling if the user interacts with + // the page in any way while new data is loading in. Don't want to scroll and + // hijack their browser if they're in the middle of something else! + useEffect(() => { + const cancelScroll = () => { + scrollAfterDataLoadsRef.current = false; + }; + + for (const event of userInteractionEvents) { + window.addEventListener(event, cancelScroll); + } + + return () => { + for (const event of userInteractionEvents) { + window.removeEventListener(event, cancelScroll); + } + }; + }, []); + + // Syncs scroll tracking to page changes. Wanted to handle these changes via a + // click event handler, but that got overly complicated between making sure + // that events didn't bubble all the way to the window (where they would + // immediately be canceled by window), and needing to update all downstream + // click handlers to be aware of event objects. Must be layout effect in order + // to fire before layout effect defined below + const mountedRef = useRef(false); + useLayoutEffect(() => { + // Never want to turn scrolling on for initial mount. Tried avoiding ref and + // checking things like viewport, but they all seemed unreliable (especially + // if the user can interact with the page while JS is still loading in) + if (mountedRef.current) { + mountedRef.current = true; + return; + } + + scrollAfterDataLoadsRef.current = true; + }, [currentPage]); + + // Jumps the user to the top of the paginated container each time new data + // loads in. Has no dependency array, because you can't sync based off of + // showingPreviousData. If its value is always false (via default params), + // an effect synced with it will never fire beyond the on-mount call + useLayoutEffect(() => { + const shouldScroll = + autoScroll && !showingPreviousData && scrollAfterDataLoadsRef.current; + + if (shouldScroll) { + scrollContainerRef.current?.scrollIntoView({ + behavior: "instant", + }); + } + }); + + return ( +
+
+ {children} + {totalRecords !== undefined && ( + + )} +
+
+ ); +}; From e9a99d42e3a95d7e677c207255def99f927924bd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 Nov 2023 23:04:55 +0000 Subject: [PATCH 64/91] chore: integrate pagination into users table --- .../PaginationWidget/Pagination.tsx | 1 + .../PaginationWidget/PaginationWidgetBase.tsx | 2 +- site/src/pages/UsersPage/UsersPage.tsx | 1 + site/src/pages/UsersPage/UsersPageView.tsx | 59 ++++++++++--------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 66185698c6781..63a59c36cfa08 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -92,6 +92,7 @@ export const Pagination: FC = ({ if (shouldScroll) { scrollContainerRef.current?.scrollIntoView({ + block: "start", behavior: "instant", }); } diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx index 9eb99e097216d..11b963f32798a 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -38,7 +38,7 @@ export const PaginationWidgetBase = ({ alignItems: "center", display: "flex", flexDirection: "row", - padding: "20px", + padding: "0 20px", columnGap: "6px", }} > diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index ef366b8787d5d..a9f366ae00cb6 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -159,6 +159,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { page={usersQuery.currentPage} limit={usersQuery.limit} onPageChange={usersQuery.onPageChange} + showingPreviousData={usersQuery.isPreviousData} /> void; + showingPreviousData: boolean; } export const UsersPageView: FC> = ({ @@ -65,6 +66,7 @@ export const UsersPageView: FC> = ({ onPageChange, page, groupsByUserId, + showingPreviousData, }) => { return ( <> @@ -79,35 +81,34 @@ export const UsersPageView: FC> = ({ /> - - - {count !== undefined && ( - + - )} + ); }; From 5bf5b9cbfa308cf4937765366a797105b115a16a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 Nov 2023 23:56:43 +0000 Subject: [PATCH 65/91] wip: commit current progress on scroll logic --- .../PaginationWidget/Pagination.tsx | 63 +++++++++---------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 63a59c36cfa08..43ebe4c4c6efb 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -4,9 +4,12 @@ import { useEffect, useLayoutEffect, useRef, + useMemo, } from "react"; import { PaginationWidgetBase } from "./PaginationWidgetBase"; +import { throttle } from "lodash"; +import { useEffectEvent } from "hooks/hookPolyfills"; type PaginationProps = PropsWithChildren<{ currentPage: number; @@ -42,61 +45,53 @@ export const Pagination: FC = ({ showingPreviousData = false, }) => { const scrollContainerRef = useRef(null); - const scrollAfterDataLoadsRef = useRef(false); + const scrollCanceledRef = useRef(false); - // Manages event handlers for canceling scrolling if the user interacts with - // the page in any way while new data is loading in. Don't want to scroll and - // hijack their browser if they're in the middle of something else! - useEffect(() => { - const cancelScroll = () => { - scrollAfterDataLoadsRef.current = false; - }; + /** + * @todo Probably better just to make a useThrottledFunction custom hook, + * rather than the weird useEffectEvent+useMemo approach. Cannot use throttle + * inside the render path directly; it will create a new stateful function + * every single render, and there won't be a single throttle state + */ + const cancelScroll = useEffectEvent(() => { + if (showingPreviousData) { + scrollCanceledRef.current = true; + } + }); + + const throttledCancelScroll = useMemo(() => { + return throttle(cancelScroll, 200); + }, [cancelScroll]); + useEffect(() => { for (const event of userInteractionEvents) { - window.addEventListener(event, cancelScroll); + window.addEventListener(event, throttledCancelScroll); } return () => { for (const event of userInteractionEvents) { - window.removeEventListener(event, cancelScroll); + window.removeEventListener(event, throttledCancelScroll); } }; - }, []); + }, [throttledCancelScroll]); - // Syncs scroll tracking to page changes. Wanted to handle these changes via a - // click event handler, but that got overly complicated between making sure - // that events didn't bubble all the way to the window (where they would - // immediately be canceled by window), and needing to update all downstream - // click handlers to be aware of event objects. Must be layout effect in order - // to fire before layout effect defined below - const mountedRef = useRef(false); useLayoutEffect(() => { - // Never want to turn scrolling on for initial mount. Tried avoiding ref and - // checking things like viewport, but they all seemed unreliable (especially - // if the user can interact with the page while JS is still loading in) - if (mountedRef.current) { - mountedRef.current = true; - return; - } - - scrollAfterDataLoadsRef.current = true; + scrollCanceledRef.current = false; }, [currentPage]); - // Jumps the user to the top of the paginated container each time new data - // loads in. Has no dependency array, because you can't sync based off of - // showingPreviousData. If its value is always false (via default params), - // an effect synced with it will never fire beyond the on-mount call useLayoutEffect(() => { - const shouldScroll = - autoScroll && !showingPreviousData && scrollAfterDataLoadsRef.current; + if (showingPreviousData) { + return; + } + const shouldScroll = autoScroll && !scrollCanceledRef.current; if (shouldScroll) { scrollContainerRef.current?.scrollIntoView({ block: "start", behavior: "instant", }); } - }); + }, [autoScroll, showingPreviousData]); return (
From 9eebb204d27f4f573c7e841904193862099773c5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sat, 25 Nov 2023 00:22:42 +0000 Subject: [PATCH 66/91] wip: more attempts --- .../PaginationWidget/Pagination.tsx | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 43ebe4c4c6efb..b1c96295bd847 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -4,12 +4,10 @@ import { useEffect, useLayoutEffect, useRef, - useMemo, } from "react"; -import { PaginationWidgetBase } from "./PaginationWidgetBase"; -import { throttle } from "lodash"; import { useEffectEvent } from "hooks/hookPolyfills"; +import { PaginationWidgetBase } from "./PaginationWidgetBase"; type PaginationProps = PropsWithChildren<{ currentPage: number; @@ -47,44 +45,48 @@ export const Pagination: FC = ({ const scrollContainerRef = useRef(null); const scrollCanceledRef = useRef(false); - /** - * @todo Probably better just to make a useThrottledFunction custom hook, - * rather than the weird useEffectEvent+useMemo approach. Cannot use throttle - * inside the render path directly; it will create a new stateful function - * every single render, and there won't be a single throttle state - */ const cancelScroll = useEffectEvent(() => { if (showingPreviousData) { scrollCanceledRef.current = true; } }); - const throttledCancelScroll = useMemo(() => { - return throttle(cancelScroll, 200); - }, [cancelScroll]); - useEffect(() => { for (const event of userInteractionEvents) { - window.addEventListener(event, throttledCancelScroll); + window.addEventListener(event, cancelScroll); } return () => { for (const event of userInteractionEvents) { - window.removeEventListener(event, throttledCancelScroll); + window.removeEventListener(event, cancelScroll); } }; - }, [throttledCancelScroll]); - - useLayoutEffect(() => { - scrollCanceledRef.current = false; - }, [currentPage]); + }, [cancelScroll]); - useLayoutEffect(() => { + const handlePageChange = useEffectEvent(() => { if (showingPreviousData) { + scrollCanceledRef.current = false; return; } - const shouldScroll = autoScroll && !scrollCanceledRef.current; + if (!autoScroll) { + return; + } + + scrollContainerRef.current?.scrollIntoView({ + block: "start", + behavior: "instant", + }); + }); + + useLayoutEffect(() => { + handlePageChange(); + }, [handlePageChange, currentPage]); + + useLayoutEffect(() => { + const shouldScroll = + autoScroll && !showingPreviousData && !scrollCanceledRef.current; + if (shouldScroll) { scrollContainerRef.current?.scrollIntoView({ block: "start", From 3d3983518edab39b53d256a881b9d7cefe4071d4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sat, 25 Nov 2023 00:36:01 +0000 Subject: [PATCH 67/91] wip: more progress --- .../PaginationWidget/Pagination.tsx | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index b1c96295bd847..1e54ffa4246a1 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -1,6 +1,6 @@ import { type FC, - type PropsWithChildren, + type HTMLAttributes, useEffect, useLayoutEffect, useRef, @@ -9,21 +9,22 @@ import { import { useEffectEvent } from "hooks/hookPolyfills"; import { PaginationWidgetBase } from "./PaginationWidgetBase"; -type PaginationProps = PropsWithChildren<{ +type PaginationProps = HTMLAttributes & { currentPage: number; pageSize: number; totalRecords: number | undefined; onPageChange: (newPage: number) => void; autoScroll?: boolean; + scrollBehavior?: ScrollBehavior; /** - * Meant to interface with useQuery's isPreviousData property + * Meant to interface with useQuery's isPreviousData property. * * Indicates whether data for a previous query is being shown while a new * query is loading in */ - showingPreviousData?: boolean; -}>; + showingPreviousData: boolean; +}; const userInteractionEvents: (keyof WindowEventMap)[] = [ "click", @@ -38,20 +39,20 @@ export const Pagination: FC = ({ currentPage, pageSize, totalRecords, + showingPreviousData, onPageChange, autoScroll = true, - showingPreviousData = false, + scrollBehavior = "instant", + ...delegatedProps }) => { const scrollContainerRef = useRef(null); - const scrollCanceledRef = useRef(false); - - const cancelScroll = useEffectEvent(() => { - if (showingPreviousData) { - scrollCanceledRef.current = true; - } - }); + const deferredScrollIsCanceled = useRef(true); useEffect(() => { + const cancelScroll = () => { + deferredScrollIsCanceled.current = true; + }; + for (const event of userInteractionEvents) { window.addEventListener(event, cancelScroll); } @@ -61,22 +62,23 @@ export const Pagination: FC = ({ window.removeEventListener(event, cancelScroll); } }; - }, [cancelScroll]); + }, []); - const handlePageChange = useEffectEvent(() => { - if (showingPreviousData) { - scrollCanceledRef.current = false; - return; + const scroll = useEffectEvent(() => { + if (autoScroll) { + scrollContainerRef.current?.scrollIntoView({ + block: "start", + behavior: scrollBehavior, + }); } + }); - if (!autoScroll) { - return; + const handlePageChange = useEffectEvent(() => { + if (showingPreviousData) { + deferredScrollIsCanceled.current = false; + } else { + scroll(); } - - scrollContainerRef.current?.scrollIntoView({ - block: "start", - behavior: "instant", - }); }); useLayoutEffect(() => { @@ -84,16 +86,10 @@ export const Pagination: FC = ({ }, [handlePageChange, currentPage]); useLayoutEffect(() => { - const shouldScroll = - autoScroll && !showingPreviousData && !scrollCanceledRef.current; - - if (shouldScroll) { - scrollContainerRef.current?.scrollIntoView({ - block: "start", - behavior: "instant", - }); + if (!showingPreviousData && !deferredScrollIsCanceled.current) { + scroll(); } - }, [autoScroll, showingPreviousData]); + }, [scroll, showingPreviousData]); return (
@@ -103,6 +99,7 @@ export const Pagination: FC = ({ flexFlow: "column nowrap", rowGap: "24px", }} + {...delegatedProps} > {children} {totalRecords !== undefined && ( From d817cc5245f602958b53190c96fb8d332e6549a7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Sat, 25 Nov 2023 00:36:30 +0000 Subject: [PATCH 68/91] wip: more progress --- site/src/components/PaginationWidget/Pagination.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 1e54ffa4246a1..1aa4ee6c48e9e 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -46,11 +46,11 @@ export const Pagination: FC = ({ ...delegatedProps }) => { const scrollContainerRef = useRef(null); - const deferredScrollIsCanceled = useRef(true); + const isDeferredScrollActiveRef = useRef(false); useEffect(() => { const cancelScroll = () => { - deferredScrollIsCanceled.current = true; + isDeferredScrollActiveRef.current = false; }; for (const event of userInteractionEvents) { @@ -75,7 +75,7 @@ export const Pagination: FC = ({ const handlePageChange = useEffectEvent(() => { if (showingPreviousData) { - deferredScrollIsCanceled.current = false; + isDeferredScrollActiveRef.current = true; } else { scroll(); } @@ -86,7 +86,7 @@ export const Pagination: FC = ({ }, [handlePageChange, currentPage]); useLayoutEffect(() => { - if (!showingPreviousData && !deferredScrollIsCanceled.current) { + if (!showingPreviousData && isDeferredScrollActiveRef.current) { scroll(); } }, [scroll, showingPreviousData]); From d36dca7b74188ef6d3d31f94a8faab99d070ed08 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:01:20 +0000 Subject: [PATCH 69/91] refactor: clean up scroll sync logic --- .../PaginationWidget/Pagination.tsx | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 1aa4ee6c48e9e..27ca5524e8d13 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -15,7 +15,6 @@ type PaginationProps = HTMLAttributes & { totalRecords: number | undefined; onPageChange: (newPage: number) => void; autoScroll?: boolean; - scrollBehavior?: ScrollBehavior; /** * Meant to interface with useQuery's isPreviousData property. @@ -42,15 +41,14 @@ export const Pagination: FC = ({ showingPreviousData, onPageChange, autoScroll = true, - scrollBehavior = "instant", ...delegatedProps }) => { const scrollContainerRef = useRef(null); - const isDeferredScrollActiveRef = useRef(false); + const isScrollingQueuedRef = useRef(false); useEffect(() => { const cancelScroll = () => { - isDeferredScrollActiveRef.current = false; + isScrollingQueuedRef.current = false; }; for (const event of userInteractionEvents) { @@ -64,32 +62,25 @@ export const Pagination: FC = ({ }; }, []); - const scroll = useEffectEvent(() => { - if (autoScroll) { + const syncScrollChange = useEffectEvent(() => { + if (showingPreviousData) { + isScrollingQueuedRef.current = true; + return; + } + + if (autoScroll && isScrollingQueuedRef.current) { scrollContainerRef.current?.scrollIntoView({ block: "start", - behavior: scrollBehavior, + behavior: "instant", }); } - }); - const handlePageChange = useEffectEvent(() => { - if (showingPreviousData) { - isDeferredScrollActiveRef.current = true; - } else { - scroll(); - } + isScrollingQueuedRef.current = false; }); useLayoutEffect(() => { - handlePageChange(); - }, [handlePageChange, currentPage]); - - useLayoutEffect(() => { - if (!showingPreviousData && isDeferredScrollActiveRef.current) { - scroll(); - } - }, [scroll, showingPreviousData]); + syncScrollChange(); + }, [syncScrollChange, currentPage, showingPreviousData]); return (
From 3961ec1ac114cb87178697a159636a63fece0930 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:13:36 +0000 Subject: [PATCH 70/91] docs: add big comment explainign syncScrollChange --- .../PaginationWidget/Pagination.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 27ca5524e8d13..28d4c47007aa1 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -62,6 +62,29 @@ export const Pagination: FC = ({ }; }, []); + /** + * This function is mainly accounting for five different triggers for the + * below useLayoutEffect call: + * + * 1. Initial render – We don't want anything to run on the initial render to + * avoid hijacking the user's browser and also make sure the UI doesn't + * feel janky. showingPreviousData should always be false, and currentPage + * should generally be 1. + * 2. Current page doesn’t change, but showingPreviousData becomes true - Also + * do nothing. + * 3. Current page doesn’t change, but showingPreviousData becomes false - The + * data for the current page has finally come in; scroll if a scroll is + * queued from a previous render + * 4. Current page changes and showingPreviousData is false – we have cached + * data for whatever page we just jumped to. Scroll immediately (and reset + * the scrolling state just to be on the safe side) + * 5. Current page changes and showingPreviousData is false – Cache miss. + * Queue up a scroll, but do nothing else. If the user does anything at all + * while the new data is loading in, cancel the scroll. + * + * Set up as an effect event because currentPage and showingPreviousData + * should be the only two cues for syncing scroll position + */ const syncScrollChange = useEffectEvent(() => { if (showingPreviousData) { isScrollingQueuedRef.current = true; From c7e94dd02abc4dfdcfb0448523544b7cdf5510df Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:25:16 +0000 Subject: [PATCH 71/91] fix: make sure effects run properly --- .../PaginationWidget/Pagination.tsx | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 28d4c47007aa1..d1127f88bdfb7 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -63,8 +63,8 @@ export const Pagination: FC = ({ }, []); /** - * This function is mainly accounting for five different triggers for the - * below useLayoutEffect call: + * Have to account for for five different triggers to determine when the + * component should scroll the user to the top of the container: * * 1. Initial render – We don't want anything to run on the initial render to * avoid hijacking the user's browser and also make sure the UI doesn't @@ -82,15 +82,10 @@ export const Pagination: FC = ({ * Queue up a scroll, but do nothing else. If the user does anything at all * while the new data is loading in, cancel the scroll. * - * Set up as an effect event because currentPage and showingPreviousData - * should be the only two cues for syncing scroll position + * currentPage and showingPreviousData should be the only two cues for syncing + * the scroll position */ - const syncScrollChange = useEffectEvent(() => { - if (showingPreviousData) { - isScrollingQueuedRef.current = true; - return; - } - + const scroll = useEffectEvent(() => { if (autoScroll && isScrollingQueuedRef.current) { scrollContainerRef.current?.scrollIntoView({ block: "start", @@ -101,9 +96,21 @@ export const Pagination: FC = ({ isScrollingQueuedRef.current = false; }); + const syncPageChange = useEffectEvent(() => { + if (showingPreviousData) { + isScrollingQueuedRef.current = true; + } else { + scroll(); + } + }); + + useLayoutEffect(() => { + syncPageChange(); + }, [syncPageChange, currentPage]); + useLayoutEffect(() => { - syncScrollChange(); - }, [syncScrollChange, currentPage, showingPreviousData]); + scroll(); + }, [scroll, showingPreviousData]); return (
From 66fc6ba1bcca3a78c65fc0c593b8100ebe15a118 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:33:19 +0000 Subject: [PATCH 72/91] fix: finalize effect sync logic --- .../src/components/PaginationWidget/Pagination.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index d1127f88bdfb7..1885d10294d0a 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -85,8 +85,8 @@ export const Pagination: FC = ({ * currentPage and showingPreviousData should be the only two cues for syncing * the scroll position */ - const scroll = useEffectEvent(() => { - if (autoScroll && isScrollingQueuedRef.current) { + const syncScrollPosition = useEffectEvent(() => { + if (autoScroll) { scrollContainerRef.current?.scrollIntoView({ block: "start", behavior: "instant", @@ -96,11 +96,13 @@ export const Pagination: FC = ({ isScrollingQueuedRef.current = false; }); + // Would've liked to consolidate these effects into a single useLayoutEffect + // call, but they kept messing each other up when grouped together const syncPageChange = useEffectEvent(() => { if (showingPreviousData) { isScrollingQueuedRef.current = true; } else { - scroll(); + syncScrollPosition(); } }); @@ -109,8 +111,10 @@ export const Pagination: FC = ({ }, [syncPageChange, currentPage]); useLayoutEffect(() => { - scroll(); - }, [scroll, showingPreviousData]); + if (!showingPreviousData && isScrollingQueuedRef.current) { + syncScrollPosition(); + } + }, [syncScrollPosition, showingPreviousData]); return (
From 7d660a49cd1bce5ed12c15ae7dc4f9b41fa5ba29 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:41:38 +0000 Subject: [PATCH 73/91] fix: add on-mount logic for effects --- .../PaginationWidget/Pagination.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 1885d10294d0a..fb3a47cf5a4a6 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -46,7 +46,13 @@ export const Pagination: FC = ({ const scrollContainerRef = useRef(null); const isScrollingQueuedRef = useRef(false); + // Sets up event handlers for canceling queued scrolls in response to + // literally any user behavior useEffect(() => { + if (!autoScroll) { + return; + } + const cancelScroll = () => { isScrollingQueuedRef.current = false; }; @@ -60,7 +66,7 @@ export const Pagination: FC = ({ window.removeEventListener(event, cancelScroll); } }; - }, []); + }, [autoScroll]); /** * Have to account for for five different triggers to determine when the @@ -83,7 +89,8 @@ export const Pagination: FC = ({ * while the new data is loading in, cancel the scroll. * * currentPage and showingPreviousData should be the only two cues for syncing - * the scroll position + * the scroll position. There's not a lot of code, but it's obnoxious because + * this use case doesn't line up that well with useEffect's API */ const syncScrollPosition = useEffectEvent(() => { if (autoScroll) { @@ -96,16 +103,23 @@ export const Pagination: FC = ({ isScrollingQueuedRef.current = false; }); - // Would've liked to consolidate these effects into a single useLayoutEffect - // call, but they kept messing each other up when grouped together + const isOnFirstRenderRef = useRef(true); const syncPageChange = useEffectEvent(() => { + if (isOnFirstRenderRef.current) { + isOnFirstRenderRef.current = false; + return; + } + if (showingPreviousData) { isScrollingQueuedRef.current = true; - } else { - syncScrollPosition(); + return; } + + syncScrollPosition(); }); + // Would've liked to consolidate these effects into a single useLayoutEffect + // call, but they kept messing each other up when grouped together useLayoutEffect(() => { syncPageChange(); }, [syncPageChange, currentPage]); From a03e1673059973fcdbffa41514a518a57697a670 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:43:42 +0000 Subject: [PATCH 74/91] fix: remove autoScroll property (not a good enough use case) --- .../components/PaginationWidget/Pagination.tsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index fb3a47cf5a4a6..f00bd7ba3b792 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -14,7 +14,6 @@ type PaginationProps = HTMLAttributes & { pageSize: number; totalRecords: number | undefined; onPageChange: (newPage: number) => void; - autoScroll?: boolean; /** * Meant to interface with useQuery's isPreviousData property. @@ -40,7 +39,6 @@ export const Pagination: FC = ({ totalRecords, showingPreviousData, onPageChange, - autoScroll = true, ...delegatedProps }) => { const scrollContainerRef = useRef(null); @@ -49,10 +47,6 @@ export const Pagination: FC = ({ // Sets up event handlers for canceling queued scrolls in response to // literally any user behavior useEffect(() => { - if (!autoScroll) { - return; - } - const cancelScroll = () => { isScrollingQueuedRef.current = false; }; @@ -66,7 +60,7 @@ export const Pagination: FC = ({ window.removeEventListener(event, cancelScroll); } }; - }, [autoScroll]); + }, []); /** * Have to account for for five different triggers to determine when the @@ -93,12 +87,10 @@ export const Pagination: FC = ({ * this use case doesn't line up that well with useEffect's API */ const syncScrollPosition = useEffectEvent(() => { - if (autoScroll) { - scrollContainerRef.current?.scrollIntoView({ - block: "start", - behavior: "instant", - }); - } + scrollContainerRef.current?.scrollIntoView({ + block: "start", + behavior: "instant", + }); isScrollingQueuedRef.current = false; }); From 4ffacfb0c8a859ce7da9e066245c6d14aa6d1784 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 12:46:07 +0000 Subject: [PATCH 75/91] refactor: clean up code --- site/src/components/PaginationWidget/Pagination.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index f00bd7ba3b792..871cc65f8ac7c 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -104,10 +104,9 @@ export const Pagination: FC = ({ if (showingPreviousData) { isScrollingQueuedRef.current = true; - return; + } else { + syncScrollPosition(); } - - syncScrollPosition(); }); // Would've liked to consolidate these effects into a single useLayoutEffect From e98ab1b6d95d8f821fa8e7d5b8285ed79bc203a5 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 14:38:11 +0000 Subject: [PATCH 76/91] fix: make disabled messages have consistent verbage --- site/src/components/PaginationWidget/PaginationWidgetBase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx index 11b963f32798a..73720ddf5a99b 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -70,7 +70,7 @@ export const PaginationWidgetBase = ({ )} { From 8fb2703f35cced8e9c9038eefd0c80210b203d11 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 21:33:16 +0000 Subject: [PATCH 77/91] refactor: quarantine useEffect evil --- .../PaginationWidget/Pagination.tsx | 165 ++++++++++++------ 1 file changed, 112 insertions(+), 53 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 871cc65f8ac7c..29766f0c7e744 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -4,6 +4,8 @@ import { useEffect, useLayoutEffect, useRef, + MouseEvent as ReactMouseEvent, + KeyboardEvent as ReactKeyboardEvent, } from "react"; import { useEffectEvent } from "hooks/hookPolyfills"; @@ -22,6 +24,10 @@ type PaginationProps = HTMLAttributes & { * query is loading in */ showingPreviousData: boolean; + + // Mainly here for Storybook integration; autoScroll should almost always be + // flipped to true in production + autoScroll?: boolean; }; const userInteractionEvents: (keyof WindowEventMap)[] = [ @@ -39,14 +45,65 @@ export const Pagination: FC = ({ totalRecords, showingPreviousData, onPageChange, + autoScroll = true, ...delegatedProps }) => { + const scrollContainerProps = useScrollOnPageChange( + currentPage, + showingPreviousData, + autoScroll, + ); + + return ( +
+
+ {children} + {totalRecords !== undefined && ( + + )} +
+
+ ); +}; + +/** + * Splitting this into a custom hook because there's a lot of convoluted logic + * here (the use case doesn't line up super well with useEffect, even though + * it's the only tool that solves the problem). Do not export this; it should be + * treated as an internal implementation detail + * + * Scrolls the user to the top of the pagination container when the current + * page changes (accounting for old data being shown during loading transitions) + * + * See component test file for all cases this is meant to handle + */ +function useScrollOnPageChange( + currentPage: number, + showingPreviousData: boolean, + autoScroll: boolean, +) { const scrollContainerRef = useRef(null); const isScrollingQueuedRef = useRef(false); // Sets up event handlers for canceling queued scrolls in response to - // literally any user behavior + // literally any user interaction useEffect(() => { + if (!autoScroll) { + return; + } + const cancelScroll = () => { isScrollingQueuedRef.current = false; }; @@ -60,33 +117,9 @@ export const Pagination: FC = ({ window.removeEventListener(event, cancelScroll); } }; - }, []); + }, [autoScroll]); - /** - * Have to account for for five different triggers to determine when the - * component should scroll the user to the top of the container: - * - * 1. Initial render – We don't want anything to run on the initial render to - * avoid hijacking the user's browser and also make sure the UI doesn't - * feel janky. showingPreviousData should always be false, and currentPage - * should generally be 1. - * 2. Current page doesn’t change, but showingPreviousData becomes true - Also - * do nothing. - * 3. Current page doesn’t change, but showingPreviousData becomes false - The - * data for the current page has finally come in; scroll if a scroll is - * queued from a previous render - * 4. Current page changes and showingPreviousData is false – we have cached - * data for whatever page we just jumped to. Scroll immediately (and reset - * the scrolling state just to be on the safe side) - * 5. Current page changes and showingPreviousData is false – Cache miss. - * Queue up a scroll, but do nothing else. If the user does anything at all - * while the new data is loading in, cancel the scroll. - * - * currentPage and showingPreviousData should be the only two cues for syncing - * the scroll position. There's not a lot of code, but it's obnoxious because - * this use case doesn't line up that well with useEffect's API - */ - const syncScrollPosition = useEffectEvent(() => { + const scrollToTop = useEffectEvent(() => { scrollContainerRef.current?.scrollIntoView({ block: "start", behavior: "instant", @@ -95,6 +128,8 @@ export const Pagination: FC = ({ isScrollingQueuedRef.current = false; }); + // Tracking whether we're on the first render, because calling the effects + // unconditionally will just hijack the user and make pages feel janky const isOnFirstRenderRef = useRef(true); const syncPageChange = useEffectEvent(() => { if (isOnFirstRenderRef.current) { @@ -105,7 +140,7 @@ export const Pagination: FC = ({ if (showingPreviousData) { isScrollingQueuedRef.current = true; } else { - syncScrollPosition(); + scrollToTop(); } }); @@ -117,30 +152,54 @@ export const Pagination: FC = ({ useLayoutEffect(() => { if (!showingPreviousData && isScrollingQueuedRef.current) { - syncScrollPosition(); + scrollToTop(); } - }, [syncScrollPosition, showingPreviousData]); + }, [scrollToTop, showingPreviousData]); - return ( -
-
- {children} - {totalRecords !== undefined && ( - - )} -
-
- ); -}; + /** + * This is meant to capture and stop event bubbling for events that come from + * deeper within Pagination + * + * Without this, this is the order of operations that happens when you change + * a page while no data is available for the page you're going to: + * 1. User uses keyboard/mouse to change page + * 2. Event handler dispatches state changes to React + * 3. Even though flushing a state change is async, React will still flush + * and re-render before the event is allowed to travel further up + * 4. The current page triggers the layout effect, queuing a scroll + * 5. The event resumes bubbling up and reaches the window object + * 6. The window object unconditionally cancels the scroll, immediately and + * always undoing any kind of scroll queuing you try to do + * + * One alternative was reaching deeper within the event handlers for the inner + * components and passing the events directly through multiple layers. Tried + * it, but it got clunky fast. Better to have the ugliness in one spot + */ + const stopInternalEventBubbling = ( + event: ReactMouseEvent | ReactKeyboardEvent, + ) => { + const { nativeEvent } = event; + + const isEventFromClick = + nativeEvent instanceof MouseEvent || + (nativeEvent instanceof KeyboardEvent && + (nativeEvent.key === " " || nativeEvent.key === "Enter")); + + const shouldStopBubbling = + isEventFromClick && + !isScrollingQueuedRef.current && + event.target instanceof HTMLElement && + scrollContainerRef.current !== event.target && + scrollContainerRef.current?.contains(event.target); + + if (shouldStopBubbling) { + event.stopPropagation(); + } + }; + + return { + ref: scrollContainerRef, + onClick: stopInternalEventBubbling, + onKeyDown: stopInternalEventBubbling, + } as const; +} From 1fea4bc36db26774ffac72996647ac5ca2a2176e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 22:28:27 +0000 Subject: [PATCH 78/91] refactor: revise Pagination implementation --- .../PaginationWidget/Pagination.tsx | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 29766f0c7e744..0a9bceccba051 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -1,32 +1,19 @@ import { type FC, type HTMLAttributes, + type MouseEvent as ReactMouseEvent, + type KeyboardEvent as ReactKeyboardEvent, useEffect, useLayoutEffect, useRef, - MouseEvent as ReactMouseEvent, - KeyboardEvent as ReactKeyboardEvent, } from "react"; +import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; import { useEffectEvent } from "hooks/hookPolyfills"; import { PaginationWidgetBase } from "./PaginationWidgetBase"; type PaginationProps = HTMLAttributes & { - currentPage: number; - pageSize: number; - totalRecords: number | undefined; - onPageChange: (newPage: number) => void; - - /** - * Meant to interface with useQuery's isPreviousData property. - * - * Indicates whether data for a previous query is being shown while a new - * query is loading in - */ - showingPreviousData: boolean; - - // Mainly here for Storybook integration; autoScroll should almost always be - // flipped to true in production + paginationResult: UsePaginatedQueryResult; autoScroll?: boolean; }; @@ -40,17 +27,13 @@ const userInteractionEvents: (keyof WindowEventMap)[] = [ export const Pagination: FC = ({ children, - currentPage, - pageSize, - totalRecords, - showingPreviousData, - onPageChange, + paginationResult, autoScroll = true, ...delegatedProps }) => { const scrollContainerProps = useScrollOnPageChange( - currentPage, - showingPreviousData, + paginationResult.currentPage, + paginationResult.isPreviousData, autoScroll, ); @@ -65,12 +48,15 @@ export const Pagination: FC = ({ {...delegatedProps} > {children} - {totalRecords !== undefined && ( + + {paginationResult.isSuccess && ( )}
@@ -81,13 +67,13 @@ export const Pagination: FC = ({ /** * Splitting this into a custom hook because there's a lot of convoluted logic * here (the use case doesn't line up super well with useEffect, even though - * it's the only tool that solves the problem). Do not export this; it should be - * treated as an internal implementation detail + * it's the only tool that solves the problem). Please do not export this; it + * should be treated as an internal implementation detail * * Scrolls the user to the top of the pagination container when the current * page changes (accounting for old data being shown during loading transitions) * - * See component test file for all cases this is meant to handle + * See Pagination test file for all cases this is meant to handle */ function useScrollOnPageChange( currentPage: number, @@ -120,8 +106,16 @@ function useScrollOnPageChange( }, [autoScroll]); const scrollToTop = useEffectEvent(() => { - scrollContainerRef.current?.scrollIntoView({ - block: "start", + const scrollMargin = 48; + const newVerticalPosition = + (scrollContainerRef.current?.getBoundingClientRect().top ?? 0) + + window.scrollY - + scrollMargin; + + // Not using element.scrollIntoView because it gives no control over scroll + // offsets/margins + window.scrollTo({ + top: Math.max(0, newVerticalPosition), behavior: "instant", }); @@ -129,7 +123,7 @@ function useScrollOnPageChange( }); // Tracking whether we're on the first render, because calling the effects - // unconditionally will just hijack the user and make pages feel janky + // unconditionally will just hijack the user and feel absolutely awful const isOnFirstRenderRef = useRef(true); const syncPageChange = useEffectEvent(() => { if (isOnFirstRenderRef.current) { From 12b37909e38d2a1a44f195eeae90e66984717607 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 27 Nov 2023 23:23:34 +0000 Subject: [PATCH 79/91] chore: get Pagination component integrated into users table --- .../PaginationWidget/Pagination.tsx | 81 ++++++++++++++++--- .../PaginationWidget/PaginationWidgetBase.tsx | 21 +++-- site/src/hooks/usePaginatedQuery.ts | 4 + site/src/pages/UsersPage/UsersPage.tsx | 6 +- site/src/pages/UsersPage/UsersPageView.tsx | 35 ++------ 5 files changed, 94 insertions(+), 53 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 0a9bceccba051..eb2d62f0135a2 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -8,12 +8,21 @@ import { useRef, } from "react"; -import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import { useTheme } from "@emotion/react"; import { useEffectEvent } from "hooks/hookPolyfills"; +import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; + import { PaginationWidgetBase } from "./PaginationWidgetBase"; +import Skeleton from "@mui/material/Skeleton"; type PaginationProps = HTMLAttributes & { paginationResult: UsePaginatedQueryResult; + paginationUnitLabel: string; + + /** + * Mainly here to simplify Storybook integrations. This should almost always + * be true in production + */ autoScroll?: boolean; }; @@ -28,6 +37,7 @@ const userInteractionEvents: (keyof WindowEventMap)[] = [ export const Pagination: FC = ({ children, paginationResult, + paginationUnitLabel, autoScroll = true, ...delegatedProps }) => { @@ -39,11 +49,16 @@ export const Pagination: FC = ({ return (
+ +
@@ -64,6 +79,54 @@ export const Pagination: FC = ({ ); }; +type PaginationHeaderProps = { + paginationResult: UsePaginatedQueryResult; + paginationUnitLabel: string; +}; + +const PaginationHeader: FC = ({ + paginationResult, + paginationUnitLabel, +}) => { + const theme = useTheme(); + const endBound = Math.min( + paginationResult.limit - 1, + (paginationResult.totalRecords ?? 0) - (paginationResult.currentChunk ?? 0), + ); + + return ( +
+ {!paginationResult.isSuccess ? ( + + ) : ( +
+ Showing {paginationUnitLabel}{" "} + + {paginationResult.currentChunk}– + {paginationResult.currentChunk + endBound} + {" "} + ({paginationResult.totalRecords}{" "} + {paginationUnitLabel} total) +
+ )} +
+ ); +}; + /** * Splitting this into a custom hook because there's a lot of convoluted logic * here (the use case doesn't line up super well with useEffect, even though @@ -106,19 +169,13 @@ function useScrollOnPageChange( }, [autoScroll]); const scrollToTop = useEffectEvent(() => { - const scrollMargin = 48; const newVerticalPosition = (scrollContainerRef.current?.getBoundingClientRect().top ?? 0) + - window.scrollY - - scrollMargin; - - // Not using element.scrollIntoView because it gives no control over scroll - // offsets/margins - window.scrollTo({ - top: Math.max(0, newVerticalPosition), - behavior: "instant", - }); + window.scrollY; + // Not using element.scrollIntoView for testing reasons; much easier to mock + // the global window object + window.scrollTo({ top: newVerticalPosition, behavior: "instant" }); isScrollingQueuedRef.current = false; }); diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx index 73720ddf5a99b..945c5e80d82e0 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -12,6 +12,9 @@ export type PaginationWidgetBaseProps = { pageSize: number; totalRecords: number; onPageChange: (newPage: number) => void; + + hasPreviousPage?: boolean; + hasNextPage?: boolean; }; export const PaginationWidgetBase = ({ @@ -19,6 +22,8 @@ export const PaginationWidgetBase = ({ pageSize, totalRecords, onPageChange, + hasPreviousPage, + hasNextPage, }: PaginationWidgetBaseProps) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); @@ -28,8 +33,12 @@ export const PaginationWidgetBase = ({ return null; } - const onFirstPage = currentPage <= 1; - const onLastPage = currentPage >= totalPages; + // Ugly stopgap hack to make sure that the PaginationBase can be used for both + // the old and new pagination implementations while the transition is + // happening - without breaking existing Storybook tests + const currentPageOffset = (currentPage - 1) * pageSize; + hasPreviousPage ??= currentPage > 1; + hasNextPage ??= pageSize + currentPageOffset < totalRecords; return (
{ - if (!onFirstPage) { + if (hasPreviousPage) { onPageChange(currentPage - 1); } }} @@ -71,10 +80,10 @@ export const PaginationWidgetBase = ({ { - if (!onLastPage) { + if (hasNextPage) { onPageChange(currentPage + 1); } }} diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index aa16e1c92c448..abbdf43940031 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -259,6 +259,7 @@ export function usePaginatedQuery< hasPreviousPage, totalRecords: totalRecords as number, totalPages: totalPages as number, + currentChunk: currentPageOffset + 1, } : { isSuccess: false, @@ -266,6 +267,7 @@ export function usePaginatedQuery< hasPreviousPage: false, totalRecords: undefined, totalPages: undefined, + currentChunk: undefined, }), }; @@ -306,6 +308,7 @@ type PaginationResultInfo = { hasPreviousPage: false; totalRecords: undefined; totalPages: undefined; + currentChunk: undefined; } | { isSuccess: true; @@ -313,6 +316,7 @@ type PaginationResultInfo = { hasPreviousPage: boolean; totalRecords: number; totalPages: number; + currentChunk: number; } ); diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index a9f366ae00cb6..54522a05ece0f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -155,11 +155,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { error: usersQuery.error, menus: { status: statusMenu }, }} - count={usersQuery.totalRecords} - page={usersQuery.currentPage} - limit={usersQuery.limit} - onPageChange={usersQuery.onPageChange} - showingPreviousData={usersQuery.isPreviousData} + paginationResult={usersQuery} /> void; - showingPreviousData: boolean; + paginationResult: UsePaginatedQueryResult; } export const UsersPageView: FC> = ({ @@ -61,32 +52,16 @@ export const UsersPageView: FC> = ({ isNonInitialPage, actorID, authMethods, - count, - limit, - onPageChange, - page, groupsByUserId, - showingPreviousData, + paginationResult, }) => { return ( <> - - - - Date: Mon, 27 Nov 2023 23:50:56 +0000 Subject: [PATCH 80/91] refactor: make updated stories happy --- .../PaginationWidget/Pagination.tsx | 40 +++++++++++-------- site/src/hooks/usePaginatedQuery.ts | 2 +- .../pages/UsersPage/UsersPageView.stories.tsx | 18 +++++++-- site/src/pages/UsersPage/UsersPageView.tsx | 8 ++-- 4 files changed, 44 insertions(+), 24 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index eb2d62f0135a2..921017c564678 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -10,13 +10,17 @@ import { import { useTheme } from "@emotion/react"; import { useEffectEvent } from "hooks/hookPolyfills"; -import { type UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import { type PaginationResultInfo } from "hooks/usePaginatedQuery"; import { PaginationWidgetBase } from "./PaginationWidgetBase"; import Skeleton from "@mui/material/Skeleton"; +export type PaginationResult = PaginationResultInfo & { + isPreviousData: boolean; +}; + type PaginationProps = HTMLAttributes & { - paginationResult: UsePaginatedQueryResult; + paginationResult: PaginationResult; paginationUnitLabel: string; /** @@ -26,14 +30,6 @@ type PaginationProps = HTMLAttributes & { autoScroll?: boolean; }; -const userInteractionEvents: (keyof WindowEventMap)[] = [ - "click", - "scroll", - "pointerenter", - "touchstart", - "keydown", -]; - export const Pagination: FC = ({ children, paginationResult, @@ -80,7 +76,7 @@ export const Pagination: FC = ({ }; type PaginationHeaderProps = { - paginationResult: UsePaginatedQueryResult; + paginationResult: PaginationResult; paginationUnitLabel: string; }; @@ -127,6 +123,15 @@ const PaginationHeader: FC = ({ ); }; +// Events to listen to for canceling queued scrolls +const userInteractionEvents: (keyof WindowEventMap)[] = [ + "click", + "scroll", + "pointerenter", + "touchstart", + "keydown", +]; + /** * Splitting this into a custom hook because there's a lot of convoluted logic * here (the use case doesn't line up super well with useEffect, even though @@ -179,8 +184,9 @@ function useScrollOnPageChange( isScrollingQueuedRef.current = false; }); - // Tracking whether we're on the first render, because calling the effects - // unconditionally will just hijack the user and feel absolutely awful + // Reminder: effects always run on mount, no matter what's in the dependency + // array. Not doing anything on initial render because unconditionally + // scrolling and hijacking the user's page will feel absolutely awful const isOnFirstRenderRef = useRef(true); const syncPageChange = useEffectEvent(() => { if (isOnFirstRenderRef.current) { @@ -216,15 +222,15 @@ function useScrollOnPageChange( * 1. User uses keyboard/mouse to change page * 2. Event handler dispatches state changes to React * 3. Even though flushing a state change is async, React will still flush - * and re-render before the event is allowed to travel further up + * and re-render before the event is allowed to bubble further up * 4. The current page triggers the layout effect, queuing a scroll * 5. The event resumes bubbling up and reaches the window object * 6. The window object unconditionally cancels the scroll, immediately and * always undoing any kind of scroll queuing you try to do * - * One alternative was reaching deeper within the event handlers for the inner - * components and passing the events directly through multiple layers. Tried - * it, but it got clunky fast. Better to have the ugliness in one spot + * One alternative was micro-managing the events from the individual button + * elements, but that got clunky and seemed even more fragile. Better to have + * the ugliness in a single, consolidated spot */ const stopInternalEventBubbling = ( event: ReactMouseEvent | ReactKeyboardEvent, diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts index abbdf43940031..6d8ce68c629a7 100644 --- a/site/src/hooks/usePaginatedQuery.ts +++ b/site/src/hooks/usePaginatedQuery.ts @@ -294,7 +294,7 @@ function getParamsWithoutPage(params: URLSearchParams): URLSearchParams { * All the pagination-properties for UsePaginatedQueryResult. Split up so that * the types can be used separately in multiple spots. */ -type PaginationResultInfo = { +export type PaginationResultInfo = { currentPage: number; limit: number; onPageChange: (newPage: number) => void; diff --git a/site/src/pages/UsersPage/UsersPageView.stories.tsx b/site/src/pages/UsersPage/UsersPageView.stories.tsx index d18d61500954e..b3791b28f5503 100644 --- a/site/src/pages/UsersPage/UsersPageView.stories.tsx +++ b/site/src/pages/UsersPage/UsersPageView.stories.tsx @@ -29,15 +29,27 @@ const meta: Meta = { title: "pages/UsersPage", component: UsersPageView, args: { - page: 1, - limit: 25, isNonInitialPage: false, users: [MockUser, MockUser2], roles: MockAssignableSiteRoles, - count: 2, canEditUsers: true, filterProps: defaultFilterProps, authMethods: MockAuthMethodsPasswordOnly, + paginationResult: { + isSuccess: true, + currentPage: 1, + limit: 25, + totalRecords: 2, + hasNextPage: false, + hasPreviousPage: false, + totalPages: 1, + currentChunk: 1, + isPreviousData: false, + goToFirstPage: () => {}, + goToPreviousPage: () => {}, + goToNextPage: () => {}, + onPageChange: () => {}, + }, }, }; diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 33345a6a0f1e5..dfd5979f6878f 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -4,8 +4,10 @@ import { type GroupsByUserId } from "api/queries/groups"; import { UsersTable } from "./UsersTable/UsersTable"; import { UsersFilter } from "./UsersFilter"; -import { Pagination } from "components/PaginationWidget/Pagination"; -import { UsePaginatedQueryResult } from "hooks/usePaginatedQuery"; +import { + Pagination, + type PaginationResult, +} from "components/PaginationWidget/Pagination"; export interface UsersPageViewProps { users?: TypesGen.User[]; @@ -30,7 +32,7 @@ export interface UsersPageViewProps { isNonInitialPage: boolean; actorID: string; groupsByUserId: GroupsByUserId | undefined; - paginationResult: UsePaginatedQueryResult; + paginationResult: PaginationResult; } export const UsersPageView: FC> = ({ From 71cd29ff768b33e6e699fc53f6dead3c99e80e3b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 01:32:44 +0000 Subject: [PATCH 81/91] fix: update renderComponent to support proper re-renders --- site/src/testHelpers/renderHelpers.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 9b1f0cb27ef3f..b9d3fcdde5ded 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -266,6 +266,8 @@ export const waitForLoaderToBeRemoved = async (): Promise => { ); }; -export const renderComponent = (component: React.ReactNode) => { - return tlRender({component}); +export const renderComponent = (component: React.ReactElement) => { + return tlRender(component, { + wrapper: ({ children }) => {children}, + }); }; From d0bd797268501f50f16d4d4a46542be01a7e8711 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 01:32:59 +0000 Subject: [PATCH 82/91] wip: commit current test progress --- .../PaginationWidget/Pagination.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 site/src/components/PaginationWidget/Pagination.test.tsx diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx new file mode 100644 index 0000000000000..5cd44dc5370cc --- /dev/null +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -0,0 +1,174 @@ +import { type ComponentProps, type HTMLAttributes } from "react"; +import { Pagination, type PaginationResult } from "./Pagination"; + +import { renderComponent } from "testHelpers/renderHelpers"; +import { waitFor } from "@testing-library/react"; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.clearAllMocks(); + jest.useRealTimers(); +}); + +type ResultBase = Omit< + PaginationResult, + "isPreviousData" | "currentChunk" | "totalRecords" | "totalPages" +>; + +const mockPaginationResult: ResultBase = { + isSuccess: false, + currentPage: 1, + limit: 25, + hasNextPage: false, + hasPreviousPage: false, + goToPreviousPage: () => {}, + goToNextPage: () => {}, + goToFirstPage: () => {}, + onPageChange: () => {}, +}; + +const initialRenderResult: PaginationResult = { + ...mockPaginationResult, + isSuccess: false, + isPreviousData: false, + currentChunk: undefined, + hasNextPage: false, + hasPreviousPage: false, + totalRecords: undefined, + totalPages: undefined, +}; + +const successResult: PaginationResult = { + ...mockPaginationResult, + isSuccess: true, + isPreviousData: false, + currentChunk: 1, + totalPages: 1, + totalRecords: 4, +}; + +type TestProps = Omit< + ComponentProps, + keyof HTMLAttributes +>; + +const mockUnitLabel = "ducks"; + +function render2(props: TestProps) { + return renderComponent(); +} + +/** + * Expected state transitions: + * + * 1. Initial render - isPreviousData is false, while currentPage can be any + * number (but will usually be 1) + * 1. Re-render from first-ever page loading in - currentPage stays the same, + * while isPreviousData stays false (data changes elsewhere in the app, + * though) + * 2. Re-render from user changing the page - currentPage becomes the new page, + * while isPreviousData depends on cache state + * 1. Change to page that's already been fetched - isPreviousData is false + * 2. Change to new page - isPreviousData is true during the transition + * 3. Re-render fetch for new page succeeding - currentPage stays the same, but + * isPreviousData flips from true to false + */ +describe(`${Pagination.name}`, () => { + describe("Initial render", () => { + it("Does absolutely nothing - no calls to any scrolls", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + + render2({ + paginationUnitLabel: mockUnitLabel, + paginationResult: initialRenderResult, + }); + + setTimeout(() => { + expect(mockScroll).not.toBeCalled(); + }, 5000); + + await jest.runAllTimersAsync(); + }); + }); + + describe("Responding to changes in isPreviousData (showing data for previous page while new page is loading)", () => { + // This should be impossible, but testing it just to be on the safe side + it("Does nothing when isPreviousData flips from false to true while currentPage stays the same", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + + const { rerender } = render2({ + paginationUnitLabel: mockUnitLabel, + paginationResult: initialRenderResult, + }); + + rerender( + , + ); + + setTimeout(() => { + expect(mockScroll).not.toBeCalled(); + }, 5000); + + await jest.runAllTimersAsync(); + }); + + it("Triggers scroll if scroll has been queued while waiting for isPreviousData to flip from true to false", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + + const { rerender } = render2({ + paginationUnitLabel: mockUnitLabel, + paginationResult: successResult, + }); + + setTimeout(() => { + expect(mockScroll).not.toBeCalled(); + }, 5000); + + await jest.runAllTimersAsync(); + + rerender( + , + ); + + rerender( + , + ); + + await waitFor(() => expect(mockScroll).toBeCalled()); + }); + + it.skip("Does nothing if scroll is canceled by the time isPreviousData flips from true to false", async () => { + expect.hasAssertions(); + }); + }); + + describe("Responding to page changes", () => { + it.skip("Triggers scroll immediately if data is cached", async () => { + expect.hasAssertions(); + }); + + it.skip("Queues up a scroll if new page data's needs to be fetched (cache miss)", async () => { + expect.hasAssertions(); + }); + }); +}); From 07097b160ac1175d6756866a2fd84a4e81bd3785 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 02:06:52 +0000 Subject: [PATCH 83/91] chore: finish Pagination tests --- .../PaginationWidget/Pagination.test.tsx | 126 +++++++++++++----- 1 file changed, 91 insertions(+), 35 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx index 5cd44dc5370cc..a00587da952b5 100644 --- a/site/src/components/PaginationWidget/Pagination.test.tsx +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -2,7 +2,7 @@ import { type ComponentProps, type HTMLAttributes } from "react"; import { Pagination, type PaginationResult } from "./Pagination"; import { renderComponent } from "testHelpers/renderHelpers"; -import { waitFor } from "@testing-library/react"; +import { fireEvent, waitFor } from "@testing-library/react"; beforeAll(() => { jest.useFakeTimers(); @@ -57,10 +57,29 @@ type TestProps = Omit< const mockUnitLabel = "ducks"; -function render2(props: TestProps) { +function render(props: TestProps) { return renderComponent(); } +function assertNoScroll(mockScroll: jest.SpyInstance) { + setTimeout(() => { + expect(mockScroll).not.toBeCalled(); + }, 5000); + + return jest.runAllTimersAsync(); +} + +async function mountWithSuccess(mockScroll: jest.SpyInstance) { + // eslint-disable-next-line testing-library/render-result-naming-convention -- Forced destructuring just makes this awkward + const result = render({ + paginationUnitLabel: mockUnitLabel, + paginationResult: successResult, + }); + + await assertNoScroll(mockScroll); + return result; +} + /** * Expected state transitions: * @@ -81,25 +100,59 @@ describe(`${Pagination.name}`, () => { it("Does absolutely nothing - no calls to any scrolls", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); - render2({ + render({ paginationUnitLabel: mockUnitLabel, paginationResult: initialRenderResult, }); - setTimeout(() => { - expect(mockScroll).not.toBeCalled(); - }, 5000); + await assertNoScroll(mockScroll); + }); + }); + + describe("Responding to page changes", () => { + it("Triggers scroll immediately if currentPage changes and isPreviousData is immediately false (previous query is cached)", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + const { rerender } = await mountWithSuccess(mockScroll); + + rerender( + , + ); + + await waitFor(() => expect(mockScroll).toBeCalled()); + }); + + it("Does nothing observable if page changes and isPreviousData is true (scroll will get queued, but will not be processed)", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + const { rerender } = await mountWithSuccess(mockScroll); + + rerender( + , + ); - await jest.runAllTimersAsync(); + await assertNoScroll(mockScroll); }); }); - describe("Responding to changes in isPreviousData (showing data for previous page while new page is loading)", () => { + describe("Responding to changes in React Query's isPreviousData", () => { // This should be impossible, but testing it just to be on the safe side it("Does nothing when isPreviousData flips from false to true while currentPage stays the same", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); - const { rerender } = render2({ + const { rerender } = render({ paginationUnitLabel: mockUnitLabel, paginationResult: initialRenderResult, }); @@ -111,26 +164,12 @@ describe(`${Pagination.name}`, () => { />, ); - setTimeout(() => { - expect(mockScroll).not.toBeCalled(); - }, 5000); - - await jest.runAllTimersAsync(); + await assertNoScroll(mockScroll); }); it("Triggers scroll if scroll has been queued while waiting for isPreviousData to flip from true to false", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); - - const { rerender } = render2({ - paginationUnitLabel: mockUnitLabel, - paginationResult: successResult, - }); - - setTimeout(() => { - expect(mockScroll).not.toBeCalled(); - }, 5000); - - await jest.runAllTimersAsync(); + const { rerender } = await mountWithSuccess(mockScroll); rerender( { await waitFor(() => expect(mockScroll).toBeCalled()); }); - it.skip("Does nothing if scroll is canceled by the time isPreviousData flips from true to false", async () => { - expect.hasAssertions(); - }); - }); + it("Does nothing if scroll is canceled by the time isPreviousData flips from true to false", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + const { rerender } = await mountWithSuccess(mockScroll); - describe("Responding to page changes", () => { - it.skip("Triggers scroll immediately if data is cached", async () => { - expect.hasAssertions(); - }); + rerender( + , + ); + + fireEvent.click(window); + + rerender( + , + ); - it.skip("Queues up a scroll if new page data's needs to be fetched (cache miss)", async () => { - expect.hasAssertions(); + await assertNoScroll(mockScroll); }); }); }); From 43cb93a0b942582c19b27d740139e94768c37467 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 02:19:24 +0000 Subject: [PATCH 84/91] fix: beef up test to check for every possible user event --- .../PaginationWidget/Pagination.test.tsx | 73 +++++++++++-------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx index a00587da952b5..16fa75c65d9dc 100644 --- a/site/src/components/PaginationWidget/Pagination.test.tsx +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -1,5 +1,5 @@ import { type ComponentProps, type HTMLAttributes } from "react"; -import { Pagination, type PaginationResult } from "./Pagination"; +import { type PaginationResult, Pagination } from "./Pagination"; import { renderComponent } from "testHelpers/renderHelpers"; import { fireEvent, waitFor } from "@testing-library/react"; @@ -97,7 +97,7 @@ async function mountWithSuccess(mockScroll: jest.SpyInstance) { */ describe(`${Pagination.name}`, () => { describe("Initial render", () => { - it("Does absolutely nothing - no calls to any scrolls", async () => { + it("Does absolutely nothing - should not scroll on component mount because that will violently hijack the user's browser", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); render({ @@ -148,8 +148,7 @@ describe(`${Pagination.name}`, () => { }); describe("Responding to changes in React Query's isPreviousData", () => { - // This should be impossible, but testing it just to be on the safe side - it("Does nothing when isPreviousData flips from false to true while currentPage stays the same", async () => { + it("Does nothing when isPreviousData flips from false to true while currentPage stays the same (safety net for 'impossible' case)", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); const { rerender } = render({ @@ -196,33 +195,49 @@ describe(`${Pagination.name}`, () => { await waitFor(() => expect(mockScroll).toBeCalled()); }); - it("Does nothing if scroll is canceled by the time isPreviousData flips from true to false", async () => { + it("Cancels a scroll if user interacts with the browser in any way before isPreviousData flips from true to false", async () => { const mockScroll = jest.spyOn(window, "scrollTo"); - const { rerender } = await mountWithSuccess(mockScroll); - - rerender( - , - ); - fireEvent.click(window); - - rerender( - , - ); + // Values are based on (keyof WindowEventMap), but frustratingly, the + // native events aren't camel-case, while the fireEvent properties are + const userInteractionEvents = [ + "click", + "scroll", + "pointerEnter", + "touchStart", + "keyDown", + ] as const; + + for (const event of userInteractionEvents) { + const { rerender, unmount } = await mountWithSuccess(mockScroll); + + rerender( + , + ); + + fireEvent.scroll; + fireEvent[event](window); + + rerender( + , + ); + + unmount(); + } await assertNoScroll(mockScroll); }); From 80c88bb5a058301135f8c2b4c91d2e2704c31ead Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 02:20:18 +0000 Subject: [PATCH 85/91] fix: remove stray line of code --- site/src/components/PaginationWidget/Pagination.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx index 16fa75c65d9dc..2b8e978fba029 100644 --- a/site/src/components/PaginationWidget/Pagination.test.tsx +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -222,7 +222,6 @@ describe(`${Pagination.name}`, () => { />, ); - fireEvent.scroll; fireEvent[event](window); rerender( From b4abf11cd2040d02895c38284940b40e82bee2bf Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 12:13:59 +0000 Subject: [PATCH 86/91] refactor: change timing for Pagination.test (just to improve assurity) --- site/src/components/PaginationWidget/Pagination.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx index 2b8e978fba029..adb79f5af72a3 100644 --- a/site/src/components/PaginationWidget/Pagination.test.tsx +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -235,10 +235,9 @@ describe(`${Pagination.name}`, () => { />, ); + await assertNoScroll(mockScroll); unmount(); } - - await assertNoScroll(mockScroll); }); }); }); From 983915b8cd570e53af8d92b5d319fd3d3e0593f4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 12:26:25 +0000 Subject: [PATCH 87/91] fix: update logic for calculating disabled statuses --- .../PaginationWidgetBase.test.tsx | 5 ++++- .../PaginationWidget/PaginationWidgetBase.tsx | 17 ++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.test.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.test.tsx index 4b487ea79ee9d..3cb5fa2ec6ea4 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.test.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.test.tsx @@ -74,12 +74,15 @@ describe(PaginationWidgetBase.name, () => { expect(prevButton).not.toBeDisabled(); expect(prevButton).toHaveAttribute("aria-disabled", "false"); + await userEvent.click(prevButton); + expect(onPageChange).toHaveBeenCalledTimes(1); + expect(nextButton).not.toBeDisabled(); expect(nextButton).toHaveAttribute("aria-disabled", "false"); - await userEvent.click(prevButton); await userEvent.click(nextButton); expect(onPageChange).toHaveBeenCalledTimes(2); + unmount(); } }); diff --git a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx index 945c5e80d82e0..da431fb8b1367 100644 --- a/site/src/components/PaginationWidget/PaginationWidgetBase.tsx +++ b/site/src/components/PaginationWidget/PaginationWidgetBase.tsx @@ -33,12 +33,11 @@ export const PaginationWidgetBase = ({ return null; } - // Ugly stopgap hack to make sure that the PaginationBase can be used for both - // the old and new pagination implementations while the transition is - // happening - without breaking existing Storybook tests const currentPageOffset = (currentPage - 1) * pageSize; - hasPreviousPage ??= currentPage > 1; - hasNextPage ??= pageSize + currentPageOffset < totalRecords; + const isPrevDisabled = !(hasPreviousPage ?? currentPage > 1); + const isNextDisabled = !( + hasNextPage ?? pageSize + currentPageOffset < totalRecords + ); return (
{ - if (hasPreviousPage) { + if (!isPrevDisabled) { onPageChange(currentPage - 1); } }} @@ -80,10 +79,10 @@ export const PaginationWidgetBase = ({ { - if (hasNextPage) { + if (!isNextDisabled) { onPageChange(currentPage + 1); } }} From beccc68a1657dba936a2e3deaa4700228acd0a10 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 12:36:24 +0000 Subject: [PATCH 88/91] refactor: split off PaginationHeader into separate file --- .../PaginationWidget/Pagination.tsx | 52 +---------------- .../PaginationWidget/PaginationHeader.tsx | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 51 deletions(-) create mode 100644 site/src/components/PaginationWidget/PaginationHeader.tsx diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx index 921017c564678..852079a4bb607 100644 --- a/site/src/components/PaginationWidget/Pagination.tsx +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -8,12 +8,10 @@ import { useRef, } from "react"; -import { useTheme } from "@emotion/react"; import { useEffectEvent } from "hooks/hookPolyfills"; import { type PaginationResultInfo } from "hooks/usePaginatedQuery"; - import { PaginationWidgetBase } from "./PaginationWidgetBase"; -import Skeleton from "@mui/material/Skeleton"; +import { PaginationHeader } from "./PaginationHeader"; export type PaginationResult = PaginationResultInfo & { isPreviousData: boolean; @@ -75,54 +73,6 @@ export const Pagination: FC = ({ ); }; -type PaginationHeaderProps = { - paginationResult: PaginationResult; - paginationUnitLabel: string; -}; - -const PaginationHeader: FC = ({ - paginationResult, - paginationUnitLabel, -}) => { - const theme = useTheme(); - const endBound = Math.min( - paginationResult.limit - 1, - (paginationResult.totalRecords ?? 0) - (paginationResult.currentChunk ?? 0), - ); - - return ( -
- {!paginationResult.isSuccess ? ( - - ) : ( -
- Showing {paginationUnitLabel}{" "} - - {paginationResult.currentChunk}– - {paginationResult.currentChunk + endBound} - {" "} - ({paginationResult.totalRecords}{" "} - {paginationUnitLabel} total) -
- )} -
- ); -}; - // Events to listen to for canceling queued scrolls const userInteractionEvents: (keyof WindowEventMap)[] = [ "click", diff --git a/site/src/components/PaginationWidget/PaginationHeader.tsx b/site/src/components/PaginationWidget/PaginationHeader.tsx new file mode 100644 index 0000000000000..ab8a8a5a267d9 --- /dev/null +++ b/site/src/components/PaginationWidget/PaginationHeader.tsx @@ -0,0 +1,57 @@ +import { type FC } from "react"; +import { useTheme } from "@emotion/react"; +import { type PaginationResult } from "./Pagination"; +import Skeleton from "@mui/material/Skeleton"; + +type PaginationHeaderProps = { + paginationResult: PaginationResult; + paginationUnitLabel: string; +}; + +export const PaginationHeader: FC = ({ + paginationResult, + paginationUnitLabel, +}) => { + const theme = useTheme(); + + // Need slightly more involved math to account for not having enough data to + // fill out entire page + const endBound = Math.min( + paginationResult.limit - 1, + (paginationResult.totalRecords ?? 0) - (paginationResult.currentChunk ?? 0), + ); + + return ( +
+ {!paginationResult.isSuccess ? ( + + ) : ( + // This can't be a React fragment because flexbox will rearrange each + // text node, not the whole thing +
+ Showing {paginationUnitLabel}{" "} + + {paginationResult.currentChunk}– + {paginationResult.currentChunk + endBound} + {" "} + ({paginationResult.totalRecords}{" "} + {paginationUnitLabel} total) +
+ )} +
+ ); +}; From 982a8c4e654d0cad4fabfddd35e0ea91209158ab Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 12:55:11 +0000 Subject: [PATCH 89/91] fix: switch AuditPage to use Pagination component --- .../PaginationWidget/PaginationHeader.tsx | 2 +- site/src/pages/AuditPage/AuditPage.tsx | 13 +- site/src/pages/AuditPage/AuditPageView.tsx | 141 ++++++++---------- 3 files changed, 70 insertions(+), 86 deletions(-) diff --git a/site/src/components/PaginationWidget/PaginationHeader.tsx b/site/src/components/PaginationWidget/PaginationHeader.tsx index ab8a8a5a267d9..488f9a62267e5 100644 --- a/site/src/components/PaginationWidget/PaginationHeader.tsx +++ b/site/src/components/PaginationWidget/PaginationHeader.tsx @@ -48,7 +48,7 @@ export const PaginationHeader: FC = ({ {paginationResult.currentChunk}– {paginationResult.currentChunk + endBound} {" "} - ({paginationResult.totalRecords}{" "} + ({paginationResult.totalRecords.toLocaleString()}{" "} {paginationUnitLabel} total)
)} diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 63674b071629c..7296291a0ce18 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -21,10 +21,10 @@ const AuditPage: FC = () => { * @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 searchParamsResult = useSearchParams(); + const auditsQuery = usePaginatedQuery(paginatedAudits(searchParamsResult[0])); const filter = useFilter({ - searchParamsResult: [searchParams, setSearchParams], + searchParamsResult: searchParamsResult, onUpdate: auditsQuery.goToFirstPage, }); @@ -63,12 +63,9 @@ const AuditPage: FC = () => { void; isNonInitialPage: boolean; isAuditLogVisible: boolean; error?: unknown; filterProps: ComponentProps; + paginationResult: PaginationResult; } export const AuditPageView: FC = ({ auditLogs, - count, - page, - limit, - onPageChange, isNonInitialPage, isAuditLogVisible, error, filterProps, + paginationResult, }) => { - const isLoading = (auditLogs === undefined || count === undefined) && !error; + const isLoading = + (auditLogs === undefined || paginationResult.totalRecords === undefined) && + !error; + const isEmpty = !isLoading && auditLogs?.length === 0; return ( @@ -73,72 +70,62 @@ export const AuditPageView: FC = ({ - - - + + + + + + {/* Error condition should just show an empty table. */} + + + + + + + + + + + - -
- - - {/* Error condition should just show an empty table. */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {auditLogs && ( - new Date(log.time)} - row={(log) => ( - - )} - /> - )} - - - -
-
+ + + + + + + + + + + + + + + + + + - {count !== undefined && ( - - )} + + {auditLogs && ( + new Date(log.time)} + row={(log) => ( + + )} + /> + )} + + + + + +
From 73c24a0542bb6853e7a9cb104b7f41e8e8360d28 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 13:05:11 +0000 Subject: [PATCH 90/91] fix: update stories for audit page --- .../pages/AuditPage/AuditPageView.stories.tsx | 18 +++++++++++++++--- site/src/pages/AuditPage/AuditPageView.tsx | 2 ++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AuditPage/AuditPageView.stories.tsx b/site/src/pages/AuditPage/AuditPageView.stories.tsx index 46facbf492746..e58a024fdd853 100644 --- a/site/src/pages/AuditPage/AuditPageView.stories.tsx +++ b/site/src/pages/AuditPage/AuditPageView.stories.tsx @@ -28,11 +28,23 @@ const meta: Meta = { component: AuditPageView, args: { auditLogs: [MockAuditLog, MockAuditLog2], - count: 1000, - page: 1, - limit: 25, isAuditLogVisible: true, filterProps: defaultFilterProps, + paginationResult: { + isSuccess: true, + currentPage: 1, + limit: 25, + totalRecords: 1000, + hasNextPage: false, + hasPreviousPage: false, + totalPages: 40, + currentChunk: 1, + isPreviousData: false, + goToFirstPage: () => {}, + goToPreviousPage: () => {}, + goToNextPage: () => {}, + onPageChange: () => {}, + }, }, }; diff --git a/site/src/pages/AuditPage/AuditPageView.tsx b/site/src/pages/AuditPage/AuditPageView.tsx index 83ef0fcaa4560..a7dd9a15c6187 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -63,6 +63,7 @@ export const AuditPageView: FC = ({ + {Language.subtitle} @@ -100,6 +101,7 @@ export const AuditPageView: FC = ({ + From a5fc929720c51b5fbda3a837c7762f480b7066b8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 Nov 2023 13:08:14 +0000 Subject: [PATCH 91/91] fix: update vertical spacing for workspaces table --- .../pages/WorkspacesPage/WorkspacesPageView.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 82a28ce0b2e3a..94b9cd90ba53b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -207,12 +207,14 @@ export const WorkspacesPageView = ({ /> {count !== undefined && ( - +
+ +
)} );