diff --git a/.circleci/config.yml b/.circleci/config.yml index fe016ec30..ae347e7e8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -212,7 +212,7 @@ jobs: command: | . venv/bin/activate git clone --depth 1 git@github.com:plotly/dash.git dash-main - pip install -e ./dash-main --quiet + pip install -e ./dash-main[testing] --quiet cd dash-main/dash-renderer && npm install --ignore-scripts && npm run build && pip install -e . && cd ../.. - run: @@ -226,7 +226,7 @@ jobs: name: Run integration tests command: | . venv/bin/activate - python -m unittest tests.dash.test_integration + pytest tests/integration workflows: diff --git a/CHANGELOG.md b/CHANGELOG.md index df35471f9..3bd8effd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +[#313](https://github.com/plotly/dash-table/issues/313) +- Ability to export table as csv or xlsx file. + [#497](https://github.com/plotly/dash-table/pull/497) - New `column.clearable` flag that displays a `Ø` action in the column Accepts a boolean or array of booleans for multi-line headers. diff --git a/tests/dash/__init__.py b/__init__.py similarity index 100% rename from tests/dash/__init__.py rename to __init__.py diff --git a/dash-main b/dash-main new file mode 160000 index 000000000..9819e5eb6 --- /dev/null +++ b/dash-main @@ -0,0 +1 @@ +Subproject commit 9819e5eb6f7dcdd1c828c5e070af41f76214b6ff diff --git a/dash_table/index.html b/dash_table/index.html index 5fb27b747..daab9fee6 100644 --- a/dash_table/index.html +++ b/dash_table/index.html @@ -5,11 +5,10 @@
- - + + + - diff --git a/demo/index.html b/demo/index.html index 5fb27b747..b00532b7c 100644 --- a/demo/index.html +++ b/demo/index.html @@ -5,11 +5,10 @@
- - - - + + + + diff --git a/index.py b/index.py index 94d01ecfc..552e10389 100644 --- a/index.py +++ b/index.py @@ -19,11 +19,11 @@ apps = { filename.replace(".py", "").replace("app_", ""): getattr( getattr( - __import__(".".join(["tests", "dash", filename.replace(".py", "")])), "dash" + __import__(".".join(["tests", "integration", filename.replace(".py", "")])), "integration" ), filename.replace(".py", ""), ) - for filename in os.listdir(os.path.join("tests", "dash")) + for filename in os.listdir(os.path.join("tests", "integration")) if filename.startswith("app_") and filename.endswith(".py") } diff --git a/package.json b/package.json index 9afcb2964..043e03ae5 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,8 @@ "webpack": "^4.36.1", "webpack-cli": "^3.3.6", "webpack-dev-server": "^3.7.2", - "webpack-preprocessor": "^0.1.12" + "webpack-preprocessor": "^0.1.12", + "xlsx": "^0.14.3" }, "files": [ "/dash_table/bundle*{.js,.map}" diff --git a/requirements.txt b/requirements.txt index b610d97ce..e979bf36c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,6 @@ nbformat==4.4.0 numpy==1.15.1 pandas==0.24.1 parso==0.3.1 -percy==2.0.0 pexpect==4.6.0 pickleshare==0.7.4 plotly==3.2.1 @@ -42,9 +41,7 @@ Pygments==2.2.0 pylint==2.1.1 python-dateutil==2.7.3 pytz==2018.5 -requests==2.20.0 retrying==1.3.3 -selenium==3.14.0 simplegeneric==0.8.1 six==1.11.0 toml==0.9.6 @@ -54,3 +51,4 @@ urllib3==1.23 wcwidth==0.1.7 Werkzeug==0.14.1 wrapt==1.10.11 +xlrd>= 1.0.0 \ No newline at end of file diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index c2124ef39..de7afed26 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -8,6 +8,7 @@ import { isCtrlDown, isNavKey } from 'dash-table/utils/unicode'; +import ExportButton from 'dash-table/components/Export'; import { selectionBounds, selectionCycle } from 'dash-table/utils/navigation'; import { makeCell, makeSelection } from 'dash-table/derived/cell/cellProps'; @@ -760,6 +761,9 @@ export default class ControlledTable extends PureComponent tooltip_duration ); + const { export_format, export_headers, virtual } = this.props; + const buttonProps = { export_format, virtual_data: virtual, columns, export_headers }; + return (
)} + ); } diff --git a/src/dash-table/components/Export/index.tsx b/src/dash-table/components/Export/index.tsx new file mode 100644 index 000000000..942447f3d --- /dev/null +++ b/src/dash-table/components/Export/index.tsx @@ -0,0 +1,40 @@ +import XLSX from 'xlsx'; +import React from 'react'; +import { IDerivedData, IVisibleColumn } from 'dash-table/components/Table/props'; +import { createWorkbook, createHeadings, createWorksheet } from './utils'; +import getHeaderRows from 'dash-table/derived/header/headerRows'; + +interface IExportButtonProps { + export_format: string; + virtual_data: IDerivedData; + columns: IVisibleColumn[]; + export_headers: string; +} + +export default React.memo((props: IExportButtonProps) => { + + const { columns, export_format, virtual_data, export_headers } = props; + const isFormatSupported = export_format === 'csv' || export_format === 'xlsx'; + + const handleExport = () => { + const columnID = columns.map(column => column.id); + const columnHeaders = columns.map(column => column.name); + const maxLength = getHeaderRows(columns); + const heading = (export_headers !== 'none') ? createHeadings(columnHeaders, maxLength) : []; + const ws = createWorksheet(heading, virtual_data.data, columnID, export_headers); + const wb = createWorkbook(ws); + if (export_format === 'xlsx') { + XLSX.writeFile(wb, 'Data.xlsx', {bookType: 'xlsx', type: 'buffer'}); + } else if (export_format === 'csv') { + XLSX.writeFile(wb, 'Data.csv', {bookType: 'csv', type: 'buffer'}); + } + }; + + return ( +
+ { !isFormatSupported ? null : ( + + )} +
+ ); +}); diff --git a/src/dash-table/components/Export/utils.tsx b/src/dash-table/components/Export/utils.tsx new file mode 100644 index 000000000..19d6f225e --- /dev/null +++ b/src/dash-table/components/Export/utils.tsx @@ -0,0 +1,75 @@ +import * as R from 'ramda'; +import XLSX from 'xlsx'; +import { Data } from 'dash-table/components/Table/props'; + +interface IMergeObject { + s: {r: number, c: number}; + e: {r: number, c: number}; +} + +export function transformMultDimArray(array: (string | string[])[], maxLength: number): string[][] { + const newArray: string[][] = array.map(row => { + if (row instanceof Array && row.length < maxLength) { + return row.concat(Array(maxLength - row.length).fill('')); + } + if (maxLength === 0 || maxLength === 1) { + return [row]; + } + if (row instanceof String || typeof(row) === 'string') { + return Array(maxLength).fill(row); + } + return row; + }); + return newArray; +} + +export function getMergeRanges(array: string[][]) { + let apiMergeArray: IMergeObject[] = []; + const iForEachOuter = R.addIndex<(string[]), void>(R.forEach); + const iForEachInner = R.addIndex<(string), void>(R.forEach); + iForEachOuter((row: string[], rIndex: number) => { + let dict: any = {}; + iForEachInner((cell: string, cIndex: number) => { + if (!dict[cell]) { + dict[cell] = {s: {r: rIndex, c: cIndex}, e: {r: rIndex, c: cIndex }}; + } else { + if (cIndex === (dict[cell].e.c + 1)) { + dict[cell].e = {r: rIndex, c: cIndex}; + } else { + apiMergeArray.push(dict[cell]); + dict[cell] = {s: {r: rIndex, c: cIndex}, e: {r: rIndex, c: cIndex }}; + } + } + }, row); + const objectsToMerge: IMergeObject[] = Object.values(dict); + apiMergeArray = R.concat(apiMergeArray, objectsToMerge ); + }, array); + return R.filter((item: IMergeObject) => item.s.c !== item.e.c || item.s.r !== item.e.r, apiMergeArray); +} + +export function createWorkbook(ws: XLSX.WorkSheet) { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'SheetJS'); + return wb; +} + +export function createWorksheet(heading: string[][], data: Data, columnID: string[], exportHeader: string ) { + const ws = XLSX.utils.aoa_to_sheet(heading); + if (exportHeader === 'display' || exportHeader === 'names' || exportHeader === 'none') { + XLSX.utils.sheet_add_json(ws, data, { + skipHeader: true, + origin: heading.length + }); + if (exportHeader === 'display') { + ws['!merges'] = getMergeRanges(heading); + } + } else if (exportHeader === 'ids') { + XLSX.utils.sheet_add_json(ws, data, { header: columnID }); + } + return ws; +} + +export function createHeadings(columnHeaders: (string | string[])[], maxLength: number) { + const transformedArray = transformMultDimArray(columnHeaders, maxLength); + return R.transpose(transformedArray); +} \ No newline at end of file diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index de3fc9cd8..85eecc885 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -316,6 +316,8 @@ interface IDefaultProps { css: IStylesheetRule[]; data: Data; editable: boolean; + export_format: 'csv' | 'xlsx' | 'none'; + export_headers: 'ids' | 'names' | 'none' | 'display'; fill_width: boolean; filter_query: string; filter_action: TableAction; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 51ea76657..4d943cd20 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -87,6 +87,7 @@ export const defaultProps = { data: [], columns: [], editable: false, + export_format: 'none', selected_cells: [], selected_rows: [], selected_row_ids: [], @@ -435,6 +436,23 @@ export const propTypes = { column_id: PropTypes.string }), + /** + * Denotes the type of the export data file, + * Defaults to `'none'` + */ + export_format: PropTypes.oneOf(['csv', 'xlsx', 'none']), + + /** + * Denotes the format of the headers in the export data file. + * If `'none'`, there will be no header. If `'display'`, then the header + * of the data file will be be how it is currently displayed. Note that + * `'display'` is only supported for `'xlsx'` export_format and will behave + * like `'names'` for `'csv'` export format. If `'ids'` or `'names'`, + * then the headers of data file will be the column id or the column + * names, respectively + */ + export_headers: PropTypes.oneOf(['none', 'ids', 'names', 'display']), + /** * `fill_width` toggles between a set of CSS for two common behaviors: * - True: The table container's width will grow to fill the available space diff --git a/src/dash-table/dash/Sanitizer.ts b/src/dash-table/dash/Sanitizer.ts index ea35b471d..93ee5779d 100644 --- a/src/dash-table/dash/Sanitizer.ts +++ b/src/dash-table/dash/Sanitizer.ts @@ -65,12 +65,18 @@ const applyDefaultToLocale = (locale: INumberLocale) => getLocale(locale); export default class Sanitizer { sanitize(props: PropsWithDefaults): SanitizedProps { const locale_format = this.applyDefaultToLocale(props.locale_format); - + let headerFormat = props.export_headers; + if (props.export_format === 'xlsx' && R.isNil(headerFormat)) { + headerFormat = 'names'; + } else if (props.export_format === 'csv' && R.isNil(headerFormat)) { + headerFormat = 'ids'; + } return R.merge(props, { columns: this.applyDefaultsToColumns(locale_format, props.sort_as_null, props.columns, props.editable), fixed_columns: getFixedColumns(props.fixed_columns, props.row_deletable, props.row_selectable), fixed_rows: getFixedRows(props.fixed_rows, props.columns, props.filter_action), - locale_format + locale_format, + export_headers: headerFormat }); } diff --git a/tests/__init__.py b/tests/__init__.py index 1c68c6096..e69de29bb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -from . import dash diff --git a/tests/cypress/tests/standalone/edit_headers.ts b/tests/cypress/tests/standalone/edit_headers.ts index 9276dc059..6d7e27237 100644 --- a/tests/cypress/tests/standalone/edit_headers.ts +++ b/tests/cypress/tests/standalone/edit_headers.ts @@ -1,5 +1,4 @@ import DashTable from 'cypress/DashTable'; - import { AppMode } from 'demo/AppMode'; describe(`edit, mode=${AppMode.Typed}`, () => { diff --git a/tests/cypress/tests/unit/exportUtils_tests.ts b/tests/cypress/tests/unit/exportUtils_tests.ts new file mode 100644 index 000000000..6a3c4cc24 --- /dev/null +++ b/tests/cypress/tests/unit/exportUtils_tests.ts @@ -0,0 +1,293 @@ +import { transformMultDimArray, getMergeRanges, createHeadings, createWorksheet } from 'dash-table/components/Export/utils'; +import * as R from 'ramda'; + +describe('export', () => { + + describe('transformMultDimArray', () => { + it('array with only strings', () => { + const testedArray = []; + const transformedArray = transformMultDimArray(testedArray, 0); + const expectedArray = []; + expect(transformedArray).to.deep.equal(expectedArray); + }); + it('array with only strings', () => { + const testedArray = ['a', 'b', 'c', 'd']; + const transformedArray = transformMultDimArray(testedArray, 0); + const expectedArray = [['a'], ['b'], ['c'], ['d']]; + expect(transformedArray).to.deep.equal(expectedArray); + }); + it ('array with strings and strings array with same length', () => { + const testedArray = ['a', ['b', 'c'], ['b', 'd']]; + const transformedArray = transformMultDimArray(testedArray, 2); + const expectedArray = [['a', 'a'], ['b', 'c'], ['b', 'd']]; + expect(transformedArray).to.deep.equal(expectedArray); + }); + it ('2D strings array', () => { + const testedArray = [['a', 'b', 'c'], ['b', 'c', 'd'], ['b', 'd', 'a']]; + const transformedArray = transformMultDimArray(testedArray, 3); + const expectedArray = [['a', 'b', 'c'], ['b', 'c', 'd'], ['b', 'd', 'a']]; + expect(transformedArray).to.deep.equal(expectedArray); + + }); + it ('multidimensional array', () => { + const testedArray = [['a', 'b'], ['b', 'c', 'd'], ['a', 'b', 'd', 'a']]; + const transformedArray = transformMultDimArray(testedArray, 4); + const expectedArray = [['a', 'b', '', ''], ['b', 'c', 'd', ''], ['a', 'b', 'd', 'a']]; + expect(transformedArray).to.deep.equal(expectedArray); + + }); + it ('multidimensional array with strings', () => { + const testedArray = ['rows', ['a', 'b'], ['b', 'c', 'd'], ['a', 'b', 'd', 'a']]; + const transformedArray = transformMultDimArray(testedArray, 4); + const expectedArray = [['rows', 'rows', 'rows', 'rows'], ['a', 'b', '', ''], ['b', 'c', 'd', ''], ['a', 'b', 'd', 'a']]; + expect(transformedArray).to.deep.equal(expectedArray); + }); + }); + + describe('getMergeRanges', () => { + it('no merge', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'b', 'c', 'd'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = []; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('duplicate values - no merge', () => { + const testedArray = [['a', 'b', 'c', 'a'], + ['a', 'b', 'c', 'a'], + ['a', 'b', 'c', 'a']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = []; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('merge 2 cells right with a different value in between', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'b', 'a', 'a'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [{s: {r: 1, c: 2}, e: {r: 1, c: 3}}]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('merge 2 cells left with a different value in between', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'a', 'b', 'a'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [{s: {r: 1, c: 0}, e: {r: 1, c: 1}}]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('2 cells merge', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'a', 'c', 'd'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [{s: {r: 1, c: 0}, e: {r: 1, c: 1}}]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('3 cells merge', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'a', 'a', 'd'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [{s: {r: 1, c: 0}, e: {r: 1, c: 2}}]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('4 cells merge', () => { + const testedArray = [['a', 'b', 'c', 'd'], + ['a', 'a', 'a', 'a'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [{s: {r: 1, c: 0}, e: {r: 1, c: 3}}]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('2 cells merge, 4 cells merge - same value', () => { + const testedArray = [['a', 'a', 'c', 'd'], + ['a', 'a', 'a', 'a'], + ['a', 'b', 'c', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [ + {s: {r: 0, c: 0}, e: {r: 0, c: 1}}, + {s: {r: 1, c: 0}, e: {r: 1, c: 3}} + ]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('2 cells merge, 4 cells merge, 3 cells merge - same value', () => { + const testedArray = [['a', 'a', 'c', 'd'], + ['a', 'a', 'a', 'a'], + ['a', 'a', 'a', 'd']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [ + {s: {r: 0, c: 0}, e: {r: 0, c: 1}}, + {s: {r: 1, c: 0}, e: {r: 1, c: 3}}, + {s: {r: 2, c: 0}, e: {r: 2, c: 2}} + ]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + it('table with same value', () => { + const testedArray = [['a', 'a', 'a', 'a'], + ['a', 'a', 'a', 'a'], + ['a', 'a', 'a', 'a']]; + const mergedRanges = getMergeRanges(testedArray); + const expectedRanges = [ + {s: {r: 0, c: 0}, e: {r: 0, c: 3}}, + {s: {r: 1, c: 0}, e: {r: 1, c: 3}}, + {s: {r: 2, c: 0}, e: {r: 2, c: 3}} + ]; + expect(mergedRanges).to.deep.equal(expectedRanges); + }); + }); + + describe('createHeadings ', () => { + it('strings 2D array input with same length for inner array', () => { + const input = [['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i']]; + const headings = createHeadings(input, 3); + const expectHeadings = [['a', 'd', 'g'], + ['b', 'e', 'h'], + ['c', 'f', 'i']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings 2D array input with one different length for inner array', () => { + const input = [['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i', 'j']]; + const headings = createHeadings(input, 4); + const expectHeadings = [['a', 'd', 'g'], + ['b', 'e', 'h'], + ['c', 'f', 'i'], + ['' , '', 'j']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings 2D array input with multi different length for inner array', () => { + const input = [['a', 'b', 'c'], + ['d', 'e', 'f', '1'], + ['g', 'h', 'i', 'j', 'k']]; + const headings = createHeadings(input, 5); + const expectHeadings = [['a', 'd', 'g'], + ['b', 'e', 'h'], + ['c', 'f', 'i'], + ['' , '1', 'j'], + ['', '', 'k']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings and string[] array with same length for inner array', () => { + const input = ['rows', + ['d', 'e', 'f'], + ['g', 'h', 'i']]; + const headings = createHeadings(input, 3); + const expectHeadings = [['rows', 'd', 'g'], + ['rows', 'e', 'h'], + ['rows', 'f', 'i']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings and string[] array with different length for inner array', () => { + const input = ['rows', + ['d', 'e', 'f', 'g'], + ['g', 'h', 'i']]; + const headings = createHeadings(input, 4); + const expectHeadings = [['rows', 'd', 'g'], + ['rows', 'e', 'h'], + ['rows', 'f', 'i'], + ['rows', 'g', '']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings array', () => { + const input = ['1', + '2', + '3']; + const headings = createHeadings(input, 1); + const expectHeadings = [['1', '2', '3']]; + expect(headings).to.deep.equal(expectHeadings); + }); + it('strings array', () => { + const input = []; + const headings = createHeadings(input, 0); + const expectHeadings = []; + expect(headings).to.deep.equal(expectHeadings); + }); + }); + + describe('createWorksheet ', () => { + const Headings = [['rows', 'rows', 'b'], + ['rows', 'c', 'c'], + ['rows', 'e', 'f'], + ['rows', 'rows', 'rows']]; + const data = [ + {col1: 1, col2: 2, col3: 3}, + {col1: 2, col2: 3, col3: 4}, + {col1: 1, col2: 2, col3: 3} + ]; + it('create sheet with column names as headers for name or display header mode', () => { + const columnID = ['col1', 'col2', 'col3']; + const wsName = createWorksheet(Headings, data, columnID, 'names'); + const wsDisplay = createWorksheet(Headings, data, columnID, 'display'); + const expectedWS = { + A1: {t: 's', v: 'rows'}, + A2: {t: 's', v: 'rows'}, + A3: {t: 's', v: 'rows'}, + A4: {t: 's', v: 'rows'}, + A5: {t: 'n', v: 1}, + A6: {t: 'n', v: 2}, + A7: {t: 'n', v: 1}, + B1: {t: 's', v: 'rows'}, + B2: {t: 's', v: 'c'}, + B3: {t: 's', v: 'e'}, + B4: {t: 's', v: 'rows'}, + B5: {t: 'n', v: 2}, + B6: {t: 'n', v: 3}, + B7: {t: 'n', v: 2}, + C1: {t: 's', v: 'b'}, + C2: {t: 's', v: 'c'}, + C3: {t: 's', v: 'f'}, + C4: {t: 's', v: 'rows'}, + C5: {t: 'n', v: 3}, + C6: {t: 'n', v: 4}, + C7: {t: 'n', v: 3}}; + expectedWS['!ref'] = 'A1:C7'; + const expectedWSDisplay = R.clone(expectedWS); + expectedWSDisplay['!merges'] = [ {s: {r: 0, c: 0}, e: {r: 0, c: 1}}, + {s: {r: 1, c: 1}, e: {r: 1, c: 2}}, + {s: {r: 3, c: 0}, e: {r: 3, c: 2}} ]; + expect(wsName).to.deep.equal(expectedWS); + expect(wsDisplay).to.deep.equal(expectedWSDisplay); + }); + it('create sheet with column ids as headers', () => { + const columnID = ['col1', 'col2', 'col3']; + const ws = createWorksheet(Headings, data, columnID, 'ids'); + const expectedWS = { + A1: {t: 's', v: 'col1'}, + A2: {t: 'n', v: 1}, + A3: {t: 'n', v: 2}, + A4: {t: 'n', v: 1}, + B1: {t: 's', v: 'col2'}, + B2: {t: 'n', v: 2}, + B3: {t: 'n', v: 3}, + B4: {t: 'n', v: 2}, + C1: {t: 's', v: 'col3'}, + C2: {t: 'n', v: 3}, + C3: {t: 'n', v: 4}, + C4: {t: 'n', v: 3}}; + expectedWS['!ref'] = 'A1:C4'; + expect(ws).to.deep.equal(expectedWS); + }); + it('create sheet with no headers', () => { + const columnID = ['col1', 'col2', 'col3']; + const ws = createWorksheet([], data, columnID, 'none'); + const expectedWS = { + A1: {t: 'n', v: 1}, + A2: {t: 'n', v: 2}, + A3: {t: 'n', v: 1}, + B1: {t: 'n', v: 2}, + B2: {t: 'n', v: 3}, + B3: {t: 'n', v: 2}, + C1: {t: 'n', v: 3}, + C2: {t: 'n', v: 4}, + C3: {t: 'n', v: 3}}; + expectedWS['!ref'] = 'A1:C3'; + expect(ws).to.deep.equal(expectedWS); + }); + }); + +}); diff --git a/tests/dash/IntegrationTests.py b/tests/integration/IntegrationTests.py similarity index 100% rename from tests/dash/IntegrationTests.py rename to tests/integration/IntegrationTests.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/dash/app_dataframe_backend_paging.py b/tests/integration/app_dataframe_backend_paging.py similarity index 100% rename from tests/dash/app_dataframe_backend_paging.py rename to tests/integration/app_dataframe_backend_paging.py diff --git a/tests/dash/app_dataframe_updating_graph_fe.py b/tests/integration/app_dataframe_updating_graph_fe.py similarity index 100% rename from tests/dash/app_dataframe_updating_graph_fe.py rename to tests/integration/app_dataframe_updating_graph_fe.py diff --git a/tests/dash/test_integration.py b/tests/integration/test_integration.py similarity index 100% rename from tests/dash/test_integration.py rename to tests/integration/test_integration.py diff --git a/tests/integration/test_table_export_csv.py b/tests/integration/test_table_export_csv.py new file mode 100644 index 000000000..47c01f8e5 --- /dev/null +++ b/tests/integration/test_table_export_csv.py @@ -0,0 +1,26 @@ +import os +import pandas as pd +import dash_table +import dash +import dash.testing.wait as wait + + +def test_tbex001_table_export(dash_duo): + df = pd.read_csv( + "https://raw.githubusercontent.com/plotly/datasets/master/solar.csv" + ) + app = dash.Dash(__name__) + app.layout = dash_table.DataTable( + id="table", + columns=[{"name": i, "id": i} for i in df.columns], + data=df.to_dict("records"), + export_format="csv", + ) + dash_duo.start_server(app) + dash_duo.find_element(".export").click() + + download = os.path.sep.join((dash_duo.download_path, "Data.csv")) + wait.until(lambda: os.path.exists(download), timeout=2) + + df_bis = pd.read_csv(download) + assert df_bis.equals(df) diff --git a/tests/integration/test_table_export_xlsx.py b/tests/integration/test_table_export_xlsx.py new file mode 100644 index 000000000..ac4799cde --- /dev/null +++ b/tests/integration/test_table_export_xlsx.py @@ -0,0 +1,27 @@ +import os +import pandas as pd +import dash_table +import dash +import dash.testing.wait as wait + + +def test_tbex001_table_export(dash_duo): + df = pd.read_csv( + "https://raw.githubusercontent.com/plotly/datasets/master/solar.csv" + ) + app = dash.Dash(__name__) + app.layout = dash_table.DataTable( + id="table", + columns=[{"name": i, "id": i} for i in df.columns], + data=df.to_dict("records"), + export_format="xlsx", + ) + print(df) + dash_duo.start_server(app) + dash_duo.find_element(".export").click() + + download = os.path.sep.join((dash_duo.download_path, "Data.xlsx")) + wait.until(lambda: os.path.exists(download), timeout=2) + + df_bis = pd.read_excel(download) + assert df_bis.equals(df) diff --git a/tests/dash/utils.py b/tests/integration/utils.py similarity index 100% rename from tests/dash/utils.py rename to tests/integration/utils.py diff --git a/tests/visual/percy-storybook/DashTable.percy.tsx b/tests/visual/percy-storybook/DashTable.percy.tsx index 64916f4cc..1daf4e599 100644 --- a/tests/visual/percy-storybook/DashTable.percy.tsx +++ b/tests/visual/percy-storybook/DashTable.percy.tsx @@ -359,4 +359,31 @@ storiesOf('DashTable/Without id', module) rule: 'border: 4px solid cyan' }]} /> - )); + ) + ); + +storiesOf('DashTable/Export', module) + .add('Export Button for xlsx file', () => ()) + .add('Export Button for csv file', () => ()) + .add('No export Button for file formatted not supported', () => ()) + .add('No export Button', () => ());