import React, { useState, useRef, useEffect, useCallback } from 'react' import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync' import classNames from 'classnames' import { IVTableProps, Pagination } from '.' import { IVTableCellValue, IVTableCells, IVTableColumn, IVTableRow, UseTableResult, } from './useTable' import { numberWithCommas, pluralizeWithCount } from '~/utils/text' import DesktopVerticalTable from './VerticalTable' import TableDownloader from './TableDownloader' import TableRowMenu from './TableRowMenu' import useDisableSelectionWithShiftKey from '~/utils/useDisableSelectionWithShiftKey' import IVSpinner from '~/components/IVSpinner' import SortUpIcon from '~/icons/compiled/SortUp' import SortDownIcon from '~/icons/compiled/SortDown' import InfoIcon from '~/icons/compiled/Info' import SearchIcon from '~/icons/compiled/Search' import CloseIcon from '~/icons/compiled/Close' import RenderValue from '../RenderValue' import { preventDefaultInputEnterKey } from '~/utils/preventDefaultInputEnter' import useResizeObserver from '@react-hook/resize-observer' const CHECKBOX_COLUMN_WIDTH = 41 // includes 1px border on right const HEADER_HEIGHT = 41 // includes 1px border const MAX_CELL_WIDTH = 600 const MIN_CELL_WIDTH = 41 type OrientationTableProps = IVTableProps & { tableRef: (node: HTMLDivElement) => void columnSizes: number[] } /** * DesktopHorizontalTable consists of two separate elements: * - A scrolling, sticky header containing the controls (search, pagination, etc) and the column headers * - The table body * * The separate header is required because sticky headers don't work for horizontally scrolling tables. * We use react-scroll-sync to scroll both elements together. */ function DesktopHorizontalTable(props: OrientationTableProps) { const [lastRowsCount, setLastRowsCount] = useState(10) const stableComponentId = useRef(Math.random().toString(36).substring(2, 15)) const RowComponent = props.useMemoizedRows ? MemoizedHorizontalTableRow : HorizontalTableRow useEffect(() => { if (!props.table.currentPage.length) return setLastRowsCount(props.table.currentPage.length) }, [props.table.currentPage.length]) if (props.table.isEmptyWithoutFilters && !props.table.isFetching) { return (

{props.emptyMessage}

) } return (
{props.table.currentPage.length > 0 ? (
{/* this invisible row appears behind the header and factors in column labels for width calculations */} {props.table.currentPage.map(row => ( { if ( props.selectionCriteria?.min === 1 && props.selectionCriteria?.max === 1 && !props.table.selectedKeys.has(key) ) { props.table.setSelectedKeys([key]) } else { props.table.toggleRow(key, event) } }} columnSizes={props.columnSizes} fixedWidthColumns={props.fixedWidthColumns} hasRowMenus={props.table.hasRowMenus} renderMarkdown={props.renderMarkdown} shouldTruncate={props.shouldTruncate} /> ))}
) : ( <> {props.table.isFetching ? ( // loading state consists of empty rows x the previous number of rows
{Array.from({ length: lastRowsCount }).map((_, idx) => (
 
))}
) : (
{props.table.searchQuery ? (

No records found matching '{props.table.searchQuery}'

) : (

No records found

)}
)} )} {props.showControls && !props.table.isEmptyWithoutFilters && ( )}
) } function HorizontalTableHeader(props: OrientationTableProps) { const [isStuck, setIsStuck] = useState(false) const headerEl = useRef(null) // toggle .rounded-t-lg when the header becomes stuck/unstuck useEffect(() => { if (!headerEl.current) return const observer = new IntersectionObserver( ([e]) => setIsStuck(e.intersectionRatio < 1), { threshold: [1] } ) observer.observe(headerEl.current) }, []) const thClassName = 'py-2.5 whitespace-nowrap text-left font-medium' const hasSelectedRows = props.table.selectedKeys.size > 0 const areAllRowsSelected = props.table.selectedKeys.size === props.table.totalRecords && props.table.totalRecords > 0 return (
{props.showControls && ( )}
) } type HorizontalTableRowProps = { tableKey?: string columns: IVTableColumn[] row: IVTableRow isDisabled: boolean isSelectable: boolean onToggleRow: UseTableResult['toggleRow'] columnSizes: number[] fixedWidthColumns?: boolean hasRowMenus: boolean renderMarkdown?: boolean shouldTruncate?: boolean } function getHighlightColor(cellValue: IVTableCellValue) { if ( typeof cellValue === 'object' && cellValue && 'highlightColor' in cellValue ) { return cellValue.highlightColor } } function getConsistentRowHighlight({ row, columns, }: HorizontalTableRowProps): string | undefined { const rowData = row.data as IVTableCells const cellColors = columns.map(col => { const value = rowData[col.key] if ( value && typeof value === 'object' && 'highlightColor' in value && value.highlightColor ) { return value.highlightColor } }) if ( cellColors.length === columns.length && cellColors.every(color => color === cellColors[0]) ) { return cellColors[0] } } function HorizontalTableRow(props: HorizontalTableRowProps) { const { columns, row } = props const rowHighlightColor = getConsistentRowHighlight(props) return (
{ if (props.isDisabled || !props.isSelectable) return props.onToggleRow(row.key, evt) }} data-highlight-color={rowHighlightColor} > {props.isSelectable && (
readOnly />
)} {columns.map((col, colIdx) => { const cellValue = (row.data as IVTableCells)[col.key] return (
) })} {props.hasRowMenus && row.menu && row.menu.length > 0 && (
)}
) } function rowComparisonFn( prev: HorizontalTableRowProps, next: HorizontalTableRowProps ) { // complete table rerender if (prev.tableKey !== next.tableKey) return false // selection change if (prev.row.isSelected !== next.row.isSelected) return false // sort order & pagination change if (prev.row.key !== next.row.key) return false // column widths if (prev.columnSizes.toString() !== next.columnSizes.toString()) return false // disabled state change if (prev.isDisabled !== next.isDisabled) return false return true } const MemoizedHorizontalTableRow = React.memo( HorizontalTableRow, rowComparisonFn ) const getSizes = ( orientation: IVTableProps['orientation'], node: HTMLDivElement ) => { if (orientation === 'vertical') { const cells = Array.from(node.querySelectorAll('tr')) // getBoundingClientRect returns a more accurate height reading return cells.map(cell => cell.getBoundingClientRect().height) } else { const cells = Array.from( node .querySelectorAll('div[role="row"]')[0] ?.querySelectorAll('div[role="cell"]') ) // getBoundingClientRect returns a more accurate width reading return cells.map(cell => cell.getBoundingClientRect().width) } } function useColumnSizes(props: IVTableProps) { const ref = useRef(null) const [sizes, setSizes] = useState([]) const calculate = useCallback(() => { if (!ref.current) return const calculated = getSizes(props.orientation, ref.current) // if the page is resource-constrained, getBoundingClientRect() might return 0 immediately after the items render. // If we receive an array of 0's, wait 50s and re-measure. if (calculated.every(size => size === 0)) { setTimeout(calculate, 50) } else { setSizes(calculated) } }, [props.orientation]) // callback ref: https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node const tableRef = useCallback( (node: HTMLDivElement) => { if (node) { ref.current = node } calculate() }, [calculate] ) useResizeObserver(ref.current, calculate) return { tableRef, columnSizes: sizes } } function SelectionsNotice({ label, buttonLabel, onClick, }: { label: string buttonLabel: string onClick: () => void }) { return (

{label}

) } function SelectionCriteria({ min, max, selected, }: { min?: number max?: number selected: number }) { if (min !== undefined && max !== undefined) { return ( max ? 'text-red-600' : ''}`}> Selected {numberWithCommas(selected)} of{' '} {min === max ? ( <>{numberWithCommas(min)} ) : ( <> {numberWithCommas(min)}-{numberWithCommas(max)} )} ) } if (max !== undefined) { return ( max ? 'text-red-600' : ''}`}> Select up to {numberWithCommas(max)} ) } if (min !== undefined) { return Select at least {numberWithCommas(min)} } return {numberWithCommas(selected)} selected } // immediately returns the new value when the input is cleared, otherwise debounces to ${delay}ms. function useSearchDebounce(value: string, delay: number) { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { const handler = window.setTimeout(() => { setDebouncedValue(value) }, delay) return () => { window.clearTimeout(handler) } }, [value, delay]) return value === '' ? value : debouncedValue } export function TableControls( props: IVTableProps & { isStuck?: boolean location: 'top' | 'bottom' customFilters?: React.ReactNode } ) { const mobileSearchRef = useRef(null) const [isSearching, setIsSearching] = useState(false) const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useSearchDebounce(searchQuery, 500) const { onSearch } = props.table useEffect(() => { onSearch(debouncedSearchQuery) }, [debouncedSearchQuery, onSearch]) const onClearSearch = () => setSearchQuery('') const onToggleSearch = () => { setIsSearching(prev => { if (prev) { return false } else { setTimeout(() => mobileSearchRef.current?.focus(), 10) return true } }) } return (
{props.location === 'top' && props.table.isFilterable && ( )} {props.table.isFilterable && (
setSearchQuery(e.target.value)} onKeyDown={preventDefaultInputEnterKey} /> {!!props.table.searchQuery && ( )}
)} {props.selectionCriteria && (
)} {props.customFilters}
{props.table.isDownloadable && ( )}
{props.table.isFilterable && (
setSearchQuery(e.target.value)} /> {!!props.table.searchQuery && ( )}
)}
) } export default function DesktopTable(props: IVTableProps) { const { tableRef, columnSizes } = useColumnSizes(props) const TableComponent = props.orientation === 'vertical' && props.table.columns ? DesktopVerticalTable : DesktopHorizontalTable const selectedOnPage = props.table.currentPage.filter( row => row.isSelected ).length const totalSelected = props.table.selectedKeys.size const isSelectingRange = useDisableSelectionWithShiftKey() return (
{selectedOnPage < totalSelected && (
props.table.selectNone({ skipWarning: true })} />
)}
) }