xref: /aosp_15_r20/external/perfetto/ui/src/components/widgets/sql/table/table.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this 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 {
17  filterTitle,
18  SqlColumn,
19  sqlColumnId,
20  TableColumn,
21  tableColumnId,
22  TableManager,
23} from './column';
24import {Button} from '../../../../widgets/button';
25import {MenuDivider, MenuItem, PopupMenu2} from '../../../../widgets/menu';
26import {buildSqlQuery} from './query_builder';
27import {Icons} from '../../../../base/semantic_icons';
28import {sqliteString} from '../../../../base/string_utils';
29import {
30  ColumnType,
31  Row,
32  SqlValue,
33} from '../../../../trace_processor/query_result';
34import {Anchor} from '../../../../widgets/anchor';
35import {BasicTable, ReorderableColumns} from '../../../../widgets/basic_table';
36import {Spinner} from '../../../../widgets/spinner';
37
38import {ArgumentSelector} from './argument_selector';
39import {FILTER_OPTION_TO_OP, FilterOption} from './render_cell_utils';
40import {SqlTableState} from './state';
41import {SqlTableDescription} from './table_description';
42import {Intent} from '../../../../widgets/common';
43import {Form} from '../../../../widgets/form';
44import {TextInput} from '../../../../widgets/text_input';
45
46export interface SqlTableConfig {
47  readonly state: SqlTableState;
48  // For additional menu items to add to the column header menus
49  readonly addColumnMenuItems?: (
50    column: TableColumn,
51    columnAlias: string,
52  ) => m.Children;
53}
54
55type AdditionalColumnMenuItems = Record<string, m.Children>;
56
57function renderCell(
58  column: TableColumn,
59  row: Row,
60  state: SqlTableState,
61): m.Children {
62  const {columns} = state.getCurrentRequest();
63  const sqlValue = row[columns[sqlColumnId(column.primaryColumn())]];
64
65  const additionalValues: {[key: string]: SqlValue} = {};
66  const dependentColumns = column.dependentColumns?.() ?? {};
67  for (const [key, col] of Object.entries(dependentColumns)) {
68    additionalValues[key] = row[columns[sqlColumnId(col)]];
69  }
70
71  return column.renderCell(sqlValue, getTableManager(state), additionalValues);
72}
73
74export function columnTitle(column: TableColumn): string {
75  if (column.getTitle !== undefined) {
76    const title = column.getTitle();
77    if (title !== undefined) return title;
78  }
79  return sqlColumnId(column.primaryColumn());
80}
81
82interface AddColumnMenuItemAttrs {
83  table: SqlTable;
84  state: SqlTableState;
85  index: number;
86}
87
88// This is separated into a separate class to store the index of the column to be
89// added and increment it when multiple columns are added from the same popup menu.
90class AddColumnMenuItem implements m.ClassComponent<AddColumnMenuItemAttrs> {
91  // Index where the new column should be inserted.
92  // In the regular case, a click would close the popup (destroying this class) and
93  // the `index` would not change during its lifetime.
94  // However, for mod-click, we want to keep adding columns to the right of the recently
95  // added column, so to achieve that we keep track of the index and increment it for
96  // each new column added.
97  index: number;
98
99  constructor({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
100    this.index = attrs.index;
101  }
102
103  view({attrs}: m.Vnode<AddColumnMenuItemAttrs>) {
104    return m(
105      MenuItem,
106      {label: 'Add column', icon: Icons.AddColumn},
107      attrs.table.renderAddColumnOptions((column) => {
108        attrs.state.addColumn(column, this.index++);
109      }),
110    );
111  }
112}
113
114interface ColumnFilterAttrs {
115  filterOption: FilterOption;
116  columns: SqlColumn[];
117  state: SqlTableState;
118}
119
120// Separating out an individual column filter into a class
121// so that we can store the raw input value.
122class ColumnFilter implements m.ClassComponent<ColumnFilterAttrs> {
123  // Holds the raw string value from the filter text input element
124  private inputValue: string;
125
126  constructor() {
127    this.inputValue = '';
128  }
129
130  view({attrs}: m.Vnode<ColumnFilterAttrs>) {
131    const {filterOption, columns, state} = attrs;
132
133    const {op, requiresParam} = FILTER_OPTION_TO_OP[filterOption];
134
135    return m(
136      MenuItem,
137      {
138        label: filterOption,
139        // Filter options that do not need an input value will filter the
140        // table directly when clicking on the menu item
141        // (ex: IS NULL or IS NOT NULL)
142        onclick: !requiresParam
143          ? () => {
144              state.addFilter({
145                op: (cols) => `${cols[0]} ${op}`,
146                columns,
147              });
148            }
149          : undefined,
150      },
151      // All non-null filter options will have a submenu that allows
152      // the user to enter a value into textfield and filter using
153      // the Filter button.
154      requiresParam &&
155        m(
156          Form,
157          {
158            onSubmit: () => {
159              // Convert the string extracted from
160              // the input text field into the correct data type for
161              // filtering. The order in which each data type is
162              // checked matters: string, number (floating), and bigint.
163              if (this.inputValue === '') return;
164
165              let filterValue: ColumnType;
166
167              if (Number.isNaN(Number.parseFloat(this.inputValue))) {
168                filterValue = sqliteString(this.inputValue);
169              } else if (
170                !Number.isInteger(Number.parseFloat(this.inputValue))
171              ) {
172                filterValue = Number(this.inputValue);
173              } else {
174                filterValue = BigInt(this.inputValue);
175              }
176
177              state.addFilter({
178                op: (cols) => `${cols[0]} ${op} ${filterValue}`,
179                columns,
180              });
181            },
182            submitLabel: 'Filter',
183          },
184          m(TextInput, {
185            id: 'column_filter_value',
186            ref: 'COLUMN_FILTER_VALUE',
187            autofocus: true,
188            oninput: (e: KeyboardEvent) => {
189              if (!e.target) return;
190
191              this.inputValue = (e.target as HTMLInputElement).value;
192            },
193          }),
194        ),
195    );
196  }
197}
198
199export class SqlTable implements m.ClassComponent<SqlTableConfig> {
200  private readonly table: SqlTableDescription;
201
202  private state: SqlTableState;
203
204  constructor(vnode: m.Vnode<SqlTableConfig>) {
205    this.state = vnode.attrs.state;
206    this.table = this.state.config;
207  }
208
209  renderFilters(): m.Children {
210    const filters: m.Child[] = [];
211    for (const filter of this.state.getFilters()) {
212      const label = filterTitle(filter);
213      filters.push(
214        m(Button, {
215          label,
216          icon: 'close',
217          intent: Intent.Primary,
218          onclick: () => {
219            this.state.removeFilter(filter);
220          },
221        }),
222      );
223    }
224    return filters;
225  }
226
227  renderAddColumnOptions(addColumn: (column: TableColumn) => void): m.Children {
228    // We do not want to add columns which already exist, so we track the
229    // columns which we are already showing here.
230    // TODO(altimin): Theoretically a single table can have two different
231    // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here.
232    const existingColumnIds = new Set<string>();
233
234    for (const column of this.state.getSelectedColumns()) {
235      existingColumnIds.add(tableColumnId(column));
236    }
237
238    const result = [];
239    for (const column of this.table.columns) {
240      if (column instanceof TableColumn) {
241        if (existingColumnIds.has(tableColumnId(column))) continue;
242        result.push(
243          m(MenuItem, {
244            label: columnTitle(column),
245            onclick: () => addColumn(column),
246          }),
247        );
248      } else {
249        result.push(
250          m(
251            MenuItem,
252            {
253              label: column.getTitle(),
254            },
255            m(ArgumentSelector, {
256              alreadySelectedColumnIds: existingColumnIds,
257              tableManager: getTableManager(this.state),
258              columnSet: column,
259              onArgumentSelected: (column: TableColumn) => {
260                addColumn(column);
261              },
262            }),
263          ),
264        );
265        continue;
266      }
267    }
268    return result;
269  }
270
271  renderColumnFilterOptions(
272    c: TableColumn,
273  ): m.Vnode<ColumnFilterAttrs, unknown>[] {
274    return Object.values(FilterOption).map((filterOption) =>
275      m(ColumnFilter, {
276        filterOption,
277        columns: [c.primaryColumn()],
278        state: this.state,
279      }),
280    );
281  }
282
283  renderColumnHeader(
284    column: TableColumn,
285    index: number,
286    additionalColumnHeaderMenuItems?: m.Children,
287  ) {
288    const sorted = this.state.isSortedBy(column);
289    const icon =
290      sorted === 'ASC'
291        ? Icons.SortedAsc
292        : sorted === 'DESC'
293          ? Icons.SortedDesc
294          : Icons.ContextMenu;
295
296    return m(
297      PopupMenu2,
298      {
299        trigger: m(Anchor, {icon}, columnTitle(column)),
300      },
301      sorted !== 'DESC' &&
302        m(MenuItem, {
303          label: 'Sort: highest first',
304          icon: Icons.SortedDesc,
305          onclick: () => {
306            this.state.sortBy({
307              column: column,
308              direction: 'DESC',
309            });
310          },
311        }),
312      sorted !== 'ASC' &&
313        m(MenuItem, {
314          label: 'Sort: lowest first',
315          icon: Icons.SortedAsc,
316          onclick: () => {
317            this.state.sortBy({
318              column: column,
319              direction: 'ASC',
320            });
321          },
322        }),
323      sorted !== undefined &&
324        m(MenuItem, {
325          label: 'Unsort',
326          icon: Icons.Close,
327          onclick: () => this.state.unsort(),
328        }),
329      this.state.getSelectedColumns().length > 1 &&
330        m(MenuItem, {
331          label: 'Hide',
332          icon: Icons.Hide,
333          onclick: () => this.state.hideColumnAtIndex(index),
334        }),
335      m(
336        MenuItem,
337        {label: 'Add filter', icon: Icons.Filter},
338        this.renderColumnFilterOptions(column),
339      ),
340      additionalColumnHeaderMenuItems,
341      // Menu items before divider apply to selected column
342      m(MenuDivider),
343      // Menu items after divider apply to entire table
344      m(AddColumnMenuItem, {table: this, state: this.state, index}),
345    );
346  }
347
348  getAdditionalColumnMenuItems(
349    addColumnMenuItems?: (
350      column: TableColumn,
351      columnAlias: string,
352    ) => m.Children,
353  ) {
354    if (addColumnMenuItems === undefined) return;
355
356    const additionalColumnMenuItems: AdditionalColumnMenuItems = {};
357    this.state.getSelectedColumns().forEach((column) => {
358      const columnAlias =
359        this.state.getCurrentRequest().columns[
360          sqlColumnId(column.primaryColumn())
361        ];
362
363      additionalColumnMenuItems[columnAlias] = addColumnMenuItems(
364        column,
365        columnAlias,
366      );
367    });
368
369    return additionalColumnMenuItems;
370  }
371
372  view({attrs}: m.Vnode<SqlTableConfig>) {
373    const rows = this.state.getDisplayedRows();
374    const additionalColumnMenuItems = this.getAdditionalColumnMenuItems(
375      attrs.addColumnMenuItems,
376    );
377
378    const columns = this.state.getSelectedColumns();
379    const columnDescriptors = columns.map((column, i) => {
380      return {
381        title: this.renderColumnHeader(
382          column,
383          i,
384          additionalColumnMenuItems &&
385            additionalColumnMenuItems[
386              this.state.getCurrentRequest().columns[
387                sqlColumnId(column.primaryColumn())
388              ]
389            ],
390        ),
391        render: (row: Row) => renderCell(column, row, this.state),
392      };
393    });
394
395    return [
396      m('div', this.renderFilters()),
397      m(
398        BasicTable<Row>,
399        {
400          data: rows,
401          columns: [
402            new ReorderableColumns(
403              columnDescriptors,
404              (from: number, to: number) => this.state.moveColumn(from, to),
405            ),
406          ],
407        },
408        this.state.isLoading() && m(Spinner),
409        this.state.getQueryError() !== undefined &&
410          m('.query-error', this.state.getQueryError()),
411      ),
412    ];
413  }
414}
415
416function getTableManager(state: SqlTableState): TableManager {
417  return {
418    addFilter: (filter) => {
419      state.addFilter(filter);
420    },
421    trace: state.trace,
422    getSqlQuery: (columns: {[key: string]: SqlColumn}) =>
423      buildSqlQuery({
424        table: state.config.name,
425        columns,
426        filters: state.getFilters(),
427        orderBy: state.getOrderedBy(),
428      }),
429  };
430}
431