Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useInfiniteGetList hook #8063

Merged
merged 10 commits into from
Aug 31, 2022
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ra-core/src/dataProvider/index.ts
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ export * from './useUpdate';
export * from './useUpdateMany';
export * from './useDelete';
export * from './useDeleteMany';
export * from './useInfiniteGetList';

export type { Options } from './fetch';

Empty file.
146 changes: 146 additions & 0 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from 'react';
import expect from 'expect';
import { render, waitFor } from '@testing-library/react';
import { QueryClient } from 'react-query';
import { UseInfiniteComponent } from './useInfiniteGetList.stories';
import { CoreAdminContext } from '../core';

describe('useInfiniteGetList', () => {
it('should call dataProvider.getList() on mount', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 1, title: 'Bruce' }],
total: 1,
})
),
} as any;

render(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent />
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
expect(dataProvider.getList).toBeCalledWith('posts', {
filter: {},
pagination: { page: 1, perPage: 1 },
sort: { field: 'id', order: 'DESC' },
});
});
});

it('should not call the dataProvider on update', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 1, title: 'Bruce' }],
total: 1,
})
),
} as any;
const { rerender } = render(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent />
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
});
rerender(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent />
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
});
});

it('should call the dataProvider on update when the resource changes', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 1, title: 'Bruce' }],
total: 1,
})
),
} as any;
const { rerender } = render(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent />
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
});
rerender(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent resource="comments" />
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(2);
});
});

it('should accept a meta parameter', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 1, title: 'Bruce' }],
total: 1,
})
),
} as any;
render(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent
pagination={{ page: 1, perPage: 20 }}
meta={{ hello: 'world' }}
/>
</CoreAdminContext>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledWith('posts', {
filter: {},
pagination: { page: 1, perPage: 20 },
sort: { field: 'id', order: 'DESC' },
meta: { hello: 'world' },
});
});
});

it('should execute success side effects on success', async () => {
const onSuccess = jest.fn();
const dataProvider = {
getList: jest
.fn()
.mockReturnValueOnce(
Promise.resolve({
data: [{ id: 1, title: 'Bruce' }],
total: 2,
})
)
.mockReturnValueOnce(
Promise.resolve({
data: [{ id: 3, foo: 'Wayne' }],
total: 2,
})
),
};
render(
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent options={{ onSuccess }} />
</CoreAdminContext>
);
await waitFor(() => {
expect(onSuccess).toBeCalledTimes(1);
expect(onSuccess.mock.calls.pop()[0]).toEqual({
data: [{ id: 1, title: 'Bruce' }],
total: 2,
});
});
});
});
73 changes: 73 additions & 0 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as React from 'react';
import { QueryClient } from 'react-query';
import { useInfiniteGetList } from '..';

import { CoreAdminContext } from '../core';

export default { title: 'ra-core/dataProvider/useInfiniteGetList' };

export const UseInfiniteListCore = () => {
const posts = [
{ id: 1, title: 'Hello' },
{ id: 2, title: 'World' },
{ id: 3, title: 'How' },
{ id: 4, title: 'are' },
{ id: 5, title: 'you' },
{ id: 6, title: 'today' },
{ id: 7, title: '?' },
];
const dataProvider = {
getList: (resource, params) => {
return Promise.resolve({
data: posts.slice(
(params.pagination.page - 1) * params.pagination.perPage,
(params.pagination.page - 1) * params.pagination.perPage +
params.pagination.perPage
),
total: posts.length,
});
},
} as any;
return (
<CoreAdminContext
queryClient={new QueryClient()}
dataProvider={dataProvider}
>
<UseInfiniteComponent />
</CoreAdminContext>
);
};

