グリッド:編集可能なReduxグリッド (React)

FlexGridは通常、グリッドを介してユーザーが行った変更で、基になるデータ配列を更新します。 このアプローチは、データの不変性を必要とするReduxのような状態管理システムでは機能しません。

この問題は、ImmutabilityProvider拡張コンポーネントを使用することで解決できます。 このコンポーネントは、FlexGridコントロールにアタッチされ、Reduxストアからのデータ配列にバインドされているため、グリッドの動作を次のように変更します。

  • ユーザーがグリッドを介して通常の方法でデータを編集できるようにします(アイテム値の変更、行の追加/削除、テキストの貼り付けなど)。並べ替え、グループ化、フィルタリングなどのすべてのデータ変換操作もサポートされます。
  • ユーザーの編集に応じて、グリッドが基になるデータ配列を変更しないようにします。代わりに、データ変更アクションをReduxストアにディスパッチするために使用できるdataChangedイベントをトリガーします。

このサンプルはReactを使用しています。

import 'bootstrap.css'; import '@mescius/wijmo.styles/wijmo.css'; import ReactDOM from 'react-dom/client'; import React from 'react'; import { createStore } from 'redux'; import { Provider as _Provider } from 'react-redux'; const Provider = _Provider; import './app.css'; import { appReducer } from './reducers'; import { GridViewContainer } from './GridViewContainer'; // Create global Redux Store const store = createStore(appReducer); function App() { return (<Provider store={store}> <GridViewContainer /> </Provider>); } const container = document.getElementById('app'); if (container) { const root = ReactDOM.createRoot(container); root.render(<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="https://cdnjs.cloudflare.com/ajax/libs/systemjs/0.19.40/system.src.js" integrity="sha512-G6mEj6h18+m3MvzdviSDfPle/TfH0//cXcB33AKlNR/Rha0yQsKefDZKRTkIZos97HEGq2JMV1RT5ybMoQ3WsQ==" crossorigin="anonymous"></script> <script src="systemjs.config.js"></script> <script> System.import('./src/app'); </script> </head> <body> <div id="app"></div> </body> </html>
h1, h2, h3, h4, h5, h6 { 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 = ['アメリカ', 'ドイツ', 'イギリス', '日本', 'イタリア', 'ギリシャ']; const products = ['ウィジェット', 'ガジェット', 'ツール']; 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; }
import React, { useRef, useState } from 'react'; import useEvent from 'react-use-event-hook'; import * as wjInput from '@mescius/wijmo.react.input'; import * as wjFlexGrid from '@mescius/wijmo.react.grid'; import * as wjGroupPanel from '@mescius/wijmo.react.grid.grouppanel'; import * as wjGridFilter from '@mescius/wijmo.react.grid.filter'; import * as wjcGridImmutable from '@mescius/wijmo.grid.immutable'; import { ImmutabilityProvider } from '@mescius/wijmo.react.grid.immutable'; import '@mescius/wijmo.touch'; // add touch support on mobile devices // Presentation component with an editable Redux grid export function GridView(props) { const [showStoreData, setShowStoreData] = useState(true); const groupPanelRef = useRef(null); const onCountChanged = useEvent((s) => { props.changeCountAction(s.selectedValue); }); const onGridInitialized = useEvent((s) => { // Attach group panel if (groupPanelRef.current) { groupPanelRef.current.control.grid = s; } }); // Dispatches data change actions to the Redux Store in response to // user edits made via the grid. const onGridDataChanged = useEvent((s, e) => { switch (e.action) { case wjcGridImmutable.DataChangeAction.Add: props.addItemAction(e.newItem); break; case wjcGridImmutable.DataChangeAction.Remove: props.removeItemAction(e.newItem, e.itemIndex); break; case wjcGridImmutable.DataChangeAction.Change: props.changeItemAction(e.newItem, e.itemIndex); break; default: throw 'Unknown data action'; } }); return (<div className="container-fluid"> <h4> データソースの変更を伴わない編集可能なFlexGrid </h4> <div> <p> この<b>編集可能な</b><i>FlexGrid</i>コンポーネントには、その子として<i>ImmutabilityProvider</i>コンポーネントがあります。 後者は、<b>itemsSource</b>プロパティを使用して、Redux Storeの<i>items</i>配列にバインドされます。 また、<b>ImmutabilityProvider.dataChanged</b>イベントのハンドラーも定義します。 これは、ユーザーがグリッドを介してデータを編集するとトリガーされ、データ変更<i>アクション</i>をReduxストアにディスパッチするために使用されます。 </p> <p> Redux Store配列内のアイテムは、<b>Object.freeze()</b>メソッドを使用して固定され、FlexGridが基になるデータを実際に変更しないようにします。 グリッドでユーザーが編集しても、基になるデータは直接変更されません。 代わりに、<b>dataChanged</b>イベントハンドラーから呼び出されるデータ変更<i>アクション</i>により、causeReduxStore <i>Reducer</i>はグローバル状態の<i>items</i>配列を更新します。 <i>ImmutabilityProvider.itemsSource</i>プロパティはこの配列に直接バインドされているため、適用された変更を検出し、<b>FlexGrid</b>がコンテンツを更新して変更を反映します。 この一見複雑なプロセスの全体的なパフォーマンスが優れていることがわかります。編集はすぐに適用されます。 </p> <p> このようにして、データグリッドで通常のデータ編集体験を得ることができます。 ただし、基になるデータ配列を直接変更する代わりに、データの更新はRedux Store <i>Reducer</i>メカニズムを介して実行されます。 必要に応じて、データを並べ替え、グループ化、フィルタリングすることもできます。 </p> <div> <wjInput.Menu header='データ数' value={props.itemCount} itemClicked={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={groupPanelRef} placeholder="列をここにドラッグして、グループを作成してください。"/> </div> <div> <wjFlexGrid.FlexGrid allowAddNew allowDelete initialized={onGridInitialized}> <ImmutabilityProvider itemsSource={props.items} dataChanged={onGridDataChanged}/> <wjGridFilter.FlexGridFilter /> <wjFlexGrid.FlexGridColumn binding="id" header="ID" width={80} isReadOnly={true}/> <wjFlexGrid.FlexGridColumn binding="start" header="日付" format="d"/> <wjFlexGrid.FlexGridColumn binding="end" header="時刻" format="t"/> <wjFlexGrid.FlexGridColumn binding="country" header="国"/> <wjFlexGrid.FlexGridColumn binding="product" header="商品"/> <wjFlexGrid.FlexGridColumn binding="sales" header="売上" format="n2"/> <wjFlexGrid.FlexGridColumn binding="downloads" header="DL数" format="n0"/> <wjFlexGrid.FlexGridColumn binding="active" header="有効" width={80}/> </wjFlexGrid.FlexGrid> </div> <div> <h4> ストア内のデータを確認する </h4> <p> この<b>読み取り専用</b>グリッドには、Reduxストアの同じデータ配列が表示され、更新操作の進行方法を制御できます。 </p> <p> 大きな配列でのデータ変更操作のパフォーマンスを評価する場合は、次の方法でデータから切断することができます。 以下のチェックボックスをオンにして、このグリッドの更新によるパフォーマンスのペナルティを追加しないようにします。 </p> <input type="checkbox" checked={showStoreData} onChange={(e) => { setShowStoreData(e.target.checked); }}/> {' '} <b>データを表示する</b> <wjFlexGrid.FlexGrid itemsSource={showStoreData ? props.items : null} isReadOnly> <wjFlexGrid.FlexGridColumn binding="id" header="ID" width={80}/> <wjFlexGrid.FlexGridColumn binding="start" header="日付" format="d"/> <wjFlexGrid.FlexGridColumn binding="end" header="時刻" format="t"/> <wjFlexGrid.FlexGridColumn binding="country" header="国"/> <wjFlexGrid.FlexGridColumn binding="product" header="商品"/> <wjFlexGrid.FlexGridColumn binding="sales" header="売上" format="n2"/> <wjFlexGrid.FlexGridColumn binding="downloads" header="DL数" format="n0"/> <wjFlexGrid.FlexGridColumn binding="active" header="有効" width={80}/> </wjFlexGrid.FlexGrid> </div> </div>); }
// 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 '@mescius/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', '@mescius/wijmo': 'npm:@mescius/wijmo/index.js', '@mescius/wijmo.input': 'npm:@mescius/wijmo.input/index.js', '@mescius/wijmo.styles': 'npm:@mescius/wijmo.styles', '@mescius/wijmo.cultures': 'npm:@mescius/wijmo.cultures', '@mescius/wijmo.chart': 'npm:@mescius/wijmo.chart/index.js', '@mescius/wijmo.chart.analytics': 'npm:@mescius/wijmo.chart.analytics/index.js', '@mescius/wijmo.chart.animation': 'npm:@mescius/wijmo.chart.animation/index.js', '@mescius/wijmo.chart.annotation': 'npm:@mescius/wijmo.chart.annotation/index.js', '@mescius/wijmo.chart.finance': 'npm:@mescius/wijmo.chart.finance/index.js', '@mescius/wijmo.chart.finance.analytics': 'npm:@mescius/wijmo.chart.finance.analytics/index.js', '@mescius/wijmo.chart.hierarchical': 'npm:@mescius/wijmo.chart.hierarchical/index.js', '@mescius/wijmo.chart.interaction': 'npm:@mescius/wijmo.chart.interaction/index.js', '@mescius/wijmo.chart.radar': 'npm:@mescius/wijmo.chart.radar/index.js', '@mescius/wijmo.chart.render': 'npm:@mescius/wijmo.chart.render/index.js', '@mescius/wijmo.chart.webgl': 'npm:@mescius/wijmo.chart.webgl/index.js', '@mescius/wijmo.chart.map': 'npm:@mescius/wijmo.chart.map/index.js', '@mescius/wijmo.gauge': 'npm:@mescius/wijmo.gauge/index.js', '@mescius/wijmo.grid': 'npm:@mescius/wijmo.grid/index.js', '@mescius/wijmo.grid.detail': 'npm:@mescius/wijmo.grid.detail/index.js', '@mescius/wijmo.grid.filter': 'npm:@mescius/wijmo.grid.filter/index.js', '@mescius/wijmo.grid.search': 'npm:@mescius/wijmo.grid.search/index.js', '@mescius/wijmo.grid.grouppanel': 'npm:@mescius/wijmo.grid.grouppanel/index.js', '@mescius/wijmo.grid.multirow': 'npm:@mescius/wijmo.grid.multirow/index.js', '@mescius/wijmo.grid.transposed': 'npm:@mescius/wijmo.grid.transposed/index.js', '@mescius/wijmo.grid.transposedmultirow': 'npm:@mescius/wijmo.grid.transposedmultirow/index.js', '@mescius/wijmo.grid.pdf': 'npm:@mescius/wijmo.grid.pdf/index.js', '@mescius/wijmo.grid.sheet': 'npm:@mescius/wijmo.grid.sheet/index.js', '@mescius/wijmo.grid.xlsx': 'npm:@mescius/wijmo.grid.xlsx/index.js', '@mescius/wijmo.grid.selector': 'npm:@mescius/wijmo.grid.selector/index.js', '@mescius/wijmo.grid.cellmaker': 'npm:@mescius/wijmo.grid.cellmaker/index.js', '@mescius/wijmo.grid.immutable': 'npm:@mescius/wijmo.grid.immutable/index.js', '@mescius/wijmo.touch': 'npm:@mescius/wijmo.touch/index.js', '@mescius/wijmo.cloud': 'npm:@mescius/wijmo.cloud/index.js', '@mescius/wijmo.nav': 'npm:@mescius/wijmo.nav/index.js', '@mescius/wijmo.odata': 'npm:@mescius/wijmo.odata/index.js', '@mescius/wijmo.olap': 'npm:@mescius/wijmo.olap/index.js', '@mescius/wijmo.rest': 'npm:@mescius/wijmo.rest/index.js', '@mescius/wijmo.pdf': 'npm:@mescius/wijmo.pdf/index.js', '@mescius/wijmo.pdf.security': 'npm:@mescius/wijmo.pdf.security/index.js', '@mescius/wijmo.viewer': 'npm:@mescius/wijmo.viewer/index.js', '@mescius/wijmo.xlsx': 'npm:@mescius/wijmo.xlsx/index.js', '@mescius/wijmo.undo': 'npm:@mescius/wijmo.undo/index.js', '@mescius/wijmo.interop.grid': 'npm:@mescius/wijmo.interop.grid/index.js', '@mescius/wijmo.barcode': 'npm:@mescius/wijmo.barcode/index.js', '@mescius/wijmo.barcode.common': 'npm:@mescius/wijmo.barcode.common/index.js', '@mescius/wijmo.barcode.composite': 'npm:@mescius/wijmo.barcode.composite/index.js', '@mescius/wijmo.barcode.specialized': 'npm:@mescius/wijmo.barcode.specialized/index.js', "@mescius/wijmo.react.chart.analytics": "npm:@mescius/wijmo.react.chart.analytics/index.js", "@mescius/wijmo.react.chart.animation": "npm:@mescius/wijmo.react.chart.animation/index.js", "@mescius/wijmo.react.chart.annotation": "npm:@mescius/wijmo.react.chart.annotation/index.js", "@mescius/wijmo.react.chart.finance.analytics": "npm:@mescius/wijmo.react.chart.finance.analytics/index.js", "@mescius/wijmo.react.chart.finance": "npm:@mescius/wijmo.react.chart.finance/index.js", "@mescius/wijmo.react.chart.hierarchical": "npm:@mescius/wijmo.react.chart.hierarchical/index.js", "@mescius/wijmo.react.chart.interaction": "npm:@mescius/wijmo.react.chart.interaction/index.js", "@mescius/wijmo.react.chart.radar": "npm:@mescius/wijmo.react.chart.radar/index.js", "@mescius/wijmo.react.chart": "npm:@mescius/wijmo.react.chart/index.js", "@mescius/wijmo.react.core": "npm:@mescius/wijmo.react.core/index.js", '@mescius/wijmo.react.chart.map': 'npm:@mescius/wijmo.react.chart.map/index.js', "@mescius/wijmo.react.gauge": "npm:@mescius/wijmo.react.gauge/index.js", "@mescius/wijmo.react.grid.detail": "npm:@mescius/wijmo.react.grid.detail/index.js", "@mescius/wijmo.react.grid.filter": "npm:@mescius/wijmo.react.grid.filter/index.js", "@mescius/wijmo.react.grid.grouppanel": "npm:@mescius/wijmo.react.grid.grouppanel/index.js", '@mescius/wijmo.react.grid.search': 'npm:@mescius/wijmo.react.grid.search/index.js', "@mescius/wijmo.react.grid.multirow": "npm:@mescius/wijmo.react.grid.multirow/index.js", "@mescius/wijmo.react.grid.sheet": "npm:@mescius/wijmo.react.grid.sheet/index.js", '@mescius/wijmo.react.grid.transposed': 'npm:@mescius/wijmo.react.grid.transposed/index.js', '@mescius/wijmo.react.grid.transposedmultirow': 'npm:@mescius/wijmo.react.grid.transposedmultirow/index.js', '@mescius/wijmo.react.grid.immutable': 'npm:@mescius/wijmo.react.grid.immutable/index.js', "@mescius/wijmo.react.grid": "npm:@mescius/wijmo.react.grid/index.js", "@mescius/wijmo.react.input": "npm:@mescius/wijmo.react.input/index.js", "@mescius/wijmo.react.olap": "npm:@mescius/wijmo.react.olap/index.js", "@mescius/wijmo.react.viewer": "npm:@mescius/wijmo.react.viewer/index.js", "@mescius/wijmo.react.nav": "npm:@mescius/wijmo.react.nav/index.js", "@mescius/wijmo.react.base": "npm:@mescius/wijmo.react.base/index.js", '@mescius/wijmo.react.barcode.common': 'npm:@mescius/wijmo.react.barcode.common/index.js', '@mescius/wijmo.react.barcode.composite': 'npm:@mescius/wijmo.react.barcode.composite/index.js', '@mescius/wijmo.react.barcode.specialized': 'npm:@mescius/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', "react-use-event-hook": "npm:react-use-event-hook/dist/esm/useEvent.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);