Skip to content

Commit

Permalink
Merge pull request #24 from lifeomic/use-infinite-query
Browse files Browse the repository at this point in the history
feat: add use api infinite query hook
  • Loading branch information
jkdowdle authored Jun 9, 2023
2 parents bacacab + 49867c0 commit cd61181
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 5 deletions.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +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' }]`.
### `useInfiniteAPIQuery`
Type-safe wrapper around `useInfiniteQuery` from `react-query` which has a similar api as `useQuery` with a few key differences.
```tsx
const query = useInfiniteAPIQuery(
'GET /list',
{
after: undefined,
},
{
getNextPageParam: (lastPage) => ({ after: lastPage.next }),
getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }),
},
);

...

<button
onClick={() => {
void query.fetchNextPage();

// Or fetch previous page
// void query.fetchPreviousPage();
}}
/>;
```
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.
When returning `undefined` from `getNextPageParam` it will set `query.hasNextPage` to false, otherwise it will merge the next api request payload with the returned object, likewise for `getPreviousPageParam` and `query.hasPreviousPage`.
```tsx
{
query.data.pages.flatMap((page) => page.items.map((item) => ...));
}
```
An alternative to using the `getNextPageParam` or `getPreviousPageParam` callback options, the query methods also accept a `pageParam` input.
```typescript
const lastPage = query?.data?.pages[query.data.pages.length - 1];
query.fetchNextPage({
pageParam: {
after: lastPage?.next,
},
});
```
### `useAPIMutation`
Type-safe wrapper around `useMutation` from `react-query`.
Expand Down
210 changes: 205 additions & 5 deletions src/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type TestEndpoints = {
Request: { filter: string };
Response: { message: string };
};

'GET /items/:id': {
Request: { filter: string };
Response: { message: string };
Expand All @@ -24,17 +25,34 @@ type TestEndpoints = {
Request: { message: string }[];
Response: { message: string };
};
'GET /list': {
Request: {
filter: string;
after?: string;
before?: string;
};
Response: {
items: { message: string }[];
next?: string;
previous?: string;
};
};
};

const client = axios.create({ baseURL: 'https://www.lifeomic.com' });

jest.spyOn(client, 'request');

const { useAPIQuery, useAPIMutation, useCombinedAPIQueries, useAPICache } =
createAPIHooks<TestEndpoints>({
name: 'test-name',
client,
});
const {
useAPIQuery,
useInfiniteAPIQuery,
useAPIMutation,
useCombinedAPIQueries,
useAPICache,
} = createAPIHooks<TestEndpoints>({
name: 'test-name',
client,
});

const network = createAPIMockingUtility<TestEndpoints>({
baseUrl: 'https://www.lifeomic.com',
Expand Down Expand Up @@ -103,6 +121,188 @@ describe('useAPIQuery', () => {
});
});

describe('useInfiniteAPIQuery', () => {
test('works correctly', async () => {
const next = 'second';
const previous = 'previous';
const pages = {
previous: {
previous: undefined,
next: 'first',
items: [
{
message: 'previous',
},
],
},
first: {
previous,
next: 'second',
items: [
{
message: 'first',
},
],
},
next: {
previous: 'first',
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.previous,
});

network.mock('GET /list', listSpy);

render(() => {
const query = useInfiniteAPIQuery('GET /list', {
filter: 'some-filter',
after: undefined,
});

return (
<div>
<button
onClick={() => {
const lastPage = query?.data?.pages.at(-1);
void query.fetchNextPage({
pageParam: {
after: lastPage?.next,
},
});
}}
>
fetch next
</button>
<button
onClick={() => {
const firstPage = query?.data?.pages.at(0);

void query.fetchPreviousPage({
pageParam: {
before: firstPage?.previous,
},
});
}}
>
fetch previous
</button>
{query?.data?.pages?.flatMap((page) =>
page.items.map((message) => (
<p key={message.message}>{message.message}</p>
)),
)}
</div>
);
});

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();

// load next page _after_ first
TestingLibrary.fireEvent.click(
TestingLibrary.screen.getByRole('button', {
name: /fetch next/i,
}),
);
await TestingLibrary.screen.findByText(nextMessage);

expect(TestingLibrary.screen.queryByText(firstMessage)).toBeTruthy();
expect(TestingLibrary.screen.queryByText(nextMessage)).toBeTruthy();
expect(TestingLibrary.screen.queryByText(previousMessage)).not.toBeTruthy();

// 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({
query: { filter: 'some-filter' },
}),
);
expect(listSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
query: { filter: 'some-filter', after: next },
}),
);
expect(listSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
query: { filter: 'some-filter', before: previous },
}),
);
});

