Repository: gregnb/mui-datatables
Pull request #860 (2019-08-23 23:43:27)

Improve documentation and plumbing for rows selection
Minor improvements to documentation, prop types, and callback.

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

Coverage remained the same at 75.223% when pulling **0a69d9766fbe500d82113204fb4a7a481b90ca72 on enhancement/minor-row-selection-cleanup** into **d1914e4156aea4ff7d6d8ad9794da3ca4b156758 on master**.


Patch:
diff --git a/README.md b/README.md
index f3a7aa9..26c75e9 100644
--- a/README.md
+++ b/README.md
@@ -142,7 +142,7 @@ The component accepts the following props:
 |**`pagination`**|boolean|true|Enable/disable pagination
 |**`selectableRows`**|string|'multiple'|Numbers of rows that can be selected. Options are "multiple", "single", "none".
 |**`selectableRowsOnClick`**|boolean|false|Enable/disable select toggle when row is clicked. When False, only checkbox will trigger this action.
-|**`isRowSelectable`**|function||Enable/disable selection on certain rows with custom function. Returns true if not provided. `function(dataIndex: number, selectedRows: object(lookup: {dataindex: boolean}, data: arrayOfObjects: {index, dataIndex})) => bool`.
+|**`isRowSelectable`**|function||Enable/disable selection on certain rows with custom function. Returns true if not provided. `function(dataIndex: number, selectedRows: object(lookup: {[dataIndex]: boolean}, data: arrayOfObjects: {index: number, dataIndex: number})) => boolean`.
 |**`expandableRows`**|boolean|false|Enable/disable expandable rows
 |**`expandableRowsOnClick`**|boolean|false|Enable/disable expand trigger when row is clicked. When False, only expand icon will trigger this action.
 |**`renderExpandableRow`**|function||Render expandable row. `function(rowData, rowMeta) => React Component`
@@ -166,13 +166,14 @@ The component accepts the following props:
 |**`filter`**|boolean|true|Show/hide filter icon from toolbar
 |**`search`**|boolean|true|Show/hide search icon from toolbar
 |**`searchText`**|string||Initial search text
