diff --git a/.changeset/mighty-comics-worry.md b/.changeset/mighty-comics-worry.md new file mode 100644 index 000000000..dd4bcbcef --- /dev/null +++ b/.changeset/mighty-comics-worry.md @@ -0,0 +1,5 @@ +--- +"openapi-react-query": minor +--- + +Implements useInfiniteQuery() in openapi-react-query diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 099fe653c..f3ddc79c9 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -3,10 +3,11 @@ import { type UseMutationResult, type UseQueryOptions, type UseQueryResult, - type UseSuspenseQueryOptions, - type UseSuspenseQueryResult, + type InfiniteData, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, type QueryClient, type QueryFunctionContext, type SkipToken, @@ -15,7 +16,13 @@ import { useSuspenseQuery, useInfiniteQuery, } from "@tanstack/react-query"; -import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch"; +import type { + ClientMethod, + FetchResponse, + MaybeOptionalInit, + Client as FetchClient, + DefaultParamsOption, +} from "openapi-fetch"; import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers"; // Helper type to dynamically infer the type from the `select` property @@ -93,6 +100,32 @@ export type UseQueryMethod>, : [InitWithUnknowns, Options?, QueryClient?] ) => UseQueryResult, Response["error"]>; +export type UseInfiniteQueryMethod>, Media extends MediaType> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Response extends Required>, + Options extends Omit< + UseInfiniteQueryOptions< + Response["data"], + Response["error"], + InfiniteData, + Response["data"], + QueryKey, + unknown + >, + "queryKey" | "queryFn" + > & { + pageParamName?: string; + }, +>( + method: Method, + url: Path, + init: InitWithUnknowns, + options: Options, + queryClient?: QueryClient, +) => UseInfiniteQueryResult, Response["error"]>; + export type UseSuspenseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -115,45 +148,6 @@ export type UseSuspenseQueryMethod, Options?, QueryClient?] ) => UseSuspenseQueryResult, Response["error"]>; -export type UseInfiniteQueryMethod>, Media extends MediaType> = < - Method extends HttpMethod, - Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, ->( - method: Method, - url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [ - InitWithUnknowns?, - Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - Response["data"], - number, - QueryKey - >, - "queryKey" | "queryFn" - >?, - QueryClient?, - ] - : [ - InitWithUnknowns, - Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - Response["data"], - number, - QueryKey - >, - "queryKey" | "queryFn" - >?, - QueryClient?, - ] -) => UseInfiniteQueryResult; - export type UseMutationMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -199,46 +193,45 @@ export default function createClient, - Init extends MaybeOptionalInit, - Response extends Required>, - >( - method: Method, - path: Path, - init?: InitWithUnknowns, - options?: Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - Response["data"], - number, - QueryKey - >, - "queryKey" | "queryFn" - >, - ) => ({ - queryKey: [method, path, init] as const, - queryFn, - ...options, - }); - return { queryOptions, useQuery: (method, path, ...[init, options, queryClient]) => useQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), useSuspenseQuery: (method, path, ...[init, options, queryClient]) => useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), - useInfiniteQuery: (method, path, ...[init, options, queryClient]) => { - const baseOptions = infiniteQueryOptions(method, path, init as InitWithUnknowns, options as any); // TODO: find a way to avoid as any + useInfiniteQuery: (method, path, init, options, queryClient) => { + const { pageParamName = "cursor", ...restOptions } = options; + return useInfiniteQuery( { - ...baseOptions, - initialPageParam: 0, - getNextPageParam: (lastPage: any, allPages: any[], lastPageParam: number, allPageParams: number[]) => - options?.getNextPageParam?.(lastPage, allPages, lastPageParam, allPageParams) ?? allPages.length, - } as any, + queryKey: [method, path, init] as const, + queryFn: async >({ + queryKey: [method, path, init], + pageParam = 0, + signal, + }: QueryFunctionContext, unknown>) => { + const mth = method.toUpperCase() as Uppercase; + const fn = client[mth] as ClientMethod; + const mergedInit = { + ...init, + signal, + params: { + ...(init?.params || {}), + query: { + ...(init?.params as { query?: DefaultParamsOption })?.query, + [pageParamName]: pageParam, + }, + }, + }; + + const { data, error } = await fn(path, mergedInit as any); + if (error) { + throw error; + } + return data; + }, + ...restOptions, + }, queryClient, ); }, diff --git a/packages/openapi-react-query/test/fixtures/api.d.ts b/packages/openapi-react-query/test/fixtures/api.d.ts index 463ce893f..403dae490 100644 --- a/packages/openapi-react-query/test/fixtures/api.d.ts +++ b/packages/openapi-react-query/test/fixtures/api.d.ts @@ -15,6 +15,7 @@ export interface paths { parameters: { query: { limit: number; + cursor?: number; }; header?: never; path?: never; diff --git a/packages/openapi-react-query/test/fixtures/api.yaml b/packages/openapi-react-query/test/fixtures/api.yaml index 62b4c5c60..75ae0d336 100644 --- a/packages/openapi-react-query/test/fixtures/api.yaml +++ b/packages/openapi-react-query/test/fixtures/api.yaml @@ -11,6 +11,11 @@ paths: required: true schema: type: integer + - in: query + name: cursor + required: false + schema: + type: integer responses: '200': description: Successful response diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index d7f8a4c2e..d368be05f 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -3,7 +3,7 @@ import { server, baseUrl, useMockRequestHandler } from "./fixtures/mock-server.j import type { paths } from "./fixtures/api.js"; import createClient from "../src/index.js"; import createFetchClient from "openapi-fetch"; -import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, renderHook, screen, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider, @@ -823,11 +823,12 @@ describe("client", () => { }); }); describe("useInfiniteQuery", () => { - it("should fetch data correctly with pagination", async () => { + it("should fetch data correctly with pagination and include cursor", async () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); - useMockRequestHandler({ + // First page request handler + const firstRequestHandler = useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", @@ -835,17 +836,36 @@ describe("client", () => { body: { items: [1, 2, 3], nextPage: 1 }, }); - const { result } = renderHook( - () => client.useInfiniteQuery("get", "/paginated-data", { params: { query: { limit: 3 } } }), + const { result, rerender } = renderHook( + () => + client.useInfiniteQuery( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + }, + ), { wrapper }, ); + // Wait for initial query to complete await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect((result.current.data as any).pages[0]).toEqual({ items: [1, 2, 3], nextPage: 1 }); + // Verify first request + const firstRequestUrl = firstRequestHandler.getRequestUrl(); + expect(firstRequestUrl?.searchParams.get("limit")).toBe("3"); + expect(firstRequestUrl?.searchParams.get("cursor")).toBe("0"); - // Set up mock for second page - useMockRequestHandler({ + // Set up mock for second page before triggering next page fetch + const secondRequestHandler = useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", @@ -853,34 +873,208 @@ describe("client", () => { body: { items: [4, 5, 6], nextPage: 2 }, }); - await result.current.fetchNextPage(); + // Fetch next page + await act(async () => { + await result.current.fetchNextPage(); + // Force a rerender to ensure state is updated + rerender(); + }); - await waitFor(() => expect(result.current.isFetching).toBe(false)); + // Wait for second page to be fetched and verify loading states + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + expect(result.current.hasNextPage).toBe(true); + expect(result.current.data?.pages).toHaveLength(2); + }); + + // Verify second request + const secondRequestUrl = secondRequestHandler.getRequestUrl(); + expect(secondRequestUrl?.searchParams.get("limit")).toBe("3"); + expect(secondRequestUrl?.searchParams.get("cursor")).toBe("1"); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.pages[0].nextPage).toBe(1); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.pages[1].nextPage).toBe(2); - expect((result.current.data as any).pages).toHaveLength(2); - expect((result.current.data as any).pages[1]).toEqual({ items: [4, 5, 6], nextPage: 2 }); + // Verify the complete data structure + expect(result.current.data?.pages).toEqual([ + { items: [1, 2, 3], nextPage: 1 }, + { items: [4, 5, 6], nextPage: 2 }, + ]); + + // Verify we can access all items through pages + const allItems = result.current.data?.pages.flatMap((page) => page.items); + expect(allItems).toEqual([1, 2, 3, 4, 5, 6]); }); + it("should reverse pages and pageParams when using the select option", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + // First page request handler + const firstRequestHandler = useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [1, 2, 3], nextPage: 1 }, + }); + + const { result, rerender } = renderHook( + () => + client.useInfiniteQuery( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + select: (data) => ({ + pages: [...data.pages].reverse(), + pageParams: [...data.pageParams].reverse(), + }), + }, + ), + { wrapper }, + ); + + // Wait for initial query to complete + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Verify first request + const firstRequestUrl = firstRequestHandler.getRequestUrl(); + expect(firstRequestUrl?.searchParams.get("limit")).toBe("3"); + expect(firstRequestUrl?.searchParams.get("cursor")).toBe("0"); + + // Set up mock for second page before triggering next page fetch + const secondRequestHandler = useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [4, 5, 6], nextPage: 2 }, + }); + + // Fetch next page + await act(async () => { + await result.current.fetchNextPage(); + rerender(); + }); + + // Wait for second page to complete + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + expect(result.current.hasNextPage).toBe(true); + }); + + // Verify reversed pages and pageParams + expect(result.current.data).toBeDefined(); - it("should handle errors correctly", async () => { + // Since pages are reversed, the second page will now come first + expect(result.current.data?.pages).toEqual([ + { items: [4, 5, 6], nextPage: 2 }, + { items: [1, 2, 3], nextPage: 1 }, + ]); + + // Verify reversed pageParams + expect(result.current.data?.pageParams).toEqual([1, 0]); + + // Verify all items from reversed pages + const allItems = result.current.data?.pages.flatMap((page) => page.items); + expect(allItems).toEqual([4, 5, 6, 1, 2, 3]); + }); + it("should use custom cursor params", async () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); - useMockRequestHandler({ + // First page request handler + const firstRequestHandler = useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", - status: 500, - body: { code: 500, message: "Internal Server Error" }, + status: 200, + body: { items: [1, 2, 3], nextPage: 1 }, }); - const { result } = renderHook( - () => client.useInfiniteQuery("get", "/paginated-data", { params: { query: { limit: 3 } } }), + const { result, rerender } = renderHook( + () => + client.useInfiniteQuery( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + pageParamName: "follow_cursor", + }, + ), { wrapper }, ); - await waitFor(() => expect(result.current.isError).toBe(true)); + // Wait for initial query to complete + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // Verify first request + const firstRequestUrl = firstRequestHandler.getRequestUrl(); + expect(firstRequestUrl?.searchParams.get("limit")).toBe("3"); + expect(firstRequestUrl?.searchParams.get("follow_cursor")).toBe("0"); + + // Set up mock for second page before triggering next page fetch + const secondRequestHandler = useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [4, 5, 6], nextPage: 2 }, + }); + + // Fetch next page + await act(async () => { + await result.current.fetchNextPage(); + // Force a rerender to ensure state is updated + rerender(); + }); + + // Wait for second page to be fetched and verify loading states + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + expect(result.current.hasNextPage).toBe(true); + expect(result.current.data?.pages).toHaveLength(2); + }); + + // Verify second request + const secondRequestUrl = secondRequestHandler.getRequestUrl(); + expect(secondRequestUrl?.searchParams.get("limit")).toBe("3"); + expect(secondRequestUrl?.searchParams.get("follow_cursor")).toBe("1"); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.pages[0].nextPage).toBe(1); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.pages[1].nextPage).toBe(2); + + // Verify the complete data structure + expect(result.current.data?.pages).toEqual([ + { items: [1, 2, 3], nextPage: 1 }, + { items: [4, 5, 6], nextPage: 2 }, + ]); - expect(result.current.error).toEqual({ code: 500, message: "Internal Server Error" }); + // Verify we can access all items through pages + const allItems = result.current.data?.pages.flatMap((page) => page.items); + expect(allItems).toEqual([1, 2, 3, 4, 5, 6]); }); }); });