| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use size file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| import {allUnique, range} from '../base/array_utils'; |
| import { |
| compareUniversal, |
| comparingBy, |
| ComparisonFn, |
| SortableValue, |
| SortDirection, |
| withDirection, |
| } from '../base/comparison_utils'; |
| import {scheduleFullRedraw} from './raf'; |
| import {MenuItem, PopupMenu2} from './menu'; |
| import {Button} from './button'; |
| |
| // For a table column that can be sorted; the standard popup icon should |
| // reflect the current sorting direction. This function returns an icon |
| // corresponding to optional SortDirection according to which the column is |
| // sorted. (Optional because column might be unsorted) |
| export function popupMenuIcon(sortDirection?: SortDirection) { |
| switch (sortDirection) { |
| case undefined: |
| return 'more_horiz'; |
| case 'DESC': |
| return 'arrow_drop_down'; |
| case 'ASC': |
| return 'arrow_drop_up'; |
| } |
| } |
| |
| export interface ColumnDescriptorAttrs<T> { |
| // Context menu items displayed on the column header. |
| contextMenu?: m.Child[]; |
| |
| // Unique column ID, used to identify which column is currently sorted. |
| columnId?: string; |
| |
| // Sorting predicate: if provided, column would be sortable. |
| ordering?: ComparisonFn<T>; |
| |
| // Simpler way to provide a sorting: instead of full predicate, the function |
| // can map the row for "sorting key" associated with the column. |
| sortKey?: (value: T) => SortableValue; |
| } |
| |
| export class ColumnDescriptor<T> { |
| name: string; |
| render: (row: T) => m.Child; |
| id: string; |
| contextMenu?: m.Child[]; |
| ordering?: ComparisonFn<T>; |
| |
| constructor( |
| name: string, |
| render: (row: T) => m.Child, |
| attrs?: ColumnDescriptorAttrs<T>, |
| ) { |
| this.name = name; |
| this.render = render; |
| this.id = attrs?.columnId === undefined ? name : attrs.columnId; |
| |
| if (attrs === undefined) { |
| return; |
| } |
| |
| if (attrs.sortKey !== undefined && attrs.ordering !== undefined) { |
| throw new Error('only one way to order a column should be specified'); |
| } |
| |
| if (attrs.sortKey !== undefined) { |
| this.ordering = comparingBy(attrs.sortKey, compareUniversal); |
| } |
| if (attrs.ordering !== undefined) { |
| this.ordering = attrs.ordering; |
| } |
| } |
| } |
| |
| export function numberColumn<T>( |
| name: string, |
| getter: (t: T) => number, |
| contextMenu?: m.Child[], |
| ): ColumnDescriptor<T> { |
| return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter}); |
| } |
| |
| export function stringColumn<T>( |
| name: string, |
| getter: (t: T) => string, |
| contextMenu?: m.Child[], |
| ): ColumnDescriptor<T> { |
| return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter}); |
| } |
| |
| export function widgetColumn<T>( |
| name: string, |
| getter: (t: T) => m.Child, |
| ): ColumnDescriptor<T> { |
| return new ColumnDescriptor<T>(name, getter); |
| } |
| |
| interface SortingInfo<T> { |
| columnId: string; |
| direction: SortDirection; |
| // TODO(ddrone): figure out if storing this can be avoided. |
| ordering: ComparisonFn<T>; |
| } |
| |
| // Encapsulated table data, that contains the input to be displayed, as well as |
| // some helper information to allow sorting. |
| export class TableData<T> { |
| data: T[]; |
| private _sortingInfo?: SortingInfo<T>; |
| private permutation: number[]; |
| |
| constructor(data: T[]) { |
| this.data = data; |
| this.permutation = range(data.length); |
| } |
| |
| *iterateItems(): Generator<T> { |
| for (const index of this.permutation) { |
| yield this.data[index]; |
| } |
| } |
| |
| items(): T[] { |
| return Array.from(this.iterateItems()); |
| } |
| |
| setItems(newItems: T[]) { |
| this.data = newItems; |
| this.permutation = range(newItems.length); |
| if (this._sortingInfo !== undefined) { |
| this.reorder(this._sortingInfo); |
| } |
| scheduleFullRedraw(); |
| } |
| |
| resetOrder() { |
| this.permutation = range(this.data.length); |
| this._sortingInfo = undefined; |
| scheduleFullRedraw(); |
| } |
| |
| get sortingInfo(): SortingInfo<T> | undefined { |
| return this._sortingInfo; |
| } |
| |
| reorder(info: SortingInfo<T>) { |
| this._sortingInfo = info; |
| this.permutation.sort( |
| withDirection( |
| comparingBy((index: number) => this.data[index], info.ordering), |
| info.direction, |
| ), |
| ); |
| scheduleFullRedraw(); |
| } |
| } |
| |
| export interface TableAttrs<T> { |
| data: TableData<T>; |
| columns: ColumnDescriptor<T>[]; |
| } |
| |
| function directionOnIndex( |
| columnId: string, |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| info?: SortingInfo<any>, |
| ): SortDirection | undefined { |
| if (info === undefined) { |
| return undefined; |
| } |
| return info.columnId === columnId ? info.direction : undefined; |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| export class Table implements m.ClassComponent<TableAttrs<any>> { |
| renderColumnHeader( |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| vnode: m.Vnode<TableAttrs<any>>, |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| column: ColumnDescriptor<any>, |
| ): m.Child { |
| let currDirection: SortDirection | undefined = undefined; |
| |
| let items = column.contextMenu; |
| if (column.ordering !== undefined) { |
| const ordering = column.ordering; |
| currDirection = directionOnIndex(column.id, vnode.attrs.data.sortingInfo); |
| const newItems: m.Child[] = []; |
| if (currDirection !== 'ASC') { |
| newItems.push( |
| m(MenuItem, { |
| label: 'Sort ascending', |
| onclick: () => { |
| vnode.attrs.data.reorder({ |
| columnId: column.id, |
| direction: 'ASC', |
| ordering, |
| }); |
| }, |
| }), |
| ); |
| } |
| if (currDirection !== 'DESC') { |
| newItems.push( |
| m(MenuItem, { |
| label: 'Sort descending', |
| onclick: () => { |
| vnode.attrs.data.reorder({ |
| columnId: column.id, |
| direction: 'DESC', |
| ordering, |
| }); |
| }, |
| }), |
| ); |
| } |
| if (currDirection !== undefined) { |
| newItems.push( |
| m(MenuItem, { |
| label: 'Restore original order', |
| onclick: () => { |
| vnode.attrs.data.resetOrder(); |
| }, |
| }), |
| ); |
| } |
| items = [...newItems, ...(items ?? [])]; |
| } |
| |
| return m( |
| 'td', |
| column.name, |
| items && |
| m( |
| PopupMenu2, |
| { |
| trigger: m(Button, {icon: popupMenuIcon(currDirection)}), |
| }, |
| items, |
| ), |
| ); |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| checkValid(attrs: TableAttrs<any>) { |
| if (!allUnique(attrs.columns.map((c) => c.id))) { |
| throw new Error('column IDs should be unique'); |
| } |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| oncreate(vnode: m.VnodeDOM<TableAttrs<any>, this>) { |
| this.checkValid(vnode.attrs); |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| onupdate(vnode: m.VnodeDOM<TableAttrs<any>, this>) { |
| this.checkValid(vnode.attrs); |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| view(vnode: m.Vnode<TableAttrs<any>>): m.Child { |
| const attrs = vnode.attrs; |
| |
| return m( |
| 'table.generic-table', |
| m( |
| 'thead', |
| m( |
| 'tr.header', |
| attrs.columns.map((column) => this.renderColumnHeader(vnode, column)), |
| ), |
| ), |
| attrs.data.items().map((row) => |
| m( |
| 'tr', |
| attrs.columns.map((column) => m('td', column.render(row))), |
| ), |
| ), |
| ); |
| } |
| } |