diff --git a/UPGRADE.md b/UPGRADE.md index 684a4c08df8..5b310a5ad37 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1539,6 +1539,34 @@ In general, you should use `isLoading`. It's false as long as the data has never The new props are actually returned by react-query's `useQuery` hook. Check [their documentation](https://react-query.tanstack.com/reference/useQuery) for more information. +## Changes In Translation Messages + +The `ra.navigation.prev` message was renamed to `ra.navigation.previous`. Update your translation files accordingly. + +```diff +const messages = { + ra: { + navigation: { + no_results: 'No results found', + no_more_results: + 'The page number %{page} is out of boundaries. Try the previous page.', + page_out_of_boundaries: 'Page number %{page} out of boundaries', + page_out_from_end: 'Cannot go after last page', + page_out_from_begin: 'Cannot go before page 1', + page_range_info: '%{offsetBegin}-%{offsetEnd} of %{total}', + partial_page_range_info: + '%{offsetBegin}-%{offsetEnd} of more than %{offsetEnd}', + current_page: 'Page %{page}', + page: 'Go to page %{page}', + next: 'Go to next page', +- prev: 'Go to previous page', ++ previous: 'Go to previous page', + page_rows_per_page: 'Rows per page:', + skip_nav: 'Skip to content', + }, + // ... +``` + ## Unit Tests for Data Provider Dependent Components Need A QueryClientContext If you were using components dependent on the dataProvider hooks in isolation (e.g. in unit or integration tests), you now need to wrap them inside a `` component, to let the access react-query's `QueryClient` instance. diff --git a/cypress/support/ListPage.js b/cypress/support/ListPage.js index cbc13792cb3..d0971446218 100644 --- a/cypress/support/ListPage.js +++ b/cypress/support/ListPage.js @@ -10,9 +10,9 @@ export default url => ({ filterMenuItem: source => `.new-filter-item[data-key="${source}"]`, hideFilterButton: source => `.filter-field[data-source="${source}"] .hide-filter`, - nextPage: '.next-page', - pageNumber: n => `.page-number[data-page='${n - 1}']`, - previousPage: '.previous-page', + nextPage: "button[aria-label='Go to next page']", + previousPage: "button[aria-label='Go to previous page']", + pageNumber: n => `button[aria-label='Go to page ${n}']`, recordRows: '.datagrid-body tr', viewsColumn: '.datagrid-body tr td:nth-child(7)', datagridHeaders: 'th', @@ -53,15 +53,15 @@ export default url => ({ }, nextPage() { - cy.get(this.elements.nextPage).click({ force: true }); + cy.get(this.elements.nextPage).click(); }, previousPage() { - cy.get(this.elements.previousPage).click({ force: true }); + cy.get(this.elements.previousPage).click(); }, goToPage(n) { - return cy.get(this.elements.pageNumber(n)).click({ force: true }); + return cy.get(this.elements.pageNumber(n)).click(); }, addCommentableFilter() { diff --git a/docs/DataProviderWriting.md b/docs/DataProviderWriting.md index 37e544034d5..ab7f6492e38 100644 --- a/docs/DataProviderWriting.md +++ b/docs/DataProviderWriting.md @@ -94,9 +94,9 @@ Data Providers methods must return a Promise for an object with a `data` propert | Method | Response format | | ------------------ | --------------------------------------------------------------- | -| `getList` | `{ data: {Record[]}, total: {int}, validUntil?: {Date} }` | -| `getOne` | `{ data: {Record}, validUntil?: {Date} }` | -| `getMany` | `{ data: {Record[]}, validUntil?: {Date} }` | +| `getList` | `{ data: {Record[]}, total: {int} }` | +| `getOne` | `{ data: {Record} }` | +| `getMany` | `{ data: {Record[]} }` | | `getManyReference` | `{ data: {Record[]}, total: {int} }` | | `create` | `{ data: {Record} }` | | `update` | `{ data: {Record} }` | @@ -198,7 +198,33 @@ dataProvider.deleteMany('posts', { ids: [123, 234] }) // } ``` -**Tip**: The `validUntil` field in the response is optional. It enables the Application cache, a client-side optimization to speed up rendering and reduce network traffic. Check [the Caching documentation](./Caching.md#application-cache) for more details. +## Partial Pagination + +The `getList()` and `getManyReference()` methods return paginated responses. Sometimes, executing a "count" server-side to return the `total` number of records is expensive. In this case, you can omit the `total` property in the response, and pass a `pageInfo` object instead, specifying if there are previous and next pages: + +```js +dataProvider.getList('posts', { + pagination: { page: 1, perPage: 5 }, + sort: { field: 'title', order: 'ASC' }, + filter: { author_id: 12 }, +}) +.then(response => console.log(response)); +// { +// data: [ +// { id: 126, title: "allo?", author_id: 12 }, +// { id: 127, title: "bien le bonjour", author_id: 12 }, +// { id: 124, title: "good day sunshine", author_id: 12 }, +// { id: 123, title: "hello, world", author_id: 12 }, +// { id: 125, title: "howdy partner", author_id: 12 }, +// ], +// pageInfo: { +// hasPreviousPage: false, +// hasNextPage: true, +// } +// } +``` + +React-admin's `` component will automatically handle the `pageInfo` object and display the appropriate pagination controls. ## Error Format diff --git a/docs/ListTutorial.md b/docs/ListTutorial.md index 4b74a31cb1d..2be8f56fb20 100644 --- a/docs/ListTutorial.md +++ b/docs/ListTutorial.md @@ -626,7 +626,9 @@ The [``](./Pagination.md) component gets the following constants fro * `page`: The current page number (integer). First page is `1`. * `perPage`: The number of records per page. * `setPage`: `Function(page: number) => void`. A function that set the current page number. -* `total`: The total number of records. +* `total`: The total number of records (may be undefined when the data provider uses [Partial pagination](./DataProviderWriting.md#partial-pagination)). +* `hasPreviousPage`: True if the page number is greater than 1. +* `hasNextPage`: True if the page number is lower than the total number of pages. * `actions`: A component that displays the pagination buttons (default: ``) * `limit`: An element that is displayed if there is no data to show (default: ``) @@ -639,24 +641,29 @@ import ChevronLeft from '@mui/icons-material/ChevronLeft'; import ChevronRight from '@mui/icons-material/ChevronRight'; const PostPagination = () => { - const { page, perPage, total, setPage } = useListContext(); - const nbPages = Math.ceil(total / perPage) || 1; + const { page, hasPreviousPage, hasNextPage, setPage } = useListContext(); + if (!hasPreviousPage && !hasNextPage) return null; return ( - nbPages > 1 && - - {page > 1 && - - } - {page !== nbPages && - - } - + + {hasPreviousPage && + + } + {hasNextPage && + + } + ); } @@ -676,7 +683,15 @@ import { PaginationActions as RaPaginationActions, } from 'react-admin'; -export const PaginationActions = props => ; +export const PaginationActions = props => ( + component + color="primary" + showFirstButton + showLastButton + /> +); export const Pagination = props => ; diff --git a/docs/useGetList.md b/docs/useGetList.md index 7d9a4a3c083..2cbc77a99d0 100644 --- a/docs/useGetList.md +++ b/docs/useGetList.md @@ -7,30 +7,79 @@ title: "useGetList" This hook calls `dataProvider.getList()` when the component mounts. It's ideal for getting a list of records. It supports filtering, sorting, and pagination. + +## Syntax + ```jsx -// syntax const { data, total, isLoading, error, refetch } = useGetList( resource, { pagination, sort, filter, meta }, options ); +``` -// example +## Usage + +```jsx import { useGetList } from 'react-admin'; const LatestNews = () => { - const { data, isLoading, error } = useGetList( + const { data, total, isLoading, error } = useGetList( 'posts', - { pagination: { page: 1, perPage: 10 }, sort: { field: 'published_at', order: 'DESC' } } + { + pagination: { page: 1, perPage: 10 }, + sort: { field: 'published_at', order: 'DESC' } + } ); if (isLoading) { return ; } if (error) { return

ERROR

; } return ( -
    - {data.map(record => -
  • {record.title}
  • - )} -
+ <> +

Latest news

+
    + {data.map(record => +
  • {record.title}
  • + )} +
+

{data.length} / {total} articles

+ ); }; ``` + +## Partial Pagination + +If your data provider doesn't return the `total` number of records (see [Partial Pagination](./DataProviderWriting.md#partial-pagination)), you can use the `pageInfo` field to determine if there are more records to fetch. + +```jsx +import { useState } from 'react'; +import { useGetList } from 'react-admin'; + +const LatestNews = () => { + const [page, setPage] = useState(1); + const { data, pageInfo, isLoading, error } = useGetList( + 'posts', + { + pagination: { page, perPage: 10 }, + sort: { field: 'published_at', order: 'DESC' } + } + ); + if (isLoading) { return ; } + if (error) { return

ERROR

; } + const { hasNextPage, hasPreviousPage } = pageInfo; + + const getNextPage = () => setPage(page + 1); + + return ( + <> +

Latest news

+
    + {data.map(record => +
  • {record.title}
  • + )} +
+ {hasNextPage && } + + ); +}; +``` \ No newline at end of file diff --git a/docs/useGetManyReference.md b/docs/useGetManyReference.md index 0f5337409df..6fe1b64241a 100644 --- a/docs/useGetManyReference.md +++ b/docs/useGetManyReference.md @@ -7,15 +7,19 @@ title: "useGetManyReference" This hook calls `dataProvider.getManyReference()` when the component mounts. It queries the data provider for a list of records related to another one (e.g. all the comments for a post). It supports filtering, sorting, and pagination. +## Syntax + ```jsx -// syntax const { data, total, isLoading, error, refetch } = useGetManyReference( resource, { target, id, pagination, sort, filter, meta }, options ); +``` -// example +## Usage + +```jsx import { useGetManyReference } from 'react-admin'; const PostComments = ({ record }) => { @@ -40,3 +44,41 @@ const PostComments = ({ record }) => { ); }; ``` + +## Partial Pagination + +If your data provider doesn't return the `total` number of records (see [Partial Pagination](./DataProviderWriting.md#partial-pagination)), you can use the `pageInfo` field to determine if there are more records to fetch. + +```jsx +import { useState } from 'react'; +import { useGetManyReference } from 'react-admin'; + +const PostComments = ({ record }) => { + const [page, setPage] = useState(1); + const { data, isLoading, pageInfo, error } = useGetManyReference( + 'comments', + { + target: 'post_id', + id: record.id, + pagination: { page, perPage: 10 }, + sort: { field: 'published_at', order: 'DESC' } + } + ); + if (isLoading) { return ; } + if (error) { return

