From f903938b4fb6a421494c113a6600569f4e4e26b5 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 19 Jul 2019 09:06:04 +0200 Subject: [PATCH 1/5] Declare fetch side effects as function Refs #3413 --- .../src/controller/useCreateController.ts | 50 ++++++------- .../src/controller/useEditController.ts | 71 +++++++++++-------- .../src/controller/useListController.ts | 15 ++-- .../src/controller/useShowController.ts | 16 ++--- packages/ra-core/src/fetch/useCreate.ts | 2 +- packages/ra-core/src/fetch/useDataProvider.ts | 40 +++++++---- packages/ra-core/src/sideEffect/fetch.ts | 6 +- packages/ra-core/src/sideEffect/index.ts | 6 ++ packages/ra-core/src/sideEffect/useNotify.ts | 43 +++++++++++ .../ra-core/src/sideEffect/useRedirect.ts | 50 +++++++++++++ packages/ra-core/src/sideEffect/useRefresh.ts | 21 ++++++ 11 files changed, 235 insertions(+), 85 deletions(-) create mode 100644 packages/ra-core/src/sideEffect/useNotify.ts create mode 100644 packages/ra-core/src/sideEffect/useRedirect.ts create mode 100644 packages/ra-core/src/sideEffect/useRefresh.ts diff --git a/packages/ra-core/src/controller/useCreateController.ts b/packages/ra-core/src/controller/useCreateController.ts index fdae4cd125b..4c82bf30304 100644 --- a/packages/ra-core/src/controller/useCreateController.ts +++ b/packages/ra-core/src/controller/useCreateController.ts @@ -8,7 +8,8 @@ import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Location } from 'history'; import { match as Match } from 'react-router'; import { Record } from '../types'; -import { RedirectionSideEffect } from '../sideEffect'; +import { useNotify, useRedirect, RedirectionSideEffect } from '../sideEffect'; + import { useTranslate } from '../i18n'; export interface CreateControllerProps { @@ -67,35 +68,34 @@ const useCreateController = (props: CreateProps): CreateControllerProps => { } = props; const translate = useTranslate(); + const notify = useNotify(); + const redirect = useRedirect(); const recordToUse = getRecord(location, record); - const [create, { loading: isSaving }] = useCreate( - resource, - {}, // set by the caller - { - onSuccess: { - notification: { - body: 'ra.notification.created', - level: 'info', - messageArgs: { - smart_count: 1, - }, - }, - basePath, - }, - onFailure: { - notification: { - body: 'ra.notification.http_error', - level: 'warning', - }, - }, - } - ); + const [create, { loading: isSaving }] = useCreate(resource); const save = useCallback( (data: Partial, redirectTo = 'list') => - create(null, { data }, { onSuccess: { redirectTo } }), - [create] + create( + null, + { data }, + { + onSuccess: ({ data: newRecord }) => { + notify('ra.notification.created', 'info', { + smart_count: 1, + }); + redirect(redirectTo, basePath, newRecord.id, newRecord); + }, + onFailure: error => + notify( + typeof error === 'string' + ? error + : error.message || 'ra.notification.http_error', + 'warning' + ), + } + ), + [basePath, create, notify, redirect] ); const resourceName = translate(`resources.${resource}.name`, { diff --git a/packages/ra-core/src/controller/useEditController.ts b/packages/ra-core/src/controller/useEditController.ts index 0c35728a891..d047c3b6126 100644 --- a/packages/ra-core/src/controller/useEditController.ts +++ b/packages/ra-core/src/controller/useEditController.ts @@ -8,7 +8,12 @@ import useVersion from './useVersion'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { REDUX_FORM_NAME } from '../form'; import { Record, Identifier } from '../types'; -import { RedirectionSideEffect } from '../sideEffect'; +import { + useNotify, + useRedirect, + useRefresh, + RedirectionSideEffect, +} from '../sideEffect'; import { useGetOne, useUpdate } from '../fetch'; import { useTranslate } from '../i18n'; @@ -56,18 +61,17 @@ const useEditController = (props: EditProps): EditControllerProps => { useCheckMinimumRequiredProps('Edit', ['basePath', 'resource'], props); const { basePath, id, resource, undoable = true } = props; const translate = useTranslate(); + const notify = useNotify(); + const redirect = useRedirect(); + const refresh = useRefresh(); const dispatch = useDispatch(); const version = useVersion(); const { data: record, loading } = useGetOne(resource, id, { - basePath, version, // used to force reload - onFailure: { - notification: { - body: 'ra.notification.item_doesnt_exist', - level: 'warning', - }, - redirectTo: 'list', - refresh: true, + onFailure: () => { + notify('ra.notification.item_doesnt_exist', 'warning'); + redirect('list', basePath); + refresh(); }, }); @@ -89,32 +93,37 @@ const useEditController = (props: EditProps): EditControllerProps => { resource, id, {}, // set by the caller - record, - { - onSuccess: { - notification: { - body: 'ra.notification.updated', - level: 'info', - messageArgs: { - smart_count: 1, - }, - }, - basePath, - }, - onFailure: { - notification: { - body: 'ra.notification.http_error', - level: 'warning', - }, - }, - undoable, - } + record ); const save = useCallback( (data: Partial, redirectTo = 'list') => - update(null, { data }, { onSuccess: { redirectTo } }), - [update] + update( + null, + { data }, + { + onSuccess: () => { + notify( + 'ra.notification.updated', + 'info', + { + smart_count: 1, + }, + undoable + ); + redirect(redirectTo, basePath, data.id, data); + }, + onFailure: error => + notify( + typeof error === 'string' + ? error + : error.message || 'ra.notification.http_error', + 'warning' + ), + undoable, + } + ), + [basePath, notify, redirect, undoable, update] ); return { diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index cbf80e4ddbd..431ce4cac98 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -9,6 +9,7 @@ import useVersion from './useVersion'; import { useTranslate } from '../i18n'; import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { ListParams } from '../actions/listActions'; +import { useNotify } from '../sideEffect'; import { Sort, RecordMap, Identifier } from '../types'; import useGetList from './../fetch/useGetList'; @@ -109,6 +110,7 @@ const useListController = (props: ListProps): ListControllerProps => { ); } const translate = useTranslate(); + const notify = useNotify(); const version = useVersion(); const [query, queryModifiers] = useListParams({ @@ -132,12 +134,13 @@ const useListController = (props: ListProps): ListControllerProps => { { ...query.filter, ...filter }, { version, - onFailure: { - notification: { - body: 'ra.notification.http_error', - level: 'warning', - }, - }, + onFailure: error => + notify( + typeof error === 'string' + ? error + : error.message || 'ra.notification.http_error', + 'warning' + ), } ); diff --git a/packages/ra-core/src/controller/useShowController.ts b/packages/ra-core/src/controller/useShowController.ts index 704cae86e92..c5bc8ee0874 100644 --- a/packages/ra-core/src/controller/useShowController.ts +++ b/packages/ra-core/src/controller/useShowController.ts @@ -5,6 +5,7 @@ import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import { Record, Identifier } from '../types'; import { useGetOne } from '../fetch'; import { useTranslate } from '../i18n'; +import { useNotify, useRedirect, useRefresh } from '../sideEffect'; export interface ShowProps { basePath: string; @@ -48,17 +49,16 @@ const useShowController = (props: ShowProps): ShowControllerProps => { useCheckMinimumRequiredProps('Show', ['basePath', 'resource'], props); const { basePath, id, resource, undoable = true } = props; const translate = useTranslate(); + const notify = useNotify(); + const redirect = useRedirect(); + const refresh = useRefresh(); const version = useVersion(); const { data: record, loading } = useGetOne(resource, id, { - basePath, version, // used to force reload - onFailure: { - notification: { - body: 'ra.notification.item_doesnt_exist', - level: 'warning', - }, - redirectTo: 'list', - refresh: true, + onFailure: () => { + notify('ra.notification.item_doesnt_exist', 'warning'); + redirect('list', basePath); + refresh(); }, }); diff --git a/packages/ra-core/src/fetch/useCreate.ts b/packages/ra-core/src/fetch/useCreate.ts index 6639c8a2778..fcc7d7c90b2 100644 --- a/packages/ra-core/src/fetch/useCreate.ts +++ b/packages/ra-core/src/fetch/useCreate.ts @@ -28,7 +28,7 @@ import useMutation from './useMutation'; * return - - - ); - } -} + return ( + + + + + ); +}; DeleteWithConfirmButton.propTypes = { basePath: PropTypes.string, classes: PropTypes.object, className: PropTypes.string, - crudDelete: PropTypes.func.isRequired, label: PropTypes.string, record: PropTypes.object, redirect: PropTypes.oneOfType([ @@ -123,7 +145,6 @@ DeleteWithConfirmButton.propTypes = { PropTypes.func, ]), resource: PropTypes.string.isRequired, - translate: PropTypes.func, icon: PropTypes.element, }; @@ -132,11 +153,4 @@ DeleteWithConfirmButton.defaultProps = { icon: , }; -export default compose( - connect( - null, - { crudDelete } - ), - translate, - withStyles(styles) -)(DeleteWithConfirmButton); +export default DeleteWithConfirmButton; diff --git a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js index c8cb8c8c96c..474b4c8d062 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js +++ b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js @@ -1,19 +1,16 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import compose from 'recompose/compose'; -import { withStyles, createStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@material-ui/core/styles'; import { fade } from '@material-ui/core/styles/colorManipulator'; import ActionDelete from '@material-ui/icons/Delete'; import classnames from 'classnames'; -import { translate, crudDelete, startUndoable } from 'ra-core'; +import { useDelete, useRefresh, useNotify, useRedirect } from 'ra-core'; import Button from './Button'; export const sanitizeRestProps = ({ basePath, classes, - dispatchCrudDelete, filterValues, handleSubmit, handleSubmitWithRedirect, @@ -23,75 +20,81 @@ export const sanitizeRestProps = ({ resource, saving, selectedIds, - startUndoable, undoable, redirect, submitOnEnter, - translate, ...rest }) => rest; -const styles = theme => - createStyles({ - deleteButton: { - color: theme.palette.error.main, - '&:hover': { - backgroundColor: fade(theme.palette.error.main, 0.12), - // Reset on mouse devices - '@media (hover: none)': { - backgroundColor: 'transparent', - }, +const useStyles = makeStyles(theme => ({ + deleteButton: { + color: theme.palette.error.main, + '&:hover': { + backgroundColor: fade(theme.palette.error.main, 0.12), + // Reset on mouse devices + '@media (hover: none)': { + backgroundColor: 'transparent', }, }, - }); + }, +})); -class DeleteWithUndoButton extends Component { - handleDelete = event => { +const DeleteWithUndoButton = ({ + label = 'ra.action.delete', + classes: classesOverride, + className, + icon, + onClick, + resource, + record, + basePath, + redirect: redirectTo, + ...rest +}) => { + const classes = useStyles({ classes: classesOverride }); + const notify = useNotify(); + const redirect = useRedirect(); + const refresh = useRefresh(); + const [deleteOne, { loading }] = useDelete(resource, record.id, record, { + onSuccess: () => { + notify('ra.notification.deleted', 'info', { smart_count: 1 }, true); + redirect(redirectTo, basePath); + refresh(); + }, + onFailure: error => + notify( + typeof error === 'string' + ? error + : error.message || 'ra.notification.http_error', + 'warning' + ), + undoable: true, + }); + const handleDelete = event => { event.stopPropagation(); - const { - startUndoable, - resource, - record, - basePath, - redirect, - onClick, - } = this.props; - - startUndoable( - crudDelete(resource, record.id, record, basePath, redirect) - ); - + deleteOne(); if (typeof onClick === 'function') { onClick(); } }; - render() { - const { - label = 'ra.action.delete', - classes = {}, - className, - icon, - onClick, - ...rest - } = this.props; - return ( - - ); - } -} + return ( + + ); +}; DeleteWithUndoButton.propTypes = { basePath: PropTypes.string, @@ -105,8 +108,6 @@ DeleteWithUndoButton.propTypes = { PropTypes.func, ]), resource: PropTypes.string.isRequired, - startUndoable: PropTypes.func, - translate: PropTypes.func, icon: PropTypes.element, }; @@ -116,11 +117,4 @@ DeleteWithUndoButton.defaultProps = { icon: , }; -export default compose( - connect( - null, - { startUndoable } - ), - translate, - withStyles(styles) -)(DeleteWithUndoButton); +export default DeleteWithUndoButton; From ef831011d6bbc0b22a8049572e1aa73661294360 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 19 Jul 2019 22:51:02 +0200 Subject: [PATCH 3/5] add useUnselectAll side effect --- packages/ra-core/src/sideEffect/index.ts | 2 ++ .../ra-core/src/sideEffect/useRedirect.ts | 4 ++-- .../ra-core/src/sideEffect/useUnselectAll.ts | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 packages/ra-core/src/sideEffect/useUnselectAll.ts diff --git a/packages/ra-core/src/sideEffect/index.ts b/packages/ra-core/src/sideEffect/index.ts index 88f238e9bb4..c047a5905a5 100644 --- a/packages/ra-core/src/sideEffect/index.ts +++ b/packages/ra-core/src/sideEffect/index.ts @@ -13,6 +13,7 @@ import unloadSaga from './unload'; import useRedirect from './useRedirect'; import useNotify from './useNotify'; import useRefresh from './useRefresh'; +import useUnselectAll from './useUnselectAll'; export { adminSaga, @@ -34,4 +35,5 @@ export { useRedirect, useNotify, useRefresh, + useUnselectAll, }; diff --git a/packages/ra-core/src/sideEffect/useRedirect.ts b/packages/ra-core/src/sideEffect/useRedirect.ts index 0ad9075019f..350af322f03 100644 --- a/packages/ra-core/src/sideEffect/useRedirect.ts +++ b/packages/ra-core/src/sideEffect/useRedirect.ts @@ -7,7 +7,7 @@ import { Identifier, Record } from '../types'; import resolveRedirectTo from '../util/resolveRedirectTo'; type RedirectToFunction = ( - basePath: string, + basePath?: string, id?: Identifier, data?: Record ) => string; @@ -34,7 +34,7 @@ const useRedirect = () => { return useCallback( ( redirectTo: RedirectionSideEffect, - basePath: string, + basePath: string = '', id?: Identifier, data?: Partial ) => diff --git a/packages/ra-core/src/sideEffect/useUnselectAll.ts b/packages/ra-core/src/sideEffect/useUnselectAll.ts new file mode 100644 index 00000000000..ac8e7885a0c --- /dev/null +++ b/packages/ra-core/src/sideEffect/useUnselectAll.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { setListSelectedIds } from '../actions'; + +/** + * Hook for Unselect All Side Effect + * + * @example + * + * const unselectAll = useUnselectAll('posts'); + * unselectAll(); + */ +const useUnselectAll = resource1 => { + const dispatch = useDispatch(); + return useCallback( + resource2 => dispatch(setListSelectedIds(resource2 || resource1, [])), + [dispatch, resource1] + ); +}; + +export default useUnselectAll; From 0da2044062ebb9760a566b2e3a875b1a3412a1ca Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 19 Jul 2019 22:51:16 +0200 Subject: [PATCH 4/5] update documentation --- docs/Actions.md | 210 +++++++------------------------------ docs/_layouts/default.html | 9 -- 2 files changed, 40 insertions(+), 179 deletions(-) diff --git a/docs/Actions.md b/docs/Actions.md index eb6fd7f45ef..cd7e28a0e47 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -86,7 +86,7 @@ You can destructure the return value of the `useQuery` hook as `{ data, total, e ## `useQueryWithStore` Hook -Internally, react-admin uses a more powerful version of `useQuery` called `useQueryWithStore`, which has an internal cache. In practice, `useQueryWithStore` persist the response from the dataProvider in the internal react-admin store, so that result remains available if the hook is called again in the future. +Internally, react-admin uses a more powerful version of `useQuery` called `useQueryWithStore`, which has an internal cache. In practice, `useQueryWithStore` persist the response from the dataProvider in the internal react-admin redux store, so that result remains available if the hook is called again in the future. You can use this hook to avoid showing the loading indicator if the query was already fetched once. @@ -164,7 +164,7 @@ export const CommentList = (props) => ; ``` -**Tip**: For simple mutations, you can use a specialised hook like `useUpdate` instead of the more generic `useMutation`. The main benefit is that `useUpdate` will update the recod in Redux store first, allowing optimistic rendering of the UI: +**Tip**: For simple mutations, you can use a specialised hook like `useUpdate` instead of the more generic `useMutation`. The main benefit is that `useUpdate` will update the record in Redux store first, allowing optimistic rendering of the UI: ```jsx import { useUpdate } from 'react-admin'; @@ -206,9 +206,12 @@ Here is how to add notifications and a redirection to the `ApproveButton` compon ```diff // in src/comments/ApproveButton.js -import { useMutation, UPDATE } from 'react-admin'; +-import { useMutation, UPDATE } from 'react-admin'; ++import { useMutation, useNotify, useRedirect, UPDATE } from 'react-admin'; const ApproveButton = ({ record }) => { ++ const notify = useNotify(); ++ const redirect = useRedirect(); const [approve, { loading }] = useMutation( { type: UPDATE, @@ -216,30 +219,25 @@ const ApproveButton = ({ record }) => { payload: { id: record.id, data: { isApproved: true } }, }, + { -+ onSuccess: { -+ notification: { body: 'Comment approved', level: 'info' }, -+ redirectTo: '/comments', -+ }, -+ onFailure: { -+ notification: { -+ body: 'Error: comment not approved', -+ level: 'warning', -+ }, ++ onSuccess: ({ data }) => { ++ notify('Comment approved', 'info');, ++ redirect('/comments'), + }, ++ onFailure: (error) => notify(`Error: ${error.message}`, 'warning'), + } ); return ; }; ``` -React-admin can handle the following side effects: +The `onSuccess` function is called with the response from the `dataProvider` as argument. The `onError` function is called wit hthe error returned by the `dataProvider`. -- `notification`: Display a notification. The property value should be an object describing the notification to display. The `body` can be a translation key. `level` can be either `info` or `warning`. -- `redirectTo`: Redirect the user to another page. The property value should be the path to redirect the user to. -- `refresh`: Force a rerender of the current view (equivalent to pressing the Refresh button). Set to true to enable. -- `unselectAll`: Unselect all lines in the current datagrid. Set to true to enable. -- `callback`: Execute an arbitrary function. The value should be the function to execute. React-admin will call the function with an object as parameter (`{ requestPayload, payload, error }`). The `payload` contains the decoded response body when it's successful. When it's failed, the response body is passed in the `error`. -- `basePath`: This is not a side effect, but it's used internally to compute redirection paths. Set it when you have a redirection side effect. +React-admin provides the following hooks to handle most common side effects: + +- `useNotify`: Return a function to display a notification. The arguments should be a message (it can be a translation key), a level (either `info` or `warning`), an options object to pass to the `translate()` function, and a boolean to set to `true` if the notification should contain an "undo" button. +- `useRedirect`: Return a function to redirect the user to another page. The arguments should be the path to redirect the user to, and the current `basePath`. +- `useRefresh`: Return a function to force a rerender of the current view (equivalent to pressing the Refresh button). +- `useUnselectAll`: Return a function to unselect all lines in the current datagrid. Pass the name of the resource as argument. ## Optimistic Rendering and Undo @@ -264,13 +262,12 @@ const ApproveButton = ({ record }) => { }, { + undoable: true, - onSuccess: { - notification: { body: 'Comment approved', level: 'info' }, - redirectTo: '/comments', + onSuccess: ({ data }) => { +- notify('Comment approved', 'info');, ++ notify('Comment approved', 'info', {}, true);, + redirect('/comments'), }, - onError: { - notification: { body: 'Error: comment not approved', level: 'warning' } - } + onFailure: (error) => notify(`Error: ${error.message}`, 'warning'), } ); return ; @@ -394,21 +391,20 @@ When calling the API to update ("mutate") data, use the `` component i Here is a version of the `` component demonstrating ``: ```jsx -import { Mutation, UPDATE } from 'react-admin'; - -const options = { - undoable: true, - onSuccess: { - notification: { body: 'Comment approved', level: 'info' }, - redirectTo: '/comments', - }, - onError: { - notification: { body: 'Error: comment not approved', level: 'warning' } - } -}; +import { Mutation, UPDATE, useNotify, useRedirect } from 'react-admin'; const ApproveButton = ({ record }) => { + const notify = useNotify(); + const redirect = useRedirect(); const payload = { id: record.id, data: { ...record, is_approved: true } }; + const options = { + undoable: true, + onSuccess: ({ data }) => { + notify('Comment approved', 'info', {}, true);, + redirect('/comments'), + }, + onFailure: (error) => notify(`Error: ${error.message}`, 'warning'), + }; return ( { const dispatch = useDispatch(); - const [loading, setLoading] = useState(false;) + const redirect = useRedirect(); + const notify = useNotify(); + const [loading, setLoading] = useState(false); const handleClick = () => { setLoading(true); dispatch(fetchStart()); // start the global loading indicator const updatedRecord = { ...record, is_approved: true }; fetch(`/comments/${record.id}`, { method: 'PUT', body: updatedRecord }) .then(() => { - dispatch(showNotification('Comment approved')); - dispatch(push('/comments')); + notify('Comment approved'); + redirect('/comments'); }) .catch((e) => { - dispatch(showNotification('Error: comment not approved', 'warning')) + notify('Error: comment not approved', 'warning') }) .finally(() => { setLoading(false); @@ -508,100 +506,8 @@ const ApproveButton = ({ record }) => { export default ApproveButton; ``` -If you use `fetch`, you'll have to handle side effects on your own using Redux actions, as shown in this example. - -`showNotification` and `push` are *action creators*. This is a Redux term for functions that return a simple action object. - **TIP**: APIs often require a bit of HTTP plumbing to deal with authentication, query parameters, encoding, headers, etc. It turns out you probably already have a function that maps from a REST request to an HTTP request: your [Data Provider](./DataProviders.md). So it's often better to use `useDataProvider` instead of `fetch`. -## Using a Custom Action Creator - -In some rare cases, several components may share the same data fetching logic. In these cases, you will probably want to extract that logic into a custom Redux action. - -Warning: This is for advanced use cases only, and it requires a good level of understanding of Redux and react-admin internals. In most cases, `useDataProvider` is enough. - -First, extract the request into a custom action creator. Use the dataProvider verb (`UPDATE`) as the `fetch` meta, pass the resource name as the `resource` meta, and pass the request parameters as the action `payload`: - -```jsx -// in src/comment/commentActions.js -import { UPDATE } from 'react-admin'; - -export const COMMENT_APPROVE = 'COMMENT_APPROVE'; -export const commentApprove = (id, data, basePath) => ({ - type: COMMENT_APPROVE, - payload: { id, data: { ...data, is_approved: true } }, - meta: { fetch: UPDATE, resource: 'comments' }, -}); -``` - -Upon dispatch, this action will trigger the call to `dataProvider(UPDATE, 'comments', { id, data: { ...data, is_approved: true })`, dispatch a `COMMENT_APPROVE_LOADING` action, then after receiving the response, dispatch either a `COMMENT_APPROVE_SUCCESS`, or a `COMMENT_APPROVE_FAILURE`. - -To use the new action creator in the component, `dispatch` it: - -```jsx -// in src/comments/ApproveButton.js -import { dispatch } from 'react-redux'; -import { commentApprove } from './commentActions'; - -const ApproveButton = ({ record }) => { - const dispatch = useDispatch(); - const handleClick = () => { - dispatch(commentApprove(record.id, record)); - // how about push and showNotification? - } - return ; -} - -export default ApproveButton; -``` - -It works fine: when a user presses the "Approve" button, the API receives the `UPDATE` call, and that approves the comment. Another added benefit of using custom actions with the `fetch` meta is that react-admin automatically handles the loading state, so you don't need to mess up with `fetchStart()` and `fetchEnd()` manually. - -But it's not possible to call `push` or `showNotification` in `handleClick` anymore. This is because `commentApprove()` returns immediately, whether the API call succeeds or not. How can you run a function only when the action succeeds? - -## Adding Side Effects to Actions - -Just like for the `useDataProvider` hook, you can associate side effects to a fetch action declaratively by setting the appropriate keys in the action `meta`. - -So the side effects will be declared in the action creator rather than in the component. For instance, to display a notification when the `COMMENT_APPROVE` action is successfully dispatched, add the `notification` meta: - -```diff -// in src/comment/commentActions.js -import { UPDATE } from 'react-admin'; -export const COMMENT_APPROVE = 'COMMENT_APPROVE'; -export const commentApprove = (id, data, basePath) => ({ - type: COMMENT_APPROVE, - payload: { id, data: { ...data, is_approved: true } }, - meta: { - resource: 'comments', - fetch: UPDATE, -+ onSuccess: { -+ notification: { -+ body: 'resources.comments.notification.approved_success', -+ level: 'info', -+ }, -+ redirectTo: '/comments', -+ basePath, -+ }, -+ onFailure: { -+ notification: { -+ body: 'resources.comments.notification.approved_failure', -+ level: 'warning', -+ }, -+ }, - }, -}); -``` - -The side effects accepted in the `meta` field of the action are the same as in the fourth parameter of the function returned by `useQuery`, `useMutation`, or `withDataProvider`: - -- `notification`: Display a notification. The property value should be an object describing the notification to display. The `body` can be a translation key. `level` can be either `info` or `warning`. -- `redirectTo`: Redirect the user to another page. The property value should be the path to redirect the user to. -- `refresh`: Force a rerender of the current view (equivalent to pressing the Refresh button). Set to true to enable. -- `unselectAll`: Unselect all lines in the current datagrid. Set to true to enable. -- `callback`: Execute an arbitrary function. The value should be the function to execute. React-admin will call the function with an object as parameter (`{ requestPayload, payload, error }`). The `payload` contains the decoded response body when it's successful. When it's failed, the response body is passed in the `error`. -- `basePath`: This is not a side effect, but it's used internally to compute redirection paths. Set it when you have a redirection side effect. - ## Making An Action Undoable When using the `useMutation` hook, you could trigger optimistic rendering and get an undo button for free. The same feature is possible using custom actions. You need to decorate the action with the `startUndoable` action creator: @@ -681,42 +587,6 @@ const PostCreateToolbar = props => ( ); ``` -## Custom Side Effects - -Sometimes, you may want to trigger other *side effects* - like closing a popup window, or sending a message to an analytics server. The easiest way to achieve this is to use the `callback` side effect: - -```diff -// in src/comment/commentActions.js -import { UPDATE } from 'react-admin'; -export const COMMENT_APPROVE = 'COMMENT_APPROVE'; -export const commentApprove = (id, data, basePath) => ({ - type: COMMENT_APPROVE, - payload: { id, data: { ...data, is_approved: true } }, - meta: { - resource: 'comments', - fetch: UPDATE, - onSuccess: { - notification: { - body: 'resources.comments.notification.approved_success', - level: 'info', - }, - redirectTo: '/comments', -+ callback: ({ payload, requestPayload }) => { /* your own logic */ } - basePath, - }, - onFailure: { - notification: { - body: 'resources.comments.notification.approved_failure', - level: 'warning', - }, -+ callback: ({ payload, requestPayload }) => { /* your own logic */ } - }, - }, -}); -``` - -Under the hood, `useDataProvider` uses the `callback` side effect to provide a Promise interface for dispatching fetch actions. As chaining custom side effects will quickly lead you to callback hell, we recommend that you use the `callback` side effect sparingly. - ## Custom Sagas React-admin promotes a programming style where side effects are decoupled from the rest of the code, which has the benefit of making them testable. diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index a1640f44303..c7d88f8f968 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -743,12 +743,6 @@
  • Querying The API With fetch
  • -
  • - Using a Custom Action Creator -
  • -
  • - Adding Side Effects to Actions -
  • Undoable Action
  • @@ -756,9 +750,6 @@ Altering the Form Values before Submitting -
  • - Custom Side Effects -
  • Custom Sagas
  • From 55c8e0663e97b11aa8144a8c7aee84e81ba49054 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 22 Jul 2019 22:35:31 +0200 Subject: [PATCH 5/5] Review --- docs/Actions.md | 2 +- examples/simple/src/users/UserEdit.js | 2 +- packages/ra-core/src/fetch/useDelete.ts | 3 ++- .../ra-ui-materialui/src/button/DeleteWithConfirmButton.js | 2 +- packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/Actions.md b/docs/Actions.md index cd7e28a0e47..b90672a7afb 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -230,7 +230,7 @@ const ApproveButton = ({ record }) => { }; ``` -The `onSuccess` function is called with the response from the `dataProvider` as argument. The `onError` function is called wit hthe error returned by the `dataProvider`. +The `onSuccess` function is called with the response from the `dataProvider` as argument. The `onError` function is called with the error returned by the `dataProvider`. React-admin provides the following hooks to handle most common side effects: diff --git a/examples/simple/src/users/UserEdit.js b/examples/simple/src/users/UserEdit.js index 384c09c36e2..2080a7fb26e 100644 --- a/examples/simple/src/users/UserEdit.js +++ b/examples/simple/src/users/UserEdit.js @@ -35,7 +35,7 @@ const UserEditToolbar = props => { return ( - + ); }; diff --git a/packages/ra-core/src/fetch/useDelete.ts b/packages/ra-core/src/fetch/useDelete.ts index 23c7cfa26be..8976592c3d8 100644 --- a/packages/ra-core/src/fetch/useDelete.ts +++ b/packages/ra-core/src/fetch/useDelete.ts @@ -4,7 +4,8 @@ import useMutation from './useMutation'; import { Identifier } from '../types'; /** - * Get a callback to call the dataProvider with a DELETE verb, the result and the loading state. + * Get a callback to call the dataProvider with a DELETE verb, the result + * of the call (the deleted record), and the loading state. * * The return value updates according to the request state: * diff --git a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.js b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.js index 542e467621c..970c1f4b198 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.js +++ b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.js @@ -144,7 +144,7 @@ DeleteWithConfirmButton.propTypes = { PropTypes.bool, PropTypes.func, ]), - resource: PropTypes.string.isRequired, + resource: PropTypes.string, icon: PropTypes.element, }; diff --git a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js index 474b4c8d062..899ffc33273 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js +++ b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.js @@ -107,7 +107,7 @@ DeleteWithUndoButton.propTypes = { PropTypes.bool, PropTypes.func, ]), - resource: PropTypes.string.isRequired, + resource: PropTypes.string, icon: PropTypes.element, };