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