편집 가능한 Redux 그리드 (React)

일반적으로 FlexGrid는 사용자가 그리드를 통해 변경하는 내용으로 기본 데이터 배열을 업데이트합니다. 이 접근 방식은 Redux와 같이 데이터 불변성이 필요한 상태 관리 시스템에서는 사용할 수 없습니다.

이 문제는 ImmutabilityProvider 확장 컴포넌트를 사용하여 해결할 수 있습니다. 이 컴포넌트는 FlexGrid 컨트롤에 연결되고 Redux Store의 데이터 배열에 바인딩되면 다음과 같은 방식으로 그리드 동작을 변경합니다.

  • 사용자가 평소 하던 방식(항목 값 변경, 행 추가/삭제, 텍스트 붙여넣기 등)대로 그리드를 통해 데이터를 편집할 수 있도록 허용합니다. 정렬, 그룹화, 필터링과 같은 모든 데이터 변환 작업도 허용됩니다.

  • 그리드가 사용자 편집에 반응하여 기본 데이터 배열을 변경하지 않도록 합니다. 대신에 dataChanged 이벤트를 트리거합니다. 이 이벤트는 데이터 변경 작업을 Redux Store로 디스패치하는 데 사용할 수 있습니다.

FlexGrid 알아보기 | FlexGrid API 문서

이 데모는 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/client'; 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>; } } setTimeout(() => { const container = document.getElementById('app'); if (container) { const root = ReactDOM.createRoot(container); root.render(<App />); } }, 100);
<!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: #48A9C5; 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.chart.map': 'npm:@grapecity/wijmo.chart.map/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.transposedmultirow': 'npm:@grapecity/wijmo.grid.transposedmultirow/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.rest': 'npm:@grapecity/wijmo.rest/index.js', '@grapecity/wijmo.pdf': 'npm:@grapecity/wijmo.pdf/index.js', '@grapecity/wijmo.pdf.security': 'npm:@grapecity/wijmo.pdf.security/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.barcode': 'npm:@grapecity/wijmo.barcode/index.js', '@grapecity/wijmo.barcode.common': 'npm:@grapecity/wijmo.barcode.common/index.js', '@grapecity/wijmo.barcode.composite': 'npm:@grapecity/wijmo.barcode.composite/index.js', '@grapecity/wijmo.barcode.specialized': 'npm:@grapecity/wijmo.barcode.specialized/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.chart.map': 'npm:@grapecity/wijmo.react.chart.map/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.transposedmultirow': 'npm:@grapecity/wijmo.react.grid.transposedmultirow/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", '@grapecity/wijmo.react.barcode.common': 'npm:@grapecity/wijmo.react.barcode.common/index.js', '@grapecity/wijmo.react.barcode.composite': 'npm:@grapecity/wijmo.react.barcode.composite/index.js', '@grapecity/wijmo.react.barcode.specialized': 'npm:@grapecity/wijmo.react.barcode.specialized/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', 'react-dom/client': '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);