Repository: gregnb/mui-datatables
Pull request #501 (2019-03-19 12:22:54)

Added options for data manipulation onDownload of CSV file
I've added the possibility to add custom header and footer names to the csv output. I needed this for a project and hopefully it's useful to someone else.

If this gets merged I'll add the types to the DefinitelyTyped repo.

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

Coverage increased (+0.02%) to 72.469% when pulling **90fb7dee32e2ef854e93547bf408d9f568be5826 on mellson:master** into **3eab7adb1caa07996b7cd554a6f331cc8162b368 on gregnb:master**.

mellson: Hi @gregnb 👋Thanks for the great work on this library! If you get an available moment I would love some feedback on this PR, hopefully it's useable for others than myself.
gregnb: @mellson Thanks for submitting the PR. Just got back from a long vacation. Allow me some time to review this and see if I have any changes. 
mellson: Hi @gregnb hope you've landed on your feet again. Have you had some time to look through this one yet? (sorry if I'm too impatient)
gabrielliwerant: @mellson Hello, I took a look at your PR, thanks for your work!

However, I just want to make sure I understand the use case here. Excel-style files have a concept of headers/footers that are part of meta-data, but as I understand it, csv files do not. So what is created here is another means of modifying the column labels for export only, and an additional row of different column names as the final data row:

