xref: /aosp_15_r20/external/perfetto/ui/src/widgets/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 {allUnique, range} from '../base/array_utils';
17import {
18  compareUniversal,
19  comparingBy,
20  ComparisonFn,
21  SortableValue,
22  SortDirection,
23  withDirection,
24} from '../base/comparison_utils';
25import {scheduleFullRedraw} from './raf';
26import {MenuItem, PopupMenu2} from './menu';
27import {Button} from './button';
28
29// For a table column that can be sorted; the standard popup icon should
30// reflect the current sorting direction. This function returns an icon
31// corresponding to optional SortDirection according to which the column is
32// sorted. (Optional because column might be unsorted)
33export function popupMenuIcon(sortDirection?: SortDirection) {
34  switch (sortDirection) {
35    case undefined:
36      return 'more_horiz';
37    case 'DESC':
38      return 'arrow_drop_down';
39    case 'ASC':
40      return 'arrow_drop_up';
41  }
42}
43
44export interface ColumnDescriptorAttrs<T> {
45  // Context menu items displayed on the column header.
46  contextMenu?: m.Child[];
47
48  // Unique column ID, used to identify which column is currently sorted.
49  columnId?: string;
50
51  // Sorting predicate: if provided, column would be sortable.
52  ordering?: ComparisonFn<T>;
53
54  // Simpler way to provide a sorting: instead of full predicate, the function
55  // can map the row for "sorting key" associated with the column.
56  sortKey?: (value: T) => SortableValue;
57}
58
59export class ColumnDescriptor<T> {
60  name: string;
61  render: (row: T) => m.Child;
62  id: string;
63  contextMenu?: m.Child[];
64  ordering?: ComparisonFn<T>;
65
66  constructor(
67    name: string,
68    render: (row: T) => m.Child,
69    attrs?: ColumnDescriptorAttrs<T>,
70  ) {
71    this.name = name;
72    this.render = render;
73    this.id = attrs?.columnId === undefined ? name : attrs.columnId;
74
75    if (attrs === undefined) {
76      return;
77    }
78
79    if (attrs.sortKey !== undefined && attrs.ordering !== undefined) {
80      throw new Error('only one way to order a column should be specified');
81    }
82
83    if (attrs.sortKey !== undefined) {
84      this.ordering = comparingBy(attrs.sortKey, compareUniversal);
85    }
86    if (attrs.ordering !== undefined) {
87      this.ordering = attrs.ordering;
88    }
89  }
90}
91
92export function numberColumn<T>(
93  name: string,
94  getter: (t: T) => number,
95  contextMenu?: m.Child[],
96): ColumnDescriptor<T> {
97  return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
98}
99
100export function stringColumn<T>(
101  name: string,
102  getter: (t: T) => string,
103  contextMenu?: m.Child[],
104): ColumnDescriptor<T> {
105  return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
106}
107
108export function widgetColumn<T>(
109  name: string,
110  getter: (t: T) => m.Child,
111): ColumnDescriptor<T> {
112  return new ColumnDescriptor<T>(name, getter);
113}
114
115interface SortingInfo<T> {
116  columnId: string;
117  direction: SortDirection;
118  // TODO(ddrone): figure out if storing this can be avoided.
119  ordering: ComparisonFn<T>;
120}
121
122// Encapsulated table data, that contains the input to be displayed, as well as
123// some helper information to allow sorting.
124export class TableData<T> {
125  data: T[];
126  private _sortingInfo?: SortingInfo<T>;
127  private permutation: number[];
128
129  constructor(data: T[]) {
130    this.data = data;
131    this.permutation = range(data.length);
132  }
133
134  *iterateItems(): Generator<T> {
135    for (const index of this.permutation) {
136      yield this.data[index];
137    }
138  }
139
140  items(): T[] {
141    return Array.from(this.iterateItems());
142  }
143
144  setItems(newItems: T[]) {
145    this.data = newItems;
146    this.permutation = range(newItems.length);
147    if (this._sortingInfo !== undefined) {
148      this.reorder(this._sortingInfo);
149    }
150    scheduleFullRedraw();
151  }
152
153  resetOrder() {
154    this.permutation = range(this.data.length);
155    this._sortingInfo = undefined;
156    scheduleFullRedraw();
157  }
158
159  get sortingInfo(): SortingInfo<T> | undefined {
160    return this._sortingInfo;
161  }
162
163  reorder(info: SortingInfo<T>) {
164    this._sortingInfo = info;
165    this.permutation.sort(
166      withDirection(
167        comparingBy((index: number) => this.data[index], info.ordering),
168        info.direction,
169      ),
170    );
171    scheduleFullRedraw();
172  }
173}
174
175export interface TableAttrs<T> {
176  data: TableData<T>;
177  columns: ColumnDescriptor<T>[];
178}
179
180function directionOnIndex(
181  columnId: string,
182  // eslint-disable-next-line @typescript-eslint/no-explicit-any
183  info?: SortingInfo<any>,
184): SortDirection | undefined {
185  if (info === undefined) {
186    return undefined;
187  }
188  return info.columnId === columnId ? info.direction : undefined;
189}
190
191// eslint-disable-next-line @typescript-eslint/no-explicit-any
192export class Table implements m.ClassComponent<TableAttrs<any>> {
193  renderColumnHeader(
194    // eslint-disable-next-line @typescript-eslint/no-explicit-any
195    vnode: m.Vnode<TableAttrs<any>>,
196    // eslint-disable-next-line @typescript-eslint/no-explicit-any
197    column: ColumnDescriptor<any>,
198  ): m.Child {
199    let currDirection: SortDirection | undefined = undefined;
200
201    let items = column.contextMenu;
202    if (column.ordering !== undefined) {
203      const ordering = column.ordering;
204      currDirection = directionOnIndex(column.id, vnode.attrs.data.sortingInfo);
205      const newItems: m.Child[] = [];
206      if (currDirection !== 'ASC') {
207        newItems.push(
208          m(MenuItem, {
209            label: 'Sort ascending',
210            onclick: () => {
211              vnode.attrs.data.reorder({
212                columnId: column.id,
213                direction: 'ASC',
214                ordering,
215              });
216            },
217          }),
218        );
219      }
220      if (currDirection !== 'DESC') {
221        newItems.push(
222          m(MenuItem, {
223            label: 'Sort descending',
224            onclick: () => {
225              vnode.attrs.data.reorder({
226                columnId: column.id,
227                direction: 'DESC',
228                ordering,
229              });
230            },
231          }),
232        );
233      }
234      if (currDirection !== undefined) {
235        newItems.push(
236          m(MenuItem, {
237            label: 'Restore original order',
238            onclick: () => {
239              vnode.attrs.data.resetOrder();
240            },
241          }),
242        );
243      }
244      items = [...newItems, ...(items ?? [])];
245    }
246
247    return m(
248      'td',
249      column.name,
250      items &&
251        m(
252          PopupMenu2,
253          {
254            trigger: m(Button, {icon: popupMenuIcon(currDirection)}),
255          },
256          items,
257        ),
258    );
259  }
260
261  // eslint-disable-next-line @typescript-eslint/no-explicit-any
262  checkValid(attrs: TableAttrs<any>) {
263    if (!allUnique(attrs.columns.map((c) => c.id))) {
264      throw new Error('column IDs should be unique');
265    }
266  }
267
268  // eslint-disable-next-line @typescript-eslint/no-explicit-any
269  oncreate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
270    this.checkValid(vnode.attrs);
271  }
272
273  // eslint-disable-next-line @typescript-eslint/no-explicit-any
274  onupdate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
275    this.checkValid(vnode.attrs);
276  }
277
278  // eslint-disable-next-line @typescript-eslint/no-explicit-any
279  view(vnode: m.Vnode<TableAttrs<any>>): m.Child {
280    const attrs = vnode.attrs;
281
282    return m(
283      'table.generic-table',
284      m(
285        'thead',
286        m(
287          'tr.header',
288          attrs.columns.map((column) => this.renderColumnHeader(vnode, column)),
289        ),
290      ),
291      attrs.data.items().map((row) =>
292        m(
293          'tr',
294          attrs.columns.map((column) => m('td', column.render(row))),
295        ),
296      ),
297    );
298  }
299}
300