diff --git a/CHANGELOG.md b/CHANGELOG.md index 59870ec86..d8dc950fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). row of `data`. - Additionally clearing the column will reset the filter for the affected column(s) +[#318](https://github.com/plotly/dash-table/issues/318) +- Headers are included when copying from the table to different +tabs and elsewhere. They are ignored when copying from the table onto itself and +between two tables within the same tab. + ### Changed [#497](https://github.com/plotly/dash-table/pull/497) - Like for clearing above, deleting through the `x` action will also diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index 6d80f0228..7d37039bb 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -557,10 +557,12 @@ export default class ControlledTable extends PureComponent const { selected_cells, viewport, - visibleColumns + columns, + visibleColumns, + include_headers_on_copy_paste } = this.props; - TableClipboardHelper.toClipboard(e, selected_cells, visibleColumns, viewport.data); + TableClipboardHelper.toClipboard(e, selected_cells, columns, visibleColumns, viewport.data, include_headers_on_copy_paste); this.$el.focus(); } @@ -573,7 +575,8 @@ export default class ControlledTable extends PureComponent setProps, sort_by, viewport, - visibleColumns + visibleColumns, + include_headers_on_copy_paste } = this.props; if (!editable || !active_cell) { @@ -587,7 +590,8 @@ export default class ControlledTable extends PureComponent visibleColumns, data, true, - !sort_by.length || !filter_query.length + !sort_by.length || !filter_query.length, + include_headers_on_copy_paste ); if (result) { diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 3905fb2e2..b2e3fd92b 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -319,6 +319,7 @@ interface IDefaultProps { fill_width: boolean; filter_query: string; filter_action: TableAction; + include_headers_on_copy_paste: boolean; merge_duplicate_headers: boolean; fixed_columns: Fixed; fixed_rows: Fixed; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 21f70b1b8..562ed2a43 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -88,6 +88,7 @@ export const defaultProps = { columns: [], editable: false, export_format: 'none', + include_headers_on_copy_paste: false, selected_cells: [], selected_rows: [], selected_row_ids: [], @@ -285,6 +286,14 @@ export const propTypes = { * The `id` is not visible in the table. */ id: PropTypes.string.isRequired, + + /** + * If true, headers are included when copying from the table to different + * tabs and elsewhere. Note that headers are ignored when copying from the table onto itself and + * between two tables within the same tab. + */ + include_headers_on_copy_paste: PropTypes.bool, + /** * The `name` of the column, * as it appears in the column header. diff --git a/src/dash-table/utils/TableClipboardHelper.ts b/src/dash-table/utils/TableClipboardHelper.ts index 54b19765d..91d02c58e 100644 --- a/src/dash-table/utils/TableClipboardHelper.ts +++ b/src/dash-table/utils/TableClipboardHelper.ts @@ -5,12 +5,15 @@ import Clipboard from 'core/Clipboard'; import Logger from 'core/Logger'; import { ICellCoordinates, Columns, Data, SelectedCells } from 'dash-table/components/Table/props'; +import { createHeadings } from 'dash-table/components/Export/utils'; import applyClipboardToData from './applyClipboardToData'; +import getHeaderRows from 'dash-table/derived/header/headerRows'; export default class TableClipboardHelper { private static lastLocalCopy: any[][] = [[]]; + private static localCopyWithoutHeaders: any[][] = [[]]; - public static toClipboard(e: any, selectedCells: SelectedCells, columns: Columns, data: Data) { + public static toClipboard(e: any, selectedCells: SelectedCells, columns: Columns, visibleColumns: Columns, data: Data, includeHeaders: boolean) { const selectedRows = R.uniq(R.pluck('row', selectedCells).sort((a, b) => a - b)); const selectedCols: any = R.uniq(R.pluck('column', selectedCells).sort((a, b) => a - b)); @@ -19,12 +22,21 @@ export default class TableClipboardHelper { R.last(selectedRows) as any + 1, data ).map(row => - R.props(selectedCols, R.props(R.pluck('id', columns) as any, row) as any) + R.props(selectedCols, R.props(R.pluck('id', visibleColumns) as any, row) as any) ); - const value = SheetClip.prototype.stringify(df); + let value = SheetClip.prototype.stringify(df); TableClipboardHelper.lastLocalCopy = df; + if (includeHeaders) { + const transposedHeaders = createHeadings(R.pluck('name', visibleColumns), getHeaderRows(columns)); + const headers: any = R.map((row: string[]) => R.map((index: number) => row[index], selectedCols), transposedHeaders); + const dfHeaders = headers.concat(df); + value = SheetClip.prototype.stringify(dfHeaders); + TableClipboardHelper.lastLocalCopy = dfHeaders; + TableClipboardHelper.localCopyWithoutHeaders = df; + } + Logger.trace('TableClipboard -- set clipboard data: ', value); Clipboard.set(e, value); @@ -37,7 +49,8 @@ export default class TableClipboardHelper { columns: Columns, data: Data, overflowColumns: boolean = true, - overflowRows: boolean = true + overflowRows: boolean = true, + includeHeaders: boolean ): { data: Data, columns: Columns } | void { const text = Clipboard.get(ev); Logger.trace('TableClipboard -- get clipboard data: ', text); @@ -47,10 +60,8 @@ export default class TableClipboardHelper { } const localDf = SheetClip.prototype.stringify(TableClipboardHelper.lastLocalCopy); - - const values = localDf === text ? - TableClipboardHelper.lastLocalCopy : - SheetClip.prototype.parse(text); + const localCopy = includeHeaders ? TableClipboardHelper.localCopyWithoutHeaders : TableClipboardHelper.lastLocalCopy; + const values = (localDf === text) ? localCopy : SheetClip.prototype.parse(text); return applyClipboardToData( values, diff --git a/tests/cypress/dash/v_copy_paste.py b/tests/cypress/dash/v_copy_paste.py index 052bcfe30..094274799 100644 --- a/tests/cypress/dash/v_copy_paste.py +++ b/tests/cypress/dash/v_copy_paste.py @@ -48,6 +48,28 @@ editable=True, sort_action='native', ), + dash_table.DataTable( + id="table2", + data=df[0:10], + columns=[ + {"id": 0, "name": "Complaint ID"}, + {"id": 1, "name": "Product"}, + {"id": 2, "name": "Sub-product"}, + {"id": 3, "name": "Issue"}, + {"id": 4, "name": "Sub-issue"}, + {"id": 5, "name": "State"}, + {"id": 6, "name": "ZIP"}, + {"id": 7, "name": "code"}, + {"id": 8, "name": "Date received"}, + {"id": 9, "name": "Date sent to company"}, + {"id": 10, "name": "Company"}, + {"id": 11, "name": "Company response"}, + {"id": 12, "name": "Timely response?"}, + {"id": 13, "name": "Consumer disputed?"}, + ], + editable=True, + sort_action='native', + ), ] ) @@ -71,6 +93,5 @@ def updateData(timestamp, current, previous): return current - if __name__ == "__main__": app.run_server(port=8082, debug=False) diff --git a/tests/cypress/tests/server/copy_paste_test.ts b/tests/cypress/tests/server/copy_paste_test.ts index 242885bfc..96d4cb1b5 100644 --- a/tests/cypress/tests/server/copy_paste_test.ts +++ b/tests/cypress/tests/server/copy_paste_test.ts @@ -56,6 +56,29 @@ describe('copy paste', () => { } }); + it('can copy multiple rows and columns from one table and paste to another', () => { + DashTable.getCell(10, 0).click(); + DOM.focused.type(Key.Shift, { release: false }); + DashTable.getCell(13, 3).click(); + + DOM.focused.type(`${Key.Meta}c`); + cy.get(`#table2 tbody tr td.column-${0}`).eq(0).click(); + DOM.focused.type(`${Key.Meta}v`); + cy.get(`#table2 tbody tr td.column-${3}`).eq(3).click(); + + DashTable.getCell(14, 0).click(); + DOM.focused.type(Key.Shift, { release: false }); + + for (let row = 9; row <= 13; ++row) { + for (let column = 0; column <= 3; ++column) { + let initialValue: string; + + DashTable.getCell(row, column).within(() => cy.get('.dash-cell-value').then($cells => initialValue = $cells[0].innerHTML)); + cy.get(`#table2 tbody tr td.column-${column}`).eq(row - 10).within(() => cy.get('.dash-cell-value').should('have.html', initialValue)); + } + } + }); + // Commenting this test as Cypress team is having issues with the copy/paste scenario // LINK: https://github.com/cypress-io/cypress/issues/2386 describe('BE roundtrip on copy-paste', () => { @@ -98,7 +121,7 @@ describe('copy paste', () => { }); it('BE rountrip with sorted, unfiltered data', () => { - cy.get('tr th.column-2 .sort').last().click(); + cy.get('#table tr th.column-2 .sort').last().click(); DashTable.getCell(0, 0).click(); DashTable.getCell(0, 0).within(() => cy.get('.dash-cell-value').should('have.value', '11'));