![image](https://user-images.githubusercontent.com/1779564/57093373-aeee4800-6cdb-11e9-9fcf-13d0f27e216c.png)

So, I think it's possible that calling them `header` and `footer` could lead to confusion as they don't appear to serve the same role that they would in a typical spreadsheet, so maybe different terminology is in order.

Also, can you explain the use case for having a bottom row of different column names?

My concern would be that we might be adding specific functionality here that has a very narrow use case. Another option would be instead to provide an `onDownload` callback which would allow customization of output before downloading. This would have the advantage of not specifying very specific sets of features which are the only avenues for customization, but rather allow the full range of use cases for anyone. What do you think @gregnb?
mellson: @gabrielliwerant thanks for the great feedback 👏 

I think you are on point with all your comments.
I've added this because we have an integration with another system which requires us to emulate headers and footers in this way - e.g. a very specific and narrow use case as you mention.

So maybe the `onDownload` callback option would indeed be a better approach. Do you have any further thoughts around that or should I just try to implement my own thoughts of how that would work?
gabrielliwerant: @mellson Thank you so much for graciously accepting the feedback!

Greg will have final say, so I'm 100% sure what he would accept, but if it were up to me, I would accept something like `onDownload(buildHead, buildBody)` where `buildHead` and `buildBody` are the respective functions for creating those parts of the csv and that accept `columns` and `data` as their arguments. `onDownload` would probably need to be called just before the final csv data is converted to a Blob, and should return a csv of the same format if `onDownload` is truthy.

Might need to also pass `columns` and `data` along from the util function into the callback. I like parsimonious functions wherever possible, but including `columns` and `data` as part of the callback arguments ensures that any modifications made to them elsewhere can be utilized by the invoker of the function in cases where they were not managed directly via state when passed into the table.

Also, just a reminder that the PR has fallen behind, so will need latest from master merged in. Sorry for the inconvenience.
mellson: @gabrielliwerant thank you for the input, I will have a look at it in the coming days and update this PR when I have an initial implementation for `onDownload`

Patch:
diff --git a/README.md b/README.md
index 05aab14..a974c1b 100644
--- a/README.md
+++ b/README.md
@@ -163,9 +163,10 @@ The component accepts the following props:
 |**`print`**|boolean|true|Show/hide print	 icon from toolbar
 |**`download`**|boolean|true|Show/hide download icon from toolbar
 |**`downloadOptions`**|object||Options to change the output of the CSV file. Default options: `{filename: 'tableDownload.csv', separator: ','}`
+|**`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`
 |**`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: 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.)
 |**`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`
@@ -363,10 +364,22 @@ const options = {
 }
 ```
 
+## Contributing
+Thanks for taking an interest in the library and the github community!
+
+The following commands should get you started:
+
+```sh
+npm i
+npm run dev
+```
+open  http://localhost:5050/ in browser
+
+After you make your changes locally, you can run the test suite with `npm test`. 
+
 ## License
 The files included in this repository are licensed under the MIT license.
 
-
 ## Thanks
 
 [<img src="https://www.browserstack.com/images/mail/browserstack-logo-footer.png" width="120">](https://www.browserstack.com/)
diff --git a/examples/on-download/index.js b/examples/on-download/index.js
new file mode 100644
index 0000000..e93ca16
--- /dev/null
+++ b/examples/on-download/index.js
@@ -0,0 +1,121 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import MUIDataTable from '../../src';
+
+class Example extends React.Component {
+  render() {
+    const columns = [
+      {
+        name: 'Name',
+        options: {
+          filter: true,
+          display: 'excluded',
+        },
+      },
+      {
+        label: 'Modified Title Label',
+        name: 'Title',
+        options: {
+          filter: true,
+        },
+      },
+      {
+        name: 'Location',
+        options: {
+          print: false,
+          filter: false,
+        },
+      },
+      {
+        name: 'Age',
+        options: {
+          filter: true,
+          print: false,
+        },
+      },
+      {
+        name: 'Salary',
+        options: {
+          filter: true,
+          sort: false,
+        },
+      },
+    ];
+
+    const data = [
+      ['Gabby George', 'Business Analyst', 'Minneapolis', 30, '$100,000'],
+      ['Aiden Lloyd', 'Business Consultant', 'Dallas', 55, '$200,000'],
+      ['Jaden Collins', 'Attorney', 'Santa Ana', 27, '$500,000'],
+      ['Franky Rees', 'Business Analyst', 'St. Petersburg', 22, '$50,000'],
+      ['Aaren Rose', 'Business Consultant', 'Toledo', 28, '$75,000'],
+      ['Blake Duncan', 'Business Management Analyst', 'San Diego', 65, '$94,000'],
+      ['Frankie Parry', 'Agency Legal Counsel', 'Jacksonville', 71, '$210,000'],
+      ['Lane Wilson', 'Commercial Specialist', 'Omaha', 19, '$65,000'],
+      ['Robin Duncan', 'Business Analyst', 'Los Angeles', 20, '$77,000'],
+      ['Mel Brooks', 'Business Consultant', 'Oklahoma City', 37, '$135,000'],
+      ['Harper White', 'Attorney', 'Pittsburgh', 52, '$420,000'],
+      ['Kris Humphrey', 'Agency Legal Counsel', 'Laredo', 30, '$150,000'],
+      ['Frankie Long', 'Industrial Analyst', 'Austin', 31, '$170,000'],
+      ['Brynn Robbins', 'Business Analyst', 'Norfolk', 22, '$90,000'],
+      ['Justice Mann', 'Business Consultant', 'Chicago', 24, '$133,000'],
+      ['Addison Navarro', 'Business Management Analyst', 'New York', 50, '$295,000'],
+      ['Jesse Welch', 'Agency Legal Counsel', 'Seattle', 28, '$200,000'],
+      ['Eli Mejia', 'Commercial Specialist', 'Long Beach', 65, '$400,000'],
+      ['Gene Leblanc', 'Industrial Analyst', 'Hartford', 34, '$110,000'],
+      ['Danny Leon', 'Computer Scientist', 'Newark', 60, '$220,000'],
+      ['Lane Lee', 'Corporate Counselor', 'Cincinnati', 52, '$180,000'],
+      ['Jesse Hall', 'Business Analyst', 'Baltimore', 44, '$99,000'],
+      ['Danni Hudson', 'Agency Legal Counsel', 'Tampa', 37, '$90,000'],
+      ['Terry Macdonald', 'Commercial Specialist', 'Miami', 39, '$140,000'],
+      ['Justice Mccarthy', 'Attorney', 'Tucson', 26, '$330,000'],
+      ['Silver Carey', 'Computer Scientist', 'Memphis', 47, '$250,000'],
+      ['Franky Miles', 'Industrial Analyst', 'Buffalo', 49, '$190,000'],
+      ['Glen Nixon', 'Corporate Counselor', 'Arlington', 44, '$80,000'],
+      ['Gabby Strickland', 'Business Process Consultant', 'Scottsdale', 26, '$45,000'],
+      ['Mason Ray', 'Computer Scientist', 'San Francisco', 39, '$142,000'],
+    ];
+
+    // Data for building a custom head and body with the onDownload option
+    const headerNames = [
+      {
+        name: 'Given name',
+        download: true,
+      },
+      {
+        name: 'Role',
+        download: true,
+      },
+      {
+        name: 'City',
+        download: true,
+      },
+      {
+        name: 'Years',
+        download: true,
+      },
+      {
+        name: 'Dough',
+        download: true,
+      },
+    ];
+    const footerNames = ['Full Name', 'Job', 'Whereabouts', 'Age', 'Allowance'];
+
+    const options = {
+      filter: true,
+      filterType: 'dropdown',
+      responsive: 'scroll',
+      onDownload: (buildHead, buildBody, columns, data) =>
+        buildHead(headerNames) +
+        buildBody(
+          data.concat({
+            index: data.length,
+            data: footerNames,
+          }),
+        ),
+    };
+
+    return <MUIDataTable title={'ACME Employee list'} data={data} columns={columns} options={options} />;
+  }
+}
+
+ReactDOM.render(<Example />, document.getElementById('app-root'));
diff --git a/examples/selectable-rows/index.js b/examples/selectable-rows/index.js
index a08b586..9860ba9 100644
--- a/examples/selectable-rows/index.js
+++ b/examples/selectable-rows/index.js
@@ -57,6 +57,10 @@ class Example extends React.Component {
         console.log(rowsSelected, allRows);
       },
       onRowsDelete: (rowsDeleted) => {
+        if (rowsDeleted.data[0].dataIndex === 0) {
+          window.alert('Can\'t delete this!');
+          return false;
+        };
         console.log(rowsDeleted, "were deleted!");
       },
       onChangePage: (numberRows) => {
diff --git a/src/MUIDataTable.js b/src/MUIDataTable.js
index ba3dee8..5b45796 100644
--- a/src/MUIDataTable.js
+++ b/src/MUIDataTable.js
@@ -137,6 +137,7 @@ class MUIDataTable extends React.Component {
         filename: PropTypes.string,
         separator: PropTypes.string,
       }),
+      onDownload: PropTypes.func,
     }),
     /** Pass and use className to style MUIDataTable as desired */
     className: PropTypes.string,
