DataTableInfinite.tsx269 lines · main
| 1 | import { type FetchNextPageOptions } from '@tanstack/react-query' |
| 2 | import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' |
| 3 | import { flexRender } from '@tanstack/react-table' |
| 4 | import { LoaderCircle } from 'lucide-react' |
| 5 | import { useQueryState } from 'nuqs' |
| 6 | import { Fragment, UIEvent, useCallback, useRef } from 'react' |
| 7 | import { Button, cn } from 'ui' |
| 8 | import { ShimmeringLoader } from 'ui-patterns' |
| 9 | |
| 10 | import AlertError from '../AlertError' |
| 11 | import { formatCompactNumber } from './DataTable.utils' |
| 12 | import { useDataTable } from './providers/DataTableProvider' |
| 13 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table' |
| 14 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 15 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 16 | |
| 17 | // TODO: add a possible chartGroupBy |
| 18 | export interface DataTableInfiniteProps<TData, TValue, _TMeta> { |
| 19 | columns: ColumnDef<TData, TValue>[] |
| 20 | defaultColumnVisibility?: VisibilityState |
| 21 | totalRows?: number |
| 22 | filterRows?: number |
| 23 | totalRowsFetched?: number |
| 24 | isFetching?: boolean |
| 25 | isLoading?: boolean |
| 26 | hasNextPage?: boolean |
| 27 | fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise<unknown> |
| 28 | setColumnOrder: (columnOrder: string[]) => void |
| 29 | setColumnVisibility: (columnVisibility: VisibilityState) => void |
| 30 | |
| 31 | // [Joshen] See if we can type this properly |
| 32 | searchParamsParser: any |
| 33 | } |
| 34 | |
| 35 | // [Joshen] JFYI this component is NOT virtualized and hence will struggle handling many data points |
| 36 | export function DataTableInfinite<TData, TValue, TMeta>({ |
| 37 | columns, |
| 38 | defaultColumnVisibility = {}, |
| 39 | fetchNextPage, |
| 40 | hasNextPage, |
| 41 | totalRows = 0, |
| 42 | filterRows = 0, |
| 43 | totalRowsFetched = 0, |
| 44 | setColumnOrder, |
| 45 | setColumnVisibility, |
| 46 | searchParamsParser, |
| 47 | }: DataTableInfiniteProps<TData, TValue, TMeta>) { |
| 48 | const tableRef = useRef<HTMLTableElement>(null) |
| 49 | const { table, error, isError, isLoading, isFetching, openRowId, setOpenRowId } = useDataTable() |
| 50 | |
| 51 | const headerGroups = table.getHeaderGroups() |
| 52 | const headers = headerGroups[0].headers |
| 53 | const rows = table.getRowModel().rows ?? [] |
| 54 | |
| 55 | const onScroll = useCallback( |
| 56 | (e: UIEvent<HTMLElement>) => { |
| 57 | const onPageBottom = |
| 58 | Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= |
| 59 | e.currentTarget.scrollHeight |
| 60 | |
| 61 | if (onPageBottom && !isFetching && totalRows > totalRowsFetched) { |
| 62 | fetchNextPage() |
| 63 | } |
| 64 | }, |
| 65 | [fetchNextPage, isFetching, totalRows, totalRowsFetched] |
| 66 | ) |
| 67 | |
| 68 | useShortcut(SHORTCUT_IDS.DATA_TABLE_RESET_COLUMNS, () => { |
| 69 | setColumnOrder([]) |
| 70 | setColumnVisibility(defaultColumnVisibility) |
| 71 | }) |
| 72 | |
| 73 | return ( |
| 74 | <Table |
| 75 | ref={tableRef} |
| 76 | onScroll={onScroll} |
| 77 | className={cn( |
| 78 | !isLoading && rows.length === 0 && 'h-full', |
| 79 | isLoading && '[mask-image:linear-gradient(to_bottom,black_70%,transparent_100%)]' |
| 80 | )} |
| 81 | > |
| 82 | <TableHeader> |
| 83 | <TableRow className="bg-surface-75"> |
| 84 | {headers.map((header) => { |
| 85 | const sort = header.column.getIsSorted() |
| 86 | const canResize = header.column.getCanResize() |
| 87 | const onResize = header.getResizeHandler() |
| 88 | const headerClassName = (header.column.columnDef.meta as any)?.headerClassName |
| 89 | |
| 90 | return ( |
| 91 | <TableHead |
| 92 | key={header.id} |
| 93 | id={header.id} |
| 94 | className={cn('w-full', headerClassName)} |
| 95 | aria-sort={sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none'} |
| 96 | > |
| 97 | {header.isPlaceholder |
| 98 | ? null |
| 99 | : flexRender(header.column.columnDef.header, header.getContext())} |
| 100 | {canResize && ( |
| 101 | <div |
| 102 | onDoubleClick={() => header.column.resetSize()} |
| 103 | onMouseDown={onResize} |
| 104 | onTouchStart={onResize} |
| 105 | className={cn( |
| 106 | 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center', |
| 107 | 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border' |
| 108 | )} |
| 109 | /> |
| 110 | )} |
| 111 | </TableHead> |
| 112 | ) |
| 113 | })} |
| 114 | </TableRow> |
| 115 | </TableHeader> |
| 116 | |
| 117 | <TableBody |
| 118 | id="content" |
| 119 | tabIndex={-1} |
| 120 | // REMINDER: avoids scroll (skipping the table header) when using skip to content |
| 121 | style={{ scrollMarginTop: 'calc(var(--top-bar-height))' }} |
| 122 | > |
| 123 | {rows.length ? ( |
| 124 | rows.map((row) => ( |
| 125 | // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 |
| 126 | <DataTableRow |
| 127 | key={row.id} |
| 128 | row={row} |
| 129 | table={table} |
| 130 | searchParamsParser={searchParamsParser} |
| 131 | selected={row.id === openRowId} |
| 132 | onSelect={() => setOpenRowId(row.id === openRowId ? undefined : row.id)} |
| 133 | /> |
| 134 | )) |
| 135 | ) : isLoading ? ( |
| 136 | <Fragment> |
| 137 | {new Array(15).fill(0).map((_, x) => ( |
| 138 | <TableRow |
| 139 | key={x} |
| 140 | className="h-[30px] hover:!bg-transparent [&>td]:group-hover:!bg-transparent" |
| 141 | > |
| 142 | {table.getAllLeafColumns().map((col, idx) => ( |
| 143 | <TableCell key={col.id}> |
| 144 | <ShimmeringLoader className={cn('py-2', idx % 2 === 0 && 'opacity-50')} /> |
| 145 | </TableCell> |
| 146 | ))} |
| 147 | </TableRow> |
| 148 | ))} |
| 149 | </Fragment> |
| 150 | ) : isError ? ( |
| 151 | <Fragment> |
| 152 | <TableRow className="hover:bg-transparent h-full"> |
| 153 | <TableCell colSpan={columns.length} className="text-center"> |
| 154 | <div className="flex flex-col items-start justify-start h-full gap-3 px-4 pt-4"> |
| 155 | <AlertError |
| 156 | error={error} |
| 157 | className="text-left" |
| 158 | subject="Failed to retrieve logs" |
| 159 | /> |
| 160 | </div> |
| 161 | </TableCell> |
| 162 | </TableRow> |
| 163 | </Fragment> |
| 164 | ) : ( |
| 165 | <Fragment> |
| 166 | <TableRow className="hover:bg-transparent h-full"> |
| 167 | <TableCell colSpan={columns.length} className="text-center"> |
| 168 | <div className="flex flex-col items-center justify-center h-full gap-3"> |
| 169 | <p className="text-foreground-light text-sm">No results found</p> |
| 170 | </div> |
| 171 | </TableCell> |
| 172 | </TableRow> |
| 173 | </Fragment> |
| 174 | )} |
| 175 | |
| 176 | {/* Only show load more section if we have rows OR if we're not in initial loading state */} |
| 177 | {(rows.length > 0 || (!isLoading && !rows.length)) && ( |
| 178 | <TableRow className="hover:bg-transparent data-[state=selected]:bg-transparent"> |
| 179 | <TableCell colSpan={columns.length} className="text-center py-2!"> |
| 180 | {hasNextPage || isFetching ? ( |
| 181 | <div className="flex flex-col items-center gap-2"> |
| 182 | <Button |
| 183 | disabled={isFetching} |
| 184 | onClick={() => fetchNextPage()} |
| 185 | size="small" |
| 186 | type="default" |
| 187 | icon={ |
| 188 | isFetching ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> : null |
| 189 | } |
| 190 | > |
| 191 | Load more |
| 192 | </Button> |
| 193 | <p className="text-xs text-foreground-lighter"> |
| 194 | Showing{' '} |
| 195 | <span className="font-mono font-medium"> |
| 196 | {formatCompactNumber(totalRowsFetched)} |
| 197 | </span>{' '} |
| 198 | of{' '} |
| 199 | <span className="font-mono font-medium">{formatCompactNumber(totalRows)}</span>{' '} |
| 200 | rows |
| 201 | </p> |
| 202 | </div> |
| 203 | ) : ( |
| 204 | rows.length > 0 && ( |
| 205 | <p className="text-xs text-foreground-lighter"> |
| 206 | No more data to load ( |
| 207 | <span className="font-mono font-medium">{formatCompactNumber(filterRows)}</span>{' '} |
| 208 | of{' '} |
| 209 | <span className="font-mono font-medium">{formatCompactNumber(totalRows)}</span>{' '} |
| 210 | rows) |
| 211 | </p> |
| 212 | ) |
| 213 | )} |
| 214 | </TableCell> |
| 215 | </TableRow> |
| 216 | )} |
| 217 | </TableBody> |
| 218 | </Table> |
| 219 | ) |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * REMINDER: this is the heaviest component in the table if lots of rows |
| 224 | * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height) |
| 225 | * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader |
| 226 | */ |
| 227 | |
| 228 | function DataTableRow<TData>({ |
| 229 | row, |
| 230 | table, |
| 231 | selected, |
| 232 | searchParamsParser, |
| 233 | onSelect, |
| 234 | }: { |
| 235 | row: Row<TData> |
| 236 | table: TTable<TData> |
| 237 | selected?: boolean |
| 238 | searchParamsParser: any |
| 239 | onSelect: () => void |
| 240 | }) { |
| 241 | useQueryState('live', searchParamsParser.live) |
| 242 | const rowClassName = cn('group/row', (table.options.meta as any)?.getRowClassName?.(row)) |
| 243 | const cells = row.getVisibleCells() |
| 244 | |
| 245 | return ( |
| 246 | <TableRow |
| 247 | id={row.id} |
| 248 | tabIndex={0} |
| 249 | data-state={selected && 'selected'} |
| 250 | onClick={onSelect} |
| 251 | onKeyDown={(event) => { |
| 252 | if (event.key === 'Enter') { |
| 253 | event.preventDefault() |
| 254 | onSelect() |
| 255 | } |
| 256 | }} |
| 257 | className={cn(rowClassName)} |
| 258 | > |
| 259 | {cells.map((cell) => { |
| 260 | const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName |
| 261 | return ( |
| 262 | <TableCell key={cell.id} className={cn(cellClassName)}> |
| 263 | {flexRender(cell.column.columnDef.cell, cell.getContext())} |
| 264 | </TableCell> |
| 265 | ) |
| 266 | })} |
| 267 | </TableRow> |
| 268 | ) |
| 269 | } |