• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2025 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
17export interface ColumnDescriptor<T> {
18  readonly title: m.Children;
19  readonly render: (row: T) => {
20    colspan?: number;
21    className?: string;
22    cell: m.Children;
23  };
24}
25
26// This is a class to be able to perform runtime checks on `columns` below.
27export interface ReorderableColumns<T> {
28  readonly columns: ColumnDescriptor<T>[];
29  // Enables drag'n'drop reordering of columns.
30  readonly reorder?: (from: number, to: number) => void;
31  // Whether the first column should have a left border. True by default.
32  readonly hasLeftBorder?: boolean;
33}
34
35export interface CustomTableAttrs<T> {
36  readonly data: ReadonlyArray<T>;
37  readonly columns: ReadonlyArray<ReorderableColumns<T> | undefined>;
38  readonly className?: string;
39}
40
41export class CustomTable<T> implements m.ClassComponent<CustomTableAttrs<T>> {
42  view({attrs}: m.Vnode<CustomTableAttrs<T>>): m.Children {
43    const columns: {column: ColumnDescriptor<T>; extraClasses: string}[] = [];
44    const headers: m.Children[] = [];
45    for (const [index, columnGroup] of attrs.columns
46      .filter((c) => c !== undefined)
47      .entries()) {
48      const hasLeftBorder = (columnGroup.hasLeftBorder ?? true) && index !== 0;
49      const currentColumns = columnGroup.columns.map((column, columnIndex) => ({
50        column,
51        extraClasses:
52          hasLeftBorder && columnIndex === 0 ? '.has-left-border' : '',
53      }));
54      if (columnGroup.reorder === undefined) {
55        for (const {column, extraClasses} of currentColumns) {
56          headers.push(m(`td${extraClasses}`, column.title));
57        }
58      } else {
59        headers.push(
60          m(ReorderableCellGroup, {
61            cells: currentColumns.map(({column, extraClasses}) => ({
62              content: column.title,
63              extraClasses,
64            })),
65            onReorder: columnGroup.reorder,
66          }),
67        );
68      }
69      columns.push(...currentColumns);
70    }
71
72    return m(
73      `table.generic-table`,
74      {
75        className: attrs.className,
76        // TODO(altimin, stevegolton): this should be the default for
77        // generic-table, but currently it is overriden by
78        // .pf-details-shell .pf-content table, so specify this here for now.
79        style: {
80          'table-layout': 'auto',
81        },
82      },
83      m('thead', m('tr.header', headers)),
84      m(
85        'tbody',
86        attrs.data.map((row) => {
87          const cells = [];
88          for (let i = 0; i < columns.length; ) {
89            const {column, extraClasses} = columns[i];
90            const {colspan, className, cell} = column.render(row);
91            cells.push(m(`td${extraClasses}`, {colspan, className}, cell));
92            i += colspan ?? 1;
93          }
94          return m('tr', cells);
95        }),
96      ),
97    );
98  }
99}
100
101export interface ReorderableCellGroupAttrs {
102  cells: {
103    content: m.Children;
104    extraClasses: string;
105  }[];
106  onReorder: (from: number, to: number) => void;
107}
108
109const placeholderElement = document.createElement('span');
110
111// A component that renders a group of cells on the same row that can be
112// reordered between each other by using drag'n'drop.
113//
114// On completed reorder, a callback is fired.
115class ReorderableCellGroup
116  implements m.ClassComponent<ReorderableCellGroupAttrs>
117{
118  private drag?: {
119    from: number;
120    to?: number;
121  };
122
123  private getClassForIndex(index: number): string {
124    if (this.drag?.from === index) {
125      return 'dragged';
126    }
127    if (this.drag?.to === index) {
128      return 'highlight-left';
129    }
130    if (this.drag?.to === index + 1) {
131      return 'highlight-right';
132    }
133    return '';
134  }
135
136  view(vnode: m.Vnode<ReorderableCellGroupAttrs>): m.Children {
137    return vnode.attrs.cells.map((cell, index) =>
138      m(
139        `td.reorderable-cell${cell.extraClasses}`,
140        {
141          draggable: 'draggable',
142          class: this.getClassForIndex(index),
143          ondragstart: (e: DragEvent) => {
144            this.drag = {
145              from: index,
146            };
147            if (e.dataTransfer !== null) {
148              e.dataTransfer.setDragImage(placeholderElement, 0, 0);
149            }
150          },
151          ondragover: (e: DragEvent) => {
152            let target = e.target as HTMLElement;
153            if (this.drag === undefined || this.drag?.from === index) {
154              // Don't do anything when hovering on the same cell that's
155              // been dragged, or when dragging something other than the
156              // cell from the same group.
157              return;
158            }
159
160            while (
161              target.tagName.toLowerCase() !== 'td' &&
162              target.parentElement !== null
163            ) {
164              target = target.parentElement;
165            }
166
167            // When hovering over cell on the right half, the cell will be
168            // moved to the right of it, vice versa for the left side. This
169            // is done such that it's possible to put dragged cell to every
170            // possible position.
171            const offset = e.clientX - target.getBoundingClientRect().x;
172            const direction =
173              offset > target.clientWidth / 2 ? 'right' : 'left';
174            const dest = direction === 'left' ? index : index + 1;
175            const adjustedDest =
176              dest === this.drag.from || dest === this.drag.from + 1
177                ? undefined
178                : dest;
179            if (adjustedDest !== this.drag.to) {
180              this.drag.to = adjustedDest;
181            }
182          },
183          ondragleave: (e: DragEvent) => {
184            if (this.drag?.to !== index) return;
185            this.drag.to = undefined;
186            if (e.dataTransfer !== null) {
187              e.dataTransfer.dropEffect = 'none';
188            }
189          },
190          ondragend: () => {
191            if (
192              this.drag !== undefined &&
193              this.drag.to !== undefined &&
194              this.drag.from !== this.drag.to
195            ) {
196              vnode.attrs.onReorder(this.drag.from, this.drag.to);
197            }
198
199            this.drag = undefined;
200          },
201        },
202        cell.content,
203      ),
204    );
205  }
206}
207