diff --git a/src/components/Table.tsx b/src/components/Table.tsx index 2a281b0d..6709a959 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -31,14 +31,15 @@ import { import { rankItem } from '@tanstack/match-sorter-utils' import type { VirtualItem } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual' -import styled from 'styled-components' -import { isEmpty } from 'lodash-es' +import styled, { useTheme } from 'styled-components' +import { isEmpty, isNil } from 'lodash-es' import Button from './Button' import CaretUpIcon from './icons/CaretUpIcon' import ArrowRightIcon from './icons/ArrowRightIcon' import { FillLevelProvider } from './contexts/FillLevelContext' import EmptyState from './EmptyState' +import { Spinner } from './Spinner' export type TableProps = Omit< DivProps, @@ -65,12 +66,15 @@ export type TableProps = Omit< virtualizeRows?: boolean lockColumnsOnFirstScroll?: boolean reactVirtualOptions?: Omit< - Parameters, - 'parentRef' | 'size' + Parameters[0], + 'count' | 'getScrollElement' > reactTableOptions?: Partial, 'data' | 'columns'>> onRowClick?: (e: MouseEvent, row: Row) => void emptyStateProps?: ComponentProps + hasNextPage?: boolean + fetchNextPage?: () => void + isFetchingNextPage?: boolean } const propTypes = {} @@ -290,6 +294,18 @@ const TdExpand = styled.td(({ theme }) => ({ padding: '16px 12px', })) +const TdLoading = styled(Td)(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gridColumn: '1 / -1', + textAlign: 'center', + gap: theme.spacing.xsmall, + color: theme.colors['text-xlight'], + minHeight: theme.spacing.large * 2 + theme.spacing.xlarge, +})) + function isRow(row: Row | VirtualItem): row is Row { return typeof (row as Row).getVisibleCells === 'function' } @@ -454,14 +470,18 @@ function TableRef( width, virtualizeRows = false, lockColumnsOnFirstScroll, - reactVirtualOptions: virtualizerOptions, + reactVirtualOptions, reactTableOptions, onRowClick, emptyStateProps, + hasNextPage, + isFetchingNextPage, + fetchNextPage, ...props }: TableProps, forwardRef: Ref ) { + const theme = useTheme() const tableContainerRef = useRef() const [hover, setHover] = useState(false) const [scrollTop, setScrollTop] = useState(0) @@ -487,6 +507,7 @@ function TableRef( enableSorting: false, sortingFn: 'alphanumeric', }, + enableRowSelection: false, ...reactTableOptions, }) const [fixedGridTemplateColumns, setFixedGridTemplateColumns] = useState< @@ -495,8 +516,8 @@ function TableRef( const { rows: tableRows } = table.getRowModel() const rowVirtualizer = useVirtualizer({ - count: tableRows.length, - overscan: 1, + count: hasNextPage ? tableRows.length + 1 : tableRows.length, + overscan: 6, getScrollElement: () => tableContainerRef.current, estimateSize: () => 52, measureElement: (el) => { @@ -513,7 +534,7 @@ function TableRef( return el.getBoundingClientRect().height }, - ...virtualizerOptions, + ...reactVirtualOptions, }) const virtualRows = rowVirtualizer.getVirtualItems() const virtualHeight = rowVirtualizer.getTotalSize() @@ -565,6 +586,25 @@ function TableRef( [columns, fixedGridTemplateColumns] ) + useEffect(() => { + const lastItem = virtualRows[virtualRows.length - 1] + + if ( + lastItem && + lastItem.index >= tableRows.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage() + } + }, [ + hasNextPage, + fetchNextPage, + isFetchingNextPage, + virtualRows, + tableRows.length, + ]) + return (
)} {rows.map((maybeRow) => { - const row: Row = isRow(maybeRow) + const i = maybeRow.index + const isLoaderRow = i > tableRows.length - 1 + const row: Row | null = isRow(maybeRow) ? maybeRow + : isLoaderRow + ? null : tableRows[maybeRow.index] - const i = row.index + const key = row?.id ?? maybeRow.index + const lighter = i % 2 === 0 return ( - + onRowClick?.(e, row)} - $lighter={i % 2 === 0} - $selectable={row.getCanSelect()} - $selected={row.getIsSelected() ?? false} + $lighter={lighter} + $selectable={row?.getCanSelect() ?? false} + $selected={row?.getIsSelected() ?? false} $clickable={!!onRowClick} // data-index is required for virtual scrolling to work - data-index={row.index} + data-index={row?.index} {...(virtualizeRows ? { ref: rowVirtualizer.measureElement } : {})} > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} +
Loading
+ + + ) : ( + row?.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + )) + )} - {row.getIsExpanded() && ( + {row?.getIsExpanded() && ( diff --git a/src/stories/Table.stories.tsx b/src/stories/Table.stories.tsx index 22604699..fdc558cb 100644 --- a/src/stories/Table.stories.tsx +++ b/src/stories/Table.stories.tsx @@ -4,6 +4,7 @@ import React, { type ComponentProps, type ReactElement, useEffect, + useMemo, useState, } from 'react' import type { Row } from '@tanstack/react-table' @@ -210,6 +211,49 @@ function Template(args: any) { return } +function PagedTemplate({ data, pageSize, ...args }: any) { + const [endIndex, setEndIndex] = useState(pageSize - 1) + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false) + const pagedData = useMemo( + () => (data as any[]).slice(0, endIndex), + [data, endIndex] + ) + const hasNextPage = pagedData.length < data.length + + useEffect(() => { + if (isFetchingNextPage) { + let cancelled = false + + setTimeout(() => { + if (cancelled) { + return + } + const nextEndIndex = endIndex + pageSize + + setEndIndex(nextEndIndex > data.length - 1 ? data.length : nextEndIndex) + setIsFetchingNextPage(false) + }, 1500) + + return () => { + cancelled = true + } + } + }, [data.length, endIndex, isFetchingNextPage, pageSize]) + + return ( +
{ + setIsFetchingNextPage(true) + }} + data={pagedData} + /> + ) +} + function SelectableTemplate(args: any) { const [selectedId, setSelectedId] = useState('') @@ -307,6 +351,15 @@ VirtualizedRows.args = { columns, } +export const PagedData = PagedTemplate.bind({}) +PagedData.args = { + pageSize: 30, + width: '900px', + height: '400px', + data: extremeLengthData, + columns, +} + export const Loose = Template.bind({}) Loose.args = {