go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/projects/nodes/static/_nextjs/src/components/editTable.tsx (about) 1 /** 2 * Copyright (c) 2024 - Present. Will Charczuk. All rights reserved. 3 * Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 4 */ 5 import { Cell, CellRenderer, Column, ColumnHeaderCell, EditableCell2, EditableName, FocusedCellCoordinates, Region, RenderMode, RowHeaderCell, SelectionModes, Table2, TableLoadingOption } from '@blueprintjs/table'; 6 import * as api from '../api/nodes'; 7 import { Component, ReactElement, useCallback, useEffect, useRef, useState } from 'react'; 8 import { Intent, Menu, MenuItem, PopupKind } from '@blueprintjs/core'; 9 10 export interface EditTableProps { 11 info: api.TableInfo; 12 getVisibleRange: (range: any) => Promise<any[][]>; 13 onColumnAdded?: (col: number, name: string) => Promise<void>; 14 onColumnReorder?: (oldCol: number, newCol: number) => Promise<void>; 15 onColumnNameChanged?: (col: number, name: string) => Promise<void>; 16 onColumnRemoved?: (col: number) => Promise<void>; 17 onRowAdded?: (row: number) => Promise<void>; 18 onRowRemoved?: (col: number) => Promise<void>; 19 onRowReorder?: (oldRow: number, newRow) => Promise<void>; 20 onCellChanged?: (row: number, col: number, value: any) => Promise<void>; 21 } 22 23 export interface EditTableState { 24 info: api.TableInfo; 25 visibleRange: any[][]; 26 focusedCell?: FocusedCellCoordinates; 27 selectedRegions: Region[]; 28 visibleRegion?: api.TableRange; 29 } 30 31 interface RowIndices { 32 rowIndexStart: number; 33 rowIndexEnd: number; 34 } 35 interface ColumnIndices { 36 columnIndexStart: number; 37 columnIndexEnd: number; 38 } 39 40 export class EditTable extends Component<EditTableProps, EditTableState> { 41 public constructor(props: EditTableProps) { 42 super(props); 43 44 this.addColumn = this.addColumn.bind(this); 45 this.addRow = this.addRow.bind(this); 46 this.atRowCol = this.atRowCol.bind(this); 47 this.columnMenuRenderer = this.columnMenuRenderer.bind(this); 48 this.deleteColumn = this.deleteColumn.bind(this); 49 this.deleteRow = this.deleteRow.bind(this); 50 this.fetchRange = this.fetchRange.bind(this); 51 this.getCellClipboardData = this.getCellClipboardData.bind(this); 52 this.ghostCell = this.ghostCell.bind(this); 53 this.ghostColumnHeader = this.ghostColumnHeader.bind(this); 54 this.onEditableCellConfirm = this.onEditableCellConfirm.bind(this); 55 this.onColumnHeaderNameChangeConfirm = this.onColumnHeaderNameChangeConfirm.bind(this); 56 this.onColumnsReordered = this.onColumnsReordered.bind(this); 57 this.onFocusedCell = this.onFocusedCell.bind(this); 58 this.onRowsReordered = this.onRowsReordered.bind(this); 59 this.onSelection = this.onSelection.bind(this); 60 this.onVisibleCellsChange = this.onVisibleCellsChange.bind(this); 61 this.render = this.render.bind(this); 62 this.renderCell = this.renderCell.bind(this); 63 this.renderColumnHeaderCell = this.renderColumnHeaderCell.bind(this); 64 this.renderColumnHeaderCellName = this.renderColumnHeaderCellName.bind(this); 65 this.renderTable = this.renderTable.bind(this); 66 this.renderColumns = this.renderColumns.bind(this); 67 this.rowMenuRenderer = this.rowMenuRenderer.bind(this); 68 69 this.state = { 70 info: props.info, 71 visibleRange: [], 72 selectedRegions: [], 73 } 74 } 75 76 public componentDidUpdate(prevProps: Readonly<EditTableProps>, prevState: Readonly<EditTableState>, snapshot?: any): void { 77 if (this.props.info != prevProps.info) { 78 this.setState({ info: this.props.info }) 79 } 80 } 81 82 public render() { 83 return ( 84 <div className="edit-table-outer"> 85 {this.renderTable()} 86 </div> 87 ) 88 } 89 90 private renderTable() { 91 return ( 92 <div className="layout-boundary"> 93 <Table2 94 numRows={this.state.info.rows + 1} 95 cellRendererDependencies={[this.state.info, this.state.visibleRange]} 96 className='edit-table' 97 enableColumnHeader={true} 98 enableColumnInteractionBar={true} 99 enableColumnReordering={true} 100 enableFocusedCell={true} 101 enableRowReordering={true} 102 focusedCell={this.state.focusedCell} 103 renderMode={RenderMode.BATCH} 104 onVisibleCellsChange={this.onVisibleCellsChange} 105 getCellClipboardData={this.getCellClipboardData} 106 onColumnsReordered={this.onColumnsReordered} 107 onFocusedCell={this.onFocusedCell} 108 onRowsReordered={this.onRowsReordered} 109 onSelection={this.onSelection} 110 rowHeaderCellRenderer={this.renderRowHeaderCell} 111 selectedRegions={this.state.selectedRegions} 112 > 113 {this.renderColumns()} 114 </Table2> 115 </div> 116 ) 117 } 118 119 private renderColumns() { 120 if (this.state.info.columns && this.state.info.columns.length > 0) { 121 return this.state.info.columns.map((c, i) => { 122 return <Column key={i} name={c} cellRenderer={this.renderCell} columnHeaderCellRenderer={this.renderColumnHeaderCell} /> 123 }) 124 } 125 return (<Column key={0} name={"New Column"} cellRenderer={this.ghostCell} columnHeaderCellRenderer={this.ghostColumnHeader} />) 126 } 127 128 private columnMenuRenderer(columnIndex: number) { 129 var menuItems: Array<ReactElement> = [] 130 if (columnIndex > 0) { 131 menuItems.push(<MenuItem key={0} icon="add" text="Insert Column (left)" intent={Intent.SUCCESS} onClick={() => this.addColumn(columnIndex)} />) 132 } 133 if (this.state.info.columns.length === 0) { 134 menuItems.push(<MenuItem key={1} icon="add" text="Insert Column" intent={Intent.SUCCESS} onClick={() => this.addColumn(columnIndex)} />) 135 } else { 136 menuItems.push(<MenuItem key={2} icon="add" text="Insert Column (right)" intent={Intent.SUCCESS} onClick={() => this.addColumn(columnIndex + 1)} />) 137 } 138 menuItems.push(<MenuItem key={3} icon="delete" text="Delete Column" intent={Intent.DANGER} onClick={() => this.deleteColumn(columnIndex)} />) 139 return ( 140 <Menu> 141 {menuItems} 142 </Menu> 143 ) 144 } 145 146 private rowMenuRenderer(rowIndex: number) { 147 var menuItems: Array<ReactElement> = [] 148 if (rowIndex > 0) { 149 menuItems.push(<MenuItem key={0} icon="add" text="Insert Row (above)" intent={Intent.SUCCESS} onClick={() => this.addRow(rowIndex)} />) 150 } 151 if (this.state.info.rows === 0) { 152 menuItems.push(<MenuItem key={1} icon="add" text="Insert Row" intent={Intent.SUCCESS} onClick={() => this.addRow(rowIndex)} />) 153 } else { 154 menuItems.push(<MenuItem key={2} icon="add" text="Insert Row (below)" intent={Intent.SUCCESS} onClick={() => this.addRow(rowIndex + 1)} />) 155 } 156 menuItems.push(<MenuItem key={3} icon="delete" text="Delete Row" intent={Intent.DANGER} onClick={() => this.deleteRow(rowIndex)} />) 157 return ( 158 <Menu> 159 {menuItems} 160 </Menu> 161 ) 162 } 163 164 private renderColumnHeaderCell(columnIndex: number) { 165 if (this.state.info.columns && columnIndex < this.props.info.columns.length) { 166 return ( 167 <ColumnHeaderCell 168 index={columnIndex} 169 name={this.state.info.columns[columnIndex]} 170 nameRenderer={this.renderColumnHeaderCellName} 171 enableColumnReordering={true} 172 menuRenderer={this.columnMenuRenderer} 173 /> 174 ) 175 } 176 return null 177 } 178 179 private renderRowHeaderCell(rowIndex: number) { 180 return (<RowHeaderCell 181 index={rowIndex} 182 menuRenderer={this.rowMenuRenderer} 183 enableRowReordering={true} 184 name={String(rowIndex + 1)} 185 />) 186 } 187 188 private renderColumnHeaderCellName(name?: string, columnIndex?: number) { 189 return ( 190 <EditableName index={columnIndex} key={columnIndex} name={name} onConfirm={this.onColumnHeaderNameChangeConfirm} /> 191 ) 192 } 193 194 private ghostCell() { 195 return (<Cell key={'ghost-cell'} interactive={false} loading={true}></Cell>) 196 } 197 198 private ghostColumnHeader() { 199 return ( 200 <ColumnHeaderCell 201 index={0} 202 key={'ghost-column-header'} 203 name={''} 204 nameRenderer={this.renderColumnHeaderCellName} 205 enableColumnInteractionBar={true} 206 enableColumnReordering={false} 207 selectCellsOnMenuClick={false} 208 menuRenderer={this.columnMenuRenderer} 209 /> 210 ) 211 } 212 213 private renderCell(rowIndex: number, columnIndex: number) { 214 const value = this.atRowCol(rowIndex, columnIndex) 215 const valueAsString = value == null ? "" : value; 216 return (<EditableCell2 217 value={valueAsString} 218 rowIndex={rowIndex} 219 columnIndex={columnIndex} 220 onConfirm={this.onEditableCellConfirm} 221 editableTextProps={{ 222 confirmOnEnterKey: true, 223 }} 224 ></EditableCell2>) 225 } 226 227 private async fetchRange(visibleRegion: api.TableRange) { 228 const data = await this.props.getVisibleRange(visibleRegion); 229 this.setState({ 230 visibleRegion: visibleRegion, 231 visibleRange: data, 232 }) 233 } 234 235 private async addColumn(columnIndex: number) { 236 const columnName = 'New Column' 237 if (this.props.onColumnAdded) { 238 await this.props.onColumnAdded(columnIndex, columnName) 239 } 240 if (this.state.visibleRegion !== undefined) { 241 await this.fetchRange(this.state.visibleRegion) 242 } 243 } 244 245 private async deleteColumn(columnIndex: number) { 246 if (this.props.onColumnRemoved) { 247 await this.props.onColumnRemoved(columnIndex) 248 } 249 if (this.state.visibleRegion !== undefined) { 250 await this.fetchRange(this.state.visibleRegion) 251 } 252 } 253 254 private async addRow(rowIndex: number) { 255 if (this.props.onRowAdded) { 256 await this.props.onRowAdded(rowIndex) 257 } 258 if (this.state.visibleRegion !== undefined) { 259 await this.fetchRange(this.state.visibleRegion) 260 } 261 } 262 263 private async deleteRow(rowIndex: number) { 264 if (this.props.onRowRemoved) { 265 await this.props.onRowRemoved(rowIndex) 266 } 267 if (this.state.visibleRegion !== undefined) { 268 await this.fetchRange(this.state.visibleRegion) 269 } 270 } 271 272 private atRowCol(rowIndex: number, columnIndex: number): any { 273 const rowOffset = this.state.visibleRegion !== undefined ? this.state.visibleRegion.top : 0; 274 const colOffset = this.state.visibleRegion !== undefined ? this.state.visibleRegion.left : 0; 275 const effectiveRowIndex = rowIndex - rowOffset; 276 const effectiveColumnIndex = columnIndex - colOffset; 277 if (this.state.visibleRange && this.state.visibleRange.length > effectiveRowIndex) { 278 if (this.state.visibleRange[effectiveRowIndex] && this.state.visibleRange[effectiveRowIndex].length > effectiveColumnIndex) { 279 return this.state.visibleRange[effectiveRowIndex][effectiveColumnIndex] 280 } 281 } 282 return null 283 } 284 285 private async onColumnHeaderNameChangeConfirm(value: string, columnIndex: number) { 286 if (this.props.onColumnNameChanged) { 287 await this.props.onColumnNameChanged(columnIndex, value); 288 } 289 } 290 291 private async onEditableCellConfirm(value: string, rowIndex: number, columnIndex: number) { 292 if (this.props.onCellChanged) { 293 await this.props.onCellChanged(rowIndex, columnIndex, value); 294 } 295 if (this.state.visibleRegion) { 296 this.fetchRange(this.state.visibleRegion) 297 } 298 } 299 300 private onVisibleCellsChange(rowIndices: RowIndices, columnIndices: ColumnIndices) { 301 if ( 302 this.state.visibleRegion === undefined || 303 rowIndices.rowIndexStart !== this.state.visibleRegion.top || 304 rowIndices.rowIndexEnd !== this.state.visibleRegion.bottom || 305 columnIndices.columnIndexStart !== this.state.visibleRegion.left || 306 columnIndices.columnIndexEnd !== this.state.visibleRegion.right 307 ) { 308 const newRegion = { 309 top: rowIndices.rowIndexStart, 310 left: columnIndices.columnIndexStart, 311 bottom: rowIndices.rowIndexEnd, 312 right: columnIndices.columnIndexEnd, 313 } 314 this.fetchRange(newRegion) 315 } 316 } 317 318 private getCellClipboardData(row: number, col: number, celRenderer: CellRenderer) { 319 return this.atRowCol(row, col); 320 } 321 322 private async onColumnsReordered(oldIndex: number, newIndex: number, length: number) { 323 if (oldIndex === newIndex) { 324 return 325 } 326 if (this.props.onColumnReorder) { 327 await this.props.onColumnReorder(oldIndex, newIndex); 328 } 329 330 if (this.state.visibleRegion !== undefined) { 331 await this.fetchRange(this.state.visibleRegion) 332 } 333 } 334 335 private async onRowsReordered(oldIndex: number, newIndex: number, length: number) { 336 if (oldIndex === newIndex) { 337 return 338 } 339 if (this.props.onRowReorder) { 340 await this.props.onRowReorder(oldIndex, newIndex); 341 } 342 343 if (this.state.visibleRegion !== undefined) { 344 await this.fetchRange(this.state.visibleRegion) 345 } 346 } 347 348 private onFocusedCell(fc) { 349 this.setState({ focusedCell: fc }) 350 } 351 352 private onSelection(sc) { 353 this.setState({ selectedRegions: sc }) 354 } 355 }