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