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 (
)
}
return (
{props.table.currentPage.length > 0 ? (
{/* this invisible row appears behind the header and factors in column labels for width calculations */}
{props.table.isSelectable && (
)}
{props.table.columns.map((column, index) => (
{column.label ?? column.accessorKey}
))}
{props.table.hasRowMenus && (
)}
{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 && (
)}
{props.table.isSelectable && (
{
// Disable "select all" for single-selection tables
!(
props.selectionCriteria?.min === 1 &&
props.selectionCriteria?.max === 1
) && (
{
if (e.target.checked && !hasSelectedRows) {
props.table.selectAll()
} else {
props.table.selectNone()
}
}}
/>
)
}
)}
{props.table.columns.map((col, colIdx) => {
const SortIcon =
props.table.sortDirection === 'asc'
? SortUpIcon
: SortDownIcon
const width = props.table.isSelectable
? props.columnSizes[colIdx + 1]
: props.columnSizes[colIdx]
const canSortByCol =
props.table.isSortable &&
!!col.label &&
(col.accessorKey || props.table.cachesRecords)
return (
props.table.handleSort(col.key)
: undefined
}
style={{ width }}
role="columnheader"
>
{col.label}
{props.table.isSortable &&
props.table.sortColumn === col.key && (
)}
)
})}
{props.table.hasRowMenus && (
// for spacing calculations
)}
)
}
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 && (
)}
{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}
onClick()}
>
{buttonLabel}
)
}
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 })}
/>
)}
)
}