From d0b3faa0e3190bcb2b99f4f63338bf77359a128f Mon Sep 17 00:00:00 2001 From: Bill Dwyer Date: Mon, 28 Nov 2016 14:50:46 -0800 Subject: [PATCH] Controllable selection Adds "selectedRegions" prop to Table to allow to user to take over control of selection. If defined, the user will be responsible for updating the selection and should probable add a "onSelection" callback. Adds "selectedRegionTransform" prop to table to allow users to transform the selection region. This allows users to, for example, select a whole row when the user clicks on a single cell. Using a transform is advantageous because we can still handle interaction semantics like multiselect, meta-click, and click-to-deselect. --- packages/table/preview/index.html | 4 ++ packages/table/preview/index.tsx | 56 +++++++++++++++++- packages/table/src/headers/columnHeader.tsx | 2 + packages/table/src/headers/rowHeader.tsx | 2 + packages/table/src/index.ts | 5 ++ .../table/src/interactions/selectable.tsx | 38 +++++++++--- packages/table/src/table.tsx | 58 +++++++++++++++++-- packages/table/src/tableBody.tsx | 10 +++- packages/table/test/selectionTests.tsx | 21 +++++++ 9 files changed, 183 insertions(+), 13 deletions(-) diff --git a/packages/table/preview/index.html b/packages/table/preview/index.html index 1d4765d5db..d08a8a7a47 100644 --- a/packages/table/preview/index.html +++ b/packages/table/preview/index.html @@ -78,6 +78,10 @@

All Selection Modes, Body Context Menu



+

Row Transformed Controlled Selection

+
+

+

Ghost Inline



