diff --git a/docs/openapi-react-query/use-infinite-query.md b/docs/openapi-react-query/use-infinite-query.md new file mode 100644 index 000000000..86bcbad1e --- /dev/null +++ b/docs/openapi-react-query/use-infinite-query.md @@ -0,0 +1,112 @@ +--- +title: useInfiniteQuery +--- + +# {{ $frontmatter.title }} + +The `useInfiniteQuery` method allows you to use the original [useInfiniteQuery](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) + +- The result is the same as the original function. +- The `queryKey` is `[method, path, params]`. +- `data` and `error` are fully typed. +- You can pass infinite query options as fourth parameter. + +::: tip +You can find more information about `useInfiniteQuery` on the [@tanstack/react-query documentation](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries). +::: + +## Example + +::: code-group + +```tsx [src/app.tsx] +import { $api } from "./api"; +const PostList = () => { + const { data, fetchNextPage, hasNextPage, isFetching } = + $api.useInfiniteQuery( + "get", + "/posts", + { + params: { + query: { + limit: 10, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + } + ); + + return ( +
+ {data?.pages.map((page, i) => ( +
+ {page.items.map((post) => ( +
{post.title}
+ ))} +
+ ))} + {hasNextPage && ( + + )} +
+ ); +}; + +export const App = () => { + return ( + `Error: ${error.message}`}> + + + ); +}; +``` + +```ts [src/api.ts] +import createFetchClient from "openapi-fetch"; +import createClient from "openapi-react-query"; +import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript + +const fetchClient = createFetchClient({ + baseUrl: "https://myapi.dev/v1/", +}); +export const $api = createClient(fetchClient); +``` + +::: + +## Api + +```tsx +const query = $api.useInfiniteQuery( + method, + path, + options, + infiniteQueryOptions, + queryClient +); +``` + +**Arguments** + +- `method` **(required)** + - The HTTP method to use for the request. + - The method is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `path` **(required)** + - The pathname to use for the request. + - Must be an available path for the given method in your schema. + - The pathname is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `options` + - The fetch options to use for the request. + - Only required if the OpenApi schema requires parameters. + - The options `params` are used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `infiniteQueryOptions` + - The original `useInfiniteQuery` options. + - [See more information](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery) +- `queryClient` + - The original `queryClient` option. + - [See more information](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index cbee066c0..1aba524b9 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -5,12 +5,15 @@ import { type UseQueryResult, type UseSuspenseQueryOptions, type UseSuspenseQueryResult, + type UseInfiniteQueryOptions, + type UseInfiniteQueryResult, type QueryClient, type QueryFunctionContext, type SkipToken, useMutation, useQuery, useSuspenseQuery, + useInfiniteQuery, } from "@tanstack/react-query"; import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch"; import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers"; @@ -84,6 +87,45 @@ export type UseSuspenseQueryMethod, Options?, QueryClient?] ) => UseSuspenseQueryResult; +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, @@ -101,6 +143,7 @@ export interface OpenapiQueryClient; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; + useInfiniteQuery: UseInfiniteQueryMethod; useMutation: UseMutationMethod; } @@ -128,12 +171,49 @@ 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 + return useInfiniteQuery( + { + ...baseOptions, + initialPageParam: 0, + getNextPageParam: (lastPage: any, allPages: any[], lastPageParam: number, allPageParams: number[]) => + options?.getNextPageParam?.(lastPage, allPages, lastPageParam, allPageParams) ?? allPages.length, + } as any, + queryClient, + ); + }, useMutation: (method, path, options, queryClient) => useMutation( { diff --git a/packages/openapi-react-query/test/fixtures/api.d.ts b/packages/openapi-react-query/test/fixtures/api.d.ts index cad5160d4..463ce893f 100644 --- a/packages/openapi-react-query/test/fixtures/api.d.ts +++ b/packages/openapi-react-query/test/fixtures/api.d.ts @@ -4,6 +4,58 @@ */ export interface paths { + "/paginated-data": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query: { + limit: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: number[]; + nextPage?: number; + }; + }; + }; + /** @description Error response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code?: number; + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/comment": { parameters: { query?: never; diff --git a/packages/openapi-react-query/test/fixtures/api.yaml b/packages/openapi-react-query/test/fixtures/api.yaml index 8994e1ba8..62b4c5c60 100644 --- a/packages/openapi-react-query/test/fixtures/api.yaml +++ b/packages/openapi-react-query/test/fixtures/api.yaml @@ -3,6 +3,39 @@ info: title: Test Specification version: "1.0" paths: + /paginated-data: + get: + parameters: + - in: query + name: limit + required: true + schema: + type: integer + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: integer + nextPage: + type: integer + '500': + description: Error response + content: + application/json: + schema: + type: object + properties: + code: + type: integer + message: + type: string /comment: put: requestBody: diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 294175df9..a2ad01efc 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 { fireEvent, render, renderHook, screen, waitFor, act } from "@testing-library/react"; +import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider, @@ -789,4 +789,65 @@ describe("client", () => { }); }); }); + describe("useInfiniteQuery", () => { + it("should fetch data correctly with pagination", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [1, 2, 3], nextPage: 1 }, + }); + + const { result } = renderHook( + () => client.useInfiniteQuery("get", "/paginated-data", { params: { query: { limit: 3 } } }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect((result.current.data as any).pages[0]).toEqual({ items: [1, 2, 3], nextPage: 1 }); + + // Set up mock for second page + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [4, 5, 6], nextPage: 2 }, + }); + + await result.current.fetchNextPage(); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect((result.current.data as any).pages).toHaveLength(2); + expect((result.current.data as any).pages[1]).toEqual({ items: [4, 5, 6], nextPage: 2 }); + }); + + it("should handle errors correctly", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 500, + body: { code: 500, message: "Internal Server Error" }, + }); + + const { result } = renderHook( + () => client.useInfiniteQuery("get", "/paginated-data", { params: { query: { limit: 3 } } }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toEqual({ code: 500, message: "Internal Server Error" }); + }); + }); });