Skip to content

Commit 1d6525a

Browse files
authored
Merge pull request #10367 from marmelab/feat/next/select_all
Add a “SELECT ALL” button in the `<BulkActionsToolbar>`
2 parents ecaee9d + 31b3bd5 commit 1d6525a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2585
-323
lines changed

docs/Buttons.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ React-Admin provides button components for all the common uses.
2020
- [`<BulkUpdateButton>`](#bulkupdatebutton)
2121
- [`<BulkUpdateFormButton>`](#bulkupdateformbutton)
2222
- [`<FilterButton>`](#filterbutton)
23+
- [`<SelectAllButton>`](#selectallbutton)
2324

2425
- **Record Buttons**: To be used in detail views
2526
- [`<UpdateButton>`](#updatebutton)
@@ -1136,6 +1137,79 @@ If your `authProvider` implements [Access Control](./Permissions.md#access-contr
11361137

11371138
## `<RefreshButton>`
11381139

1140+
## `<SelectAllButton>`
1141+
1142+
The `<SelectAllButton>` component allows users to select all items from a resource, no matter the pagination.
1143+
1144+
![SelectAllButton](./img/SelectAllButton.png)
1145+
1146+
### Usage
1147+
1148+
By default, react-admin's `<Datagrid>` displays a `<SelectAllButton>` in its `bulkActionsToolbar`. You can customize it by specifying your own `<BulkActionsToolbar selectAllButton>`:
1149+
1150+
{% raw %}
1151+
1152+
```jsx
1153+
import { List, Datagrid, BulkActionsToolbar, SelectAllButton, BulkDeleteButton } from 'react-admin';
1154+
1155+
const PostSelectAllButton = () => (
1156+
<SelectAllButton
1157+
label="Select all records"
1158+
queryOptions={{ meta: { foo: 'bar' } }}
1159+
/>
1160+
);
1161+
1162+
export const PostList = () => (
1163+
<List>
1164+
<Datagrid
1165+
bulkActionsToolbar={
1166+
<BulkActionsToolbar selectAllButton={PostSelectAllButton}>
1167+
<BulkDeleteButton />
1168+
</BulkActionsToolbar>
1169+
}
1170+
>
1171+
...
1172+
</Datagrid>
1173+
</List>
1174+
);
1175+
```
1176+
1177+
{% endraw %}
1178+
1179+
### `label`
1180+
1181+
By default, the `<SelectAllButton>` label is "Select all" (or the `ra.action.select_all_button` message translation). You can also pass a custom `label`:
1182+
1183+
```jsx
1184+
const PostSelectAllButton = () => <SelectAllButton label="Select all posts" />;
1185+
```
1186+
1187+
**Tip**: The label will go through [the `useTranslate` hook](./useTranslate.md), so you can use translation keys.
1188+
1189+
### `limit`
1190+
1191+
By default, `<SelectAllButton>` selects the 250 first items of your list. To customize this limit, you can use the `limit` prop:
1192+
1193+
```jsx
1194+
const PostSelectAllButton = () => <SelectAllButton limit={100} />;
1195+
```
1196+
1197+
### `queryOptions`
1198+
1199+
`<SelectAllButton>` 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.
1200+
1201+
{% raw %}
1202+
1203+
```jsx
1204+
const PostSelectAllButton = () => <SelectAllButton queryOptions={{ meta: { foo: 'bar' } }} />;
1205+
```
1206+
1207+
{% endraw %}
1208+
1209+
### `sx`: CSS API
1210+
1211+
To override the style of all instances of `<SelectAllButton>` components using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaSelectAllButton` key.
1212+
11391213
## `<SkipNavigationButton>`
11401214

11411215
### `sx`: CSS API

docs/Datagrid.md

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,24 @@ Both are [Enterprise Edition](https://react-admin-ee.marmelab.com) components.
4545

4646
## Props
4747

48-
| Prop | Required | Type | Default | Description |
49-
| ------------------- | -------- | ----------------------- | --------------------- | ------------------------------------------------------------- |
50-
| `children` | Required | Element | n/a | The list of `<Field>` components to render as columns. |
51-
| `body` | Optional | Element | `<Datagrid Body>` | The component used to render the body of the table. |
52-
| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons. |
53-
| `empty` | Optional | Element | `<Empty>` | The component used to render the empty table. |
54-
| `expand` | Optional | Element | | The component used to render the expand panel for each row. |
55-
| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. |
56-
| `header` | Optional | Element | `<Datagrid Header>` | The component used to render the table header. |
57-
| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. |
58-
| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. |
59-
| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. |
60-
| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. |
61-
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. |
62-
| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. |
63-
| `rowSx` | Optional | Function | | A function that returns the sx prop to apply to a row. |
64-
| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. |
65-
| `sx` | Optional | Object | | The sx prop passed down to the Material UI `<Table>` element. |
48+
| Prop | Required | Type | Default | Description |
49+
| -------------------- | -------- | ----------------------- | --------------------- | ------------------------------------------------------------- |
50+
| `children` | Required | Element | n/a | The list of `<Field>` components to render as columns. |
51+
| `body` | Optional | Element | `<Datagrid Body>` | The component used to render the body of the table. |
52+
| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons. |
53+
| `empty` | Optional | Element | `<Empty>` | The component used to render the empty table. |
54+
| `expand` | Optional | Element | | The component used to render the expand panel for each row. |
55+
| `expandSingle` | Optional | Boolean | `false` | Whether to allow only one expanded row at a time. |
56+
| `header` | Optional | Element | `<Datagrid Header>` | The component used to render the table header. |
57+
| `hover` | Optional | Boolean | `true` | Whether to highlight the row under the mouse. |
58+
| `isRowExpandable` | Optional | Function | `() => true` | A function that returns whether a row is expandable. |
59+
| `isRowSelectable` | Optional | Function | `() => true` | A function that returns whether a row is selectable. |
60+
| `optimized` | Optional | Boolean | `false` | Whether to optimize the rendering of the table. |
61+
| `rowClick` | Optional | mixed | | The action to trigger when the user clicks on a row. |
62+
| `rowStyle` | Optional | Function | | A function that returns the style to apply to a row. |
63+
| `rowSx` | Optional | Function | | A function that returns the sx prop to apply to a row. |
64+
| `size` | Optional | `'small'` or `'medium'` | `'small'` | The size of the table. |
65+
| `sx` | Optional | Object | | The sx prop passed down to the Material UI `<Table>` element. |
6666

6767
Additional props are passed down to [the Material UI `<Table>` element](https://mui.com/material-ui/api/table/).
6868

docs/Reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ title: "Index"
157157
* [`<Search>`](./Search.md)<img class="icon" src="./img/premium.svg" />
158158
* [`<SearchInput>`](./SearchInput.md)
159159
* [`<SearchWithResult>`](./SearchWithResult.md)<img class="icon" src="./img/premium.svg" />
160+
* [`<SelectAllButton>`](./Buttons.md#selectallbutton)
160161
* [`<SelectArrayInput>`](./SelectArrayInput.md)
161162
* [`<SelectColumnsButton>`](./SelectColumnsButton.md)
162163
* [`<SelectField>`](./SelectField.md)

docs/img/SelectAllButton.png

61.1 KB
Loading

packages/ra-core/src/controller/field/useReferenceArrayFieldController.spec.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as React from 'react';
22
import expect from 'expect';
3-
import { render, waitFor } from '@testing-library/react';
3+
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
44

55
import { useReferenceArrayFieldController } from './useReferenceArrayFieldController';
66
import { testDataProvider } from '../../dataProvider';
77
import { CoreAdminContext } from '../../core';
8+
import { Basic } from './useReferenceArrayFieldController.stories';
89

910
const ReferenceArrayFieldController = props => {
1011
const { children, ...rest } = props;
@@ -166,4 +167,42 @@ describe('<useReferenceArrayFieldController />', () => {
166167
})
167168
);
168169
});
170+
171+
describe('onSelectAll', () => {
172+
it('should select all records', async () => {
173+
render(<Basic />);
174+
await waitFor(() => {
175+
expect(screen.getByTestId('selected_ids').textContent).toBe(
176+
'Selected ids: []'
177+
);
178+
});
179+
fireEvent.click(await screen.findByText('Select All'));
180+
await waitFor(() => {
181+
expect(screen.getByTestId('selected_ids').textContent).toBe(
182+
'Selected ids: [1,2]'
183+
);
184+
});
185+
});
186+
187+
it('should select all records even though some records are already selected', async () => {
188+
render(<Basic />);
189+
await waitFor(() => {
190+
expect(screen.getByTestId('selected_ids').textContent).toBe(
191+
'Selected ids: []'
192+
);
193+
});
194+
fireEvent.click(await screen.findByTestId('checkbox-1'));
195+
await waitFor(() => {
196+
expect(screen.getByTestId('selected_ids').textContent).toBe(
197+
'Selected ids: [1]'
198+
);
199+
});
200+
fireEvent.click(await screen.findByText('Select All'));
201+
await waitFor(() => {
202+
expect(screen.getByTestId('selected_ids').textContent).toBe(
203+
'Selected ids: [1,2]'
204+
);
205+
});
206+
});
207+
});
169208
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as React from 'react';
2+
import {
3+
CoreAdminContext,
4+
type GetManyResult,
5+
type ListControllerResult,
6+
testDataProvider,
7+
useReferenceArrayFieldController,
8+
} from '../..';
9+
10+
const dataProvider = testDataProvider({
11+
getMany: (_resource, _params): Promise<GetManyResult> =>
12+
Promise.resolve({
13+
data: [
14+
{ id: 1, title: 'bar1' },
15+
{ id: 2, title: 'bar2' },
16+
],
17+
}),
18+
});
19+
20+
/**
21+
* Render prop version of the controller hook
22+
*/
23+
const ReferenceArrayFieldController = props => {
24+
const { children, ...rest } = props;
25+
const controllerProps = useReferenceArrayFieldController({
26+
sort: {
27+
field: 'id',
28+
order: 'ASC',
29+
},
30+
...rest,
31+
});
32+
return children(controllerProps);
33+
};
34+
35+
const defaultRenderProp = (props: ListControllerResult) => (
36+
<div>
37+
<div
38+
style={{
39+
display: 'flex',
40+
alignItems: 'center',
41+
gap: '10px',
42+
}}
43+
>
44+
<button
45+
onClick={() => props.onSelectAll()}
46+
disabled={props.total === props.selectedIds.length}
47+
>
48+
Select All
49+
</button>
50+
<button
51+
onClick={props.onUnselectItems}
52+
disabled={props.selectedIds.length === 0}
53+
>
54+
Unselect All
55+
</button>
56+
<p data-testid="selected_ids">
57+
Selected ids: {JSON.stringify(props.selectedIds)}
58+
</p>
59+
</div>
60+
<ul
61+
style={{
62+
listStyleType: 'none',
63+
}}
64+
>
65+
{props.data?.map(record => (
66+
<li key={record.id}>
67+
<input
68+
type="checkbox"
69+
checked={props.selectedIds.includes(record.id)}
70+
onChange={() => props.onToggleItem(record.id)}
71+
style={{
72+
cursor: 'pointer',
73+
marginRight: '10px',
74+
}}
75+
data-testid={`checkbox-${record.id}`}
76+
/>
77+
{record.id} - {record.title}
78+
</li>
79+
))}
80+
</ul>
81+
</div>
82+
);
83+
84+
export const Basic = ({ children = defaultRenderProp }) => (
85+
<CoreAdminContext dataProvider={dataProvider}>
86+
<ReferenceArrayFieldController
87+
resource="foo"
88+
reference="bar"
89+
record={{ id: 1, barIds: [1, 2] }}
90+
source="barIds"
91+
>
92+
{children}
93+
</ReferenceArrayFieldController>
94+
</CoreAdminContext>
95+
);
96+
97+
export default {
98+
title: 'ra-core/controller/useReferenceArrayFieldController',
99+
};

packages/ra-core/src/controller/field/useReferenceManyFieldController.spec.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { testDataProvider } from '../../dataProvider/testDataProvider';
66
import { CoreAdminContext } from '../../core';
77
import { useReferenceManyFieldController } from './useReferenceManyFieldController';
88
import { memoryStore } from '../../store';
9+
import {
10+
Basic,
11+
defaultDataProvider,
12+
} from './useReferenceManyFieldController.stories';
913

1014
const ReferenceManyFieldController = props => {
1115
const { children, page = 1, perPage = 25, ...rest } = props;
@@ -412,4 +416,70 @@ describe('useReferenceManyFieldController', () => {
412416
);
413417
});
414418
});
419+
420+
describe('onSelectAll', () => {
421+
it('should select all records', async () => {
422+
render(<Basic />);
423+
await waitFor(() => {
424+
expect(screen.getByTestId('selected_ids').textContent).toBe(
425+
'Selected ids: []'
426+
);
427+
});
428+
fireEvent.click(await screen.findByText('Select All'));
429+
await waitFor(() => {
430+
expect(screen.getByTestId('selected_ids').textContent).toBe(
431+
'Selected ids: [0,1]'
432+
);
433+
});
434+
});
435+
436+
it('should select all records even though some records are already selected', async () => {
437+
render(<Basic />);
438+
await waitFor(() => {
439+
expect(screen.getByTestId('selected_ids').textContent).toBe(
440+
'Selected ids: []'
441+
);
442+
});
443+
fireEvent.click(await screen.findByTestId('checkbox-1'));
444+
await waitFor(() => {
445+
expect(screen.getByTestId('selected_ids').textContent).toBe(
446+
'Selected ids: [1]'
447+
);
448+
});
449+
fireEvent.click(await screen.findByText('Select All'));
450+
await waitFor(() => {
451+
expect(screen.getByTestId('selected_ids').textContent).toBe(
452+
'Selected ids: [0,1]'
453+
);
454+
});
455+
});
456+
457+
it('should not select more records than the provided limit', async () => {
458+
const dataProvider = defaultDataProvider;
459+
const getManyReference = jest.spyOn(
460+
dataProvider,
461+
'getManyReference'
462+
);
463+
render(<Basic dataProvider={dataProvider} />);
464+
await waitFor(() => {
465+
expect(screen.getByTestId('selected_ids').textContent).toBe(
466+
'Selected ids: []'
467+
);
468+
});
469+
fireEvent.click(await screen.findByText('Limited Select All'));
470+
await waitFor(() => {
471+
expect(screen.getByTestId('selected_ids').textContent).toBe(
472+
'Selected ids: [0]'
473+
);
474+
});
475+
await waitFor(() => {
476+
expect(getManyReference).toHaveBeenCalledWith(
477+
'books',
478+
expect.objectContaining({
479+
pagination: { page: 1, perPage: 1 },
480+
})
481+
);
482+
});
483+
});
484+
});
415485
});

0 commit comments

Comments
 (0)