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