export const UseInfiniteComponent = ({
resource = 'posts',
pagination = { page: 1, perPage: 1 },
sort = { field: 'id', order: 'DESC' },
filter = {},
options = {},
meta = undefined,
callback = null,
...rest
}) => {
const { data, fetchNextPage, hasNextPage } = useInfiniteGetList(
resource,
{ pagination, sort, filter, meta },
options
);

return (
<>
<ul>
{data?.pages.map(page => {
return page.data.map(post => (
<li key={post.id}>{post.title}</li>
));
})}
</ul>
<div>
<button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
Refetch
</button>
</div>
</>
);
};
79 changes: 76 additions & 3 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,68 @@
import { useInfiniteQuery } from 'react-query';
import {
useInfiniteQuery,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
useQueryClient,
} from 'react-query';

import { RaRecord, GetListParams } from '../types';
import { RaRecord, GetListParams, GetInfiniteListResult } from '../types';
import { useDataProvider } from './useDataProvider';

/**
* Call the dataProvider.getList() method and return the resolved result
* as well as the loading state.
*
*
* This hook will return the cached result when called a second time
* with the same parameters, until the response arrives.
*
* @param {string} resource The resource name, e.g. 'posts'
* @param {Params} params The getList parameters { pagination, sort, filter, meta }
* @param {Object} options Options object to pass to the queryClient.
* May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { fetchNextPage(); } }
*
* @typedef Params
* @prop params.pagination The request pagination { page, perPage }, e.g. { page: 1, perPage: 10 }
* @prop params.sort The request sort { field, order }, e.g. { field: 'id', order: 'DESC' }
* @prop params.filter The request filters, e.g. { title: 'hello, world' }
* @prop params.meta Optional meta parameters
*
* @returns The current request state. Destructure as { data, total, error, isLoading, isSuccess, hasNextPage, fetchNextPage }.
*
* @example
*
* import { useInfinteGetList } from 'react-admin';
*
* const LatestNews = () => {
* const { data, total, isLoading, error, hasNextPage, fetchNextPage } = useInfiniteGetList(
* 'posts',
* { pagination: { page: 1, perPage: 10 }, sort: { field: 'published_at', order: 'DESC' } }
* );
* if (isLoading) { return <Loading />; }
* if (error) { return <p>ERROR</p>; }
* return (
* <>
* <ul>
* {data?.pages.map(page => {
* return page.data.map(post => (
* <li key={post.id}>{post.title}</li>
* ));
* })}
* </ul>
* <div>
* <button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
* Refetch
* </button>
* </div>
* </>
* );
* };
*/

export const useInfiniteGetList = <RecordType extends RaRecord = any>(
resource: string,
params: Partial<GetListParams> = {},
options?
options?: any
) => {
const {
pagination = { page: 1, perPage: 25 },
@@ -15,6 +71,7 @@ export const useInfiniteGetList = <RecordType extends RaRecord = any>(
meta,
} = params;
const dataProvider = useDataProvider();
const queryClient = useQueryClient();

const result = useInfiniteQuery(
[resource, 'getList', { pagination, sort, filter, meta }],
@@ -44,6 +101,22 @@ export const useInfiniteGetList = <RecordType extends RaRecord = any>(
? Number(lastPage.pageParam) + 1
: undefined;
},
onSuccess: data => {
// optimistically populate the getOne cache
data.pages.forEach(page => {
page.data.forEach(record => {
queryClient.setQueryData(
[
resource,
'getOne',
{ id: String(record.id), meta },
],
oldRecord => oldRecord ?? record
);
});
});
},
...options,
}
);

4 changes: 2 additions & 2 deletions packages/ra-core/src/types.ts
Original file line number Diff line number Diff line change
@@ -143,9 +143,9 @@ export interface GetListResult<RecordType extends RaRecord = any> {

export interface GetInfiniteListResult<RecordType extends RaRecord = any> {
data: RecordType[];
pagination: PaginationPayload;
total?: number;
pageParam?: number;
}

export interface GetOneParams<RecordType extends RaRecord = any> {
id: RecordType['id'];
meta?: any;