Skip to content

Commit

Permalink
Merge pull request #3448 from marmelab/actions-without-side-effect
Browse files Browse the repository at this point in the history
[RFR] Add useDeleteMany and useUpdateMany hooks
  • Loading branch information
djhi authored Jul 24, 2019
2 parents 6276620 + 1a669da commit b82260c
Show file tree
Hide file tree
Showing 8 changed files with 423 additions and 258 deletions.
192 changes: 116 additions & 76 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,110 +258,150 @@ Bulk action button components receive several props allowing them to perform the
* `filterValues`: the filter values. This can be useful if you want to apply your action on all items matching the filter.
* `selectedIds`: the identifiers of the currently selected items.

Here is an example leveraging the `UPDATE_MANY` crud action, which will set the `views` property of all posts to `0`:
Here is an example leveraging the `useUpdateMany` hook, which sets the `views` property of all posts to `0`:

```jsx
// in ./ResetViewsButton.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Button, crudUpdateMany } from 'react-admin';

class ResetViewsButton extends Component {
handleClick = () => {
const { basePath, crudUpdateMany, resource, selectedIds } = this.props;
crudUpdateMany(resource, selectedIds, { views: 0 }, basePath);
};
import React from 'react';
import {
Button,
useUpdateMany,
useRefresh,
useNotify,
useUnselectAll,
} from 'react-admin';

const ResetViewsButton = ({ selectedIds }) => {
const refresh = useRefresh();
const notify = useNotify();
const unselectAll = useUnselectAll();
const [updateMany, { loading }] = useUpdateMany(
'posts',
selectedIds,
{ views: 0 },
{
onSuccess: () => {
refresh();
notify('Posts updated');
unselectAll(resource);
},
onFailure: error => notify('Error: posts not updated', 'warning'),
}
);

render() {
return (
<Button label="Reset Views" onClick={this.handleClick} />
);
}
}
return (
<Button
label="simple.action.resetViews"
disabled={loading}
onClick={updateMany}
>
<VisibilityOff />
</Button>
);
};

export default connect(undefined, { crudUpdateMany })(ResetViewsButton);
export default ResetViewsButton;
```

But most of the time, bulk actions are mini-applications with a standalone user interface (in a Dialog). Here is the same `ResetViewsAction` implemented behind a confirmation dialog:

```jsx
// in ./ResetViewsButton.js
import React, { Fragment, Component } from 'react';
import { connect } from 'react-redux';
import { Button, Confirm, crudUpdateMany } from 'react-admin';

class ResetViewsButton extends Component {
state = {
isOpen: false,
}

handleClick = () => {
this.setState({ isOpen: true });
}

handleDialogClose = () => {
this.setState({ isOpen: false });
};
import React, { Fragment, useState } from 'react';
import {
Button,
Confirm,
useUpdateMany,
useRefresh,
useNotify,
useUnselectAll,
} from 'react-admin';

const ResetViewsButton = ({ selectedIds }) => {
const [open, setOpen] = useState(false);
const refresh = useRefresh();
const notify = useNotify();
const unselectAll = useUnselectAll();
const [updateMany, { loading }] = useUpdateMany(
'posts',
selectedIds,
{ views: 0 },
{
onSuccess: () => {
refresh();
notify('Posts updated');
unselectAll(resource);
},
onFailure: error => notify('Error: posts not updated', 'warning'),
}
);
const handleClick = () => setOpen(true);
const handleDialogClose = () => setOpen(false);

handleConfirm = () => {
const { basePath, crudUpdateMany, resource, selectedIds } = this.props;
crudUpdateMany(resource, selectedIds, { views: 0 }, basePath);
this.setState({ isOpen: true });
const handleConfirm = () => {
updateMany();
setOpen(false);
};

render() {
return (
<Fragment>
<Button label="Reset Views" onClick={this.handleClick} />
<Confirm
isOpen={this.state.isOpen}
title="Update View Count"
content="Are you sure you want to reset the views for these items?"
onConfirm={this.handleConfirm}
onClose={this.handleDialogClose}
/>
</Fragment>
);
}
return (
<Fragment>
<Button label="Reset Views" onClick={handleClick} />
<Confirm
isOpen={open}
title="Update View Count"
content="Are you sure you want to reset the views for these items?"
onConfirm={handleConfirm}
onClose={handleDialogClose}
/>
</Fragment>
);
}

export default connect(undefined, { crudUpdateMany })(ResetViewsButton);
export default ResetViewsButton;
```

**Tip**: `<Confirm>` leverages material-ui's `<Dialog>` component to implement a confirmation popup. Feel free to use it in your admins!

**Tip**: `<Confirm>` text props such as `title` and `content` are translatable. You can pass them translation keys.
**Tip**: `<Confirm>` text props such as `title` and `content` are translatable. You can pass use translation keys in these props.

**Tip**: You can customize the text of the two `<Confirm>` component buttons using the `cancel` and `confirm` prop which accepts translation keys too.

**Tip**: React-admin doesn't use the `<Confirm>` component internally, because deletes and updates are applied locally immediately, then dispatched to the server after a few seconds, unless the user chooses to undo the modification. That's what we call optimistic rendering. You can do the same for the `ResetViewsButton` by wrapping the `crudUpdateMany()` action creator inside a `startUndoable()` action creator, as follows:
**Tip**: React-admin doesn't use the `<Confirm>` component internally, because deletes and updates are applied locally immediately, then dispatched to the server after a few seconds, unless the user chooses to undo the modification. That's what we call optimistic rendering. You can do the same for the `ResetViewsButton` by setting `undoable: true` in the last argument of `useUpdateMany()`, as follows:

```jsx
```diff
// in ./ResetViewsButton.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Button, crudUpdateMany, startUndoable } from 'react-admin';

