diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d99c3e..7a318398c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,27 @@ This project adheres to [Semantic Versioning](http://semver.org/). - new `eq` behavior (will attempt to convert and compare numeric values if possible) - new readonly `derived_filter_structure` prop exposing the query structure in a programmatically friendlier way +[#412](https://github.com/plotly/dash-table/pull/412) +- Add support for row IDs, based on the `'id'` attribute of each row of `data` + - IDs will not be displayed unless there is a column with `id='id'` + - `active_cell`, `start_cell`, `end_cell`, and items in `selected_cells` contain row and column IDs: All are now dicts `{'row', 'column', 'row_id' and 'column_id'}` rather than arrays `[row, column]`. + - Added new props mirroring all existing row indices props: + - `selected_row_ids` mirrors `selected_rows` + - `derived_viewport_row_ids` mirrors `derived_viewport_indices` + - `derived_virtual_row_ids` mirrors `derived_virtual_indices` + - `derived_viewport_selected_row_ids` mirrors `derived_viewport_selected_rows` + - `derived_virtual_selected_row_ids` mirrors `derived_virtual_selected_rows` + ### Changed [#397](https://github.com/plotly/dash-table/pull/397) - Rename `filtering_settings` to `filter` -[#412](https://github.com/plotly/dash-table/pull/412) +[#417](https://github.com/plotly/dash-table/pull/417) - Rename `sorting_settings` to `sort_by` +[#412](https://github.com/plotly/dash-table/pull/412) +- `active_cell` and `selected_cells` items are dicts `{'row', 'column', 'row_id' and 'column_id'}` instead of arrays `[row, column]` + ## [3.6.0] - 2019-03-04 ### Fixed [#189](https://github.com/plotly/dash-table/issues/189) diff --git a/dash_table/Format.py b/dash_table/Format.py index 011d50728..4ae06ace3 100644 --- a/dash_table/Format.py +++ b/dash_table/Format.py @@ -1,6 +1,4 @@ import collections -import inspect -import sys def get_named_tuple(name, dict): @@ -26,23 +24,23 @@ def get_named_tuple(name, dict): }) Prefix = get_named_tuple('prefix', { - 'yocto': 10**-24, - 'zepto': 10**-21, - 'atto': 10**-18, - 'femto': 10**-15, - 'pico': 10**-12, - 'nano': 10**-9, - 'micro': 10**-6, - 'milli': 10**-3, + 'yocto': 10 ** -24, + 'zepto': 10 ** -21, + 'atto': 10 ** -18, + 'femto': 10 ** -15, + 'pico': 10 ** -12, + 'nano': 10 ** -9, + 'micro': 10 ** -6, + 'milli': 10 ** -3, 'none': None, - 'kilo': 10**3, - 'mega': 10**6, - 'giga': 10**9, - 'tera': 10**12, - 'peta': 10**15, - 'exa': 10**18, - 'zetta': 10**21, - 'yotta': 10**24 + 'kilo': 10 ** 3, + 'mega': 10 ** 6, + 'giga': 10 ** 9, + 'tera': 10 ** 12, + 'peta': 10 ** 15, + 'exa': 10 ** 18, + 'zetta': 10 ** 21, + 'yotta': 10 ** 24 }) Scheme = get_named_tuple('scheme', { @@ -102,11 +100,17 @@ def __init__(self, **kwargs): 'type': Scheme.default } - valid_methods = [m for m in dir(self.__class__) if m[0] != '_' and m != 'to_plotly_json'] + valid_methods = [ + m for m in dir(self.__class__) + if m[0] != '_' and m != 'to_plotly_json' + ] for kw, val in kwargs.items(): if kw not in valid_methods: - raise TypeError('{0} is not a format method. Expected one of'.format(kw), str(list(valid_methods))) + raise TypeError( + '{0} is not a format method. Expected one of'.format(kw), + str(list(valid_methods)) + ) getattr(self, kw)(val) @@ -128,7 +132,8 @@ def _validate_non_negative_integer_or_none(self, value): def _validate_named(self, value, named_values): if value not in named_values: - raise TypeError('expected value to be one of', str(list(named_values))) + raise TypeError('expected value to be one of', + str(list(named_values))) def _validate_string(self, value): if not isinstance(value, (str, u''.__class__)): @@ -174,7 +179,9 @@ def padding_width(self, value): def precision(self, value): self._validate_non_negative_integer_or_none(value) - self._specifier['precision'] = '.{0}'.format(value) if value is not None else '' + self._specifier['precision'] = ( + '.{0}'.format(value) if value is not None else '' + ) return self def scheme(self, value): @@ -238,13 +245,20 @@ def group_delimiter(self, value): return self def groups(self, groups): - groups = groups if isinstance(groups, list) else [groups] if isinstance(groups, int) else None + groups = ( + groups if isinstance(groups, list) else + [groups] if isinstance(groups, int) else None + ) if not isinstance(groups, list): - raise TypeError('expected groups to be an integer or a list of integers') - + raise TypeError( + 'expected groups to be an integer or a list of integers' + ) if len(groups) == 0: - raise ValueError('expected groups to be an integer or a list of one or more integers') + raise ValueError( + 'expected groups to be an integer or a list of ' + 'one or more integers' + ) for group in groups: if not isinstance(group, int): @@ -273,8 +287,9 @@ def to_plotly_json(self): f['locale'] = self._locale.copy() f['nully'] = self._nully f['prefix'] = self._prefix + aligned = self._specifier['align'] != Align.default f['specifier'] = '{}{}{}{}{}{}{}{}{}{}'.format( - self._specifier['fill'] if self._specifier['align'] != Align.default else '', + self._specifier['fill'] if aligned else '', self._specifier['align'], self._specifier['sign'], self._specifier['symbol'], diff --git a/dash_table/FormatTemplate.py b/dash_table/FormatTemplate.py index b75ce22aa..9991437c7 100644 --- a/dash_table/FormatTemplate.py +++ b/dash_table/FormatTemplate.py @@ -1,4 +1,3 @@ -from enum import Enum from .Format import Format, Group, Scheme, Sign, Symbol diff --git a/package.json b/package.json index 1f60c8c26..00677ad1f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "private::host_dash8083": "python tests/cypress/dash/v_fe_page.py", "private::host_js": "http-server ./dash_table -c-1 --silent", "private::lint:ts": "tslint '{src,demo,tests}/**/*.{js,ts,tsx}' --exclude '**/@Types/*.*'", - "private::lint:py": "flake8 --exclude=DataTable.py,__init__.py,_imports_.py --ignore=E501,F401,F841,F811,F821 dash_table", + "private::lint:py": "flake8 --exclude=DataTable.py,__init__.py,_imports_.py dash_table", "private::wait_dash8081": "wait-on http://localhost:8081", "private::wait_dash8082": "wait-on http://localhost:8082", "private::wait_dash8083": "wait-on http://localhost:8083", diff --git a/src/dash-table/components/CellFactory.tsx b/src/dash-table/components/CellFactory.tsx index 217606cf0..1f47e04e0 100644 --- a/src/dash-table/components/CellFactory.tsx +++ b/src/dash-table/components/CellFactory.tsx @@ -50,7 +50,6 @@ export default class CellFactory { } = this.props; const operations = this.cellOperations( - active_cell, data, virtualized.data, virtualized.indices, @@ -114,4 +113,4 @@ export default class CellFactory { (o, c) => Array.prototype.concat(o, c) ); } -} \ 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 685ea9e7e..387000636 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -8,7 +8,8 @@ import { isCtrlDown, isNavKey } from 'dash-table/utils/unicode'; -import { selectionCycle } from 'dash-table/utils/navigation'; +import { selectionBounds, selectionCycle } from 'dash-table/utils/navigation'; +import { makeCell, makeSelection } from 'dash-table/derived/cell/cellProps'; import getScrollbarWidth from 'core/browser/scrollbarWidth'; import Logger from 'core/Logger'; @@ -17,7 +18,7 @@ import { memoizeOne } from 'core/memoizer'; import lexer from 'core/syntax-tree/lexer'; import TableClipboardHelper from 'dash-table/utils/TableClipboardHelper'; -import { ControlledTableProps } from 'dash-table/components/Table/props'; +import { ControlledTableProps, ICellFactoryProps } from 'dash-table/components/Table/props'; import dropdownHelper from 'dash-table/components/dropdownHelper'; import derivedTable from 'dash-table/derived/table'; @@ -31,8 +32,6 @@ import TableTooltip from './fragments/TableTooltip'; import queryLexicon from 'dash-table/syntax-tree/lexicon/query'; -const sortNumerical = R.sort((a, b) => a - b); - const DEFAULT_STYLE = { width: '100%' }; @@ -108,7 +107,7 @@ export default class ControlledTable extends PureComponent } = this.props; if (selected_cells.length && - active_cell.length && + active_cell && !R.contains(active_cell, selected_cells) ) { setProps({ active_cell: selected_cells[0] }); @@ -320,6 +319,8 @@ export default class ControlledTable extends PureComponent active_cell, columns, selected_cells, + start_cell, + end_cell, setProps, viewport } = this.props; @@ -330,6 +331,14 @@ export default class ControlledTable extends PureComponent // TAB event.preventDefault(); + if (!active_cell) { + // there should always be an active_cell if we got here... + // but if for some reason there isn't, bail out rather than + // doing something unexpected + Logger.warning('Trying to change cell, but no cell is active.'); + return; + } + // If we are moving yank focus away from whatever input may still have // focus. // TODO There is a better way to handle native focus being out of sync @@ -363,69 +372,88 @@ export default class ControlledTable extends PureComponent setProps({ is_focused: false, selected_cells: [nextCell], - active_cell: nextCell + active_cell: nextCell, + start_cell: nextCell, + end_cell: nextCell }); return; } // else we are navigating with arrow keys and extending selection // with shift. - let targetCells: any[] = []; - let removeCells: any[] = []; - const selectedRows = sortNumerical(R.uniq(R.pluck(0, selected_cells))); - const selectedCols = sortNumerical(R.uniq(R.pluck(1, selected_cells))); - - const minRow = selectedRows[0]; - const minCol = selectedCols[0]; - const maxRow = selectedRows[selectedRows.length - 1]; - const maxCol = selectedCols[selectedCols.length - 1]; + let {minRow, minCol, maxRow, maxCol} = selectionBounds(selected_cells); const selectingDown = e.keyCode === KEY_CODES.ARROW_DOWN || e.keyCode === KEY_CODES.ENTER; const selectingUp = e.keyCode === KEY_CODES.ARROW_UP; const selectingRight = e.keyCode === KEY_CODES.ARROW_RIGHT || e.keyCode === KEY_CODES.TAB; const selectingLeft = e.keyCode === KEY_CODES.ARROW_LEFT; + let startRow = start_cell && start_cell.row; + let startCol = start_cell && start_cell.column; + let endRow = end_cell && end_cell.row; + let endCol = end_cell && end_cell.column; + + if (selectingDown) { + if (active_cell.row > minRow) { + minRow++; + endRow = minRow; + } else if (maxRow < viewport.data.length - 1) { + maxRow++; + endRow = maxRow; + } + } else if (selectingUp) { + if (active_cell.row < maxRow) { + maxRow--; + endRow = maxRow; + } else if (minRow > 0) { + minRow--; + endRow = minRow; + } + } else if (selectingRight) { + if (active_cell.column > minCol) { + minCol++; + endCol = minCol; + } else if (maxCol < columns.length - 1) { + maxCol++; + endCol = maxCol; + } + } else if (selectingLeft) { + if (active_cell.column < maxCol) { + maxCol--; + endCol = maxCol; + } else if (minCol > 0) { + minCol--; + endCol = minCol; + } + } else { + return; + } - // If there are selections above the active cell and we are - // selecting down then pull down the top selection towards - // the active cell. - if (selectingDown && (active_cell as any)[0] > minRow) { - removeCells = selectedCols.map(col => [minRow, col]); - } else if (selectingDown && maxRow !== viewport.data.length - 1) { - // Otherwise if we are selecting down select the next row if possible. - targetCells = selectedCols.map(col => [maxRow + 1, col]); - } else if (selectingUp && (active_cell as any)[0] < maxRow) { - // If there are selections below the active cell and we are selecting - // up remove lower row. - removeCells = selectedCols.map(col => [maxRow, col]); - } else if (selectingUp && minRow > 0) { - // Otherwise if we are selecting up select next row if possible. - targetCells = selectedCols.map(col => [minRow - 1, col]); - } else if (selectingLeft && (active_cell as any)[1] < maxCol) { - // If there are selections to the right of the active cell and - // we are selecting left, move the right side closer to active_cell - removeCells = selectedRows.map(row => [row, maxCol]); - } else if (selectingLeft && minCol > 0) { - // Otherwise increase the selection left if possible - targetCells = selectedRows.map(row => [row, minCol - 1]); - } else if (selectingRight && (active_cell as any)[1] > minCol) { - // If there are selections to the left of the active cell and - // we are selecting right, move the left side closer to active_cell - removeCells = selectedRows.map(row => [row, minCol]); - } else if (selectingRight && maxCol + 1 <= columns.length - 1) { - // Otherwise move selection right if possible - targetCells = selectedRows.map(row => [row, maxCol + 1]); - } - - const newSelectedCell = R.without( - removeCells, - R.uniq(R.concat(targetCells, selected_cells)) + const finalSelected = makeSelection( + {minRow, maxRow, minCol, maxCol}, + columns, viewport ); - setProps({ + + const newProps: Partial = { is_focused: false, - selected_cells: newSelectedCell - }); + end_cell: makeCell(endRow, endCol, columns, viewport), + selected_cells: finalSelected + }; + + const newStartRow = endRow === minRow ? maxRow : minRow; + const newStartCol = endCol === minCol ? maxCol : minCol; + + if (startRow !== newStartRow || startCol !== newStartCol) { + newProps.start_cell = makeCell( + newStartRow, + newStartCol, + columns, + viewport + ); + } + + setProps(newProps); } deleteCell = (event: any) => { @@ -443,7 +471,7 @@ export default class ControlledTable extends PureComponent let newData = data; const realCells: [number, number][] = R.map( - cell => [viewport.indices[cell[0]], cell[1]] as [number, number], + cell => [viewport.indices[cell.row], cell.column] as [number, number], selected_cells ); @@ -467,55 +495,42 @@ export default class ControlledTable extends PureComponent const e = event; + const {row, column} = currentCell; + let nextCoords; + switch (e.keyCode) { case KEY_CODES.ARROW_LEFT: - return restrictToSelection - ? selectionCycle( - [currentCell[0], currentCell[1] - 1], - selected_cells - ) - : [ - currentCell[0], - R.max(0, currentCell[1] - 1) - ]; + nextCoords = restrictToSelection + ? selectionCycle([row, column - 1], selected_cells) + : [row, R.max(0, column - 1)]; + break; case KEY_CODES.ARROW_RIGHT: case KEY_CODES.TAB: - return restrictToSelection - ? selectionCycle( - [currentCell[0], currentCell[1] + 1], - selected_cells - ) - : [ - currentCell[0], - R.min(columns.length - 1, currentCell[1] + 1) - ]; + nextCoords = restrictToSelection + ? selectionCycle([row, column + 1], selected_cells) + : [row, R.min(columns.length - 1, column + 1)]; + break; case KEY_CODES.ARROW_UP: - return restrictToSelection - ? selectionCycle( - [currentCell[0] - 1, currentCell[1]], - selected_cells - ) - : [R.max(0, currentCell[0] - 1), currentCell[1]]; + nextCoords = restrictToSelection + ? selectionCycle([row - 1, column], selected_cells) + : [R.max(0, row - 1), column]; + break; case KEY_CODES.ARROW_DOWN: case KEY_CODES.ENTER: - return restrictToSelection - ? selectionCycle( - [currentCell[0] + 1, currentCell[1]], - selected_cells - ) - : [ - R.min(viewport.data.length - 1, currentCell[0] + 1), - currentCell[1] - ]; + nextCoords = restrictToSelection + ? selectionCycle([row + 1, column], selected_cells) + : [R.min(viewport.data.length - 1, row + 1), column]; + break; default: throw new Error( `Table.getNextCell: unknown navigation keycode ${e.keyCode}` ); } + return makeCell(nextCoords[0], nextCoords[1], columns, viewport); } onCopy = (e: any) => { @@ -541,7 +556,7 @@ export default class ControlledTable extends PureComponent viewport } = this.props; - if (!editable) { + if (!editable || !active_cell) { return; } diff --git a/src/dash-table/components/Table/index.tsx b/src/dash-table/components/Table/index.tsx index 2ebd9d603..84c7dbcfb 100644 --- a/src/dash-table/components/Table/index.tsx +++ b/src/dash-table/components/Table/index.tsx @@ -185,25 +185,38 @@ export default class Table extends Component virtual.data[i].id, + virtual_selected_rows + ); } if (!viewportSelectedRowsCached) { newProps.derived_viewport_selected_rows = viewport_selected_rows; + newProps.derived_viewport_selected_row_ids = R.map( + i => viewport.data[i].id, + viewport_selected_rows + ); } if (invalidateSelection) { newProps.active_cell = undefined; - newProps.selected_cells = undefined; - newProps.selected_rows = undefined; + newProps.selected_cells = []; + newProps.start_cell = undefined; + newProps.end_cell = undefined; + newProps.selected_rows = []; + newProps.selected_row_ids = []; } if (!R.keys(newProps).length) { diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 698d16aec..4625f139e 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -52,18 +52,24 @@ export enum ContentStyle { Grow = 'grow' } -export type ActiveCell = CellCoordinates | []; -export type CellCoordinates = [number, number]; +export interface ICellCoordinates { + row: number; + column: number; + row_id?: RowId; + column_id: ColumnId; +} + export type ColumnId = string | number; export type Columns = IColumn[]; export type Data = Datum[]; export type Datum = IDatumObject | any; export type Filtering = 'fe' | 'be' | boolean; export type Indices = number[]; +export type RowId = string | number; export type Navigation = 'page'; export type PaginationMode = 'fe' | 'be' | boolean; export type RowSelection = 'single' | 'multi' | false; -export type SelectedCells = CellCoordinates[]; +export type SelectedCells = ICellCoordinates[]; export type SetProps = (...args: any[]) => void; export type SetState = (state: Partial) => void; export type Sorting = 'fe' | 'be' | boolean; @@ -242,12 +248,12 @@ export interface IState { export type StandaloneState = IState & Partial; -interface IProps { +export interface IProps { data_previous?: any[]; data_timestamp?: number; - end_cell?: [number, number]; + end_cell?: ICellCoordinates; is_focused?: boolean; - start_cell?: [number, number]; + start_cell?: ICellCoordinates; id: string; @@ -257,7 +263,7 @@ interface IProps { column_static_tooltip: ITableStaticTooltips; column_conditional_tooltips: ConditionalTooltip[]; - active_cell?: ActiveCell; + active_cell?: ICellCoordinates; columns?: Columns; column_conditional_dropdowns?: IConditionalColumnDropdown[]; column_static_dropdown?: IColumnDropdown[]; @@ -279,6 +285,7 @@ interface IProps { row_selectable?: RowSelection; selected_cells?: SelectedCells; selected_rows?: Indices; + selected_row_ids?: RowId[]; setProps?: SetProps; sorting?: Sorting; sort_by?: SortSettings; @@ -302,7 +309,7 @@ interface IProps { } interface IDefaultProps { - active_cell: ActiveCell; + active_cell: ICellCoordinates; columns: Columns; column_conditional_dropdowns: IConditionalColumnDropdown[]; column_static_dropdown: IColumnDropdown[]; @@ -320,7 +327,10 @@ interface IDefaultProps { row_deletable: boolean; row_selectable: RowSelection; selected_cells: SelectedCells; - selected_rows: number[]; + start_cell: ICellCoordinates; + end_cell: ICellCoordinates; + selected_rows: Indices; + selected_row_ids: RowId[]; sorting: Sorting; sort_by: SortSettings; sorting_type: SortingType; @@ -348,10 +358,14 @@ interface IDerivedProps { derived_filter_structure: object | null; derived_viewport_data: Data; derived_viewport_indices: Indices; + derived_viewport_row_ids: RowId[]; derived_viewport_selected_rows: Indices; + derived_viewport_selected_row_ids: RowId[]; derived_virtual_data: Data; derived_virtual_indices: Indices; + derived_virtual_row_ids: RowId[]; derived_virtual_selected_rows: Indices; + derived_virtual_selected_row_ids: RowId[]; } export type PropsWithDefaults = IProps & IDefaultProps; @@ -372,7 +386,7 @@ export type ControlledTableProps = PropsWithDefaults & IState & { }; export interface ICellFactoryProps { - active_cell: ActiveCell; + active_cell: ICellCoordinates; columns: VisibleColumns; column_conditional_dropdowns: IConditionalColumnDropdown[]; column_conditional_tooltips: ConditionalTooltip[]; @@ -389,7 +403,9 @@ export interface ICellFactoryProps { row_deletable: boolean; row_selectable: RowSelection; selected_cells: SelectedCells; - selected_rows: number[]; + start_cell: ICellCoordinates; + end_cell: ICellCoordinates; + selected_rows: Indices; setProps: SetProps; setState: SetState; style_cell: Style; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index e140777da..3a60ed1e6 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -51,10 +51,14 @@ export const defaultProps = { derived_viewport_data: [], derived_viewport_indices: [], + derived_viewport_row_ids: [], derived_viewport_selected_rows: [], + derived_viewport_selected_row_ids: [], derived_virtual_data: [], derived_virtual_indices: [], + derived_virtual_row_ids: [], derived_virtual_selected_rows: [], + derived_virtual_selected_row_ids: [], column_conditional_dropdowns: [], column_static_dropdown: [], @@ -67,9 +71,9 @@ export const defaultProps = { data: [], columns: [], editable: false, - active_cell: [], - selected_cells: [[]], + selected_cells: [], selected_rows: [], + selected_row_ids: [], row_selectable: false, style_table: {}, @@ -81,10 +85,14 @@ export const defaultProps = { export const propTypes = { /** - * The [row, column] index of which cell is currently - * active. + * The row and column indices and IDs of the currently active cell. */ - active_cell: PropTypes.array, + active_cell: PropTypes.exact({ + row: PropTypes.number, + column: PropTypes.number, + row_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + column_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }), /** * Columns describes various aspects about each individual column. @@ -367,6 +375,10 @@ export const propTypes = { /** * The contents of the table. * The keys of each item in data should match the column IDs. + * Each item can also have an 'id' key, whose value is its row ID. If there + * is a column with ID='id' this will display the row ID, otherwise it is + * just used to reference the row for selections, filtering, etc. + * * Example: * * [ @@ -411,11 +423,16 @@ export const propTypes = { /** * When selecting multiple cells * (via clicking on a cell and then shift-clicking on another cell), - * `end_cell` represents the [row, column] coordinates of the cell + * `end_cell` represents the row / column coordinates and IDs of the cell * in one of the corners of the region. * `start_cell` represents the coordinates of the other corner. */ - end_cell: PropTypes.arrayOf(PropTypes.number), + end_cell: PropTypes.exact({ + row: PropTypes.number, + column: PropTypes.number, + row_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + column_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }), /** * The ID of the table. @@ -485,25 +502,35 @@ export const propTypes = { row_selectable: PropTypes.oneOf(['single', 'multi', false]), /** - * `selected_cells` represents the set of cells that are selected. - * This is similar to `active_cell` except that it contains multiple - * cells. Multiple cells can be selected by holding down shift and + * `selected_cells` represents the set of cells that are selected, + * as an array of objects, each item similar to `active_cell`. + * Multiple cells can be selected by holding down shift and * clicking on a different cell or holding down shift and navigating * with the arrow keys. - * - * NOTE - This property may change in the future, subscribe to - * [https://github.com/plotly/dash-table/issues/177](https://github.com/plotly/dash-table/issues/177) - * for more details. */ - selected_cells: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), + selected_cells: PropTypes.arrayOf(PropTypes.exact({ + row: PropTypes.number, + column: PropTypes.number, + row_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + column_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + })), /** - * `selected_rows` contains the indices of the rows that + * `selected_rows` contains the indices of rows that * are selected via the UI elements that appear when * `row_selectable` is `'single'` or `'multi'`. */ selected_rows: PropTypes.arrayOf(PropTypes.number), + /** + * `selected_row_ids` contains the ids of rows that + * are selected via the UI elements that appear when + * `row_selectable` is `'single'` or `'multi'`. + */ + selected_row_ids: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + setProps: PropTypes.func, /** @@ -513,7 +540,12 @@ export const propTypes = { * in one of the corners of the region. * `end_cell` represents the coordinates of the other corner. */ - start_cell: PropTypes.arrayOf(PropTypes.number), + start_cell: PropTypes.exact({ + row: PropTypes.number, + column: PropTypes.number, + row_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + column_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }), /** * If True, then the table will be styled like a list view @@ -1022,16 +1054,34 @@ export const propTypes = { * `derived_viewport_indices` indicates the order in which the original * rows appear after being filtered, sorted, and/or paged. * `derived_viewport_indices` contains indices for the current page, - * while `derived_virtual_indices` contains indices for across all pages. + * while `derived_virtual_indices` contains indices across all pages. */ derived_viewport_indices: PropTypes.arrayOf(PropTypes.number), + /** + * `derived_viewport_row_ids` lists row IDs in the order they appear + * after being filtered, sorted, and/or paged. + * `derived_viewport_row_ids` contains IDs for the current page, + * while `derived_virtual_row_ids` contains IDs across all pages. + */ + derived_viewport_row_ids: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + /** * `derived_viewport_selected_rows` represents the indices of the - * `selected_rows` from the perspective of the `derived_viewport_indices`. + * `selected_rows` from the perspective of the `derived_viewport_indices`. */ derived_viewport_selected_rows: PropTypes.arrayOf(PropTypes.number), + /** + * `derived_viewport_selected_row_ids` represents the IDs of the + * `selected_rows` on the currently visible page. + */ + derived_viewport_selected_row_ids: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + /** * This property represents the visible state of `data` * across all pages after the front-end sorting and filtering @@ -1040,19 +1090,38 @@ export const propTypes = { derived_virtual_data: PropTypes.arrayOf(PropTypes.object), /** - * `derived_viewport_indices` indicates the order in which the original - * rows appear after being filtered, sorted, and/or paged. + * `derived_virtual_indices` indicates the order in which the original + * rows appear after being filtered and sorted. * `derived_viewport_indices` contains indices for the current page, - * while `derived_virtual_indices` contains indices for across all pages. + * while `derived_virtual_indices` contains indices across all pages. */ derived_virtual_indices: PropTypes.arrayOf(PropTypes.number), + /** + * `derived_virtual_row_ids` indicates the row IDs in the order in which + * they appear after being filtered and sorted. + * `derived_viewport_row_ids` contains IDs for the current page, + * while `derived_virtual_row_ids` contains IDs across all pages. + */ + derived_virtual_row_ids: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + /** * `derived_virtual_selected_rows` represents the indices of the * `selected_rows` from the perspective of the `derived_virtual_indices`. */ derived_virtual_selected_rows: PropTypes.arrayOf(PropTypes.number), + /** + * `derived_virtual_selected_row_ids` represents the IDs of the + * `selected_rows` as they appear after filtering and sorting, + * across all pages. + */ + derived_virtual_selected_row_ids: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), + /** * DEPRECATED * Subscribe to [https://github.com/plotly/dash-table/issues/168](https://github.com/plotly/dash-table/issues/168) diff --git a/src/dash-table/derived/cell/cellProps.ts b/src/dash-table/derived/cell/cellProps.ts new file mode 100644 index 000000000..a76051847 --- /dev/null +++ b/src/dash-table/derived/cell/cellProps.ts @@ -0,0 +1,39 @@ +import { map, range, xprod } from 'ramda'; +import { ICellCoordinates, Columns, IDerivedData } from 'dash-table/components/Table/props'; + +export function makeCell ( + row: number, + column: number, + columns: Columns, + viewport: IDerivedData +) { + const cell: ICellCoordinates = { + row, + column, + column_id: columns[column].id + }; + const rowId = viewport.data[row].id; + if (rowId !== undefined) { + cell.row_id = rowId; + } + return cell; +} + +interface ISelectionBounds { + minRow: number; + maxRow: number; + minCol: number; + maxCol: number; +} + +export function makeSelection ( + bounds: ISelectionBounds, + columns: Columns, + viewport: IDerivedData +) { + const {minRow, maxRow, minCol, maxCol} = bounds; + return map( + rc => makeCell((rc as number[])[0], (rc as number[])[1], columns, viewport), + xprod(range(minRow, maxRow + 1), range(minCol, maxCol + 1)) + ); +} diff --git a/src/dash-table/derived/cell/contents.tsx b/src/dash-table/derived/cell/contents.tsx index 4a7a282ee..dfc1ebac5 100644 --- a/src/dash-table/derived/cell/contents.tsx +++ b/src/dash-table/derived/cell/contents.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { - ActiveCell, + ICellCoordinates, Data, Datum, DropdownValues, @@ -57,7 +57,7 @@ class Contents { } get = memoizeOne(( - activeCell: ActiveCell, + activeCell: ICellCoordinates | undefined, columns: VisibleColumns, data: Data, offset: IViewportOffset, @@ -122,4 +122,4 @@ class Contents { data ); }); -} \ No newline at end of file +} diff --git a/src/dash-table/derived/cell/isActive.ts b/src/dash-table/derived/cell/isActive.ts index 78b705788..4cfd31d41 100644 --- a/src/dash-table/derived/cell/isActive.ts +++ b/src/dash-table/derived/cell/isActive.ts @@ -1,7 +1,11 @@ -import { ActiveCell } from 'dash-table/components/Table/props'; +import { ICellCoordinates } from 'dash-table/components/Table/props'; export default ( - activeCell: ActiveCell, + activeCell: ICellCoordinates | undefined, row: number, column: number -) => activeCell[0] === row && activeCell[1] === column; \ No newline at end of file +) => ( + !!activeCell && + activeCell.row === row && + activeCell.column === column +); diff --git a/src/dash-table/derived/cell/isSelected.ts b/src/dash-table/derived/cell/isSelected.ts index 8e701b864..3c12569d9 100644 --- a/src/dash-table/derived/cell/isSelected.ts +++ b/src/dash-table/derived/cell/isSelected.ts @@ -6,4 +6,4 @@ export default ( selectedCells: SelectedCells, row: number, column: number -) => R.contains([row, column], selectedCells); \ No newline at end of file +) => R.any(cell => cell.row === row && cell.column === column, selectedCells); diff --git a/src/dash-table/derived/cell/operations.tsx b/src/dash-table/derived/cell/operations.tsx index c160fd8c2..952e9dd03 100644 --- a/src/dash-table/derived/cell/operations.tsx +++ b/src/dash-table/derived/cell/operations.tsx @@ -2,31 +2,38 @@ import * as R from 'ramda'; import React from 'react'; import { memoizeOneFactory } from 'core/memoizer'; +import { clearSelection } from 'dash-table/utils/actions'; import { Data, Datum, SetProps, RowSelection, - ActiveCell, Indices } from 'dash-table/components/Table/props'; -function deleteRow(idx: number, activeCell: ActiveCell, data: Data, selectedRows: number[]) { +function deleteRow(idx: number, data: Data, selectedRows: number[]) { const newProps: any = { - data: R.remove(idx, 1, data) + data: R.remove(idx, 1, data), + // We could try to adjust selection, but there are lots of edge cases + ...clearSelection }; - if (R.is(Array, activeCell) && activeCell[0] === idx) { - newProps.active_cell = []; - } - if (R.is(Array, selectedRows) && R.contains(idx, selectedRows)) { - newProps.selected_rows = R.without([idx], selectedRows); + if (R.is(Array, selectedRows) && R.any(i => i >= idx, selectedRows)) { + newProps.selected_rows = R.map( + // all rows past idx have now lost one from their index + (i: number) => i > idx ? i - 1 : i, + R.without([idx], selectedRows) + ); + newProps.selected_row_ids = R.map( + i => newProps.data[i].id, + newProps.selected_rows + ); } return newProps; } -function rowSelectCell(idx: number, rowSelectable: RowSelection, selectedRows: number[], setProps: SetProps) { +function rowSelectCell(idx: number, rowSelectable: RowSelection, selectedRows: number[], setProps: SetProps, data: Data) { return ( setProps({ - selected_rows: - rowSelectable === 'single' ? - [idx] : - R.ifElse( - R.contains(idx), - R.without([idx]), - R.append(idx) - )(selectedRows) - })} + onChange={() => { + const newSelectedRows = rowSelectable === 'single' ? + [idx] : + R.ifElse( + R.contains(idx), + R.without([idx]), + R.append(idx) + )(selectedRows); + setProps({ + selected_rows: newSelectedRows, + selected_row_ids: R.map(i => data[i].id, newSelectedRows) + }); + }} /> ); } -function rowDeleteCell(setProps: SetProps, deleteFn: () => any) { +function rowDeleteCell(doDelete: () => any) { return ( setProps(deleteFn())} + onClick={() => doDelete()} style={{ width: `30px`, maxWidth: `30px`, minWidth: `30px` }} > {'×'} @@ -62,7 +72,6 @@ function rowDeleteCell(setProps: SetProps, deleteFn: () => any) { } const getter = ( - activeCell: ActiveCell, data: Data, viewportData: Data, viewportIndices: Indices, @@ -72,8 +81,8 @@ const getter = ( setProps: SetProps ): JSX.Element[][] => R.addIndex(R.map)( (_, rowIndex) => [ - ...(rowDeletable ? [rowDeleteCell(setProps, deleteRow.bind(undefined, viewportIndices[rowIndex], activeCell, data, selectedRows))] : []), - ...(rowSelectable ? [rowSelectCell(viewportIndices[rowIndex], rowSelectable, selectedRows, setProps)] : []) + ...(rowDeletable ? [rowDeleteCell(() => setProps(deleteRow(viewportIndices[rowIndex], data, selectedRows)))] : []), + ...(rowSelectable ? [rowSelectCell(viewportIndices[rowIndex], rowSelectable, selectedRows, setProps, data)] : []) ], viewportData ); diff --git a/src/dash-table/derived/cell/wrappers.tsx b/src/dash-table/derived/cell/wrappers.tsx index 6c86d9ea8..f0c813578 100644 --- a/src/dash-table/derived/cell/wrappers.tsx +++ b/src/dash-table/derived/cell/wrappers.tsx @@ -3,7 +3,7 @@ import React, { MouseEvent } from 'react'; import { memoizeOne } from 'core/memoizer'; import memoizerCache from 'core/cache/memoizer'; -import { Data, IVisibleColumn, VisibleColumns, ActiveCell, SelectedCells, Datum, ColumnId, IViewportOffset, Presentation, ICellFactoryProps } from 'dash-table/components/Table/props'; +import { Data, IVisibleColumn, VisibleColumns, ICellCoordinates, SelectedCells, Datum, ColumnId, IViewportOffset, Presentation, ICellFactoryProps } from 'dash-table/components/Table/props'; import Cell from 'dash-table/components/Cell'; import derivedCellEventHandlerProps, { Handler } from 'dash-table/derived/cell/eventHandlerProps'; import isActiveCell from 'dash-table/derived/cell/isActive'; @@ -23,7 +23,7 @@ class Wrappers { * Returns the wrapper for each cell in the table. */ get = memoizeOne(( - activeCell: ActiveCell, + activeCell: ICellCoordinates | undefined, columns: VisibleColumns, data: Data, offset: IViewportOffset, diff --git a/src/dash-table/derived/data/viewport.ts b/src/dash-table/derived/data/viewport.ts index 6110c26a9..6257d12d0 100644 --- a/src/dash-table/derived/data/viewport.ts +++ b/src/dash-table/derived/data/viewport.ts @@ -1,4 +1,5 @@ import { memoizeOneFactory } from 'core/memoizer'; +import { lastPage } from 'dash-table/derived/paginator'; import { Data, @@ -13,10 +14,7 @@ function getNoPagination(data: Data, indices: Indices): IDerivedData { } function getFrontEndPagination(settings: IPaginationSettings, data: Data, indices: Indices): IDerivedData { - let currentPage = Math.min( - settings.current_page, - Math.floor(data.length / settings.page_size) - ); + let currentPage = Math.min(settings.current_page, lastPage(data, settings)); const firstIndex = settings.page_size * currentPage; const lastIndex = Math.min( diff --git a/src/dash-table/derived/header/content.tsx b/src/dash-table/derived/header/content.tsx index 003ebe27b..fef77afed 100644 --- a/src/dash-table/derived/header/content.tsx +++ b/src/dash-table/derived/header/content.tsx @@ -50,7 +50,8 @@ function doSort(columnId: ColumnId, sortSettings: SortSettings, sortType: Sortin sort_by: sortingStrategy( sortSettings, { column_id: columnId, direction } - ) + ), + ...actions.clearSelection }); }; } diff --git a/src/dash-table/derived/paginator.ts b/src/dash-table/derived/paginator.ts index f384bb4f7..da0a8a343 100644 --- a/src/dash-table/derived/paginator.ts +++ b/src/dash-table/derived/paginator.ts @@ -1,7 +1,9 @@ -import * as R from 'ramda'; +import { merge } from 'ramda'; import { memoizeOneFactory } from 'core/memoizer'; +import { clearSelection } from 'dash-table/utils/actions'; + import { Data, PaginationMode, @@ -14,6 +16,10 @@ export interface IPaginator { loadPrevious(): void; } +export function lastPage(data: Data, settings: IPaginationSettings) { + return Math.max(Math.ceil(data.length / settings.page_size) - 1, 0); +} + function getBackEndPagination( pagination_settings: IPaginationSettings, setProps: SetProps @@ -21,7 +27,7 @@ function getBackEndPagination( return { loadNext: () => { pagination_settings.current_page++; - setProps({ pagination_settings }); + setProps({ pagination_settings, ...clearSelection }); }, loadPrevious: () => { if (pagination_settings.current_page <= 0) { @@ -29,7 +35,7 @@ function getBackEndPagination( } pagination_settings.current_page--; - setProps({ pagination_settings }); + setProps({ pagination_settings, ...clearSelection }); } }; } @@ -41,28 +47,28 @@ function getFrontEndPagination( ) { return { loadNext: () => { - let maxPageIndex = Math.floor(data.length / pagination_settings.page_size); + const maxPageIndex = lastPage(data, pagination_settings); if (pagination_settings.current_page >= maxPageIndex) { return; } - pagination_settings = R.merge(pagination_settings, { + pagination_settings = merge(pagination_settings, { current_page: pagination_settings.current_page + 1 }); - setProps({ pagination_settings }); + setProps({ pagination_settings, ...clearSelection }); }, loadPrevious: () => { if (pagination_settings.current_page <= 0) { return; } - pagination_settings = R.merge(pagination_settings, { + pagination_settings = merge(pagination_settings, { current_page: pagination_settings.current_page - 1 }); - setProps({ pagination_settings }); + setProps({ pagination_settings, ...clearSelection }); } }; } @@ -93,4 +99,4 @@ const getter = ( } }; -export default memoizeOneFactory(getter); \ No newline at end of file +export default memoizeOneFactory(getter); diff --git a/src/dash-table/derived/table/index.tsx b/src/dash-table/derived/table/index.tsx index 2aa87abf2..b4a59ad92 100644 --- a/src/dash-table/derived/table/index.tsx +++ b/src/dash-table/derived/table/index.tsx @@ -5,10 +5,11 @@ import { memoizeOne } from 'core/memoizer'; import CellFactory from 'dash-table/components/CellFactory'; import FilterFactory from 'dash-table/components/FilterFactory'; import HeaderFactory from 'dash-table/components/HeaderFactory'; +import { clearSelection } from 'dash-table/utils/actions'; import { ControlledTableProps, SetProps, SetState } from 'dash-table/components/Table/props'; const handleSetFilter = (setProps: SetProps, setState: SetState, filter: string, rawFilterQuery: string) => { - setProps({ filter }); + setProps({ filter, ...clearSelection }); setState({ rawFilterQuery }); }; @@ -51,4 +52,4 @@ export default (propsFn: () => ControlledTableProps) => { const headerFactory = new HeaderFactory(propsFn); return getter.bind(undefined, cellFactory, filterFactory, headerFactory); -}; \ No newline at end of file +}; diff --git a/src/dash-table/handlers/cellEvents.ts b/src/dash-table/handlers/cellEvents.ts index 8560fef7c..c61f4a03b 100644 --- a/src/dash-table/handlers/cellEvents.ts +++ b/src/dash-table/handlers/cellEvents.ts @@ -1,55 +1,78 @@ -import * as R from 'ramda'; -import { SelectedCells, ICellFactoryProps } from 'dash-table/components/Table/props'; +import { min, max, set, lensPath } from 'ramda'; +import { ICellFactoryProps } from 'dash-table/components/Table/props'; import isActive from 'dash-table/derived/cell/isActive'; +import isSelected from 'dash-table/derived/cell/isSelected'; +import { makeCell, makeSelection } from 'dash-table/derived/cell/cellProps'; import reconcile from 'dash-table/type/reconcile'; -function isCellSelected(selectedCells: SelectedCells, idx: number, i: number) { - return selectedCells && R.contains([idx, i], selectedCells); -} - export const handleClick = (propsFn: () => ICellFactoryProps, idx: number, i: number, e: any) => { const { selected_cells, + active_cell, setProps, - virtualized + virtualized, + columns, + viewport } = propsFn(); - const selected = isCellSelected(selected_cells, idx, i); + const row = idx + virtualized.offset.rows; + const col = i + virtualized.offset.columns; - // don't update if already selected - if (selected) { + const clickedCell = makeCell(row, col, columns, viewport); + + // clicking again on the already-active cell: ignore + if (active_cell && row === active_cell.row && col === active_cell.column) { return; } e.preventDefault(); - const cellLocation: [number, number] = [ - idx + virtualized.offset.rows, - i + virtualized.offset.columns - ]; + + /* + * In some cases this will initiate browser text selection. + * We've hijacked copying, so while it might be nice to allow copying part + * of a cell, currently you'll always get the whole cell regardless of what + * the browser thinks is selected. + * And when you've selected multiple cells the browser selection is + * completely wrong. + */ + const browserSelection = window.getSelection(); + if (browserSelection) { + browserSelection.removeAllRanges(); + } + + const selected = isSelected(selected_cells, row, col); + + // if clicking on a *different* already-selected cell (NOT shift-clicking, + // not the active cell), don't alter the selection, + // just move the active cell + if (selected && !e.shiftKey) { + setProps({ + is_focused: false, + active_cell: clickedCell + }); + return; + } const newProps: Partial = { is_focused: false, - active_cell: cellLocation + end_cell: clickedCell }; - const selectedRows = R.uniq(R.pluck(0, selected_cells)).sort((a, b) => a - b); - const selectedCols = R.uniq(R.pluck(1, selected_cells)).sort((a, b) => a - b); - const minRow = selectedRows[0]; - const minCol = selectedCols[0]; - - if (e.shiftKey) { - newProps.selected_cells = R.xprod( - R.range( - R.min(minRow, cellLocation[0]), - R.max(minRow, cellLocation[0]) + 1 - ), - R.range( - R.min(minCol, cellLocation[1]), - R.max(minCol, cellLocation[1]) + 1 - ) - ) as any; + if (e.shiftKey && active_cell) { + newProps.selected_cells = makeSelection( + { + minRow: min(row, active_cell.row), + maxRow: max(row, active_cell.row), + minCol: min(col, active_cell.column), + maxCol: max(col, active_cell.column) + }, + columns, + viewport + ); } else { - newProps.selected_cells = [cellLocation]; + newProps.active_cell = clickedCell; + newProps.start_cell = clickedCell; + newProps.selected_cells = [clickedCell]; } setProps(newProps); @@ -60,23 +83,28 @@ export const handleDoubleClick = (propsFn: () => ICellFactoryProps, idx: number, editable, is_focused, setProps, - virtualized + virtualized, + columns, + viewport } = propsFn(); if (!editable) { return; } - const cellLocation: [number, number] = [ + const newCell = makeCell( idx + virtualized.offset.rows, - i + virtualized.offset.columns - ]; + i + virtualized.offset.columns, + columns, viewport + ); if (!is_focused) { e.preventDefault(); const newProps = { - selected_cells: [cellLocation], - active_cell: cellLocation, + selected_cells: [newCell], + active_cell: newCell, + start_cell: newCell, + end_cell: newCell, is_focused: true }; setProps(newProps); @@ -105,8 +133,8 @@ export const handleChange = (propsFn: () => ICellFactoryProps, idx: number, i: n return; } - const newData = R.set( - R.lensPath([realIdx, c.id]), + const newData = set( + lensPath([realIdx, c.id]), result.value, data ); @@ -184,4 +212,4 @@ export const handleOnMouseUp = (propsFn: () => ICellFactoryProps, idx: number, i export const handlePaste = (_propsFn: () => ICellFactoryProps, _idx: number, _i: number, e: any) => { e.preventDefault(); -}; \ No newline at end of file +}; diff --git a/src/dash-table/utils/TableClipboardHelper.ts b/src/dash-table/utils/TableClipboardHelper.ts index 7d7698209..54b19765d 100644 --- a/src/dash-table/utils/TableClipboardHelper.ts +++ b/src/dash-table/utils/TableClipboardHelper.ts @@ -4,15 +4,15 @@ import SheetClip from 'sheetclip'; import Clipboard from 'core/Clipboard'; import Logger from 'core/Logger'; -import { ActiveCell, Columns, Data, SelectedCells } from 'dash-table/components/Table/props'; +import { ICellCoordinates, Columns, Data, SelectedCells } from 'dash-table/components/Table/props'; import applyClipboardToData from './applyClipboardToData'; export default class TableClipboardHelper { private static lastLocalCopy: any[][] = [[]]; public static toClipboard(e: any, selectedCells: SelectedCells, columns: Columns, data: Data) { - const selectedRows = R.uniq(R.pluck(0, selectedCells).sort((a, b) => a - b)); - const selectedCols: any = R.uniq(R.pluck(1, selectedCells).sort((a, b) => a - b)); + 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)); const df = R.slice( R.head(selectedRows) as any, @@ -32,7 +32,7 @@ export default class TableClipboardHelper { public static fromClipboard( ev: ClipboardEvent, - activeCell: ActiveCell, + activeCell: ICellCoordinates, derived_viewport_indices: number[], columns: Columns, data: Data, @@ -62,4 +62,4 @@ export default class TableClipboardHelper { overflowRows ); } -} \ No newline at end of file +} diff --git a/src/dash-table/utils/actions.js b/src/dash-table/utils/actions.js index e135fac9c..931b25559 100644 --- a/src/dash-table/utils/actions.js +++ b/src/dash-table/utils/actions.js @@ -43,13 +43,17 @@ export function deleteColumn(column, columns, headerRowIndex, props) { // inconsistencies. In an ideal world, we would probably only // update them if they contained one of the columns that we're // trying to delete - active_cell: [], - end_cell: [], - selected_cells: [], - start_cell: [0] + ...clearSelection }; } +export const clearSelection = { + active_cell: undefined, + start_cell: undefined, + end_cell: undefined, + selected_cells: [] +}; + export function editColumnName(column, columns, headerRowIndex, props) { const { groupIndexFirst, groupIndexLast } = getGroupedColumnIndices( column, columns, headerRowIndex, props diff --git a/src/dash-table/utils/applyClipboardToData.ts b/src/dash-table/utils/applyClipboardToData.ts index 7973a891d..b1bbb9df1 100644 --- a/src/dash-table/utils/applyClipboardToData.ts +++ b/src/dash-table/utils/applyClipboardToData.ts @@ -2,13 +2,13 @@ import * as R from 'ramda'; import Logger from 'core/Logger'; -import { ActiveCell, Columns, Data, ColumnType } from 'dash-table/components/Table/props'; +import { ICellCoordinates, Columns, Data, ColumnType } from 'dash-table/components/Table/props'; import reconcile from 'dash-table/type/reconcile'; import isEditable from 'dash-table/derived/cell/isEditable'; export default ( values: any[][], - activeCell: ActiveCell, + activeCell: ICellCoordinates, derived_viewport_indices: number[], columns: Columns, data: Data, @@ -27,10 +27,10 @@ export default ( let newData = R.clone(data); const newColumns = R.clone(columns); - if (overflowColumns && values[0].length + (activeCell as any)[1] >= columns.length) { + if (overflowColumns && values[0].length + (activeCell as any).column >= columns.length) { for ( let i = columns.length; - i < values[0].length + (activeCell as any)[1]; + i < values[0].length + (activeCell as any).column; i++ ) { newColumns.push({ @@ -42,7 +42,7 @@ export default ( } } - const realActiveRow = derived_viewport_indices[(activeCell as any)[0]]; + const realActiveRow = derived_viewport_indices[(activeCell as any).row]; if (overflowRows && values.length + realActiveRow >= data.length) { const emptyRow: any = {}; columns.forEach(c => (emptyRow[c.id] = '')); @@ -60,7 +60,7 @@ export default ( for (let [i, row] of values.entries()) { for (let [j, value] of row.entries()) { - const viewportIndex = (activeCell as any)[0] + i; + const viewportIndex = (activeCell as any).row + i; let iRealCell: number | undefined = viewportSize > viewportIndex ? derived_viewport_indices[viewportIndex] : @@ -72,7 +72,7 @@ export default ( continue; } - const jOffset = (activeCell as any)[1] + j; + const jOffset = (activeCell as any).column + j; const col = newColumns[jOffset]; if (!col || !isEditable(true, col.editable)) { continue; @@ -93,4 +93,4 @@ export default ( } return { data: newData, columns: newColumns }; -}; \ No newline at end of file +}; diff --git a/src/dash-table/utils/navigation.js b/src/dash-table/utils/navigation.js index 3df332a9d..052d3d8c7 100644 --- a/src/dash-table/utils/navigation.js +++ b/src/dash-table/utils/navigation.js @@ -1,13 +1,19 @@ import * as R from 'ramda'; -export function selectionCycle(nextCell, selected_cells) { - const selectedRows = R.uniq(R.pluck(0, selected_cells)).sort((a, b) => a - b); - const selectedCols = R.uniq(R.pluck(1, selected_cells)).sort((a, b) => a - b); +export function selectionBounds(selected_cells) { + const selectedRows = R.pluck('row', selected_cells); + const selectedCols = R.pluck('column', selected_cells); + + return { + minRow: R.reduce(R.min, Infinity, selectedRows), + minCol: R.reduce(R.min, Infinity, selectedCols), + maxRow: R.reduce(R.max, 0, selectedRows), + maxCol: R.reduce(R.max, 0, selectedCols) + }; +} - const minRow = selectedRows[0]; - const minCol = selectedCols[0]; - const maxRow = selectedRows[selectedRows.length - 1]; - const maxCol = selectedCols[selectedCols.length - 1]; +export function selectionCycle(nextCell, selected_cells) { + const {minRow, minCol, maxRow, maxCol} = selectionBounds(selected_cells); const [nextRow, nextCol] = nextCell; const adjustedCell = [nextRow, nextCol]; diff --git a/tests/cypress/dash/v_fe_page.py b/tests/cypress/dash/v_fe_page.py index 096b99ad4..57fd34d8b 100644 --- a/tests/cypress/dash/v_fe_page.py +++ b/tests/cypress/dash/v_fe_page.py @@ -1,23 +1,31 @@ # pylint: disable=global-statement -import dash -from dash.dependencies import Input, Output -import dash_html_components as html +import json import os import pandas as pd import sys +import dash +from dash.dependencies import Input, Output +import dash_html_components as html + sys.path.append( os.path.abspath( - os.path.join(os.path.dirname(sys.argv[0]), os.pardir, os.pardir, os.pardir) + os.path.join(os.path.dirname(sys.argv[0]), + os.pardir, os.pardir, os.pardir) ) ) module_names = ["dash_table"] modules = [__import__(module) for module in module_names] dash_table = modules[0] -url = "https://github.com/plotly/datasets/raw/master/" "26k-consumer-complaints.csv" +url = ("https://github.com/plotly/datasets/raw/master/" + "26k-consumer-complaints.csv") df = pd.read_csv(url, nrows=1000) -df = df.values +# add IDs that don't match but are easily derivable from row #s +data = [ + {k: v for k, v in list(enumerate(row)) + [('id', i + 3000)]} + for i, row in enumerate(df.values) +] app = dash.Dash() app.css.config.serve_locally = True @@ -25,11 +33,9 @@ app.layout = html.Div( [ - html.Div(id="derived_virtual_selected_rows_container", children="undefined"), - html.Div(id="derived_viewport_selected_rows_container", children="undefined"), dash_table.DataTable( id="table", - data=df, + data=data, pagination_mode="fe", pagination_settings={ "current_page": 0, @@ -60,24 +66,33 @@ filtering=True, editable=True, ), + html.Div(id="props_container") ] ) -@app.callback( - Output("derived_virtual_selected_rows_container", "children"), - [Input("table", "derived_virtual_selected_rows")] -) -def exposeDerivedVirtualSelectedRows(rows): - return str(rows); +props = [ + 'active_cell', 'start_cell', 'end_cell', 'selected_cells', + 'selected_rows', 'selected_row_ids', + 'derived_viewport_selected_rows', 'derived_viewport_selected_row_ids', + 'derived_virtual_selected_rows', 'derived_virtual_selected_row_ids', + 'derived_viewport_indices', 'derived_viewport_row_ids', + 'derived_virtual_indices', 'derived_virtual_row_ids' +] @app.callback( - Output("derived_viewport_selected_rows_container", "children"), - [Input("table", "derived_viewport_selected_rows")] + Output("props_container", "children"), + [Input("table", prop) for prop in props] ) -def exposeDerivedViewportSelectedRows(rows): - return str(rows); +def show_props(*args): + return html.Table([ + html.Tr([ + html.Td(prop), + html.Td(json.dumps(val), id=prop + '_container') + ]) + for prop, val in zip(props, args) + ]) if __name__ == "__main__": - app.run_server(port=8083, debug=False) \ No newline at end of file + app.run_server(port=8083, debug=False) diff --git a/tests/cypress/tests/server/dash_test.ts b/tests/cypress/tests/server/dash_test.ts index 329804132..b0ba93557 100644 --- a/tests/cypress/tests/server/dash_test.ts +++ b/tests/cypress/tests/server/dash_test.ts @@ -12,6 +12,7 @@ describe('dash basic', () => { DashTable.getCell(0, 0).within(() => cy.get('input').should('have.value', '0')); cy.get('button.next-page').click(); + DashTable.getCell(0, 0).click(); DashTable.getCell(0, 0).within(() => cy.get('input').should('have.value', '250')); }); diff --git a/tests/cypress/tests/server/select_props_test.ts b/tests/cypress/tests/server/select_props_test.ts new file mode 100644 index 000000000..b995293bf --- /dev/null +++ b/tests/cypress/tests/server/select_props_test.ts @@ -0,0 +1,196 @@ +import DashTable from 'cypress/DashTable'; +import DOM from 'cypress/DOM'; +import Key from 'cypress/Key'; +import { map, xprod } from 'ramda'; + +function expectArray(selector: string, vals: number[], sorted?: boolean) { + cy.get(selector).should($container => { + const valsOut = JSON.parse($container.text()) as number[]; + if (sorted !== false) { + valsOut.sort(); + } + expect(valsOut).to.deep.equal(vals); + }); +} + +function expectCellSelection( + rows: number[], + rowIds?: number[], + cols?: number[], + colIds?: number[], + activeItem?: number[], // indices within rows/cols, dflt [0,0] + startItem?: number[] // same^ +) { + function makeCell(rc: number[]) { + const [r, c] = rc; + return { + row: rows[r], + row_id: rowIds && rowIds[r], + column: cols && cols[c], + column_id: colIds && colIds[c] + }; + } + + let activeCell: any; + let startCell: any; + let endCell: any; + let selectedCells: any; + if (rows.length && cols) { + activeCell = makeCell(activeItem || [0, 0]); + startCell = makeCell(startItem || [0, 0]); + endCell = makeCell(startItem ? [0, 0] : [rows.length - 1, cols.length - 1]); + selectedCells = map(makeCell, xprod(range(0, rows.length - 1), range(0, cols.length - 1))); + } else { + activeCell = startCell = endCell = selectedCells = null; + } + + cy.get('#active_cell_container').should($container => { + expect(JSON.parse($container.text())).to.deep.equal(activeCell); + }); + cy.get('#start_cell_container').should($container => { + expect(JSON.parse($container.text())).to.deep.equal(startCell); + }); + cy.get('#end_cell_container').should($container => { + expect(JSON.parse($container.text())).to.deep.equal(endCell); + }); + cy.get('#selected_cells_container').should($container => { + if (selectedCells && selectedCells.length) { + expect(JSON.parse($container.text())).to.deep.equal(selectedCells); + } else { + expect($container.text()).to.be.oneOf(['null', '[]']); + } + }); +} + +// NOTE: this function includes both endpoints +// easier to compare with the full arrays that way. +function range(from: number, to: number, step?: number) { + const _step = step || 1; + const out: number[] = []; + for (let v = from; v * _step <= to * _step; v += _step) { + out.push(v); + } + return out; +} + +describe('select row', () => { + describe('be pagination & sort', () => { + beforeEach(() => cy.visit('http://localhost:8081')); + + it('can select row', () => { + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); + }); + + it('can select row when sorted', () => { + cy.get('tr th.column-0 .sort').last().click({ force: true }); + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); + }); + + it('select, sort, new row is not selected', () => { + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); + cy.get('tr th.column-0 .sort').last().click({ force: true }); + DashTable.getSelect(0).within(() => cy.get('input').should('not.be.checked')); + }); + }); + + describe('fe pagination & sort', () => { + beforeEach(() => cy.visit('http://localhost:8083')); + + it('selection props are correct, no sort / filter', () => { + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(1).within(() => cy.get('input').click()); + + expectCellSelection([]); + + // single cell selection + DashTable.getCell(3, 1).click(); + expectCellSelection([3], [3003], [1], [1]); + + // region up & left - active & start stay at the bottom right + DOM.focused.type(Key.Shift, { release: false }); + DashTable.getCell(1, 0).click(); + expectCellSelection([1, 2, 3], [3001, 3002, 3003], [0, 1], [0, 1], [2, 1], [2, 1]); + + // shrink the selection + DOM.focused.type(Key.Shift, { release: false }); + DashTable.getCell(2, 1).click(); + expectCellSelection([2, 3], [3002, 3003], [1], [1], [1, 0], [1, 0]); + + // move the active cell without changing the selection + DOM.focused.type(Key.Shift); // and release + DashTable.getCell(2, 1).click(); + expectCellSelection([2, 3], [3002, 3003], [1], [1], [0, 0], [1, 0]); + + expectArray('#selected_rows_container', [0, 1]); + expectArray('#selected_row_ids_container', [3000, 3001]); + expectArray('#derived_viewport_selected_rows_container', [0, 1]); + expectArray('#derived_viewport_selected_row_ids_container', [3000, 3001]); + expectArray('#derived_virtual_selected_rows_container', [0, 1]); + expectArray('#derived_virtual_selected_row_ids_container', [3000, 3001]); + expectArray('#derived_viewport_indices_container', range(0, 249), false); + expectArray('#derived_viewport_row_ids_container', range(3000, 3249), false); + expectArray('#derived_virtual_indices_container', range(0, 999), false); + expectArray('#derived_virtual_row_ids_container', range(3000, 3999), false); + }); + + it('selection props are correct, with filter', () => { + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(1).within(() => cy.get('input').click()); + DashTable.getSelect(2).within(() => cy.get('input').click()); + + cy.get('tr th.column-0.dash-filter input').type(`is even${Key.Enter}`); + + // filtered-out data is still selected + expectArray('#selected_rows_container', [0, 1, 2]); + expectArray('#selected_row_ids_container', [3000, 3001, 3002]); + expectArray('#derived_viewport_selected_rows_container', [0, 1]); + expectArray('#derived_viewport_selected_row_ids_container', [3000, 3002]); + expectArray('#derived_virtual_selected_rows_container', [0, 1]); + expectArray('#derived_virtual_selected_row_ids_container', [3000, 3002]); + expectArray('#derived_viewport_indices_container', range(0, 498, 2), false); + expectArray('#derived_viewport_row_ids_container', range(3000, 3498, 2), false); + expectArray('#derived_virtual_indices_container', range(0, 998, 2), false); + expectArray('#derived_virtual_row_ids_container', range(3000, 3998, 2), false); + }); + + it('selection props are correct, with filter & sort', () => { + DashTable.getSelect(0).within(() => cy.get('input').click()); + DashTable.getSelect(1).within(() => cy.get('input').click()); + + DashTable.getCell(3, 1).click(); + expectCellSelection([3], [3003], [1], [1]); + + cy.get('tr th.column-0.dash-filter input').type(`is even${Key.Enter}`); + + expectCellSelection([]); + + DashTable.getCell(3, 1).click(); + expectCellSelection([3], [3006], [1], [1]); + + cy.get('tr th.column-0 .sort').last().click({ force: true }); + + expectCellSelection([]); + + cy.get('tr th.column-0 .sort').last().click({ force: true }); + + DashTable.getSelect(0).within(() => cy.get('input').click()); + + DashTable.getCell(3, 1).click(); + expectCellSelection([3], [3992], [1], [1]); + + expectArray('#selected_rows_container', [0, 1, 998]); + expectArray('#selected_row_ids_container', [3000, 3001, 3998]); + expectArray('#derived_viewport_selected_rows_container', [0]); + expectArray('#derived_viewport_selected_row_ids_container', [3998]); + expectArray('#derived_virtual_selected_rows_container', [0, 499]); + expectArray('#derived_virtual_selected_row_ids_container', [3000, 3998]); + expectArray('#derived_viewport_indices_container', range(998, 500, -2), false); + expectArray('#derived_viewport_row_ids_container', range(3998, 3500, -2), false); + expectArray('#derived_virtual_indices_container', range(998, 0, -2), false); + expectArray('#derived_virtual_row_ids_container', range(3998, 3000, -2), false); + }); + }); +}); diff --git a/tests/cypress/tests/server/select_row_test.ts b/tests/cypress/tests/server/select_row_test.ts deleted file mode 100644 index c38d4b672..000000000 --- a/tests/cypress/tests/server/select_row_test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import DashTable from 'cypress/DashTable'; -import Key from 'cypress/Key'; - -describe('select row', () => { - describe('be pagination & sort', () => { - beforeEach(() => cy.visit('http://localhost:8081')); - - it('can select row', () => { - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); - }); - - it('can select row when sorted', () => { - cy.get('tr th.column-0 .sort').last().click({ force: true }); - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); - }); - - it('select, sort, new row is not selected', () => { - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(0).within(() => cy.get('input').should('be.checked')); - cy.get('tr th.column-0 .sort').last().click({ force: true }); - DashTable.getSelect(0).within(() => cy.get('input').should('not.be.checked')); - }); - }); - - describe('fe pagination & sort', () => { - beforeEach(() => cy.visit('http://localhost:8083')); - - it('derived selected rows are correct, no sort / filter', () => { - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(1).within(() => cy.get('input').click()); - - cy.get('#derived_viewport_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.be.oneOf([`[0, 1]`, `[1, 0]`]); - }); - - cy.get('#derived_virtual_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.be.oneOf([`[0, 1]`, `[1, 0]`]); - }); - }); - - it('derived selected rows are correct, with filter', () => { - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(1).within(() => cy.get('input').click()); - DashTable.getSelect(2).within(() => cy.get('input').click()); - - cy.get('tr th.column-0.dash-filter input').type(`is even${Key.Enter}`); - - cy.get('#derived_viewport_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.be.oneOf([`[0, 1]`, `[1, 0]`]); - }); - - cy.get('#derived_virtual_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.be.oneOf([`[0, 1]`, `[1, 0]`]); - }); - }); - - it('derived selected rows are correct, with filter & sort', () => { - DashTable.getSelect(0).within(() => cy.get('input').click()); - DashTable.getSelect(1).within(() => cy.get('input').click()); - - cy.get('tr th.column-0.dash-filter input').type(`is even${Key.Enter}`); - cy.get('tr th.column-0 .sort').last().click({ force: true }); - cy.get('tr th.column-0 .sort').last().click({ force: true }); - - DashTable.getSelect(0).within(() => cy.get('input').click()); - - cy.get('#derived_viewport_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.equal(`[0]`); - }); - - cy.get('#derived_virtual_selected_rows_container').should($container => { - expect($container.first()[0].innerText).to.be.oneOf([`[0, 499]`, `[499, 0]`]); - }); - }); - }); -}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/clipboard_test.ts b/tests/cypress/tests/unit/clipboard_test.ts index 285f35148..6d362de43 100644 --- a/tests/cypress/tests/unit/clipboard_test.ts +++ b/tests/cypress/tests/unit/clipboard_test.ts @@ -7,7 +7,7 @@ describe('clipboard', () => { it('pastes one line at [0, 0] in one line df', () => { const res = applyClipboardToData( R.range(0, 1).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 1), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 1).map(() => ({ c1: 'c1' })), @@ -26,7 +26,7 @@ describe('clipboard', () => { it('pastes two lines at [0, 0] in one line df', () => { const res = applyClipboardToData( R.range(0, 2).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 1), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 1).map(() => ({ c1: 'c1' })), @@ -46,7 +46,7 @@ describe('clipboard', () => { it('pastes ten lines at [0, 0] in three line df', () => { const res = applyClipboardToData( R.range(0, 10).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 3), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 3).map(() => ({ c1: 'c1' })), @@ -67,7 +67,7 @@ describe('clipboard', () => { it('pastes ten lines at [1, 0] in three line df', () => { const res = applyClipboardToData( R.range(0, 10).map(value => [`${value}`]), - [1, 0], + {row: 1, column: 0, column_id: ''}, R.range(0, 3), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 3).map(() => ({ c1: 'c1' })), @@ -91,7 +91,7 @@ describe('clipboard', () => { it('pastes one line at [0, 0] in one line df', () => { const res = applyClipboardToData( R.range(0, 1).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 1), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 1).map(() => ({ c1: 'c1' })), @@ -110,7 +110,7 @@ describe('clipboard', () => { it('pastes two lines at [0, 0] in one line df', () => { const res = applyClipboardToData( R.range(0, 2).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 1), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 1).map(() => ({ c1: 'c1' })), @@ -129,7 +129,7 @@ describe('clipboard', () => { it('pastes ten lines at [0, 0] in three line df', () => { const res = applyClipboardToData( R.range(0, 10).map(value => [`${value}`]), - [0, 0], + {row: 0, column: 0, column_id: ''}, R.range(0, 3), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 3).map(() => ({ c1: 'c1' })), @@ -150,7 +150,7 @@ describe('clipboard', () => { it('pastes ten lines at [1, 0] in three line df', () => { const res = applyClipboardToData( R.range(0, 10).map(value => [`${value}`]), - [1, 0], + {row: 1, column: 0, column_id: ''}, R.range(0, 3), ['c1'].map(id => ({ id: id, name: id })), R.range(0, 3).map(() => ({ c1: 'c1' })), @@ -169,4 +169,4 @@ describe('clipboard', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/visual/percy-storybook/DashTable.percy.tsx b/tests/visual/percy-storybook/DashTable.percy.tsx index dd681b1c3..a39afdddb 100644 --- a/tests/visual/percy-storybook/DashTable.percy.tsx +++ b/tests/visual/percy-storybook/DashTable.percy.tsx @@ -9,9 +9,41 @@ import fixtures from './fixtures'; const setProps = () => { }; +function makeCell(row: number, column: number, data: any[], columns: any[]) { + const cell: any = { + row, + column, + column_id: columns[column].id + }; + const rowID = data[row].id; + if (rowID !== undefined) { + cell.row_id = rowID; + } + return cell; +} + +function makeSelection(coords: any[], data: any[], columns: any[]) { + return coords.map(pair => makeCell(pair[0], pair[1], data, columns)); +} + // Legacy: Tests previously run in Python const fixtureStories = storiesOf('DashTable/Fixtures', module); -fixtures.forEach(fixture => fixtureStories.add(fixture.name, () => ())); +fixtures.forEach(fixture => { + // update active and selected cells for the new cell object format + const {data, columns, active_cell, selected_cells} = fixture.props; + if (Array.isArray(active_cell)) { + fixture.props.active_cell = makeCell( + active_cell[0], active_cell[1], data as any[], columns as any[] + ); + } + if (Array.isArray(selected_cells) && Array.isArray(selected_cells[0])) { + fixture.props.selected_cells = makeSelection( + selected_cells, data as any[], columns as any[] + ); + } + + fixtureStories.add(fixture.name, () => ()); +}); import dataset from './../../../datasets/gapminder.csv'; @@ -64,7 +96,7 @@ storiesOf('DashTable/With Data', module) ]} />)); -const columns = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] +const columnsA2J = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] .map(id => ({ id: id, name: id.toUpperCase() })); const idMap: { [key: string]: string } = { @@ -83,7 +115,7 @@ const idMap: { [key: string]: string } = { const mergedColumns = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] .map(id => ({ id: id, name: [idMap[id], id.toUpperCase()] })); -const data = (() => { +const dataA2J = (() => { const r = random(1); return R.range(0, 100).map(() => ( @@ -102,8 +134,8 @@ storiesOf('DashTable/Fixed Rows & Columns', module) .add('with 1 fixed row, 2 fixed columns', () => ( ( ( ()) .add('with 2 fixed rows, 3 fixed columns, hidden columns and merged cells', () => { - const testColumns = JSON.parse(JSON.stringify(columns)); + const testColumns = JSON.parse(JSON.stringify(columnsA2J)); testColumns[2].hidden = true; return ( column, { hidden: index % 2 === 0 } ]), - columns + columnsA2J ); storiesOf('DashTable/Hidden Columns', module) .add('hides', () => ()) .add('active cell', () => ()) .add('selected cells', () => ()); @@ -243,8 +275,8 @@ storiesOf('DashTable/Sorting', module) storiesOf('DashTable/Without id', module) .add('with 1 fixed row, 2 fixed columns', () => ()) .add('with 1 fixed row, 2 fixed columns, set height and width', () => ()) .add('with set height and width and colors', () => ( (