blob: 1389907337698e329c2d350aa34d54993511e1a4 [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 {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))),
),
),
);
}
}