diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.css b/frontend/src/metabase/visualizations/components/TableInteractive.css index 354c88e47eb0303d5660672353cf3f294b9a5cc3..79d44f33d604b73a98d5f9afc77d3353937b3b1c 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.css +++ b/frontend/src/metabase/visualizations/components/TableInteractive.css @@ -50,11 +50,17 @@ border-bottom: 1px solid var(--table-border-color); } -.TableInteractive .TableInteractive-cellWrapper:hover { +.TableInteractive .TableInteractive-cellWrapper--active, +.TableInteractive:not(.TableInteractive--noHover) + .TableInteractive-cellWrapper:hover { border-color: var(--brand-color); color: var(--brand-color); } +.TableInteractive .TableInteractive-cellWrapper--active { + z-index: 1; +} + .TableInteractive .TableInteractive-header, .TableInteractive .TableInteractive-header .TableInteractive-cellWrapper { background-color: #fff; diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index 3bd56696b7d5ee392bde4cfd3a9fab2ed675e8b5..7a8e6fbfa5860b58a3647fe24bc1e011c56990d4 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -29,6 +29,9 @@ const ROW_HEIGHT = 30; const MIN_COLUMN_WIDTH = ROW_HEIGHT; const RESIZE_HANDLE_WIDTH = 5; +// HACK: used to get react-draggable to reset after a drag +let DRAG_COUNTER = 0; + import type { VisualizationProps } from "metabase/meta/types/Visualization"; function pickRowsToMeasure(rows, columnIndex, count = 10) { @@ -244,6 +247,15 @@ export default class TableInteractive extends Component { setTimeout(() => this.recomputeGridSize(), 1); } + onColumnReorder(columnIndex: number, newColumnIndex: number) { + const { settings, onUpdateVisualizationSettings } = this.props; + const columns = settings["table.columns"].slice(); // copy since splice mutates + columns.splice(newColumnIndex, 0, columns.splice(columnIndex, 1)[0]); + onUpdateVisualizationSettings({ + "table.columns": columns, + }); + } + cellRenderer = ({ key, style, rowIndex, columnIndex }: CellRendererProps) => { const { data, @@ -269,7 +281,11 @@ export default class TableInteractive extends Component { return ( <div key={key} - style={style} + style={{ + ...style, + // use computed left if dragging + left: this.getColumnLeft(style, columnIndex), + }} className={cx("TableInteractive-cellWrapper", { "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, "TableInteractive-cellWrapper--lastColumn": @@ -299,6 +315,62 @@ export default class TableInteractive extends Component { ); }; + getDragColNewIndex(data) { + const { columnPositions, dragColNewIndex, dragColStyle } = this.state; + if (data.x < 0) { + const left = dragColStyle.left + data.x; + const index = _.findIndex(columnPositions, p => left < p.center); + if (index >= 0) { + return index; + } + } else if (data.x > 0) { + const right = dragColStyle.left + dragColStyle.width + data.x; + const index = _.findLastIndex(columnPositions, p => right > p.center); + if (index >= 0) { + return index; + } + } + return dragColNewIndex; + } + + getColumnPositions() { + let left = 0; + return this.props.data.cols.map((col, index) => { + const width = this.getColumnWidth({ index }); + const pos = { + left, + right: left + width, + center: left + width / 2, + width, + }; + left += width; + return pos; + }); + } + + getNewColumnLefts(dragColNewIndex) { + const { dragColIndex, columnPositions } = this.state; + const { cols } = this.props.data; + const indexes = cols.map((col, index) => index); + indexes.splice(dragColNewIndex, 0, indexes.splice(dragColIndex, 1)[0]); + let left = 0; + const lefts = indexes.map(index => { + const thisLeft = left; + left += columnPositions[index].width; + return { index, left: thisLeft }; + }); + lefts.sort((a, b) => a.index - b.index); + return lefts.map(p => p.left); + } + + getColumnLeft(style, index) { + const { dragColNewIndex, dragColNewLefts } = this.state; + if (dragColNewIndex != null && dragColNewLefts) { + return dragColNewLefts[index]; + } + return style.left; + } + tableHeaderRenderer = ({ key, style, columnIndex }: CellRendererProps) => { const { sort, @@ -325,6 +397,8 @@ export default class TableInteractive extends Component { clicked = { column }; } + const isDragging = this.state.dragColIndex === columnIndex; + const isClickable = onVisualizationClick && visualizationIsClickable(clicked); const isSortable = isClickable && column.source; @@ -334,77 +408,126 @@ export default class TableInteractive extends Component { const isSorted = sort && sort[0] && sort[0][0] && sort[0][0][1] === column.id; const isAscending = sort && sort[0] && sort[0][1] === "ascending"; - return ( - <div - key={key} - style={{ - ...style, - overflow: "visible" /* ensure resize handle is visible */, + <Draggable + /* needs to be index+name+counter so Draggable resets after each drag */ + key={columnIndex + column.name + DRAG_COUNTER} + axis="x" + onStart={(e, d) => { + this.setState({ + columnPositions: this.getColumnPositions(), + dragColIndex: columnIndex, + dragColStyle: style, + dragColNewIndex: columnIndex, + }); + }} + onDrag={(e, data) => { + const newIndex = this.getDragColNewIndex(data); + if (newIndex !== this.state.dragColNewIndex) { + this.setState({ + dragColNewIndex: newIndex, + dragColNewLefts: this.getNewColumnLefts(newIndex), + }); + } + }} + onStop={(e, d) => { + DRAG_COUNTER++; + const { dragColIndex, dragColNewIndex } = this.state; + if (dragColIndex !== dragColNewIndex) { + this.onColumnReorder(dragColIndex, dragColNewIndex); + } + this.setState({ + columnPositions: null, + dragColIndex: null, + dragColStyle: null, + dragColNewIndex: null, + dragColNewLefts: null, + }); }} - className={cx( - "TableInteractive-cellWrapper TableInteractive-headerCellData", - { - "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, - "TableInteractive-cellWrapper--lastColumn": - columnIndex === cols.length - 1, - "TableInteractive-headerCellData--sorted": isSorted, - "cursor-pointer": isClickable, - "justify-end": isRightAligned, - }, - )} - // use onMouseUp instead of onClick since we can stopPropation when resizing headers - onMouseUp={ - isClickable - ? e => { - onVisualizationClick({ ...clicked, element: e.currentTarget }); - } - : undefined - } > - <div className="cellData"> - {isSortable && - isRightAligned && ( - <Icon - className="Icon mr1" - name={isAscending ? "chevronup" : "chevrondown"} - size={8} - /> - )} - {columnTitle} - {isSortable && - !isRightAligned && ( - <Icon - className="Icon ml1" - name={isAscending ? "chevronup" : "chevrondown"} - size={8} - /> - )} - </div> - <Draggable - axis="x" - bounds={{ left: RESIZE_HANDLE_WIDTH }} - position={{ x: this.getColumnWidth({ index: columnIndex }), y: 0 }} - onStop={(e, { x }) => { - // prevent onVisualizationClick from being fired - e.stopPropagation(); - this.onColumnResize(columnIndex, x); + <div + key={key} + style={{ + ...style, + overflow: "visible" /* ensure resize handle is visible */, + // use computed left if dragging, except for the dragged header + left: isDragging + ? style.left + : this.getColumnLeft(style, columnIndex), }} + className={cx( + "TableInteractive-cellWrapper TableInteractive-headerCellData", + { + "TableInteractive-cellWrapper--firstColumn": columnIndex === 0, + "TableInteractive-cellWrapper--lastColumn": + columnIndex === cols.length - 1, + "TableInteractive-cellWrapper--active": isDragging, + "TableInteractive-headerCellData--sorted": isSorted, + "cursor-pointer": isClickable, + "justify-end": isRightAligned, + }, + )} + // use onMouseUp instead of onClick since we can stopPropation when resizing headers + onMouseUp={ + isClickable + ? e => { + onVisualizationClick({ + ...clicked, + element: e.currentTarget, + }); + } + : undefined + } > - <div - className="bg-brand-hover bg-brand-active" - style={{ - zIndex: 99, - position: "absolute", - width: RESIZE_HANDLE_WIDTH, - top: 0, - bottom: 0, - left: -RESIZE_HANDLE_WIDTH - 1, - cursor: "ew-resize", + <div className="cellData"> + {isSortable && + isRightAligned && ( + <Icon + className="Icon mr1" + name={isAscending ? "chevronup" : "chevrondown"} + size={8} + /> + )} + {columnTitle} + {isSortable && + !isRightAligned && ( + <Icon + className="Icon ml1" + name={isAscending ? "chevronup" : "chevrondown"} + size={8} + /> + )} + </div> + <Draggable + axis="x" + bounds={{ left: RESIZE_HANDLE_WIDTH }} + position={{ x: this.getColumnWidth({ index: columnIndex }), y: 0 }} + onStart={e => { + e.stopPropagation(); + this.setState({ dragColIndex: columnIndex }); }} - /> - </Draggable> - </div> + onStop={(e, { x }) => { + // prevent onVisualizationClick from being fired + e.stopPropagation(); + this.onColumnResize(columnIndex, x); + this.setState({ dragColIndex: null }); + }} + > + <div + className="bg-brand-hover bg-brand-active" + style={{ + zIndex: 99, + position: "absolute", + width: RESIZE_HANDLE_WIDTH, + top: 0, + bottom: 0, + left: -RESIZE_HANDLE_WIDTH - 1, + cursor: "ew-resize", + }} + /> + </Draggable> + </div> + </Draggable> ); }; @@ -439,6 +562,8 @@ export default class TableInteractive extends Component { className={cx(className, "TableInteractive relative", { "TableInteractive--pivot": this.props.isPivoted, "TableInteractive--ready": this.state.contentWidths, + // no hover if we're dragging a column + "TableInteractive--noHover": this.state.dragColIndex != null, })} > <canvas