DataTableInfinite.tsx269 lines · main
1import { type FetchNextPageOptions } from '@tanstack/react-query'
2import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table'
3import { flexRender } from '@tanstack/react-table'
4import { LoaderCircle } from 'lucide-react'
5import { useQueryState } from 'nuqs'
6import { Fragment, UIEvent, useCallback, useRef } from 'react'
7import { Button, cn } from 'ui'
8import { ShimmeringLoader } from 'ui-patterns'
9
10import AlertError from '../AlertError'
11import { formatCompactNumber } from './DataTable.utils'
12import { useDataTable } from './providers/DataTableProvider'
13import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table'
14import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
15import { useShortcut } from '@/state/shortcuts/useShortcut'
16
17// TODO: add a possible chartGroupBy
18export 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
36export 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
228function 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}