diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss index 26a03a0789..d88c8f1c3f 100644 --- a/ui/src/assets/common.scss +++ b/ui/src/assets/common.scss @@ -195,6 +195,10 @@ $bottom-tab-padding: 10px; thead { font-weight: normal; + + td.reorderable-cell { + cursor: grab; + } } tr:hover td { @@ -215,6 +219,10 @@ $bottom-tab-padding: 10px; // density a little bit. font-size: 16px; } + + has-left-border { + border-left: 1px solid rgba(60, 76, 92, 0.4); + } } } @@ -233,9 +241,6 @@ $bottom-tab-padding: 10px; border-left: 1px solid $table-border-color; padding-left: 6px; } - thead td.reorderable-cell { - cursor: grab; - } .disabled { cursor: default; } diff --git a/ui/src/frontend/widgets/sql/table/state.ts b/ui/src/frontend/widgets/sql/table/state.ts index cb1b7d5000..1976f934c6 100644 --- a/ui/src/frontend/widgets/sql/table/state.ts +++ b/ui/src/frontend/widgets/sql/table/state.ts @@ -423,6 +423,18 @@ export class SqlTableState { this.reload({offset: 'keep'}); } + moveColumn(fromIndex: number, toIndex: number) { + if (fromIndex === toIndex) return; + const column = this.columns[fromIndex]; + this.columns.splice(fromIndex, 1); + if (fromIndex < toIndex) { + // We have deleted a column, therefore we need to adjust the target index. + --toIndex; + } + this.columns.splice(toIndex, 0, column); + raf.scheduleFullRedraw(); + } + getSelectedColumns(): TableColumn[] { return this.columns; } diff --git a/ui/src/frontend/widgets/sql/table/table.ts b/ui/src/frontend/widgets/sql/table/table.ts index e80d269b0e..7aeafd0ac5 100644 --- a/ui/src/frontend/widgets/sql/table/table.ts +++ b/ui/src/frontend/widgets/sql/table/table.ts @@ -37,7 +37,7 @@ import { SqlValue, } from '../../../../trace_processor/query_result'; import {Anchor} from '../../../../widgets/anchor'; -import {BasicTable} from '../../../../widgets/basic_table'; +import {BasicTable, ReorderableColumns} from '../../../../widgets/basic_table'; import {Spinner} from '../../../../widgets/spinner'; import {ArgumentSelector} from './argument_selector'; @@ -341,18 +341,26 @@ export class SqlTable implements m.ClassComponent { view() { const rows = this.state.getDisplayedRows(); + const columns = this.state.getSelectedColumns(); + const columnDescriptors = columns.map((column, i) => { + return { + title: this.renderColumnHeader(column, i), + render: (row: Row) => renderCell(column, row, this.state), + }; + }); + return [ m('div', this.renderFilters()), m( BasicTable, { data: rows, - columns: this.state.getSelectedColumns().map((column, i) => { - return { - title: this.renderColumnHeader(column, i), - render: (row: Row) => renderCell(column, row, this.state), - }; - }), + columns: [ + new ReorderableColumns( + columnDescriptors, + (from: number, to: number) => this.state.moveColumn(from, to), + ), + ], }, this.state.isLoading() && m(Spinner), this.state.getQueryError() !== undefined && diff --git a/ui/src/widgets/basic_table.ts b/ui/src/widgets/basic_table.ts index 5231a9fafd..64490e535d 100644 --- a/ui/src/widgets/basic_table.ts +++ b/ui/src/widgets/basic_table.ts @@ -13,27 +13,56 @@ // limitations under the License. import m from 'mithril'; +import {scheduleFullRedraw} from './raf'; export interface ColumnDescriptor { readonly title: m.Children; render: (row: T) => m.Children; } +// This is a class to be able to perform runtime checks on `columns` below. +export class ReorderableColumns { + constructor( + public columns: ColumnDescriptor[], + public reorder?: (from: number, to: number) => void, + ) {} +} + export interface TableAttrs { readonly data: ReadonlyArray; - readonly columns: ReadonlyArray>; + readonly columns: ReadonlyArray | ReorderableColumns>; } export class BasicTable implements m.ClassComponent> { - private renderColumnHeader( - _vnode: m.Vnode>, - column: ColumnDescriptor, - ): m.Children { - return m('td', column.title); - } - view(vnode: m.Vnode>): m.Children { const attrs = vnode.attrs; + const columnBlocks: ColumnBlock[] = getColumns(attrs); + + const columns: {column: ColumnDescriptor; extraClasses: string}[] = []; + const headers: m.Children[] = []; + for (const [blockIndex, block] of columnBlocks.entries()) { + const currentColumns = block.columns.map((column, columnIndex) => ({ + column, + extraClasses: + columnIndex === 0 && blockIndex !== 0 ? '.has-left-border' : '', + })); + if (block.reorder === undefined) { + for (const {column, extraClasses} of currentColumns) { + headers.push(m(`td${extraClasses}`, column.title)); + } + } else { + headers.push( + m(ReorderableCellGroup, { + cells: currentColumns.map(({column, extraClasses}) => ({ + content: column.title, + extraClasses, + })), + onReorder: block.reorder, + }), + ); + } + columns.push(...currentColumns); + } return m( 'table.generic-table', @@ -45,19 +74,152 @@ export class BasicTable implements m.ClassComponent> { 'table-layout': 'auto', }, }, - m( - 'thead', - m( - 'tr.header', - attrs.columns.map((column) => this.renderColumnHeader(vnode, column)), - ), - ), + m('thead', m('tr.header', headers)), attrs.data.map((row) => m( 'tr', - attrs.columns.map((column) => m('td', column.render(row))), + columns.map(({column, extraClasses}) => + m(`td${extraClasses}`, column.render(row)), + ), ), ), ); } } + +type ColumnBlock = { + columns: ColumnDescriptor[]; + reorder?: (from: number, to: number) => void; +}; + +function getColumns(attrs: TableAttrs): ColumnBlock[] { + const result: ColumnBlock[] = []; + let current: ColumnBlock = {columns: []}; + for (const col of attrs.columns) { + if (col instanceof ReorderableColumns) { + if (current.columns.length > 0) { + result.push(current); + current = {columns: []}; + } + result.push(col); + } else { + current.columns.push(col); + } + } + if (current.columns.length > 0) { + result.push(current); + } + return result; +} + +export interface ReorderableCellGroupAttrs { + cells: { + content: m.Children; + extraClasses: string; + }[]; + onReorder: (from: number, to: number) => void; +} + +const placeholderElement = document.createElement('span'); + +// A component that renders a group of cells on the same row that can be +// reordered between each other by using drag'n'drop. +// +// On completed reorder, a callback is fired. +class ReorderableCellGroup + implements m.ClassComponent +{ + private drag?: { + from: number; + to?: number; + }; + + private getClassForIndex(index: number): string { + if (this.drag?.from === index) { + return 'dragged'; + } + if (this.drag?.to === index) { + return 'highlight-left'; + } + if (this.drag?.to === index + 1) { + return 'highlight-right'; + } + return ''; + } + + view(vnode: m.Vnode): m.Children { + return vnode.attrs.cells.map((cell, index) => + m( + `td.reorderable-cell${cell.extraClasses}`, + { + draggable: 'draggable', + class: this.getClassForIndex(index), + ondragstart: (e: DragEvent) => { + this.drag = { + from: index, + }; + if (e.dataTransfer !== null) { + e.dataTransfer.setDragImage(placeholderElement, 0, 0); + } + + scheduleFullRedraw(); + }, + ondragover: (e: DragEvent) => { + let target = e.target as HTMLElement; + if (this.drag === undefined || this.drag?.from === index) { + // Don't do anything when hovering on the same cell that's + // been dragged, or when dragging something other than the + // cell from the same group. + return; + } + + while ( + target.tagName.toLowerCase() !== 'td' && + target.parentElement !== null + ) { + target = target.parentElement; + } + + // When hovering over cell on the right half, the cell will be + // moved to the right of it, vice versa for the left side. This + // is done such that it's possible to put dragged cell to every + // possible position. + const offset = e.clientX - target.getBoundingClientRect().x; + const direction = + offset > target.clientWidth / 2 ? 'right' : 'left'; + const dest = direction === 'left' ? index : index + 1; + const adjustedDest = + dest === this.drag.from || dest === this.drag.from + 1 + ? undefined + : dest; + if (adjustedDest !== this.drag.to) { + this.drag.to = adjustedDest; + scheduleFullRedraw(); + } + }, + ondragleave: (e: DragEvent) => { + if (this.drag?.to !== index) return; + this.drag.to = undefined; + scheduleFullRedraw(); + if (e.dataTransfer !== null) { + e.dataTransfer.dropEffect = 'none'; + } + }, + ondragend: () => { + if ( + this.drag !== undefined && + this.drag.to !== undefined && + this.drag.from !== this.drag.to + ) { + vnode.attrs.onReorder(this.drag.from, this.drag.to); + } + + this.drag = undefined; + scheduleFullRedraw(); + }, + }, + cell.content, + ), + ); + } +}