mirror of https://github.com/coder/coder.git
feat: add usePaginatedQuery hook (#10803)
* wip: commit current progress on usePaginatedQuery * chore: add cacheTime to users query * chore: update cache logic for UsersPage usersQuery * wip: commit progress on Pagination * chore: add function overloads to prepareQuery * wip: commit progress on usePaginatedQuery * docs: add clarifying comment about implementation * chore: remove optional prefetch property from query options * chore: redefine queryKey * refactor: consolidate how queryKey/queryFn are called * refactor: clean up pagination code more * fix: remove redundant properties * refactor: clean up code * wip: commit progress on usePaginatedQuery * wip: commit current pagination progress * docs: clean up comments for clarity * wip: get type signatures compatible (breaks runtime logic slightly) * refactor: clean up type definitions * chore: add support for custom onInvalidPage functions * refactor: clean up type definitions more for clarity reasons * chore: delete Pagination component (separate PR) * chore: remove cacheTime fixes (to be resolved in future PR) * docs: add clarifying/intellisense comments for DX * refactor: link users queries to same queryKey implementation * docs: remove misleading comment * docs: more comments * chore: update onInvalidPage params for more flexibility * fix: remove explicit any * refactor: clean up type definitions * refactor: rename query params for consistency * refactor: clean up input validation for page changes * refactor/fix: update hook to be aware of async data * chore: add contravariance to dictionary * refactor: increase type-safety of usePaginatedQuery * docs: more comments * chore: move usePaginatedQuery file * fix: add back cacheTime * chore: swap in usePaginatedQuery for users table * chore: add goToFirstPage to usePaginatedQuery * fix: make page redirects work properly * refactor: clean up clamp logic * chore: swap in usePaginatedQuery for Audits table * refactor: move dependencies around * fix: remove deprecated properties from hook * refactor: clean up code more * docs: add todo comment * chore: update testing fixtures * wip: commit current progress for tests * fix: update useEffectEvent to sync via layout effects * wip: commit more progress on tests * wip: stub out all expected test cases * wip: more test progress * wip: more test progress * wip: commit more test progress * wip: AHHHHHHHH * chore: finish two more test cases * wip: add in all tests (still need to investigate prefetching * refactor: clean up code slightly * fix: remove math bugs when calculating pages * fix: wrap up all testing and clean up cases * docs: update comments for clarity * fix: update error-handling for invalid page handling * fix: apply suggestions
This commit is contained in:
parent
329aa45c16
commit
d016f93de8
|
@ -18,6 +18,7 @@
|
|||
"coderdenttest",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"contravariance",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbmem",
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { getAuditLogs } from "api/api";
|
||||
import { type AuditLogResponse } from "api/typesGenerated";
|
||||
import { useFilterParamsKey } from "components/Filter/filter";
|
||||
import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
|
||||
export function paginatedAudits(
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<AuditLogResponse, string> {
|
||||
return {
|
||||
searchParams,
|
||||
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
|
||||
queryKey: ({ payload, pageNumber }) => {
|
||||
return ["auditLogs", payload, pageNumber] as const;
|
||||
},
|
||||
queryFn: ({ payload, limit, offset }) => {
|
||||
return getAuditLogs({
|
||||
offset,
|
||||
limit,
|
||||
q: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import { QueryClient, type UseQueryOptions } from "react-query";
|
||||
import * as API from "api/api";
|
||||
import {
|
||||
import type {
|
||||
AuthorizationRequest,
|
||||
GetUsersResponse,
|
||||
UpdateUserPasswordRequest,
|
||||
|
@ -10,11 +10,36 @@ import {
|
|||
} from "api/typesGenerated";
|
||||
import { getAuthorizationKey } from "./authCheck";
|
||||
import { getMetadataAsJSON } from "utils/metadata";
|
||||
import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
import { prepareQuery } from "utils/filters";
|
||||
|
||||
export function usersKey(req: UsersRequest) {
|
||||
return ["users", req] as const;
|
||||
}
|
||||
|
||||
export function paginatedUsers(): UsePaginatedQueryOptions<
|
||||
GetUsersResponse,
|
||||
UsersRequest
|
||||
> {
|
||||
return {
|
||||
queryPayload: ({ limit, offset, searchParams }) => {
|
||||
return {
|
||||
limit,
|
||||
offset,
|
||||
q: prepareQuery(searchParams.get("filter") ?? ""),
|
||||
};
|
||||
},
|
||||
|
||||
queryKey: ({ payload }) => usersKey(payload),
|
||||
queryFn: ({ payload, signal }) => API.getUsers(payload, signal),
|
||||
};
|
||||
}
|
||||
|
||||
export const users = (req: UsersRequest): UseQueryOptions<GetUsersResponse> => {
|
||||
return {
|
||||
queryKey: ["users", req],
|
||||
queryKey: usersKey(req),
|
||||
queryFn: ({ signal }) => API.getUsers(req, signal),
|
||||
cacheTime: 5 * 1000 * 60,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ type UseFilterConfig = {
|
|||
onUpdate?: (newValue: string) => void;
|
||||
};
|
||||
|
||||
const useFilterParamsKey = "filter";
|
||||
export const useFilterParamsKey = "filter";
|
||||
|
||||
export const useFilter = ({
|
||||
fallbackFilter = "",
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* They do not have the same ESLinter exceptions baked in that the official
|
||||
* hooks do, especially for dependency arrays.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* A DIY version of useEffectEvent.
|
||||
|
@ -35,7 +35,12 @@ export function useEffectEvent<TArgs extends unknown[], TReturn = unknown>(
|
|||
callback: (...args: TArgs) => TReturn,
|
||||
) {
|
||||
const callbackRef = useRef(callback);
|
||||
useEffect(() => {
|
||||
|
||||
// useLayoutEffect should be overkill here 99% of the time, but if this were
|
||||
// defined as a regular effect, useEffectEvent would not be able to work with
|
||||
// any layout effects at all; the callback sync here would fire *after* the
|
||||
// layout effect that needs the useEffectEvent function
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
|
|
|
@ -0,0 +1,407 @@
|
|||
import { renderHookWithAuth } from "testHelpers/renderHelpers";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
type PaginatedData,
|
||||
type UsePaginatedQueryOptions,
|
||||
usePaginatedQuery,
|
||||
} from "./usePaginatedQuery";
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function render<
|
||||
TQueryFnData extends PaginatedData = PaginatedData,
|
||||
TQueryPayload = never,
|
||||
>(
|
||||
options: UsePaginatedQueryOptions<TQueryFnData, TQueryPayload>,
|
||||
route?: `/?page=${string}`,
|
||||
) {
|
||||
return renderHookWithAuth(({ options }) => usePaginatedQuery(options), {
|
||||
route,
|
||||
path: "/",
|
||||
initialProps: { options },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* There are a lot of test cases in this file. Scoping mocking to inner describe
|
||||
* function calls to limit the cognitive load of maintaining all this stuff
|
||||
*/
|
||||
describe(`${usePaginatedQuery.name}`, () => {
|
||||
describe("queryPayload method", () => {
|
||||
const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 }));
|
||||
|
||||
it("Passes along an undefined payload if queryPayload is not used", async () => {
|
||||
const mockQueryKey = jest.fn(() => ["mockQuery"]);
|
||||
|
||||
await render({
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
const payloadValueMock = expect.objectContaining({
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
expect(mockQueryKey).toHaveBeenCalledWith(payloadValueMock);
|
||||
expect(mockQueryFn).toHaveBeenCalledWith(payloadValueMock);
|
||||
});
|
||||
|
||||
it("Passes along type-safe payload if queryPayload is provided", async () => {
|
||||
const mockQueryKey = jest.fn(({ payload }) => {
|
||||
return ["mockQuery", payload];
|
||||
});
|
||||
|
||||
const testPayloadValues = [1, "Blah", { cool: true }];
|
||||
for (const payload of testPayloadValues) {
|
||||
const { unmount } = await render({
|
||||
queryPayload: () => payload,
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
const matcher = expect.objectContaining({ payload });
|
||||
expect(mockQueryKey).toHaveBeenCalledWith(matcher);
|
||||
expect(mockQueryFn).toHaveBeenCalledWith(matcher);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Querying for current page", () => {
|
||||
const mockQueryKey = jest.fn(() => ["mock"]);
|
||||
const mockQueryFn = jest.fn(() => Promise.resolve({ count: 50 }));
|
||||
|
||||
it("Parses page number if it exists in URL params", async () => {
|
||||
const pageNumbers = [1, 2, 7, 39, 743];
|
||||
|
||||
for (const num of pageNumbers) {
|
||||
const { result, unmount } = await render(
|
||||
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
|
||||
`/?page=${num}`,
|
||||
);
|
||||
|
||||
expect(result.current.currentPage).toBe(num);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("Defaults to page 1 if no page value can be parsed from params", async () => {
|
||||
const { result } = await render({
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Prefetching", () => {
|
||||
const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]);
|
||||
|
||||
type Context = { pageNumber: number; limit: number };
|
||||
const mockQueryFnImplementation = ({ pageNumber, limit }: Context) => {
|
||||
const data: { value: number }[] = [];
|
||||
if (pageNumber * limit < 75) {
|
||||
for (let i = 0; i < limit; i++) {
|
||||
data.push({ value: i });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({ data, count: 75 });
|
||||
};
|
||||
|
||||
const testPrefetch = async (
|
||||
startingPage: number,
|
||||
targetPage: number,
|
||||
shouldMatch: boolean,
|
||||
) => {
|
||||
// Have to reinitialize mock function every call to avoid false positives
|
||||
// from shared mutable tracking state
|
||||
const mockQueryFn = jest.fn(mockQueryFnImplementation);
|
||||
const { result } = await render(
|
||||
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
|
||||
`/?page=${startingPage}`,
|
||||
);
|
||||
|
||||
const pageMatcher = expect.objectContaining({ pageNumber: targetPage });
|
||||
if (shouldMatch) {
|
||||
await waitFor(() => expect(result.current.totalRecords).toBeDefined());
|
||||
await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher));
|
||||
} else {
|
||||
// Can't use waitFor to test this, because the expect call will
|
||||
// immediately succeed for the not case, even though queryFn needs to be
|
||||
// called async via React Query
|
||||
setTimeout(() => {
|
||||
expect(mockQueryFn).not.toBeCalledWith(pageMatcher);
|
||||
}, 1000);
|
||||
|
||||
jest.runAllTimers();
|
||||
}
|
||||
};
|
||||
|
||||
it("Prefetches the previous page if it exists", async () => {
|
||||
await testPrefetch(2, 1, true);
|
||||
});
|
||||
|
||||
it("Prefetches the next page if it exists", async () => {
|
||||
await testPrefetch(2, 3, true);
|
||||
});
|
||||
|
||||
it("Avoids prefetch for previous page if it doesn't exist", async () => {
|
||||
await testPrefetch(1, 0, false);
|
||||
await testPrefetch(6, 5, false);
|
||||
});
|
||||
|
||||
it("Avoids prefetch for next page if it doesn't exist", async () => {
|
||||
await testPrefetch(3, 4, false);
|
||||
});
|
||||
|
||||
it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => {
|
||||
const startPage = 2;
|
||||
const mockQueryFn = jest.fn(mockQueryFnImplementation);
|
||||
|
||||
await render(
|
||||
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
|
||||
`/?page=${startPage}`,
|
||||
);
|
||||
|
||||
const currentMatcher = expect.objectContaining({ pageNumber: startPage });
|
||||
expect(mockQueryKey).toBeCalledWith(currentMatcher);
|
||||
expect(mockQueryFn).toBeCalledWith(currentMatcher);
|
||||
|
||||
const prevPageMatcher = expect.objectContaining({
|
||||
pageNumber: startPage - 1,
|
||||
});
|
||||
await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher));
|
||||
await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher));
|
||||
|
||||
const nextPageMatcher = expect.objectContaining({
|
||||
pageNumber: startPage + 1,
|
||||
});
|
||||
await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher));
|
||||
await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Safety nets/redirects for invalid pages", () => {
|
||||
const mockQueryKey = jest.fn(() => ["mock"]);
|
||||
const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
|
||||
Promise.resolve({
|
||||
data: new Array(limit).fill(pageNumber),
|
||||
count: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
it("No custom callback: synchronously defaults to page 1 if params are corrupt/invalid", async () => {
|
||||
const { result } = await render(
|
||||
{
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
},
|
||||
"/?page=Cat",
|
||||
);
|
||||
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it("No custom callback: auto-redirects user to last page if requested page overshoots total pages", async () => {
|
||||
const { result } = await render(
|
||||
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
|
||||
"/?page=35",
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(4));
|
||||
});
|
||||
|
||||
it("No custom callback: auto-redirects user to first page if requested page goes below 1", async () => {
|
||||
const { result } = await render(
|
||||
{ queryKey: mockQueryKey, queryFn: mockQueryFn },
|
||||
"/?page=-9999",
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(1));
|
||||
});
|
||||
|
||||
it("With custom callback: Calls callback and does not update search params automatically", async () => {
|
||||
const testControl = new URLSearchParams({
|
||||
page: "1000",
|
||||
});
|
||||
|
||||
const onInvalidPageChange = jest.fn();
|
||||
await render({
|
||||
onInvalidPageChange,
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
searchParams: testControl,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInvalidPageChange).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
pageNumber: expect.any(Number),
|
||||
limit: expect.any(Number),
|
||||
offset: expect.any(Number),
|
||||
totalPages: expect.any(Number),
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
setSearchParams: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(testControl.get("page")).toBe("1000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Passing in searchParams property", () => {
|
||||
const mockQueryKey = jest.fn(() => ["mock"]);
|
||||
const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
|
||||
Promise.resolve({
|
||||
data: new Array(limit).fill(pageNumber),
|
||||
count: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
it("Reads from searchParams property if provided", async () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: "2",
|
||||
});
|
||||
|
||||
const { result } = await render({
|
||||
searchParams,
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
expect(result.current.currentPage).toBe(2);
|
||||
});
|
||||
|
||||
it("Flushes state changes via provided searchParams property instead of internal searchParams", async () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: "2",
|
||||
});
|
||||
|
||||
const { result } = await render({
|
||||
searchParams,
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
result.current.goToFirstPage();
|
||||
expect(searchParams.get("page")).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`${usePaginatedQuery.name} - Returned properties`, () => {
|
||||
describe("Page change methods", () => {
|
||||
const mockQueryKey = jest.fn(() => ["mock"]);
|
||||
|
||||
const mockQueryFn = jest.fn(({ pageNumber, limit }) => {
|
||||
type Data = PaginatedData & { data: readonly number[] };
|
||||
|
||||
return new Promise<Data>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: new Array(limit).fill(pageNumber),
|
||||
count: 100,
|
||||
});
|
||||
}, 10_000);
|
||||
});
|
||||
});
|
||||
|
||||
test("goToFirstPage always succeeds regardless of fetch status", async () => {
|
||||
const queryFns = [mockQueryFn, jest.fn(() => Promise.reject("Too bad"))];
|
||||
|
||||
for (const queryFn of queryFns) {
|
||||
const { result, unmount } = await render(
|
||||
{ queryFn, queryKey: mockQueryKey },
|
||||
"/?page=5",
|
||||
);
|
||||
|
||||
expect(result.current.currentPage).toBe(5);
|
||||
result.current.goToFirstPage();
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(1));
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test("goToNextPage works only if hasNextPage is true", async () => {
|
||||
const { result } = await render(
|
||||
{
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
},
|
||||
"/?page=1",
|
||||
);
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
result.current.goToNextPage();
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
result.current.goToNextPage();
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(2));
|
||||
});
|
||||
|
||||
test("goToPreviousPage works only if hasPreviousPage is true", async () => {
|
||||
const { result } = await render(
|
||||
{
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
},
|
||||
"/?page=3",
|
||||
);
|
||||
|
||||
expect(result.current.hasPreviousPage).toBe(false);
|
||||
result.current.goToPreviousPage();
|
||||
expect(result.current.currentPage).toBe(3);
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(result.current.hasPreviousPage).toBe(true));
|
||||
result.current.goToPreviousPage();
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(2));
|
||||
});
|
||||
|
||||
test("onPageChange accounts for floats and truncates numeric values before navigating", async () => {
|
||||
const { result } = await render({
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
result.current.onPageChange(2.5);
|
||||
|
||||
await waitFor(() => expect(result.current.currentPage).toBe(2));
|
||||
});
|
||||
|
||||
test("onPageChange rejects impossible numeric values and does nothing", async () => {
|
||||
const { result } = await render({
|
||||
queryKey: mockQueryKey,
|
||||
queryFn: mockQueryFn,
|
||||
});
|
||||
|
||||
await jest.runAllTimersAsync();
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
result.current.onPageChange(NaN);
|
||||
result.current.onPageChange(Infinity);
|
||||
result.current.onPageChange(-Infinity);
|
||||
|
||||
setTimeout(() => {
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
}, 1000);
|
||||
|
||||
jest.runAllTimers();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,444 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffectEvent } from "./hookPolyfills";
|
||||
import { type SetURLSearchParams, useSearchParams } from "react-router-dom";
|
||||
import { clamp } from "lodash";
|
||||
|
||||
import {
|
||||
type QueryFunctionContext,
|
||||
type QueryKey,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
useQueryClient,
|
||||
useQuery,
|
||||
} from "react-query";
|
||||
|
||||
const DEFAULT_RECORDS_PER_PAGE = 25;
|
||||
|
||||
/**
|
||||
* The key to use for getting/setting the page number from the search params
|
||||
*/
|
||||
const PAGE_NUMBER_PARAMS_KEY = "page";
|
||||
|
||||
/**
|
||||
* A more specialized version of UseQueryOptions built specifically for
|
||||
* paginated queries.
|
||||
*/
|
||||
export type UsePaginatedQueryOptions<
|
||||
// Aside from TQueryPayload, all type parameters come from the base React
|
||||
// Query type definition, and are here for compatibility
|
||||
TQueryFnData extends PaginatedData = PaginatedData,
|
||||
TQueryPayload = never,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = BasePaginationOptions<TQueryFnData, TError, TData, TQueryKey> &
|
||||
QueryPayloadExtender<TQueryPayload> & {
|
||||
/**
|
||||
* An optional dependency for React Router's URLSearchParams. If this is
|
||||
* provided, all URL state changes will go through this object instead of
|
||||
* an internal value.
|
||||
*/
|
||||
searchParams?: URLSearchParams;
|
||||
|
||||
/**
|
||||
* A function that takes pagination information and produces a full query
|
||||
* key.
|
||||
*
|
||||
* Must be a function so that it can be used for the active query, and then
|
||||
* reused for any prefetching queries (swapping the page number out).
|
||||
*/
|
||||
queryKey: (params: QueryPageParamsWithPayload<TQueryPayload>) => TQueryKey;
|
||||
|
||||
/**
|
||||
* A version of queryFn that is required and that exposes the pagination
|
||||
* information through its query function context argument
|
||||
*/
|
||||
queryFn: (
|
||||
context: PaginatedQueryFnContext<TQueryKey, TQueryPayload>,
|
||||
) => TQueryFnData | Promise<TQueryFnData>;
|
||||
|
||||
/**
|
||||
* A custom, optional function for handling what happens if the user
|
||||
* navigates to a page that doesn't exist for the paginated data.
|
||||
*
|
||||
* If this function is not defined/provided when an invalid page is
|
||||
* encountered, usePaginatedQuery will default to navigating the user to the
|
||||
* closest valid page.
|
||||
*/
|
||||
onInvalidPageChange?: (params: InvalidPageParams) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of calling usePaginatedQuery. Mirrors the result of the base
|
||||
* useQuery as closely as possible, while adding extra pagination properties
|
||||
*/
|
||||
export type UsePaginatedQueryResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
> = UseQueryResult<TData, TError> & PaginationResultInfo;
|
||||
|
||||
export function usePaginatedQuery<
|
||||
TQueryFnData extends PaginatedData = PaginatedData,
|
||||
TQueryPayload = never,
|
||||
TError = unknown,
|
||||
TData extends PaginatedData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UsePaginatedQueryOptions<
|
||||
TQueryFnData,
|
||||
TQueryPayload,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
>,
|
||||
): UsePaginatedQueryResult<TData, TError> {
|
||||
const {
|
||||
queryKey,
|
||||
queryPayload,
|
||||
onInvalidPageChange,
|
||||
searchParams: outerSearchParams,
|
||||
queryFn: outerQueryFn,
|
||||
...extraOptions
|
||||
} = options;
|
||||
|
||||
const [innerSearchParams, setSearchParams] = useSearchParams();
|
||||
const searchParams = outerSearchParams ?? innerSearchParams;
|
||||
|
||||
const limit = DEFAULT_RECORDS_PER_PAGE;
|
||||
const currentPage = parsePage(searchParams);
|
||||
const currentPageOffset = (currentPage - 1) * limit;
|
||||
|
||||
const getQueryOptionsFromPage = (pageNumber: number) => {
|
||||
const pageParams: QueryPageParams = {
|
||||
pageNumber,
|
||||
limit,
|
||||
offset: (pageNumber - 1) * limit,
|
||||
searchParams: getParamsWithoutPage(searchParams),
|
||||
};
|
||||
|
||||
const payload = queryPayload?.(pageParams) as RuntimePayload<TQueryPayload>;
|
||||
|
||||
return {
|
||||
queryKey: queryKey({ ...pageParams, payload }),
|
||||
queryFn: (context: QueryFunctionContext<TQueryKey>) => {
|
||||
return outerQueryFn({ ...context, ...pageParams, payload });
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
// Not using infinite query right now because that requires a fair bit of list
|
||||
// virtualization as the lists get bigger (especially for the audit logs).
|
||||
// Keeping initial implementation simple.
|
||||
const query = useQuery<TQueryFnData, TError, TData, TQueryKey>({
|
||||
...extraOptions,
|
||||
...getQueryOptionsFromPage(currentPage),
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const totalRecords = query.data?.count;
|
||||
const totalPages =
|
||||
totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined;
|
||||
|
||||
const hasNextPage =
|
||||
totalRecords !== undefined && limit + currentPageOffset < totalRecords;
|
||||
const hasPreviousPage =
|
||||
totalRecords !== undefined &&
|
||||
currentPage > 1 &&
|
||||
currentPageOffset - limit < totalRecords;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const prefetchPage = useEffectEvent((newPage: number) => {
|
||||
const options = getQueryOptionsFromPage(newPage);
|
||||
return queryClient.prefetchQuery(options);
|
||||
});
|
||||
|
||||
// Have to split hairs and sync on both the current page and the hasXPage
|
||||
// variables, because the page can change immediately client-side, but the
|
||||
// hasXPage values are derived from the server and won't always be immediately
|
||||
// ready on the initial render
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
void prefetchPage(currentPage + 1);
|
||||
}
|
||||
}, [prefetchPage, currentPage, hasNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreviousPage) {
|
||||
void prefetchPage(currentPage - 1);
|
||||
}
|
||||
}, [prefetchPage, currentPage, hasPreviousPage]);
|
||||
|
||||
// Mainly here to catch user if they navigate to a page directly via URL;
|
||||
// totalPages parameterized to insulate function from fetch status changes
|
||||
const updatePageIfInvalid = useEffectEvent(async (totalPages: number) => {
|
||||
// If totalPages is 0, that's a sign that the currentPage overshot, and the
|
||||
// API returned a count of 0 because it didn't know how to process the query
|
||||
let fixedTotalPages: number;
|
||||
if (totalPages !== 0) {
|
||||
fixedTotalPages = totalPages;
|
||||
} else {
|
||||
const firstPageOptions = getQueryOptionsFromPage(1);
|
||||
try {
|
||||
const firstPageResult = await queryClient.fetchQuery(firstPageOptions);
|
||||
const rounded = Math.ceil(firstPageResult?.count ?? 0 / limit);
|
||||
fixedTotalPages = Math.max(rounded, 1);
|
||||
} catch {
|
||||
fixedTotalPages = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const clamped = clamp(currentPage, 1, fixedTotalPages);
|
||||
if (currentPage === clamped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const withoutPage = getParamsWithoutPage(searchParams);
|
||||
if (onInvalidPageChange === undefined) {
|
||||
withoutPage.set(PAGE_NUMBER_PARAMS_KEY, String(clamped));
|
||||
setSearchParams(withoutPage);
|
||||
} else {
|
||||
const params: InvalidPageParams = {
|
||||
limit,
|
||||
setSearchParams,
|
||||
offset: currentPageOffset,
|
||||
searchParams: withoutPage,
|
||||
totalPages: fixedTotalPages,
|
||||
pageNumber: currentPage,
|
||||
};
|
||||
|
||||
onInvalidPageChange(params);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.isFetching && totalPages !== undefined) {
|
||||
void updatePageIfInvalid(totalPages);
|
||||
}
|
||||
}, [updatePageIfInvalid, query.isFetching, totalPages]);
|
||||
|
||||
const onPageChange = (newPage: number) => {
|
||||
// Page 1 is the only page that can be safely navigated to without knowing
|
||||
// totalPages; no reliance on server data for math calculations
|
||||
if (totalPages === undefined && newPage !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages ?? 1);
|
||||
if (Number.isNaN(cleanedInput)) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput));
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
// Have to do a type assertion for final return type to make React Query's
|
||||
// internal types happy; splitting type definitions up to limit risk of the
|
||||
// type assertion silencing type warnings we actually want to pay attention to
|
||||
const info: PaginationResultInfo = {
|
||||
limit,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
goToFirstPage: () => onPageChange(1),
|
||||
|
||||
goToPreviousPage: () => {
|
||||
if (hasPreviousPage) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
},
|
||||
|
||||
goToNextPage: () => {
|
||||
if (hasNextPage) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
},
|
||||
|
||||
...(query.isSuccess
|
||||
? {
|
||||
isSuccess: true,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
totalRecords: totalRecords as number,
|
||||
totalPages: totalPages as number,
|
||||
}
|
||||
: {
|
||||
isSuccess: false,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
totalRecords: undefined,
|
||||
totalPages: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...query, ...info } as UsePaginatedQueryResult<TData, TError>;
|
||||
}
|
||||
|
||||
function parsePage(params: URLSearchParams): number {
|
||||
const parsed = Number(params.get("page"));
|
||||
return Number.isInteger(parsed) && parsed > 1 ? parsed : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips out the page number from a query so that there aren't mismatches
|
||||
* between it and usePaginatedQuery's currentPage property (especially for
|
||||
* prefetching)
|
||||
*/
|
||||
function getParamsWithoutPage(params: URLSearchParams): URLSearchParams {
|
||||
const withoutPage = new URLSearchParams(params);
|
||||
withoutPage.delete(PAGE_NUMBER_PARAMS_KEY);
|
||||
return withoutPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the pagination-properties for UsePaginatedQueryResult. Split up so that
|
||||
* the types can be used separately in multiple spots.
|
||||
*/
|
||||
type PaginationResultInfo = {
|
||||
currentPage: number;
|
||||
limit: number;
|
||||
onPageChange: (newPage: number) => void;
|
||||
goToPreviousPage: () => void;
|
||||
goToNextPage: () => void;
|
||||
goToFirstPage: () => void;
|
||||
} & (
|
||||
| {
|
||||
isSuccess: false;
|
||||
hasNextPage: false;
|
||||
hasPreviousPage: false;
|
||||
totalRecords: undefined;
|
||||
totalPages: undefined;
|
||||
}
|
||||
| {
|
||||
isSuccess: true;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
totalRecords: number;
|
||||
totalPages: number;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Papers over how the queryPayload function is defined at the type level, so
|
||||
* that UsePaginatedQueryOptions doesn't look as scary.
|
||||
*
|
||||
* You're going to see these tuple types in a few different spots in this file;
|
||||
* it's a "hack" to get around the function contravariance that pops up when you
|
||||
* normally try to share the TQueryPayload between queryPayload, queryKey, and
|
||||
* queryFn via the direct/"obvious" way. By throwing the types into tuples
|
||||
* (which are naturally covariant), it's a lot easier to share the types without
|
||||
* TypeScript complaining all the time or getting so confused that it degrades
|
||||
* the type definitions into a bunch of "any" types
|
||||
*/
|
||||
type QueryPayloadExtender<TQueryPayload = never> = [TQueryPayload] extends [
|
||||
never,
|
||||
]
|
||||
? { queryPayload?: never }
|
||||
: {
|
||||
/**
|
||||
* An optional function for defining reusable "patterns" for taking
|
||||
* pagination data (current page, etc.), which will be evaluated and
|
||||
* passed to queryKey and queryFn for active queries and prefetch queries.
|
||||
*
|
||||
* queryKey and queryFn can each access the result of queryPayload
|
||||
* by accessing the "payload" property from their main function argument
|
||||
*/
|
||||
queryPayload: (params: QueryPageParams) => TQueryPayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Information about a paginated request. This information is passed into the
|
||||
* queryPayload, queryKey, and queryFn properties of the hook.
|
||||
*/
|
||||
type QueryPageParams = {
|
||||
/**
|
||||
* The page number used when evaluating queryKey and queryFn. pageNumber will
|
||||
* be the current page during rendering, but will be the next/previous pages
|
||||
* for any prefetching.
|
||||
*/
|
||||
pageNumber: number;
|
||||
|
||||
/**
|
||||
* The number of data records to pull per query. Currently hard-coded based
|
||||
* off the value from PaginationWidget's utils file
|
||||
*/
|
||||
limit: number;
|
||||
|
||||
/**
|
||||
* The page offset to use for querying. Just here for convenience; can also be
|
||||
* derived from pageNumber and limit
|
||||
*/
|
||||
offset: number;
|
||||
|
||||
/**
|
||||
* The current URL search params. Useful for letting you grab certain search
|
||||
* terms from the URL
|
||||
*/
|
||||
searchParams: URLSearchParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Weird, hard-to-describe type definition, but it's necessary for making sure
|
||||
* that the type information involving the queryPayload function narrows
|
||||
* properly.
|
||||
*/
|
||||
type RuntimePayload<TPayload = never> = [TPayload] extends [never]
|
||||
? undefined
|
||||
: TPayload;
|
||||
|
||||
/**
|
||||
* The query page params, appended with the result of the queryPayload function.
|
||||
* This type is passed to both queryKey and queryFn. If queryPayload is
|
||||
* undefined, payload will always be undefined
|
||||
*/
|
||||
type QueryPageParamsWithPayload<TPayload = never> = QueryPageParams & {
|
||||
payload: RuntimePayload<TPayload>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any JSON-serializable object returned by the API that exposes the total
|
||||
* number of records that match a query
|
||||
*/
|
||||
export type PaginatedData = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query's QueryFunctionContext (minus pageParam, which is weird and
|
||||
* defaults to type any anyway), plus all properties from
|
||||
* QueryPageParamsWithPayload.
|
||||
*/
|
||||
type PaginatedQueryFnContext<
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPayload = never,
|
||||
> = Omit<QueryFunctionContext<TQueryKey>, "pageParam"> &
|
||||
QueryPageParamsWithPayload<TPayload>;
|
||||
|
||||
/**
|
||||
* The set of React Query properties that UsePaginatedQueryOptions derives from.
|
||||
*
|
||||
* Three properties are stripped from it:
|
||||
* - keepPreviousData - The value must always be true to keep pagination feeling
|
||||
* nice, so better to prevent someone from trying to touch it at all
|
||||
* - queryFn - Removed to make it easier to swap in a custom queryFn type
|
||||
* definition with a custom context argument
|
||||
* - queryKey - Removed so that it can be replaced with the function form of
|
||||
* queryKey
|
||||
* - onSuccess/onError - APIs are deprecated and removed in React Query v5
|
||||
*/
|
||||
type BasePaginationOptions<
|
||||
TQueryFnData extends PaginatedData = PaginatedData,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"keepPreviousData" | "queryKey" | "queryFn" | "onSuccess" | "onError"
|
||||
>;
|
||||
|
||||
/**
|
||||
* The argument passed to a custom onInvalidPageChange callback.
|
||||
*/
|
||||
type InvalidPageParams = QueryPageParams & {
|
||||
totalPages: number;
|
||||
setSearchParams: SetURLSearchParams;
|
||||
};
|
|
@ -1,7 +1,4 @@
|
|||
import {
|
||||
DEFAULT_RECORDS_PER_PAGE,
|
||||
isNonInitialPage,
|
||||
} from "components/PaginationWidget/utils";
|
||||
import { isNonInitialPage } from "components/PaginationWidget/utils";
|
||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
|
||||
import { FC } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
@ -10,20 +7,27 @@ import { pageTitle } from "utils/page";
|
|||
import { AuditPageView } from "./AuditPageView";
|
||||
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||
import { useFilter } from "components/Filter/filter";
|
||||
import { usePagination } from "hooks";
|
||||
import { useQuery } from "react-query";
|
||||
import { getAuditLogs } from "api/api";
|
||||
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
|
||||
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
|
||||
import { paginatedAudits } from "api/queries/audits";
|
||||
|
||||
const AuditPage: FC = () => {
|
||||
const searchParamsResult = useSearchParams();
|
||||
const pagination = usePagination({ searchParamsResult });
|
||||
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
|
||||
|
||||
/**
|
||||
* There is an implicit link between auditsQuery and filter via the
|
||||
* searchParams object
|
||||
*
|
||||
* @todo Make link more explicit (probably by making it so that components
|
||||
* and hooks can share the result of useSearchParams directly)
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const auditsQuery = usePaginatedQuery(paginatedAudits(searchParams));
|
||||
const filter = useFilter({
|
||||
searchParamsResult,
|
||||
onUpdate: () => {
|
||||
pagination.goToPage(1);
|
||||
},
|
||||
searchParamsResult: [searchParams, setSearchParams],
|
||||
onUpdate: auditsQuery.goToFirstPage,
|
||||
});
|
||||
|
||||
const userMenu = useUserFilterMenu({
|
||||
value: filter.values.username,
|
||||
onChange: (option) =>
|
||||
|
@ -32,6 +36,7 @@ const AuditPage: FC = () => {
|
|||
username: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const actionMenu = useActionFilterMenu({
|
||||
value: filter.values.action,
|
||||
onChange: (option) =>
|
||||
|
@ -40,6 +45,7 @@ const AuditPage: FC = () => {
|
|||
action: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const resourceTypeMenu = useResourceTypeFilterMenu({
|
||||
value: filter.values["resource_type"],
|
||||
onChange: (option) =>
|
||||
|
@ -48,37 +54,25 @@ const AuditPage: FC = () => {
|
|||
resource_type: option?.value,
|
||||
}),
|
||||
});
|
||||
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
|
||||
const { data, error } = useQuery({
|
||||
queryKey: ["auditLogs", filter.query, pagination.page],
|
||||
queryFn: () => {
|
||||
const limit = DEFAULT_RECORDS_PER_PAGE;
|
||||
const page = pagination.page;
|
||||
return getAuditLogs({
|
||||
offset: page <= 0 ? 0 : (page - 1) * limit,
|
||||
limit: limit,
|
||||
q: filter.query,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle("Audit")}</title>
|
||||
</Helmet>
|
||||
|
||||
<AuditPageView
|
||||
auditLogs={data?.audit_logs}
|
||||
count={data?.count}
|
||||
page={pagination.page}
|
||||
limit={pagination.limit}
|
||||
onPageChange={pagination.goToPage}
|
||||
isNonInitialPage={isNonInitialPage(searchParamsResult[0])}
|
||||
auditLogs={auditsQuery.data?.audit_logs}
|
||||
count={auditsQuery.totalRecords}
|
||||
page={auditsQuery.currentPage}
|
||||
limit={auditsQuery.limit}
|
||||
onPageChange={auditsQuery.onPageChange}
|
||||
isNonInitialPage={isNonInitialPage(searchParams)}
|
||||
isAuditLogVisible={isAuditLogVisible}
|
||||
error={error}
|
||||
error={auditsQuery.error}
|
||||
filterProps={{
|
||||
filter,
|
||||
error,
|
||||
error: auditsQuery.error,
|
||||
menus: {
|
||||
user: userMenu,
|
||||
action: actionMenu,
|
||||
|
|
|
@ -6,7 +6,7 @@ import { groupsByUserId } from "api/queries/groups";
|
|||
import { getErrorMessage } from "api/errors";
|
||||
import { deploymentConfig } from "api/queries/deployment";
|
||||
import {
|
||||
users,
|
||||
paginatedUsers,
|
||||
suspendUser,
|
||||
activateUser,
|
||||
deleteUser,
|
||||
|
@ -17,14 +17,13 @@ import {
|
|||
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { useOrganizationId, usePagination } from "hooks";
|
||||
import { useOrganizationId } from "hooks";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import { usePermissions } from "hooks/usePermissions";
|
||||
import { useStatusFilterMenu } from "./UsersFilter";
|
||||
import { useFilter } from "components/Filter/filter";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { generateRandomString } from "utils/random";
|
||||
import { prepareQuery } from "utils/filters";
|
||||
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
|
@ -34,6 +33,7 @@ import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
|||
import { pageTitle } from "utils/page";
|
||||
import { UsersPageView } from "./UsersPageView";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
|
||||
|
||||
export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -43,19 +43,11 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
const { entitlements } = useDashboard();
|
||||
const [searchParams] = searchParamsResult;
|
||||
|
||||
const pagination = usePagination({ searchParamsResult });
|
||||
const usersQuery = useQuery(
|
||||
users({
|
||||
q: prepareQuery(searchParams.get("filter") ?? ""),
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
}),
|
||||
);
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId));
|
||||
const authMethodsQuery = useQuery(authMethods());
|
||||
|
||||
const me = useMe();
|
||||
const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
|
||||
const rolesQuery = useQuery(roles());
|
||||
const { data: deploymentValues } = useQuery({
|
||||
|
@ -63,13 +55,12 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
enabled: viewDeploymentValues,
|
||||
});
|
||||
|
||||
const me = useMe();
|
||||
const usersQuery = usePaginatedQuery(paginatedUsers());
|
||||
const useFilterResult = useFilter({
|
||||
searchParamsResult,
|
||||
onUpdate: () => {
|
||||
pagination.goToPage(1);
|
||||
},
|
||||
onUpdate: usersQuery.goToFirstPage,
|
||||
});
|
||||
|
||||
const statusMenu = useStatusFilterMenu({
|
||||
value: useFilterResult.values.status,
|
||||
onChange: (option) =>
|
||||
|
@ -164,10 +155,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
error: usersQuery.error,
|
||||
menus: { status: statusMenu },
|
||||
}}
|
||||
count={usersQuery.data?.count}
|
||||
page={pagination.page}
|
||||
limit={pagination.limit}
|
||||
onPageChange={pagination.goToPage}
|
||||
count={usersQuery.totalRecords}
|
||||
page={usersQuery.currentPage}
|
||||
limit={usersQuery.limit}
|
||||
onPageChange={usersQuery.onPageChange}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
|
|
|
@ -125,6 +125,7 @@ export async function renderHookWithAuth<Result, Props>(
|
|||
{
|
||||
initialProps,
|
||||
path = "/",
|
||||
route = "/",
|
||||
extraRoutes = [],
|
||||
}: RenderHookWithAuthOptions<Props> = {},
|
||||
) {
|
||||
|
@ -144,10 +145,10 @@ export async function renderHookWithAuth<Result, Props>(
|
|||
*/
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
|
||||
const [readonlyStatefulRouter] = useState(() => {
|
||||
return createMemoryRouter([
|
||||
{ path, element: <>{children}</> },
|
||||
...extraRoutes,
|
||||
]);
|
||||
return createMemoryRouter(
|
||||
[{ path, element: <>{children}</> }, ...extraRoutes],
|
||||
{ initialEntries: [route] },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
export const prepareQuery = (query?: string) => {
|
||||
export function prepareQuery(query: string): string;
|
||||
export function prepareQuery(query: undefined): undefined;
|
||||
export function prepareQuery(query: string | undefined): string | undefined;
|
||||
export function prepareQuery(query?: string): string | undefined {
|
||||
return query?.trim().replace(/ +/g, " ");
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue