From 6613d102d8a8ee0e2a94f04668dc240c72ea32c4 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Thu, 8 Jun 2023 17:45:11 -0600 Subject: [PATCH 1/3] feat: add use api infinite query hook --- README.md | 32 ++++++++++ src/hooks.test.tsx | 147 +++++++++++++++++++++++++++++++++++++++++++-- src/hooks.ts | 25 ++++++++ src/types.ts | 25 ++++++-- 4 files changed, 220 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 32712a2..1a788d4 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,38 @@ query.data; // Message[] Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`. +### `useAPIInfiniteQuery` + +Type-safe wrapper around `useInfiniteQuery` from `react-query` which has a similar api as `useQuery` with a few key differences. + +```typescript +const query = useAPIInfiniteQuery( + 'GET /list', + { + // specify the property name on the payload that would indicate the next page + nextPageParamKey: 'after', + // on subsequent requests on the same list 'after' will be populated by the + // returned value from `getNextPageParam` + after: undefined, + }, + { + // from the previous response populate the `after` value that will be used on + // the payload for the next request + getNextPageParam: (last) => { + return (last as { next?: string }).next; + }, + }, +); +``` + +The return value of this hook is identical to the behavior of the `react-query` `useInfiniteQuery` hook's return value where `data` holds an array of pages. + +```tsx +{ + query.data.pages.flatMap((page) => page.items.map((item) => ...)); +} +``` + ### `useAPIMutation` Type-safe wrapper around `useMutation` from `react-query`. diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 9be8131..89cf482 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -12,6 +12,7 @@ type TestEndpoints = { Request: { filter: string }; Response: { message: string }; }; + 'GET /items/:id': { Request: { filter: string }; Response: { message: string }; @@ -24,17 +25,26 @@ type TestEndpoints = { Request: { message: string }[]; Response: { message: string }; }; + 'GET /list': { + Request: { filter: string; after?: string }; + Response: { items: { message: string }[]; next?: string }; + }; }; const client = axios.create({ baseURL: 'https://www.lifeomic.com' }); jest.spyOn(client, 'request'); -const { useAPIQuery, useAPIMutation, useCombinedAPIQueries, useAPICache } = - createAPIHooks({ - name: 'test-name', - client, - }); +const { + useAPIQuery, + useAPIInfiniteQuery, + useAPIMutation, + useCombinedAPIQueries, + useAPICache, +} = createAPIHooks({ + name: 'test-name', + client, +}); const network = createAPIMockingUtility({ baseUrl: 'https://www.lifeomic.com', @@ -103,6 +113,133 @@ describe('useAPIQuery', () => { }); }); +describe('useAPIInfiniteQuery', () => { + test('works correctly', async () => { + const next = 'second'; + const pages = { + first: { + next: 'second', + items: [ + { + message: 'first', + }, + ], + }, + next: { + next: undefined, + items: [ + { + message: 'second', + }, + ], + }, + }; + + const listSpy = jest + .fn() + .mockResolvedValueOnce({ + status: 200, + data: pages.first, + }) + .mockResolvedValueOnce({ + status: 200, + data: pages.next, + }) + .mockResolvedValue({ + status: 200, + data: pages.next, + }); + + network.mock('GET /list', listSpy); + + render(() => { + const query = useAPIInfiniteQuery('GET /list', { + filter: 'some-filter', + after: undefined, + nextPageParamKey: 'after', + }); + + return ( +
+ + {query?.data?.pages?.flatMap((page) => + page.items.map((message) => ( +

{message.message}

+ )), + )} +
+ ); + }); + + const [firstMessage, nextMessage] = Object.values(pages) + .flatMap((page) => page.items) + .map((item) => item.message); + + await TestingLibrary.screen.findByText(firstMessage); + expect(TestingLibrary.screen.queryByText(nextMessage)).not.toBeTruthy(); + + TestingLibrary.fireEvent.click(TestingLibrary.screen.getByRole('button')); + await TestingLibrary.screen.findByText(nextMessage); + + // all data should be on the page + expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy(); + + expect(listSpy).toHaveBeenCalledTimes(2); + expect(listSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + query: { filter: 'some-filter' }, + }), + ); + expect(listSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + query: { filter: 'some-filter', after: next }, + }), + ); + }); + + test('sending axios parameters works', async () => { + const listSpy = jest.fn().mockResolvedValue({ + status: 200, + data: { + next: undefined, + items: [], + }, + }); + + network.mock('GET /list', listSpy); + + render(() => { + useAPIInfiniteQuery( + 'GET /list', + { filter: 'test-filter', nextPageParamKey: 'after' }, + { axios: { headers: { 'test-header': 'test-value' } } }, + ); + return
; + }); + + await TestingLibrary.waitFor(() => { + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'test-header': 'test-value', + }), + }), + ); + }); + }); +}); + describe('useAPIMutation', () => { test('works correctly', async () => { const networkPost = jest.fn().mockReturnValue({ diff --git a/src/hooks.ts b/src/hooks.ts index bd3d912..6460cbb 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,6 @@ import { useQuery, + useInfiniteQuery, useMutation, QueryKey, useQueries, @@ -33,6 +34,30 @@ export const createAPIHooks = ({ options, ); }, + useAPIInfiniteQuery: (route, initPayload, options) => { + const { nextPageParamKey } = initPayload; + // @ts-expect-error, typescript enforcing only deleting non-required props + delete initPayload.nextPageParamKey; + const queryKey: QueryKey = [createQueryKey(name, route, initPayload)]; + return useInfiniteQuery( + queryKey, + ({ pageParam }) => { + const nextPayload = + pageParam && nextPageParamKey + ? { + ...initPayload, + [nextPageParamKey]: pageParam, + } + : undefined; + const payload = nextPayload || initPayload; + + return client + .request(route, payload, options?.axios) + .then((res) => res.data); + }, + options, + ); + }, useAPIMutation: (route, options) => useMutation( (payload) => client.request(route, payload).then((res) => res.data), diff --git a/src/types.ts b/src/types.ts index 450467f..f0cd1d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ import { UseMutationResult, UseQueryOptions, UseQueryResult, + UseInfiniteQueryOptions, + UseInfiniteQueryResult, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; import { CombinedQueriesResult } from './combination'; @@ -46,10 +48,14 @@ export type RequestPayloadOf< Route extends keyof Endpoints, > = Endpoints[Route]['Request'] & PathParamsOf; -type RestrictedUseQueryOptions = Omit< - UseQueryOptions, - 'queryKey' | 'queryFn' -> & { +type QueryOptions = + | UseQueryOptions + | UseInfiniteQueryOptions; + +type RestrictedUseQueryOptions< + Response, + TQueryOptions extends QueryOptions = UseQueryOptions, +> = Omit & { axios?: AxiosRequestConfig; }; @@ -101,6 +107,17 @@ export type APIQueryHooks = { options?: RestrictedUseQueryOptions, ) => UseQueryResult; + useAPIInfiniteQuery: ( + route: Route, + payload: RequestPayloadOf & { + nextPageParamKey: keyof RequestPayloadOf; + }, + options?: RestrictedUseQueryOptions< + Endpoints[Route]['Response'], + UseInfiniteQueryOptions + >, + ) => UseInfiniteQueryResult; + useAPIMutation: ( route: Route, options?: UseMutationOptions< From 7635d4bbb9d0ff7f1c7798ce10e4965f3e065ec4 Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Fri, 9 Jun 2023 14:26:41 -0600 Subject: [PATCH 2/3] improve api of infinite query hook wrapper --- README.md | 41 +++++++++++++------ src/hooks.test.tsx | 100 ++++++++++++++++++++++++++++++++++++++------- src/hooks.ts | 21 ++++------ src/types.ts | 53 +++++++++++++++++------- 4 files changed, 161 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 1a788d4..0e1c599 100644 --- a/README.md +++ b/README.md @@ -208,38 +208,55 @@ query.data; // Message[] Queries are cached using a combination of `route name + payload`. So, in the example above, the query key looks roughly like `['GET /messages', { filter: 'some-filter' }]`. -### `useAPIInfiniteQuery` +### `useInfiniteAPIQuery` Type-safe wrapper around `useInfiniteQuery` from `react-query` which has a similar api as `useQuery` with a few key differences. -```typescript -const query = useAPIInfiniteQuery( +```tsx +const query = useInfiniteAPIQuery( 'GET /list', { - // specify the property name on the payload that would indicate the next page - nextPageParamKey: 'after', - // on subsequent requests on the same list 'after' will be populated by the - // returned value from `getNextPageParam` after: undefined, }, { - // from the previous response populate the `after` value that will be used on - // the payload for the next request - getNextPageParam: (last) => { - return (last as { next?: string }).next; - }, + getNextPageParam: (lastPage) => ({ after: lastPage.next }), + getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }), }, ); + +... + + + {query?.data?.pages?.flatMap((page) => page.items.map((message) => (

{message.message}

@@ -179,21 +215,41 @@ describe('useAPIInfiniteQuery', () => { ); }); - const [firstMessage, nextMessage] = Object.values(pages) + const [previousMessage, firstMessage, nextMessage] = Object.values(pages) .flatMap((page) => page.items) .map((item) => item.message); + // initial load await TestingLibrary.screen.findByText(firstMessage); + expect(TestingLibrary.screen.queryByText(previousMessage)).not.toBeTruthy(); expect(TestingLibrary.screen.queryByText(nextMessage)).not.toBeTruthy(); - TestingLibrary.fireEvent.click(TestingLibrary.screen.getByRole('button')); + // load next page _after_ first + TestingLibrary.fireEvent.click( + TestingLibrary.screen.getByRole('button', { + name: /fetch next/i, + }), + ); await TestingLibrary.screen.findByText(nextMessage); - // all data should be on the page expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy(); expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(previousMessage)).not.toBeTruthy(); - expect(listSpy).toHaveBeenCalledTimes(2); + // load previous page _before_ first + TestingLibrary.fireEvent.click( + TestingLibrary.screen.getByRole('button', { + name: /fetch previous/i, + }), + ); + await TestingLibrary.screen.findByText(previousMessage); + + // all data should now be on the page + expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy(); + expect(TestingLibrary.screen.queryByText(previousMessage)).toBeTruthy(); + + expect(listSpy).toHaveBeenCalledTimes(3); expect(listSpy).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -206,6 +262,18 @@ describe('useAPIInfiniteQuery', () => { query: { filter: 'some-filter', after: next }, }), ); + expect(listSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + query: { filter: 'some-filter' }, + }), + ); + expect(listSpy).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + query: { filter: 'some-filter', before: previous }, + }), + ); }); test('sending axios parameters works', async () => { @@ -220,10 +288,12 @@ describe('useAPIInfiniteQuery', () => { network.mock('GET /list', listSpy); render(() => { - useAPIInfiniteQuery( + useInfiniteAPIQuery( 'GET /list', - { filter: 'test-filter', nextPageParamKey: 'after' }, - { axios: { headers: { 'test-header': 'test-value' } } }, + { filter: 'test-filter' }, + { + axios: { headers: { 'test-header': 'test-value' } }, + }, ); return
; }); diff --git a/src/hooks.ts b/src/hooks.ts index 6460cbb..e964e53 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -34,22 +34,15 @@ export const createAPIHooks = ({ options, ); }, - useAPIInfiniteQuery: (route, initPayload, options) => { - const { nextPageParamKey } = initPayload; - // @ts-expect-error, typescript enforcing only deleting non-required props - delete initPayload.nextPageParamKey; + useInfiniteAPIQuery: (route, initPayload, options) => { const queryKey: QueryKey = [createQueryKey(name, route, initPayload)]; - return useInfiniteQuery( + const query = useInfiniteQuery( queryKey, ({ pageParam }) => { - const nextPayload = - pageParam && nextPageParamKey - ? { - ...initPayload, - [nextPageParamKey]: pageParam, - } - : undefined; - const payload = nextPayload || initPayload; + const payload = { + ...initPayload, + ...pageParam, + } as typeof initPayload; return client .request(route, payload, options?.axios) @@ -57,6 +50,8 @@ export const createAPIHooks = ({ }, options, ); + + return query; }, useAPIMutation: (route, options) => useMutation( diff --git a/src/types.ts b/src/types.ts index f0cd1d0..77956f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,9 @@ import { UseQueryResult, UseInfiniteQueryOptions, UseInfiniteQueryResult, + FetchNextPageOptions, + FetchPreviousPageOptions, + InfiniteQueryObserverResult, } from '@tanstack/react-query'; import { AxiosRequestConfig } from 'axios'; import { CombinedQueriesResult } from './combination'; @@ -48,15 +51,36 @@ export type RequestPayloadOf< Route extends keyof Endpoints, > = Endpoints[Route]['Request'] & PathParamsOf; -type QueryOptions = - | UseQueryOptions - | UseInfiniteQueryOptions; +type RestrictedUseQueryOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +> & { + axios?: AxiosRequestConfig; +}; -type RestrictedUseQueryOptions< - Response, - TQueryOptions extends QueryOptions = UseQueryOptions, -> = Omit & { +type RestrictedUseInfiniteQueryOptions = Omit< + UseInfiniteQueryOptions, + 'queryKey' | 'queryFn' | 'getNextPageParam' | 'getPreviousPageParam' +> & { axios?: AxiosRequestConfig; + getNextPageParam?: (lastPage: Response) => Partial | undefined; + getPreviousPageParam?: (firstPage: Response) => Partial | undefined; +}; + +type RestrictedUseInfiniteQueryResult = Omit< + UseInfiniteQueryResult, + 'fetchNextPage' | 'fetchPreviousPage' +> & { + fetchNextPage: ( + options?: Omit & { + pageParam: Partial; + }, + ) => Promise>; + fetchPreviousPage: ( + options?: Omit & { + pageParam: Partial; + }, + ) => Promise>; }; export type CombinedRouteTuples< @@ -107,16 +131,17 @@ export type APIQueryHooks = { options?: RestrictedUseQueryOptions, ) => UseQueryResult; - useAPIInfiniteQuery: ( + useInfiniteAPIQuery: ( route: Route, - payload: RequestPayloadOf & { - nextPageParamKey: keyof RequestPayloadOf; - }, - options?: RestrictedUseQueryOptions< + payload: RequestPayloadOf, + options?: RestrictedUseInfiniteQueryOptions< Endpoints[Route]['Response'], - UseInfiniteQueryOptions + RequestPayloadOf >, - ) => UseInfiniteQueryResult; + ) => RestrictedUseInfiniteQueryResult< + Endpoints[Route]['Response'], + RequestPayloadOf + >; useAPIMutation: ( route: Route, From 49867c0f51ada376083471a061c276dcd35e5bba Mon Sep 17 00:00:00 2001 From: Josh Dowdle Date: Fri, 9 Jun 2023 14:29:51 -0600 Subject: [PATCH 3/3] fixups --- src/hooks.test.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/hooks.test.tsx b/src/hooks.test.tsx index 91a55ab..2f3eeb1 100644 --- a/src/hooks.test.tsx +++ b/src/hooks.test.tsx @@ -178,7 +178,6 @@ describe('useInfiniteAPIQuery', () => { after: undefined, }); - query.hasPreviousPage; return (