Skip to content

Commit

Permalink
[ui] Make table columns reorderable
Browse files Browse the repository at this point in the history
Implement drag'n'drop reorderability support for BasicTable and leverage
it in SqlTable.

The implementation is mostly copied from reorderable_cells.ts with minor
improvements. The old implementation continues to live in
reorderable_cells.ts as I'm not touching the old pivot table
implementation if I can help it.

Change-Id: Icfa553732f64170ed6b0d4a030f9c9a62359e6cf
  • Loading branch information
Alexander Timin committed Sep 30, 2024
1 parent 6b3daa9 commit 5063b37
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 26 deletions.
11 changes: 8 additions & 3 deletions ui/src/assets/common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ $bottom-tab-padding: 10px;

thead {
font-weight: normal;

td.reorderable-cell {
cursor: grab;
}
}

tr:hover td {
Expand All @@ -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);
}
}
}

Expand All @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions ui/src/frontend/widgets/sql/table/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
22 changes: 15 additions & 7 deletions ui/src/frontend/widgets/sql/table/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -341,18 +341,26 @@ export class SqlTable implements m.ClassComponent<SqlTableConfig> {
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<Row>,
{
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 &&
Expand Down
194 changes: 178 additions & 16 deletions ui/src/widgets/basic_table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,56 @@
// limitations under the License.

import m from 'mithril';
import {scheduleFullRedraw} from './raf';

export interface ColumnDescriptor<T> {
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<T> {
constructor(
public columns: ColumnDescriptor<T>[],
public reorder?: (from: number, to: number) => void,
) {}
}

export interface TableAttrs<T> {
readonly data: ReadonlyArray<T>;
readonly columns: ReadonlyArray<ColumnDescriptor<T>>;
readonly columns: ReadonlyArray<ColumnDescriptor<T> | ReorderableColumns<T>>;
}

export class BasicTable<T> implements m.ClassComponent<TableAttrs<T>> {
private renderColumnHeader(
_vnode: m.Vnode<TableAttrs<T>>,
column: ColumnDescriptor<T>,
): m.Children {
return m('td', column.title);
}

view(vnode: m.Vnode<TableAttrs<T>>): m.Children {
const attrs = vnode.attrs;
const columnBlocks: ColumnBlock<T>[] = getColumns(attrs);

const columns: {column: ColumnDescriptor<T>; 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',
Expand All @@ -45,19 +74,152 @@ export class BasicTable<T> implements m.ClassComponent<TableAttrs<T>> {
'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<T> = {
columns: ColumnDescriptor<T>[];
reorder?: (from: number, to: number) => void;
};

function getColumns<T>(attrs: TableAttrs<T>): ColumnBlock<T>[] {
const result: ColumnBlock<T>[] = [];
let current: ColumnBlock<T> = {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<ReorderableCellGroupAttrs>
{
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<ReorderableCellGroupAttrs>): 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,
),
);
}
}

0 comments on commit 5063b37

Please sign in to comment.