diff --git a/packages/table/preview/index.tsx b/packages/table/preview/index.tsx index 7deeee16a8..7f06b339fd 100644 --- a/packages/table/preview/index.tsx +++ b/packages/table/preview/index.tsx @@ -9,7 +9,14 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import { Intent, Menu, MenuItem, MenuDivider } from "@blueprintjs/core"; + +import { + Button, + Intent, + Menu, + MenuItem, + MenuDivider, +} from "@blueprintjs/core"; import { Cell, @@ -20,6 +27,7 @@ import { EditableName, IColumnHeaderCellProps, IColumnProps, + ICoordinateData, HorizontalCellDivider, IMenuContext, IRegion, @@ -257,6 +265,52 @@ ReactDOM.render( document.getElementById("table-big") ); +class RowSelectableTable extends React.Component<{}, {}> { + public state = { + selectedRegions: [ Regions.row(2) ], + }; + + public render() { + return (
+ + + + +
+
+ +
); + } + + private handleClear = () => { + this.setState({ selectedRegions: [] }); + } + + private handleSelection = (selectedRegions: IRegion[]) => { + this.setState({ selectedRegions }); + } + + private selectedRegionTransform = (region: IRegion) => { + // convert cell selection to row selection + if (Regions.getRegionCardinality(region) === RegionCardinality.CELLS) { + return Regions.row(region.rows[0], region.rows[1]); + } + + return region; + } +} + +ReactDOM.render( + , + document.getElementById("table-select-rows") +); + document.getElementById("table-ledger").classList.add("bp-table-striped"); ReactDOM.render( diff --git a/packages/table/src/headers/columnHeader.tsx b/packages/table/src/headers/columnHeader.tsx index 75eed4d107..747385971f 100644 --- a/packages/table/src/headers/columnHeader.tsx +++ b/packages/table/src/headers/columnHeader.tsx @@ -129,6 +129,7 @@ export class ColumnHeader extends React.Component { onResizeGuide, onSelection, selectedRegions, + selectedRegionTransform, } = this.props; const rect = grid.getColumnRect(columnIndex); @@ -162,6 +163,7 @@ export class ColumnHeader extends React.Component { locateDrag={this.locateDrag} onSelection={onSelection} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} > { onSelection, renderRowHeader, selectedRegions, + selectedRegionTransform, } = this.props; const rect = grid.getRowRect(rowIndex); @@ -155,6 +156,7 @@ export class RowHeader extends React.Component { locateDrag={this.locateDrag} onSelection={onSelection} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} > IRegion; + export interface ISelectableProps { /** * If `false`, only a single region of a single column/row/cell may be @@ -20,17 +22,27 @@ export interface ISelectableProps { */ allowMultipleSelection: boolean; + /** + * When the user selects something, this callback is called with a new + * array of Regions. This array should be considered the new selection + * state for the entire table. + */ + onSelection: (regions: IRegion[]) => void; + /** * An array containing the table's selection Regions. */ selectedRegions: IRegion[]; /** - * When the user selects something, this callback is called with a new - * array of Regions. This array should be considered the new selection - * state for the entire table. + * An optional transform function that will be applied to the located + * `Region`. + * + * This allows you to, for example, convert cell `Region`s into row + * `Region`s while maintaining the existing multi-select and meta-click + * functionality. */ - onSelection: (regions: IRegion[]) => void; + selectedRegionTransform?: ISelectedRegionTransform; } export interface IDragSelectableProps extends ISelectableProps { @@ -76,12 +88,16 @@ export class DragSelectable extends React.Component { return false; } - const region = this.props.locateClick(event); + let region = this.props.locateClick(event); if (!Regions.isValid(region)) { return false; } + if (this.props.selectedRegionTransform != null) { + region = this.props.selectedRegionTransform(region, event); + } + const foundIndex = Regions.findMatchingRegion(this.props.selectedRegions, region); if (foundIndex !== -1) { // If re-clicking on an existing region, we either carefully @@ -107,7 +123,7 @@ export class DragSelectable extends React.Component { } private handleDragMove = (event: MouseEvent, coords: ICoordinateData) => { - const region = (this.props.allowMultipleSelection) ? + let region = (this.props.allowMultipleSelection) ? this.props.locateDrag(event, coords) : this.props.locateClick(event); @@ -115,6 +131,10 @@ export class DragSelectable extends React.Component { return; } + if (this.props.selectedRegionTransform != null) { + region = this.props.selectedRegionTransform(region, event, coords); + } + this.props.onSelection(Regions.update(this.props.selectedRegions, region)); } @@ -123,13 +143,17 @@ export class DragSelectable extends React.Component { return false; } - const region = this.props.locateClick(event); + let region = this.props.locateClick(event); if (!Regions.isValid(region)) { this.props.onSelection([]); return false; } + if (this.props.selectedRegionTransform != null) { + region = this.props.selectedRegionTransform(region, event); + } + if (this.props.selectedRegions.length > 0) { this.props.onSelection(Regions.update(this.props.selectedRegions, region)); } else { diff --git a/packages/table/src/table.tsx b/packages/table/src/table.tsx index 77f0beb1d5..f8011e8c7b 100644 --- a/packages/table/src/table.tsx +++ b/packages/table/src/table.tsx @@ -20,6 +20,7 @@ import { IRowHeaderRenderer, IRowHeights, renderDefaultRowHeader, RowHeader } fr import { IContextMenuRenderer } from "./interactions/menus"; import { IIndexedResizeCallback } from "./interactions/resizable"; import { ResizeSensor } from "./interactions/resizeSensor"; +import { ISelectedRegionTransform } from "./interactions/selectable"; import { GuideLayer } from "./layers/guides"; import { IRegionStyler, RegionLayer } from "./layers/regions"; import { Locator } from "./locator"; @@ -119,6 +120,29 @@ export interface ITableProps extends IProps, IRowHeights, IColumnWidths { */ numRows?: number; + /** + * If defined, will set the selected regions in the cells. If defined, this + * changes table selection to "controlled" mode, meaning you in charge of + * setting the selections in response to events in the `onSelection` + * callback. + * + * Note that the `selectionModes` prop controls which types of events are + * triggered to the `onSelection` callback, but does not restrict what + * selection you can pass to the `selectedRegions` prop. Therefore you can, + * for example, convert cell clicks into row selections. + */ + selectedRegions?: IRegion[]; + + /** + * An optional transform function that will be applied to the located + * `Region`. + * + * This allows you to, for example, convert cell `Region`s into row + * `Region`s while maintaining the existing multi-select and meta-click + * functionality. + */ + selectedRegionTransform?: ISelectedRegionTransform; + /** * A `SelectionModes` enum value indicating the selection mode. You may * equivalently provide an array of `RegionCardinality` enum values for @@ -254,16 +278,26 @@ export class Table extends AbstractComponent { let newRowHeights = Utils.times(numRows, () => defaultRowHeight); newRowHeights = Utils.assignSparseValues(newRowHeights, rowHeights); + const selectedRegions = (props.selectedRegions == null) ? [] as IRegion[] : props.selectedRegions; + this.state = { columnWidths: newColumnWidths, isLayoutLocked: false, rowHeights: newRowHeights, - selectedRegions: [], + selectedRegions, }; } public componentWillReceiveProps(nextProps: ITableProps) { - const { defaultRowHeight, defaultColumnWidth, columnWidths, rowHeights, children, numRows } = nextProps; + const { + defaultRowHeight, + defaultColumnWidth, + columnWidths, + rowHeights, + children, + numRows, + selectedRegions, + } = nextProps; const newChildArray = React.Children.toArray(children) as Array>; // Try to maintain widths of columns by looking up the width of the @@ -286,12 +320,15 @@ export class Table extends AbstractComponent { newRowHeights = Utils.arrayOfLength(newRowHeights, numRows, defaultRowHeight); newRowHeights = Utils.assignSparseValues(newRowHeights, rowHeights); + const newselectedRegions = (selectedRegions == null) ? this.state.selectedRegions : selectedRegions; + this.childrenArray = newChildArray; this.columnIdToIndex = Table.createColumnIdIndex(this.childrenArray); this.invalidateGrid(); this.setState({ columnWidths: newColumnWidths, rowHeights: newRowHeights, + selectedRegions: newselectedRegions, }); } @@ -414,6 +451,7 @@ export class Table extends AbstractComponent { isColumnResizable, maxColumnWidth, minColumnWidth, + selectedRegionTransform, } = this.props; const classes = classNames("bp-table-column-headers", { "bp-table-selection-enabled": this.isSelectionModeEnabled(RegionCardinality.FULL_COLUMNS), @@ -435,6 +473,7 @@ export class Table extends AbstractComponent { onResizeGuide={this.handleColumnResizeGuide} onSelection={this.getEnabledSelectionHandler(RegionCardinality.FULL_COLUMNS)} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} viewportRect={viewportRect} {...columnIndices} > @@ -456,6 +495,7 @@ export class Table extends AbstractComponent { maxRowHeight, minRowHeight, renderRowHeader, + selectedRegionTransform, } = this.props; const classes = classNames("bp-table-row-headers", { "bp-table-selection-enabled": this.isSelectionModeEnabled(RegionCardinality.FULL_ROWS), @@ -479,6 +519,7 @@ export class Table extends AbstractComponent { onSelection={this.getEnabledSelectionHandler(RegionCardinality.FULL_ROWS)} renderRowHeader={renderRowHeader} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} viewportRect={viewportRect} {...rowIndices} /> @@ -494,7 +535,12 @@ export class Table extends AbstractComponent { private renderBody() { const { grid } = this; - const { allowMultipleSelection, fillBodyWithGhostCells, renderBodyContextMenu } = this.props; + const { + allowMultipleSelection, + fillBodyWithGhostCells, + renderBodyContextMenu, + selectedRegionTransform, + } = this.props; const { locator, selectedRegions, viewportRect, verticalGuides, horizontalGuides } = this.state; const style = grid.getRect().sizeStyle(); @@ -528,6 +574,7 @@ export class Table extends AbstractComponent { onSelection={this.getEnabledSelectionHandler(RegionCardinality.CELLS)} renderBodyContextMenu={renderBodyContextMenu} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} viewportRect={viewportRect} {...rowIndices} {...columnIndices} @@ -741,7 +788,10 @@ export class Table extends AbstractComponent { } private handleSelection = (selectedRegions: IRegion[]) => { - this.setState({ selectedRegions } as ITableState); + // only set selectedRegions state if not specified in props + if (this.props.selectedRegions == null) { + this.setState({ selectedRegions } as ITableState); + } const { onSelection } = this.props; if (onSelection != null) { diff --git a/packages/table/src/tableBody.tsx b/packages/table/src/tableBody.tsx index 27f57bda89..26eddc3fdb 100644 --- a/packages/table/src/tableBody.tsx +++ b/packages/table/src/tableBody.tsx @@ -141,7 +141,14 @@ export class TableBody extends React.Component { } private renderCell = (rowIndex: number, columnIndex: number, extremaClasses: string[]) => { - const { allowMultipleSelection, grid, cellRenderer, selectedRegions, onSelection } = this.props; + const { + allowMultipleSelection, + grid, + cellRenderer, + onSelection, + selectedRegions, + selectedRegionTransform, + } = this.props; const cell = Utils.assignClasses( cellRenderer(rowIndex, columnIndex), TableBody.cellClassNames(rowIndex, columnIndex), @@ -160,6 +167,7 @@ export class TableBody extends React.Component { locateDrag={this.locateDrag} onSelection={onSelection} selectedRegions={selectedRegions} + selectedRegionTransform={selectedRegionTransform} > {React.cloneElement(cell, { style } as ICellProps)} diff --git a/packages/table/test/selectionTests.tsx b/packages/table/test/selectionTests.tsx index 8becb519af..fe26edf604 100644 --- a/packages/table/test/selectionTests.tsx +++ b/packages/table/test/selectionTests.tsx @@ -15,6 +15,7 @@ describe("Selection", () => { let harness = new ReactHarness(); const TH_SELECTOR = ".bp-table-column-headers .bp-table-header"; const ROW_TH_SELECTOR = ".bp-table-row-headers .bp-table-header"; + const CELL_SELECTOR = ".bp-table-cell-row-2.bp-table-cell-col-0"; afterEach(() => { harness.unmount(); @@ -96,6 +97,26 @@ describe("Selection", () => { expect(onSelection.lastCall.args).to.deep.equal([[]], "meta key clear"); }); + it("Transforms regions on selections", () => { + const selectedRegionTransform = () => { + return Regions.row(1); + }; + const onSelection = sinon.spy(); + const table = harness.mount(createTableOfSize(3, 7, {}, {onSelection, selectedRegionTransform})); + + // clicking adds transformed selection + table.find(CELL_SELECTOR).mouse("mousedown").mouse("mouseup"); + + expect(onSelection.called).to.equal(true); + expect(onSelection.lastCall.args).to.deep.equal([[Regions.row(1)]]); + }); + + it("Accepts controlled selection", () => { + const table = harness.mount(createTableOfSize(3, 7, {}, { selectedRegions: [ Regions.row(0) ]})); + const selectionRegion = table.find(".bp-table-selection-region"); + expect(selectionRegion.element).to.exist; + }); + // TODO fix these tests on CircleCI. // // These tests pass locally. They are disabled because in the linux