blob: fca00d3e025f39dea8d6d391dd1529c84e09bff5 [file] [log] [blame]
// 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 {globals} from '../globals';
import {
menuItem,
PopupMenuButton,
popupMenuIcon,
PopupMenuItem,
} from '../popup_menu';
export interface ColumnDescriptorAttrs<T> {
// Context menu items displayed on the column header.
contextMenu?: PopupMenuItem[];
// 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?: PopupMenuItem[];
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?: PopupMenuItem[]):
ColumnDescriptor<T> {
return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
}
export function stringColumn<T>(
name: string, getter: (t: T) => string, contextMenu?: PopupMenuItem[]):
ColumnDescriptor<T> {
return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: 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);
}
globals.rafScheduler.scheduleFullRedraw();
}
resetOrder() {
this.permutation = range(this.data.length);
this._sortingInfo = undefined;
globals.rafScheduler.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));
globals.rafScheduler.scheduleFullRedraw();
}
}
export interface TableAttrs<T> {
data: TableData<T>;
columns: ColumnDescriptor<T>[];
}
function directionOnIndex(
columnId: string, info?: SortingInfo<any>): SortDirection|undefined {
if (info === undefined) {
return undefined;
}
return info.columnId === columnId ? info.direction : undefined;
}
export class Table implements m.ClassComponent<TableAttrs<any>> {
renderColumnHeader(
vnode: m.Vnode<TableAttrs<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: PopupMenuItem[] = [];
if (currDirection !== 'ASC') {
newItems.push(menuItem('Sort ascending', () => {
vnode.attrs.data.reorder(
{columnId: column.id, direction: 'ASC', ordering});
}));
}
if (currDirection !== 'DESC') {
newItems.push(menuItem('Sort descending', () => {
vnode.attrs.data.reorder({
columnId: column.id,
direction: 'DESC',
ordering,
});
}));
}
if (currDirection !== undefined) {
newItems.push(menuItem('Restore original order', () => {
vnode.attrs.data.resetOrder();
}));
}
items = [
...newItems,
...(items ?? []),
];
}
return m(
'td', column.name, items === undefined ? null : m(PopupMenuButton, {
icon: popupMenuIcon(currDirection),
items,
}));
}
checkValid(attrs: TableAttrs<any>) {
if (!allUnique(attrs.columns.map((c) => c.id))) {
throw new Error('column IDs should be unique');
}
}
oncreate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
this.checkValid(vnode.attrs);
}
onupdate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
this.checkValid(vnode.attrs);
}
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))))));
}
}