Skip to content
This repository was archived by the owner on Jun 4, 2024. It is now read-only.

Commit 26c65f8

Browse files
authored
Merge pull request #499 from plotly/ExportTable
Ability to export data as csv / xlsx file
2 parents 05cae79 + 303a3e4 commit 26c65f8

27 files changed

+540
-22
lines changed

.circleci/config.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ jobs:
212212
command: |
213213
. venv/bin/activate
214214
git clone --depth 1 [email protected]:plotly/dash.git dash-main
215-
pip install -e ./dash-main --quiet
215+
pip install -e ./dash-main[testing] --quiet
216216
cd dash-main/dash-renderer && npm install --ignore-scripts && npm run build && pip install -e . && cd ../..
217217
218218
- run:
@@ -226,7 +226,7 @@ jobs:
226226
name: Run integration tests
227227
command: |
228228
. venv/bin/activate
229-
python -m unittest tests.dash.test_integration
229+
pytest tests/integration
230230
231231
232232
workflows:

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [Unreleased]
66
### Added
7+
[#313](https://github.com/plotly/dash-table/issues/313)
8+
- Ability to export table as csv or xlsx file.
9+
710
[#497](https://github.com/plotly/dash-table/pull/497)
811
- New `column.clearable` flag that displays a `Ø` action in the column
912
Accepts a boolean or array of booleans for multi-line headers.
File renamed without changes.

dash-main

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 9819e5eb6f7dcdd1c828c5e070af41f76214b6ff

dash_table/index.html

+3-4
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
</head>
66
<body>
77
<div id='root'></div>
8-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.js'></script>
9-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react-dom.js'></script>
8+
9+
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
10+
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
1011

11-
<!-- <script src='https://cdnjs.cloudflare.com/ajax/libs/react/16.4.2/umd/react.development.js'></script>
12-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.4.2/umd/react-dom.development.js'></script> -->
1312
<script src="./demo.js"></script>
1413
</body>
1514
</html>

demo/index.html

+4-5
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
</head>
66
<body>
77
<div id='root'></div>
8-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react.js'></script>
9-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2/react-dom.js'></script>
10-
11-
<!-- <script src='https://cdnjs.cloudflare.com/ajax/libs/react/16.4.2/umd/react.development.js'></script>
12-
<script src='https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.4.2/umd/react-dom.development.js'></script> -->
8+
9+
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
10+
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
11+
1312
<script src="./demo.js"></script>
1413
</body>
1514
</html>

index.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
apps = {
2020
filename.replace(".py", "").replace("app_", ""): getattr(
2121
getattr(
22-
__import__(".".join(["tests", "dash", filename.replace(".py", "")])), "dash"
22+
__import__(".".join(["tests", "integration", filename.replace(".py", "")])), "integration"
2323
),
2424
filename.replace(".py", ""),
2525
)
26-
for filename in os.listdir(os.path.join("tests", "dash"))
26+
for filename in os.listdir(os.path.join("tests", "integration"))
2727
if filename.startswith("app_") and filename.endswith(".py")
2828
}
2929

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@
9595
"webpack": "^4.36.1",
9696
"webpack-cli": "^3.3.6",
9797
"webpack-dev-server": "^3.7.2",
98-
"webpack-preprocessor": "^0.1.12"
98+
"webpack-preprocessor": "^0.1.12",
99+
"xlsx": "^0.14.3"
99100
},
100101
"files": [
101102
"/dash_table/bundle*{.js,.map}"

requirements.txt

+1-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ nbformat==4.4.0
3030
numpy==1.15.1
3131
pandas==0.24.1
3232
parso==0.3.1
33-
percy==2.0.0
3433
pexpect==4.6.0
3534
pickleshare==0.7.4
3635
plotly==3.2.1
@@ -42,9 +41,7 @@ Pygments==2.2.0
4241
pylint==2.1.1
4342
python-dateutil==2.7.3
4443
pytz==2018.5
45-
requests==2.20.0
4644
retrying==1.3.3
47-
selenium==3.14.0
4845
simplegeneric==0.8.1
4946
six==1.11.0
5047
toml==0.9.6
@@ -54,3 +51,4 @@ urllib3==1.23
5451
wcwidth==0.1.7
5552
Werkzeug==0.14.1
5653
wrapt==1.10.11
54+
xlrd>= 1.0.0

src/dash-table/components/ControlledTable/index.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isCtrlDown,
99
isNavKey
1010
} from 'dash-table/utils/unicode';
11+
import ExportButton from 'dash-table/components/Export';
1112
import { selectionBounds, selectionCycle } from 'dash-table/utils/navigation';
1213
import { makeCell, makeSelection } from 'dash-table/derived/cell/cellProps';
1314

@@ -760,6 +761,9 @@ export default class ControlledTable extends PureComponent<ControlledTableProps>
760761
tooltip_duration
761762
);
762763

