DevExtreme v23.2 is now available.

Explore our newest features/capabilities and share your thoughts with us.

Your search did not match any results.

Collaborative Editing

Documentation

Multiple users can edit the DataGrid's data in real-time. In this demo, changes made in one DataGrid are broadcasted to the other DataGrid via the SignalR service.

To implement this functionality:

  1. Generate a session ID used to identify DataGrids that should be edited simultaneously (groupId in this demo).

  2. Configure CustomStores. In this demo, we use the createStore method (part of the DevExtreme.AspNet.data extension). The onBeforeSend function is used to send the session ID from step 1 to the server.

  3. Create store instances—one per DataGrid.

  4. Create DataGrids and bind them to the store instances.

  5. Update all the store instances when a push notification is received (see the updateStores function).

Changes made collaboratively are lost if you refresh the page because the SignalR service broadcasts changes without saving them.

Backend API
import React, { useEffect } from 'react'; import { HubConnectionBuilder, HttpTransportType } from '@aspnet/signalr'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; import Guid from 'devextreme/core/guid'; import Grid from './Grid.tsx'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore/'; const url = `${BASE_PATH}api/DataGridCollaborativeEditing`; const groupId = new Guid().toString(); interface Change { type: 'insert' | 'update' | 'remove'; data?: any; key?: any; } const createStore = () => AspNetData.createStore({ key: 'ID', loadUrl: url, insertUrl: url, updateUrl: url, deleteUrl: url, onBeforeSend(operation, ajaxSettings) { ajaxSettings.data.groupId = groupId; }, }); const store1 = createStore(); const store2 = createStore(); const updateStores = (events: Change[]) => { store1.push(events); store2.push(events); }; const App = () => { useEffect(() => { const hubUrl = `${BASE_PATH}dataGridCollaborativeEditingHub?GroupId=${groupId}`; const connection = new HubConnectionBuilder() .withUrl(hubUrl, { skipNegotiation: true, transport: HttpTransportType.WebSockets, }) .build(); connection.start() .then(() => { connection.on('update', (key, data) => { updateStores([{ type: 'update', key, data }]); }); connection.on('insert', (data) => { updateStores([{ type: 'insert', data }]); }); connection.on('remove', (key) => { updateStores([{ type: 'remove', key }]); }); }); }, []); return ( <div className="tables"> <div className="column"> <Grid dataSource={store1} /> </div> <div className="column"> <Grid dataSource={store2} /> </div> </div> ); }; export default App;
import React, { useEffect } from 'react'; import { HubConnectionBuilder, HttpTransportType } from '@aspnet/signalr'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; import Guid from 'devextreme/core/guid'; import Grid from './Grid.js'; const BASE_PATH = 'https://js.devexpress.com/Demos/NetCore/'; const url = `${BASE_PATH}api/DataGridCollaborativeEditing`; const groupId = new Guid().toString(); const createStore = () => AspNetData.createStore({ key: 'ID', loadUrl: url, insertUrl: url, updateUrl: url, deleteUrl: url, onBeforeSend(operation, ajaxSettings) { ajaxSettings.data.groupId = groupId; }, }); const store1 = createStore(); const store2 = createStore(); const updateStores = (events) => { store1.push(events); store2.push(events); }; const App = () => { useEffect(() => { const hubUrl = `${BASE_PATH}dataGridCollaborativeEditingHub?GroupId=${groupId}`; const connection = new HubConnectionBuilder() .withUrl(hubUrl, { skipNegotiation: true, transport: HttpTransportType.WebSockets, }) .build(); connection.start().then(() => { connection.on('update', (key, data) => { updateStores([{ type: 'update', key, data }]); }); connection.on('insert', (data) => { updateStores([{ type: 'insert', data }]); }); connection.on('remove', (key) => { updateStores([{ type: 'remove', key }]); }); }); }, []); return ( <div className="tables"> <div className="column"> <Grid dataSource={store1} /> </div> <div className="column"> <Grid dataSource={store2} /> </div> </div> ); }; export default App;
import React from 'react'; import DataGrid, { Column, Editing, Paging, RequiredRule, RangeRule, Lookup, } from 'devextreme-react/data-grid'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; const maxDate = new Date(3000, 0); const statesStore = AspNetData.createStore({ key: 'ID', loadUrl: 'https://js.devexpress.com/Demos/NetCore/api/DataGridStatesLookup', }); const Grid = (props) => ( <DataGrid dataSource={props.dataSource} height={600} showBorders={true} repaintChangesOnly={true} highlightChanges={true} > <Paging enabled={false} /> <Editing mode="cell" refreshMode="reshape" allowUpdating={true} allowDeleting={true} allowAdding={true} useIcons={true} /> <Column dataField="Prefix" caption="Title" width={50} > <RequiredRule /> </Column> <Column dataField="FirstName" > <RequiredRule /> </Column> <Column dataField="StateID" caption="State" > <Lookup dataSource={statesStore} displayExpr="Name" valueExpr="ID" /> <RequiredRule /> </Column> <Column dataField="BirthDate" dataType="date" > <RangeRule max={maxDate} message="Date can not be greater than 01/01/3000" /> </Column> </DataGrid> ); export default Grid;
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App.tsx'; ReactDOM.render( <App />, document.getElementById('app'), );
window.exports = window.exports || {}; window.config = { transpiler: 'ts', typescriptOptions: { module: 'system', emitDecoratorMetadata: true, experimentalDecorators: true, jsx: 'react', }, meta: { 'react': { 'esModule': true, }, 'typescript': { 'exports': 'ts', }, 'devextreme/time_zone_utils.js': { 'esModule': true, }, 'devextreme/localization.js': { 'esModule': true, }, 'devextreme/viz/palette.js': { 'esModule': true, }, 'devextreme-aspnet-data-nojquery': { 'esModule': true, }, }, paths: { 'npm:': 'https://unpkg.com/', }, defaultExtension: 'js', map: { 'ts': 'npm:plugin-typescript@4.2.4/lib/plugin.js', 'typescript': 'npm:typescript@4.2.4/lib/typescript.js', 'react': 'npm:react@17.0.2/umd/react.development.js', 'react-dom': 'npm:react-dom@17.0.2/umd/react-dom.development.js', 'prop-types': 'npm:prop-types@15.8.1/prop-types.js', '@aspnet/signalr': 'npm:@aspnet/signalr@1.0.27/dist/cjs', 'tslib': 'npm:tslib@2.6.2/tslib.js', 'devextreme-aspnet-data-nojquery': 'npm:devextreme-aspnet-data-nojquery@3.0.0/index.js', 'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js', 'luxon': 'npm:luxon@1.28.1/build/global/luxon.min.js', 'es6-object-assign': 'npm:es6-object-assign@1.1.0', 'devextreme': 'npm:devextreme@23.2.5/cjs', 'devextreme-react': 'npm:devextreme-react@23.2.5/cjs', 'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js', 'devextreme-quill': 'npm:devextreme-quill@1.6.4/dist/dx-quill.min.js', 'devexpress-diagram': 'npm:devexpress-diagram@2.2.5/dist/dx-diagram.js', 'devexpress-gantt': 'npm:devexpress-gantt@4.1.51/dist/dx-gantt.js', '@devextreme/runtime': 'npm:@devextreme/runtime@3.0.12', 'inferno': 'npm:inferno@7.4.11/dist/inferno.min.js', 'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js', 'inferno-create-element': 'npm:inferno-create-element@7.4.11/dist/inferno-create-element.min.js', 'inferno-dom': 'npm:inferno-dom/dist/inferno-dom.min.js', 'inferno-hydrate': 'npm:inferno-hydrate@7.4.11/dist/inferno-hydrate.min.js', 'inferno-clone-vnode': 'npm:inferno-clone-vnode/dist/inferno-clone-vnode.min.js', 'inferno-create-class': 'npm:inferno-create-class/dist/inferno-create-class.min.js', 'inferno-extras': 'npm:inferno-extras/dist/inferno-extras.min.js', 'devextreme-cldr-data': 'npm:devextreme-cldr-data@1.0.3', // SystemJS plugins 'plugin-babel': 'npm:systemjs-plugin-babel@0.0.25/plugin-babel.js', 'systemjs-babel-build': 'npm:systemjs-plugin-babel@0.0.25/systemjs-babel-browser.js', // Prettier 'prettier/standalone': 'npm:prettier@2.8.4/standalone.js', 'prettier/parser-html': 'npm:prettier@2.8.4/parser-html.js', }, packages: { 'devextreme': { defaultExtension: 'js', }, 'devextreme-react': { main: 'index.js', }, 'devextreme/events/utils': { main: 'index', }, 'devextreme/localization/messages': { format: 'json', defaultExtension: '', }, 'devextreme/events': { main: 'index', }, '@aspnet/signalr': { main: 'index.js', defaultExtension: 'js', }, 'es6-object-assign': { main: './index.js', defaultExtension: 'js', }, }, packageConfigPaths: [ 'npm:@devextreme/*/package.json', 'npm:@devextreme/runtime@3.0.12/inferno/package.json', ], babelOptions: { sourceMaps: false, stage0: true, react: true, }, }; System.config(window.config);
import React from 'react'; import DataGrid, { Column, Editing, Paging, RequiredRule, RangeRule, Lookup, } from 'devextreme-react/data-grid'; import * as AspNetData from 'devextreme-aspnet-data-nojquery'; const maxDate = new Date(3000, 0); const statesStore = AspNetData.createStore({ key: 'ID', loadUrl: 'https://js.devexpress.com/Demos/NetCore/api/DataGridStatesLookup', }); const Grid = (props) => ( <DataGrid dataSource={props.dataSource} height={600} showBorders={true} repaintChangesOnly={true} highlightChanges={true} > <Paging enabled={false} /> <Editing mode="cell" refreshMode="reshape" allowUpdating={true} allowDeleting={true} allowAdding={true} useIcons={true} /> <Column dataField="Prefix" caption="Title" width={50} > <RequiredRule /> </Column> <Column dataField="FirstName"> <RequiredRule /> </Column> <Column dataField="StateID" caption="State" > <Lookup dataSource={statesStore} displayExpr="Name" valueExpr="ID" /> <RequiredRule /> </Column> <Column dataField="BirthDate" dataType="date" > <RangeRule max={maxDate} message="Date can not be greater than 01/01/3000" /> </Column> </DataGrid> ); export default Grid;
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App.js'; ReactDOM.render(<App />, document.getElementById('app'));
<!DOCTYPE html> <html> <head> <title>DevExtreme Demo</title> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/23.2.5/css/dx.light.css" /> <script src="https://unpkg.com/core-js@2.6.12/client/shim.min.js"></script> <script src="https://unpkg.com/systemjs@0.21.3/dist/system.js"></script> <script type="text/javascript" src="config.js"></script> <link rel="stylesheet" type="text/css" href="styles.css" /> <script type="text/javascript"> System.import("./index.tsx"); </script> </head> <body class="dx-viewport"> <div class="demo-container"> <div id="app"></div> </div> </body> </html>
.tables { display: flex; } .column:first-child { width: 50%; padding-right: 15px; } .column:last-child { width: 50%; padding-left: 15px; }
using DevExtreme.AspNet.Data; using DevExtreme.AspNet.Mvc; using DevExtreme.MVC.Demos.Hubs; using DevExtreme.MVC.Demos.Models.DataGrid; using DevExtreme.MVC.Demos.Models.SampleData; using Microsoft.AspNet.SignalR; using Newtonsoft.Json; using System; using System.Net; using System.Net.Http; using System.Net.Http.Formatting; using System.Web.Http; namespace DevExtreme.MVC.Demos.Controllers.ApiControllers { public class DataGridCollaborativeEditingController : ApiController { static readonly Random random = new Random(); IHubContext hubContext; public DataGridCollaborativeEditingController() { hubContext = GlobalHost.ConnectionManager.GetHubContext<DataGridCollaborativeEditingHub>(); } [HttpGet] public object Get(DataSourceLoadOptions loadOptions) { return DataSourceLoader.Load(SampleData.DataGridEmployees, loadOptions); } [HttpPost] public HttpResponseMessage Post(FormDataCollection form) { var values = form.Get("values"); var groupId = form.Get("groupId"); var newEmployee = new Employee(); JsonConvert.PopulateObject(values, newEmployee); newEmployee.ID = random.Next(int.MaxValue); Validate(newEmployee); if(!ModelState.IsValid) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState.GetFullErrorMessage()); } // db.Employees.Add(newEmployee); // db.SaveChanges(); hubContext.Clients.Group(groupId).insert(newEmployee); return Request.CreateResponse(newEmployee); } [HttpPut] public HttpResponseMessage Put(FormDataCollection form) { var key = Convert.ToInt32(form.Get("key")); var values = form.Get("values"); var groupId = form.Get("groupId"); var employee = new Employee(); JsonConvert.PopulateObject(values, employee); employee.ID = key; Validate(employee); if(!ModelState.IsValid) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState.GetFullErrorMessage()); } // db.SaveChanges(); hubContext.Clients.Group(groupId).update(key, employee); return Request.CreateResponse(employee); } [HttpDelete] public void Delete(FormDataCollection form) { var key = Convert.ToInt32(form.Get("key")); var groupId = form.Get("groupId"); // var employee = db.Employees.First(a => a.ID == key); // db.Employees.Remove(employee); // db.SaveChanges(); hubContext.Clients.Group(groupId).remove(key); } } }
using Microsoft.AspNet.SignalR; using System.Threading.Tasks; namespace DevExtreme.MVC.Demos.Hubs { public class DataGridCollaborativeEditingHub : Hub { public override Task OnConnected() { var groupId = Context.QueryString["GroupId"]; Groups.Add(Context.ConnectionId, groupId); return base.OnConnected(); } } }