Repository: gregnb/mui-datatables
Pull request #827 (2019-08-11 04:19:31)

Added support for Shift + Click selection of rows
An implementation of the feature suggested in https://github.com/gregnb/mui-datatables/issues/663

The UX for shift+click is modeled off of how GMail does it. For example, if you select/unselect row A and then hold shift and select/unselect row B:

- If row B is being selected, then the rows between A and B (including A) will be selected.
- If row B is being unselected, then the rows between A and B (including A) will be unselected. 

If any filters are removed or columns resorted or selection headers toggled or data fetched (in the case of Server Side tables), between the selection of rows A and B, then there will be no special effect.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/25105092/badge)](https://coveralls.io/builds/25105092)

Coverage decreased (-1.3%) to 74.024% when pulling **272a03281bcc8f58ca0cfa1c342116706385f747 on patorjk:shift-click** into **a5ab0daf36e28394b69e0831d15b5f87bed8a407 on gregnb:master**.

patorjk: This PR is most likely not compatible with https://github.com/gregnb/mui-datatables/pull/825, however, I could adjust it so that in the MUIDataTable.js file, when it was in the process of adding the adjacent rows, it would stop if it hit the maxSelectedRows threshold.
gabrielliwerant: This is a cool feature, thank you for working on this!

I just reviewed and suggested a number of changes to https://github.com/gregnb/mui-datatables/pull/825, so how about we wait for that to be merged and integrated with yours before review?
patorjk: Sounds good, I’ll be unavailable next week but could make any adjustments tomorrow or the week after that.
gabrielliwerant: @patorjk The `isRowSelectable` changes are in latest master now, if you want to pull those.
patorjk: Sounds good, I’ll pull them and update this PR early next week!

Patch:
diff --git a/src/MUIDataTable.js b/src/MUIDataTable.js
index 1bce47c..28ab4a4 100644
--- a/src/MUIDataTable.js
+++ b/src/MUIDataTable.js
@@ -191,6 +191,7 @@ class MUIDataTable extends React.Component {
       data: [],
       lookup: {},
     },
+    previousSelectedRow: null,
     expandedRows: {
       data: [],
       lookup: {},
@@ -580,6 +581,7 @@ class MUIDataTable extends React.Component {
       const sortedData = this.sortTable(tableData, sortIndex, sortDirection);
       tableData = sortedData.data;
     }
+
     /* set source data and display Data set source set */
     this.setState(
       prevState => ({
@@ -592,6 +594,7 @@ class MUIDataTable extends React.Component {
         count: this.options.count,
         data: tableData,
         displayData: this.getDisplayData(columns, tableData, filterList, searchText),
+        previousSelectedRow: null,
       }),
       callback,
     );
@@ -833,6 +836,7 @@ class MUIDataTable extends React.Component {
             data: sortedData.data,
             displayData: this.getDisplayData(columns, sortedData.data, prevState.filterList, prevState.searchText),
             selectedRows: sortedData.selectedRows,
+            previousSelectedRow: null,
           };
         }
 
@@ -948,6 +952,7 @@ class MUIDataTable extends React.Component {
           displayData: this.options.serverSide
             ? prevState.displayData
             : this.getDisplayData(prevState.columns, prevState.data, filterList, prevState.searchText),
+          previousSelectedRow: null,
         };
       },
       () => {
@@ -1015,7 +1020,7 @@ class MUIDataTable extends React.Component {
     );
   };
 
-  selectRowUpdate = (type, value) => {
+  selectRowUpdate = (type, value, shiftAdjacentRows = []) => {
     // safety check
     const { selectableRows } = this.options;
     if (selectableRows === 'none') {
@@ -1053,6 +1058,7 @@ class MUIDataTable extends React.Component {
               data: newRows,
               lookup: selectedMap,
             },
+            previousSelectedRow: null,
           };
         },
         () => {
@@ -1078,11 +1084,31 @@ class MUIDataTable extends React.Component {
 
           if (rowPos >= 0) {
             selectedRows.splice(rowPos, 1);
+
+            // handle rows affected by shift+click
+            if (shiftAdjacentRows.length > 0) {
+              let shiftAdjacentMap = buildMap(shiftAdjacentRows);
+              for (let cIndex = selectedRows.length - 1; cIndex >= 0; cIndex--) {
+                if (shiftAdjacentMap[selectedRows[cIndex].dataIndex]) {
+                  selectedRows.splice(cIndex, 1);
+                }
+              }
+            }
           } else if (selectableRows === 'single') {
             selectedRows = [value];
           } else {
             // multiple
             selectedRows.push(value);
+
+            // handle rows affected by shift+click
+            if (shiftAdjacentRows.length > 0) {
+              let selectedMap = buildMap(selectedRows);
+              shiftAdjacentRows.forEach(aRow => {
+                if (!selectedMap[aRow.dataIndex]) {
+                  selectedRows.push(aRow);
+                }
+              });
+            }
           }
 
           return {
@@ -1090,6 +1116,7 @@ class MUIDataTable extends React.Component {
               lookup: buildMap(selectedRows),
               data: selectedRows,
             },
+            previousSelectedRow: value,
           };
         },
         () => {
@@ -1108,6 +1135,7 @@ class MUIDataTable extends React.Component {
       this.setState(
         {
           selectedRows: { data, lookup },
+          previousSelectedRow: null,
         },
         () => {
           this.setTableAction('rowsSelect');
@@ -1171,6 +1199,7 @@ class MUIDataTable extends React.Component {
       filterData,
       filterList,
       selectedRows,
+      previousSelectedRow,
       expandedRows,
       searchText,
     } = this.state;
@@ -1257,6 +1286,7 @@ class MUIDataTable extends React.Component {
               rowsPerPage={rowsPerPage}
               selectedRows={selectedRows}
               selectRowUpdate={this.selectRowUpdate}
+              previousSelectedRow={previousSelectedRow}
               expandedRows={expandedRows}
               toggleExpandRow={this.toggleExpandRow}
               options={this.options}
diff --git a/src/components/TableBody.js b/src/components/TableBody.js
index dedb641..a366e8b 100644
--- a/src/components/TableBody.js
+++ b/src/components/TableBody.js
@@ -6,6 +6,7 @@ import TableBodyCell from './TableBodyCell';
 import TableBodyRow from './TableBodyRow';
 import TableSelectCell from './TableSelectCell';
 import { withStyles } from '@material-ui/core/styles';
+import cloneDeep from 'lodash.clonedeep';
 
 const defaultBodyStyles = {
   root: {},
@@ -32,6 +33,8 @@ class TableBody extends React.Component {
     selectedRows: PropTypes.object,
     /** Callback to trigger table row select */
     selectRowUpdate: PropTypes.func,
+    /** The most recent row to have been selected/unselected */
+    previousSelectedRow: PropTypes.object,
     /** Data used to search table against */
     searchText: PropTypes.string,
     /** Toggle row expandable */
@@ -86,8 +89,10 @@ class TableBody extends React.Component {
     return expandedRows.lookup && expandedRows.lookup[dataIndex] ? true : false;
   }
 
-  isRowSelectable(dataIndex) {
-    const { options, selectedRows } = this.props;
+  isRowSelectable(dataIndex, selectedRows) {
+    const { options } = this.props;
+    selectedRows = selectedRows || this.props.selectedRows;
+
     if (options.isRowSelectable) {
       return options.isRowSelectable(dataIndex, selectedRows);
     } else {
@@ -95,12 +100,54 @@ class TableBody extends React.Component {
     }
   }
 
-  handleRowSelect = data => {
-    this.props.selectRowUpdate('cell', data);
+  handleRowSelect = (data, event) => {
+    let shiftKey = event && event.nativeEvent ? event.nativeEvent.shiftKey : false;
+    let shiftAdjacentRows = [];
+    let previousSelectedRow = this.props.previousSelectedRow;
+
+    // If the user is pressing shift and has previously clicked another row.
+    if (shiftKey && previousSelectedRow && previousSelectedRow.index < this.props.data.length) {
+      let curIndex = previousSelectedRow.index;
+
+      // Create a copy of the selectedRows object. This will be used and modified
+      // below when we see if we can add adjacent rows.
+      let selectedRows = cloneDeep(this.props.selectedRows);
+
+      // Add the clicked on row to our copy of selectedRows (if it isn't already present).
+      let clickedDataIndex = this.props.data[data.index].dataIndex;
+      if (selectedRows.data.filter(d => d.dataIndex === clickedDataIndex).length === 0) {
+        selectedRows.data.push({
+          index: data.index,
+          dataIndex: clickedDataIndex
+        });
+        selectedRows.lookup[clickedDataIndex] = true;
+      }
+
+      while (curIndex !== data.index) {
+        let dataIndex = this.props.data[curIndex].dataIndex;
+
+        if (this.isRowSelectable(dataIndex, selectedRows)) {
+          let lookup = {
+            index: curIndex,
+            dataIndex: dataIndex,
+          };
+          
+          // Add adjacent row to temp selectedRow object if it isn't present.
+          if (selectedRows.data.filter(d => d.dataIndex === dataIndex).length === 0) {
+            selectedRows.data.push(lookup);
+            selectedRows.lookup[dataIndex] = true;
+          }
+
+          shiftAdjacentRows.push(lookup);
+        }
+        curIndex = data.index > curIndex ? curIndex + 1 : curIndex - 1;
+      }
+    }
+    this.props.selectRowUpdate('cell', data, shiftAdjacentRows);
   };
 
   handleRowClick = (row, data, event) => {
-    // Don't trigger onRowClick if the event was actually the expandable icon
+    // Don't trigger onRowClick if the event was actually the expandable icon.
     if (
       event.target.id === 'expandable-button' ||
       (event.target.nodeName === 'path' && event.target.parentNode.id === 'expandable-button')
@@ -127,7 +174,7 @@ class TableBody extends React.Component {
       this.isRowSelectable(data.dataIndex)
     ) {
       const selectRow = { index: data.rowIndex, dataIndex: data.dataIndex };
-      this.handleRowSelect(selectRow);
+      this.handleRowSelect(selectRow, event);
     }
     // Check if we should trigger row expand when row is clicked anywhere
     if (this.props.options.expandableRowsOnClick && this.props.options.expandableRows) {


Test patch:
diff --git a/test/MUIDataTable.test.js b/test/MUIDataTable.test.js
index e9e14f5..4699780 100644
--- a/test/MUIDataTable.test.js
+++ b/test/MUIDataTable.test.js
@@ -920,6 +920,39 @@ describe('<MUIDataTable />', function() {
     assert.deepEqual(state.selectedRows.data, expectedResult);
   });
 
+  it('should allow multiple cells to be selected when selectableRows=multiple and selectRowUpdate method with type=cell and there are adjacent rows.', () => {
+    const options = { selectableRows: 'multiple' };
+    const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} options={options} />).dive();
+    const instance = shallowWrapper.instance();
+
+    instance.selectRowUpdate('cell', { index: 0, dataIndex: 0 }, [
+      { index: 0, dataIndex: 0 },
+      { index: 1, dataIndex: 1 },
+    ]);
+    shallowWrapper.update();
+
+    const expectedResult = [{ index: 0, dataIndex: 0 }, { index: 1, dataIndex: 1 }];
+    const state = shallowWrapper.state();
+    assert.deepEqual(state.selectedRows.data, expectedResult);
+  });
+
+  it('should allow multiple cells to be selected and then unselected when selectableRows=multiple and selectRowUpdate method with type=cell and there are adjacent rows.', () => {
+    const options = { selectableRows: 'multiple' };
+    const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} options={options} />).dive();
+    const instance = shallowWrapper.instance();
+
+    instance.selectRowUpdate('cell', { index: 0, dataIndex: 0 }, [
+      { index: 1, dataIndex: 1 },
+      { index: 2, dataIndex: 2 },
+    ]);
+    instance.selectRowUpdate('cell', { index: 1, dataIndex: 1 }, [{ index: 0, dataIndex: 0 }]);
+    shallowWrapper.update();
+
+    const expectedResult = [{ index: 2, dataIndex: 2 }];
+    const state = shallowWrapper.state();
+    assert.deepEqual(state.selectedRows.data, expectedResult);
+  });
+
   it('should not update selectedRows when using rowsSelected option with type=none', () => {
     const options = {
       selectableRows: 'none',
diff --git a/test/MUIDataTableBody.test.js b/test/MUIDataTableBody.test.js
index bb4613f..553cec8 100644
--- a/test/MUIDataTableBody.test.js
+++ b/test/MUIDataTableBody.test.js
@@ -202,6 +202,42 @@ describe('<TableBody />', function() {
     assert.strictEqual(selectRowUpdate.callCount, 1);
   });
 
+  it('should select the adjacent rows when a row is shift+clicked and a previous row has been selected.', () => {
+    let adjacentRows = [];
+    const options = { sort: true, selectableRows: true, selectableRowsOnClick: true };
+    const previousSelectedRow = { index: 0, dataIndex: 0 };
+    const selectRowUpdate = (type, data, adjacent) => {
+      adjacentRows = adjacent;
+    };
+    const selectedRows = {data: [], lookup: {}};
+    const toggleExpandRow = () => {};
+
+    const mountWrapper = mount(
+      <TableBody
+        data={displayData}
+        count={displayData.length}
+        columns={columns}
+        page={0}
+        rowsPerPage={10}
+        selectedRows={selectedRows}
+        selectRowUpdate={selectRowUpdate}
+        previousSelectedRow={previousSelectedRow}
+        expandedRows={[]}
+        toggleExpandRow={toggleExpandRow}
+        options={options}
+        searchText={''}
+        filterList={[]}
+      />,
+    );
+
+    mountWrapper
+      .find('#MUIDataTableBodyRow-3')
+      .first()
+      .simulate('click', { nativeEvent: { shiftKey: true } });
+
+    assert.strictEqual(adjacentRows.length, 3);
+  });
+
   it('should gather selected row data when clicking row with selectableRowsOnClick=true.', () => {
     let selectedRowData;
     const options = { selectableRows: true, selectableRowsOnClick: true };