diff --git a/docs/Actions.md b/docs/Actions.md index 405aa03eb0..804fa55ab1 100644 --- a/docs/Actions.md +++ b/docs/Actions.md @@ -1,17 +1,25 @@ --- layout: default -title: "Actions" +title: "Querying the API" --- -# Writing Actions +# Querying the API -Admin interfaces often have to offer custom actions, beyond the simple CRUD. For instance, in an administration for comments, an "Approve" button (allowing to update the `is_approved` property and to save the updated record in one click) - is a must have. +Admin interfaces often have to query the API beyond CRUD requests. For instance, in an administration for comments, an "Approve" button (allowing to update the `is_approved` property and to save the updated record in one click) - is a must have. -How can you add such custom actions with react-admin? The answer is twofold, and learning to do it properly will give you a better understanding of how react-admin uses Redux and redux-saga. +How can you add such custom actions with react-admin? There are several answers to that question, and you should understand the strengths and drawbacks of each solution before choosing one. -## The Simple Way +* [Using `fetch`](#the-basic-way-using-fetch) +* [Using the `dataProvider`](#using-the-data-provider-instead-of-fetch) +* [Using the `withDataProvider` Decorator](#using-the-withdataprovider-decorator) +* [Using the `` and `` Components](#query-and-mutation-components) +* [Using a Custom Action Creator](#using-a-custom-action-creator) -Here is an implementation of the "Approve" button that works perfectly: +**Tip**: If you don't have the time to read this entire chapter, head to [the `` and `` components section](#query-and-mutation-components). It's the best choice in 90% of the cases. + +## The Basic Way: Using `fetch` + +Here is an implementation of the "Approve" button using the browser `fetch()` function that works fine: ```jsx // in src/comments/ApproveButton.js @@ -32,7 +40,6 @@ class ApproveButton extends Component { push('/comments'); }) .catch((e) => { - console.error(e); showNotification('Error: comment not approved', 'warning') }); } @@ -107,9 +114,9 @@ export const CommentEdit = (props) => ; ``` -## Using a Data Provider Instead of Fetch +## Using The Data Provider Instead of Fetch -The previous code uses `fetch()`, which means it has to make raw HTTP requests. The REST logic often requires 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: the [Data Provider](./DataProviders.md). So it's a good idea to use this function instead of `fetch` - provided you have exported it: +The previous code uses `fetch()`, which means it makes HTTP requests directly. But 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: the [Data Provider](./DataProviders.md). So it's a good idea to use this function instead of `fetch` - provided you have exported it: ```jsx // in src/dataProvider.js @@ -117,6 +124,8 @@ import jsonServerProvider from 'ra-data-json-server'; export default jsonServerProvider('http://Mydomain.com/api/'); ``` +The `dataProvider` function returns a Promise, so the difference with `fetch` is minimal: + ```diff // in src/comments/ApproveButton.js -import { showNotification } from 'react-admin'; @@ -128,23 +137,14 @@ class ApproveButton extends Component { const { push, record, showNotification } = this.props; const updatedRecord = { ...record, is_approved: true }; - fetch(`/comments/${record.id}`, { method: 'PUT', body: updatedRecord }) -- .then(() => { -- showNotification('Comment approved'); -- push('/comments'); -- }) -- .catch((e) => { -- console.error(e); -- showNotification('Error: comment not approved', 'warning') -- }); + dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }) -+ .then(() => { -+ showNotification('Comment approved'); -+ push('/comments'); -+ }) -+ .catch((e) => { -+ console.error(e); -+ showNotification('Error: comment not approved', 'warning') -+ }); + .then(() => { + showNotification('Comment approved'); + push('/comments'); + }) + .catch((e) => { + showNotification('Error: comment not approved', 'warning') + }); } render() { @@ -153,7 +153,7 @@ class ApproveButton extends Component { } ``` -There you go: no more `fetch`. Just like `fetch`, the `dataProvider` returns a `Promise`. It's signature is: +As a reminder, the signature of the `dataProvider` function is: ```jsx /** @@ -173,39 +173,42 @@ const dataProvider = (type, resource, params) => new Promise(); As for the syntax of the various request types (`GET_LIST`, `GET_ONE`, `UPDATE`, etc.), head to the [Data Provider documentation](./DataProviders.md#request-format) for more details. -## Triggering The Loading Indicator +## Using the `withDataProvider` Decorator + +Using either `fetch` or the `dataProvider` has one drawback: while the request is being processed by the server, the UI doesn't show the loading indicator. -Fetching data with `fetch` or the `dataProvider` right inside the component is easy. But it has one drawback: while the request is being processed by the server, the UI doesn't show the loading indicator. +React-admin components don't call the `dataProvider` function directly. Instead, they dispatch special Redux actions that react-admin turns into `dataProvider` calls. This allows react-admin to handle the loading state automatically. -React-admin keeps track of the number of pending XHR requests in its internal state. The main spinner (on the top app bar) shows up when there is at least one pending request. You can increase or decrease the number of pending requests by hand by using the `fetchStart()` and `fetchEnd()` action creators, as follows: +You can use the same feature for your own components. You'll need to wrap your component with a function called `withDataProvider`, which injects a `dataProvider` prop to the component. This `dataProvider` prop is a function which behaves exactly like your own `dataProvider`: it has the same signature, and it returns a Promise. The only difference is that it uses Redux under the hood. That means you get a loading indicator! In addition, `withDataProvider` injects the `dispatch` function into the component, so you don't even need to `connect()` your own component to dispatch actions anymore. + +Here is the `ApproveButton` component modified to use `withDataProvider`: ```diff // in src/comments/ApproveButton.js --import { showNotification, UPDATE } from 'react-admin'; -+import { -+ showNotification, -+ fetchStart, -+ fetchEnd, -+ UPDATE -+} from 'react-admin'; +import { + showNotification, + UPDATE, ++ withDataProvider, +} from 'react-admin'; +-import { connect } from 'react-redux'; +-import dataProvider from '../dataProvider'; class ApproveButton extends Component { handleClick = () => { - const { push, record, showNotification } = this.props; -+ const { push, record, showNotification, fetchStart, fetchEnd } = this.props; ++ const { dataProvider, dispatch, record } = this.props; const updatedRecord = { ...record, is_approved: true }; -+ fetchStart(); dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }) .then(() => { - showNotification('Comment approved'); - push('/comments'); +- showNotification('Comment approved'); ++ dispatch(showNotification('Comment approved')); +- push('/comments'); ++ dispatch(push('/comments')); }) .catch((e) => { - console.error(e); - showNotification('Error: comment not approved', 'warning') -- }); -+ }) -+ .finally(fetchEnd); +- showNotification('Error: comment not approved', 'warning') ++ dispatch(showNotification('Error: comment not approved', 'warning')) + }); } render() { @@ -214,26 +217,184 @@ class ApproveButton extends Component { } ApproveButton.propTypes = { -+ fetchStart: PropTypes.func, -+ fetchEnd: PropTypes.func, - push: PropTypes.func, ++ dataProvider: PropTypes.func.isRequired, ++ dispatch: PropTypes.func.isRequired, +- push: PropTypes.func, record: PropTypes.object, - showNotification: PropTypes.func, +- showNotification: PropTypes.func, }; -export default connect(null, { - showNotification, -+ fetchStart, -+ fetchEnd, - push, -})(ApproveButton); +-export default connect(null, { +- showNotification, +- push, +-})(ApproveButton); ++export default withDataProvider(ApproveButton) +``` + +## Handling Side Effects + +Fetching data is called a *side effect*, since it calls the outside world, and is asynchronous. Usual actions may have other side effects, like showing a notification, or redirecting the user to another page. The `dataProvider` function injected by `withDataProvider` accepts a fourth parameter, which lets you describe the options of the query, including success and failure side effects. So the previous component can be even further simplified as follows: + +```diff +// in src/comments/ApproveButton.js +import { +- showNotification, + UPDATE, + withDataProvider, +} from 'react-admin'; +-import { push } from 'react-router-redux'; + +class ApproveButton extends Component { + handleClick = () => { + const { dataProvider, dispatch, record } = this.props; + const updatedRecord = { ...record, is_approved: true }; +- dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }) +- .then(() => { +- dispatch(showNotification('Comment approved')); +- dispatch(push('/comments')); +- }) +- .catch((e) => { +- dispatch(showNotification('Error: comment not approved', 'warning')) +- }); +- } ++ dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }, { ++ onSuccess: { ++ notification: 'Comment approved', ++ redirectTo: '/comments', ++ }, ++ onError: { ++ notification: { body: 'Error: comment not approved', level: 'warning' } ++ } ++ }) + + render() { + return ; + } +} + +ApproveButton.propTypes = { + dataProvider: PropTypes.func.isRequired, +- dispatch: PropTypes.func.isRequired, + record: PropTypes.object, +}; + +export default withDataProvider(ApproveButton); +``` + +React-admin can handle the following side effects: + +- `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. +- `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. +- `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 successfull. When it's failed, the response body is passed in the `error`. + +## Optimistic Rendering and Undo + +In the previous example, after clicking on the "Approve" button, a spinner displays while the data provider is fetched. Then, users are redirected to the comments list. But in most cases, the server returns a success response, so the user waits for this response for nothing. + +For its own fetch actions, react-admin uses an approach called *optimistic rendering*. The idea is to handle the calls to the `dataProvider` on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the data provider are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. + +As a bonus, while the success notification is displayed, users have the ability to cancel the action *before* the data provider is even called. + +You can benefit from optimistic rendering when you call the `dataProvider` prop function, too. You just need to pass the `undoable: true` option in the options parameter: + +```diff +// in src/comments/ApproveButton.js +class ApproveButton extends Component { + handleClick = () => { + const { dataProvider, dispatch, record } = this.props; + const updatedRecord = { ...record, is_approved: true }; + dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }, { ++ undoable: true, + onSuccess: { + notification: 'Comment approved', + redirectTo: '/comments', + }, + onError: { + notification: { body: 'Error: comment not approved', level: 'warning' } + } + }) + + render() { + return ; + } +} ``` -That solution is perfectly all right from a UI perspective, but also a bit verbose. Fortunately, react-admin uses and provides a shorter way to make HTTP requests in a component. +The fact that react-admin can handle side effects and undo a call to the API if you use `withDataProvider` should be a good motivation to prefer it to raw `fetch`. + +## `` and `` Components + +When using the `withDataProvider` decorator to fetch data from the API, you must create a stateful class component to handle the initial state, the loading state, the loaded state, and the error state. That's a lot of boilerplate for a simple query. -## Using a Custom Action Creator +For such cases, react-admin provides a `` component, which uses `withDataProvider` under the hood. It leverages the render props pattern to reduce the boilerplate. -React-admin components don't call the `dataProvider` directly. Instead, they dispatch a Redux action with the `fetch` meta. React-admin watches this kind of actions, turns them into `dataProvider` calls, and handles the loading state automatically. You can use the same feature for your own actions. +For instance, to fetch and display a user profile in a standalone component: + +{% raw %} +```jsx +import { Query } from 'react-admin'; + +const UserProfile = ({ record }) => ( + + {({ data, loading, error }) => { + if (loading) { return ; } + if (error) { return

