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