764+
const { export_format, export_headers, virtual } = this.props;
765+
const buttonProps = { export_format, virtual_data: virtual, columns, export_headers };
766+
763767
return (<div
764768
id={id}
765769
onCopy={this.onCopy}
@@ -802,6 +806,7 @@ export default class ControlledTable extends PureComponent<ControlledTableProps>
802806
<button className='next-page' onClick={this.loadNext}>Next</button>
803807
</div>
804808
)}
809+
<ExportButton {...buttonProps} />
805810
</div>);
806811
}
807812

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import XLSX from 'xlsx';
2+
import React from 'react';
3+
import { IDerivedData, IVisibleColumn } from 'dash-table/components/Table/props';
4+
import { createWorkbook, createHeadings, createWorksheet } from './utils';
5+
import getHeaderRows from 'dash-table/derived/header/headerRows';
6+
7+
interface IExportButtonProps {
8+
export_format: string;
9+
virtual_data: IDerivedData;
10+
columns: IVisibleColumn[];
11+
export_headers: string;
12+
}
13+
14+
export default React.memo((props: IExportButtonProps) => {
15+
16+
const { columns, export_format, virtual_data, export_headers } = props;
17+
const isFormatSupported = export_format === 'csv' || export_format === 'xlsx';
18+
19+
const handleExport = () => {
20+
const columnID = columns.map(column => column.id);
21+
const columnHeaders = columns.map(column => column.name);
22+
const maxLength = getHeaderRows(columns);
23+
const heading = (export_headers !== 'none') ? createHeadings(columnHeaders, maxLength) : [];
24+
const ws = createWorksheet(heading, virtual_data.data, columnID, export_headers);
25+
const wb = createWorkbook(ws);
26+
if (export_format === 'xlsx') {
27+
XLSX.writeFile(wb, 'Data.xlsx', {bookType: 'xlsx', type: 'buffer'});
28+
} else if (export_format === 'csv') {
29+
XLSX.writeFile(wb, 'Data.csv', {bookType: 'csv', type: 'buffer'});
30+
}
31+
};
32+
33+
return (
34+
<div>
35+
{ !isFormatSupported ? null : (
36+
<button className='export' onClick={handleExport}>Export</button>
37+
)}
38+
</div>
39+
);
40+
});
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as R from 'ramda';
2+
import XLSX from 'xlsx';
3+
import { Data } from 'dash-table/components/Table/props';
4+
5+
interface IMergeObject {
6+
s: {r: number, c: number};
7+
e: {r: number, c: number};
8+
}
9+
10+
export function transformMultDimArray(array: (string | string[])[], maxLength: number): string[][] {
11+
const newArray: string[][] = array.map(row => {
12+
if (row instanceof Array && row.length < maxLength) {
13+
return row.concat(Array(maxLength - row.length).fill(''));
14+
}
15+
if (maxLength === 0 || maxLength === 1) {
16+
return [row];
17+
}
18+
if (row instanceof String || typeof(row) === 'string') {
19+
return Array(maxLength).fill(row);
20+
}
21+
return row;
22+
});
23+
return newArray;
24+
}
25+
26+
export function getMergeRanges(array: string[][]) {
27+
let apiMergeArray: IMergeObject[] = [];
28+
const iForEachOuter = R.addIndex<(string[]), void>(R.forEach);
29+
const iForEachInner = R.addIndex<(string), void>(R.forEach);
30+
iForEachOuter((row: string[], rIndex: number) => {
31+
let dict: any = {};
32+
iForEachInner((cell: string, cIndex: number) => {
33+
if (!dict[cell]) {
34+
dict[cell] = {s: {r: rIndex, c: cIndex}, e: {r: rIndex, c: cIndex }};
35+
} else {
36+
if (cIndex === (dict[cell].e.c + 1)) {
37+
dict[cell].e = {r: rIndex, c: cIndex};
38+
} else {
39+
apiMergeArray.push(dict[cell]);
40+
dict[cell] = {s: {r: rIndex, c: cIndex}, e: {r: rIndex, c: cIndex }};
41+
}
42+
}
43+
}, row);
44+
const objectsToMerge: IMergeObject[] = Object.values(dict);
45+
apiMergeArray = R.concat(apiMergeArray, objectsToMerge );
46+
}, array);
47+
return R.filter((item: IMergeObject) => item.s.c !== item.e.c || item.s.r !== item.e.r, apiMergeArray);
48+
}
49+
50+
export function createWorkbook(ws: XLSX.WorkSheet) {
51+
const wb = XLSX.utils.book_new();
52+
XLSX.utils.book_append_sheet(wb, ws, 'SheetJS');
53+
return wb;
54+
}
55+
56+
export function createWorksheet(heading: string[][], data: Data, columnID: string[], exportHeader: string ) {
57+
const ws = XLSX.utils.aoa_to_sheet(heading);
58+
if (exportHeader === 'display' || exportHeader === 'names' || exportHeader === 'none') {
59+
XLSX.utils.sheet_add_json(ws, data, {
60+
skipHeader: true,
61+
origin: heading.length
62+
});
63+
if (exportHeader === 'display') {
64+
ws['!merges'] = getMergeRanges(heading);
65+
}
66+
} else if (exportHeader === 'ids') {
67+
XLSX.utils.sheet_add_json(ws, data, { header: columnID });
68+
}
69+
return ws;
70+
}
71+
72+
export function createHeadings(columnHeaders: (string | string[])[], maxLength: number) {
73+
const transformedArray = transformMultDimArray(columnHeaders, maxLength);
74+
return R.transpose(transformedArray);
75+
}