+|**`searchPlaceholder`**|string||Search text placeholder. [Example](https://github.com/gregnb/mui-datatables/blob/master/examples/customize-search/index.js)
 |**`print`**|boolean|true|Show/hide print	 icon from toolbar
 |**`download`**|boolean|true|Show/hide download icon from toolbar
 |**`downloadOptions`**|object|`{filename: 'tableDownload.csv', separator: ','}`|Options to change the output of the CSV file: `filename`: string, `separator`: string, `filterOptions`: object(`useDisplayedColumnsOnly`: boolean, `useDisplayedRowsOnly`: boolean)
 |**`onDownload`**|function||A callback function that triggers when the user downloads the CSV file. In the callback, you can control what is written to the CSV file. `function(buildHead: (columns) => string, buildBody: (data) => string, columns, data) => string`. Return `false` to cancel download of file.
 |**`viewColumns`**|boolean|true|Show/hide viewColumns icon from toolbar
 |**`onRowsSelect`**|function||Callback function that triggers when row(s) are selected. `function(currentRowsSelected: array, allRowsSelected: array) => void`
-|**`onRowsDelete`**|function||Callback function that triggers when row(s) are deleted. `function(rowsDeleted: object(lookup: {dataindex: boolean}, data: arrayOfObjects: {index, dataIndex})) => void OR false` (Returning `false` prevents row deletion.)
+|**`onRowsDelete`**|function||Callback function that triggers when row(s) are deleted. `function(rowsDeleted: object(lookup: {[dataIndex]: boolean}, data: arrayOfObjects: {index: number, dataIndex: number})) => void OR false` (Returning `false` prevents row deletion.)
 |**`onRowClick`**|function||Callback function that triggers when a row is clicked. `function(rowData: string[], rowMeta: { dataIndex: number, rowIndex: number }) => void`
 |**`onCellClick`**|function||Callback function that triggers when a cell is clicked. `function(colData: any, cellMeta: { colIndex: number, rowIndex: number, dataIndex: number }) => void`
 |**`onChangePage`**|function||Callback function that triggers when a page has changed. `function(currentPage: number) => void`
@@ -238,17 +239,17 @@ const columns = [
 function(columnMeta: {
   customHeadRender: func,
   display: enum('true', 'false', 'excluded'),
-  filter: bool,
-  sort: bool,
-  sortDirection: bool,
-  download: bool,
-  empty: bool,
+  filter: boolean,
+  sort: boolean,
+  sortDirection: boolean,
+  download: boolean,
+  empty: boolean,
   index: number,
   label: string,
   name: string,
-  print: bool,
-  searchable: bool,
-  viewColumns: bool
+  print: boolean,
+  searchable: boolean,
+  viewColumns: boolean
 }, handleToggleColumn: function(columnIndex))
 ```
 
diff --git a/examples/customize-search/index.js b/examples/customize-search/index.js
index 8ff1701..a958358 100644
--- a/examples/customize-search/index.js
+++ b/examples/customize-search/index.js
@@ -83,6 +83,7 @@ class Example extends React.Component {
       responsive: 'stacked',
       page: 1,
       searchText: this.state.searchText,
+      searchPlaceholder: 'Your Custom Search Placeholder',
       customSearch: (searchQuery, currentRow, columns) => {
         let isFound = false;
         currentRow.forEach(col => {
diff --git a/src/MUIDataTable.js b/src/MUIDataTable.js
index 1bce47c..ef99335 100644
--- a/src/MUIDataTable.js
+++ b/src/MUIDataTable.js
@@ -130,6 +130,7 @@ class MUIDataTable extends React.Component {
       customSearchRender: PropTypes.oneOfType([PropTypes.func, PropTypes.element]),
       customRowRender: PropTypes.func,
       onRowClick: PropTypes.func,
+      onRowsSelect: PropTypes.func,
       resizableColumns: PropTypes.bool,
       selectableRows: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['none', 'single', 'multiple'])]),
       selectableRowsOnClick: PropTypes.bool,
@@ -152,6 +153,7 @@ class MUIDataTable extends React.Component {
       customSearch: PropTypes.func,
       search: PropTypes.bool,
       searchText: PropTypes.string,
+      searchPlaceholder: PropTypes.string,
       print: PropTypes.bool,
       viewColumns: PropTypes.bool,
       download: PropTypes.bool,
@@ -191,6 +193,7 @@ class MUIDataTable extends React.Component {
       data: [],
       lookup: {},
     },
+    previousSelectedRow: null,
     expandedRows: {
       data: [],
       lookup: {},
@@ -580,6 +583,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 +596,7 @@ class MUIDataTable extends React.Component {
         count: this.options.count,
         data: tableData,
         displayData: this.getDisplayData(columns, tableData, filterList, searchText),
+        previousSelectedRow: null,
       }),
       callback,
     );
@@ -833,6 +838,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 +954,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 +1022,7 @@ class MUIDataTable extends React.Component {
     );
   };
 
-  selectRowUpdate = (type, value) => {
+  selectRowUpdate = (type, value, shiftAdjacentRows = []) => {
     // safety check
     const { selectableRows } = this.options;
     if (selectableRows === 'none') {
@@ -1053,6 +1060,7 @@ class MUIDataTable extends React.Component {
               data: newRows,
               lookup: selectedMap,
             },
+            previousSelectedRow: null,
           };
         },
         () => {
@@ -1078,11 +1086,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 +1118,7 @@ class MUIDataTable extends React.Component {
               lookup: buildMap(selectedRows),
               data: selectedRows,
             },
+            previousSelectedRow: value,
           };
         },
         () => {
@@ -1108,6 +1137,7 @@ class MUIDataTable extends React.Component {
       this.setState(
         {
           selectedRows: { data, lookup },
+          previousSelectedRow: null,
         },
         () => {
           this.setTableAction('rowsSelect');
@@ -1171,6 +1201,7 @@ class MUIDataTable extends React.Component {
       filterData,
       filterList,
       selectedRows,
+      previousSelectedRow,
       expandedRows,
       searchText,
     } = this.state;
@@ -1257,6 +1288,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..b4a1a98 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')
@@ -124,10 +171,10 @@ class TableBody extends React.Component {
     if (
       this.props.options.selectableRowsOnClick &&
       this.props.options.selectableRows !== 'none' &&
-      this.isRowSelectable(data.dataIndex)
+      this.isRowSelectable(data.dataIndex, this.props.selectedRows)
     ) {
       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) {
diff --git a/src/components/TableHeadCell.js b/src/components/TableHeadCell.js
index a5aafba..2e3a2f5 100644
--- a/src/components/TableHeadCell.js
+++ b/src/components/TableHeadCell.js
@@ -75,6 +75,15 @@ class TableHeadCell extends React.Component {
     isHintTooltipOpen: false,
   };
 
+  handleKeyboardSortinput = (e) => {
+    if (e.key === 'Enter')
+    {
+      this.props.toggleSort(this.props.index);
+    }
+
+    return false;
+  }
+
   handleSortClick = () => {
     this.props.toggleSort(this.props.index);
   };
@@ -117,7 +126,7 @@ class TableHeadCell extends React.Component {
             onClose={() => this.setState({ isSortTooltipOpen: false })}>
             <span
               role="button"
-              onKeyUp={this.handleClickSort}
+              onKeyUp={this.handleKeyboardSortinput}
               onClick={this.handleSortClick}
               className={classes.toolButton}
               tabIndex={0}>
diff --git a/src/components/TableSearch.js b/src/components/TableSearch.js
index 9eec1d1..1d88da3 100644
--- a/src/components/TableSearch.js
+++ b/src/components/TableSearch.js
@@ -69,6 +69,7 @@ class TableSearch extends React.Component {
             onChange={this.handleTextChange}
             fullWidth={true}
             inputRef={el => (this.searchField = el)}
+            placeholder={options.searchPlaceholder}
           />
           <IconButton className={classes.clearIcon} onClick={onHide}>
             <ClearIcon />
diff --git a/src/components/TableToolbar.js b/src/components/TableToolbar.js
index c25125c..6711eca 100644
--- a/src/components/TableToolbar.js
+++ b/src/components/TableToolbar.js
@@ -248,11 +248,16 @@ class TableToolbar extends React.Component {
             <span>
               <ReactToPrint
                 trigger={() => (
-                  <Tooltip title={print}>
-                    <IconButton data-testid={print + '-iconButton'} aria-label={print} classes={{ root: classes.icon }}>
-                      <PrintIcon />
-                    </IconButton>
-                  </Tooltip>
+                  <span>
+                    <Tooltip title={print}>
+                      <IconButton
+                        data-testid={print + '-iconButton'}
+                        aria-label={print}
+                        classes={{ root: classes.icon }}>
+                        <PrintIcon />
+                      </IconButton>
+                    </Tooltip>
+                  </span>
                 )}
                 content={() => this.props.tableRef()}
               />
diff --git a/src/components/TableToolbarSelect.js b/src/components/TableToolbarSelect.js
index 05bd1bb..7f1bbe4 100644
--- a/src/components/TableToolbarSelect.js
+++ b/src/components/TableToolbarSelect.js
@@ -16,8 +16,8 @@ const defaultToolbarSelectStyles = theme => ({
     zIndex: 120,
     justifyContent: 'space-between',
     alignItems: 'center',
-    paddingTop: theme.spacing.unit,
-    paddingBottom: theme.spacing.unit,
+    paddingTop: typeof theme.spacing === 'function' ? theme.spacing(1) : theme.spacing.unit,
+    paddingBottom: typeof theme.spacing === 'function' ? theme.spacing(1) : theme.spacing.unit,
   },
   title: {
     paddingLeft: '26px',
diff --git a/webpack.config.js b/webpack.config.js
index b98ca64..e507b2a 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -3,7 +3,7 @@ const webpack = require('webpack');
 
 module.exports = {
   entry: {
-    app: "./examples/customize-filter/index.js"
+    app: "./examples/customize-search/index.js"
   },
   stats: "verbose",
   context: __dirname,


Test patch:
diff --git a/test/MUIDataTable.test.js b/test/MUIDataTable.test.js
index e9e14f5..27bb4fd 100644
--- a/test/MUIDataTable.test.js
+++ b/test/MUIDataTable.test.js
@@ -75,12 +75,12 @@ describe('<MUIDataTable />', function() {
 
   it('should render a table', () => {
     const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} />);
-    assert.strictEqual(
+    assert.include(
+      ['Paper', 'ForwardRef(Paper)'],
       shallowWrapper
         .dive()
         .dive()
         .name(),
-      'Paper',
     );
   });
 
@@ -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 };
diff --git a/test/MUIDataTableSearch.test.js b/test/MUIDataTableSearch.test.js
index 0d1f38e..9c9ad6d 100644
--- a/test/MUIDataTableSearch.test.js
+++ b/test/MUIDataTableSearch.test.js
@@ -45,6 +45,19 @@ describe('<TableSearch />', function() {
     assert.strictEqual(actualResult.find(TextField).props().value, 'nextText');
   });
 
+  it('should render a search bar with placeholder when searchPlaceholder is set', () => {
+    const options = { textLabels, searchPlaceholder: 'TestingPlaceholder' };
+    const onSearch = () => {};
+    const onHide = () => {};
+
+    const mountWrapper = mount(
+        <TableSearch onSearch={onSearch} onHide={onHide} options={options} />,
+    );
+    const actualResult = mountWrapper.find(TextField);
+    assert.strictEqual(actualResult.length, 1);
+    assert.strictEqual(actualResult.props().placeholder, 'TestingPlaceholder');
+  });
+
   it('should trigger handleTextChange prop callback when calling method handleTextChange', () => {
     const options = { onSearchChange: () => true, textLabels };
     const onSearch = spy();
diff --git a/test/setup-mocha-env.js b/test/setup-mocha-env.js
index c56683a..4cf7850 100644
--- a/test/setup-mocha-env.js
+++ b/test/setup-mocha-env.js
@@ -54,6 +54,8 @@ function setupDom() {
 
   global.window.cancelAnimationFrame = () => {};
   global.getComputedStyle = global.window.getComputedStyle;
+  global.HTMLInputElement = global.window.HTMLInputElement;
+  global.Element = global.window.Element;
 
   Object.defineProperty(global.window.URL, 'createObjectURL', { value: () => {} });
   global.Blob = () => '';