From d1fa423a43a9d163832948068b678827bdad24be Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 08:43:00 -0300 Subject: [PATCH 01/10] Working function --- packages/openapi-react-query/src/index.ts | 169 ++++++++++++---------- 1 file changed, 96 insertions(+), 73 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 099fe653c..712b5d443 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -3,10 +3,10 @@ import { type UseMutationResult, type UseQueryOptions, type UseQueryResult, - type UseSuspenseQueryOptions, - type UseSuspenseQueryResult, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, type QueryClient, type QueryFunctionContext, type SkipToken, @@ -15,7 +15,7 @@ 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 +93,45 @@ 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>, +>( + 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 UseSuspenseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -115,45 +154,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,30 +199,54 @@ 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, - }); + const infiniteQueryOptions = < + Method extends HttpMethod, + Path extends PathsWithMethod, + 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: async ({ + queryKey: [method, path, init], + pageParam = 0, + signal, + }: QueryFunctionContext, number>) => { + 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, + cursor: pageParam, + }, + }, + }; + + const { data, error } = await fn(path, mergedInit as any); + if (error) { + throw error; + } + return data; + }, + ...options, + }); return { queryOptions, @@ -233,13 +257,12 @@ export default function createClient { 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, + { + ...baseOptions, + 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) => From 2ad2e7712d5f2be0ea01729a96f1c5c5d4a510af Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 12:23:02 -0300 Subject: [PATCH 02/10] So far so good --- packages/openapi-react-query/src/index.ts | 52 +++++------ .../test/fixtures/api.d.ts | 1 + .../test/fixtures/api.yaml | 5 ++ .../openapi-react-query/test/index.test.tsx | 87 ++++++++++++------- 4 files changed, 90 insertions(+), 55 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 712b5d443..e7b341cef 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -3,6 +3,8 @@ import { type UseMutationResult, type UseQueryOptions, type UseQueryResult, + type InfiniteData, + type InfiniteQueryObserverResult, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseSuspenseQueryOptions, @@ -103,34 +105,34 @@ export type UseInfiniteQueryMethod 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?, ] : [ - 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; +) => UseInfiniteQueryResult, Response["error"]>; export type UseSuspenseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, 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..f3f53236e 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,16 @@ 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({ + // Track request URLs using the mock handler + let firstRequestUrl: URL | undefined; + let secondRequestUrl: URL | undefined; + + // First page request handler + const firstRequestHandler = useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", @@ -835,17 +840,32 @@ 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 + 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,41 @@ 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); + }); - 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 second request + secondRequestUrl = secondRequestHandler.getRequestUrl(); + expect(secondRequestUrl?.searchParams.get('limit')).toBe('3'); + expect(secondRequestUrl?.searchParams.get('cursor')).toBe('1'); - it("should handle errors correctly", async () => { - const fetchClient = createFetchClient({ baseUrl }); - const client = createClient(fetchClient); + expect(result.current.data).toBeDefined(); + expect(result.current.data!.pages[0].nextPage).toBe(1); - 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 }, - ); + expect(result.current.data).toBeDefined(); + expect(result.current.data!.pages[1].nextPage).toBe(2); - await waitFor(() => expect(result.current.isError).toBe(true)); + // 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]); }); - }); + }) }); From 78bb72178f30f207a87c4760cdf9938f0abbf244 Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 12:49:26 -0300 Subject: [PATCH 03/10] Reusable options --- packages/openapi-react-query/src/index.ts | 32 +++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index e7b341cef..27ed6e013 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -100,36 +100,28 @@ export type UseInfiniteQueryMethod, Init extends MaybeOptionalInit, Response extends Required>, + Options extends Omit< + UseInfiniteQueryOptions< + Response["data"], + Response["error"], + Response["data"], + number, + QueryKey + >, + "queryKey" | "queryFn" + > >( method: Method, url: Path, ...[init, options, queryClient]: RequiredKeysOf extends never ? [ InitWithUnknowns?, - Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - Response["data"], - number, - QueryKey - >, - "queryKey" | "queryFn" - >?, + Options?, QueryClient?, ] : [ InitWithUnknowns, - Omit< - UseInfiniteQueryOptions< - Response["data"], - Response["error"], - Response["data"], - number, - QueryKey - >, - "queryKey" | "queryFn" - >?, + Options?, QueryClient?, ] ) => UseInfiniteQueryResult, Response["error"]>; From 6e4cde08d400b566ee3d8a2424b7e372fc7f3f96 Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 16:05:26 -0300 Subject: [PATCH 04/10] More tests --- .../openapi-react-query/test/index.test.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index f3f53236e..6ab68725b 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -909,5 +909,87 @@ describe("client", () => { 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(); + + // 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]); + }); }) }); From d5f5af08a9e485075d92ffefe05e8a49dfbcfd08 Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 18:02:42 -0300 Subject: [PATCH 05/10] Fixe select types --- packages/openapi-react-query/src/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 27ed6e013..f01938625 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -104,9 +104,10 @@ export type UseInfiniteQueryMethod, Response["data"], - number, - QueryKey + QueryKey, + unknown >, "queryKey" | "queryFn" > @@ -207,8 +208,9 @@ export default function createClient + Response["data"], + QueryKey, + unknown >, "queryKey" | "queryFn" >, From a97c5b3a1f0dcd8cf97d011f663368e8b35394ce Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 18:46:50 -0300 Subject: [PATCH 06/10] Better usage --- packages/openapi-react-query/src/index.ts | 156 +++++++++------------- 1 file changed, 64 insertions(+), 92 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index f01938625..05b528775 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -95,37 +95,33 @@ 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" - > ->( - method: Method, - url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [ - InitWithUnknowns?, - Options?, - QueryClient?, - ] - : [ - InitWithUnknowns, - Options?, - QueryClient?, - ] -) => UseInfiniteQueryResult, Response["error"]>; +export type UseInfiniteQueryMethod< + Paths extends Record>, + 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" + > + >( + method: Method, + url: Path, + init: InitWithUnknowns, + options: Options, + queryClient?: QueryClient + ) => UseInfiniteQueryResult, Response["error"]>); export type UseSuspenseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, @@ -194,73 +190,48 @@ 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"], - Response["data"], - QueryKey, - unknown - >, - "queryKey" | "queryFn" - >, - ) => ({ - queryKey: [method, path, init] as const, - queryFn: async ({ - queryKey: [method, path, init], - pageParam = 0, - signal, - }: QueryFunctionContext, number>) => { - 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, - cursor: pageParam, - }, - }, - }; - - const { data, error } = await fn(path, mergedInit as any); - if (error) { - throw error; - } - return data; - }, - ...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, - getNextPageParam: (lastPage: any, allPages: any[], lastPageParam: number, allPageParams: number[]) => - options?.getNextPageParam?.(lastPage, allPages, lastPageParam, allPageParams) ?? allPages.length, - } as any, - queryClient, - ); - }, + useInfiniteQuery: (method, path, init, options, queryClient) => + useInfiniteQuery( + { + queryKey: [method, path, init] as const, + queryFn: async < + Method extends HttpMethod, + Path extends PathsWithMethod, + >({ + 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, + cursor: pageParam, + }, + }, + }; + + const { data, error } = await fn(path, mergedInit as any); + if (error) { + throw error; + } + return data; + }, + ...options, + }, + queryClient, + ), useMutation: (method, path, options, queryClient) => useMutation( { @@ -281,3 +252,4 @@ export default function createClient Date: Fri, 24 Jan 2025 18:53:55 -0300 Subject: [PATCH 07/10] Lint --- packages/openapi-react-query/src/index.ts | 124 +++++++++--------- .../openapi-react-query/test/index.test.tsx | 103 +++++++-------- 2 files changed, 112 insertions(+), 115 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 05b528775..8cb6aad74 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -17,7 +17,13 @@ import { useSuspenseQuery, useInfiniteQuery, } from "@tanstack/react-query"; -import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient, DefaultParamsOption } 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 @@ -95,33 +101,29 @@ export type UseQueryMethod>, : [InitWithUnknowns, Options?, QueryClient?] ) => UseQueryResult, Response["error"]>; -export type UseInfiniteQueryMethod< - Paths extends Record>, - 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" - > - >( - method: Method, - url: Path, - init: InitWithUnknowns, - options: Options, - queryClient?: QueryClient - ) => UseInfiniteQueryResult, 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" + >, +>( + method: Method, + url: Path, + init: InitWithUnknowns, + options: Options, + queryClient?: QueryClient, +) => UseInfiniteQueryResult, Response["error"]>; export type UseSuspenseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, @@ -197,41 +199,38 @@ export default function createClient useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), useInfiniteQuery: (method, path, init, options, queryClient) => - useInfiniteQuery( - { - queryKey: [method, path, init] as const, - queryFn: async < - Method extends HttpMethod, - Path extends PathsWithMethod, - >({ - 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, - cursor: pageParam, - }, - }, - }; - - const { data, error } = await fn(path, mergedInit as any); - if (error) { - throw error; - } - return data; + useInfiniteQuery( + { + 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, + cursor: pageParam, + }, }, - ...options, - }, - queryClient, - ), + }; + + const { data, error } = await fn(path, mergedInit as any); + if (error) { + throw error; + } + return data; + }, + ...options, + }, + queryClient, + ), useMutation: (method, path, options, queryClient) => useMutation( { @@ -252,4 +251,3 @@ export default function createClient { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); - // Track request URLs using the mock handler - let firstRequestUrl: URL | undefined; - let secondRequestUrl: URL | undefined; - // First page request handler const firstRequestHandler = useMockRequestHandler({ baseUrl, @@ -841,18 +837,22 @@ describe("client", () => { }); const { result, rerender } = renderHook( - () => client.useInfiniteQuery("get", "/paginated-data", - { - params: { - query: { - limit: 3 - } - } - }, - { - getNextPageParam: (lastPage) => lastPage.nextPage, - initialPageParam: 0, - }), + () => + client.useInfiniteQuery( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + }, + ), { wrapper }, ); @@ -860,9 +860,9 @@ describe("client", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); // Verify first request - firstRequestUrl = firstRequestHandler.getRequestUrl(); - expect(firstRequestUrl?.searchParams.get('limit')).toBe('3'); - expect(firstRequestUrl?.searchParams.get('cursor')).toBe('0'); + 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({ @@ -888,25 +888,24 @@ describe("client", () => { }); // Verify second request - secondRequestUrl = secondRequestHandler.getRequestUrl(); - expect(secondRequestUrl?.searchParams.get('limit')).toBe('3'); - expect(secondRequestUrl?.searchParams.get('cursor')).toBe('1'); + 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?.pages[0].nextPage).toBe(1); expect(result.current.data).toBeDefined(); - expect(result.current.data!.pages[1].nextPage).toBe(2); + 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 } + { items: [4, 5, 6], nextPage: 2 }, ]); // Verify we can access all items through pages - const allItems = result.current.data?.pages.flatMap(page => page.items); + 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 () => { @@ -923,27 +922,27 @@ describe("client", () => { }); 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 } + () => + 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 @@ -979,17 +978,17 @@ describe("client", () => { expect(result.current.data).toBeDefined(); // Since pages are reversed, the second page will now come first - expect(result.current.data!.pages).toEqual([ + 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]); + expect(result.current.data?.pageParams).toEqual([1, 0]); // Verify all items from reversed pages - const allItems = result.current.data!.pages.flatMap((page) => page.items); + const allItems = result.current.data?.pages.flatMap((page) => page.items); expect(allItems).toEqual([4, 5, 6, 1, 2, 3]); }); - }) + }); }); From 78115c552d1cbcf79a0b5647327a58f227671ff2 Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Fri, 24 Jan 2025 19:07:40 -0300 Subject: [PATCH 08/10] Added changeset --- .changeset/mighty-comics-worry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mighty-comics-worry.md 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 From 5742f4f6a12d33d31d5485883e45c41a1ae451bf Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Sat, 25 Jan 2025 16:08:48 -0300 Subject: [PATCH 09/10] Add pageParamName --- packages/openapi-react-query/src/index.ts | 18 ++-- .../openapi-react-query/test/index.test.tsx | 88 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 8cb6aad74..f3ddc79c9 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -4,7 +4,6 @@ import { type UseQueryOptions, type UseQueryResult, type InfiniteData, - type InfiniteQueryObserverResult, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, type UseSuspenseQueryOptions, @@ -116,7 +115,9 @@ export type UseInfiniteQueryMethod, "queryKey" | "queryFn" - >, + > & { + pageParamName?: string; + }, >( method: Method, url: Path, @@ -198,8 +199,10 @@ export default function createClient, options), queryClient), useSuspenseQuery: (method, path, ...[init, options, queryClient]) => useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), - useInfiniteQuery: (method, path, init, options, queryClient) => - useInfiniteQuery( + useInfiniteQuery: (method, path, init, options, queryClient) => { + const { pageParamName = "cursor", ...restOptions } = options; + + return useInfiniteQuery( { queryKey: [method, path, init] as const, queryFn: async >({ @@ -216,7 +219,7 @@ export default function createClient useMutation( { diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 7a878bc6a..7e9d44740 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -822,6 +822,7 @@ describe("client", () => { }); }); }); + // >: {"tests": ["infiniteQuery"]} describe("useInfiniteQuery", () => { it("should fetch data correctly with pagination and include cursor", async () => { const fetchClient = createFetchClient({ baseUrl }); @@ -990,5 +991,92 @@ describe("client", () => { 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); + + // 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, + pageParamName: "follow_cursor", + }, + ), + { 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("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 }, + ]); + + // 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]); + }); }); + // <: {"tests": ["infiniteQuery"]} }); From bb9b4adaaaf930e8d6bfd777f6ccb930ff077504 Mon Sep 17 00:00:00 2001 From: Lucas Eduardo Date: Sat, 25 Jan 2025 16:28:58 -0300 Subject: [PATCH 10/10] Remove custom annotation --- packages/openapi-react-query/test/index.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 7e9d44740..d368be05f 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -822,7 +822,6 @@ describe("client", () => { }); }); }); - // >: {"tests": ["infiniteQuery"]} describe("useInfiniteQuery", () => { it("should fetch data correctly with pagination and include cursor", async () => { const fetchClient = createFetchClient({ baseUrl }); @@ -1078,5 +1077,4 @@ describe("client", () => { expect(allItems).toEqual([1, 2, 3, 4, 5, 6]); }); }); - // <: {"tests": ["infiniteQuery"]} });