import type { UseInfiniteQueryResult } from '@tanstack/react-query'; import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import { useLocalStorage } from 'usehooks-ts'; import { useColumns } from './columns'; // Custom hook for persistent column visibility const usePersistentColumnVisibility = (columns: any[]) => { const [savedVisibility, setSavedVisibility] = useLocalStorage< Record >('@op:sessions-table-column-visibility', {}); // Create column visibility from saved state, defaulting to true (visible) const columnVisibility = useMemo(() => { return columns.reduce( (acc, column) => { const columnId = column.id || column.accessorKey; if (columnId) { acc[columnId] = savedVisibility[columnId] ?? true; } return acc; }, {} as Record, ); }, [columns, savedVisibility]); const handleColumnVisibilityChange = (updater: any) => { const newVisibility = typeof updater === 'function' ? updater(columnVisibility) : updater; setSavedVisibility(newVisibility); }; return { columnVisibility, setColumnVisibility: handleColumnVisibilityChange, }; }; import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { Skeleton } from '@/components/skeleton'; import { AnimatedSearchInput, DataTableToolbarContainer, } from '@/components/ui/data-table/data-table-toolbar'; import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { arePropsEqual } from '@/utils/are-props-equal'; import { cn } from '@/utils/cn'; import type { IServiceSession } from '@openpanel/db'; import type { Table } from '@tanstack/react-table'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; import type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; import { Loader2Icon } from 'lucide-react'; import { last } from 'ramda'; import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useInViewport } from 'react-in-viewport'; type Props = { query: UseInfiniteQueryResult< TRPCInfiniteData< RouterInputs['session']['list'], RouterOutputs['session']['list'] >, unknown >; }; const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceSession[]; const ROW_HEIGHT = 40; interface VirtualizedSessionsTableProps { table: Table; data: IServiceSession[]; isLoading: boolean; } interface VirtualRowProps { row: any; virtualRow: any; headerColumns: any[]; scrollMargin: number; isLoading: boolean; headerColumnsHash: string; } const VirtualRow = memo( function VirtualRow({ row, virtualRow, headerColumns, scrollMargin, isLoading, }: VirtualRowProps) { return (
`${col.getSize()}px`) .join(' '), minWidth: 'fit-content', minHeight: ROW_HEIGHT, }} > {row.getVisibleCells().map((cell: any) => { const width = `${cell.column.getSize()}px`; return (
{isLoading ? ( ) : cell.column.columnDef.cell ? ( typeof cell.column.columnDef.cell === 'function' ? ( cell.column.columnDef.cell(cell.getContext()) ) : ( cell.column.columnDef.cell ) ) : ( (cell.getValue() as React.ReactNode) )}
); })}
); }, (prevProps, nextProps) => { return ( prevProps.row.id === nextProps.row.id && prevProps.virtualRow.index === nextProps.virtualRow.index && prevProps.virtualRow.start === nextProps.virtualRow.start && prevProps.virtualRow.size === nextProps.virtualRow.size && prevProps.isLoading === nextProps.isLoading && prevProps.headerColumnsHash === nextProps.headerColumnsHash ); }, ); const VirtualizedSessionsTable = ({ table, data, isLoading, }: VirtualizedSessionsTableProps) => { const parentRef = useRef(null); const headerColumns = table.getAllLeafColumns().filter((col) => { return table.getState().columnVisibility[col.id] !== false; }); const rowVirtualizer = useWindowVirtualizer({ count: data.length, estimateSize: () => ROW_HEIGHT, // Estimated row height overscan: 10, scrollMargin: parentRef.current?.offsetTop ?? 0, }); const virtualRows = rowVirtualizer.getVirtualItems(); const headerColumnsHash = headerColumns.map((col) => col.id).join(','); return (
{/* Table Header */}
`${col.getSize()}px`) .join(' '), minWidth: 'fit-content', }} > {headerColumns.map((column) => { const header = column.columnDef.header; const width = `${column.getSize()}px`; return (
{typeof header === 'function' ? header({} as any) : header}
); })}
{!isLoading && data.length === 0 && ( )} {/* Table Body */}
{virtualRows.map((virtualRow) => { const row = table.getRowModel().rows[virtualRow.index]; if (!row) return null; return ( ); })}
); }; export const SessionsTable = ({ query }: Props) => { const { isLoading } = query; const columns = useColumns(); const data = useMemo(() => { if (isLoading) { return LOADING_DATA; } return query.data?.pages?.flatMap((p) => p.data) ?? []; }, [query.data]); // const { setPage, state: pagination } = useDataTablePagination(); const { columnVisibility, setColumnVisibility } = usePersistentColumnVisibility(columns); const table = useReactTable({ data, getCoreRowModel: getCoreRowModel(), manualPagination: true, manualFiltering: true, manualSorting: true, columns, rowCount: 50, pageCount: 1, filterFns: { isWithinRange: () => true, }, state: { columnVisibility, }, onColumnVisibilityChange: setColumnVisibility, getRowId: (row, index) => row.id ?? `loading-${index}`, }); const inViewportRef = useRef(null); const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, { disconnectOnLeave: true, }); const hasNextPage = last(query.data?.pages ?? [])?.meta.next; useEffect(() => { if ( hasNextPage && data.length > 0 && inViewport && enterCount > 0 && query.isFetchingNextPage === false ) { console.log('fetching next page'); query.fetchNextPage(); } }, [inViewport, enterCount, hasNextPage]); return ( <>
); }; function SessionTableToolbar({ table }: { table: Table }) { const { search, setSearch } = useSearchQueryState(); return ( ); }