ERROR

; } + return
User {data.username}
; + }} +
+); +``` +{% endraw %} + +Just like the `dataProvider` injected prop, the `` component expects three parameters: `type`, `resource`, and `payload`. It fetches the data provider on mount, and passes the data to its child component once the response from the API arrives. + +The `` component is designed to read data from the API. When calling the API to update ("mutate") data, use the `` component instead. It passes a callback to trigger the API call to its child function. And the `` component from previous sections is a great use case for demonstrating ``: + +```jsx +import { Mutation } from 'react-admin'; + +const options = { + undoable: true, + onSuccess: { + notification: 'Comment approved', + redirectTo: '/comments', + }, + onError: { + notification: { body: 'Error: comment not approved', level: 'warning' } + } +}; + +const ApproveButton = ({ record }) => { + const payload = { id: record.id, data: { ...record, is_approved: true } }; + return ( + + {(approve) => ( + + )} + + ); +} + +export default ApproveButton; +``` + +Thanks to `Query` and `Mutation`, you can use a stateless function component instead of a class component, avoid the decoration with the `withDataProvider` HOC, and write less code. + +And if you need to chain API calls, don't hesitate to nest `` components! + +## 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, `withDataProvider` 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`: @@ -249,38 +410,20 @@ export const commentApprove = (id, data, basePath) => ({ }); ``` -Upon dispatch, this action will trigger the call to `dataProvider(UPDATE, 'comments')`, dispatch a `COMMENT_APPROVE_LOADING` action, then after receiving the response, dispatch either a `COMMENT_APPROVE_SUCCESS`, or a `COMMENT_APPROVE_FAILURE`. +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, `connect` it: -```diff +```jsx // in src/comments/ApproveButton.js --import { -- showNotification, -- fetchStart, -- fetchEnd, -- UPDATE --} from 'react-admin'; -+import { commentApprove } from './commentActions'; +import { connect } from 'react-redux'; +import { commentApprove } from './commentActions'; class ApproveButton extends Component { handleClick = () => { -- const { push, record, showNotification, fetchStart, fetchEnd } = this.props; -+ const { commentApprove, record } = this.props; -- const updatedRecord = { ...record, is_approved: true }; -- fetchStart(); -- dataProvider(UPDATE, 'comments', { id: record.id, data: updatedRecord }) -- .then(() => { -- showNotification('Comment approved'); -- push('/comments'); -- }) -- .catch((e) => { -- console.error(e); -- showNotification('Error: comment not approved', 'warning') -- }) -- .finally(fetchEnd); -+ commentApprove(record.id, record); -+ // how about push and showNotification? + const { commentApprove, record } = this.props; + commentApprove(record.id, record); + // how about push and showNotification? } render() { @@ -289,32 +432,22 @@ class ApproveButton extends Component { } ApproveButton.propTypes = { -- fetchStart: PropTypes.func, -- fetchEnd: PropTypes.func, -- push: PropTypes.func, -- showNotification: PropTypes.func, -+ commentApprove: PropTypes.func.isRequired,, + commentApprove: PropTypes.func.isRequired,, record: PropTypes.object, }; -export default connect(null, { -- showNotification, -- fetchStart, -- fetchEnd, -- push, -+ commentApprove -})(ApproveButton); +export default connect(null, { commentApprove })(ApproveButton); ``` -That's way shorter, and easier to read. And 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. +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? -## Handling Side Effects +## Adding Side Effects to Actions -Fetching data is called a *side effect*, since it calls the outside world, and is asynchronous. Usual actions may have other side effects, like showing a notification, or redirecting the user to another page. Just like for the `fetch` side effect, you can associate side effects to an action declaratively by setting the appropriate keys in the action `meta`. +Just like for the `withDataProvider`, 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 dispatched, add the `notification` 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 @@ -326,47 +459,6 @@ export const commentApprove = (id, data, basePath) => ({ meta: { resource: 'comments', fetch: UPDATE, -+ notification: { -+ body: 'resources.comments.notification.approved_success', -+ level: 'info', -+ }, -+ redirectTo: '/comments', -+ basePath, - }, -}); -``` - -React-admin can handle the following side effects metas: - -- `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 meta value should be the function to execute. It receives the `requestPayload` and the response `payload`. -- `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. - -## Success and Failure Side Effects - -React-admin triggers all side effects declared in the `meta` property of an action *simultaneously*. So in the previous example, the "notification approved" notification appears when the `COMMENT_APPROVE` action is dispatched, i.e. *before* the server is even called. That's a bit too early: what if the server returns an error? - -In practice, most side effects must be triggered only after the `fetch` side effect succeeds or fails. To support that, you can enclose side effects under the `onSuccess` and `onFailure` keys in the `meta` property of an action: - -```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, -- notification: { -- body: 'resources.comments.notification.approved_success', -- level: 'info', -- }, -- redirectTo: '/comments', -- basePath, + onSuccess: { + notification: { + body: 'resources.comments.notification.approved_success', @@ -385,37 +477,18 @@ export const commentApprove = (id, data, basePath) => ({ }); ``` -In this case, no side effect is triggered when the `COMMENT_APPROVE` action is dispatched. However, when the `fetch` side effects returns successfully, react-admin dispatches a `COMMENT_APPROVE_SUCCESS` action, and copies the `onSuccess` side effects into the `meta` property. So it will dispatch an action looking like: +The side effects accepted in the `meta` field of the action are the same as in the fourth parameter of the `dataProvider` function injected by `withDataProvider`: -```js -{ - type: COMMENT_APPROVE_SUCCESS, - payload: { data: { /* data returned by the server */ } }, - meta: { - resource: 'comments', - notification: { - body: 'resources.comments.notification.approved_success', - level: 'info', - }, - redirectTo: '/comments', - basePath, - }, -} -``` - -And then, the side effects will trigger. With this code, approving a review now displays the correct notification, and redirects to the comment list. - -You can use `onSuccess` and `onFailure` metas in your own actions to handle side effects - that's the recommended way. - -## Optimistic Rendering and Undo - -In the previous example, after clicking on the "Approve" button, a spinner displays while the data provider is fetched. Then, users are redirected to the comments list. But in most cases, the server returns a success response, so the user waits for this response for nothing. - -For its own fetch actions, react-admin uses an approach called *optimistic rendering*. The idea is to handle the `fetch` actions on the client side first (i.e. updating entities in the Redux store), and re-render the screen immediately. The user sees the effect of their action with no delay. Then, react-admin applies the success side effects, and only after that, it triggers the call to the data provider. If the fetch ends with a success, react-admin does nothing more than a refresh to grab the latest data from the server. In most cases, the user sees no difference (the data in the Redux store and the data from the data provider are the same). If the fetch fails, react-admin shows an error notification, and forces a refresh, too. +- `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 successfull. 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. -As a bonus, while the success notification is displayed, users have the ability to cancel the action *before* the data provider is even called. +## Making An Action Undoable -You can benefit from optimistic rendering in your own custom actions, too. You just need to decorate the action with the `startUndoable` action creator: +when using the `withDataProvider` function, 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: ```diff // in src/comments/ApproveButton.js @@ -450,8 +523,6 @@ export default connect(null, { And that's all it takes to make a fetch action optimistic. Note that the `startUndoable` action creator is passed to Redux `connect` as `mapDispatchToProp`, to be decorated with `dispatch` - but `commentApprove` is not. Only the first action must be decorated with dispatch. -The fact that react-admin updates the internal store if you use custom actions with the `fetch` meta should be another motivation to avoid using raw `fetch`. - ## Altering the Form Values before Submitting Sometimes, you may want your custom action to alter the form values before actually sending them to the `dataProvider`. For those cases, you should know that every buttons inside a form [Toolbar](/CreateEdit.md#toolbar) receive two props: @@ -517,7 +588,7 @@ const PostCreateToolbar = props => ( ); ``` -## Custom Sagas +## 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: @@ -551,7 +622,11 @@ export const commentApprove = (id, data, basePath) => ({ }); ``` -However, 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. +Under the hood, `withDataProvider` 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. In react-admin, side effects are handled by Sagas. [Redux-saga](https://redux-saga.github.io/redux-saga/) is a side effect library built for Redux, where side effects are defined by generator functions. If this is new to you, take a few minutes to go through the Saga documentation. @@ -692,8 +767,41 @@ You can find a complete example of a custom Bulk Action button in the `List` doc ## Conclusion -Which style should you choose for your own action buttons? - -The first version (with `fetch`) is perfectly fine, and if you're not into unit testing your components, or decoupling side effects from pure functions, then you can stick with it without problem. - -On the other hand, if you want to promote reusability, separation of concerns, adhere to react-admin's coding standards, and if you know enough Redux and Saga, use the final version. +Which style should you choose for your own action buttons? Here is a quick benchmark: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SolutionAdvantagesDrawbacks
fetch
  • Nothing to learn
  • Requires duplication of authentication
  • Does not handle the loading state
  • Adds boilerplate