ERROR

; } + const { hasNextPage, hasPreviousPage } = pageInfo; + + const getNextPage = () => setPage(page + 1); + + return ( + <> +
    + {data.map(comment => ( +
  • {comment.body}
  • + ))} +
+ {hasNextPage && } + + ); +}; +``` \ No newline at end of file diff --git a/docs/useList.md b/docs/useList.md index 93c0a1f67b3..9e821e9a1cf 100644 --- a/docs/useList.md +++ b/docs/useList.md @@ -220,6 +220,8 @@ const { perPage, // the number of results per page. Defaults to 25 setPage, // a callback to change the page, e.g. setPage(3) setPerPage, // a callback to change the number of results per page, e.g. setPerPage(25) + hasPreviousPage, // boolean, true if the current page is not the first one + hasNextPage, // boolean, true if the current page is not the last one // sorting sort, // a sort object { field, order }, e.g. { field: 'date', order: 'DESC' } setSort, // a callback to change the sort, e.g. setSort({ field: 'name', order: 'ASC' }) diff --git a/docs/useListContext.md b/docs/useListContext.md index c54c4a42f10..d032d782619 100644 --- a/docs/useListContext.md +++ b/docs/useListContext.md @@ -70,6 +70,8 @@ const { perPage, // the number of results per page. Defaults to 25 setPage, // a callback to change the page, e.g. setPage(3) setPerPage, // a callback to change the number of results per page, e.g. setPerPage(25) + hasPreviousPage, // boolean, true if the current page is not the first one + hasNextPage, // boolean, true if the current page is not the last one // sorting sort, // a sort object { field, order }, e.g. { field: 'date', order: 'DESC' } setSort, // a callback to change the sort, e.g. setSort({ field: 'name', orfer: 'ASC' }) diff --git a/docs/useListController.md b/docs/useListController.md index c21666a6cb4..e14ad2175a2 100644 --- a/docs/useListController.md +++ b/docs/useListController.md @@ -101,6 +101,8 @@ const { perPage, // the number of results per page. Defaults to 25 setPage, // a callback to change the page, e.g. setPage(3) setPerPage, // a callback to change the number of results per page, e.g. setPerPage(25) + hasPreviousPage, // boolean, true if the current page is not the first one + hasNextPage, // boolean, true if the current page is not the last one // sorting sort, // a sort object { field, order }, e.g. { field: 'date', order: 'DESC' } setSort, // a callback to change the sort, e.g. setSort({ field: 'name', order: 'ASC' }) diff --git a/examples/simple/src/comments/CommentList.tsx b/examples/simple/src/comments/CommentList.tsx index ff45075c648..2326b6c51b1 100644 --- a/examples/simple/src/comments/CommentList.tsx +++ b/examples/simple/src/comments/CommentList.tsx @@ -1,17 +1,13 @@ import * as React from 'react'; import { styled } from '@mui/material/styles'; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import ChevronRight from '@mui/icons-material/ChevronRight'; import PersonIcon from '@mui/icons-material/Person'; import { Avatar, - Button, Card, CardActions, CardContent, CardHeader, Grid, - Toolbar, Typography, useMediaQuery, Theme, @@ -23,7 +19,7 @@ import { ListActions, DateField, EditButton, - PaginationLimit, + Pagination, ReferenceField, ReferenceInput, SearchInput, @@ -103,43 +99,6 @@ const exporter = (records, fetchRelatedRecords) => }); }); -const CommentPagination = () => { - const { isLoading, data, page, perPage, total, setPage } = useListContext(); - const translate = useTranslate(); - const nbPages = Math.ceil(total / perPage) || 1; - if (!isLoading && (total === 0 || (data && !data.length))) { - return ; - } - - return ( - nbPages > 1 && ( - - {page > 1 && ( - - )} - {page !== nbPages && ( - - )} - - ) - ); -}; - const CommentGrid = () => { const { data } = useListContext(); const translate = useTranslate(); @@ -171,7 +130,17 @@ const CommentGrid = () => { } /> - + @@ -229,7 +198,7 @@ const ListView = () => { <ListToolbar filters={commentFilters} actions={<ListActions />} /> {isSmall ? <CommentMobileList /> : <CommentGrid />} - <CommentPagination /> + <Pagination rowsPerPageOptions={[6, 9, 12]} /> </Root> ); }; diff --git a/examples/simple/src/dataProvider.tsx b/examples/simple/src/dataProvider.tsx index 834e474551f..cdcc64dbd0e 100644 --- a/examples/simple/src/dataProvider.tsx +++ b/examples/simple/src/dataProvider.tsx @@ -9,6 +9,20 @@ const dataProvider = fakeRestProvider(data, true); const addTagsSearchSupport = (dataProvider: DataProvider) => ({ ...dataProvider, getList: (resource, params) => { + if (resource === 'comments') { + // partial pagination + return dataProvider + .getList(resource, params) + .then(({ data, total }) => ({ + data, + pageInfo: { + hasNextPage: + params.pagination.perPage * params.pagination.page < + total, + hasPreviousPage: params.pagination.page > 1, + }, + })); + } if (resource === 'tags') { const matchSearchFilter = Object.keys(params.filter).find(key => key.endsWith('_q') diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index 7de9d87ceb4..931f6963a0f 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -146,6 +146,49 @@ describe('useReferenceManyFieldController', () => { { id: 1, title: 'hello' }, { id: 2, title: 'world' }, ], + total: 2, + hasPreviousPage: false, + hasNextPage: false, + }) + ); + }); + }); + + it('should handle partial pagination', async () => { + const children = jest.fn().mockReturnValue('children'); + const dataProvider = testDataProvider({ + getManyReference: () => + Promise.resolve({ + data: [ + { id: 1, title: 'hello' }, + { id: 2, title: 'world' }, + ], + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + }, + }) as any, + }); + + render( + <CoreAdminContext dataProvider={dataProvider}> + <ReferenceManyFieldController + resource="authors" + source="id" + record={{ id: 123, name: 'James Joyce' }} + reference="books" + target="author_id" + > + {children} + </ReferenceManyFieldController> + </CoreAdminContext> + ); + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + total: undefined, + hasPreviousPage: false, + hasNextPage: false, }) ); }); diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index e681c3bed97..216899e2b75 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -150,6 +150,7 @@ export const useReferenceManyFieldController = ( const { data, total, + pageInfo, error, isFetching, isLoading, @@ -205,6 +206,12 @@ export const useReferenceManyFieldController = ( setFilters, setPage, setPerPage, + hasNextPage: pageInfo + ? pageInfo.hasNextPage + : total != null + ? page * perPage < total + : undefined, + hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : page > 1, setSort, showFilter, total, diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts index c732b1c5b91..9847ca882ad 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.ts @@ -219,6 +219,7 @@ export const useReferenceArrayInputController = < const { data: matchingReferences, total, + pageInfo, error: errorGetList, isLoading: isLoadingGetList, isFetching: isFetchingGetList, @@ -251,37 +252,44 @@ export const useReferenceArrayInputController = < }, [refetchGetMany, refetchGetMatching]); return { - choices: dataStatus.choices, - sort, - data: matchingReferences, displayedFilters, + choices: dataStatus.choices, + warning: dataStatus.warning, error: errorGetMany || errorGetList ? translate('ra.input.references.all_missing', { _: 'ra.input.references.all_missing', }) : undefined, - filterValues, - hideFilter, isFetching: isFetchingGetMany || isFetchingGetList, isLoading: isLoadingGetMany || isLoadingGetList, + // possibleValues + data: matchingReferences, + total, + sort, + setSort, + filterValues, + hideFilter, + showFilter, + setFilter, + setFilters, onSelect, onToggleItem, onUnselectItems, page, perPage, - refetch, - resource, - selectedIds: input.value || EmptyArray, - setFilter, - setFilters, setPage, setPagination, setPerPage, - setSort, - showFilter, - warning: dataStatus.warning, - total, + hasNextPage: pageInfo + ? pageInfo.hasNextPage + : total != null + ? page * perPage < total + : undefined, + hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : page > 1, + refetch, + resource, + selectedIds: input.value || EmptyArray, }; }; diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx index 830e478b45b..df3f9108d2e 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx @@ -165,6 +165,8 @@ describe('useReferenceInputController', () => { isLoading: false, page: 1, perPage: 25, + hasPreviousPage: false, + hasNextPage: false, hideFilter: expect.any(Function), onSelect: expect.any(Function), onToggleItem: expect.any(Function), diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index d12d15a8493..1c9522896fa 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -110,7 +110,8 @@ export const useReferenceInputController = <RecordType extends RaRecord = any>( // fetch possible values const { data: possibleValuesData = [], - total: possibleValuesTotal, + total, + pageInfo, isFetching: possibleValuesFetching, isLoading: possibleValuesLoading, error: possibleValuesError, @@ -138,11 +139,11 @@ export const useReferenceInputController = <RecordType extends RaRecord = any>( !referenceRecord || possibleValuesData.find(record => record.id === input.value) ) { - finalData = [...possibleValuesData]; - finalTotal = possibleValuesTotal; + finalData = possibleValuesData; + finalTotal = total; } else { finalData = [referenceRecord, ...possibleValuesData]; - finalTotal = possibleValuesTotal + 1; + finalTotal = total == null ? undefined : total + 1; } // overall status @@ -170,6 +171,12 @@ export const useReferenceInputController = <RecordType extends RaRecord = any>( setPage, perPage, setPerPage, + hasNextPage: pageInfo + ? pageInfo.hasNextPage + : finalTotal != null + ? page * perPage < finalTotal + : undefined, + hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : page > 1, sort, setSort, filterValues, diff --git a/packages/ra-core/src/controller/list/ListContext.tsx b/packages/ra-core/src/controller/list/ListContext.tsx index 1a55cb48ccd..e132d78e06e 100644 --- a/packages/ra-core/src/controller/list/ListContext.tsx +++ b/packages/ra-core/src/controller/list/ListContext.tsx @@ -58,6 +58,8 @@ export const ListContext = createContext<ListControllerResult>({ defaultTitle: null, displayedFilters: null, filterValues: null, + hasNextPage: null, + hasPreviousPage: null, hideFilter: null, isFetching: null, isLoading: null, diff --git a/packages/ra-core/src/controller/list/ListPaginationContext.tsx b/packages/ra-core/src/controller/list/ListPaginationContext.tsx index 64b82a99b40..a9e4da05cc8 100644 --- a/packages/ra-core/src/controller/list/ListPaginationContext.tsx +++ b/packages/ra-core/src/controller/list/ListPaginationContext.tsx @@ -15,6 +15,9 @@ import { ListControllerResult } from './useListController'; * @prop {Function} setPage a callback to change the page, e.g. setPage(3) * @prop {integer} perPage the number of results per page. Defaults to 25 * @prop {Function} setPerPage a callback to change the number of results per page, e.g. setPerPage(25) + * @prop {Boolean} hasPreviousPage true if the current page is not the first one + * @prop {Boolean} hasNextPage true if the current page is not the last one + * @prop {string} resource the resource name, deduced from the location. e.g. 'posts' * * @typedef Props @@ -44,7 +47,9 @@ export const ListPaginationContext = createContext<ListPaginationContextValue>({ perPage: null, setPage: null, setPerPage: null, - total: null, + hasPreviousPage: null, + hasNextPage: null, + total: undefined, resource: null, }); @@ -53,6 +58,8 @@ ListPaginationContext.displayName = 'ListPaginationContext'; export type ListPaginationContextValue = Pick< ListControllerResult, | 'isLoading' + | 'hasPreviousPage' + | 'hasNextPage' | 'page' | 'perPage' | 'setPage' @@ -68,6 +75,8 @@ export const usePickPaginationContext = ( () => pick(context, [ 'isLoading', + 'hasPreviousPage', + 'hasNextPage', 'page', 'perPage', 'setPage', @@ -78,6 +87,8 @@ export const usePickPaginationContext = ( // eslint-disable-next-line react-hooks/exhaustive-deps [ context.isLoading, + context.hasPreviousPage, + context.hasNextPage, context.page, context.perPage, context.setPage, diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 5b4f09de2dc..3c57e3354ce 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -231,6 +231,8 @@ export const useList = <RecordType extends RaRecord = any>( error, displayedFilters, filterValues, + hasNextPage: page * perPage < finalItems.total, + hasPreviousPage: page > 1, hideFilter, isFetching: fetchingState, isLoading: loadingState, diff --git a/packages/ra-core/src/controller/list/useListController.spec.tsx b/packages/ra-core/src/controller/list/useListController.spec.tsx index 730eda838a0..0b87832c449 100644 --- a/packages/ra-core/src/controller/list/useListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useListController.spec.tsx @@ -1,6 +1,12 @@ import * as React from 'react'; import expect from 'expect'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { + render, + fireEvent, + waitFor, + screen, + act, +} from '@testing-library/react'; import lolex from 'lolex'; // TODO: we shouldn't import mui components in ra-core import { TextField } from '@mui/material'; @@ -15,23 +21,10 @@ import { } from './useListController'; import { CoreAdminContext, createAdminStore } from '../../core'; import { CRUD_CHANGE_LIST_PARAMS } from '../../actions'; -import { SORT_ASC } from './queryReducer'; describe('useListController', () => { const defaultProps = { children: jest.fn(), - hasCreate: true, - hasEdit: true, - hasList: true, - hasShow: true, - query: { - page: 1, - perPage: 10, - sort: 'id', - order: SORT_ASC, - filter: {}, - displayedFilters: {}, - }, resource: 'posts', debounce: 200, }; @@ -343,6 +336,93 @@ describe('useListController', () => { }); }); + describe('pagination', () => { + it('should compute hasNextPage and hasPreviousPage based on total', async () => { + const getList = jest + .fn() + .mockImplementation(() => + Promise.resolve({ data: [], total: 25 }) + ); + const dataProvider = testDataProvider({ getList }); + const children = jest.fn().mockReturnValue(<span>children</span>); + const props = { + ...defaultProps, + children, + }; + render( + <CoreAdminContext dataProvider={dataProvider}> + <ListController {...props} /> + </CoreAdminContext> + ); + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + total: 25, + hasNextPage: true, + hasPreviousPage: false, + }) + ); + }); + act(() => { + // @ts-ignore + children.mock.calls.at(-1)[0].setPage(2); + }); + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + page: 2, + total: 25, + hasNextPage: true, + hasPreviousPage: true, + }) + ); + }); + act(() => { + // @ts-ignore + children.mock.calls.at(-1)[0].setPage(3); + }); + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + page: 3, + total: 25, + hasNextPage: false, + hasPreviousPage: true, + }) + ); + }); + }); + it('should compute hasNextPage and hasPreviousPage based on pageInfo', async () => { + const getList = jest.fn().mockImplementation(() => + Promise.resolve({ + data: [], + pageInfo: { hasNextPage: true, hasPreviousPage: false }, + }) + ); + const dataProvider = testDataProvider({ getList }); + const children = jest.fn().mockReturnValue(<span>children</span>); + const props = { + ...defaultProps, + children, + }; + render( + <CoreAdminContext dataProvider={dataProvider}> + <ListController {...props} /> + </CoreAdminContext> + ); + await waitFor(() => { + expect(children).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + total: undefined, + hasNextPage: true, + hasPreviousPage: false, + }) + ); + }); + }); + }); describe('getListControllerProps', () => { it('should only pick the props injected by the ListController', () => { expect( diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 4917c422873..ce4726844ab 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -71,9 +71,15 @@ export const useListController = <RecordType extends RaRecord = any>( const [selectedIds, selectionModifiers] = useRecordSelection(resource); - const { data, total, error, isLoading, isFetching, refetch } = useGetList< - RecordType - >( + const { + data, + pageInfo, + total, + error, + isLoading, + isFetching, + refetch, + } = useGetList<RecordType>( resource, { pagination: { @@ -99,14 +105,19 @@ export const useListController = <RecordType extends RaRecord = any>( // change page if there is no data useEffect(() => { - const totalPages = Math.ceil(total / query.perPage) || 1; if ( query.page <= 0 || (!isFetching && query.page > 1 && data.length === 0) ) { // Query for a page that doesn't exist, set page to 1 queryModifiers.setPage(1); - } else if (!isFetching && query.page > totalPages) { + return; + } + if (total == null) { + return; + } + const totalPages = Math.ceil(total / query.perPage) || 1; + if (!isFetching && query.page > totalPages) { // Query for a page out of bounds, set page to the last existing page // It occurs when deleting the last element of the last page queryModifiers.setPage(totalPages); @@ -152,6 +163,12 @@ export const useListController = <RecordType extends RaRecord = any>( setSort: queryModifiers.setSort, showFilter: queryModifiers.showFilter, total: total, + hasNextPage: pageInfo + ? pageInfo.hasNextPage + : total != null + ? query.page * perPage < total + : undefined, + hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : query.page > 1, }; }; @@ -166,7 +183,14 @@ export interface ListControllerProps<RecordType extends RaRecord = any> { filter?: FilterPayload; filterDefaultValues?: object; perPage?: number; - queryOptions?: UseQueryOptions<{ data: RecordType[]; total: number }>; + queryOptions?: UseQueryOptions<{ + data: RecordType[]; + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; + }>; resource?: string; sort?: SortPayload; } @@ -206,6 +230,8 @@ export interface ListControllerResult<RecordType extends RaRecord = any> { setSort: (sort: SortPayload) => void; showFilter: (filterName: string, defaultValue: any) => void; total: number; + hasNextPage: boolean; + hasPreviousPage: boolean; } export const injectedProps = [ diff --git a/packages/ra-core/src/controller/list/useListPaginationContext.ts b/packages/ra-core/src/controller/list/useListPaginationContext.ts index f890a1a2080..1c0ccce3d00 100644 --- a/packages/ra-core/src/controller/list/useListPaginationContext.ts +++ b/packages/ra-core/src/controller/list/useListPaginationContext.ts @@ -17,6 +17,8 @@ import { * @prop {Function} setPage a callback to change the page, e.g. setPage(3) * @prop {integer} perPage the number of results per page. Defaults to 25 * @prop {Function} setPerPage a callback to change the number of results per page, e.g. setPerPage(25) + * @prop {Boolean} hasPreviousPage true if the current page is not the first one + * @prop {Boolean} hasNextPage true if the current page is not the last one * @prop {string} resource the resource name, deduced from the location. e.g. 'posts' * * @returns {ListPaginationContextValue} list controller props diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index 2884e6a5486..17bdd5e8566 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -5,7 +5,7 @@ import { useQueryClient, } from 'react-query'; -import { RaRecord, GetListParams } from '../types'; +import { RaRecord, GetListParams, GetListResult } from '../types'; import { useDataProvider } from './useDataProvider'; /** @@ -53,7 +53,7 @@ import { useDataProvider } from './useDataProvider'; export const useGetList = <RecordType extends RaRecord = any>( resource: string, params: Partial<GetListParams> = {}, - options?: UseQueryOptions<{ data: RecordType[]; total: number }, Error> + options?: UseQueryOptions<GetListResult<RecordType>, Error> ): UseGetListHookValue<RecordType> => { const { pagination = { page: 1, perPage: 25 }, @@ -64,9 +64,9 @@ export const useGetList = <RecordType extends RaRecord = any>( const dataProvider = useDataProvider(); const queryClient = useQueryClient(); const result = useQuery< - { data: RecordType[]; total: number }, + GetListResult<RecordType>, Error, - { data: RecordType[]; total: number } + GetListResult<RecordType> >( [resource, 'getList', { pagination, sort, filter, meta }], () => @@ -77,7 +77,11 @@ export const useGetList = <RecordType extends RaRecord = any>( filter, meta, }) - .then(({ data, total }) => ({ data, total })), + .then(({ data, total, pageInfo }) => ({ + data, + total, + pageInfo, + })), { onSuccess: ({ data }) => { // optimistically populate the getOne cache @@ -97,10 +101,23 @@ export const useGetList = <RecordType extends RaRecord = any>( ...result, data: result.data?.data, total: result.data?.total, + pageInfo: result.data?.pageInfo, } - : result) as UseQueryResult<RecordType[], Error> & { total?: number }; + : result) as UseQueryResult<RecordType[], Error> & { + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; + }; }; export type UseGetListHookValue< RecordType extends RaRecord = any -> = UseQueryResult<RecordType[], Error> & { total?: number }; +> = UseQueryResult<RecordType[], Error> & { + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; +}; diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index a5300f0125e..62a865c24e8 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -5,7 +5,11 @@ import { useQueryClient, } from 'react-query'; -import { RaRecord, GetManyReferenceParams } from '../types'; +import { + RaRecord, + GetManyReferenceParams, + GetManyReferenceResult, +} from '../types'; import { useDataProvider } from './useDataProvider'; /** @@ -70,9 +74,9 @@ export const useGetManyReference = <RecordType extends RaRecord = any>( const dataProvider = useDataProvider(); const queryClient = useQueryClient(); const result = useQuery< - { data: RecordType[]; total: number }, + GetManyReferenceResult<RecordType>, Error, - { data: RecordType[]; total: number } + GetManyReferenceResult<RecordType> >( [ resource, @@ -89,7 +93,11 @@ export const useGetManyReference = <RecordType extends RaRecord = any>( filter, meta, }) - .then(({ data, total }) => ({ data, total })), + .then(({ data, total, pageInfo }) => ({ + data, + total, + pageInfo, + })), { onSuccess: ({ data }) => { // optimistically populate the getOne cache @@ -109,10 +117,23 @@ export const useGetManyReference = <RecordType extends RaRecord = any>( ...result, data: result.data?.data, total: result.data?.total, + pageInfo: result.data?.pageInfo, } - : result) as UseQueryResult<RecordType[], Error> & { total?: number }; + : result) as UseQueryResult<RecordType[], Error> & { + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; + }; }; export type UseGetManyReferenceHookValue< RecordType extends RaRecord = any -> = UseQueryResult<RecordType[], Error> & { total?: number }; +> = UseQueryResult<RecordType[], Error> & { + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; +}; diff --git a/packages/ra-core/src/dataProvider/validateResponseFormat.ts b/packages/ra-core/src/dataProvider/validateResponseFormat.ts index 816cac620ec..0041017cd2f 100644 --- a/packages/ra-core/src/dataProvider/validateResponseFormat.ts +++ b/packages/ra-core/src/dataProvider/validateResponseFormat.ts @@ -51,7 +51,8 @@ function validateResponseFormat( } if ( fetchActionsWithTotalResponse.includes(type) && - !response.hasOwnProperty('total') + !response.hasOwnProperty('total') && + !response.hasOwnProperty('pageInfo') ) { logger( `The response to '${type}' must be like { data: [...], total: 123 }, but the received response does not have a 'total' key. The dataProvider is probably wrong for '${type}'` diff --git a/packages/ra-core/src/i18n/TranslationMessages.ts b/packages/ra-core/src/i18n/TranslationMessages.ts index 7b678e31165..8196f81a6e9 100644 --- a/packages/ra-core/src/i18n/TranslationMessages.ts +++ b/packages/ra-core/src/i18n/TranslationMessages.ts @@ -111,9 +111,14 @@ export interface TranslationMessages extends StringMap { page_out_from_end: string; page_out_from_begin: string; page_range_info: string; + partial_page_range_info: string; page_rows_per_page: string; + current_page: string; + page: string; + first: string; + last: string; next: string; - prev: string; + previous: string; skip_nav: string; }; sort: { diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 4b1958db528..0daf705cfd6 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -128,7 +128,11 @@ export interface GetListParams { } export interface GetListResult<RecordType extends RaRecord = any> { data: RecordType[]; - total: number; + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; } export interface GetOneParams<RecordType extends RaRecord = any> { @@ -157,7 +161,11 @@ export interface GetManyReferenceParams { } export interface GetManyReferenceResult<RecordType extends RaRecord = any> { data: RecordType[]; - total: number; + total?: number; + pageInfo?: { + hasNextPage?: boolean; + hasPreviousPage?: boolean; + }; } export interface UpdateParams<T = any> { diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 711194e6743..3298bc12181 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -111,9 +111,15 @@ const englishMessages: TranslationMessages = { page_out_from_end: 'Cannot go after last page', page_out_from_begin: 'Cannot go before page 1', page_range_info: '%{offsetBegin}-%{offsetEnd} of %{total}', + partial_page_range_info: + '%{offsetBegin}-%{offsetEnd} of more than %{offsetEnd}', + current_page: 'Page %{page}', + page: 'Go to page %{page}', + first: 'Go to first page', + last: 'Go to last page', + next: 'Go to next page', + previous: 'Go to previous page', page_rows_per_page: 'Rows per page:', - next: 'Next', - prev: 'Prev', skip_nav: 'Skip to content', }, sort: { diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index 64704f2b56d..e48b338e4a3 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -115,9 +115,15 @@ const frenchMessages: TranslationMessages = { page_out_from_end: 'Fin de la pagination', page_out_from_begin: 'La page doit être supérieure à 1', page_range_info: '%{offsetBegin}-%{offsetEnd} sur %{total}', + partial_page_range_info: + '%{offsetBegin}-%{offsetEnd} sur plus de %{offsetEnd}', page_rows_per_page: 'Lignes par page :', - next: 'Suivant', - prev: 'Précédent', + current_page: 'Page %{page}', + page: 'Aller à la page %{page}', + first: 'Aller à la première page', + last: 'Aller à la dernière page', + next: 'Aller à la prochaine page', + previous: 'Aller à la page précédente', skip_nav: 'Aller au contenu', }, sort: { diff --git a/packages/ra-ui-materialui/src/list/pagination/Pagination.spec.tsx b/packages/ra-ui-materialui/src/list/pagination/Pagination.spec.tsx index 9f267453d79..d974083dbbb 100644 --- a/packages/ra-ui-materialui/src/list/pagination/Pagination.spec.tsx +++ b/packages/ra-ui-materialui/src/list/pagination/Pagination.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import expect from 'expect'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import { ListPaginationContext } from 'ra-core'; @@ -15,13 +15,15 @@ describe('<Pagination />', () => { page: 1, perPage: 10, setPage: () => null, - loading: false, + isLoading: false, setPerPage: () => {}, + hasNextPage: undefined, + hasPreviousPage: undefined, }; describe('no results mention', () => { it('should display a pagination limit when there is no result', () => { - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ ...defaultProps, total: 0 }} @@ -30,11 +32,13 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.no_results')).not.toBeNull(); + expect( + screen.queryByText('ra.navigation.no_results') + ).not.toBeNull(); }); it('should not display a pagination limit when there are results', () => { - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ ...defaultProps, total: 1 }} @@ -43,13 +47,13 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.no_results')).toBeNull(); + expect(screen.queryByText('ra.navigation.no_results')).toBeNull(); }); it('should display a pagination limit on an out of bounds page (more than total pages)', async () => { jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const setPage = jest.fn().mockReturnValue(null); - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -66,13 +70,15 @@ describe('<Pagination />', () => { ); // mui TablePagination displays no more a warning in that case // Then useEffect fallbacks on a valid page - expect(queryByText('ra.navigation.no_results')).not.toBeNull(); + expect( + screen.queryByText('ra.navigation.no_results') + ).not.toBeNull(); }); it('should display a pagination limit on an out of bounds page (less than 0)', async () => { jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const setPage = jest.fn().mockReturnValue(null); - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -89,13 +95,15 @@ describe('<Pagination />', () => { ); // mui TablePagination displays no more a warning in that case // Then useEffect fallbacks on a valid page - expect(queryByText('ra.navigation.no_results')).not.toBeNull(); + expect( + screen.queryByText('ra.navigation.no_results') + ).not.toBeNull(); }); }); - describe('Pagination buttons', () => { + describe('Total pagination', () => { it('should display a next button when there are more results', () => { - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -109,10 +117,14 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.next')).not.toBeNull(); + const nextButton = screen.queryByLabelText( + 'ra.navigation.next' + ) as HTMLButtonElement; + expect(nextButton).not.toBeNull(); + expect(nextButton.disabled).toBe(false); }); - it('should not display a next button when there are no more results', () => { - const { queryByText } = render( + it('should display a disabled next button when there are no more results', () => { + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -126,10 +138,14 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.next')).toBeNull(); + const nextButton = screen.queryByLabelText( + 'ra.navigation.next' + ) as HTMLButtonElement; + expect(nextButton).not.toBeNull(); + expect(nextButton.disabled).toBe(true); }); it('should display a prev button when there are previous results', () => { - const { queryByText } = render( + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -143,10 +159,14 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.prev')).not.toBeNull(); + const prevButton = screen.queryByLabelText( + 'ra.navigation.previous' + ) as HTMLButtonElement; + expect(prevButton).not.toBeNull(); + expect(prevButton.disabled).toBe(false); }); - it('should not display a prev button when there are no previous results', () => { - const { queryByText } = render( + it('should display a disabled prev button when there are no previous results', () => { + render( <ThemeProvider theme={theme}> <ListPaginationContext.Provider value={{ @@ -160,13 +180,108 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </ThemeProvider> ); - expect(queryByText('ra.navigation.prev')).toBeNull(); + const prevButton = screen.queryByLabelText( + 'ra.navigation.previous' + ) as HTMLButtonElement; + expect(prevButton).not.toBeNull(); + expect(prevButton.disabled).toBe(true); + }); + }); + + describe('Partial pagination', () => { + it('should display a next button when there are more results', () => { + render( + <ThemeProvider theme={theme}> + <ListPaginationContext.Provider + value={{ + ...defaultProps, + perPage: 1, + total: undefined, + page: 1, + hasNextPage: true, + }} + > + <Pagination rowsPerPageOptions={[1]} /> + </ListPaginationContext.Provider> + </ThemeProvider> + ); + const nextButton = screen.queryByLabelText( + 'ra.navigation.next' + ) as HTMLButtonElement; + expect(nextButton).not.toBeNull(); + expect(nextButton.disabled).toBe(false); + }); + it('should display a disabled next button when there are no more results', () => { + render( + <ThemeProvider theme={theme}> + <ListPaginationContext.Provider + value={{ + ...defaultProps, + perPage: 1, + total: undefined, + page: 2, + hasNextPage: false, + }} + > + <Pagination rowsPerPageOptions={[1]} /> + </ListPaginationContext.Provider> + </ThemeProvider> + ); + const nextButton = screen.queryByLabelText( + 'ra.navigation.next' + ) as HTMLButtonElement; + expect(nextButton).not.toBeNull(); + expect(nextButton.disabled).toBe(true); + }); + it('should display a prev button when there are previous results', () => { + render( + <ThemeProvider theme={theme}> + <ListPaginationContext.Provider + value={{ + ...defaultProps, + perPage: 1, + total: undefined, + page: 2, + hasPreviousPage: true, + }} + > + <Pagination rowsPerPageOptions={[1]} /> + </ListPaginationContext.Provider> + </ThemeProvider> + ); + const prevButton = screen.queryByLabelText( + 'ra.navigation.previous' + ) as HTMLButtonElement; + expect(prevButton).not.toBeNull(); + expect(prevButton.disabled).toBe(false); + }); + it('should display a disabled prev button when there are no previous results', () => { + render( + <ThemeProvider theme={theme}> + <ListPaginationContext.Provider + value={{ + ...defaultProps, + perPage: 1, + total: undefined, + page: 1, + hasPreviousPage: false, + }} + > + <Pagination rowsPerPageOptions={[1]} /> + </ListPaginationContext.Provider> + </ThemeProvider> + ); + const prevButton = screen.queryByLabelText( + 'ra.navigation.previous' + ) as HTMLButtonElement; + expect(prevButton).not.toBeNull(); + expect(prevButton.disabled).toBe(true); }); }); describe('mobile', () => { it('should not render a rowsPerPage choice', () => { - const { queryByText } = render( + render( <DeviceTestWrapper width="sm"> <ListPaginationContext.Provider value={{ @@ -180,13 +295,15 @@ describe('<Pagination />', () => { </ListPaginationContext.Provider> </DeviceTestWrapper> ); - expect(queryByText('ra.navigation.page_rows_per_page')).toBeNull(); + expect( + screen.queryByText('ra.navigation.page_rows_per_page') + ).toBeNull(); }); }); describe('desktop', () => { it('should render rowsPerPage choice', () => { - const { queryByText } = render( + render( <DeviceTestWrapper width="lg"> <ListPaginationContext.Provider value={{ @@ -202,7 +319,7 @@ describe('<Pagination />', () => { ); expect( - queryByText('ra.navigation.page_rows_per_page') + screen.queryByText('ra.navigation.page_rows_per_page') ).not.toBeNull(); }); }); diff --git a/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx b/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx index aab9911c693..593ec57ddb1 100644 --- a/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx +++ b/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx @@ -21,12 +21,13 @@ import { PaginationLimit } from './PaginationLimit'; export const Pagination: FC<PaginationProps> = memo(props => { const { rowsPerPageOptions = DefaultRowsPerPageOptions, - actions = PaginationActions, + actions, limit = DefaultLimit, ...rest } = props; const { isLoading, + hasNextPage, page, perPage, total, @@ -39,7 +40,7 @@ export const Pagination: FC<PaginationProps> = memo(props => { ); const totalPages = useMemo(() => { - return Math.ceil(total / perPage) || 1; + return total != null ? Math.ceil(total / perPage) : undefined; }, [perPage, total]); /** @@ -69,23 +70,39 @@ export const Pagination: FC<PaginationProps> = memo(props => { const labelDisplayedRows = useCallback( ({ from, to, count }) => - translate('ra.navigation.page_range_info', { - offsetBegin: from, - offsetEnd: to, - total: count, - }), + count === -1 && hasNextPage + ? translate('ra.navigation.partial_page_range_info', { + offsetBegin: from, + offsetEnd: to, + _: `%{from}-%{to} of more than %{to}`, + }) + : translate('ra.navigation.page_range_info', { + offsetBegin: from, + offsetEnd: to, + total: count === -1 ? to : count, + _: `%{from}-%{to} of %{count === -1 ? to : count}`, + }), + [translate, hasNextPage] + ); + + const labelItem = useCallback( + type => translate(`ra.navigation.${type}`, { _: `Go to ${type} page` }), [translate] ); + if (isLoading) { + return <Toolbar variant="dense" />; + } + // Avoid rendering TablePagination if "page" value is invalid - if (total == null || total === 0 || page < 1 || page > totalPages) { - return isLoading ? <Toolbar variant="dense" /> : limit; + if (total === 0 || page < 1 || (total != null && page > totalPages)) { + return limit; } if (isSmall) { return ( <TablePagination - count={total} + count={total == null ? -1 : total} rowsPerPage={perPage} page={page - 1} onPageChange={handlePageChange} @@ -97,17 +114,28 @@ export const Pagination: FC<PaginationProps> = memo(props => { ); } + const ActionsComponent = actions + ? actions // overridden by caller + : !isLoading && total != null + ? PaginationActions // regular navigation + : undefined; // partial navigation (uses default TablePaginationActions) + return ( <TablePagination - count={total} + count={total == null ? -1 : total} rowsPerPage={perPage} page={page - 1} onPageChange={handlePageChange} onRowsPerPageChange={handlePerPageChange} - ActionsComponent={actions} + // @ts-ignore + ActionsComponent={ActionsComponent} + nextIconButtonProps={{ + disabled: !hasNextPage, + }} component="span" labelRowsPerPage={translate('ra.navigation.page_rows_per_page')} labelDisplayedRows={labelDisplayedRows} + getItemAriaLabel={labelItem} rowsPerPageOptions={rowsPerPageOptions} {...sanitizeListRestProps(rest)} /> diff --git a/packages/ra-ui-materialui/src/list/pagination/PaginationActions.spec.tsx b/packages/ra-ui-materialui/src/list/pagination/PaginationActions.spec.tsx index 132b1dc54a7..568d5852523 100644 --- a/packages/ra-ui-materialui/src/list/pagination/PaginationActions.spec.tsx +++ b/packages/ra-ui-materialui/src/list/pagination/PaginationActions.spec.tsx @@ -1,50 +1,44 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { PaginationActions } from './PaginationActions'; describe('<PaginationActions />', () => { it('should not render any actions when no pagination is necessary', () => { - const { queryAllByRole } = render( + render( <PaginationActions page={0} rowsPerPage={20} count={15} - translate={x => x} onPageChange={() => null} - classes={{}} /> ); - expect(queryAllByRole('button')).toHaveLength(0); + expect(screen.queryAllByRole('button')).toHaveLength(0); }); it('should render action buttons when pagination is necessary', () => { - const { queryAllByRole } = render( + render( <PaginationActions page={0} rowsPerPage={5} count={15} - translate={x => x} onPageChange={() => null} - classes={{}} /> ); - // 1 2 3 next - expect(queryAllByRole('button')).toHaveLength(4); + // prev 1 2 3 next + expect(screen.queryAllByRole('button')).toHaveLength(5); }); it('should skip page action buttons when there are too many', () => { - const { queryAllByRole } = render( + render( <PaginationActions page={7} rowsPerPage={1} count={15} - translate={x => x} onPageChange={() => null} - classes={{}} /> ); // prev 1 ... 7 8 9 ... 15 next - expect(queryAllByRole('button')).toHaveLength(7); + expect(screen.queryAllByRole('button')).toHaveLength(7); }); }); diff --git a/packages/ra-ui-materialui/src/list/pagination/PaginationActions.tsx b/packages/ra-ui-materialui/src/list/pagination/PaginationActions.tsx index 2dbbc4aefa2..0beb7c945aa 100644 --- a/packages/ra-ui-materialui/src/list/pagination/PaginationActions.tsx +++ b/packages/ra-ui-materialui/src/list/pagination/PaginationActions.tsx @@ -1,177 +1,67 @@ import * as React from 'react'; import { memo, FC } from 'react'; import { styled } from '@mui/material/styles'; +import { Pagination, PaginationProps } from '@mui/material'; import PropTypes from 'prop-types'; -import Button from '@mui/material/Button'; -import { useTheme } from '@mui/material/styles'; -import ChevronLeft from '@mui/icons-material/ChevronLeft'; -import ChevronRight from '@mui/icons-material/ChevronRight'; import { useTranslate } from 'ra-core'; -import classnames from 'classnames'; export const PaginationActions: FC<PaginationActionsProps> = memo(props => { - const { page, rowsPerPage, count, onPageChange, color, size } = props; - + const { + page, + rowsPerPage, + count, + onPageChange, + size = 'small', + ...rest + } = props; const translate = useTranslate(); - const theme = useTheme(); - /** - * Warning: material-ui's page is 0-based - */ - const range = () => { - const nbPages = Math.ceil(count / rowsPerPage) || 1; - if (isNaN(page) || nbPages === 1) { - return []; - } - const input = []; - // display page links around the current page - if (page > 1) { - input.push(1); - } - if (page === 3) { - input.push(2); - } - if (page > 3) { - input.push('.'); - } - if (page > 0) { - input.push(page); - } - input.push(page + 1); - if (page < nbPages - 1) { - input.push(page + 2); - } - if (page === nbPages - 4) { - input.push(nbPages - 1); - } - if (page < nbPages - 4) { - input.push('.'); - } - if (page < nbPages - 2) { - input.push(nbPages); - } - - return input; - }; - const getNbPages = () => Math.ceil(count / rowsPerPage) || 1; + const nbPages = Math.ceil(count / rowsPerPage) || 1; - const prevPage = event => { - if (page === 0) { - throw new Error(translate('ra.navigation.page_out_from_begin')); - } - onPageChange(event, page - 1); - }; - - const nextPage = event => { - if (page > getNbPages() - 1) { - throw new Error(translate('ra.navigation.page_out_from_end')); - } - onPageChange(event, page + 1); - }; + if (nbPages === 1) { + return <Root />; + } - const gotoPage = event => { - const page = parseInt(event.currentTarget.dataset.page, 10); - if (page < 0 || page > getNbPages() - 1) { - throw new Error( - translate('ra.navigation.page_out_of_boundaries', { - page: page + 1, - }) - ); + const getItemAriaLabel = ( + type: 'page' | 'first' | 'last' | 'next' | 'previous', + page: number, + selected: boolean + ) => { + if (type === 'page') { + return selected + ? translate('ra.navigation.current_page', { + page, + _: `page ${page}`, + }) + : translate('ra.navigation.page', { + page, + _: `Go to page ${page}`, + }); } - onPageChange(event, page); - }; - - const renderPageNums = () => { - return range().map((pageNum, index) => - pageNum === '.' ? ( - <span - key={`hyphen_${index}`} - className={PaginationActionsClasses.hellip} - > - … - </span> - ) : ( - <Button - size={size} - className={classnames( - 'page-number', - PaginationActionsClasses.button, - { - [PaginationActionsClasses.currentPageButton]: - pageNum === page + 1, - } - )} - color={color} - variant={pageNum === page + 1 ? 'outlined' : 'text'} - key={pageNum} - data-page={pageNum - 1} - onClick={gotoPage} - > - {pageNum} - </Button> - ) - ); + return translate(`ra.navigation.${type}`, { _: `Go to ${type} page` }); }; - const nbPages = getNbPages(); - - if (nbPages === 1) { - return <Root className={PaginationActionsClasses.actions} />; - } - return ( - <Root className={PaginationActionsClasses.actions}> - {page > 0 && ( - <Button - color={color} - size={size} - key="prev" - onClick={prevPage} - className="previous-page" - > - {theme.direction === 'rtl' ? ( - <ChevronRight /> - ) : ( - <ChevronLeft /> - )} - {translate('ra.navigation.prev')} - </Button> - )} - {renderPageNums()} - {page !== nbPages - 1 && ( - <Button - color={color} - size={size} - key="next" - onClick={nextPage} - className="next-page" - > - {translate('ra.navigation.next')} - {theme.direction === 'rtl' ? ( - <ChevronLeft /> - ) : ( - <ChevronRight /> - )} - </Button> - )} + <Root> + <Pagination + size={size} + count={nbPages} + // <TablePagination>, the parent, uses 0-based pagination + // while <Pagination> uses 1-based pagination + page={page + 1} + onChange={(e: any, page) => onPageChange(e, page - 1)} + {...sanitizeRestProps(rest)} + getItemAriaLabel={getItemAriaLabel} + /> </Root> ); }); -export interface PaginationActionsProps { +export interface PaginationActionsProps extends PaginationProps { page: number; rowsPerPage: number; count: number; onPageChange: (event: MouseEvent, page: number) => void; - color: - | 'inherit' - | 'primary' - | 'secondary' - | 'success' - | 'error' - | 'info' - | 'warning'; - size: 'small' | 'medium' | 'large'; } /** * PaginationActions propTypes are copied over from material-ui’s @@ -184,32 +74,19 @@ PaginationActions.propTypes = { onPageChange: PropTypes.func.isRequired, page: PropTypes.number.isRequired, rowsPerPage: PropTypes.number.isRequired, - color: PropTypes.oneOf(['primary', 'secondary']), + color: PropTypes.oneOf(['primary', 'secondary', 'standard']), size: PropTypes.oneOf(['small', 'medium', 'large']), }; -PaginationActions.defaultProps = { - color: 'primary', - size: 'small', -}; - const PREFIX = 'RaPaginationActions'; -export const PaginationActionsClasses = { - actions: `${PREFIX}-actions`, - button: `${PREFIX}-button`, - currentPageButton: `${PREFIX}-currentPageButton`, - hellip: `${PREFIX}-hellip`, -}; - const Root = styled('div', { name: PREFIX })(({ theme }) => ({ - [`&.${PaginationActionsClasses.actions}`]: { - flexShrink: 0, - color: theme.palette.text.secondary, - marginLeft: 20, - }, - - [`& .${PaginationActionsClasses.button}`]: {}, - [`& .${PaginationActionsClasses.currentPageButton}`]: {}, - [`& .${PaginationActionsClasses.hellip}`]: { padding: '1.2em' }, + flexShrink: 0, + ml: 4, })); + +const sanitizeRestProps = ({ + nextIconButtonProps, + backIconButtonProps, + ...rest +}: any) => rest;