xref: /aosp_15_r20/external/perfetto/ui/src/widgets/table.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1*6dbdd20aSAndroid Build Coastguard Worker// Copyright (C) 2023 The Android Open Source Project
2*6dbdd20aSAndroid Build Coastguard Worker//
3*6dbdd20aSAndroid Build Coastguard Worker// Licensed under the Apache License, Version 2.0 (the "License");
4*6dbdd20aSAndroid Build Coastguard Worker// you may not use size file except in compliance with the License.
5*6dbdd20aSAndroid Build Coastguard Worker// You may obtain a copy of the License at
6*6dbdd20aSAndroid Build Coastguard Worker//
7*6dbdd20aSAndroid Build Coastguard Worker//      http://www.apache.org/licenses/LICENSE-2.0
8*6dbdd20aSAndroid Build Coastguard Worker//
9*6dbdd20aSAndroid Build Coastguard Worker// Unless required by applicable law or agreed to in writing, software
10*6dbdd20aSAndroid Build Coastguard Worker// distributed under the License is distributed on an "AS IS" BASIS,
11*6dbdd20aSAndroid Build Coastguard Worker// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12*6dbdd20aSAndroid Build Coastguard Worker// See the License for the specific language governing permissions and
13*6dbdd20aSAndroid Build Coastguard Worker// limitations under the License.
14*6dbdd20aSAndroid Build Coastguard Worker
15*6dbdd20aSAndroid Build Coastguard Workerimport m from 'mithril';
16*6dbdd20aSAndroid Build Coastguard Workerimport {allUnique, range} from '../base/array_utils';
17*6dbdd20aSAndroid Build Coastguard Workerimport {
18*6dbdd20aSAndroid Build Coastguard Worker  compareUniversal,
19*6dbdd20aSAndroid Build Coastguard Worker  comparingBy,
20*6dbdd20aSAndroid Build Coastguard Worker  ComparisonFn,
21*6dbdd20aSAndroid Build Coastguard Worker  SortableValue,
22*6dbdd20aSAndroid Build Coastguard Worker  SortDirection,
23*6dbdd20aSAndroid Build Coastguard Worker  withDirection,
24*6dbdd20aSAndroid Build Coastguard Worker} from '../base/comparison_utils';
25*6dbdd20aSAndroid Build Coastguard Workerimport {scheduleFullRedraw} from './raf';
26*6dbdd20aSAndroid Build Coastguard Workerimport {MenuItem, PopupMenu2} from './menu';
27*6dbdd20aSAndroid Build Coastguard Workerimport {Button} from './button';
28*6dbdd20aSAndroid Build Coastguard Worker
29*6dbdd20aSAndroid Build Coastguard Worker// For a table column that can be sorted; the standard popup icon should
30*6dbdd20aSAndroid Build Coastguard Worker// reflect the current sorting direction. This function returns an icon
31*6dbdd20aSAndroid Build Coastguard Worker// corresponding to optional SortDirection according to which the column is
32*6dbdd20aSAndroid Build Coastguard Worker// sorted. (Optional because column might be unsorted)
33*6dbdd20aSAndroid Build Coastguard Workerexport function popupMenuIcon(sortDirection?: SortDirection) {
34*6dbdd20aSAndroid Build Coastguard Worker  switch (sortDirection) {
35*6dbdd20aSAndroid Build Coastguard Worker    case undefined:
36*6dbdd20aSAndroid Build Coastguard Worker      return 'more_horiz';
37*6dbdd20aSAndroid Build Coastguard Worker    case 'DESC':
38*6dbdd20aSAndroid Build Coastguard Worker      return 'arrow_drop_down';
39*6dbdd20aSAndroid Build Coastguard Worker    case 'ASC':
40*6dbdd20aSAndroid Build Coastguard Worker      return 'arrow_drop_up';
41*6dbdd20aSAndroid Build Coastguard Worker  }
42*6dbdd20aSAndroid Build Coastguard Worker}
43*6dbdd20aSAndroid Build Coastguard Worker
44*6dbdd20aSAndroid Build Coastguard Workerexport interface ColumnDescriptorAttrs<T> {
45*6dbdd20aSAndroid Build Coastguard Worker  // Context menu items displayed on the column header.
46*6dbdd20aSAndroid Build Coastguard Worker  contextMenu?: m.Child[];
47*6dbdd20aSAndroid Build Coastguard Worker
48*6dbdd20aSAndroid Build Coastguard Worker  // Unique column ID, used to identify which column is currently sorted.
49*6dbdd20aSAndroid Build Coastguard Worker  columnId?: string;
50*6dbdd20aSAndroid Build Coastguard Worker
51*6dbdd20aSAndroid Build Coastguard Worker  // Sorting predicate: if provided, column would be sortable.
52*6dbdd20aSAndroid Build Coastguard Worker  ordering?: ComparisonFn<T>;
53*6dbdd20aSAndroid Build Coastguard Worker
54*6dbdd20aSAndroid Build Coastguard Worker  // Simpler way to provide a sorting: instead of full predicate, the function
55*6dbdd20aSAndroid Build Coastguard Worker  // can map the row for "sorting key" associated with the column.
56*6dbdd20aSAndroid Build Coastguard Worker  sortKey?: (value: T) => SortableValue;
57*6dbdd20aSAndroid Build Coastguard Worker}
58*6dbdd20aSAndroid Build Coastguard Worker
59*6dbdd20aSAndroid Build Coastguard Workerexport class ColumnDescriptor<T> {
60*6dbdd20aSAndroid Build Coastguard Worker  name: string;
61*6dbdd20aSAndroid Build Coastguard Worker  render: (row: T) => m.Child;
62*6dbdd20aSAndroid Build Coastguard Worker  id: string;
63*6dbdd20aSAndroid Build Coastguard Worker  contextMenu?: m.Child[];
64*6dbdd20aSAndroid Build Coastguard Worker  ordering?: ComparisonFn<T>;
65*6dbdd20aSAndroid Build Coastguard Worker
66*6dbdd20aSAndroid Build Coastguard Worker  constructor(
67*6dbdd20aSAndroid Build Coastguard Worker    name: string,
68*6dbdd20aSAndroid Build Coastguard Worker    render: (row: T) => m.Child,
69*6dbdd20aSAndroid Build Coastguard Worker    attrs?: ColumnDescriptorAttrs<T>,
70*6dbdd20aSAndroid Build Coastguard Worker  ) {
71*6dbdd20aSAndroid Build Coastguard Worker    this.name = name;
72*6dbdd20aSAndroid Build Coastguard Worker    this.render = render;
73*6dbdd20aSAndroid Build Coastguard Worker    this.id = attrs?.columnId === undefined ? name : attrs.columnId;
74*6dbdd20aSAndroid Build Coastguard Worker
75*6dbdd20aSAndroid Build Coastguard Worker    if (attrs === undefined) {
76*6dbdd20aSAndroid Build Coastguard Worker      return;
77*6dbdd20aSAndroid Build Coastguard Worker    }
78*6dbdd20aSAndroid Build Coastguard Worker
79*6dbdd20aSAndroid Build Coastguard Worker    if (attrs.sortKey !== undefined && attrs.ordering !== undefined) {
80*6dbdd20aSAndroid Build Coastguard Worker      throw new Error('only one way to order a column should be specified');
81*6dbdd20aSAndroid Build Coastguard Worker    }
82*6dbdd20aSAndroid Build Coastguard Worker
83*6dbdd20aSAndroid Build Coastguard Worker    if (attrs.sortKey !== undefined) {
84*6dbdd20aSAndroid Build Coastguard Worker      this.ordering = comparingBy(attrs.sortKey, compareUniversal);
85*6dbdd20aSAndroid Build Coastguard Worker    }
86*6dbdd20aSAndroid Build Coastguard Worker    if (attrs.ordering !== undefined) {
87*6dbdd20aSAndroid Build Coastguard Worker      this.ordering = attrs.ordering;
88*6dbdd20aSAndroid Build Coastguard Worker    }
89*6dbdd20aSAndroid Build Coastguard Worker  }
90*6dbdd20aSAndroid Build Coastguard Worker}
91*6dbdd20aSAndroid Build Coastguard Worker
92*6dbdd20aSAndroid Build Coastguard Workerexport function numberColumn<T>(
93*6dbdd20aSAndroid Build Coastguard Worker  name: string,
94*6dbdd20aSAndroid Build Coastguard Worker  getter: (t: T) => number,
95*6dbdd20aSAndroid Build Coastguard Worker  contextMenu?: m.Child[],
96*6dbdd20aSAndroid Build Coastguard Worker): ColumnDescriptor<T> {
97*6dbdd20aSAndroid Build Coastguard Worker  return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
98*6dbdd20aSAndroid Build Coastguard Worker}
99*6dbdd20aSAndroid Build Coastguard Worker
100*6dbdd20aSAndroid Build Coastguard Workerexport function stringColumn<T>(
101*6dbdd20aSAndroid Build Coastguard Worker  name: string,
102*6dbdd20aSAndroid Build Coastguard Worker  getter: (t: T) => string,
103*6dbdd20aSAndroid Build Coastguard Worker  contextMenu?: m.Child[],
104*6dbdd20aSAndroid Build Coastguard Worker): ColumnDescriptor<T> {
105*6dbdd20aSAndroid Build Coastguard Worker  return new ColumnDescriptor<T>(name, getter, {contextMenu, sortKey: getter});
106*6dbdd20aSAndroid Build Coastguard Worker}
107*6dbdd20aSAndroid Build Coastguard Worker
108*6dbdd20aSAndroid Build Coastguard Workerexport function widgetColumn<T>(
109*6dbdd20aSAndroid Build Coastguard Worker  name: string,
110*6dbdd20aSAndroid Build Coastguard Worker  getter: (t: T) => m.Child,
111*6dbdd20aSAndroid Build Coastguard Worker): ColumnDescriptor<T> {
112*6dbdd20aSAndroid Build Coastguard Worker  return new ColumnDescriptor<T>(name, getter);
113*6dbdd20aSAndroid Build Coastguard Worker}
114*6dbdd20aSAndroid Build Coastguard Worker
115*6dbdd20aSAndroid Build Coastguard Workerinterface SortingInfo<T> {
116*6dbdd20aSAndroid Build Coastguard Worker  columnId: string;
117*6dbdd20aSAndroid Build Coastguard Worker  direction: SortDirection;
118*6dbdd20aSAndroid Build Coastguard Worker  // TODO(ddrone): figure out if storing this can be avoided.
119*6dbdd20aSAndroid Build Coastguard Worker  ordering: ComparisonFn<T>;
120*6dbdd20aSAndroid Build Coastguard Worker}
121*6dbdd20aSAndroid Build Coastguard Worker
122*6dbdd20aSAndroid Build Coastguard Worker// Encapsulated table data, that contains the input to be displayed, as well as
123*6dbdd20aSAndroid Build Coastguard Worker// some helper information to allow sorting.
124*6dbdd20aSAndroid Build Coastguard Workerexport class TableData<T> {
125*6dbdd20aSAndroid Build Coastguard Worker  data: T[];
126*6dbdd20aSAndroid Build Coastguard Worker  private _sortingInfo?: SortingInfo<T>;
127*6dbdd20aSAndroid Build Coastguard Worker  private permutation: number[];
128*6dbdd20aSAndroid Build Coastguard Worker
129*6dbdd20aSAndroid Build Coastguard Worker  constructor(data: T[]) {
130*6dbdd20aSAndroid Build Coastguard Worker    this.data = data;
131*6dbdd20aSAndroid Build Coastguard Worker    this.permutation = range(data.length);
132*6dbdd20aSAndroid Build Coastguard Worker  }
133*6dbdd20aSAndroid Build Coastguard Worker
134*6dbdd20aSAndroid Build Coastguard Worker  *iterateItems(): Generator<T> {
135*6dbdd20aSAndroid Build Coastguard Worker    for (const index of this.permutation) {
136*6dbdd20aSAndroid Build Coastguard Worker      yield this.data[index];
137*6dbdd20aSAndroid Build Coastguard Worker    }
138*6dbdd20aSAndroid Build Coastguard Worker  }
139*6dbdd20aSAndroid Build Coastguard Worker
140*6dbdd20aSAndroid Build Coastguard Worker  items(): T[] {
141*6dbdd20aSAndroid Build Coastguard Worker    return Array.from(this.iterateItems());
142*6dbdd20aSAndroid Build Coastguard Worker  }
143*6dbdd20aSAndroid Build Coastguard Worker
144*6dbdd20aSAndroid Build Coastguard Worker  setItems(newItems: T[]) {
145*6dbdd20aSAndroid Build Coastguard Worker    this.data = newItems;
146*6dbdd20aSAndroid Build Coastguard Worker    this.permutation = range(newItems.length);
147*6dbdd20aSAndroid Build Coastguard Worker    if (this._sortingInfo !== undefined) {
148*6dbdd20aSAndroid Build Coastguard Worker      this.reorder(this._sortingInfo);
149*6dbdd20aSAndroid Build Coastguard Worker    }
150*6dbdd20aSAndroid Build Coastguard Worker    scheduleFullRedraw();
151*6dbdd20aSAndroid Build Coastguard Worker  }
152*6dbdd20aSAndroid Build Coastguard Worker
153*6dbdd20aSAndroid Build Coastguard Worker  resetOrder() {
154*6dbdd20aSAndroid Build Coastguard Worker    this.permutation = range(this.data.length);
155*6dbdd20aSAndroid Build Coastguard Worker    this._sortingInfo = undefined;
156*6dbdd20aSAndroid Build Coastguard Worker    scheduleFullRedraw();
157*6dbdd20aSAndroid Build Coastguard Worker  }
158*6dbdd20aSAndroid Build Coastguard Worker
159*6dbdd20aSAndroid Build Coastguard Worker  get sortingInfo(): SortingInfo<T> | undefined {
160*6dbdd20aSAndroid Build Coastguard Worker    return this._sortingInfo;
161*6dbdd20aSAndroid Build Coastguard Worker  }
162*6dbdd20aSAndroid Build Coastguard Worker
163*6dbdd20aSAndroid Build Coastguard Worker  reorder(info: SortingInfo<T>) {
164*6dbdd20aSAndroid Build Coastguard Worker    this._sortingInfo = info;
165*6dbdd20aSAndroid Build Coastguard Worker    this.permutation.sort(
166*6dbdd20aSAndroid Build Coastguard Worker      withDirection(
167*6dbdd20aSAndroid Build Coastguard Worker        comparingBy((index: number) => this.data[index], info.ordering),
168*6dbdd20aSAndroid Build Coastguard Worker        info.direction,
169*6dbdd20aSAndroid Build Coastguard Worker      ),
170*6dbdd20aSAndroid Build Coastguard Worker    );
171*6dbdd20aSAndroid Build Coastguard Worker    scheduleFullRedraw();
172*6dbdd20aSAndroid Build Coastguard Worker  }
173*6dbdd20aSAndroid Build Coastguard Worker}
174*6dbdd20aSAndroid Build Coastguard Worker
175*6dbdd20aSAndroid Build Coastguard Workerexport interface TableAttrs<T> {
176*6dbdd20aSAndroid Build Coastguard Worker  data: TableData<T>;
177*6dbdd20aSAndroid Build Coastguard Worker  columns: ColumnDescriptor<T>[];
178*6dbdd20aSAndroid Build Coastguard Worker}
179*6dbdd20aSAndroid Build Coastguard Worker
180*6dbdd20aSAndroid Build Coastguard Workerfunction directionOnIndex(
181*6dbdd20aSAndroid Build Coastguard Worker  columnId: string,
182*6dbdd20aSAndroid Build Coastguard Worker  // eslint-disable-next-line @typescript-eslint/no-explicit-any
183*6dbdd20aSAndroid Build Coastguard Worker  info?: SortingInfo<any>,
184*6dbdd20aSAndroid Build Coastguard Worker): SortDirection | undefined {
185*6dbdd20aSAndroid Build Coastguard Worker  if (info === undefined) {
186*6dbdd20aSAndroid Build Coastguard Worker    return undefined;
187*6dbdd20aSAndroid Build Coastguard Worker  }
188*6dbdd20aSAndroid Build Coastguard Worker  return info.columnId === columnId ? info.direction : undefined;
189*6dbdd20aSAndroid Build Coastguard Worker}
190*6dbdd20aSAndroid Build Coastguard Worker
191*6dbdd20aSAndroid Build Coastguard Worker// eslint-disable-next-line @typescript-eslint/no-explicit-any
192*6dbdd20aSAndroid Build Coastguard Workerexport class Table implements m.ClassComponent<TableAttrs<any>> {
193*6dbdd20aSAndroid Build Coastguard Worker  renderColumnHeader(
194*6dbdd20aSAndroid Build Coastguard Worker    // eslint-disable-next-line @typescript-eslint/no-explicit-any
195*6dbdd20aSAndroid Build Coastguard Worker    vnode: m.Vnode<TableAttrs<any>>,
196*6dbdd20aSAndroid Build Coastguard Worker    // eslint-disable-next-line @typescript-eslint/no-explicit-any
197*6dbdd20aSAndroid Build Coastguard Worker    column: ColumnDescriptor<any>,
198*6dbdd20aSAndroid Build Coastguard Worker  ): m.Child {
199*6dbdd20aSAndroid Build Coastguard Worker    let currDirection: SortDirection | undefined = undefined;
200*6dbdd20aSAndroid Build Coastguard Worker
201*6dbdd20aSAndroid Build Coastguard Worker    let items = column.contextMenu;
202*6dbdd20aSAndroid Build Coastguard Worker    if (column.ordering !== undefined) {
203*6dbdd20aSAndroid Build Coastguard Worker      const ordering = column.ordering;
204*6dbdd20aSAndroid Build Coastguard Worker      currDirection = directionOnIndex(column.id, vnode.attrs.data.sortingInfo);
205*6dbdd20aSAndroid Build Coastguard Worker      const newItems: m.Child[] = [];
206*6dbdd20aSAndroid Build Coastguard Worker      if (currDirection !== 'ASC') {
207*6dbdd20aSAndroid Build Coastguard Worker        newItems.push(
208*6dbdd20aSAndroid Build Coastguard Worker          m(MenuItem, {
209*6dbdd20aSAndroid Build Coastguard Worker            label: 'Sort ascending',
210*6dbdd20aSAndroid Build Coastguard Worker            onclick: () => {
211*6dbdd20aSAndroid Build Coastguard Worker              vnode.attrs.data.reorder({
212*6dbdd20aSAndroid Build Coastguard Worker                columnId: column.id,
213*6dbdd20aSAndroid Build Coastguard Worker                direction: 'ASC',
214*6dbdd20aSAndroid Build Coastguard Worker                ordering,
215*6dbdd20aSAndroid Build Coastguard Worker              });
216*6dbdd20aSAndroid Build Coastguard Worker            },
217*6dbdd20aSAndroid Build Coastguard Worker          }),
218*6dbdd20aSAndroid Build Coastguard Worker        );
219*6dbdd20aSAndroid Build Coastguard Worker      }
220*6dbdd20aSAndroid Build Coastguard Worker      if (currDirection !== 'DESC') {
221*6dbdd20aSAndroid Build Coastguard Worker        newItems.push(
222*6dbdd20aSAndroid Build Coastguard Worker          m(MenuItem, {
223*6dbdd20aSAndroid Build Coastguard Worker            label: 'Sort descending',
224*6dbdd20aSAndroid Build Coastguard Worker            onclick: () => {
225*6dbdd20aSAndroid Build Coastguard Worker              vnode.attrs.data.reorder({
226*6dbdd20aSAndroid Build Coastguard Worker                columnId: column.id,
227*6dbdd20aSAndroid Build Coastguard Worker                direction: 'DESC',
228*6dbdd20aSAndroid Build Coastguard Worker                ordering,
229*6dbdd20aSAndroid Build Coastguard Worker              });
230*6dbdd20aSAndroid Build Coastguard Worker            },
231*6dbdd20aSAndroid Build Coastguard Worker          }),
232*6dbdd20aSAndroid Build Coastguard Worker        );
233*6dbdd20aSAndroid Build Coastguard Worker      }
234*6dbdd20aSAndroid Build Coastguard Worker      if (currDirection !== undefined) {
235*6dbdd20aSAndroid Build Coastguard Worker        newItems.push(
236*6dbdd20aSAndroid Build Coastguard Worker          m(MenuItem, {
237*6dbdd20aSAndroid Build Coastguard Worker            label: 'Restore original order',
238*6dbdd20aSAndroid Build Coastguard Worker            onclick: () => {
239*6dbdd20aSAndroid Build Coastguard Worker              vnode.attrs.data.resetOrder();
240*6dbdd20aSAndroid Build Coastguard Worker            },
241*6dbdd20aSAndroid Build Coastguard Worker          }),
242*6dbdd20aSAndroid Build Coastguard Worker        );
243*6dbdd20aSAndroid Build Coastguard Worker      }
244*6dbdd20aSAndroid Build Coastguard Worker      items = [...newItems, ...(items ?? [])];
245*6dbdd20aSAndroid Build Coastguard Worker    }
246*6dbdd20aSAndroid Build Coastguard Worker
247*6dbdd20aSAndroid Build Coastguard Worker    return m(
248*6dbdd20aSAndroid Build Coastguard Worker      'td',
249*6dbdd20aSAndroid Build Coastguard Worker      column.name,
250*6dbdd20aSAndroid Build Coastguard Worker      items &&
251*6dbdd20aSAndroid Build Coastguard Worker        m(
252*6dbdd20aSAndroid Build Coastguard Worker          PopupMenu2,
253*6dbdd20aSAndroid Build Coastguard Worker          {
254*6dbdd20aSAndroid Build Coastguard Worker            trigger: m(Button, {icon: popupMenuIcon(currDirection)}),
255*6dbdd20aSAndroid Build Coastguard Worker          },
256*6dbdd20aSAndroid Build Coastguard Worker          items,
257*6dbdd20aSAndroid Build Coastguard Worker        ),
258*6dbdd20aSAndroid Build Coastguard Worker    );
259*6dbdd20aSAndroid Build Coastguard Worker  }
260*6dbdd20aSAndroid Build Coastguard Worker
261*6dbdd20aSAndroid Build Coastguard Worker  // eslint-disable-next-line @typescript-eslint/no-explicit-any
262*6dbdd20aSAndroid Build Coastguard Worker  checkValid(attrs: TableAttrs<any>) {
263*6dbdd20aSAndroid Build Coastguard Worker    if (!allUnique(attrs.columns.map((c) => c.id))) {
264*6dbdd20aSAndroid Build Coastguard Worker      throw new Error('column IDs should be unique');
265*6dbdd20aSAndroid Build Coastguard Worker    }
266*6dbdd20aSAndroid Build Coastguard Worker  }
267*6dbdd20aSAndroid Build Coastguard Worker
268*6dbdd20aSAndroid Build Coastguard Worker  // eslint-disable-next-line @typescript-eslint/no-explicit-any
269*6dbdd20aSAndroid Build Coastguard Worker  oncreate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
270*6dbdd20aSAndroid Build Coastguard Worker    this.checkValid(vnode.attrs);
271*6dbdd20aSAndroid Build Coastguard Worker  }
272*6dbdd20aSAndroid Build Coastguard Worker
273*6dbdd20aSAndroid Build Coastguard Worker  // eslint-disable-next-line @typescript-eslint/no-explicit-any
274*6dbdd20aSAndroid Build Coastguard Worker  onupdate(vnode: m.VnodeDOM<TableAttrs<any>, this>) {
275*6dbdd20aSAndroid Build Coastguard Worker    this.checkValid(vnode.attrs);
276*6dbdd20aSAndroid Build Coastguard Worker  }
277*6dbdd20aSAndroid Build Coastguard Worker
278*6dbdd20aSAndroid Build Coastguard Worker  // eslint-disable-next-line @typescript-eslint/no-explicit-any
279*6dbdd20aSAndroid Build Coastguard Worker  view(vnode: m.Vnode<TableAttrs<any>>): m.Child {
280*6dbdd20aSAndroid Build Coastguard Worker    const attrs = vnode.attrs;
281*6dbdd20aSAndroid Build Coastguard Worker
282*6dbdd20aSAndroid Build Coastguard Worker    return m(
283*6dbdd20aSAndroid Build Coastguard Worker      'table.generic-table',
284*6dbdd20aSAndroid Build Coastguard Worker      m(
285*6dbdd20aSAndroid Build Coastguard Worker        'thead',
286*6dbdd20aSAndroid Build Coastguard Worker        m(
287*6dbdd20aSAndroid Build Coastguard Worker          'tr.header',
288*6dbdd20aSAndroid Build Coastguard Worker          attrs.columns.map((column) => this.renderColumnHeader(vnode, column)),
289*6dbdd20aSAndroid Build Coastguard Worker        ),
290*6dbdd20aSAndroid Build Coastguard Worker      ),
291*6dbdd20aSAndroid Build Coastguard Worker      attrs.data.items().map((row) =>
292*6dbdd20aSAndroid Build Coastguard Worker        m(
293*6dbdd20aSAndroid Build Coastguard Worker          'tr',
294*6dbdd20aSAndroid Build Coastguard Worker          attrs.columns.map((column) => m('td', column.render(row))),
295*6dbdd20aSAndroid Build Coastguard Worker        ),
296*6dbdd20aSAndroid Build Coastguard Worker      ),
297*6dbdd20aSAndroid Build Coastguard Worker    );
298*6dbdd20aSAndroid Build Coastguard Worker  }
299*6dbdd20aSAndroid Build Coastguard Worker}
300