import React, { useCallback, useEffect, useMemo, useState } from 'react' import { type ColumnDef, type ColumnDefBase, flexRender, getCoreRowModel, getFilteredRowModel, useReactTable, } from '@tanstack/react-table' import { Cell, Row, StickyTable } from 'react-sticky-table' import { notUndefined, useVirtualizer } from '@tanstack/react-virtual' import { type SpreadsheetProps } from './spreadsheet-types-new' import SpreadsheetCell from './SpreadsheetCell/SpreadsheetCellNew' import EmptyState from '../../EmptyState/EmptyState' import { hasValue } from '../../../services/HelperServiceTyped' import styles from './_spreadsheet.module.scss' import { Skeleton } from '../../Skeleton/Skeleton' import ColumnHeaderNew, { type ColumnHeaderBaseHeader, type ColumnHeaderBasesubHeader, } from './ColumnHeader/ColumnHeaderNew' import DeleteCell from './SpreadsheetCell/DeleteCell' import { c } from '../../../translations/LibraryTranslationService' interface CustomColumn { /** The id of the column */ id?: string /** The meta data of the column */ meta?: { /** Whether to enable the delete cell */ enableDelete?: boolean /** Whether the column is the primary column */ isPrimaryColumn?: boolean } } interface ExtendedTData { /** The data of the row */ [key: string]: Record } interface ExtendedHeader extends ColumnDefBase { /** Whether the header is disabled */ disabled?: boolean /** The error count of the header */ errorCount?: number /** The header of the column */ header?: ColumnHeaderBaseHeader /** The sub header of the column */ subHeader?: ColumnHeaderBasesubHeader /** Whether to show the count */ showCount?: boolean /** The children of the header */ headerChildren?: React.ReactNode } const Spreadsheet = ({ data, onRowDelete, columns, loading, noDataFields, stickyTableConfig, isReviewMode, isLastRowDeleted, hasMore, inputRules, customHeight, customWidth, selectedColumns, searchTerm, onStickyTableClassChange, noOutsideBorder, rowHeight = 75, overscan, noVirtualization = false, onRowClick, onHeaderCellClick, headerCancelCallout, qaTestId = 'spreadsheet', columnOrder, }: SpreadsheetProps) => { const [displayColumns, setDisplayColumns] = useState(columns) const [columnVisibility, setColumnVisibility] = useState({}) const [globalFilter, setGlobalFilter] = React.useState('') useEffect(() => { if (columns) { setDisplayColumns(columns) } }, [columns]) useEffect(() => { if (hasValue(searchTerm)) { setGlobalFilter(String(searchTerm)) } else { setGlobalFilter('') } }, [searchTerm]) const memoizedColumns = useMemo(() => { return displayColumns.map( (columnCell: CustomColumn & ColumnDef) => { return { enableSorting: false, ...columnCell, ...(columnCell.cell ? { cell: columnCell.cell } : { cell: columnCell?.meta?.enableDelete ? DeleteCell : SpreadsheetCell, }), } }, ) }, [displayColumns]) const table = useReactTable({ data: data, columns: memoizedColumns as ColumnDef[], getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), state: { columnVisibility, globalFilter, ...(columnOrder && { columnOrder }), }, onColumnVisibilityChange: setColumnVisibility, onGlobalFilterChange: setGlobalFilter, meta: { inputRules, isReviewMode, isLastRowDeleted, removeRow: (rowIndex: number) => { const deletedRow = data[rowIndex] if (onRowDelete) { onRowDelete(deletedRow, rowIndex) } }, }, }) useEffect(() => { if (selectedColumns) { const allTableColumns = table.getAllLeafColumns().map((cell) => cell.id) const updatedDisplayColumns = columns.map((cell) => ({ ...cell })) setColumnVisibility((prevColumnVisibility) => { const updatedVisibility: { [key: string]: boolean } = { ...prevColumnVisibility, } allTableColumns.forEach((columnId: string) => { if (selectedColumns?.includes(columnId)) { updatedVisibility[columnId] = true // Enable column filter only for the selected column const columnIndex = updatedDisplayColumns.findIndex( (col) => col.id === columnId, ) if (columnIndex !== -1) { updatedDisplayColumns[columnIndex].enableGlobalFilter = true } } else { updatedVisibility[columnId] = false // Disable column filter for all other columns const columnIndex = updatedDisplayColumns.findIndex( (col) => col.id === columnId, ) if (columnIndex !== -1) { updatedDisplayColumns[columnIndex].enableGlobalFilter = false } } }) return updatedVisibility }) setDisplayColumns(updatedDisplayColumns) } else { setColumnVisibility({}) } }, [columns, selectedColumns, table]) const tableContainerRef = React.useRef(null) const { getHeaderGroups, getRowModel } = table const { rows } = table.getRowModel() const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => rowHeight, overscan: overscan ?? 5, }) // Function to adjust the height of each cell in a row to match the height of the tallest cell in that row const updateRowHeight = useCallback( (rowEl: HTMLElement | null) => { if (!rowEl) return const cells = rowEl?.getElementsByClassName('sticky-table-cell') if (cells?.length === 0) return // Use requestAnimationFrame for smooth updates requestAnimationFrame(() => { let maxHeight = 0 const cellArray = Array.from(cells) // First pass: Reset all heights to auto cellArray?.forEach((cell: Element) => { const cellElement = cell as HTMLElement // Reset both the cell and its immediate children's heights cellElement.style.height = 'auto' Array.from(cellElement?.children ?? [])?.forEach((child) => { ;(child as HTMLElement).style.height = 'auto' }) }) // Force a reflow to ensure heights are recalculated void rowEl.offsetHeight // Second pass: Calculate max height cellArray?.forEach((cell: Element) => { const cellElement = cell as HTMLElement // Get the scrollHeight which includes all content const height = Math.max( cellElement?.scrollHeight ?? 0, ...Array.from(cellElement?.children ?? []).map( (child) => (child as HTMLElement).scrollHeight, ), ) maxHeight = Math.max(maxHeight, height) }) // Third pass: Apply the new height cellArray?.forEach((cell: Element) => { const cellElement = cell as HTMLElement cellElement.style.height = `${maxHeight}px` // Set children back to 100% height Array.from(cellElement?.children ?? []).forEach((child) => { ;(child as HTMLElement).style.height = '100%' }) }) }) virtualizer?.measureElement(rowEl) }, [virtualizer], ) const rowVirtualizer = virtualizer.getVirtualItems() const [before, after] = rowVirtualizer.length > 0 ? [ notUndefined(rowVirtualizer[0]).start - virtualizer.options.scrollMargin, virtualizer.getTotalSize() - notUndefined(rowVirtualizer[rowVirtualizer.length - 1]).end, ] : [0, 0] const findColumnsWithFixedWidths = () => { let hasDelete = false let rowCountColumn = null let productNameColumn = null for (let i = 0; i < displayColumns.length; i++) { const column: CustomColumn & ColumnDef = displayColumns[i] if (column?.meta?.enableDelete === true) { hasDelete = true } if (column.id === 'row') { rowCountColumn = i } if (column?.meta?.isPrimaryColumn) { productNameColumn = i } } return { hasDelete, rowCountColumn, productNameColumn } } const { hasDelete, rowCountColumn, productNameColumn } = findColumnsWithFixedWidths() const equalWidthAmount = useMemo( () => Number(customWidth) / ((selectedColumns?.length ?? columns.length) - (hasDelete ? 1 : 0) - (rowCountColumn ? 1 : 0) - (productNameColumn ? 1 : 0)), [ customWidth, selectedColumns?.length, columns.length, hasDelete, rowCountColumn, productNameColumn, ], ) const renderHeader = () => getHeaderGroups().map((headerGroup) => { return ( {headerGroup.headers.map((header, i) => { const rowCount = headerGroup.headers?.length const lastLeftStickyColumn = stickyTableConfig?.left ? stickyTableConfig?.left - 1 : 0 const firstRightStickyColumn = rowCount && !!stickyTableConfig?.right && rowCount - stickyTableConfig?.right let columnWidth = equalWidthAmount // if size is defined in header column definition, use that if (header?.column?.columnDef?.size) { columnWidth = header.column.columnDef.size } else if (hasDelete && i === 0) { columnWidth = 50 } else if (rowCountColumn && i === rowCountColumn) { columnWidth = 100 } else if (productNameColumn && i === productNameColumn) { columnWidth = 300 } const headerNew = header.column .columnDef as unknown as ExtendedHeader return ( onHeaderCellClick && onHeaderCellClick({ header: headerNew, } as unknown as ColumnDef) } headerChildren={headerNew?.headerChildren} headerCancelCallout={headerCancelCallout} {...(headerNew?.showCount && typeof headerNew.errorCount === 'number' ? { showCount: true, errorCount: headerNew.errorCount } : { showCount: undefined, errorCount: undefined })} /> ) })} ) }) const noDataToDisplay = !loading && getRowModel().rows.length === 0 const renderTableBody = () => !noDataToDisplay ? rowVirtualizer.map((virtualRow) => { const row = rows[virtualRow.index] const rowCount = row.getVisibleCells()?.length return ( { if ( typeof noVirtualization === 'object' && noVirtualization?.dynamicRowHeight && rowEl ) { // Initial height calculation updateRowHeight(rowEl) } }} > {row.getVisibleCells().map((cell, i) => { const columnId = cell.column.id const lastLeftStickyColumn = stickyTableConfig?.left ? stickyTableConfig?.left - 1 : 0 const firstRightStickyColumn = rowCount && !!stickyTableConfig?.right && rowCount - stickyTableConfig?.right const cellColor = row.original?.[columnId]?.color ?? '' const lightCellColor = () => { switch (cellColor) { case 'dark-red': return '--light-red' case 'dark-blue': return '--light-blue' case 'dark-green': return '--light-green' case 'dark-yellow': return '--light-yellow' case 'dark-purple': return '--light-purple' case 'medium-purple': return '--light-purple' case 'purple': return '--light-purple' case 'teal': return '--light-teal' case 'gray': return '--light-gray' default: return '' } } const onClickButton = () => { onRowClick?.({ data: row.original, selectedCell: columnId }) } return ( onClickButton()} > {flexRender(cell.column.columnDef.cell, cell.getContext())} ) })} ) }) : null const LoadingRow = () => ( {columns.map((_, i) => ( ))} ) const renderTableLoading = () => { return Array.from(Array(5)).map((_, index) => { return }) } //// Sticky Table logic //// const [showStickyClasses, setShowStickyClasses] = useState(false) const [innerTableWidth, setInnerTableWidth] = useState(0) const tableWidth = tableContainerRef.current?.clientWidth ?? 0 const [showLeftShadow, setShowLeftShadow] = useState(false) const [showRightShadow, setShowRightShadow] = useState(true) useEffect(() => { setInnerTableWidth( tableContainerRef.current?.querySelector('.sticky-table-table') ?.clientWidth ?? 0, ) }, [columnVisibility]) const isShowStickyClasses = useMemo( () => innerTableWidth > tableWidth, [innerTableWidth, tableWidth], ) useEffect(() => { setShowStickyClasses(isShowStickyClasses) onStickyTableClassChange?.(isShowStickyClasses && showRightShadow) }, [ isShowStickyClasses, onStickyTableClassChange, showLeftShadow, showRightShadow, ]) useEffect(() => { const ref = tableContainerRef.current const handleScroll = () => { const isScrolledLeft = (ref?.scrollLeft ?? 0) > 0 const isNotScrolledRight = (ref?.scrollLeft ?? 0) < (ref?.scrollWidth ?? 0) - (ref?.clientWidth ?? 0) // Show/hide shadows based on conditions setShowLeftShadow(isScrolledLeft) setShowRightShadow(isNotScrolledRight) onStickyTableClassChange?.(isNotScrolledRight) } ref?.addEventListener('scroll', handleScroll) return () => { ref?.removeEventListener('scroll', handleScroll) } }, [columnVisibility, onStickyTableClassChange]) //// End Sticky Table logic //// return (
{renderHeader()} {loading ? ( renderTableLoading() ) : ( <> {before > 0 && ( )} {renderTableBody()} {hasMore ? : null} {after > 0 && ( )} )} {noDataToDisplay ? ( ) : null}
) } export default Spreadsheet