Editable Redux Grid (React)

FlexGrid normally updates the underlying data array with the changes made by a user via the grid. This approach doesn't work with state managements systems like Redux, which require the data immutability.

This problem can be solved by the ImmutabilityProvider extension component. Being attached to the FlexGrid control, and bound to a data array from the Redux Store, this component changes the grid behavior in the following ways:

  • Allows a user to edit the data via the grid in a usual manner (change item values, add/delete rows, paste text, and so on). Data transformation operations such as sorting, grouping, and filtering are also supported.

  • Prevents the grid from mutating the underlying data array in response to user edits. Instead, it triggers the dataChanged event, which can be used to dispatch data change actions to the Redux Store.

This example uses React.

import 'bootstrap.css'; import '@grapecity/wijmo.styles/wijmo.css'; import './app.css'; // //React/Redux import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; //Application import { appReducer } from './reducers'; import { GridViewContainer } from './GridViewContainer'; // Create global Redux Store const store = createStore(appReducer); class App extends React.Component { render() { return <Provider store={store}> <GridViewContainer /> </Provider>; } } ReactDOM.render(<App />, document.getElementById('app'));
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>Immutable Data/Redux</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- SystemJS --> <script src="node_modules/systemjs/dist/system.src.js"></script> <script src="systemjs.config.js"></script> <script> System.import('./src/app'); </script> </head> <body> <div id="app"></div> </body> </html>
body { background-color: #f8f8f8; font-family: -apple-system-font, 'Segoe UI', 'Roboto', sans-serif; margin-bottom: 50px; } h1, h2, h3, h4, h5, h6 { font-family: -apple-system-font, 'Segoe UI Light', 'Segoe UI', 'Roboto', sans-serif; font-weight: 300; } .header { background-color: #00C1D5; margin-bottom: 14px; padding: 12px 0px; color: #dcf3f6; } .header h1 { font-size: 40px; line-height: 1; margin: 8px 0 5px 0; color: #fff; } .header img { float: left; margin: 0 10px 5px 0; } h3 { margin: 30px 0 10px -12px; } h1, h2, h3, h4, h5, h6 { color: #026974; } .content { width: 60%; margin: 30px 0 10px 12px; } .detail { margin-left: 100px; } .wj-flexgrid, .wj-grouppanel { max-height: 200px; } .wj-menu { margin-bottom: 6px; }
const countries = ['US', 'Germany', 'UK', 'Japan', 'Italy', 'Greece']; const products = ['Widget', 'Gadget', 'Doohickey']; export function getData(count = 5) { const data = []; const dt = new Date(); // add count items for (let i = 0; i < count; i++) { // constants used to create data items let date = new Date(dt.getFullYear(), i % 12, 25, i % 24, i % 60, i % 60), countryId = Math.floor(Math.random() * countries.length), productId = Math.floor(Math.random() * products.length); // create the item let item = { id: i, start: date, end: date, country: countries[countryId], product: products[productId], sales: Math.random() * 10000, downloads: Math.round(Math.random() * 10000), active: i % 4 === 0 }; // make item immutable Object.freeze(item); // add the item to the list data.push(item); } // return the data return data; }
// React import * as React from 'react'; // // Wijmo import * as wjInput from '@grapecity/wijmo.react.input'; import * as wjFlexGrid from '@grapecity/wijmo.react.grid'; import * as wjGroupPanel from '@grapecity/wijmo.react.grid.grouppanel'; import * as wjGridFilter from '@grapecity/wijmo.react.grid.filter'; import '@grapecity/wijmo.touch'; // add touch support on mobile devices // // Wijmo ImmutabilityProvider import { DataChangeAction } from '@grapecity/wijmo.grid.immutable'; import { ImmutabilityProvider } from '@grapecity/wijmo.react.grid.immutable'; // // Presentation component with an editable Redux grid export class GridView extends React.Component { constructor(props) { super(props); this.onCountChanged = this.onCountChanged.bind(this); this.onGridInitialized = this.onGridInitialized.bind(this); this.onGridDataChanged = this.onGridDataChanged.bind(this); this.groupPanelRef = React.createRef(); // We store local UI related data in the local state, for simplicity, // to not bloat global store with irrelevant data. this.state = { showStoreData: true }; } render() { return <div className='container-fluid'> <h4> Editable FlexGrid without data source mutation </h4> <div> <p> This <b>editable</b> <i>FlexGrid</i> component has an <i>ImmutabilityProvider</i> component as its child. The latter is bound to the <i>items</i> array from the Redux Store, using its <b>itemsSource</b> property. It also defines a handler for the <b>ImmutabilityProvider.dataChanged</b> event, which is triggered when a user edits data via the grid, and is used to dispatch data change <i>actions</i> to the Redux Store. </p> <p> The items in the Redux Store array are frozen using the <b>Object.freeze()</b> method, to make sure that FlexGrid really doesn't change the underlying data. User edits in datagrid don't mutate the underlying data directly. Instead, the data change <i>actions</i> called from the <b>dataChanged</b> event handler cause Redux Store <i>reducers</i> to update the <i>items</i> array in the global State. Because the <i>ImmutabilityProvider.itemsSource</i> property is bound directly to this array, it detects the applied changes and causes <b>FlexGrid</b> to update its content to reflect the changes. Notice that the overall performance of this seemingly complex process is nice, the edits are applied instantly. </p> <p> This way you get a usual data editing experience in the datagrid. But instead of directly mutating the underlying data array, the updates are performed via the Redux Store <i>reducers</i> mechanism. You can also sort, group, and filter the data as usual. </p> <div> <wjInput.Menu header='Item Count' value={this.props.itemCount} itemClicked={this.onCountChanged}> <wjInput.MenuItem value={5}>5</wjInput.MenuItem> <wjInput.MenuItem value={50}>50</wjInput.MenuItem> <wjInput.MenuItem value={100}>100</wjInput.MenuItem> <wjInput.MenuItem value={500}>500</wjInput.MenuItem> <wjInput.MenuItem value={5000}>5,000</wjInput.MenuItem> <wjInput.MenuItem value={10000}>10,000</wjInput.MenuItem> <wjInput.MenuItem value={50000}>50,000</wjInput.MenuItem> <wjInput.MenuItem value={100000}>100,000</wjInput.MenuItem> </wjInput.Menu> </div> <wjGroupPanel.GroupPanel ref={this.groupPanelRef} placeholder="Drag columns here to create groups."/> </div> <div> <wjFlexGrid.FlexGrid allowAddNew allowDelete initialized={this.onGridInitialized}> <ImmutabilityProvider itemsSource={this.props.items} dataChanged={this.onGridDataChanged}/> <wjGridFilter.FlexGridFilter /> <wjFlexGrid.FlexGridColumn binding="id" header="ID" width={80} isReadOnly={true}/> <wjFlexGrid.FlexGridColumn binding="start" header="Date" format="d"/> <wjFlexGrid.FlexGridColumn binding="end" header="Time" format="t"/> <wjFlexGrid.FlexGridColumn binding="country" header="Country"/> <wjFlexGrid.FlexGridColumn binding="product" header="Product"/> <wjFlexGrid.FlexGridColumn binding="sales" header="Sales" format="n2"/> <wjFlexGrid.FlexGridColumn binding="downloads" header="Downloads" format="n0"/> <wjFlexGrid.FlexGridColumn binding="active" header="Active" width={80}/> </wjFlexGrid.FlexGrid> </div> <div> <h4> Check data in the Store </h4> <p> This <b>read-only</b> grid shows the same data array from the Redux Store, to allow you controlling how the update operations go. </p> <p> If you evaluate performance of the data change operations on a big array, you may want to disconnect it from the data by means of the checkbox below, to not bring additional performance penalties caused by this grid refresh. </p> <input type="checkbox" checked={this.state.showStoreData} onChange={(e) => { this.setState({ showStoreData: e.target.checked }); }}/> {' '} <b>Show data</b> <wjFlexGrid.FlexGrid itemsSource={this.state.showStoreData ? this.props.items : null} isReadOnly/> </div> </div>; } onCountChanged(s) { this.props.changeCountAction(s.selectedValue); } onGridInitialized(s) { // Attach group panel this.groupPanelRef.current.control.grid = s; } // Dispatches data change actions to the Redux Store in response to // user edits made via the grid. onGridDataChanged(s, e) { switch (e.action) { case DataChangeAction.Add: this.props.addItemAction(e.newItem); break; case DataChangeAction.Remove: this.props.removeItemAction(e.newItem, e.itemIndex); break; case DataChangeAction.Change: this.props.changeItemAction(e.newItem, e.itemIndex); break; default: throw 'Unknown data action'; } } }
// GridViewContainer container component for the GridView presentation component. import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { GridView } from './GridView'; import { addItemAction, removeItemAction, changeItemAction, changeCountAction } from './actions'; const mapStateToProps = state => ({ items: state.items, itemCount: state.itemCount }); const mapDispatchToProps = dispatch => { return bindActionCreators({ addItemAction, removeItemAction, changeItemAction, changeCountAction }, dispatch); }; export const GridViewContainer = connect(mapStateToProps, mapDispatchToProps)(GridView);
export const addItemAction = (item) => ({ type: 'ADD_ITEM', item }); export const removeItemAction = (item, index) => ({ type: 'REMOVE_ITEM', item, index }); export const changeItemAction = (item, index) => ({ type: 'CHANGE_ITEM', item, index }); export const changeCountAction = (count) => ({ type: 'CHANGE_COUNT', count });
import { getData } from './data'; import { copyObject } from '@grapecity/wijmo.grid.immutable'; const itemCount = 5000; const initialState = { itemCount, items: getData(itemCount), idCounter: itemCount }; export const appReducer = (state = initialState, action) => { switch (action.type) { case 'ADD_ITEM': { // make a clone of the new item which will be added to the // items array, and assigns its 'id' property with a unique value. let newItem = Object.freeze(copyObject({}, action.item, { id: state.idCounter })); return copyObject({}, state, { // items array clone with a new item added items: state.items.concat([newItem]), // increment 'id' counter idCounter: state.idCounter + 1 }); } case 'REMOVE_ITEM': { let items = state.items, index = action.index; return copyObject({}, state, { // items array clone with the item removed items: items.slice(0, index).concat(items.slice(index + 1)) }); } case 'CHANGE_ITEM': { let items = state.items, index = action.index, oldItem = items[index], // create a cloned item with the property changes applied clonedItem = Object.freeze(copyObject({}, oldItem, action.item)); return copyObject({}, state, { // items array clone with the updated item items: items.slice(0, index). concat([clonedItem]). concat(items.slice(index + 1)) }); } case 'CHANGE_COUNT': { // create a brand new state with a new data let ret = copyObject({}, state, { itemCount: action.count, items: getData(action.count), idCounter: action.count }); return ret; } default: return state; } };
(function (global) { System.config({ transpiler: 'plugin-babel', babelOptions: { es2015: true, react: true }, meta: { '*.css': { loader: 'css' } }, paths: { // paths serve as alias 'npm:': 'node_modules/' }, // map tells the System loader where to look for things map: { 'jszip': 'npm:jszip/dist/jszip.js', '@grapecity/wijmo': 'npm:@grapecity/wijmo/index.js', '@grapecity/wijmo.input': 'npm:@grapecity/wijmo.input/index.js', '@grapecity/wijmo.styles': 'npm:@grapecity/wijmo.styles', '@grapecity/wijmo.cultures': 'npm:@grapecity/wijmo.cultures', '@grapecity/wijmo.chart': 'npm:@grapecity/wijmo.chart/index.js', '@grapecity/wijmo.chart.analytics': 'npm:@grapecity/wijmo.chart.analytics/index.js', '@grapecity/wijmo.chart.animation': 'npm:@grapecity/wijmo.chart.animation/index.js', '@grapecity/wijmo.chart.annotation': 'npm:@grapecity/wijmo.chart.annotation/index.js', '@grapecity/wijmo.chart.finance': 'npm:@grapecity/wijmo.chart.finance/index.js', '@grapecity/wijmo.chart.finance.analytics': 'npm:@grapecity/wijmo.chart.finance.analytics/index.js', '@grapecity/wijmo.chart.hierarchical': 'npm:@grapecity/wijmo.chart.hierarchical/index.js', '@grapecity/wijmo.chart.interaction': 'npm:@grapecity/wijmo.chart.interaction/index.js', '@grapecity/wijmo.chart.radar': 'npm:@grapecity/wijmo.chart.radar/index.js', '@grapecity/wijmo.chart.render': 'npm:@grapecity/wijmo.chart.render/index.js', '@grapecity/wijmo.chart.webgl': 'npm:@grapecity/wijmo.chart.webgl/index.js', '@grapecity/wijmo.gauge': 'npm:@grapecity/wijmo.gauge/index.js', '@grapecity/wijmo.grid': 'npm:@grapecity/wijmo.grid/index.js', '@grapecity/wijmo.grid.detail': 'npm:@grapecity/wijmo.grid.detail/index.js', '@grapecity/wijmo.grid.filter': 'npm:@grapecity/wijmo.grid.filter/index.js', '@grapecity/wijmo.grid.search': 'npm:@grapecity/wijmo.grid.search/index.js', '@grapecity/wijmo.grid.grouppanel': 'npm:@grapecity/wijmo.grid.grouppanel/index.js', '@grapecity/wijmo.grid.multirow': 'npm:@grapecity/wijmo.grid.multirow/index.js', '@grapecity/wijmo.grid.transposed': 'npm:@grapecity/wijmo.grid.transposed/index.js', '@grapecity/wijmo.grid.pdf': 'npm:@grapecity/wijmo.grid.pdf/index.js', '@grapecity/wijmo.grid.sheet': 'npm:@grapecity/wijmo.grid.sheet/index.js', '@grapecity/wijmo.grid.xlsx': 'npm:@grapecity/wijmo.grid.xlsx/index.js', '@grapecity/wijmo.grid.selector': 'npm:@grapecity/wijmo.grid.selector/index.js', '@grapecity/wijmo.grid.cellmaker': 'npm:@grapecity/wijmo.grid.cellmaker/index.js', '@grapecity/wijmo.grid.immutable': 'npm:@grapecity/wijmo.grid.immutable/index.js', '@grapecity/wijmo.touch': 'npm:@grapecity/wijmo.touch/index.js', '@grapecity/wijmo.cloud': 'npm:@grapecity/wijmo.cloud/index.js', '@grapecity/wijmo.nav': 'npm:@grapecity/wijmo.nav/index.js', '@grapecity/wijmo.odata': 'npm:@grapecity/wijmo.odata/index.js', '@grapecity/wijmo.olap': 'npm:@grapecity/wijmo.olap/index.js', '@grapecity/wijmo.pdf': 'npm:@grapecity/wijmo.pdf/index.js', '@grapecity/wijmo.viewer': 'npm:@grapecity/wijmo.viewer/index.js', '@grapecity/wijmo.xlsx': 'npm:@grapecity/wijmo.xlsx/index.js', '@grapecity/wijmo.undo': 'npm:@grapecity/wijmo.undo/index.js', '@grapecity/wijmo.interop.grid': 'npm:@grapecity/wijmo.interop.grid/index.js', "@grapecity/wijmo.react.chart.analytics": "npm:@grapecity/wijmo.react.chart.analytics/index.js", "@grapecity/wijmo.react.chart.animation": "npm:@grapecity/wijmo.react.chart.animation/index.js", "@grapecity/wijmo.react.chart.annotation": "npm:@grapecity/wijmo.react.chart.annotation/index.js", "@grapecity/wijmo.react.chart.finance.analytics": "npm:@grapecity/wijmo.react.chart.finance.analytics/index.js", "@grapecity/wijmo.react.chart.finance": "npm:@grapecity/wijmo.react.chart.finance/index.js", "@grapecity/wijmo.react.chart.hierarchical": "npm:@grapecity/wijmo.react.chart.hierarchical/index.js", "@grapecity/wijmo.react.chart.interaction": "npm:@grapecity/wijmo.react.chart.interaction/index.js", "@grapecity/wijmo.react.chart.radar": "npm:@grapecity/wijmo.react.chart.radar/index.js", "@grapecity/wijmo.react.chart": "npm:@grapecity/wijmo.react.chart/index.js", "@grapecity/wijmo.react.core": "npm:@grapecity/wijmo.react.core/index.js", "@grapecity/wijmo.react.gauge": "npm:@grapecity/wijmo.react.gauge/index.js", "@grapecity/wijmo.react.grid.detail": "npm:@grapecity/wijmo.react.grid.detail/index.js", "@grapecity/wijmo.react.grid.filter": "npm:@grapecity/wijmo.react.grid.filter/index.js", "@grapecity/wijmo.react.grid.grouppanel": "npm:@grapecity/wijmo.react.grid.grouppanel/index.js", '@grapecity/wijmo.react.grid.search': 'npm:@grapecity/wijmo.react.grid.search/index.js', "@grapecity/wijmo.react.grid.multirow": "npm:@grapecity/wijmo.react.grid.multirow/index.js", "@grapecity/wijmo.react.grid.sheet": "npm:@grapecity/wijmo.react.grid.sheet/index.js", '@grapecity/wijmo.react.grid.transposed': 'npm:@grapecity/wijmo.react.grid.transposed/index.js', '@grapecity/wijmo.react.grid.immutable': 'npm:@grapecity/wijmo.react.grid.immutable/index.js', "@grapecity/wijmo.react.grid": "npm:@grapecity/wijmo.react.grid/index.js", "@grapecity/wijmo.react.input": "npm:@grapecity/wijmo.react.input/index.js", "@grapecity/wijmo.react.olap": "npm:@grapecity/wijmo.react.olap/index.js", "@grapecity/wijmo.react.viewer": "npm:@grapecity/wijmo.react.viewer/index.js", "@grapecity/wijmo.react.nav": "npm:@grapecity/wijmo.react.nav/index.js", "@grapecity/wijmo.react.base": "npm:@grapecity/wijmo.react.base/index.js", 'jszip': 'npm:jszip/dist/jszip.js', 'react': 'npm:react/umd/react.production.min.js', 'react-dom': 'npm:react-dom/umd/react-dom.production.min.js', 'redux': 'npm:redux/dist/redux.min.js', 'react-redux': 'npm:react-redux/dist/react-redux.min.js', 'bootstrap.css': 'npm:bootstrap/dist/css/bootstrap.min.css', 'css': 'npm:systemjs-plugin-css/css.js', 'plugin-babel': 'npm:systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build':'npm:systemjs-plugin-babel/systemjs-babel-browser.js' }, // packages tells the System loader how to load when no filename and/or no extension packages: { src: { defaultExtension: 'jsx' }, "node_modules": { defaultExtension: 'js' }, } }); })(this);