DataTableInfinite.tsx 9.6 KB

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