xref: /aosp_15_r20/external/perfetto/ui/src/widgets/basic_table.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2023 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use size file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {scheduleFullRedraw} from './raf';
17
18export interface ColumnDescriptor<T> {
19  readonly title: m.Children;
20  render: (row: T) => m.Children;
21}
22
23// This is a class to be able to perform runtime checks on `columns` below.
24export class ReorderableColumns<T> {
25  constructor(
26    public columns: ColumnDescriptor<T>[],
27    public reorder?: (from: number, to: number) => void,
28  ) {}
29}
30
31export interface TableAttrs<T> {
32  readonly data: ReadonlyArray<T>;
33  readonly columns: ReadonlyArray<ColumnDescriptor<T> | ReorderableColumns<T>>;
34}
35
36export class BasicTable<T> implements m.ClassComponent<TableAttrs<T>> {
37  view(vnode: m.Vnode<TableAttrs<T>>): m.Children {
38    const attrs = vnode.attrs;
39    const columnBlocks: ColumnBlock<T>[] = getColumns(attrs);
40
41    const columns: {column: ColumnDescriptor<T>; extraClasses: string}[] = [];
42    const headers: m.Children[] = [];
43    for (const [blockIndex, block] of columnBlocks.entries()) {
44      const currentColumns = block.columns.map((column, columnIndex) => ({
45        column,
46        extraClasses:
47          columnIndex === 0 && blockIndex !== 0 ? '.has-left-border' : '',
48      }));
49      if (block.reorder === undefined) {
50        for (const {column, extraClasses} of currentColumns) {
51          headers.push(m(`td${extraClasses}`, column.title));
52        }
53      } else {
54        headers.push(
55          m(ReorderableCellGroup, {
56            cells: currentColumns.map(({column, extraClasses}) => ({
57              content: column.title,
58              extraClasses,
59            })),
60            onReorder: block.reorder,
61          }),
62        );
63      }
64      columns.push(...currentColumns);
65    }
66
67    return m(
68      'table.generic-table',
69      {
70        // TODO(altimin, stevegolton): this should be the default for
71        // generic-table, but currently it is overriden by
72        // .pf-details-shell .pf-content table, so specify this here for now.
73        style: {
74          'table-layout': 'auto',
75        },
76      },
77      m('thead', m('tr.header', headers)),
78      attrs.data.map((row) =>
79        m(
80          'tr',
81          columns.map(({column, extraClasses}) =>
82            m(`td${extraClasses}`, column.render(row)),
83          ),
84        ),
85      ),
86    );
87  }
88}
89
90type ColumnBlock<T> = {
91  columns: ColumnDescriptor<T>[];
92  reorder?: (from: number, to: number) => void;
93};
94
95function getColumns<T>(attrs: TableAttrs<T>): ColumnBlock<T>[] {
96  const result: ColumnBlock<T>[] = [];
97  let current: ColumnBlock<T> = {columns: []};
98  for (const col of attrs.columns) {
99    if (col instanceof ReorderableColumns) {
100      if (current.columns.length > 0) {
101        result.push(current);
102        current = {columns: []};
103      }
104      result.push(col);
105    } else {
106      current.columns.push(col);
107    }
108  }
109  if (current.columns.length > 0) {
110    result.push(current);
111  }
112  return result;
113}
114
115export interface ReorderableCellGroupAttrs {
116  cells: {
117    content: m.Children;
118    extraClasses: string;
119  }[];
120  onReorder: (from: number, to: number) => void;
121}
122
123const placeholderElement = document.createElement('span');
124
125// A component that renders a group of cells on the same row that can be
126// reordered between each other by using drag'n'drop.
127//
128// On completed reorder, a callback is fired.
129class ReorderableCellGroup
130  implements m.ClassComponent<ReorderableCellGroupAttrs>
131{
132  private drag?: {
133    from: number;
134    to?: number;
135  };
136
137  private getClassForIndex(index: number): string {
138    if (this.drag?.from === index) {
139      return 'dragged';
140    }
141    if (this.drag?.to === index) {
142      return 'highlight-left';
143    }
144    if (this.drag?.to === index + 1) {
145      return 'highlight-right';
146    }
147    return '';
148  }
149
150  view(vnode: m.Vnode<ReorderableCellGroupAttrs>): m.Children {
151    return vnode.attrs.cells.map((cell, index) =>
152      m(
153        `td.reorderable-cell${cell.extraClasses}`,
154        {
155          draggable: 'draggable',
156          class: this.getClassForIndex(index),
157          ondragstart: (e: DragEvent) => {
158            this.drag = {
159              from: index,
160            };
161            if (e.dataTransfer !== null) {
162              e.dataTransfer.setDragImage(placeholderElement, 0, 0);
163            }
164
165            scheduleFullRedraw();
166          },
167          ondragover: (e: DragEvent) => {
168            let target = e.target as HTMLElement;
169            if (this.drag === undefined || this.drag?.from === index) {
170              // Don't do anything when hovering on the same cell that's
171              // been dragged, or when dragging something other than the
172              // cell from the same group.
173              return;
174            }
175
176            while (
177              target.tagName.toLowerCase() !== 'td' &&
178              target.parentElement !== null
179            ) {
180              target = target.parentElement;
181            }
182
183            // When hovering over cell on the right half, the cell will be
184            // moved to the right of it, vice versa for the left side. This
185            // is done such that it's possible to put dragged cell to every
186            // possible position.
187            const offset = e.clientX - target.getBoundingClientRect().x;
188            const direction =
189              offset > target.clientWidth / 2 ? 'right' : 'left';
190            const dest = direction === 'left' ? index : index + 1;
191            const adjustedDest =
192              dest === this.drag.from || dest === this.drag.from + 1
193                ? undefined
194                : dest;
195            if (adjustedDest !== this.drag.to) {
196              this.drag.to = adjustedDest;
197              scheduleFullRedraw();
198            }
199          },
200          ondragleave: (e: DragEvent) => {
201            if (this.drag?.to !== index) return;
202            this.drag.to = undefined;
203            scheduleFullRedraw();
204            if (e.dataTransfer !== null) {
205              e.dataTransfer.dropEffect = 'none';
206            }
207          },
208          ondragend: () => {
209            if (
210              this.drag !== undefined &&
211              this.drag.to !== undefined &&
212              this.drag.from !== this.drag.to
213            ) {
214              vnode.attrs.onReorder(this.drag.from, this.drag.to);
215            }
216
217            this.drag = undefined;
218            scheduleFullRedraw();
219          },
220        },
221        cell.content,
222      ),
223    );
224  }
225}
226