Writing Actions
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.
How can you add such custom actions with admin-on-rest? The answer is twofold, and learning to do it properly will give you a better understanding of how admin-on-rest uses Redux and redux-saga.
The Simple Way
Here is an implementation of the “Approve” button that works perfectly:
// in src/comments/ApproveButton.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import FlatButton from 'material-ui/FlatButton';
import { showNotification as showNotificationAction } from 'admin-on-rest';
import { push as pushAction } from 'react-router-redux';
class ApproveButton extends Component {
handleClick = () => {
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')
});
}
render() {
return <FlatButton label="Approve" onClick={this.handleClick} />;
}
}
ApproveButton.propTypes = {
push: PropTypes.func,
record: PropTypes.object,
showNotification: PropTypes.func,
};
export default connect(null, {
showNotification: showNotificationAction,
push: pushAction,
})(ApproveButton);
The handleClick
function makes a PUT
request the REST API with fetch
, then displays a notification (with showNotification
) and redirects to the comments list page (with push
);
showNotification
and push
are action creators. This is a Redux term for functions that return a simple action object. When given an object of action creators in the second argument, connect()
will decorate each action creator with Redux’ dispatch
method, so in the handleClick
function, a call to showNotification()
is actually a call to dispatch(showNotification())
.
This ApproveButton
can be used right away, for instance in the list of comments, where <Datagrid>
automatically injects the record
to its children:
// in src/comments/index.js
import ApproveButton from './ApproveButton';
export const CommentList = (props) =>
<List {...props}>
<Datagrid>
<DateField source="created_at" />
<TextField source="author.name" />
<TextField source="body" />
<BooleanField source="is_approved" />
<ApproveButton />
</Datagrid>
</List>;
Or, in the <Edit>
page, as a custom action:
// in src/comments/CommentEditActions.js
import React from 'react';
import { CardActions } from 'material-ui/Card';
import { ListButton, DeleteButton } from 'admin-on-rest';
import ApproveButton from './ApproveButton';
const cardActionStyle = {
zIndex: 2,
display: 'inline-block',
float: 'right',
};
const CommentEditActions = ({ basePath, data }) => (
<CardActions style={cardActionStyle}>
<ApproveButton record={data} />
<ListButton basePath={basePath} />
<DeleteButton basePath={basePath} record={data} />
</CardActions>
);
export default CommentEditActions;
// in src/comments/index.js
import CommentEditActions from './CommentEditActions';
export const CommentEdit = (props) =>
<Edit {...props} actions={<CommentEditActions />}>
...
</Edit>;
Using The REST Client 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 query parameters, encoding, headers, body formatting, etc. It turns out you probably already have a function that maps from a REST request to an HTTP request: the REST Client. So it’s a good idea to use this function instead of fetch
- provided you have exported it:
// in src/restClient.js
import { simpleRestClient } from 'admin-on-rest';
export default simpleRestClient('http://Mydomain.com/api/');
// in src/comments/ApproveButton.js
import { UPDATE } from 'admin-on-rest';
import restClient from '../restClient';
class ApproveButton extends Component {
handleClick = () => {
const { push, record, showNotification } = this.props;
const updatedRecord = { ...record, is_approved: true };
restClient(UPDATE, 'comments', { id: record.id, data: updatedRecord })
.then(() => {
showNotification('Comment approved');
push('/comments');
})
.catch((e) => {
console.error(e);
showNotification('Error: comment not approved', 'warning')
});
}
render() {
return <FlatButton label="Approve" onClick={this.handleClick} />;
}
}
There you go: no more fetch
. Just like fetch
, the restClient
returns a Promise
. It’s signature is:
/**
* Execute the REST request and return a promise for a REST response
*
* @example
* restClient(GET_ONE, 'posts', { id: 123 })
* => new Promise(resolve => resolve({ id: 123, title: "hello, world" }))
*
* @param {string} type Request type, e.g GET_LIST
* @param {string} resource Resource name, e.g. "posts"
* @param {Object} payload Request parameters. Depends on the action type
* @returns {Promise} the Promise for a REST response
*/
const restClient = (type, resource, params) => new Promise();
As for the syntax of the various request types (GET_LIST
, GET_ONE
, UPDATE
, etc.), head to the REST Client documentation for more details.
Using a Custom Action Creator
Fetching data right inside the component is easy. But if you’re a Redux user, you might want to do it in a more idiomatic way - by dispatching actions. First, create your own action creator to replace the call to restClient
:
// in src/comment/commentActions.js
import { UPDATE } from 'admin-on-rest';
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, cancelPrevious: false },
});
This action creator takes advantage of admin-on-rest’s built in fetcher, which listens to actions with the fetch
meta. Upon dispatch, this action will trigger the call to restClient(UPDATE, 'comments')
, 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:
// in src/comments/ApproveButton.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import FlatButton from 'material-ui/FlatButton';
import { commentApprove as commentApproveAction } from './commentActions';
class ApproveButton extends Component {
handleClick = () => {
const { commentApprove, record } = this.props;
commentApprove(record.id, record);
// how about push and showNotification?
}
render() {
return <FlatButton label="Approve" onClick={this.handleClick} />;
}
}
ApproveButton.propTypes = {
commentApprove: PropTypes.func,
record: PropTypes.object,
};
export default connect(null, {
commentApprove: commentApproveAction,
})(ApproveButton);
This works fine: when a user presses the “Approve” button, the API receives the UPDATE
call, and that approves the comment. 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 With a Custom Saga
fetch
, showNotification
, and push
are called side effects. It’s a functional programming term that describes functions that do more than just returning a value based on their input. Admin-on-rest promotes a programming style where side effects are decoupled from the rest of the code, which has the benefit of making them testable.
In admin-on-rest, side effects are handled by Sagas. Redux-saga is a side effect library built for Redux, where side effects are defined by generator functions. This may sound complicated, but it’s not: Here is the generator function necessary to handle the side effects for the COMMENT_APPROVE
action.
// in src/comments/commentSaga.js
import { put, takeEvery, all } from 'redux-saga/effects';
import { push } from 'react-router-redux';
import { showNotification } from 'admin-on-rest';
function* commentApproveSuccess() {
yield put(showNotification('Comment approved'));
yield put(push('/comments'));
}
function* commentApproveFailure({ error }) {
yield put(showNotification('Error: comment not approved', 'warning'));
console.error(error);
}
export default function* commentSaga() {
yield all([
takeEvery('COMMENT_APPROVE_SUCCESS', commentApproveSuccess),
takeEvery('COMMENT_APPROVE_FAILURE', commentApproveFailure),
]);
}
Let’s explain all of that, starting with the final commentSaga
generator function. A generator function (denoted by the *
in the function name) gets paused on statements called by yield
- until the yielded statement returns. yield []
yields two commands in parallel. yield takeEvery([ACTION_NAME], callback)
executes the provided callback every time the related action is called. To summarize, this will execute commentApproveSuccess
when the fetch initiated by commentApprove()
succeeds, and commentApproveFailure
otherwise.
As for commentApproveSuccess
and commentApproveFailure
, they simply dispatch (put()
) the side effects - the same side effects as in the initial version.
To use this saga, pass it in the customSagas
props of the <Admin>
component:
// in src/App.js
import React from 'react';
import { Admin, Resource } from 'admin-on-rest';
import { CommentList } from './comments';
import commentSaga from './comments/commentSaga';
const App = () => (
<Admin customSagas={[ commentSaga ]} restClient={jsonServerRestClient('http://jsonplaceholder.typicode.com')}>
<Resource name="comments" list={CommentList} />
</Admin>
);
export default App;
With this code, approving a review now displays the correct notification, and redirects to the comment list. And the side effects are testable, too.
Bonus: Optimistic Rendering
In this example, after clicking on the “Approve” button, users are redirected to the comments list. Admin-on-rest then fetches the /comments
resource to grab the list of updated comments from the server. But admin-on-rest doesn’t wait for the response to this call to display the list of comments. In fact, it has an internal instance pool (in state.admin.resources[resource]
) that is kept during navigation, and uses it to render the screen before the API calls are over - it’s called optimistic rendering.
As the custom COMMENT_APPROVE
action contains the fetch: UPDATE
meta, admin-on-rest will automatically update its instance pool with the response. That means that the initial rendering (before the GET /comments
response arrives) will show the approved comment!
The fact that admin-on-rest updates the instance pool if you use custom actions with the fetch
meta should be another motivation to avoid using raw fetch
.
Using a Custom Reducer
In addition to triggering REST calls, you may want to store the effect of your own actions in the application state. For instance, if you want to display a widget showing the current exchange rate for the bitcoin, you might need the following action:
// in src/bitcoinRateReceived.js
export const BITCOIN_RATE_RECEIVED = 'BITCOIN_RATE_RECEIVED';
export const bitcoinRateReceived = (rate) => ({
type: BITCOIN_RATE_RECEIVED,
payload: { rate },
});
This action can be triggered on mount by the following component:
// in src/BitCoinRate.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bitcoinRateReceived as bitcoinRateReceivedAction } from './bitcoinRateReceived';
class BitCoinRate extends Component {
componentWillMount() {
fetch('https://blockchain.info/fr/ticker')
.then(response => response.json())
.then(rates => rates.USD['15m'])
.then(bitcoinRateReceived) // dispatch action when the response is received
}
render() {
const { rate } = this.props;
return <div>Current bitcoin value: {rate}$</div>
}
}
BitCoinRate.propTypes = {
bitcoinRateReceived: PropTypes.func,
rate: PropTypes.number,
};
const mapStateToProps = state => ({ rate: state.bitcoinRate });
export default connect(mapStateToProps, {
bitcoinRateReceived: bitcoinRateReceivedAction,
})(BitCoinRate);
In order to put the rate passed to bitcoinRateReceived()
into the Redux store, you’ll need a reducer:
// in src/rateReducer.js
import { BITCOIN_RATE_RECEIVED } from './bitcoinRateReceived';
export default (previousState = 0, { type, payload }) => {
if (type === BITCOIN_RATE_RECEIVED) {
return payload.rate;
}
return previousState;
}
Now the question is: How can you put this reducer in the <Admin>
app? Simple: use the customReducers
props:
// in src/App.js
import React from 'react';
import { Admin } from 'admin-on-rest';
import rate from './rateReducer';
const App = () => (
<Admin customReducers={{ rate }} restClient={jsonServerRestClient('http://jsonplaceholder.typicode.com')}>
...
</Admin>
);
export default App;
Tip: You can avoid storing data in the Redux state by storing data in a component state instead. It’s much less complicated to deal with, and more performant, too. Use the global state only when you really need to.
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 admin-on-rest’s coding standards, and if you know enough Redux and Saga, use the final version.