@@ -190,7 +191,7 @@ class MUIDataTable extends React.Component {
 
   componentDidUpdate(prevProps) {
     if (this.props.data !== prevProps.data || this.props.columns !== prevProps.columns) {
-      this.setTableData(prevProps, TABLE_LOAD.INITIAL);
+      this.setTableData(this.props, TABLE_LOAD.INITIAL);
     }
 
     if (this.options.resizableColumns) {
@@ -831,6 +832,7 @@ class MUIDataTable extends React.Component {
     const cleanRows = data.filter(({ index }) => !selectedMap[index]);
 
     if (this.options.onRowsDelete) {
+      if (this.options.onRowsDelete(selectedRows) === false) return;
       this.options.onRowsDelete(selectedRows);
     }
 
diff --git a/src/components/TableToolbar.js b/src/components/TableToolbar.js
index 2e93d85..59c0829 100644
--- a/src/components/TableToolbar.js
+++ b/src/components/TableToolbar.js
@@ -199,31 +199,31 @@ class TableToolbar extends React.Component {
             </Tooltip>
           )}
           {options.print && (
-            <Tooltip title={print}>
-              <span>
-                <ReactToPrint
-                  trigger={() => (
+            <span>
+              <ReactToPrint
+                trigger={() => (
+                  <Tooltip title={print}>
                     <IconButton aria-label={print} classes={{ root: classes.icon }}>
                       <PrintIcon />
                     </IconButton>
-                  )}
-                  content={() => this.props.tableRef()}
-                />
-              </span>
-            </Tooltip>
+                  </Tooltip>
+                )}
+                content={() => this.props.tableRef()}
+              />
+            </span>
           )}
           {options.viewColumns && (
             <Popover
               refExit={this.setActiveIcon.bind(null)}
               trigger={
-                <IconButton
-                  aria-label={viewColumns}
-                  classes={{ root: this.getActiveIcon(classes, 'viewcolumns') }}
-                  onClick={this.setActiveIcon.bind(null, 'viewcolumns')}>
-                  <Tooltip title={viewColumns}>
+                <Tooltip title={viewColumns}>
+                  <IconButton
+                    aria-label={viewColumns}
+                    classes={{ root: this.getActiveIcon(classes, 'viewcolumns') }}
+                    onClick={this.setActiveIcon.bind(null, 'viewcolumns')}>
                     <ViewColumnIcon />
-                  </Tooltip>
-                </IconButton>
+                  </IconButton>
+                </Tooltip>
               }
               content={
                 <TableViewCol data={data} columns={columns} options={options} onColumnUpdate={toggleViewColumn} />
@@ -235,14 +235,14 @@ class TableToolbar extends React.Component {
               refExit={this.setActiveIcon.bind(null)}
               classes={{ paper: classes.filterPaper }}
               trigger={
-                <IconButton
-                  aria-label={filterTable}
-                  classes={{ root: this.getActiveIcon(classes, 'filter') }}
-                  onClick={this.setActiveIcon.bind(null, 'filter')}>
-                  <Tooltip title={filterTable}>
+                <Tooltip title={filterTable}>
+                  <IconButton
+                    aria-label={filterTable}
+                    classes={{ root: this.getActiveIcon(classes, 'filter') }}
+                    onClick={this.setActiveIcon.bind(null, 'filter')}>
                     <FilterIcon />
-                  </Tooltip>
-                </IconButton>
+                  </IconButton>
+                </Tooltip>
               }
               content={
                 <TableFilter
diff --git a/src/utils.js b/src/utils.js
index c85b0f7..dac9967 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -30,32 +30,41 @@ function createCSVDownload(columns, data, options) {
   const replaceDoubleQuoteInString = columnData =>
     typeof columnData === 'string' ? columnData.replace(/\"/g, '""') : columnData;
 
-  const CSVHead =
-    columns
+  const buildHead = columns => {
+    return (
+      columns
+        .reduce(
+          (soFar, column) =>
+            column.download
+              ? soFar + '"' + replaceDoubleQuoteInString(column.name) + '"' + options.downloadOptions.separator
+              : soFar,
+          '',
+        )
+        .slice(0, -1) + '\r\n'
+    );
+  };
+  const CSVHead = buildHead(columns);
+
+  const buildBody = data => {
+    return data
       .reduce(
-        (soFar, column) =>
-          column.download
-            ? soFar + '"' + replaceDoubleQuoteInString(column.name) + '"' + options.downloadOptions.separator
-            : soFar,
-        '',
+        (soFar, row) =>
+          soFar +
+          '"' +
+          row.data
+            .filter((_, index) => columns[index].download)
+            .map(columnData => replaceDoubleQuoteInString(columnData))
+            .join('"' + options.downloadOptions.separator + '"') +
+          '"\r\n',
+        [],
       )
-      .slice(0, -1) + '\r\n';
-
-  const CSVBody = data
-    .reduce(
-      (soFar, row) =>
-        soFar +
-        '"' +
-        row.data
-          .filter((field, index) => columns[index].download)
-          .map(columnData => replaceDoubleQuoteInString(columnData))
-          .join('"' + options.downloadOptions.separator + '"') +
-        '"\r\n',
-      [],
-    )
-    .trim();
+      .trim();
+  };
+  const CSVBody = buildBody(data);
 
-  const csv = `${CSVHead}${CSVBody}`;
+  const csv = options.onDownload
+    ? options.onDownload(buildHead, buildBody, columns, data)
+    : `${CSVHead}${CSVBody}`.trim();
   const blob = new Blob([csv], { type: 'text/csv' });
 
   /* taken from react-csv */
diff --git a/webpack.config.js b/webpack.config.js
index 43ad278..f3cd389 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -3,7 +3,7 @@ const webpack = require('webpack');
 
 module.exports = {
   entry: {
-    app: "./examples/custom-action-columns/index.js"
+    app: "./examples/selectable-rows/index.js"
   },
   stats: "verbose",
   context: __dirname,


Test patch:
diff --git a/test/MUIDataTable.test.js b/test/MUIDataTable.test.js
index 0bea639..a5241c3 100644
--- a/test/MUIDataTable.test.js
+++ b/test/MUIDataTable.test.js
@@ -170,6 +170,21 @@ describe('<MUIDataTable />', function() {
     assert.deepEqual(JSON.stringify(state.displayData), displayData);
   });
 
+  it('should correctly re-build display after xhr with serverSide=true', done => {
+    const fullWrapper = mount(<MUIDataTable columns={columns} data={[]} options={{ serverSide: true }} />);
+    assert.strictEqual(fullWrapper.find('tbody tr').length, 1);
+
+    // simulate xhr and test number of displayed rows
+    new Promise((resolve, reject) => {
+      setTimeout(() => {
+        fullWrapper.setProps({ data });
+        fullWrapper.update();
+        assert.strictEqual(fullWrapper.find('tbody tr').length, 4);
+        done();
+      }, 10);
+    });
+  });
+
   it('should correctly re-build internal table data and displayData structure with prop change', () => {
     const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} />);
     let state = shallowWrapper.dive().state();
@@ -679,6 +694,45 @@ describe('<MUIDataTable />', function() {
     assert.deepEqual(state.selectedRows.data, expectedResult);
   });
 
+  it('should remove selected data on selectRowDelete when type=cell', () => {
+    const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} />).dive();
+    const instance = shallowWrapper.instance();
+    const onRowsDelete = () => {};
+    const selectedRows = { data: [1] };
+
+    instance.selectRowUpdate('cell', { index: 0, dataIndex: 0 });
+    shallowWrapper.update();
+    instance.selectRowDelete()
+    shallowWrapper.update();
+
+    const newDisplayData = shallowWrapper.state().displayData;
+    const expectedResult = JSON.stringify([
+      { data: ['Walsh, John', 'Test Corp', renderCities('Hartford', { rowIndex: 0 }), null, undefined], dataIndex: 1, },
+      { data: ['Herm, Bob', 'Test Corp', renderCities('Tampa', { rowIndex: 1 }), 'FL', undefined], dataIndex: 2, },
+      { data: ['Houston, James', 'Test Corp', renderCities('Dallas', { rowIndex: 2 }), 'TX', undefined], dataIndex: 3, },
+    ]);
+
+    assert.deepEqual(JSON.stringify(newDisplayData), expectedResult);
+  });
+
+  it('should not remove selected data on selectRowDelete when type=cell when onRowsDelete returns false', () => {
+    const options = {
+      onRowsDelete: () => false
+    };
+    const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} options={options} />).dive();
+    const instance = shallowWrapper.instance();
+    const selectedRows = { data: [1] };
+
+    instance.selectRowUpdate('cell', { index: 0, dataIndex: 0 });
+    shallowWrapper.update();
+    instance.selectRowDelete()
+    shallowWrapper.update();
+
+    const myDisplayData = shallowWrapper.state().displayData;
+
+    assert.deepEqual(JSON.stringify(myDisplayData), displayData);
+  });
+
   it('should update value when calling updateValue method in customBodyRender', () => {
     const shallowWrapper = shallow(<MUIDataTable columns={columns} data={data} />).dive();
     const instance = shallowWrapper.instance();
diff --git a/test/MUIDataTableToolbar.test.js b/test/MUIDataTableToolbar.test.js
index b640108..81f8cf1 100644
--- a/test/MUIDataTableToolbar.test.js
+++ b/test/MUIDataTableToolbar.test.js
@@ -205,4 +205,30 @@ describe('<TableToolbar />', function() {
     assert.strictEqual(appendSpy.callCount, 1);
     assert.strictEqual(removeSpy.callCount, 1);
   });
+
+  it('should trigger onDownload prop callback when calling method handleCSVDownload', () => {
+    const onDownload = spy();
+
+    const newOptions = { ...options, onDownload };
+
+    const shallowWrapper = shallow(
+      <TableToolbar
+        columns={columns}
+        displayData={data}
+        data={data}
+        options={newOptions}
+        setTableAction={setTableAction}
+      />,
+    );
+    
+    const instance = shallowWrapper
+      .dive()
+      .dive()
+      .dive()
+      .instance();
+
+    instance.handleCSVDownload();
+
+    assert.strictEqual(onDownload.callCount, 1);
+  });
 });