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
Show file tree
Hide file tree
Changes from 3 commits
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
Expand Up @@ -22,6 +22,7 @@ export * from './useUpdate';
export * from './useUpdateMany';
export * from './useDelete';
export * from './useDeleteMany';
export * from './useInfiniteGetList';

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

Expand Down
141 changes: 141 additions & 0 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as React from 'react';
import expect from 'expect';
import { screen, render, waitFor } from '@testing-library/react';
import { UseInfiniteListCore } from './useInfiniteGetList.stories';

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

render(
<UseInfiniteListCore
dataProvider={dataProvider}
resource="heroes"
/>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
expect(dataProvider.getList).toBeCalledWith('heroes', {
filter: {},
pagination: { page: 1, perPage: 20 },
sort: { field: 'id', order: 'DESC' },
});
});
});

it('should not call the dataProvider on update', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 73, name: 'France', code: 'FR' }],
total: 1,
})
),
} as any;
const { rerender } = render(
<UseInfiniteListCore dataProvider={dataProvider} />
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
});
rerender(<UseInfiniteListCore dataProvider={dataProvider} />);
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: 73, name: 'France', code: 'FR' }],
total: 1,
})
),
} as any;
const { rerender } = render(
<UseInfiniteListCore
dataProvider={dataProvider}
resource="heroes"
/>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(1);
});
rerender(<UseInfiniteListCore dataProvider={dataProvider} />);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledTimes(2);
});
});

it('should accept a meta parameter', async () => {
const dataProvider = {
getList: jest.fn(() =>
Promise.resolve({
data: [{ id: 73, name: 'France', code: 'FR' }],
total: 1,
})
),
} as any;
render(
<UseInfiniteListCore
dataProvider={dataProvider}
pagination={{ page: 1, perPage: 20 }}
meta={{ hello: 'world' }}
resource="heroes"
/>
);
await waitFor(() => {
expect(dataProvider.getList).toBeCalledWith('heroes', {
filter: {},
pagination: { page: 1, perPage: 20 },
sort: { field: 'id', order: 'DESC' },
meta: { hello: 'world' },
});
});
});

it('should execute success side effects on success', async () => {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
const countries = [
{ id: 73, name: 'France', code: 'FR' },
{ id: 74, name: 'Italia', code: 'IT' },
];
const dataProvider = {
getList: (resource, params) => {
return Promise.resolve({
data: countries.slice(
(params.pagination.page - 1) *
params.pagination.perPage,
(params.pagination.page - 1) *
params.pagination.perPage +
params.pagination.perPage
),
total: countries.length,
});
},
};

render(
<UseInfiniteListCore
dataProvider={dataProvider}
pagination={{ page: 1, perPage: 1 }}
/>
);
await waitFor(async () => {
expect(screen.getByLabelText('country').innerHTML).toContain(
'France'
);
screen.getByLabelText('refetch-button').click();
await waitFor(async () => {
expect(screen.queryAllByLabelText('country')).toHaveLength(2);
});
});
});
});
74 changes: 74 additions & 0 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as React from 'react';
import { useInfiniteGetList } from '..';

import { CoreAdminContext } from '../core';
import { countries } from '../storybook/data';

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

export const UseInfiniteListCore = props => {
let { dataProvider, ...rest } = props;

if (!dataProvider) {
dataProvider = {
getList: (resource, params) => {
return Promise.resolve({
data: countries.slice(
(params.pagination.page - 1) *
params.pagination.perPage,
(params.pagination.page - 1) *
params.pagination.perPage +
params.pagination.perPage
),
total: countries.length,
});
},
} as any;
}

return (
<CoreAdminContext dataProvider={dataProvider}>
<UseInfiniteComponent {...rest} />
</CoreAdminContext>
);
};

const UseInfiniteComponent = ({
resource = 'countries',
pagination = { page: 1, perPage: 20 },
sort = { field: 'id', order: 'DESC' },
filter = {},
options = {},
meta = undefined,
callback = null,
slax57 marked this conversation as resolved.
Show resolved Hide resolved
...rest
}) => {
const { data, fetchNextPage, hasNextPage } = useInfiniteGetList(
resource,
{ pagination, sort, filter, meta },
options
);

return (
<>
<ul>
{data?.pages.map(page => {
return page.data.map(country => (
<li aria-label="country" key={country.code}>
{country.name} -- {country.code}
</li>
));
})}
</ul>
<div>
<button
aria-label="refetch-button"
disabled={!hasNextPage}
onClick={() => fetchNextPage()}
>
Refetch
slax57 marked this conversation as resolved.
Show resolved Hide resolved
</button>
</div>
</>
);
};
138 changes: 138 additions & 0 deletions packages/ra-core/src/dataProvider/useInfiniteGetList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
} from 'react-query';

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.
arimet marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
slax57 marked this conversation as resolved.
Show resolved Hide resolved
* </button>
* </div>
* </>
* );
* };
*/

export const useInfiniteGetList = <RecordType extends RaRecord = any>(
resource: string,
params: Partial<GetListParams> = {},
options?: any
slax57 marked this conversation as resolved.
Show resolved Hide resolved
): UseInfiniteQueryResult<GetInfiniteListResult> => {
slax57 marked this conversation as resolved.
Show resolved Hide resolved
const {
pagination = { page: 1, perPage: 25 },
sort = { field: 'id', order: 'DESC' },
filter = {},
meta,
} = params;
const dataProvider = useDataProvider();
const queryClient = useQueryClient();

return useInfiniteQuery(
slax57 marked this conversation as resolved.
Show resolved Hide resolved
[resource, 'getList', { pagination, sort, filter, meta }],
({ pageParam = pagination.page }) =>
dataProvider
.getList<RecordType>(resource, {
pagination: {
page: pageParam,
perPage: pagination.perPage,
},
sort,
filter,
meta,
})
.then(({ data, pageInfo, total }) => ({
data,
total,
pageParam,
pageInfo,
})),
{
...options,
getNextPageParam: lastLoadedPage => {
if (lastLoadedPage.pageInfo) {
return lastLoadedPage.pageInfo.hasNextPage
? lastLoadedPage.pageParam + 1
: undefined;
}
const totalPages = Math.ceil(
(lastLoadedPage.total || 0) / pagination.perPage
);

return lastLoadedPage.pageParam < totalPages
? Number(lastLoadedPage.pageParam) + 1
: undefined;
},
getPreviousPageParam: lastLoadedPage => {
if (lastLoadedPage.pageInfo) {
return lastLoadedPage.pageInfo.hasPreviousPage
? lastLoadedPage.pageParam - 1
: undefined;
}

return lastLoadedPage.pageParam === 1
? undefined
: lastLoadedPage.pageParam - 1;
},
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
);
});
});
},
}
);
};
Loading