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