diff --git a/.vscode/settings.json b/.vscode/settings.json index 890e561520ff7..bcbdb7baeb9fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "coderdenttest", "coderdtest", "codersdk", + "contravariance", "cronstrue", "databasefake", "dbmem", diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts new file mode 100644 index 0000000000000..c0a758619f32b --- /dev/null +++ b/site/src/api/queries/audits.ts @@ -0,0 +1,23 @@ +import { getAuditLogs } from "api/api"; +import { type AuditLogResponse } from "api/typesGenerated"; +import { useFilterParamsKey } from "components/Filter/filter"; +import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; + +export function paginatedAudits(searchParams: URLSearchParams) { + return { + searchParams, + queryPayload: () => searchParams.get(useFilterParamsKey) ?? "", + queryKey: ({ payload, pageNumber }) => { + return ["auditLogs", payload, pageNumber] as const; + }, + queryFn: ({ payload, limit, offset }) => { + return getAuditLogs({ + offset, + limit, + q: payload, + }); + }, + + cacheTime: 5 * 1000 * 60, + } as const satisfies UsePaginatedQueryOptions; +} diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 2b6900df13ac8..2d490df2de38e 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -1,6 +1,6 @@ import { QueryClient, type UseQueryOptions } from "react-query"; import * as API from "api/api"; -import { +import type { AuthorizationRequest, GetUsersResponse, UpdateUserPasswordRequest, @@ -10,11 +10,34 @@ import { } from "api/typesGenerated"; import { getAuthorizationKey } from "./authCheck"; import { getMetadataAsJSON } from "utils/metadata"; +import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; +import { prepareQuery } from "utils/filters"; + +export function usersKey(req: UsersRequest) { + return ["users", req] as const; +} + +export function paginatedUsers() { + return { + queryPayload: ({ limit, offset, searchParams }) => { + return { + limit, + offset, + q: prepareQuery(searchParams.get("filter") ?? ""), + }; + }, + + queryKey: ({ payload }) => usersKey(payload), + queryFn: ({ payload, signal }) => API.getUsers(payload, signal), + cacheTime: 5 * 1000 * 60, + } as const satisfies UsePaginatedQueryOptions; +} export const users = (req: UsersRequest): UseQueryOptions => { return { - queryKey: ["users", req], + queryKey: usersKey(req), queryFn: ({ signal }) => API.getUsers(req, signal), + cacheTime: 5 * 1000 * 60, }; }; diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 1824554721b13..e0cee67d7241d 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -45,7 +45,7 @@ type UseFilterConfig = { onUpdate?: (newValue: string) => void; }; -const useFilterParamsKey = "filter"; +export const useFilterParamsKey = "filter"; export const useFilter = ({ fallbackFilter = "", diff --git a/site/src/components/PaginationWidget/Pagination.test.tsx b/site/src/components/PaginationWidget/Pagination.test.tsx new file mode 100644 index 0000000000000..adb79f5af72a3 --- /dev/null +++ b/site/src/components/PaginationWidget/Pagination.test.tsx @@ -0,0 +1,243 @@ +import { type ComponentProps, type HTMLAttributes } from "react"; +import { type PaginationResult, Pagination } from "./Pagination"; + +import { renderComponent } from "testHelpers/renderHelpers"; +import { fireEvent, 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 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: + * + * 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 - should not scroll on component mount because that will violently hijack the user's browser", async () => { + const mockScroll = jest.spyOn(window, "scrollTo"); + + render({ + paginationUnitLabel: mockUnitLabel, + paginationResult: initialRenderResult, + }); + + 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 assertNoScroll(mockScroll); + }); + }); + + describe("Responding to changes in React Query's isPreviousData", () => { + 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({ + paginationUnitLabel: mockUnitLabel, + paginationResult: initialRenderResult, + }); + + rerender( + , + ); + + 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 } = await mountWithSuccess(mockScroll); + + rerender( + , + ); + + rerender( + , + ); + + await waitFor(() => expect(mockScroll).toBeCalled()); + }); + + 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"); + + // 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[event](window); + + rerender( + , + ); + + await assertNoScroll(mockScroll); + unmount(); + } + }); + }); +}); diff --git a/site/src/components/PaginationWidget/Pagination.tsx b/site/src/components/PaginationWidget/Pagination.tsx new file mode 100644 index 0000000000000..852079a4bb607 --- /dev/null +++ b/site/src/components/PaginationWidget/Pagination.tsx @@ -0,0 +1,212 @@ +import { + type FC, + type HTMLAttributes, + type MouseEvent as ReactMouseEvent, + type KeyboardEvent as ReactKeyboardEvent, + useEffect, + useLayoutEffect, + useRef, +} from "react"; + +import { useEffectEvent } from "hooks/hookPolyfills"; +import { type PaginationResultInfo } from "hooks/usePaginatedQuery"; +import { PaginationWidgetBase } from "./PaginationWidgetBase"; +import { PaginationHeader } from "./PaginationHeader"; + +export type PaginationResult = PaginationResultInfo & { + isPreviousData: boolean; +}; + +type PaginationProps = HTMLAttributes & { + paginationResult: PaginationResult; + paginationUnitLabel: string; + + /** + * Mainly here to simplify Storybook integrations. This should almost always + * be true in production + */ + autoScroll?: boolean; +}; + +export const Pagination: FC = ({ + children, + paginationResult, + paginationUnitLabel, + autoScroll = true, + ...delegatedProps +}) => { + const scrollContainerProps = useScrollOnPageChange( + paginationResult.currentPage, + paginationResult.isPreviousData, + autoScroll, + ); + + return ( +
+ + +
+ {children} + + {paginationResult.isSuccess && ( + + )} +
+
+ ); +}; + +// 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 + * 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 Pagination 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 interaction + useEffect(() => { + if (!autoScroll) { + return; + } + + const cancelScroll = () => { + isScrollingQueuedRef.current = false; + }; + + for (const event of userInteractionEvents) { + window.addEventListener(event, cancelScroll); + } + + return () => { + for (const event of userInteractionEvents) { + window.removeEventListener(event, cancelScroll); + } + }; + }, [autoScroll]); + + const scrollToTop = useEffectEvent(() => { + const newVerticalPosition = + (scrollContainerRef.current?.getBoundingClientRect().top ?? 0) + + 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; + }); + + // 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) { + isOnFirstRenderRef.current = false; + return; + } + + if (showingPreviousData) { + isScrollingQueuedRef.current = true; + } else { + scrollToTop(); + } + }); + + // 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]); + + useLayoutEffect(() => { + if (!showingPreviousData && isScrollingQueuedRef.current) { + scrollToTop(); + } + }, [scrollToTop, showingPreviousData]); + + /** + * 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 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 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, + ) => { + 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; +} diff --git a/site/src/components/PaginationWidget/PaginationHeader.tsx b/site/src/components/PaginationWidget/PaginationHeader.tsx new file mode 100644 index 0000000000000..488f9a62267e5 --- /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.toLocaleString()}{" "} + {paginationUnitLabel} total) +
+ )} +
+ ); +}; 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 9eb99e097216d..da431fb8b1367 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,11 @@ export const PaginationWidgetBase = ({ return null; } - const onFirstPage = currentPage <= 1; - const onLastPage = currentPage >= totalPages; + const currentPageOffset = (currentPage - 1) * pageSize; + const isPrevDisabled = !(hasPreviousPage ?? currentPage > 1); + const isNextDisabled = !( + hasNextPage ?? pageSize + currentPageOffset < totalRecords + ); return (
{ - if (!onFirstPage) { + if (!isPrevDisabled) { onPageChange(currentPage - 1); } }} @@ -70,11 +78,11 @@ export const PaginationWidgetBase = ({ )} { - if (!onLastPage) { + if (!isNextDisabled) { onPageChange(currentPage + 1); } }} 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]); diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts new file mode 100644 index 0000000000000..0ccf5574a13b4 --- /dev/null +++ b/site/src/hooks/usePaginatedQuery.test.ts @@ -0,0 +1,407 @@ +import { renderHookWithAuth } from "testHelpers/renderHelpers"; +import { waitFor } from "@testing-library/react"; + +import { + type PaginatedData, + type UsePaginatedQueryOptions, + usePaginatedQuery, +} from "./usePaginatedQuery"; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +function render< + TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, +>( + options: UsePaginatedQueryOptions, + route?: `/?page=${string}`, +) { + return renderHookWithAuth(({ options }) => usePaginatedQuery(options), { + route, + path: "/", + initialProps: { options }, + }); +} + +/** + * There are a lot of test cases in this file. Scoping mocking to inner describe + * function calls to limit the cognitive load of maintaining all this stuff + */ +describe(`${usePaginatedQuery.name} - Overall functionality`, () => { + describe("queryPayload method", () => { + const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 })); + + it("Passes along an undefined payload if queryPayload is not used", async () => { + const mockQueryKey = jest.fn(() => ["mockQuery"]); + + await render({ + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + const payloadValueMock = expect.objectContaining({ + payload: undefined, + }); + + expect(mockQueryKey).toHaveBeenCalledWith(payloadValueMock); + expect(mockQueryFn).toHaveBeenCalledWith(payloadValueMock); + }); + + it("Passes along type-safe payload if queryPayload is provided", async () => { + const mockQueryKey = jest.fn(({ payload }) => { + return ["mockQuery", payload]; + }); + + const testPayloadValues = [1, "Blah", { cool: true }]; + for (const payload of testPayloadValues) { + const { unmount } = await render({ + queryPayload: () => payload, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + const matcher = expect.objectContaining({ payload }); + expect(mockQueryKey).toHaveBeenCalledWith(matcher); + expect(mockQueryFn).toHaveBeenCalledWith(matcher); + unmount(); + } + }); + }); + + describe("Querying for current page", () => { + const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(() => Promise.resolve({ count: 50 })); + + it("Parses page number if it exists in URL params", async () => { + const pageNumbers = [1, 2, 7, 39, 743]; + + for (const num of pageNumbers) { + const { result, unmount } = await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${num}`, + ); + + expect(result.current.currentPage).toBe(num); + unmount(); + } + }); + + it("Defaults to page 1 if no page value can be parsed from params", async () => { + const { result } = await render({ + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + expect(result.current.currentPage).toBe(1); + }); + }); + + describe("Prefetching", () => { + const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]); + + type Context = { pageNumber: number; limit: number }; + const mockQueryFnImplementation = ({ pageNumber, limit }: Context) => { + const data: { value: number }[] = []; + if (pageNumber * limit < 75) { + for (let i = 0; i < limit; i++) { + data.push({ value: i }); + } + } + + return Promise.resolve({ data, count: 75 }); + }; + + const testPrefetch = async ( + startingPage: number, + targetPage: number, + shouldMatch: boolean, + ) => { + // Have to reinitialize mock function every call to avoid false positives + // from shared mutable tracking state + const mockQueryFn = jest.fn(mockQueryFnImplementation); + const { result } = await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startingPage}`, + ); + + const pageMatcher = expect.objectContaining({ pageNumber: targetPage }); + if (shouldMatch) { + await waitFor(() => expect(result.current.totalRecords).toBeDefined()); + await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher)); + } else { + // Can't use waitFor to test this, because the expect call will + // immediately succeed for the not case, even though queryFn needs to be + // called async via React Query + setTimeout(() => { + expect(mockQueryFn).not.toBeCalledWith(pageMatcher); + }, 1000); + + jest.runAllTimers(); + } + }; + + it("Prefetches the previous page if it exists", async () => { + await testPrefetch(2, 1, true); + }); + + it("Prefetches the next page if it exists", async () => { + await testPrefetch(2, 3, true); + }); + + it("Avoids prefetch for previous page if it doesn't exist", async () => { + await testPrefetch(1, 0, false); + await testPrefetch(6, 5, false); + }); + + it("Avoids prefetch for next page if it doesn't exist", async () => { + await testPrefetch(3, 4, false); + }); + + it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => { + const startPage = 2; + const mockQueryFn = jest.fn(mockQueryFnImplementation); + + await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + `/?page=${startPage}`, + ); + + const currentMatcher = expect.objectContaining({ pageNumber: startPage }); + expect(mockQueryKey).toBeCalledWith(currentMatcher); + expect(mockQueryFn).toBeCalledWith(currentMatcher); + + const prevPageMatcher = expect.objectContaining({ + pageNumber: startPage - 1, + }); + await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher)); + await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher)); + + const nextPageMatcher = expect.objectContaining({ + pageNumber: startPage + 1, + }); + await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher)); + await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher)); + }); + }); + + describe("Safety nets/redirects for invalid pages", () => { + const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => + Promise.resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, + }), + ); + + it("No custom callback: synchronously defaults to page 1 if params are corrupt/invalid", async () => { + const { result } = await render( + { + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }, + "/?page=Cat", + ); + + expect(result.current.currentPage).toBe(1); + }); + + it("No custom callback: auto-redirects user to last page if requested page overshoots total pages", async () => { + const { result } = await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + "/?page=35", + ); + + await waitFor(() => expect(result.current.currentPage).toBe(4)); + }); + + it("No custom callback: auto-redirects user to first page if requested page goes below 1", async () => { + const { result } = await render( + { queryKey: mockQueryKey, queryFn: mockQueryFn }, + "/?page=-9999", + ); + + await waitFor(() => expect(result.current.currentPage).toBe(1)); + }); + + it("With custom callback: Calls callback and does not update search params automatically", async () => { + const testControl = new URLSearchParams({ + page: "1000", + }); + + const onInvalidPageChange = jest.fn(); + await render({ + onInvalidPageChange, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + searchParams: testControl, + }); + + await waitFor(() => { + expect(onInvalidPageChange).toBeCalledWith( + expect.objectContaining({ + pageNumber: expect.any(Number), + limit: expect.any(Number), + offset: expect.any(Number), + totalPages: expect.any(Number), + searchParams: expect.any(URLSearchParams), + setSearchParams: expect.any(Function), + }), + ); + }); + + expect(testControl.get("page")).toBe("1000"); + }); + }); + + describe("Passing in searchParams property", () => { + const mockQueryKey = jest.fn(() => ["mock"]); + const mockQueryFn = jest.fn(({ pageNumber, limit }) => + Promise.resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, + }), + ); + + it("Reads from searchParams property if provided", async () => { + const searchParams = new URLSearchParams({ + page: "2", + }); + + const { result } = await render({ + searchParams, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + expect(result.current.currentPage).toBe(2); + }); + + it("Flushes state changes via provided searchParams property instead of internal searchParams", async () => { + const searchParams = new URLSearchParams({ + page: "2", + }); + + const { result } = await render({ + searchParams, + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + result.current.goToFirstPage(); + expect(searchParams.get("page")).toBe("1"); + }); + }); +}); + +describe(`${usePaginatedQuery.name} - Returned properties`, () => { + describe("Page change methods", () => { + const mockQueryKey = jest.fn(() => ["mock"]); + + const mockQueryFn = jest.fn(({ pageNumber, limit }) => { + type Data = PaginatedData & { data: readonly number[] }; + + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: new Array(limit).fill(pageNumber), + count: 100, + }); + }, 10_000); + }); + }); + + test("goToFirstPage always succeeds regardless of fetch status", async () => { + const queryFns = [mockQueryFn, jest.fn(() => Promise.reject("Too bad"))]; + + for (const queryFn of queryFns) { + const { result, unmount } = await render( + { queryFn, queryKey: mockQueryKey }, + "/?page=5", + ); + + expect(result.current.currentPage).toBe(5); + result.current.goToFirstPage(); + await waitFor(() => expect(result.current.currentPage).toBe(1)); + unmount(); + } + }); + + test("goToNextPage works only if hasNextPage is true", async () => { + const { result } = await render( + { + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }, + "/?page=1", + ); + + expect(result.current.hasNextPage).toBe(false); + result.current.goToNextPage(); + expect(result.current.currentPage).toBe(1); + + await jest.runAllTimersAsync(); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + result.current.goToNextPage(); + await waitFor(() => expect(result.current.currentPage).toBe(2)); + }); + + test("goToPreviousPage works only if hasPreviousPage is true", async () => { + const { result } = await render( + { + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }, + "/?page=3", + ); + + expect(result.current.hasPreviousPage).toBe(false); + result.current.goToPreviousPage(); + expect(result.current.currentPage).toBe(3); + + await jest.runAllTimersAsync(); + await waitFor(() => expect(result.current.hasPreviousPage).toBe(true)); + result.current.goToPreviousPage(); + await waitFor(() => expect(result.current.currentPage).toBe(2)); + }); + + test("onPageChange accounts for floats and truncates numeric values before navigating", async () => { + const { result } = await render({ + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + await jest.runAllTimersAsync(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + result.current.onPageChange(2.5); + + await waitFor(() => expect(result.current.currentPage).toBe(2)); + }); + + test("onPageChange rejects impossible numeric values and does nothing", async () => { + const { result } = await render({ + queryKey: mockQueryKey, + queryFn: mockQueryFn, + }); + + await jest.runAllTimersAsync(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + result.current.onPageChange(NaN); + result.current.onPageChange(Infinity); + result.current.onPageChange(-Infinity); + + setTimeout(() => { + expect(result.current.currentPage).toBe(1); + }, 1000); + + jest.runAllTimers(); + }); + }); +}); diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts new file mode 100644 index 0000000000000..6d8ce68c629a7 --- /dev/null +++ b/site/src/hooks/usePaginatedQuery.ts @@ -0,0 +1,447 @@ +import { useEffect } from "react"; +import { useEffectEvent } from "./hookPolyfills"; +import { type SetURLSearchParams, useSearchParams } from "react-router-dom"; +import { clamp } from "lodash"; + +import { + type QueryFunctionContext, + type QueryKey, + type UseQueryOptions, + type UseQueryResult, + useQueryClient, + useQuery, +} from "react-query"; + +const DEFAULT_RECORDS_PER_PAGE = 25; + +/** + * The key to use for getting/setting the page number from the search params + */ +const PAGE_NUMBER_PARAMS_KEY = "page"; + +/** + * A more specialized version of UseQueryOptions built specifically for + * paginated queries. + */ +export type UsePaginatedQueryOptions< + // Aside from TQueryPayload, all type parameters come from the base React + // Query type definition, and are here for compatibility + TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = BasePaginationOptions & + QueryPayloadExtender & { + /** + * An optional dependency for React Router's URLSearchParams. If this is + * provided, all URL state changes will go through this object instead of + * an internal value. + */ + searchParams?: URLSearchParams; + + /** + * A function that takes pagination information and produces a full query + * key. + * + * Must be a function so that it can be used for the active query, and then + * reused for any prefetching queries (swapping the page number out). + */ + queryKey: (params: QueryPageParamsWithPayload) => TQueryKey; + + /** + * A version of queryFn that is required and that exposes the pagination + * information through its query function context argument + */ + 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 when an invalid page is + * encountered, usePaginatedQuery will default to navigating the user to the + * closest valid page. + */ + onInvalidPageChange?: (params: InvalidPageParams) => void; + }; + +/** + * The result of calling usePaginatedQuery. Mirrors the result of the base + * useQuery as closely as possible, while adding extra pagination properties + */ +export type UsePaginatedQueryResult< + TData = unknown, + TError = unknown, +> = UseQueryResult & PaginationResultInfo; + +export function usePaginatedQuery< + TQueryFnData extends PaginatedData = PaginatedData, + TQueryPayload = never, + TError = unknown, + TData extends PaginatedData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: UsePaginatedQueryOptions< + TQueryFnData, + TQueryPayload, + TError, + TData, + TQueryKey + >, +): UsePaginatedQueryResult { + const { + queryKey, + queryPayload, + onInvalidPageChange, + searchParams: outerSearchParams, + queryFn: outerQueryFn, + ...extraOptions + } = options; + + const [innerSearchParams, setSearchParams] = useSearchParams(); + const searchParams = outerSearchParams ?? innerSearchParams; + + const limit = DEFAULT_RECORDS_PER_PAGE; + const currentPage = parsePage(searchParams); + const currentPageOffset = (currentPage - 1) * limit; + + const getQueryOptionsFromPage = (pageNumber: number) => { + const pageParams: QueryPageParams = { + pageNumber, + limit, + offset: (pageNumber - 1) * limit, + searchParams: getParamsWithoutPage(searchParams), + }; + + const payload = queryPayload?.(pageParams) as RuntimePayload; + + return { + queryKey: queryKey({ ...pageParams, payload }), + queryFn: (context: QueryFunctionContext) => { + return outerQueryFn({ ...context, ...pageParams, payload }); + }, + } as const; + }; + + // Not using infinite query right now because that requires a fair bit of list + // virtualization as the lists get bigger (especially for the audit logs). + // Keeping initial implementation simple. + const query = useQuery({ + ...extraOptions, + ...getQueryOptionsFromPage(currentPage), + keepPreviousData: true, + }); + + const totalRecords = query.data?.count; + const totalPages = + totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined; + + const hasNextPage = + totalRecords !== undefined && limit + currentPageOffset < totalRecords; + const hasPreviousPage = + totalRecords !== undefined && + currentPage > 1 && + currentPageOffset - limit < totalRecords; + + const queryClient = useQueryClient(); + const prefetchPage = useEffectEvent((newPage: number) => { + const options = getQueryOptionsFromPage(newPage); + return queryClient.prefetchQuery(options); + }); + + // Have to split hairs and sync on both the current page and the hasXPage + // variables, because the page can change immediately client-side, but the + // hasXPage values are derived from the server and won't always be immediately + // ready on the initial render + useEffect(() => { + if (hasNextPage) { + void prefetchPage(currentPage + 1); + } + }, [prefetchPage, currentPage, hasNextPage]); + + useEffect(() => { + if (hasPreviousPage) { + void prefetchPage(currentPage - 1); + } + }, [prefetchPage, currentPage, hasPreviousPage]); + + // Mainly here to catch user if they navigate to a page directly via URL; + // totalPages parameterized to insulate function from fetch status changes + const updatePageIfInvalid = useEffectEvent(async (totalPages: number) => { + // If totalPages is 0, that's a sign that the currentPage overshot, and the + // API returned a count of 0 because it didn't know how to process the query + let fixedTotalPages: number; + if (totalPages !== 0) { + fixedTotalPages = totalPages; + } else { + const firstPageOptions = getQueryOptionsFromPage(1); + try { + const firstPageResult = await queryClient.fetchQuery(firstPageOptions); + fixedTotalPages = Math.ceil(firstPageResult?.count ?? 0 / limit) || 1; + } catch (err) { + fixedTotalPages = 1; + } + } + + const clamped = clamp(currentPage, 1, fixedTotalPages); + if (currentPage === clamped) { + return; + } + + const withoutPage = getParamsWithoutPage(searchParams); + if (onInvalidPageChange === undefined) { + withoutPage.set(PAGE_NUMBER_PARAMS_KEY, String(clamped)); + setSearchParams(withoutPage); + } else { + const params: InvalidPageParams = { + limit, + setSearchParams, + offset: currentPageOffset, + searchParams: withoutPage, + totalPages: fixedTotalPages, + pageNumber: currentPage, + }; + + onInvalidPageChange(params); + } + }); + + useEffect(() => { + if (!query.isFetching && totalPages !== undefined) { + void updatePageIfInvalid(totalPages); + } + }, [updatePageIfInvalid, query.isFetching, totalPages]); + + const onPageChange = (newPage: number) => { + // Page 1 is the only page that can be safely navigated to without knowing + // totalPages; no reliance on server data for math calculations + if (totalPages === undefined && newPage !== 1) { + return; + } + + const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages ?? 1); + if (!Number.isInteger(cleanedInput) || cleanedInput <= 0) { + return; + } + + searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput)); + setSearchParams(searchParams); + }; + + // Have to do a type assertion for final return type to make React Query's + // internal types happy; splitting type definitions up to limit risk of the + // type assertion silencing type warnings we actually want to pay attention to + const info: PaginationResultInfo = { + limit, + currentPage, + onPageChange, + goToFirstPage: () => onPageChange(1), + + goToPreviousPage: () => { + if (hasPreviousPage) { + onPageChange(currentPage - 1); + } + }, + + goToNextPage: () => { + if (hasNextPage) { + onPageChange(currentPage + 1); + } + }, + + ...(query.isSuccess + ? { + isSuccess: true, + hasNextPage, + hasPreviousPage, + totalRecords: totalRecords as number, + totalPages: totalPages as number, + currentChunk: currentPageOffset + 1, + } + : { + isSuccess: false, + hasNextPage: false, + hasPreviousPage: false, + totalRecords: undefined, + totalPages: undefined, + currentChunk: undefined, + }), + }; + + return { ...query, ...info } as UsePaginatedQueryResult; +} + +function parsePage(params: URLSearchParams): number { + const parsed = Number(params.get("page")); + return Number.isInteger(parsed) && parsed > 1 ? parsed : 1; +} + +/** + * Strips out the page number from a query so that there aren't mismatches + * between it and usePaginatedQuery's currentPage property (especially for + * prefetching) + */ +function getParamsWithoutPage(params: URLSearchParams): URLSearchParams { + const withoutPage = new URLSearchParams(params); + withoutPage.delete(PAGE_NUMBER_PARAMS_KEY); + return withoutPage; +} + +/** + * All the pagination-properties for UsePaginatedQueryResult. Split up so that + * the types can be used separately in multiple spots. + */ +export type PaginationResultInfo = { + currentPage: number; + limit: number; + onPageChange: (newPage: number) => void; + goToPreviousPage: () => void; + goToNextPage: () => void; + goToFirstPage: () => void; +} & ( + | { + isSuccess: false; + hasNextPage: false; + hasPreviousPage: false; + totalRecords: undefined; + totalPages: undefined; + currentChunk: undefined; + } + | { + isSuccess: true; + hasNextPage: boolean; + hasPreviousPage: boolean; + totalRecords: number; + totalPages: number; + currentChunk: number; + } +); + +/** + * Papers over how the queryPayload function is defined at the type level, so + * that UsePaginatedQueryOptions doesn't look as scary. + * + * You're going to see these tuple types in a few different spots in this file; + * it's a "hack" to get around the function contravariance that pops up when you + * normally try to share the TQueryPayload between queryPayload, queryKey, and + * queryFn via the direct/"obvious" way. By throwing the types into tuples + * (which are naturally covariant), it's a lot easier to share the types without + * TypeScript complaining all the time or getting so confused that it degrades + * the type definitions into a bunch of "any" types + */ +type QueryPayloadExtender = [TQueryPayload] extends [ + never, +] + ? { queryPayload?: never } + : { + /** + * An optional function for defining reusable "patterns" for taking + * pagination data (current page, etc.), which will be evaluated and + * passed to queryKey and queryFn for active queries and prefetch queries. + * + * queryKey and queryFn can each access the result of queryPayload + * by accessing the "payload" property from their main function argument + */ + queryPayload: (params: QueryPageParams) => TQueryPayload; + }; + +/** + * Information about a paginated request. This information is passed into the + * queryPayload, queryKey, and queryFn properties of the hook. + */ +type QueryPageParams = { + /** + * The page number used when evaluating queryKey and queryFn. pageNumber will + * be the current page during rendering, but will be the next/previous pages + * for any prefetching. + */ + pageNumber: number; + + /** + * The number of data records to pull per query. Currently hard-coded based + * off the value from PaginationWidget's utils file + */ + limit: number; + + /** + * The page offset to use for querying. Just here for convenience; can also be + * derived from pageNumber and limit + */ + offset: number; + + /** + * The current URL search params. Useful for letting you grab certain search + * terms from the URL + */ + searchParams: URLSearchParams; +}; + +/** + * Weird, hard-to-describe type definition, but it's necessary for making sure + * that the type information involving the queryPayload function narrows + * properly. + */ +type RuntimePayload = [TPayload] 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: RuntimePayload; +}; + +/** + * Any JSON-serializable object returned by the API that exposes the total + * number of records that match a query + */ +export type PaginatedData = { + count: number; +}; + +/** + * React Query's QueryFunctionContext (minus pageParam, which is weird and + * defaults to type any anyway), plus all properties from + * QueryPageParamsWithPayload. + */ +type PaginatedQueryFnContext< + TQueryKey extends QueryKey = QueryKey, + TPayload = never, +> = Omit, "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 make it easier to swap in a custom queryFn type + * definition with a custom context argument + * - queryKey - Removed so that it can be replaced with the function form of + * queryKey + * - onSuccess/onError - APIs are deprecated and removed in React Query v5 + */ +type BasePaginationOptions< + TQueryFnData extends PaginatedData = PaginatedData, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit< + UseQueryOptions, + "keepPreviousData" | "queryKey" | "queryFn" | "onSuccess" | "onError" +>; + +/** + * The argument passed to a custom onInvalidPageChange callback. + */ +type InvalidPageParams = QueryPageParams & { + totalPages: number; + setSearchParams: SetURLSearchParams; +}; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index 174bf517a480c..7296291a0ce18 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -1,7 +1,4 @@ -import { - DEFAULT_RECORDS_PER_PAGE, - isNonInitialPage, -} from "components/PaginationWidget/utils"; +import { isNonInitialPage } from "components/PaginationWidget/utils"; import { useFeatureVisibility } from "hooks/useFeatureVisibility"; import { FC } from "react"; import { Helmet } from "react-helmet-async"; @@ -10,20 +7,27 @@ import { pageTitle } from "utils/page"; import { AuditPageView } from "./AuditPageView"; import { useUserFilterMenu } from "components/Filter/UserFilter"; import { useFilter } from "components/Filter/filter"; -import { usePagination } from "hooks"; -import { useQuery } from "react-query"; -import { getAuditLogs } from "api/api"; import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"; +import { usePaginatedQuery } from "hooks/usePaginatedQuery"; +import { paginatedAudits } from "api/queries/audits"; const AuditPage: FC = () => { + const { 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 searchParamsResult = useSearchParams(); - const pagination = usePagination({ searchParamsResult }); + const auditsQuery = usePaginatedQuery(paginatedAudits(searchParamsResult[0])); const filter = useFilter({ - searchParamsResult, - onUpdate: () => { - pagination.goToPage(1); - }, + searchParamsResult: searchParamsResult, + onUpdate: auditsQuery.goToFirstPage, }); + const userMenu = useUserFilterMenu({ value: filter.values.username, onChange: (option) => @@ -32,6 +36,7 @@ const AuditPage: FC = () => { username: option?.value, }), }); + const actionMenu = useActionFilterMenu({ value: filter.values.action, onChange: (option) => @@ -40,6 +45,7 @@ const AuditPage: FC = () => { action: option?.value, }), }); + const resourceTypeMenu = useResourceTypeFilterMenu({ value: filter.values["resource_type"], onChange: (option) => @@ -48,37 +54,22 @@ 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 + = { 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 04cc0a32d483b..a7dd9a15c6187 100644 --- a/site/src/pages/AuditPage/AuditPageView.tsx +++ b/site/src/pages/AuditPage/AuditPageView.tsx @@ -20,11 +20,11 @@ import { AuditHelpTooltip } from "./AuditHelpTooltip"; import { ComponentProps, FC } from "react"; import { AuditPaywall } from "./AuditPaywall"; import { AuditFilter } from "./AuditFilter"; + import { - PaginationStatus, - TableToolbar, -} from "components/TableToolbar/TableToolbar"; -import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"; + type PaginationResult, + Pagination, +} from "components/PaginationWidget/Pagination"; export const Language = { title: "Audit", @@ -33,28 +33,25 @@ export const Language = { export interface AuditPageViewProps { auditLogs?: AuditLog[]; - count?: number; - page: number; - limit: number; - onPageChange: (page: number) => 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 ( @@ -66,6 +63,7 @@ export const AuditPageView: FC = ({ + {Language.subtitle} @@ -73,72 +71,63 @@ 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) => ( + + )} + /> + )} + + + + + + diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index dd60039056f02..54522a05ece0f 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -6,7 +6,7 @@ import { groupsByUserId } from "api/queries/groups"; import { getErrorMessage } from "api/errors"; import { deploymentConfig } from "api/queries/deployment"; import { - users, + paginatedUsers, suspendUser, activateUser, deleteUser, @@ -17,14 +17,13 @@ import { import { useMutation, useQuery, useQueryClient } from "react-query"; import { useSearchParams, useNavigate } from "react-router-dom"; -import { useOrganizationId, usePagination } from "hooks"; +import { useOrganizationId } from "hooks"; import { useMe } from "hooks/useMe"; import { usePermissions } from "hooks/usePermissions"; import { useStatusFilterMenu } from "./UsersFilter"; import { useFilter } from "components/Filter/filter"; import { useDashboard } from "components/Dashboard/DashboardProvider"; import { generateRandomString } from "utils/random"; -import { prepareQuery } from "utils/filters"; import { Helmet } from "react-helmet-async"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; @@ -34,6 +33,7 @@ import { ResetPasswordDialog } from "./ResetPasswordDialog"; import { pageTitle } from "utils/page"; import { UsersPageView } from "./UsersPageView"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { usePaginatedQuery } from "hooks/usePaginatedQuery"; export const UsersPage: FC<{ children?: ReactNode }> = () => { const queryClient = useQueryClient(); @@ -43,19 +43,11 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const { entitlements } = useDashboard(); const [searchParams] = searchParamsResult; - const pagination = usePagination({ searchParamsResult }); - const usersQuery = useQuery( - users({ - q: prepareQuery(searchParams.get("filter") ?? ""), - limit: pagination.limit, - offset: pagination.offset, - }), - ); - const organizationId = useOrganizationId(); const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId)); const authMethodsQuery = useQuery(authMethods()); + const me = useMe(); const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions(); const rolesQuery = useQuery(roles()); const { data: deploymentValues } = useQuery({ @@ -63,13 +55,12 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { enabled: viewDeploymentValues, }); - const me = useMe(); + const usersQuery = usePaginatedQuery(paginatedUsers()); const useFilterResult = useFilter({ searchParamsResult, - onUpdate: () => { - pagination.goToPage(1); - }, + onUpdate: usersQuery.goToFirstPage, }); + const statusMenu = useStatusFilterMenu({ value: useFilterResult.values.status, onChange: (option) => @@ -164,10 +155,7 @@ 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} + paginationResult={usersQuery} /> = { 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 5c606990fb3e0..dfd5979f6878f 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -5,10 +5,9 @@ import { type GroupsByUserId } from "api/queries/groups"; import { UsersTable } from "./UsersTable/UsersTable"; import { UsersFilter } from "./UsersFilter"; import { - PaginationStatus, - TableToolbar, -} from "components/TableToolbar/TableToolbar"; -import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"; + Pagination, + type PaginationResult, +} from "components/PaginationWidget/Pagination"; export interface UsersPageViewProps { users?: TypesGen.User[]; @@ -33,12 +32,7 @@ export interface UsersPageViewProps { isNonInitialPage: boolean; actorID: string; groupsByUserId: GroupsByUserId | undefined; - - // Pagination - count?: number; - page: number; - limit: number; - onPageChange: (page: number) => void; + paginationResult: PaginationResult; } export const UsersPageView: FC> = ({ @@ -60,54 +54,38 @@ export const UsersPageView: FC> = ({ isNonInitialPage, actorID, authMethods, - count, - limit, - onPageChange, - page, groupsByUserId, + paginationResult, }) => { return ( <> - - - - - - - {count !== undefined && ( - + - )} + ); }; 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 && ( - +
+ +
)} ); diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index b07d4921bee0d..b9d3fcdde5ded 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] }, + ); }); /** @@ -265,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}, + }); }; 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, " "); -}; +}