Skip to content

Commit

Permalink
Merge pull request #10367 from marmelab/feat/next/select_all
Browse files Browse the repository at this point in the history
Add a “SELECT ALL” button in the `<BulkActionsToolbar>`
  • Loading branch information
fzaninotto authored Jan 3, 2025
2 parents ecaee9d + 31b3bd5 commit 1d6525a
Show file tree
Hide file tree
Showing 47 changed files with 2,585 additions and 323 deletions.
74 changes: 74 additions & 0 deletions docs/Buttons.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ React-Admin provides button components for all the common uses.
- [`<BulkUpdateButton>`](#bulkupdatebutton)
- [`<BulkUpdateFormButton>`](#bulkupdateformbutton)
- [`<FilterButton>`](#filterbutton)
- [`<SelectAllButton>`](#selectallbutton)

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

## `<RefreshButton>`

## `<SelectAllButton>`

The `<SelectAllButton>` component allows users to select all items from a resource, no matter the pagination.

![SelectAllButton](./img/SelectAllButton.png)

### Usage

By default, react-admin's `<Datagrid>` displays a `<SelectAllButton>` in its `bulkActionsToolbar`. You can customize it by specifying your own `<BulkActionsToolbar selectAllButton>`:

{% raw %}

```jsx
import { List, Datagrid, BulkActionsToolbar, SelectAllButton, BulkDeleteButton } from 'react-admin';

const PostSelectAllButton = () => (
<SelectAllButton
label="Select all records"
queryOptions={{ meta: { foo: 'bar' } }}
/>
);

export const PostList = () => (
<List>
<Datagrid
bulkActionsToolbar={
<BulkActionsToolbar selectAllButton={PostSelectAllButton}>
<BulkDeleteButton />
</BulkActionsToolbar>
}
>
...
</Datagrid>
</List>
);
```

{% endraw %}

### `label`

By default, the `<SelectAllButton>` label is "Select all" (or the `ra.action.select_all_button` message translation). You can also pass a custom `label`:

```jsx
const PostSelectAllButton = () => <SelectAllButton label="Select all posts" />;
```

**Tip**: The label will go through [the `useTranslate` hook](./useTranslate.md), so you can use translation keys.

### `limit`

By default, `<SelectAllButton>` selects the 250 first items of your list. To customize this limit, you can use the `limit` prop:

```jsx
const PostSelectAllButton = () => <SelectAllButton limit={100} />;
```

### `queryOptions`

`<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.

{% raw %}

```jsx
const PostSelectAllButton = () => <SelectAllButton queryOptions={{ meta: { foo: 'bar' } }} />;
```

{% endraw %}

### `sx`: CSS API

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.

## `<SkipNavigationButton>`

### `sx`: CSS API
Expand Down
36 changes: 18 additions & 18 deletions docs/Datagrid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Field>` components to render as columns. |
| `body` | Optional | Element | `<Datagrid Body>` | The component used to render the body of the table. |
| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons. |
| `empty` | Optional | Element | `<Empty>` | 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 | `<Datagrid Header>` | 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 `<Table>` element. |
| Prop | Required | Type | Default | Description |
| -------------------- | -------- | ----------------------- | --------------------- | ------------------------------------------------------------- |
| `children` | Required | Element | n/a | The list of `<Field>` components to render as columns. |
| `body` | Optional | Element | `<Datagrid Body>` | The component used to render the body of the table. |
| `bulkActionButtons` | Optional | Element | `<BulkDelete Button>` | The component used to render the bulk action buttons. |
| `empty` | Optional | Element | `<Empty>` | 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 | `<Datagrid Header>` | 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 `<Table>` element. |

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

Expand Down
1 change: 1 addition & 0 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ title: "Index"
* [`<Search>`](./Search.md)<img class="icon" src="./img/premium.svg" />
* [`<SearchInput>`](./SearchInput.md)
* [`<SearchWithResult>`](./SearchWithResult.md)<img class="icon" src="./img/premium.svg" />
* [`<SelectAllButton>`](./Buttons.md#selectallbutton)
* [`<SelectArrayInput>`](./SelectArrayInput.md)
* [`<SelectColumnsButton>`](./SelectColumnsButton.md)
* [`<SelectField>`](./SelectField.md)
Expand Down
Binary file added docs/img/SelectAllButton.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -166,4 +167,42 @@ describe('<useReferenceArrayFieldController />', () => {
})
);
});

describe('onSelectAll', () => {
it('should select all records', async () => {
render(<Basic />);
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(<Basic />);
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]'
);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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<GetManyResult> =>
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) => (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
}}
>
<button
onClick={() => props.onSelectAll()}
disabled={props.total === props.selectedIds.length}
>
Select All
</button>
<button
onClick={props.onUnselectItems}
disabled={props.selectedIds.length === 0}
>
Unselect All
</button>
<p data-testid="selected_ids">
Selected ids: {JSON.stringify(props.selectedIds)}
</p>
</div>
<ul
style={{
listStyleType: 'none',
}}
>
{props.data?.map(record => (
<li key={record.id}>
<input
type="checkbox"
checked={props.selectedIds.includes(record.id)}
onChange={() => props.onToggleItem(record.id)}
style={{
cursor: 'pointer',
marginRight: '10px',
}}
data-testid={`checkbox-${record.id}`}
/>
{record.id} - {record.title}
</li>
))}
</ul>
</div>
);

export const Basic = ({ children = defaultRenderProp }) => (
<CoreAdminContext dataProvider={dataProvider}>
<ReferenceArrayFieldController
resource="foo"
reference="bar"
record={{ id: 1, barIds: [1, 2] }}
source="barIds"
>
{children}
</ReferenceArrayFieldController>
</CoreAdminContext>
);

export default {
title: 'ra-core/controller/useReferenceArrayFieldController',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -412,4 +416,70 @@ describe('useReferenceManyFieldController', () => {
);
});
});

describe('onSelectAll', () => {
it('should select all records', async () => {
render(<Basic />);
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(<Basic />);
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(<Basic dataProvider={dataProvider} />);
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 },
})
);
});
});
});
});
Loading

0 comments on commit 1d6525a

Please sign in to comment.