test('sending axios parameters works', async () => {
const listSpy = jest.fn().mockResolvedValue({
status: 200,
data: {
next: undefined,
items: [],
},
});

network.mock('GET /list', listSpy);

render(() => {
useInfiniteAPIQuery(
'GET /list',
{ filter: 'test-filter' },
{
axios: { headers: { 'test-header': 'test-value' } },
},
);
return <div />;
});

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({
Expand Down
20 changes: 20 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
useQuery,
useInfiniteQuery,
useMutation,
QueryKey,
useQueries,
Expand Down Expand Up @@ -33,6 +34,25 @@ export const createAPIHooks = <Endpoints extends RoughEndpoints>({
options,
);
},
useInfiniteAPIQuery: (route, initPayload, options) => {
const queryKey: QueryKey = [createQueryKey(name, route, initPayload)];
const query = useInfiniteQuery(
queryKey,
({ pageParam }) => {
const payload = {
...initPayload,
...pageParam,
} as typeof initPayload;

return client
.request(route, payload, options?.axios)
.then((res) => res.data);
},
options,
);

return query;
},
useAPIMutation: (route, options) =>
useMutation(
(payload) => client.request(route, payload).then((res) => res.data),
Expand Down
42 changes: 42 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import {
UseMutationResult,
UseQueryOptions,
UseQueryResult,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
FetchNextPageOptions,
FetchPreviousPageOptions,
InfiniteQueryObserverResult,
} from '@tanstack/react-query';
import { AxiosRequestConfig } from 'axios';
import { CombinedQueriesResult } from './combination';
Expand Down Expand Up @@ -53,6 +58,31 @@ type RestrictedUseQueryOptions<Response> = Omit<
axios?: AxiosRequestConfig;
};

type RestrictedUseInfiniteQueryOptions<Response, Request> = Omit<
UseInfiniteQueryOptions<Response, unknown>,
'queryKey' | 'queryFn' | 'getNextPageParam' | 'getPreviousPageParam'
> & {
axios?: AxiosRequestConfig;
getNextPageParam?: (lastPage: Response) => Partial<Request> | undefined;
getPreviousPageParam?: (firstPage: Response) => Partial<Request> | undefined;
};

type RestrictedUseInfiniteQueryResult<Response, Request> = Omit<
UseInfiniteQueryResult<Response>,
'fetchNextPage' | 'fetchPreviousPage'
> & {
fetchNextPage: (
options?: Omit<FetchNextPageOptions, 'pageParam'> & {
pageParam: Partial<Request>;
},
) => Promise<InfiniteQueryObserverResult<Response>>;
fetchPreviousPage: (
options?: Omit<FetchPreviousPageOptions, 'pageParam'> & {
pageParam: Partial<Request>;
},
) => Promise<InfiniteQueryObserverResult<Response>>;
};

export type CombinedRouteTuples<
Endpoints extends RoughEndpoints,
Routes extends (keyof Endpoints)[],
Expand Down Expand Up @@ -101,6 +131,18 @@ export type APIQueryHooks<Endpoints extends RoughEndpoints> = {
options?: RestrictedUseQueryOptions<Endpoints[Route]['Response']>,
) => UseQueryResult<Endpoints[Route]['Response']>;

useInfiniteAPIQuery: <Route extends keyof Endpoints & string>(
route: Route,
payload: RequestPayloadOf<Endpoints, Route>,
options?: RestrictedUseInfiniteQueryOptions<
Endpoints[Route]['Response'],
RequestPayloadOf<Endpoints, Route>
>,
) => RestrictedUseInfiniteQueryResult<
Endpoints[Route]['Response'],
RequestPayloadOf<Endpoints, Route>
>;

useAPIMutation: <Route extends keyof Endpoints & string>(
route: Route,
options?: UseMutationOptions<
Expand Down

0 comments on commit cd61181

Please sign in to comment.