dataProvider
  • Familiar API
  • Does not handle the loading state
  • Adds boilerplate
withDataProvider
  • Familiar API
  • Handles side effects
  • Adds boilerplate
  • Uses HOC
<Query> and <Mutation>
  • Declarative
  • Dense
  • Handles loading and error states
  • Handles side effects
  • Mix logic and presentation in markup
Custom action
  • Allows logic reuse
  • Handles side effects
  • Idiomatic to Redux
  • Hard to chain calls
diff --git a/docs/CreateEdit.md b/docs/CreateEdit.md index 9f57600a09..02eb00d3b2 100644 --- a/docs/CreateEdit.md +++ b/docs/CreateEdit.md @@ -20,6 +20,7 @@ Here are all the props accepted by the `` and `` components: * [`title`](#page-title) * [`actions`](#actions) * [`aside`](#aside-component) +* [`undoable`](#undoable) (`` only) Here is the minimal code necessary to display a form to create and edit comments: @@ -176,6 +177,53 @@ const Aside = ({ record }) => ( **Tip**: Always test that the `record` is defined before using it, as react-admin starts rendering the UI before the API call is over. +### Undoable + +By default, the Save and Delete actions are undoable, i.e. react-admin only sends the related request to the data provider after a short delay, during which the user can cancel the action. This is part of the "optimistic rendering" strategy of react-admin ; it makes the user interactions more reactive. + +You can disable this behavior by setting `undoable={false}`. With that setting, clicking on the Delete button displays a confirmation dialog. Both the Save and the Delete actions become blocking, and delay the refresh of the screen until the data provider responds. + +```jsx +const PostEdit = props => ( + + ... + +``` + +**Tip**: If you want a confirmation dialog for the Delete button but don't mind undoable Edits, then pass a [custom toolbar](#toolbar) to the form, as follows: + +```jsx +import { + Toolbar, + SaveButton, + DeleteButton, + Edit, + SimpleForm, +} from 'react-admin'; +import { withStyles } from '@material-ui/core'; + +const toolbarStyles = { + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}; + +const CustomToolbar = withStyles(toolbarStyles)(props => ( + + + + +)); + +const PostEdit = props => ( + + }> + ... + + +``` + ## Prefilling a `` Record You may need to prepopulate a record based on another one. For that use case, use the `` component. It expects a `record` and a `basePath` (usually injected to children of ``, ``, ``, etc.), so it's as simple to use as a regular field or input. diff --git a/docs/Inputs.md b/docs/Inputs.md index f9189febfc..a7d1cd0112 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -993,10 +993,10 @@ const FullNameField = ({ record }) => {record.first_name} {record.last_nam }/> ``` -Enabling the `allowEmpty` props adds an empty choice (with `null` value) on top of the options, and makes the value nullable: +Enabling the `allowEmpty` props adds an empty choice (with a default `null` value, which you can overwrite with the `emptyValue` prop) on top of the options, and makes the value nullable: ```jsx - - 14. Writing Actions + 14. Querying the API
  • - The Simple Way + The Basic Way: Using fetch
  • - Using a Data Provider + Using the dataProvider
  • - Using a Custom Action Creator + Using withDataProvider
  • Handling Side Effects
  • - Success and Failure Side Effects + Optimistic Rendering and Undo
  • - Optimistic Rendering and Undo + <Query> and <Mutation> +
  • +
  • + Using a Custom Action Creator +
  • +
  • + Adding Side Effects to Actions +
  • +
  • + Undoable Action
  • Altering the Form Values before Submitting
  • +
  • + Custom Side Effects +
  • Custom Sagas
  • diff --git a/examples/demo/src/dashboard/Dashboard.js b/examples/demo/src/dashboard/Dashboard.js index 7e2c648a1a..042bee3042 100644 --- a/examples/demo/src/dashboard/Dashboard.js +++ b/examples/demo/src/dashboard/Dashboard.js @@ -1,5 +1,7 @@ import React, { Component } from 'react'; -import { GET_LIST, GET_MANY, Responsive } from 'react-admin'; +import { GET_LIST, GET_MANY, Responsive, withDataProvider } from 'react-admin'; +import compose from 'recompose/compose'; +import { connect } from 'react-redux'; import Welcome from './Welcome'; import MonthlyRevenue from './MonthlyRevenue'; @@ -7,7 +9,6 @@ import NbNewOrders from './NbNewOrders'; import PendingOrders from './PendingOrders'; import PendingReviews from './PendingReviews'; import NewCustomers from './NewCustomers'; -import dataProviderFactory from '../dataProvider'; const styles = { flex: { display: 'flex' }, @@ -21,118 +22,116 @@ class Dashboard extends Component { state = {}; componentDidMount() { + this.fetchData(); + } + + componentDidUpdate(prevProps) { + // handle refresh + if (this.props.version !== prevProps.version) { + this.fetchData(); + } + } + + fetchData() { + this.fetchOrders(); + this.fetchReviews(); + this.fetchCustomers(); + } + + async fetchOrders() { + const { dataProvider } = this.props; const aMonthAgo = new Date(); aMonthAgo.setDate(aMonthAgo.getDate() - 30); + const { data: recentOrders } = await dataProvider( + GET_LIST, + 'commands', + { + filter: { date_gte: aMonthAgo.toISOString() }, + sort: { field: 'date', order: 'DESC' }, + pagination: { page: 1, perPage: 50 }, + } + ); + const aggregations = recentOrders + .filter(order => order.status !== 'cancelled') + .reduce( + (stats, order) => { + if (order.status !== 'cancelled') { + stats.revenue += order.total; + stats.nbNewOrders++; + } + if (order.status === 'ordered') { + stats.pendingOrders.push(order); + } + return stats; + }, + { + revenue: 0, + nbNewOrders: 0, + pendingOrders: [], + } + ); + this.setState({ + revenue: aggregations.revenue.toLocaleString(undefined, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }), + nbNewOrders: aggregations.nbNewOrders, + pendingOrders: aggregations.pendingOrders, + }); + const { data: customers } = await dataProvider(GET_MANY, 'customers', { + ids: aggregations.pendingOrders.map(order => order.customer_id), + }); + this.setState({ + pendingOrdersCustomers: customers.reduce((prev, customer) => { + prev[customer.id] = customer; // eslint-disable-line no-param-reassign + return prev; + }, {}), + }); + } - dataProviderFactory(process.env.REACT_APP_DATA_PROVIDER).then( - dataProvider => { - dataProvider(GET_LIST, 'commands', { - filter: { date_gte: aMonthAgo.toISOString() }, - sort: { field: 'date', order: 'DESC' }, - pagination: { page: 1, perPage: 50 }, - }) - .then(response => - response.data - .filter(order => order.status !== 'cancelled') - .reduce( - (stats, order) => { - if (order.status !== 'cancelled') { - stats.revenue += order.total; - stats.nbNewOrders++; - } - if (order.status === 'ordered') { - stats.pendingOrders.push(order); - } - return stats; - }, - { - revenue: 0, - nbNewOrders: 0, - pendingOrders: [], - } - ) - ) - .then(({ revenue, nbNewOrders, pendingOrders }) => { - this.setState({ - revenue: revenue.toLocaleString(undefined, { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }), - nbNewOrders, - pendingOrders, - }); - return pendingOrders; - }) - .then(pendingOrders => - pendingOrders.map(order => order.customer_id) - ) - .then(customerIds => - dataProvider(GET_MANY, 'customers', { - ids: customerIds, - }) - ) - .then(response => response.data) - .then(customers => - customers.reduce((prev, customer) => { - prev[customer.id] = customer; // eslint-disable-line no-param-reassign - return prev; - }, {}) - ) - .then(customers => - this.setState({ pendingOrdersCustomers: customers }) - ); - - dataProvider(GET_LIST, 'reviews', { - filter: { status: 'pending' }, - sort: { field: 'date', order: 'DESC' }, - pagination: { page: 1, perPage: 100 }, - }) - .then(response => response.data) - .then(reviews => { - const nbPendingReviews = reviews.reduce(nb => ++nb, 0); - const pendingReviews = reviews.slice( - 0, - Math.min(10, reviews.length) - ); - this.setState({ pendingReviews, nbPendingReviews }); - return pendingReviews; - }) - .then(reviews => reviews.map(review => review.customer_id)) - .then(customerIds => - dataProvider(GET_MANY, 'customers', { - ids: customerIds, - }) - ) - .then(response => response.data) - .then(customers => - customers.reduce((prev, customer) => { - prev[customer.id] = customer; // eslint-disable-line no-param-reassign - return prev; - }, {}) - ) - .then(customers => - this.setState({ pendingReviewsCustomers: customers }) - ); + async fetchReviews() { + const { dataProvider } = this.props; + const { data: reviews } = await dataProvider(GET_LIST, 'reviews', { + filter: { status: 'pending' }, + sort: { field: 'date', order: 'DESC' }, + pagination: { page: 1, perPage: 100 }, + }); + const nbPendingReviews = reviews.reduce(nb => ++nb, 0); + const pendingReviews = reviews.slice(0, Math.min(10, reviews.length)); + this.setState({ pendingReviews, nbPendingReviews }); + const { data: customers } = await dataProvider(GET_MANY, 'customers', { + ids: pendingReviews.map(review => review.customer_id), + }); + this.setState({ + pendingReviewsCustomers: customers.reduce((prev, customer) => { + prev[customer.id] = customer; // eslint-disable-line no-param-reassign + return prev; + }, {}), + }); + } - dataProvider(GET_LIST, 'customers', { - filter: { - has_ordered: true, - first_seen_gte: aMonthAgo.toISOString(), - }, - sort: { field: 'first_seen', order: 'DESC' }, - pagination: { page: 1, perPage: 100 }, - }) - .then(response => response.data) - .then(newCustomers => { - this.setState({ newCustomers }); - this.setState({ - nbNewCustomers: newCustomers.reduce(nb => ++nb, 0), - }); - }); + async fetchCustomers() { + const { dataProvider } = this.props; + const aMonthAgo = new Date(); + aMonthAgo.setDate(aMonthAgo.getDate() - 30); + const { data: newCustomers } = await dataProvider( + GET_LIST, + 'customers', + { + filter: { + has_ordered: true, + first_seen_gte: aMonthAgo.toISOString(), + }, + sort: { field: 'first_seen', order: 'DESC' }, + pagination: { page: 1, perPage: 100 }, } ); + this.setState({ + newCustomers, + nbNewCustomers: newCustomers.reduce(nb => ++nb, 0), + }); } render() { @@ -222,4 +221,11 @@ class Dashboard extends Component { } } -export default Dashboard; +const mapStateToProps = state => ({ + version: state.admin.ui.viewVersion, +}); + +export default compose( + connect(mapStateToProps), + withDataProvider +)(Dashboard); diff --git a/examples/demo/src/reviews/AcceptButton.js b/examples/demo/src/reviews/AcceptButton.js index 76bab0531a..bdabf49b6e 100644 --- a/examples/demo/src/reviews/AcceptButton.js +++ b/examples/demo/src/reviews/AcceptButton.js @@ -1,44 +1,61 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { formValueSelector } from 'redux-form'; import Button from '@material-ui/core/Button'; import ThumbUp from '@material-ui/icons/ThumbUp'; -import { translate } from 'react-admin'; +import { translate, Mutation } from 'react-admin'; import compose from 'recompose/compose'; -import { reviewApprove as reviewApproveAction } from './reviewActions'; -class AcceptButton extends Component { - handleApprove = () => { - const { reviewApprove, record, comment } = this.props; - reviewApprove(record.id, { ...record, comment }); - }; +const sideEffects = { + onSuccess: { + notification: { + body: 'resources.reviews.notification.approved_success', + level: 'info', + }, + redirectTo: '/reviews', + }, + onFailure: { + notification: { + body: 'resources.reviews.notification.approved_error', + level: 'warning', + }, + }, +}; - render() { - const { record, translate } = this.props; - return record && record.status === 'pending' ? ( - - ) : ( - - ); - } -} + size="small" + onClick={approve} + > + + {translate('resources.reviews.action.accept')} + + )} + + ) : ( + + ); AcceptButton.propTypes = { record: PropTypes.object, comment: PropTypes.string, - reviewApprove: PropTypes.func, translate: PropTypes.func, }; @@ -46,14 +63,9 @@ const selector = formValueSelector('record-form'); const enhance = compose( translate, - connect( - state => ({ - comment: selector(state, 'comment'), - }), - { - reviewApprove: reviewApproveAction, - } - ) + connect(state => ({ + comment: selector(state, 'comment'), + })) ); export default enhance(AcceptButton); diff --git a/examples/demo/src/reviews/RejectButton.js b/examples/demo/src/reviews/RejectButton.js index e7a4363640..cd3bea6bdf 100644 --- a/examples/demo/src/reviews/RejectButton.js +++ b/examples/demo/src/reviews/RejectButton.js @@ -8,6 +8,9 @@ import { translate } from 'react-admin'; import compose from 'recompose/compose'; import { reviewReject as reviewRejectAction } from './reviewActions'; +/** + * This custom button demonstrate using a custom action to update data + */ class AcceptButton extends Component { handleApprove = () => { const { reviewReject, record, comment } = this.props; diff --git a/examples/simple/src/users/UserEdit.js b/examples/simple/src/users/UserEdit.js index bb122a4da7..3cbdc8e5c3 100644 --- a/examples/simple/src/users/UserEdit.js +++ b/examples/simple/src/users/UserEdit.js @@ -2,21 +2,47 @@ import React from 'react'; import PropTypes from 'prop-types'; import { + DeleteWithConfirmButton, DisabledInput, Edit, FormTab, + SaveButton, SelectInput, TabbedForm, TextInput, + Toolbar, required, } from 'react-admin'; +import { withStyles } from '@material-ui/core'; import UserTitle from './UserTitle'; import Aside from './Aside'; +const toolbarStyles = { + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}; + +/** + * Custom Toolbar for the Edit form + * + * Save with undo, but delete with confirm + */ +const UserEditToolbar = withStyles(toolbarStyles)(props => ( + + + + +)); + const UserEdit = ({ permissions, ...props }) => ( } aside={