diff --git a/docs/Buttons.md b/docs/Buttons.md index d8777e7ce33..cd07abcfa27 100644 --- a/docs/Buttons.md +++ b/docs/Buttons.md @@ -20,6 +20,7 @@ React-Admin provides button components for all the common uses. - [``](#bulkupdatebutton) - [``](#bulkupdateformbutton) - [``](#filterbutton) + - [``](#selectallbutton) - **Record Buttons**: To be used in detail views - [``](#updatebutton) @@ -1010,6 +1011,79 @@ If your `authProvider` implements [Access Control](./Permissions.md#access-contr ## `` +## `` + +The `` component allows users to select all items from a resource, no matter the pagination. + +![SelectAllButton](./img/SelectAllButton.png) + +### Usage + +By default, react-admin's `` displays a `` in its `bulkActionsToolbar`. You can customize it by specifying your own ``: + +{% raw %} + +```jsx +import { List, Datagrid, BulkActionsToolbar, SelectAllButton, BulkDeleteButton } from 'react-admin'; + +const PostSelectAllButton = () => ( + +); + +export const PostList = () => ( + + + + + } + > + ... + + +); +``` + +{% endraw %} + +### `label` + +By default, the `` label is "Select all" (or the `ra.action.select_all_button` message translation). You can also pass a custom `label`: + +```jsx +const PostSelectAllButton = () => ; +``` + +**Tip**: The label will go through [the `useTranslate` hook](./useTranslate.md), so you can use translation keys. + +### `limit` + +By default, `` selects the 250 first items of your list. To customize this limit, you can use the `limit` prop: + +```jsx +const PostSelectAllButton = () => ; +``` + +### `queryOptions` + +`` calls a `get` method of your `dataProvider` via a react-query's `useQuery` hook. You can customize the options you pass to this hook, e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the call. + +{% raw %} + +```jsx +const PostSelectAllButton = () => ; +``` + +{% endraw %} + +### `sx`: CSS API + +To override the style of all instances of `` components using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaSelectAllButton` key. + ## `` ### `sx`: CSS API diff --git a/docs/Datagrid.md b/docs/Datagrid.md index 5b238993817..7e84d85b722 100644 --- a/docs/Datagrid.md +++ b/docs/Datagrid.md @@ -45,24 +45,24 @@ Both are [Enterprise Edition](https://react-admin-ee.marmelab.com) components. ## Props -| Prop | Required | Type | Default | Description | -| ------------------- | -------- | ----------------------- | --------------------- | ------------------------------------------------------------- | -| `children` | Required | Element | n/a | The list of `` components to render as columns. | -| `body` | Optional | Element | `` | The component used to render the body of the table. | -| `bulkActionButtons` | Optional | Element | `` | The component used to render the bulk action buttons. | -| `empty` | Optional | Element | `` | The component used to render the empty table. | -| `expand` | Optional | Element | | The component used to render the expand panel for each row. | -| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. | -| `header` | Optional | Element | `` | The component used to render the table header. | -| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. | -| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. | -| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. | -| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. | -| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. | -| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. | -| `rowSx` | Optional | Function | | A function that returns the sx prop to apply to a row. | -| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. | -| `sx` | Optional | Object | | The sx prop passed down to the Material UI `` element. | +| Prop | Required | Type | Default | Description | +| -------------------- | -------- | ----------------------- | --------------------- | ------------------------------------------------------------- | +| `children` | Required | Element | n/a | The list of `` components to render as columns. | +| `body` | Optional | Element | `` | The component used to render the body of the table. | +| `bulkActionButtons` | Optional | Element | `` | The component used to render the bulk action buttons. | +| `empty` | Optional | Element | `` | The component used to render the empty table. | +| `expand` | Optional | Element | | The component used to render the expand panel for each row. | +| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. | +| `header` | Optional | Element | `` | The component used to render the table header. | +| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. | +| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. | +| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. | +| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. | +| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. | +| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. | +| `rowSx` | Optional | Function | | A function that returns the sx prop to apply to a row. | +| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. | +| `sx` | Optional | Object | | The sx prop passed down to the Material UI `
` element. | Additional props are passed down to [the Material UI `
` element](https://mui.com/material-ui/api/table/). diff --git a/docs/Reference.md b/docs/Reference.md index b4c10529082..83f439a86d2 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -157,6 +157,7 @@ title: "Index" * [``](./Search.md) * [``](./SearchInput.md) * [``](./SearchWithResult.md) +* [``](./Buttons.md#selectallbutton) * [``](./SelectArrayInput.md) * [``](./SelectColumnsButton.md) * [``](./SelectField.md) diff --git a/docs/img/SelectAllButton.png b/docs/img/SelectAllButton.png new file mode 100644 index 00000000000..769ddd8dd22 Binary files /dev/null and b/docs/img/SelectAllButton.png differ diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.spec.tsx index 0a06a926efc..4c5ad456722 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.spec.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import expect from 'expect'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; import { useReferenceArrayFieldController } from './useReferenceArrayFieldController'; import { testDataProvider } from '../../dataProvider'; import { CoreAdminContext } from '../../core'; +import { Basic } from './useReferenceArrayFieldController.stories'; const ReferenceArrayFieldController = props => { const { children, ...rest } = props; @@ -166,4 +167,42 @@ describe('', () => { }) ); }); + + describe('onSelectAll', () => { + it('should select all records', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(await screen.findByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2]' + ); + }); + }); + + it('should select all records even though some records are already selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(await screen.findByTestId('checkbox-1')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1]' + ); + }); + fireEvent.click(await screen.findByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2]' + ); + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.stories.tsx b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.stories.tsx new file mode 100644 index 00000000000..7b0f1e138c2 --- /dev/null +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.stories.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { + CoreAdminContext, + type GetManyResult, + type ListControllerResult, + testDataProvider, + useReferenceArrayFieldController, +} from '../..'; + +const dataProvider = testDataProvider({ + getMany: (_resource, _params): Promise => + Promise.resolve({ + data: [ + { id: 1, title: 'bar1' }, + { id: 2, title: 'bar2' }, + ], + }), +}); + +/** + * Render prop version of the controller hook + */ +const ReferenceArrayFieldController = props => { + const { children, ...rest } = props; + const controllerProps = useReferenceArrayFieldController({ + sort: { + field: 'id', + order: 'ASC', + }, + ...rest, + }); + return children(controllerProps); +}; + +const defaultRenderProp = (props: ListControllerResult) => ( +
+
+ + +

+ Selected ids: {JSON.stringify(props.selectedIds)} +

+
+
    + {props.data?.map(record => ( +
  • + props.onToggleItem(record.id)} + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + data-testid={`checkbox-${record.id}`} + /> + {record.id} - {record.title} +
  • + ))} +
+
+); + +export const Basic = ({ children = defaultRenderProp }) => ( + + + {children} + + +); + +export default { + title: 'ra-core/controller/useReferenceArrayFieldController', +}; diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx index 64afc5570a4..b2bf76b60f3 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx @@ -6,6 +6,10 @@ import { testDataProvider } from '../../dataProvider/testDataProvider'; import { CoreAdminContext } from '../../core'; import { useReferenceManyFieldController } from './useReferenceManyFieldController'; import { memoryStore } from '../../store'; +import { + Basic, + defaultDataProvider, +} from './useReferenceManyFieldController.stories'; const ReferenceManyFieldController = props => { const { children, page = 1, perPage = 25, ...rest } = props; @@ -412,4 +416,70 @@ describe('useReferenceManyFieldController', () => { ); }); }); + + describe('onSelectAll', () => { + it('should select all records', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(await screen.findByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [0,1]' + ); + }); + }); + + it('should select all records even though some records are already selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(await screen.findByTestId('checkbox-1')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1]' + ); + }); + fireEvent.click(await screen.findByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [0,1]' + ); + }); + }); + + it('should not select more records than the provided limit', async () => { + const dataProvider = defaultDataProvider; + const getManyReference = jest.spyOn( + dataProvider, + 'getManyReference' + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(await screen.findByText('Limited Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [0]' + ); + }); + await waitFor(() => { + expect(getManyReference).toHaveBeenCalledWith( + 'books', + expect.objectContaining({ + pagination: { page: 1, perPage: 1 }, + }) + ); + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.stories.tsx b/packages/ra-core/src/controller/field/useReferenceManyFieldController.stories.tsx new file mode 100644 index 00000000000..8ede132ed73 --- /dev/null +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.stories.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { + CoreAdminContext, + type CoreAdminContextProps, + type GetManyResult, + type ListControllerResult, + testDataProvider, + useReferenceManyFieldController, +} from '../..'; + +export const defaultDataProvider = testDataProvider({ + getManyReference: (_resource, params): Promise => + Promise.resolve({ + data: [ + { id: 0, title: 'bar0' }, + { id: 1, title: 'bar1' }, + ].slice(0, params.pagination.perPage), + total: params.pagination.perPage || 2, + }), +}); + +/** + * Render prop version of the controller hook + */ +const ReferenceManyFieldController = props => { + const { children, ...rest } = props; + const controllerProps = useReferenceManyFieldController({ + sort: { + field: 'id', + order: 'ASC', + }, + ...rest, + }); + return children(controllerProps); +}; + +const defaultRenderProp = (props: ListControllerResult) => ( +
+
+ + + +

+ Selected ids: {JSON.stringify(props.selectedIds)} +

+
+
    + {props.data?.map(record => ( +
  • + props.onToggleItem(record.id)} + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + data-testid={`checkbox-${record.id}`} + /> + {record.id} - {record.title} +
  • + ))} +
+
+); + +export const Basic = ({ + children = defaultRenderProp, + dataProvider = defaultDataProvider, +}: { + children?: (props: ListControllerResult) => React.ReactNode; + dataProvider?: CoreAdminContextProps['dataProvider']; +}) => ( + + + {children} + + +); + +export default { + title: 'ra-core/controller/useReferenceManyFieldController', + excludeStories: ['defaultDataProvider'], +}; diff --git a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts index 817d9f4aa09..422f387ae04 100644 --- a/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceManyFieldController.ts @@ -1,14 +1,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { UseQueryOptions } from '@tanstack/react-query'; +import { useQueryClient, UseQueryOptions } from '@tanstack/react-query'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import lodashDebounce from 'lodash/debounce'; -import { removeEmpty } from '../../util'; -import { useGetManyReference } from '../../dataProvider'; +import { removeEmpty, useEvent } from '../../util'; +import { useDataProvider, useGetManyReference } from '../../dataProvider'; import { useNotify } from '../../notification'; import { FilterPayload, Identifier, RaRecord, SortPayload } from '../../types'; -import { ListControllerResult } from '../list'; +import type { ListControllerResult, HandleSelectAllParams } from '../list'; import usePaginationState from '../usePaginationState'; import { useRecordSelection } from '../list/useRecordSelection'; import useSortState from '../useSortState'; @@ -67,6 +67,8 @@ export const useReferenceManyFieldController = < } = props; const notify = useNotify(); const resource = useResourceContext(props); + const dataProvider = useDataProvider(); + const queryClient = useQueryClient(); const storeKey = props.storeKey ?? `${resource}.${record?.id}.${reference}`; const { meta, ...otherQueryOptions } = queryOptions; @@ -200,6 +202,60 @@ export const useReferenceManyFieldController = < } ); + const onSelectAll = useEvent( + async ({ + limit = 250, + queryOptions = {}, + }: HandleSelectAllParams = {}) => { + const { meta, onSuccess, onError } = queryOptions; + try { + const results = await queryClient.fetchQuery({ + queryKey: [ + resource, + 'getManyReference', + { + target, + id: get(record, source) as Identifier, + pagination: { page: 1, perPage: limit }, + sort, + filter, + meta, + }, + ], + queryFn: () => + dataProvider.getManyReference(reference, { + target, + id: get(record, source) as Identifier, + pagination: { page: 1, perPage: limit }, + sort, + filter, + meta, + }), + }); + + const allIds = results.data?.map(({ id }) => id) || []; + selectionModifiers.select(allIds); + if (allIds.length === limit) { + notify('ra.message.select_all_limit_reached', { + messageArgs: { max: limit }, + type: 'warning', + }); + } + + if (onSuccess) { + onSuccess(results); + } + + return results.data; + } catch (error) { + if (onError) { + onError(error); + } + notify('ra.notification.http_error', { type: 'warning' }); + } + } + ); + return { sort, data, @@ -213,6 +269,7 @@ export const useReferenceManyFieldController = < isLoading, isPending, onSelect: selectionModifiers.select, + onSelectAll, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, page, diff --git a/packages/ra-core/src/controller/list/ListContext.tsx b/packages/ra-core/src/controller/list/ListContext.tsx index f4af11d4495..5a86604cffb 100644 --- a/packages/ra-core/src/controller/list/ListContext.tsx +++ b/packages/ra-core/src/controller/list/ListContext.tsx @@ -25,6 +25,7 @@ import { ListControllerResult } from './useListController'; * @prop {Function} hideFilter a callback to hide one of the filters, e.g. hideFilter('title') * @prop {Array} selectedIds an array listing the ids of the selected rows, e.g. [123, 456] * @prop {Function} onSelect callback to change the list of selected rows, e.g. onSelect([456, 789]) + * @prop {Function} onSelectAll callback to select all the records, e.g. onSelectAll() * @prop {Function} onToggleItem callback to toggle the selection of a given record based on its id, e.g. onToggleItem(456) * @prop {Function} onUnselectItems callback to clear the selection, e.g. onUnselectItems(); * @prop {string} defaultTitle the translated title based on the resource, e.g. 'Posts' diff --git a/packages/ra-core/src/controller/list/index.ts b/packages/ra-core/src/controller/list/index.ts index e1f1c39a4f2..1953cb4ad07 100644 --- a/packages/ra-core/src/controller/list/index.ts +++ b/packages/ra-core/src/controller/list/index.ts @@ -22,4 +22,5 @@ export * from './useListSortContext'; export * from './useRecordSelection'; export * from './useUnselect'; export * from './useUnselectAll'; +export * from './useSelectAll'; export * from './WithListContext'; diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx b/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx index 936d8ef40b3..ecf82df3e54 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx @@ -22,11 +22,13 @@ import { import { CoreAdminContext } from '../../core'; import { TestMemoryRouter } from '../../routing'; import { + Basic, Authenticated, CanAccess, DisableAuthentication, -} from './useInfiniteListController.security.stories'; -import { AuthProvider } from '../../types'; + defaultDataProvider, +} from './useInfiniteListController.stories'; +import type { AuthProvider } from '../../types'; const InfiniteListController = ({ children, @@ -45,6 +47,67 @@ describe('useInfiniteListController', () => { debounce: 200, }; + describe('onSelectAll', () => { + it('should select all records', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7]' + ); + }); + }); + it('should select all records even though some records are already selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Select item 1')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1]' + ); + }); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7]' + ); + }); + }); + it('should not select more records than the provided limit', async () => { + const dataProvider = defaultDataProvider; + const getList = jest.spyOn(dataProvider, 'getList'); + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Limited Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3]' + ); + }); + await waitFor(() => { + expect(getList).toHaveBeenCalledWith( + 'posts', + expect.objectContaining({ + pagination: { page: 1, perPage: 3 }, + }) + ); + }); + }); + }); + describe('queryOptions', () => { it('should accept custom client query options', async () => { jest.spyOn(console, 'error').mockImplementationOnce(() => {}); diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.security.stories.tsx b/packages/ra-core/src/controller/list/useInfiniteListController.stories.tsx similarity index 65% rename from packages/ra-core/src/controller/list/useInfiniteListController.security.stories.tsx rename to packages/ra-core/src/controller/list/useInfiniteListController.stories.tsx index cf358e6df7d..f31d1b841cd 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.security.stories.tsx +++ b/packages/ra-core/src/controller/list/useInfiniteListController.stories.tsx @@ -4,15 +4,13 @@ import { QueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { CoreAdmin, CoreAdminContext, CoreAdminUI, Resource } from '../../core'; import { AuthProvider, DataProvider } from '../../types'; -import { - InfiniteListControllerProps, - useInfiniteListController, -} from './useInfiniteListController'; +import { useInfiniteListController } from './useInfiniteListController'; import { Browser } from '../../storybook/FakeBrowser'; import { TestMemoryRouter } from '../../routing'; export default { title: 'ra-core/controller/list/useInfiniteListController', + excludeStories: ['defaultDataProvider'], }; const styles = { @@ -26,7 +24,7 @@ const styles = { }, }; -const defaultDataProvider = fakeDataProvider( +export const defaultDataProvider = fakeDataProvider( { posts: [ { id: 1, title: 'Post #1', votes: 90 }, @@ -41,11 +39,7 @@ const defaultDataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const Posts = (props: Partial) => { - const params = useInfiniteListController({ - resource: 'posts', - ...props, - }); +const List = params => { return (
{params.isPending ? ( @@ -65,6 +59,100 @@ const Posts = (props: Partial) => { ); }; +const Posts = ({ children = List, ...props }) => { + const params = useInfiniteListController({ + resource: 'posts', + ...props, + }); + return children(params); +}; + +const ListWithCheckboxes = params => ( +
+ {params.isPending ? ( +

Loading...

+ ) : ( +
+
+ + + + +

+ Selected ids: {JSON.stringify(params.selectedIds)} +

+
+
    + {params.data?.map(record => ( +
  • + params.onToggleItem(record.id)} + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + data-testid={`checkbox-${record.id}`} + /> + {record.id} - {record.title} +
  • + ))} +
+
+ )} +
+); + +export const Basic = ({ + dataProvider = defaultDataProvider, + children = ListWithCheckboxes, +}: { + dataProvider?: DataProvider; + children?: (props) => React.JSX.Element; +}) => { + return ( + + + + {children}} /> + + + + ); +}; + const defaultAuthProvider: AuthProvider = { checkAuth: () => new Promise(resolve => setTimeout(resolve, 500)), login: () => Promise.resolve(), @@ -223,6 +311,7 @@ const AccessDenied = () => {
); }; + const AuthenticationError = () => { return (
diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.ts b/packages/ra-core/src/controller/list/useInfiniteListController.ts index b91bc07066c..c83e7ee97af 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.ts +++ b/packages/ra-core/src/controller/list/useInfiniteListController.ts @@ -1,5 +1,5 @@ import { isValidElement, useEffect, useMemo } from 'react'; -import { +import type { InfiniteQueryObserverBaseResult, InfiniteData, } from '@tanstack/react-query'; @@ -7,23 +7,21 @@ import { import { useAuthenticated, useRequireAccess } from '../../auth'; import { useTranslate } from '../../i18n'; import { useNotify } from '../../notification'; -import { - UseInfiniteGetListOptions, - useInfiniteGetList, -} from '../../dataProvider'; +import { useInfiniteGetList } from '../../dataProvider'; import { defaultExporter } from '../../export'; -import { +import { useResourceContext, useGetResourceLabel } from '../../core'; +import { useRecordSelection } from './useRecordSelection'; +import { useListParams } from './useListParams'; +import { useSelectAll } from './useSelectAll'; +import type { UseInfiniteGetListOptions } from '../../dataProvider'; +import type { ListControllerResult } from './useListController'; +import type { RaRecord, SortPayload, FilterPayload, Exporter, GetInfiniteListResult, } from '../../types'; -import { useResourceContext, useGetResourceLabel } from '../../core'; -import { useRecordSelection } from './useRecordSelection'; -import { useListParams } from './useListParams'; - -import { ListControllerResult } from './useListController'; /** * Prepare data for the InfiniteList view @@ -139,6 +137,12 @@ export const useInfiniteListController = ( } ); + const onSelectAll = useSelectAll({ + resource, + sort: { field: query.sort, order: query.order }, + filter: { ...query.filter, ...filter }, + }); + // change page if there is no data useEffect(() => { if ( @@ -194,6 +198,7 @@ export const useInfiniteListController = ( isLoading, isPending, onSelect: selectionModifiers.select, + onSelectAll, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, page: query.page, diff --git a/packages/ra-core/src/controller/list/useList.spec.tsx b/packages/ra-core/src/controller/list/useList.spec.tsx index 5d96aada58e..aa66585de51 100644 --- a/packages/ra-core/src/controller/list/useList.spec.tsx +++ b/packages/ra-core/src/controller/list/useList.spec.tsx @@ -1,24 +1,8 @@ import * as React from 'react'; -import { ReactNode } from 'react'; import expect from 'expect'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; -import { useList, UseListOptions, UseListValue } from './useList'; -import { fireEvent, render, waitFor } from '@testing-library/react'; -import { ListContextProvider } from './ListContextProvider'; -import { useListContext } from './useListContext'; - -const UseList = ({ - children, - callback, - ...props -}: UseListOptions & { - children?: ReactNode; - callback: (value: UseListValue) => void; -}) => { - const value = useList(props); - callback(value); - return {children}; -}; +import { Basic, SelectAll, Sort } from './useList.stories'; describe('', () => { it('should apply sorting correctly', async () => { @@ -28,28 +12,12 @@ describe('', () => { { id: 2, title: 'world' }, ]; - const SortButton = () => { - const listContext = useListContext(); - - return ( - - ); - }; - const { getByText } = render( - - - + /> ); await waitFor(() => { @@ -88,25 +56,7 @@ describe('', () => { it('should apply pagination correctly', async () => { const callback = jest.fn(); - const data = [ - { id: 1, title: 'hello' }, - { id: 2, title: 'world' }, - { id: 3, title: 'baz' }, - { id: 4, title: 'bar' }, - { id: 5, title: 'foo' }, - { id: 6, title: 'plop' }, - { id: 7, title: 'bazinga' }, - ]; - - render( - - ); + render(); await waitFor(() => { expect(callback).toHaveBeenCalledWith( @@ -115,13 +65,19 @@ describe('', () => { isFetching: false, isLoading: false, data: [ - { id: 6, title: 'plop' }, - { id: 7, title: 'bazinga' }, + { id: 6, title: 'And Then There Were None' }, + { id: 7, title: 'Dream of the Red Chamber' }, + { id: 8, title: 'The Hobbit' }, + { id: 9, title: 'She: A History of Adventure' }, + { + id: 10, + title: 'The Lion, the Witch and the Wardrobe', + }, ], page: 2, perPage: 5, error: null, - total: 7, + total: 10, }) ); }); @@ -135,20 +91,15 @@ describe('', () => { ]; const { rerender } = render( - + ); rerender( - ); @@ -168,18 +119,8 @@ describe('', () => { describe('filter', () => { it('should filter string data based on the filter props', () => { const callback = jest.fn(); - const data = [ - { id: 1, title: 'hello' }, - { id: 2, title: 'world' }, - ]; - render( - + ); expect(callback).toHaveBeenCalledWith( @@ -187,7 +128,7 @@ describe('', () => { sort: { field: 'id', order: 'ASC' }, isFetching: false, isLoading: false, - data: [{ id: 2, title: 'world' }], + data: [{ id: 8, title: 'The Hobbit' }], error: null, total: 1, }) @@ -204,10 +145,9 @@ describe('', () => { ]; render( - ); @@ -240,7 +180,7 @@ describe('', () => { ]; render( - record.id > 2} @@ -273,12 +213,13 @@ describe('', () => { ]; render( - + > + children + ); expect(callback).toHaveBeenCalledWith( @@ -295,24 +236,62 @@ describe('', () => { it('should apply the q filter as a full-text filter', () => { const callback = jest.fn(); - const data = [ - { id: 1, title: 'Abc', author: 'Def' }, // matches 'ab' - { id: 2, title: 'Ghi', author: 'Jkl' }, // does not match 'ab' - { id: 3, title: 'Mno', author: 'Abc' }, // matches 'ab' - ]; - - render( - - ); + render(); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ data: [ - { id: 1, title: 'Abc', author: 'Def' }, - { id: 3, title: 'Mno', author: 'Abc' }, + { id: 2, title: 'The Little Prince' }, + { id: 5, title: 'The Lord of the Rings' }, + { id: 6, title: 'And Then There Were None' }, + { id: 7, title: 'Dream of the Red Chamber' }, + { id: 8, title: 'The Hobbit' }, + { + id: 10, + title: 'The Lion, the Witch and the Wardrobe', + }, ], }) ); }); }); + + describe('onSelectAll', () => { + it('should select all records', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByRole('button', { name: 'Select All' })); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7,8,9,10]' + ); + }); + }); + it('should select all records even though some records are already selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click( + screen.getByRole('button', { name: 'Select item 1' }) + ); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1]' + ); + }); + fireEvent.click(screen.getByRole('button', { name: 'Select All' })); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7,8,9,10]' + ); + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/list/useList.stories.tsx b/packages/ra-core/src/controller/list/useList.stories.tsx new file mode 100644 index 00000000000..aa02c721473 --- /dev/null +++ b/packages/ra-core/src/controller/list/useList.stories.tsx @@ -0,0 +1,154 @@ +import React from 'react'; + +import { ListContextProvider, useList, useListContext } from '.'; +import type { UseListValue } from '.'; + +export default { + title: 'ra-core/controller/list/useList', +}; + +const ListView = () => { + const listContext = useListContext(); + return ( +
+ {listContext.isPending ? ( +

Loading...

+ ) : ( +
+
+

+ Selected ids:{' '} + {JSON.stringify(listContext.selectedIds)} +

+
+
    + {listContext.data?.map(record => ( +
  • + + listContext.onToggleItem(record.id) + } + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + /> + {record.id} - {record.title} +
  • + ))} +
+
+ )} +
+ ); +}; + +const data = [ + { id: 1, title: 'War and Peace' }, + { id: 2, title: 'The Little Prince' }, + { id: 3, title: "Swann's Way" }, + { id: 4, title: 'A Tale of Two Cities' }, + { id: 5, title: 'The Lord of the Rings' }, + { id: 6, title: 'And Then There Were None' }, + { id: 7, title: 'Dream of the Red Chamber' }, + { id: 8, title: 'The Hobbit' }, + { id: 9, title: 'She: A History of Adventure' }, + { id: 10, title: 'The Lion, the Witch and the Wardrobe' }, +]; + +const Wrapper = ({ + children = , + callback, + ...props +}: { + children: React.ReactNode; + callback?: (value: UseListValue) => void; +}) => { + const value = useList({ + data, + sort: { field: 'id', order: 'ASC' }, + ...props, + }); + callback && callback(value); + return {children}; +}; + +export const Basic = props => ; + +const SortButton = () => { + const listContext = useListContext(); + return ( +
+ + +
+ ); +}; + +export const Sort = props => ( + + + + +); + +const SelectAllButton = () => { + const value = useListContext(); + return ( +
+ + +
+ ); +}; + +export const SelectAll = props => ( + + + + +); diff --git a/packages/ra-core/src/controller/list/useList.ts b/packages/ra-core/src/controller/list/useList.ts index 2bf8c31cf96..567c6eb5e37 100644 --- a/packages/ra-core/src/controller/list/useList.ts +++ b/packages/ra-core/src/controller/list/useList.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; + import { removeEmpty } from '../../util'; import { FilterPayload, RaRecord, SortPayload } from '../../types'; import { useResourceContext } from '../../core'; @@ -162,6 +163,7 @@ export const useList = ( }, [setDisplayedFilters, setFilterValues, setPage] ); + // handle filter prop change useEffect(() => { if (!isEqual(filter, filterRef.current)) { @@ -266,6 +268,11 @@ export const useList = ( } }, [isPending, pendingState, setPendingState]); + const onSelectAll = useCallback(() => { + const allIds = data?.map(({ id }) => id) || []; + selectionModifiers.select(allIds); + }, [data, selectionModifiers]); + return { sort, data: pendingState ? undefined : finalItems?.data ?? [], @@ -283,6 +290,7 @@ export const useList = ( isLoading: loadingState, isPending: pendingState, onSelect: selectionModifiers.select, + onSelectAll, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, page, diff --git a/packages/ra-core/src/controller/list/useListContextWithProps.ts b/packages/ra-core/src/controller/list/useListContextWithProps.ts index 206be897b56..12afb5b5e0f 100644 --- a/packages/ra-core/src/controller/list/useListContextWithProps.ts +++ b/packages/ra-core/src/controller/list/useListContextWithProps.ts @@ -34,6 +34,7 @@ import { RaRecord } from '../../types'; * @prop {Function} hideFilter a callback to hide one of the filters, e.g. hideFilter('title') * @prop {Array} selectedIds an array listing the ids of the selected rows, e.g. [123, 456] * @prop {Function} onSelect callback to change the list of selected rows, e.g. onSelect([456, 789]) + * @prop {Function} onSelectAll callback to select all the records, e.g. onSelectAll({ limit: 50, queryOptions: { meta: { foo: 'bar' } } }) * @prop {Function} onToggleItem callback to toggle the selection of a given record based on its id, e.g. onToggleItem(456) * @prop {Function} onUnselectItems callback to clear the selection, e.g. onUnselectItems(); * @prop {string} defaultTitle the translated title based on the resource, e.g. 'Posts' @@ -81,6 +82,7 @@ const extractListContextProps = ({ isLoading, isPending, onSelect, + onSelectAll, onToggleItem, onUnselectItems, page, @@ -107,6 +109,7 @@ const extractListContextProps = ({ isLoading, isPending, onSelect, + onSelectAll, onToggleItem, onUnselectItems, page, diff --git a/packages/ra-core/src/controller/list/useListController.spec.tsx b/packages/ra-core/src/controller/list/useListController.spec.tsx index 25c533febf8..95eaad19e0e 100644 --- a/packages/ra-core/src/controller/list/useListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useListController.spec.tsx @@ -22,6 +22,7 @@ import { CanAccess, DisableAuthentication, } from './useListController.security.stories'; +import { Basic, defaultDataProvider } from './useListController.stories'; describe('useListController', () => { const defaultProps = { @@ -585,4 +586,66 @@ describe('useListController', () => { expect(authProvider.checkAuth).not.toHaveBeenCalled(); }); }); + + describe('onSelectAll', () => { + it('should select all records', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7]' + ); + }); + }); + it('should select all records even though some records are already selected', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Select item 1')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1]' + ); + }); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3,4,5,6,7]' + ); + }); + }); + it('should not select more records than the provided limit', async () => { + const dataProvider = defaultDataProvider; + const getList = jest.spyOn(dataProvider, 'getList'); + render(); + fireEvent.click(await screen.findByText('Limited Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: []' + ); + }); + fireEvent.click(screen.getByText('Limited Select All')); + await waitFor(() => { + expect(screen.getByTestId('selected_ids').textContent).toBe( + 'Selected ids: [1,2,3]' + ); + }); + await waitFor(() => { + expect(getList).toHaveBeenCalledWith( + 'posts', + expect.objectContaining({ + pagination: { page: 1, perPage: 3 }, + }) + ); + }); + }); + }); }); diff --git a/packages/ra-core/src/controller/list/useListController.stories.tsx b/packages/ra-core/src/controller/list/useListController.stories.tsx new file mode 100644 index 00000000000..65a450f0691 --- /dev/null +++ b/packages/ra-core/src/controller/list/useListController.stories.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; + +import { CoreAdminContext } from '../../core'; +import { ListController } from './ListController'; +import type { DataProvider } from '../../types'; +import type { ListControllerResult } from './useListController'; + +export default { + title: 'ra-core/controller/list/useListController', + excludeStories: ['defaultDataProvider'], +}; + +export const defaultDataProvider = fakeDataProvider( + { + posts: [ + { id: 1, title: 'Morbi suscipit malesuada' }, + { id: 2, title: 'Quisque sodales ipsum' }, + { id: 3, title: 'Maecenas at tortor' }, + { id: 4, title: 'Integer commodo est' }, + { id: 5, title: 'In eget accumsan' }, + { id: 6, title: 'Curabitur fringilla tellus' }, + { id: 7, title: 'Nunc ut purus' }, + ], + }, + process.env.NODE_ENV === 'development' +); + +const defaultRender = params => ( +
+
+ + + + +

+ Selected ids: {JSON.stringify(params.selectedIds)} +

+
+
    + {params.data?.map(record => ( +
  • + params.onToggleItem(record.id)} + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + /> + {record.id} - {record.title} +
  • + ))} +
+
+); + +export const Basic = ({ + dataProvider = defaultDataProvider, + children = defaultRender, +}: { + dataProvider?: DataProvider; + children?: (params: ListControllerResult) => JSX.Element; +}) => ( + + {children} + +); diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 0e69f46e133..37c500caa98 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -8,12 +8,22 @@ import { UseGetListHookValue, UseGetListOptions, } from '../../dataProvider'; -import { defaultExporter } from '../../export'; -import { FilterPayload, SortPayload, RaRecord, Exporter } from '../../types'; import { useResourceContext, useGetResourceLabel } from '../../core'; import { useRecordSelection } from './useRecordSelection'; import { useListParams } from './useListParams'; +import { useSelectAll } from './useSelectAll'; +import { defaultExporter } from '../../export'; import { SORT_ASC } from './queryReducer'; +import type { + FilterPayload, + SortPayload, + RaRecord, + Exporter, +} from '../../types'; +import type { + UseReferenceArrayFieldControllerParams, + UseReferenceManyFieldControllerParams, +} from '../field'; /** * Prepare data for the List view @@ -168,6 +178,12 @@ export const useListController = ( name: getResourceLabel(resource, 2), }); + const onSelectAll = useSelectAll({ + resource, + sort: { field: query.sort, order: query.order }, + filter: { ...query.filter, ...filter }, + }); + return { sort: currentSort, data, @@ -183,6 +199,7 @@ export const useListController = ( isLoading, isPending, onSelect: selectionModifiers.select, + onSelectAll, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, page: query.page, @@ -195,7 +212,7 @@ export const useListController = ( setPerPage: queryModifiers.setPerPage, setSort: queryModifiers.setSort, showFilter: queryModifiers.showFilter, - total: total, + total, hasNextPage: pageInfo ? pageInfo.hasNextPage : total != null @@ -431,6 +448,7 @@ export const injectedProps = [ 'isLoading', 'isPending', 'onSelect', + 'onSelectAll', 'onToggleItem', 'onUnselectItems', 'page', @@ -475,6 +493,13 @@ export interface ListControllerBaseResult { filterValues: any; hideFilter: (filterName: string) => void; onSelect: (ids: RecordType['id'][]) => void; + onSelectAll: (options?: { + limit?: number; + queryOptions?: + | UseGetListOptions + | UseReferenceArrayFieldControllerParams['queryOptions'] + | UseReferenceManyFieldControllerParams['queryOptions']; + }) => void; onToggleItem: (id: RecordType['id']) => void; onUnselectItems: () => void; page: number; diff --git a/packages/ra-core/src/controller/list/useSelectAll.spec.tsx b/packages/ra-core/src/controller/list/useSelectAll.spec.tsx new file mode 100644 index 00000000000..08c6a5d4209 --- /dev/null +++ b/packages/ra-core/src/controller/list/useSelectAll.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { + Basic, + defaultDataProvider, + Limit, + QueryOptions, +} from './useSelectAll.stories'; + +describe('useSelectAll', () => { + it('should select all', async () => { + render(); + await screen.findByText('Selected ids: []'); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect( + screen.queryByText('Selected ids: [1,2,3,4,5,6,7]') + ).not.toBeNull(); + }); + }); + + it('should select all with limit', async () => { + render(); + await screen.findByText('Selected ids: []'); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.queryByText('Selected ids: [1,2,3]')).not.toBeNull(); + }); + }); + + it('should pass query options', async () => { + const getList = jest.spyOn(defaultDataProvider, 'getList'); + render(); + await screen.findByText('Selected ids: []'); + fireEvent.click(screen.getByText('Select All')); + await waitFor(() => { + expect(getList).toHaveBeenCalledWith('posts', { + meta: { foo: 'bar' }, + pagination: { page: 1, perPage: 250 }, + sort: { field: 'id', order: 'ASC' }, + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/list/useSelectAll.stories.tsx b/packages/ra-core/src/controller/list/useSelectAll.stories.tsx new file mode 100644 index 00000000000..985d1672038 --- /dev/null +++ b/packages/ra-core/src/controller/list/useSelectAll.stories.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; + +import { CoreAdminContext } from '../../core'; +import { ListController } from './ListController'; +import { useSelectAll } from './useSelectAll'; + +export default { + title: 'ra-core/controller/useSelectAll', + excludeStories: ['defaultDataProvider'], +}; + +export const defaultDataProvider = fakeDataProvider( + { + posts: [ + { id: 1, title: 'Morbi suscipit malesuada' }, + { id: 2, title: 'Quisque sodales ipsum' }, + { id: 3, title: 'Maecenas at tortor' }, + { id: 4, title: 'Integer commodo est' }, + { id: 5, title: 'In eget accumsan' }, + { id: 6, title: 'Curabitur fringilla tellus' }, + { id: 7, title: 'Nunc ut purus' }, + ], + }, + process.env.NODE_ENV === 'development' +); + +const ListView = ({ options, ...params }) => ( +
+ +

Selected ids: {JSON.stringify(params.selectedIds)}

+
    + {params.data?.map(record => ( +
  • + params.onToggleItem(record.id)} + style={{ + cursor: 'pointer', + marginRight: '10px', + }} + /> + {record.id} - {record.title} +
  • + ))} +
+
+); + +const SelectAllButton = ({ options }) => { + const selectAll = useSelectAll({ + resource: 'posts', + sort: { field: 'id', order: 'ASC' }, + }); + return ( + + ); +}; + +export const Basic = ({ dataProvider = defaultDataProvider, options }) => ( + + + {controllerProps => ( + + )} + + +); + +export const Limit = ({ dataProvider = defaultDataProvider }) => ( + +); + +export const QueryOptions = ({ dataProvider = defaultDataProvider }) => ( + +); diff --git a/packages/ra-core/src/controller/list/useSelectAll.tsx b/packages/ra-core/src/controller/list/useSelectAll.tsx new file mode 100644 index 00000000000..4e5e57646e5 --- /dev/null +++ b/packages/ra-core/src/controller/list/useSelectAll.tsx @@ -0,0 +1,127 @@ +import { useQueryClient } from '@tanstack/react-query'; + +import { useNotify } from '../../notification'; +import { useDataProvider, UseGetListOptions } from '../../dataProvider'; +import { useRecordSelection } from './useRecordSelection'; +import { useResourceContext } from '../../core'; +import { useEvent } from '../../util'; +import type { FilterPayload, RaRecord, SortPayload } from '../../types'; + +/** + * Get a callback to select all records of a resource (capped by the limit parameter) + * + * @param {Object} params The hook parameters { resource, sort, filter } + * @returns {Function} handleSelectAll A function to select all items of a list + * + * @example + * import { List, Datagrid, BulkActionsToolbar, BulkDeleteButton, useListContext, useSelectAll } from 'react-admin'; + * + * const MySelectAllButton = () => { + * const { sort, filter } = useListContext(); + * const handleSelectAll = useSelectAll({ resource: 'posts', sort, filter }); + * const handleClick = () => handleSelectAll({ + * queryOptions: { meta: { foo: 'bar' } }, + * limit: 250, + * }); + * return ; + * }; + * + * const PostBulkActionsToolbar = () => ( + * }> + * + * + * ); + * + * export const PostList = () => ( + * + * }> + * ... + * + * + * ); + */ +export const useSelectAll = ( + params: UseSelectAllParams +): UseSelectAllResult => { + const { sort, filter } = params; + const resource = useResourceContext(params); + if (!resource) { + throw new Error( + 'useSelectAll should be used inside a ResourceContextProvider or passed a resource prop' + ); + } + const dataProvider = useDataProvider(); + const queryClient = useQueryClient(); + const [, { select }] = useRecordSelection({ resource }); + const notify = useNotify(); + + const handleSelectAll = useEvent( + async ({ + queryOptions = {}, + limit = 250, + }: HandleSelectAllParams = {}) => { + const { meta, onSuccess, onError, ...otherQueryOptions } = + queryOptions; + try { + const results = await queryClient.fetchQuery({ + queryKey: [ + resource, + 'getList', + { + pagination: { page: 1, perPage: limit }, + sort, + filter, + meta, + }, + ], + queryFn: () => + dataProvider.getList(resource, { + pagination: { + page: 1, + perPage: limit, + }, + sort, + filter, + meta, + }), + ...otherQueryOptions, + }); + + const allIds = results.data?.map(({ id }) => id) || []; + select(allIds); + if (allIds.length === limit) { + notify('ra.message.select_all_limit_reached', { + messageArgs: { max: limit }, + type: 'warning', + }); + } + + if (onSuccess) { + onSuccess(results); + } + + return results.data; + } catch (error) { + if (onError) { + onError(error); + } else { + notify('ra.notification.http_error', { type: 'warning' }); + } + } + } + ); + return handleSelectAll; +}; + +export interface UseSelectAllParams { + resource?: string; + sort?: SortPayload; + filter?: FilterPayload; +} + +export interface HandleSelectAllParams { + limit?: number; + queryOptions?: UseGetListOptions; +} + +export type UseSelectAllResult = (options?: HandleSelectAllParams) => void; diff --git a/packages/ra-core/src/i18n/TranslationMessages.ts b/packages/ra-core/src/i18n/TranslationMessages.ts index dc63d11e88b..1ac87b1b4ed 100644 --- a/packages/ra-core/src/i18n/TranslationMessages.ts +++ b/packages/ra-core/src/i18n/TranslationMessages.ts @@ -29,6 +29,7 @@ export interface TranslationMessages extends StringMap { save: string; search: string; select_all: string; + select_all_button: string; select_row: string; show: string; sort: string; @@ -94,7 +95,9 @@ export interface TranslationMessages extends StringMap { message: { [key: string]: StringMap | string; about: string; + access_denied: string; are_you_sure: string; + authentication_error: string; auth_error: string; bulk_delete_content: string; bulk_delete_title: string; @@ -109,10 +112,9 @@ export interface TranslationMessages extends StringMap { loading: string; no: string; not_found: string; - yes: string; + select_all_limit_reached: string; unsaved_changes: string; - access_denied: string; - authentication_error: string; + yes: string; }; navigation: { [key: string]: StringMap | string; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 704b30e6919..e9449ea6d38 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -25,6 +25,7 @@ const englishMessages: TranslationMessages = { save: 'Save', search: 'Search', select_all: 'Select all', + select_all_button: 'Select all', select_row: 'Select this row', show: 'Show', sort: 'Sort', @@ -87,7 +88,11 @@ const englishMessages: TranslationMessages = { }, message: { about: 'About', + access_denied: + "You don't have the right permissions to access this page", are_you_sure: 'Are you sure?', + authentication_error: + 'The authentication server returned an error and your credentials could not be checked.', auth_error: 'An error occurred while validating the authentication token.', bulk_delete_content: @@ -103,19 +108,16 @@ const englishMessages: TranslationMessages = { delete_title: 'Delete %{name} #%{id}', details: 'Details', error: "A client error occurred and your request couldn't be completed.", - invalid_form: 'The form is not valid. Please check for errors', loading: 'Please wait', no: 'No', not_found: 'Either you typed a wrong URL, or you followed a bad link.', - yes: 'Yes', + select_all_limit_reached: + 'There are too many elements to select them all. Only the first %{max} elements were selected.', unsaved_changes: "Some of your changes weren't saved. Are you sure you want to ignore them?", - access_denied: - "You don't have the right permissions to access this page", - authentication_error: - 'The authentication server returned an error and your credentials could not be checked.', + yes: 'Yes', }, navigation: { clear_filters: 'Clear filters', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index 36773753e4f..235f51d9715 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -25,6 +25,7 @@ const frenchMessages: TranslationMessages = { remove: 'Supprimer', save: 'Enregistrer', select_all: 'Tout sélectionner', + select_all_button: 'Tout sélectionner', select_row: 'Sélectionner cette ligne', search: 'Rechercher', show: 'Afficher', @@ -89,7 +90,10 @@ const frenchMessages: TranslationMessages = { }, message: { about: 'Au sujet de', + access_denied: "Vous n'avez pas les droits d'accès à cette page", are_you_sure: 'Êtes-vous sûr ?', + authentication_error: + "Le serveur d'authentification a retourné une erreur et vos autorisations n'ont pas pu être vérifiées.", auth_error: "Une erreur est survenue lors de la validation de votre jeton d'authentification.", bulk_delete_content: @@ -107,19 +111,17 @@ const frenchMessages: TranslationMessages = { delete_title: 'Supprimer %{name} #%{id}', details: 'Détails', error: "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.", - invalid_form: "Le formulaire n'est pas valide.", loading: 'La page est en cours de chargement, merci de bien vouloir patienter.', no: 'Non', not_found: "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.", - yes: 'Oui', + select_all_limit_reached: + "Il y a trop d'éléments pour tous les sélectionner. Seuls les %{max} premiers éléments ont été sélectionnés.", unsaved_changes: "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?", - access_denied: "Vous n'avez pas les droits d'accès à cette page", - authentication_error: - "Le serveur d'authentification a retourné une erreur et vos autorisations n'ont pas pu être vérifiées.", + yes: 'Oui', }, navigation: { clear_filters: 'Effacer les filtres', diff --git a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx index 45310435893..4b650bb0828 100644 --- a/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkDeleteWithUndoButton.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; -import { styled } from '@mui/material/styles'; import { ReactElement } from 'react'; import ActionDelete from '@mui/icons-material/Delete'; -import { alpha } from '@mui/material/styles'; +import { alpha, styled } from '@mui/material/styles'; import { useDeleteMany, useRefresh, diff --git a/packages/ra-ui-materialui/src/button/BulkUpdateButton.spec.tsx b/packages/ra-ui-materialui/src/button/BulkUpdateButton.spec.tsx index 298c96ecc5d..b8aed5e8303 100644 --- a/packages/ra-ui-materialui/src/button/BulkUpdateButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/BulkUpdateButton.spec.tsx @@ -8,7 +8,9 @@ describe('BulkUpdateButton', () => { it('should ask confirmation before updating in pessimistic mode', async () => { render(); await screen.findByText('War and Peace'); - const checkbox = await screen.findByLabelText('Select all'); + const checkbox = await screen.findByRole('checkbox', { + name: 'Select all', + }); checkbox.click(); await screen.getByText('10 items selected'); const button = screen.getByLabelText('Update Pessimistic'); diff --git a/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx b/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx index 7e32088920d..400954badc7 100644 --- a/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx +++ b/packages/ra-ui-materialui/src/button/BulkUpdateWithUndoButton.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; -import { styled } from '@mui/material/styles'; import { ReactElement } from 'react'; import ActionUpdate from '@mui/icons-material/Update'; -import { alpha } from '@mui/material/styles'; +import { alpha, styled } from '@mui/material/styles'; import { useUpdateMany, useRefresh, diff --git a/packages/ra-ui-materialui/src/button/SelectAllButton.spec.tsx b/packages/ra-ui-materialui/src/button/SelectAllButton.spec.tsx new file mode 100644 index 00000000000..e3cff98ad95 --- /dev/null +++ b/packages/ra-ui-materialui/src/button/SelectAllButton.spec.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Basic, Label, Limit } from './SelectAllButton.stories'; + +describe('', () => { + it('should render a "Select All" button', async () => { + render(); + await screen.findByText('War and Peace'); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByRole('button', { name: 'Select all' }); + }); + + it('should not render a "Select All" button if not all checkboxes are checked', async () => { + render(); + await screen.findByText('War and Peace'); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + await screen.findByRole('button', { name: 'Select all' }); + fireEvent.click(screen.getAllByRole('checkbox')[1]); + await screen.findByText('9 items selected'); + expect(screen.queryByRole('button', { name: 'Select all' })).toBeNull(); + }); + + it('should select all items', async () => { + render(); + await screen.findByText('War and Peace'); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + fireEvent.click(screen.getByRole('button', { name: 'Select all' })); + await screen.findByText('17 items selected'); + }); + + describe('label', () => { + it('should allow to customize the label', async () => { + render(
); + +export const WithPagination = ({ + dataProvider = fullDataProvider, + selectAllButton, +}: { + dataProvider?: AdminProps['dataProvider']; + selectAllButton?: React.ReactElement; +}) => ( + englishMessages)} + dataProvider={dataProvider} + record={authors[3]} + > + } + perPage={5} + > + + + + } + > + + + + +); + +export const WithPaginationAndSelectAllLimit = ({ + dataProvider, + limit = 6, +}: { + dataProvider?: AdminProps['dataProvider']; + limit?: number; +}) => ( + } + dataProvider={dataProvider} + /> +); + +const AuthorEdit = () => ( + + + + + } + perPage={5} + > + + + + + + +); + +export const FullApp = () => ( + + englishMessages)} + > + + + + +); diff --git a/packages/ra-ui-materialui/src/layout/Confirm.tsx b/packages/ra-ui-materialui/src/layout/Confirm.tsx index 430482cc373..b2521e56bfc 100644 --- a/packages/ra-ui-materialui/src/layout/Confirm.tsx +++ b/packages/ra-ui-materialui/src/layout/Confirm.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { styled } from '@mui/material/styles'; import { useCallback, MouseEventHandler, ComponentType } from 'react'; import Dialog, { DialogProps } from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; @@ -7,7 +6,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import Button from '@mui/material/Button'; -import { alpha } from '@mui/material/styles'; +import { alpha, styled } from '@mui/material/styles'; import ActionCheck from '@mui/icons-material/CheckCircle'; import AlertError from '@mui/icons-material/ErrorOutline'; import clsx from 'clsx'; diff --git a/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx b/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx index 1d42530c1f6..26e39f01da1 100644 --- a/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx +++ b/packages/ra-ui-materialui/src/list/BulkActionsToolbar.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { ReactNode, useCallback } from 'react'; -import { styled } from '@mui/material/styles'; +import { isValidElement, ReactElement, ReactNode, useCallback } from 'react'; +import { alpha, styled } from '@mui/material/styles'; import clsx from 'clsx'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; @@ -10,12 +10,16 @@ import CloseIcon from '@mui/icons-material/Close'; import { useTranslate, sanitizeListRestProps, useListContext } from 'ra-core'; import TopToolbar from '../layout/TopToolbar'; +import { SelectAllButton } from '../button'; + +const defaultSelectAllButton = ; export const BulkActionsToolbar = (props: BulkActionsToolbarProps) => { const { label = 'ra.action.bulk_actions', children, className, + selectAllButton, ...rest } = props; const { selectedIds = [], onUnselectItems } = useListContext(); @@ -42,16 +46,22 @@ export const BulkActionsToolbar = (props: BulkActionsToolbarProps) => { aria-label={translate('ra.action.unselect')} title={translate('ra.action.unselect')} onClick={handleUnselectAllClick} + color="primary" size="small" > - + {translate(label, { _: label, smart_count: selectedIds.length, })} + {selectAllButton !== false + ? isValidElement(selectAllButton) + ? selectAllButton + : defaultSelectAllButton + : null}
{children} @@ -65,6 +75,7 @@ export interface BulkActionsToolbarProps { children?: ReactNode; label?: string; className?: string; + selectAllButton?: ReactElement | false; } const PREFIX = 'RaBulkActionsToolbar'; @@ -129,10 +140,13 @@ const Root = styled('div', { [`& .${BulkActionsToolbarClasses.title}`]: { display: 'flex', flex: '0 0 auto', + gap: theme.spacing(1), }, [`& .${BulkActionsToolbarClasses.icon}`]: { marginLeft: '-0.5em', - marginRight: '0.5em', + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.12), + }, }, })); diff --git a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx index c3fb1a68f57..2f865d878cc 100644 --- a/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/InfiniteList.stories.tsx @@ -12,7 +12,7 @@ import { Box, Button, Card, Typography } from '@mui/material'; import { InfiniteList } from './InfiniteList'; import { SimpleList } from './SimpleList'; -import { Datagrid } from './datagrid'; +import { Datagrid, type DatagridProps } from './datagrid'; import { InfinitePagination, Pagination as DefaultPagination, @@ -21,8 +21,9 @@ import { AdminUI } from '../AdminUI'; import { AdminContext } from '../AdminContext'; import { TextField } from '../field'; import { SearchInput } from '../input'; -import { SortButton } from '../button'; +import { BulkDeleteButton, SelectAllButton, SortButton } from '../button'; import { TopToolbar, Layout } from '../layout'; +import { BulkActionsToolbar } from './BulkActionsToolbar'; export default { title: 'ra-ui-materialui/list/InfiniteList', @@ -345,13 +346,17 @@ export const WithFooter = () => ( ); -export const WithDatagrid = () => ( +export const WithDatagrid = ({ + bulkActionsToolbar, +}: { + bulkActionsToolbar?: DatagridProps['bulkActionsToolbar']; +}) => ( ( - + @@ -362,6 +367,20 @@ export const WithDatagrid = () => ( ); +export const WithDatagridAndSelectAllLimit = ({ + limit = 23, +}: { + limit?: number; +}) => ( + }> + + + } + /> +); + const BookActions = () => ( diff --git a/packages/ra-ui-materialui/src/list/List.spec.tsx b/packages/ra-ui-materialui/src/list/List.spec.tsx index 5e117560977..a26abdb22a2 100644 --- a/packages/ra-ui-materialui/src/list/List.spec.tsx +++ b/packages/ra-ui-materialui/src/list/List.spec.tsx @@ -20,6 +20,8 @@ import { TitleFalse, TitleElement, PartialPagination, + Default, + SelectAllLimit, } from './List.stories'; const theme = createTheme(defaultTheme); @@ -340,4 +342,148 @@ describe('', () => { screen.getByText('Books'); }); }); + + describe('"Select all" button', () => { + afterEach(() => { + fireEvent.click(screen.getByRole('button', { name: 'Unselect' })); + }); + it('should be displayed if an item is selected', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(11); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + expect( + await screen.findByRole('button', { name: 'Select all' }) + ).toBeDefined(); + }); + it('should not be displayed if all items are manually selected', async () => { + render( + + Promise.resolve({ + data: [ + { + id: 0, + title: 'War and Peace', + author: 'Leo Tolstoy', + year: 1869, + }, + { + id: 1, + title: 'Pride and Prejudice', + author: 'Jane Austen', + year: 1813, + }, + ], + total: 2, + }), + })} + /> + ); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(3); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('2 items selected'); + expect( + screen.queryByRole('button', { name: 'Select all' }) + ).toBeNull(); + }); + it('should not be displayed if all items are selected with the "Select all" button', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(11); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + fireEvent.click(screen.getByRole('button', { name: 'Select all' })); + await screen.findByText('13 items selected'); + expect( + screen.queryByRole('button', { name: 'Select all' }) + ).toBeNull(); + }); + it('should not be displayed if the user reaches the limit by a manual selection', async () => { + render( + + Promise.resolve({ + data: [ + { + id: 0, + title: 'War and Peace', + author: 'Leo Tolstoy', + year: 1869, + }, + { + id: 1, + title: 'Pride and Prejudice', + author: 'Jane Austen', + year: 1813, + }, + { + id: 2, + title: 'The Picture of Dorian Gray', + author: 'Oscar Wilde', + year: 1890, + }, + ], + total: 3, + }), + })} + /> + ); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(4); + }); + fireEvent.click(screen.getAllByRole('checkbox')[1]); + fireEvent.click(screen.getAllByRole('checkbox')[2]); + await screen.findByText('2 items selected'); + expect( + screen.queryByRole('button', { name: 'Select all' }) + ).toBeNull(); + }); + it('should not be displayed if the user reaches the selectAllLimit by a click on the "Select all" button', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(11); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + fireEvent.click(screen.getByRole('button', { name: 'Select all' })); + await screen.findByText('11 items selected'); + await screen.findByText( + 'There are too many elements to select them all. Only the first 11 elements were selected.' + ); + expect( + screen.queryByRole('button', { name: 'Select all' }) + ).toBeNull(); + }); + it('should select all items', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(11); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + fireEvent.click(screen.getByRole('button', { name: 'Select all' })); + await screen.findByText('13 items selected'); + }); + it('should select the maximum items possible up to the selectAllLimit', async () => { + render(); + await waitFor(() => { + expect(screen.queryAllByRole('checkbox')).toHaveLength(11); + }); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + await screen.findByText('10 items selected'); + fireEvent.click(screen.getByRole('button', { name: 'Select all' })); + await screen.findByText('11 items selected'); + await screen.findByText( + 'There are too many elements to select them all. Only the first 11 elements were selected.' + ); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index 37b99a26627..31f3bed3cde 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -18,9 +18,10 @@ import { TextField } from '../field'; import { SearchInput, TextInput } from '../input'; import { Route } from 'react-router'; import { Link } from 'react-router-dom'; -import { ListButton } from '../button'; +import { BulkDeleteButton, ListButton, SelectAllButton } from '../button'; import { ShowGuesser } from '../detail'; import TopToolbar from '../layout/TopToolbar'; +import { BulkActionsToolbar } from './BulkActionsToolbar'; export default { title: 'ra-ui-materialui/list/List' }; @@ -134,7 +135,7 @@ const data = { authors: [], }; -const dataProvider = fakeRestDataProvider(data); +const defaultDataProvider = fakeRestDataProvider(data); const BookList = () => { const { error, isPending } = useListContext(); @@ -155,7 +156,7 @@ const BookList = () => { export const Basic = () => ( - + ( @@ -170,7 +171,7 @@ export const Basic = () => ( export const Actions = () => ( - + ( @@ -191,7 +192,7 @@ export const Actions = () => ( export const Filters = () => ( - + ( @@ -223,7 +224,7 @@ export const Filters = () => ( export const Filter = () => ( - + ( @@ -238,7 +239,7 @@ export const Filter = () => ( export const Title = () => ( - + ( @@ -253,7 +254,7 @@ export const Title = () => ( export const TitleElement = () => ( - + ( @@ -268,7 +269,7 @@ export const TitleElement = () => ( export const TitleFalse = () => ( - + ( @@ -283,7 +284,7 @@ export const TitleFalse = () => ( export const HasCreate = () => ( - + ( @@ -300,7 +301,7 @@ const AsideComponent = () => Aside; export const Aside = () => ( - + ( @@ -324,7 +325,7 @@ const CustomWrapper = ({ children }) => ( export const Component = () => ( - + ( @@ -372,7 +373,7 @@ export const PartialPagination = () => ( export const Empty = () => ( - + ( @@ -416,7 +417,7 @@ export const EmptyPartialPagination = () => ( export const SX = () => ( - + ( @@ -441,10 +442,10 @@ export const Meta = () => ( { console.log('getList', resource, params); - return dataProvider.getList(resource, params); + return defaultDataProvider.getList(resource, params); }, } as any } @@ -466,19 +467,36 @@ export const Meta = () => ( ); -export const Default = () => ( +export const Default = ({ + dataProvider = defaultDataProvider, + children, + selectAllButton, +}: { + dataProvider?: DataProvider; + children?: React.ReactNode; + selectAllButton?: React.ReactElement; +}) => ( ( ]}> - + + + + } + > + {children} )} /> @@ -486,6 +504,23 @@ export const Default = () => ( ); +export const SelectAllLimit = ({ + dataProvider, + children, + limit = 11, +}: { + dataProvider?: DataProvider; + children?: React.ReactNode; + limit?: number; +}) => ( + } + dataProvider={dataProvider} + > + {children} + +); + const NewerBooks = () => ( ( export const StoreKey = () => ( - + } /> } /> @@ -608,7 +643,7 @@ export const StoreDisabled = () => { return ( @@ -649,7 +684,7 @@ export const LocationNotSyncWithStore = () => { return ( - + } @@ -731,9 +766,12 @@ export const ResponseMetadata = () => ( { - const result = await dataProvider.getList(resource, params); + const result = await defaultDataProvider.getList( + resource, + params + ); return { ...result, meta: { diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.spec.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.spec.tsx index b6561360807..d6aa3c88148 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.spec.tsx @@ -8,8 +8,9 @@ import { ResourceContextProvider, } from 'ra-core'; import { ThemeProvider, createTheme } from '@mui/material'; + import { Datagrid } from './Datagrid'; -import { AccessControl } from './Datagrid.stories'; +import { AccessControl, SelectAllButton } from './Datagrid.stories'; const TitleField = (): JSX.Element => { const record = useRecordContext(); @@ -113,6 +114,18 @@ describe('', () => { ); }); + it('should accept a custom bulkActionsToolbar', async () => { + render(); + fireEvent.click(await screen.findByLabelText('ra.action.select_all')); + expect(screen.queryByText('Select all records')).not.toBeNull(); + }); + + it('should not display the bulk actions toolbar when when `bulkActionsToolbar` prop is false', async () => { + render(); + fireEvent.click(await screen.findByLabelText('ra.action.select_all')); + expect(screen.queryByText('Select All')).toBeNull(); + }); + describe('selecting items with the shift key', () => { it('should call onSelect with the correct ids when the last selection is after the first', () => { const Test = ({ selectedIds = [] }) => ( @@ -125,7 +138,7 @@ describe('', () => { const { rerender } = render(); const checkboxes = screen.queryAllByRole('checkbox'); fireEvent.click(checkboxes[1]); - rerender(); + rerender(); fireEvent.click(checkboxes[3], { shiftKey: true, checked: true, @@ -145,7 +158,7 @@ describe('', () => { const { rerender } = render(); const checkboxes = screen.queryAllByRole('checkbox'); fireEvent.click(checkboxes[3], { checked: true }); - rerender(); + rerender(); fireEvent.click(checkboxes[1], { shiftKey: true, checked: true, @@ -162,10 +175,12 @@ describe('', () => { ); - const { rerender } = render(); + const { rerender } = render( + + ); const checkboxes = screen.queryAllByRole('checkbox'); fireEvent.click(checkboxes[3], { checked: true }); - rerender(); + rerender(); fireEvent.click(checkboxes[4], { shiftKey: true }); expect(contextValue.onToggleItem).toHaveBeenCalledTimes(1); expect(contextValue.onSelect).toHaveBeenCalledWith([1, 2]); @@ -208,7 +223,7 @@ describe('', () => { const { rerender } = render(); const checkboxes = screen.queryAllByRole('checkbox'); fireEvent.click(checkboxes[1], { checked: true }); - rerender(); + rerender(); // Simulate unselecting all items rerender(); @@ -256,15 +271,15 @@ describe('', () => { fireEvent.click(checkboxes[1], { checked: true }); expect(contextValue.onToggleItem).toHaveBeenCalledWith(1); - rerender(); + rerender(); fireEvent.click(checkboxes[2], { shiftKey: true, checked: true }); expect(contextValue.onSelect).toHaveBeenCalledWith([1, 2]); - rerender(); + rerender(); fireEvent.click(checkboxes[2]); expect(contextValue.onToggleItem).toHaveBeenCalledWith(2); - rerender(); + rerender(); fireEvent.click(checkboxes[4], { shiftKey: true, checked: true }); expect(contextValue.onToggleItem).toHaveBeenCalledWith(4); @@ -298,7 +313,7 @@ describe('', () => { await waitFor(() => expect( screen.queryAllByLabelText('Select this row') - ).toHaveLength(4) + ).toHaveLength(7) ); }); }); diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.stories.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.stories.tsx index 3545062d62a..3c02061e1c9 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.stories.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.stories.tsx @@ -4,9 +4,7 @@ import { ResourceContextProvider, ListContextProvider, CoreAdminContext, - testDataProvider, useRecordContext, - useRecordSelection, useGetList, useList, TestMemoryRouter, @@ -21,7 +19,11 @@ import { Box, Checkbox, TableCell, TableRow, styled } from '@mui/material'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import { FieldProps, TextField } from '../../field'; -import { BulkDeleteButton, BulkExportButton } from '../../button'; +import { + BulkDeleteButton, + BulkExportButton, + SelectAllButton as RaSelectAllButton, +} from '../../button'; import { Datagrid, DatagridProps } from './Datagrid'; import { ShowGuesser, SimpleShowLayout } from '../../detail'; import { AdminUI } from '../../AdminUI'; @@ -30,69 +32,70 @@ import { List } from '../List'; import { EditGuesser } from '../../detail'; import { DatagridRowProps } from './DatagridRow'; import DatagridBody, { DatagridBodyProps } from './DatagridBody'; +import { BulkActionsToolbar } from '../BulkActionsToolbar'; export default { title: 'ra-ui-materialui/list/Datagrid' }; -const data = [ - { - id: 1, - title: 'War and Peace', - author: 'Leo Tolstoy', - year: 1869, - }, - { - id: 2, - title: 'Pride and Predjudice', - author: 'Jane Austen', - year: 1813, - }, - { - id: 3, - title: 'The Picture of Dorian Gray', - author: 'Oscar Wilde', - year: 1890, - }, - { - id: 4, - title: 'Le Petit Prince', - author: 'Antoine de Saint-Exupéry', - year: 1943, - }, -]; +const data = { + books: [ + { + id: 1, + title: 'War and Peace', + author: 'Leo Tolstoy', + year: 1869, + }, + { + id: 2, + title: 'Pride and Predjudice', + author: 'Jane Austen', + year: 1813, + }, + { + id: 3, + title: 'The Picture of Dorian Gray', + author: 'Oscar Wilde', + year: 1890, + }, + { + id: 4, + title: 'Le Petit Prince', + author: 'Antoine de Saint-Exupéry', + year: 1943, + }, + { + id: 5, + title: 'The Alchemist', + author: 'Paulo Coelho', + year: 1988, + }, + { + id: 6, + title: 'Madame Bovary', + author: 'Gustave Flaubert', + year: 1857, + }, + { + id: 7, + title: 'The Lord of the Rings', + author: 'J. R. R. Tolkien', + year: 1954, + }, + ], +}; + +const dataProvider = fakeRestDataProvider(data); const theme = createTheme(); -const SubWrapper = ({ children }) => { - const [selectedIds, selectionModifiers] = useRecordSelection({ - resource: 'books', - }); - return ( +const Wrapper = ({ children }) => ( + - - {children} - + + {children} + - ); -}; - -const Wrapper = ({ children }) => ( - - {children} ); @@ -182,6 +185,66 @@ export const RowSx = () => ( ); +export const SelectAllButton = ({ + onlyDisplay, +}: { + onlyDisplay?: 'default' | 'disabled' | 'custom'; +}) => ( + + + {(!onlyDisplay || onlyDisplay === 'default') && ( + <> +

Default

+ + + + + + + + )} + {(!onlyDisplay || onlyDisplay === 'disabled') && ( + <> +

Disabled

+ + + + } + > + + + + + + + )} + {(!onlyDisplay || onlyDisplay === 'custom') && ( + <> +

Custom

+ + } + > + + + } + > + + + + + + + )} +
+
+); + const CutomBulkActionButtons = () => ( <> @@ -373,11 +436,7 @@ const MyCustomListInteractive = () => { export const Standalone = () => ( - Promise.resolve({ data, total: 4 }) as any, - })} - > +

Static

@@ -469,8 +528,6 @@ export const RowClickFalse = () => ( ); -const dataProvider = fakeRestDataProvider({ books: data }); - export const FullApp = ({ rowClick, }: { diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index 48ed65064c7..ea532f1ad43 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -44,6 +44,7 @@ const defaultBulkActionButtons = ; * * Props: * - body + * - bulkActionToolbar * - bulkActionButtons * - children * - empty @@ -132,6 +133,7 @@ export const Datagrid: React.ForwardRefExoticComponent< className, empty = DefaultEmpty, expand, + bulkActionsToolbar, bulkActionButtons = canDelete ? defaultBulkActionButtons : false, hover, isRowSelectable, @@ -244,13 +246,14 @@ export const Datagrid: React.ForwardRefExoticComponent< sx={sx} className={clsx(DatagridClasses.root, className)} > - {bulkActionButtons !== false ? ( - - {isValidElement(bulkActionButtons) - ? bulkActionButtons - : defaultBulkActionButtons} - - ) : null} + {bulkActionsToolbar ?? + (bulkActionButtons !== false ? ( + + {isValidElement(bulkActionButtons) + ? bulkActionButtons + : defaultBulkActionButtons} + + ) : null)}
*/ className?: string; + /** + * The component used to render the bulk actions toolbar. + * + * @example + * import { List, Datagrid, BulkActionsToolbar, SelectAllButton, BulkDeleteButton } from 'react-admin'; + * + * const PostBulkActionsToolbar = () => ( + * }> + * + * + * ); + * + * export const PostList = () => ( + * + * }> + * ... + * + * + * ); + */ + bulkActionsToolbar?: ReactElement; + /** * The component used to render the bulk action buttons. Defaults to . * diff --git a/packages/ra-ui-materialui/src/list/datagrid/ExpandAllButton.spec.tsx b/packages/ra-ui-materialui/src/list/datagrid/ExpandAllButton.spec.tsx index a37ed597d36..5ab48ee866b 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/ExpandAllButton.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/ExpandAllButton.spec.tsx @@ -19,10 +19,12 @@ describe('ExpandAllButton', () => { expect(screen.queryAllByTestId('ExpandPanel')).toHaveLength(count); }; + await screen.findByText('War and Peace'); + expectExpandedRows(0); expand(); - expectExpandedRows(4); + expectExpandedRows(5); collapse(); expectExpandedRows(0);