src/dash-table/components/Table/props.ts

+2
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ interface IDefaultProps {
316316
css: IStylesheetRule[];
317317
data: Data;
318318
editable: boolean;
319+
export_format: 'csv' | 'xlsx' | 'none';
320+
export_headers: 'ids' | 'names' | 'none' | 'display';
319321
fill_width: boolean;
320322
filter_query: string;
321323
filter_action: TableAction;

src/dash-table/dash/DataTable.js

+18
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const defaultProps = {
8787
data: [],
8888
columns: [],
8989
editable: false,
90+
export_format: 'none',
9091
selected_cells: [],
9192
selected_rows: [],
9293
selected_row_ids: [],
@@ -435,6 +436,23 @@ export const propTypes = {
435436
column_id: PropTypes.string
436437
}),
437438

439+
/**
440+
* Denotes the type of the export data file,
441+
* Defaults to `'none'`
442+
*/
443+
export_format: PropTypes.oneOf(['csv', 'xlsx', 'none']),
444+
445+
/**
446+
* Denotes the format of the headers in the export data file.
447+
* If `'none'`, there will be no header. If `'display'`, then the header
448+
* of the data file will be be how it is currently displayed. Note that
449+
* `'display'` is only supported for `'xlsx'` export_format and will behave
450+
* like `'names'` for `'csv'` export format. If `'ids'` or `'names'`,
451+
* then the headers of data file will be the column id or the column
452+
* names, respectively
453+
*/
454+
export_headers: PropTypes.oneOf(['none', 'ids', 'names', 'display']),
455+
438456
/**
439457
* `fill_width` toggles between a set of CSS for two common behaviors:
440458
* - True: The table container's width will grow to fill the available space

src/dash-table/dash/Sanitizer.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,18 @@ const applyDefaultToLocale = (locale: INumberLocale) => getLocale(locale);
6565
export default class Sanitizer {
6666
sanitize(props: PropsWithDefaults): SanitizedProps {
6767
const locale_format = this.applyDefaultToLocale(props.locale_format);
68-
68+
let headerFormat = props.export_headers;
69+
if (props.export_format === 'xlsx' && R.isNil(headerFormat)) {
70+
headerFormat = 'names';
71+
} else if (props.export_format === 'csv' && R.isNil(headerFormat)) {
72+
headerFormat = 'ids';
73+
}
6974
return R.merge(props, {
7075
columns: this.applyDefaultsToColumns(locale_format, props.sort_as_null, props.columns, props.editable),
7176
fixed_columns: getFixedColumns(props.fixed_columns, props.row_deletable, props.row_selectable),
7277
fixed_rows: getFixedRows(props.fixed_rows, props.columns, props.filter_action),
73-
locale_format
78+
locale_format,
79+
export_headers: headerFormat
7480
});
7581
}
7682

tests/__init__.py

-1
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
from . import dash

tests/cypress/tests/standalone/edit_headers.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import DashTable from 'cypress/DashTable';
2-
32
import { AppMode } from 'demo/AppMode';
43

54
describe(`edit, mode=${AppMode.Typed}`, () => {

0 commit comments

Comments
 (0)