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  }