This post is part of a series about the DevExtreme React Grid. You can find the introduction and overview to the post series by following this link.
In this post, I want to take advantage of the fact that the Redux store gives me external access to the Grid state. As I outlined in the previous post, individual React component can certainly have internal state, even if they are used in an application where overall state management is done with Redux. But sometimes state can be used externally for good reason, and in this example I will implement the functionality of batch saving (and batch discarding of changes), which is not currently implemented by the grid itself.
Note that the data structures used by the Grid (or specifically, by the EditingState
) already take into account that multiple editing operations can happen in parallel. In fact, you can edit multiple rows of data out of the box. But the standard behavior supplies only a per-row Save link, and if the EditingState
event onCommitChanges
is triggered, there is only ever one change to be committed. Batch Editing should be fully implemented in the Grid at a later point.
Creating a toolbar
At the beginning of my sample code (as usual, you can find the whole sample at the bottom of this post), I import two UI elements from the react-bootstrap library:
const { ButtonToolbar, Button } = ReactBootstrap;
With the help of these two components, I define a toolbar. You see that I’m retrieving the event handlers from props
, so you might guess that this component will be integrated as a connected component, just like I did with the Grid in the previous post.
class ReduxToolbar extends React.PureComponent { render() { const { gridHasEditingChanges, onSaveButtonClick, onDiscardButtonClick } = this.props; return (<ButtonToolbar><Button bsStyle="success" disabled={!gridHasEditingChanges} onClick={onSaveButtonClick}> Save Changes</Button><Button bsStyle="danger" disabled={!gridHasEditingChanges} onClick={onDiscardButtonClick}> Discard Changes</Button></ButtonToolbar> ); } }
In addition to the event handlers, I also receive a flag called gridHasEditingChanges
, which is used to activate and deactivate the buttons. This flag represents a piece of grid-specific state information, which is supplied to the toolbar component.
Changes to the Grid configuration
I am not using an onCommitChanges
event handler anymore on the EditingState
. I added a commandTemplate
to the TableEditColumn
that removes the Save link that is normally shown when the user edits a row. As a result, saving of individual rows is no longer possible.
<TableEditColumn allowAdding allowEditing allowDeleting commandTemplate={({ id }) => (id === 'commit' ? null : undefined)} />
The commandTemplate
function receives an id
as a parameter. I’m testing this to see whether I’m looking at the Save command (which has the id commit
), and then I return either null
or undefined
. This may seem confusing and we are considering other options, but the distinction is that null
is handled by React in its standard way of not rendering anything at all, while undefined
is interpreted by our libraries and results in the standard element being rendered.
Changes to the Redux elements
Compared to the sample from the previous post, I have removed the GRID_SAVE
action. Instead, I have introduced the following three new actions:
const BATCH_SAVE = "BATCH_SAVE"; const BATCH_DISCARD = "BATCH_DISCARD"; const GRID_EDITING_STATE_CHANGE = "GRID_EDITING_STATE_CHANGE"; const batchSave = () => ({ type: BATCH_SAVE }); const batchDiscard = () => ({ type: BATCH_DISCARD }); const gridEditingStateChange = (stateFieldName, stateFieldValue) => ({ type: GRID_EDITING_STATE_CHANGE, stateFieldName, stateFieldValue });
The BATCH_SAVE
and BATCH_DISCARD
actions will be dispatched by the event handlers of the toolbar buttons. GRID_EDITING_STATE_CHANGE
is a variation of the standard GRID_STATE_CHANGE
and it will be handled specially to track whether or not the editing state currently contains any change information.
To handle GRID_EDITING_STATE_CHANGE
, I have added this block to the grid reducer:
case GRID_EDITING_STATE_CHANGE: const { editingRows, changedRows, addedRows, deletedRows } = state; const es = { editingRows, changedRows, addedRows, deletedRows }; es[action.stateFieldName] = action.stateFieldValue; const hasEditingChanges = (es.editingRows && es.editingRows.length > 0) || (es.addedRows && es.addedRows.length > 0) || (es.deletedRows && es.deletedRows.length > 0) || (es.changedRows && Object.keys(es.changedRows).length > 0); return { ...state, hasEditingChanges, [action.stateFieldName]: action.stateFieldValue };
The grid state value hasEditingChanges
, which I mentioned before as it was being used by the new toolbar, is calculated here depending on the state and the action currently being handled.
The handling of BATCH_DISCARD
is quite simple:
function discardChangeDetails(state) { return { ...state, editingRows: [], addedRows: [], changedRows: {}, deletedRows: [], hasEditingChanges: false }; } // ... in reducer: case BATCH_DISCARD: return discardChangeDetails(state);
The helper discardChangeDetails
returns a new state with all the editing related fields reset to their default values. I have created this helper function because it is also used by the BATCH_SAVE
action handling. The saving logic itself has not changed from the previous version:
case BATCH_SAVE: return _.flow( Array.from( commitChanges(state.addedRows, state.changedRows, state.deletedRows) ) )(state);
In the previous post, I mentioned how commitChanges
creates a sequence of function calls to reflect the changes recorded in state. For this version, I simply extended that sequence by adding a call to discardChangeDetails
:
function* commitChanges(added, changed, deleted) { yield* deleteFunctions(deleted); yield* changeFunctions(changed); yield* addFunctions(added); yield discardChangeDetails; }
In other words, after changes have been committed, the resulting state will be passed to discardChangeDetails
, where the change details are removed from state before that state is returned.
Note that there is currently an issue with the handling of delete state, which means that deletion doesn’t work correctly in the current version of my sample. The reason for this is that there is some special handling for the Delete
link: you don’t have to click Save
after clicking Delete
, because this is triggered automatically. Unfortunately I found that this built-in behavior collides with the concept of batch-saving externally. Our devs are looking into this and I will post about it again in the future.
Integrating the toolbar
To make the new toolbar into a connected component, I need the same elements that I’m using for the grid: a mapStateToProps
function, a mapDispatchToProps
function and a call to connect
.
const mapToolbarStateToProps = state => ({ gridHasEditingChanges: state.hasEditingChanges }); const mapToolbarDispatchToProps = dispatch => ({ onSaveButtonClick: () => dispatch(batchSave()), onDiscardButtonClick: () => dispatch(batchDiscard()) }); const ConnectedToolbar = connect( mapToolbarStateToProps, mapToolbarDispatchToProps )(ReduxToolbar);
These three elements are pretty straight-forward. The only new thing is the way mapToolbarStateToProps
accesses a piece of state information that comes from the grid.
Note that the general recommendation for connected components is to supply them with the precise set of props
they require. This statement is meant for each individual component! Theoretically you could have a wrapper that incorporates both the toolbar and the grid, and then push all the relevant state for both components into the common parent. This saves you time creating the mapStateToProps
and mapDispatchToProps
functions for one of the components. However, it also means that if any of the overall state changes, the parent component with both components inside it would now be re-rendered. In my sample, when gridHasEditingChanges
changes, only the toolbar needs to re-render, not the grid. By converting individual components into connected components and supplying with the distinct state details they require, you optimize your application performance.
The final part that changes is the call to ReactDOM.render
, since I’m adding the ConnectedToolbar
into the output:
ReactDOM.render(<div><Provider store={store}><div><ConnectedToolbar /><ConnectedGrid /></div></Provider></div>, document.getElementById("app") );
And that’s it. Here is the complete sample, and just like the previous post I recommend using the CodePen debug view and the Redux DevTools Extension to see exactly how state management and action handling work.