import { useEffect, useState, useRef, useMemo, useCallback, useLayoutEffect, } from 'react' import useResizeObserver from '@react-hook/resize-observer' import { ContextMenu, MenuItem, ContextMenuTrigger, } from '@firefox-devtools/react-contextmenu' import { ZodError } from 'zod' import classNames from 'classnames' import ReactDataSheet from 'react-datasheet' import 'react-datasheet/lib/react-datasheet.css' import { OutputSchemaBase, RecordValue } from '.' import IVButton from '~/components/IVButton' import { castValue, getColumnDefs } from './helpers' import React from 'react' import IconAddRowAbove from '~/icons/compiled/AddRowAbove' import IconDeleteRow from '~/icons/compiled/DeleteRow' import IconAddRow from '~/icons/compiled/AddRow' import IconExclamationWarn from '~/icons/compiled/ExclamationWarn' import IconChevronLeft from '~/icons/compiled/ChevronLeft' import IconChevronRight from '~/icons/compiled/ChevronRight' const PAGE_SIZE = 20 type CellValue = RecordValue export type ColumnDef = { type: 'number' | 'text' | 'boolean' isRequired: boolean } interface Cell extends ReactDataSheet.Cell { value: CellValue invalid?: boolean colInvalid?: boolean rowInvalid?: boolean colDef: ColumnDef } class DataSheet extends ReactDataSheet {} interface ContextState { event: MouseEvent cell: Cell row: number col: number } const CONTEXT_ICON_CLASS_NAME = 'w-4 h-4 mr-3' interface SpreadsheetEditorProps { id: string records: Record[] onChange: (newVal: Record[]) => void parseError: ZodError | null outputSchema: OutputSchemaBase disabled?: boolean } const SpreadsheetEditor = React.forwardRef< HTMLDivElement, SpreadsheetEditorProps >(({ id, records, onChange, parseError, outputSchema, disabled }, ref) => { const [page, setPage] = useState(0) const contextMenuRef = useRef(null) const [contextState, setContextState] = useState(null) const contextMenuId = useMemo(() => `__IV_SPREADSHEET_EDITOR_${id}`, [id]) const recordShape = useMemo( () => outputSchema._def.type._def.shape(), [outputSchema] ) const columnDefs = useMemo(() => getColumnDefs(outputSchema), [outputSchema]) const headings = useMemo(() => Object.keys(columnDefs), [columnDefs]) const [invalidColRows, invalidRows]: [Map>, Set] = useMemo(() => { const map = new Map>() const rows = new Set() if (parseError) { for (const issue of parseError.issues) { if (issue.path.length === 2) { const [rowIndex, colName] = issue.path as [number, string] rows.add(rowIndex) let rowCells = map.get(colName) if (!rowCells) { rowCells = new Set() map.set(colName, rowCells) } rowCells.add(rowIndex) } } } return [map, rows] }, [parseError]) useEffect(() => { if (page * PAGE_SIZE > records.length) { setPage(Math.floor(records.length / PAGE_SIZE)) } }, [records, page]) const recordsToCells = useCallback< (records: Record[], rowOffset?: number) => Cell[][] >( (records, rowOffset = 0) => records.map((record, index) => headings.map(heading => { return { value: record[heading], colDef: columnDefs[heading], colInvalid: invalidColRows.has(heading), rowInvalid: invalidRows.has(index + rowOffset), invalid: invalidColRows.get(heading)?.has(index + rowOffset), } }) ), [headings, invalidColRows, invalidRows, columnDefs] ) const pageData: Cell[][] = useMemo(() => { const start = page * PAGE_SIZE const pageRecords = records.slice(start, start + PAGE_SIZE) return recordsToCells(pageRecords, start) }, [recordsToCells, page, records]) const handleChange = useCallback( ( changes: ReactDataSheet.CellsChangedArgs, additions?: ReactDataSheet.CellAdditionsArgs ) => { const data = recordsToCells(records) const rowOffset = page * PAGE_SIZE const newData = [...data.map(row => [...row])] for (const change of changes) { const row = change.row + rowOffset newData[row][change.col].value = change.value } if (additions) { for (const addition of additions) { let row = newData[addition.row + rowOffset] if (!row) { row = [] newData[addition.row + rowOffset] = row } row[addition.col] = { value: addition.value, colDef: columnDefs[addition.col], } } } const newRecords: Record[] = newData.map(row => { const record = {} headings.forEach((heading, index) => { record[heading] = castValue( row[index]?.value ?? null, recordShape[heading] ) }) return record }) onChange(newRecords) }, [recordsToCells, records, page, onChange, columnDefs, headings, recordShape] ) const handleRemoveRow = useCallback( row => { const rowOffset = page * PAGE_SIZE const newRecords = [...records] newRecords.splice(row + rowOffset, 1) onChange(newRecords) }, [onChange, records, page] ) const handleAddRow = useCallback( row => { const rowOffset = page * PAGE_SIZE const newRecord = {} for (const heading of headings) { newRecord[heading] = castValue(null, recordShape[heading]) } const newRecords = [...records] newRecords.splice(row + rowOffset, 0, newRecord) onChange(newRecords) }, [onChange, headings, records, recordShape, page] ) const DataTable = useCallback( ({ children, className: baseClassName, }: ReactDataSheet.SheetRendererProps) => { return ( {Object.entries(columnDefs).map(([heading, def]) => ( ))} {children}
{heading} {`${def.type}${def.isRequired ? '*' : ''}`}
) }, [columnDefs, disabled] ) const errorsOnOtherPages = useMemo(() => { const messages: string[] = [] parseError?.issues.forEach(issue => { const [rowIndex, colName] = issue.path as [number, string] // alias row 0 as row 1, otherwise division is incorrect const pageNum = Math.ceil(Math.max(1, rowIndex) / PAGE_SIZE) // skip errors on the current page if (pageNum === page + 1) return if (issue.message.includes('received null')) { messages.push(`Missing value for '${colName}' on page ${pageNum}`) } else { messages.push(`Invalid value for '${colName}' on page ${pageNum}`) } }) return messages }, [page, parseError?.issues]) return (
{records.length > PAGE_SIZE && ( )}

Expected data format:

* indicates required field

typeof cell.value === 'boolean' ? cell.value ? 'true' : 'false' : cell.value } parsePaste={parsePaste} sheetRenderer={DataTable} rowRenderer={DataRow} cellRenderer={props => ( )} valueViewer={props => ( )} dataEditor={DataEditor} onContextMenu={(event, cell, row, col) => { setContextState({ event, cell, row, col, }) }} onSelect={() => { // @ts-ignore: react-contextmenu's types aren't great contextMenuRef.current?.handleHide({}) }} />
{records.length > PAGE_SIZE && ( )} {errorsOnOtherPages.length > 0 && (

This data contains the following errors:

    {errorsOnOtherPages.map((line, idx) => (
  • {line}
  • ))}
)} {/* @ts-ignore: react-contextmenu types aren't quite right */} { contextMenuRef.current = c as unknown as typeof ContextMenu }} id={contextMenuId} onHide={() => { setContextState(null) }} > {/* @ts-ignore: react-contextmenu types aren't quite right */} { if (!contextState) return console.log(contextState) handleAddRow(contextState.row) }} disabled={!contextState} > Add row above {/* @ts-ignore: react-contextmenu types aren't quite right */} { if (!contextState) return handleAddRow(contextState.row + 1) }} disabled={!contextState} > Add row below {/* @ts-ignore: react-contextmenu types aren't quite right */} { if (!contextState) return handleRemoveRow(contextState.row) }} disabled={!contextState || records.length === 1} > Delete row
) }) SpreadsheetEditor.displayName = 'SpreadsheetEditor' export default SpreadsheetEditor interface PaginationProps { page: number setPage: (page: number) => void totalRecords: number disabled?: boolean } function Pagination({ page, setPage, totalRecords, disabled, }: PaginationProps) { const maxPage = useMemo( () => Math.ceil(totalRecords / PAGE_SIZE) - 1, [totalRecords] ) return (
Page {page + 1} of {maxPage + 1}
Prev } condensed disabled={page === 0 || disabled} onClick={() => { if (page === 0) return setPage(page - 1) }} /> Next } condensed disabled={page === maxPage || disabled} onClick={() => { if (page >= maxPage) return setPage(page + 1) }} />
) } function parsePaste(pastedString: string): string[][] { // https://github.com/nadbm/react-datasheet/issues/309#issuecomment-1023031077 return pastedString .trim() .split(/\r\n|\n|\r/) .map(row => row.split('\t')) } function DataRow({ children, }: ReactDataSheet.RowRendererProps) { return {children} } function DataCell({ cell, children, style, row, col, onMouseDown, onMouseOver, onDoubleClick, onContextMenu, onAddRow, selected, editing, }: ReactDataSheet.CellRendererProps & { onAddRow: (row: number) => void }) { const ref = useRef(null) const customKeydownListener = useCallback( (event: KeyboardEvent) => { // must listen to metaKey, which react-datasheet uses to block going into edit mode. // with event.shiftKey we add a new row but it also initiates editing the current cell. if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { // Add new rows with Ctrl/Cmd + Return onAddRow(row + 1) } }, [onAddRow, row] ) useEffect(() => { if (selected) { window.addEventListener('keydown', customKeydownListener) } else { window.removeEventListener('keydown', customKeydownListener) } return () => { window.removeEventListener('keydown', customKeydownListener) } }, [customKeydownListener, selected]) useEffect(() => { if (!selected || cell.colDef.type !== 'boolean') return // put boolean cells into edit mode immediately upon selection ref.current?.dispatchEvent( new MouseEvent('dblclick', { view: window, bubbles: true, cancelable: true, }) ) }, [selected, cell.colDef.type]) return (
{children}
{cell.component} ) } function ValueViewer({ value, cell, row, col, onChange, }: ReactDataSheet.ValueViewerProps & { // needed to allow editing boolean cells without being in edit mode onChange: (changes: ReactDataSheet.CellsChangedArgs) => void }) { if (cell.colDef.type === 'boolean') return ( ) return ( {value === null || value === '' ? <>  : value} ) } function DataEditor({ cell, value, onChange, onKeyDown, onRevert, onCommit, }: ReactDataSheet.DataEditorProps) { const spanRef = useRef(null) const cbRef = useRef(null) const size = useSize(spanRef) const handleChange = useCallback( event => { onChange(event.target.value) }, [onChange] ) // @ts-ignore - despite typings, `value` may be a boolean or a string const typedValue = value === 'true' || value === true // for when the cell is selected but nothing is focused, e.g. checkboxes. const customKeydownListener = useCallback( (event: KeyboardEvent) => { switch (event.key) { case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': // jump out of edit mode in boolean cells using arrow keys if (cell.colDef.type === 'boolean') { // @ts-ignore - expects a keyboard event but we're attaching to window. // also purposefully committing `value` here, not the opposite of `value`. onCommit(value, event) } break default: } // must listen to metaKey, which react-datasheet uses to block going into edit mode. // with event.shiftKey we add a new row but it also initiates editing the current cell. if ( cell.colDef.type === 'boolean' && !event.metaKey && !event.ctrlKey && (event.key === 'Enter' || event.code === 'Space') ) { onChange(!typedValue) event.preventDefault() } }, [cell.colDef.type, onCommit, value, onChange, typedValue] ) useEffect(() => { window.addEventListener('keydown', customKeydownListener) return () => { window.removeEventListener('keydown', customKeydownListener) } }, [customKeydownListener]) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { switch (event.key) { case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': // jump out of edit mode in boolean cells using arrow keys if (cell.colDef.type === 'boolean') { onCommit(value, event) onKeyDown(event) } break case 'Esc': case 'Escape': onRevert() break case 'Enter': // ignore ctrl/meta + return, which creates a new row if (event.metaKey || event.ctrlKey) break if (cell.colDef.type === 'boolean') { // use enter key to make & commit changes in boolean cells. // exclude 2nd event param to keep focus on the selected cell. onChange(!typedValue) onCommit(!typedValue) } else { onCommit(value, event) } break default: onKeyDown(event) } }, [ onCommit, value, onKeyDown, onRevert, cell.colDef.type, onChange, typedValue, ] ) return ( <> {cell.colDef.type === 'boolean' ? ( ) : ( <> {value} )} ) } function useSize(target) { const [size, setSize] = useState(null) useLayoutEffect(() => { setSize(target.current.getBoundingClientRect()) }, [target]) useResizeObserver(target, entry => { setSize(entry.target.getBoundingClientRect()) }) return size }