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