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