class ResetViewsButton extends Component {
handleClick = () => {
const { basePath, resource, selectedIds, startUndoable } = this.props;
startUndoable(
crudUpdateMany(resource, selectedIds, { views: 0 }, basePath)
);
};

render() {
return (
<Button label="Reset Views" onClick={this.handleClick} />
);
}
}
const ResetViewsButton = ({ selectedIds }) => {
const refresh = useRefresh();
const notify = useNotify();
const unselectAll = useUnselectAll();
const [updateMany, { loading }] = useUpdateMany(
'posts',
selectedIds,
{ views: 0 },
{
onSuccess: () => {
refresh();
- notify('Posts updated');
+ notify('Posts updated', 'info', '{}, true); // the last argument forces the display of 'undo' in the notification
unselectAll(resource);
},
onFailure: error => notify('Error: posts not updated', 'warning'),
+ undoable: true
}
);

export default connect(undefined, { startUndoable })(ResetViewsButton);
return (
<Button
label="simple.action.resetViews"
disabled={loading}
onClick={updateMany}
>
<VisibilityOff />
</Button>
);
};
```

Note that the `crudUpdateMany` action creator is *not* present in the `mapDispatchToProps` argument of `connect()` in that case. Only `startUndoable` needs to be dispatched in this case, using the result of the `crudUpdateMany()` call as parameter.

### Filters

You can add a filter component to the list using the `filters` prop:
Expand Down
55 changes: 41 additions & 14 deletions examples/simple/src/posts/ResetViewsButton.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import VisibilityOff from '@material-ui/icons/VisibilityOff';
import { startUndoable, crudUpdateMany, Button } from 'react-admin';
import {
useUpdateMany,
useRefresh,
useNotify,
useUnselectAll,
Button,
} from 'react-admin';

const ResetViewsButton = props => {
const dispatch = useDispatch();
const { basePath, resource, selectedIds } = props;

const handleClick = useCallback(() => {
dispatch(
startUndoable(
crudUpdateMany(resource, selectedIds, { views: 0 }, basePath)
)
);
}, [basePath, dispatch, resource, selectedIds]);
const ResetViewsButton = ({ resource, selectedIds }) => {
const notify = useNotify();
const unselectAll = useUnselectAll();
const refresh = useRefresh();
const [updateMany, { loading }] = useUpdateMany(
resource,
selectedIds,
{ views: 0 },
{
onSuccess: () => {
notify(
'ra.notification.updated',
'info',
{ smart_count: selectedIds.length },
true
);
unselectAll(resource);
refresh();
},
onFailure: error =>
notify(
typeof error === 'string'
? error
: error.message || 'ra.notification.http_error',
'warning'
),
undoable: true,
}
);

return (
<Button label="simple.action.resetViews" onClick={handleClick}>
<Button
label="simple.action.resetViews"
disabled={loading}
onClick={updateMany}
>
<VisibilityOff />
</Button>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/ra-core/src/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import withDataProvider from './withDataProvider';
import useGetOne from './useGetOne';
import useGetList from './useGetList';
import useUpdate from './useUpdate';
import useUpdateMany from './useUpdateMany';
import useCreate from './useCreate';
import useDelete from './useDelete';
import useDeleteMany from './useDeleteMany';

export {
fetchUtils,
Expand All @@ -24,8 +26,10 @@ export {
useGetOne,
useGetList,
useUpdate,
useUpdateMany,
useCreate,
useDelete,
useDeleteMany,
useQueryWithStore,
withDataProvider,
};
1 change: 0 additions & 1 deletion packages/ra-core/src/fetch/useDelete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { Identifier } from '../types';
*
* @param resource The resource name, e.g. 'posts'
* @param id The resource identifier, e.g. 123
* @param data The data to initialize the new record with, e.g. { title: 'hello, world" }
* @param previousData The record before the delete is applied
* @param options Options object to pass to the dataProvider. May include side effects to be executed upon success of failure, e.g. { onSuccess: { refresh: true } }
*
Expand Down
38 changes: 38 additions & 0 deletions packages/ra-core/src/fetch/useDeleteMany.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { CRUD_DELETE_MANY } from '../actions/dataActions/crudDeleteMany';
import { DELETE_MANY } from '../dataFetchActions';
import useMutation from './useMutation';
import { Identifier } from '../types';

/**
* Get a callback to call the dataProvider with a DELETE_MANY verb, the result
* of the call (the list of deleted record ids), and the loading state.
*
* The return value updates according to the request state:
*
* - start: [callback, { loading: true, loaded: false }]
* - success: [callback, { data: [data from response], loading: false, loaded: true }]
* - error: [callback, { error: [error from response], loading: false, loaded: true }]
*
* @param resource The resource name, e.g. 'posts'
* @param ids The resource identifiers, e.g. [123, 456]
* @param options Options object to pass to the dataProvider. May include side effects to be executed upon success of failure, e.g. { onSuccess: { refresh: true } }
*
* @returns The current request state. Destructure as [delete, { data, error, loading, loaded }].
*
* @example
*
* import { useDeleteMany } from 'react-admin';
*
* const BulkDeletePostsButton = ({ selectedIds }) => {
* const [deleteMany, { loading, error }] = useDeleteMany('posts', selectedIds);
* if (error) { return <p>ERROR</p>; }
* return <button disabled={loading} onClick={deleteMany}>Delete selected posts</button>;
* };
*/
const useDeleteMany = (resource: string, ids: [Identifier], options?: any) =>
useMutation(
{ type: DELETE_MANY, resource, payload: { ids } },
{ ...options, action: CRUD_DELETE_MANY }
);

export default useDeleteMany;
Loading

0 comments on commit b82260c

Please sign in to comment.