From b2dccec2834a486dc018ac7b1d267bb327d48422 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Sun, 28 Jul 2024 00:03:47 +0800 Subject: [PATCH] feat: add VirtualizedInfiniteDataTable and refactor survey result list --- src/client/components/DataTable.tsx | 1 + .../VirtualizedInfiniteDataTable.tsx | 202 ++++++++++++++++++ src/client/routes/survey/$surveyId/index.tsx | 43 ++-- src/server/trpc/routers/survey.ts | 4 +- 4 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 src/client/components/VirtualizedInfiniteDataTable.tsx diff --git a/src/client/components/DataTable.tsx b/src/client/components/DataTable.tsx index 7bfd56b..03b6b51 100644 --- a/src/client/components/DataTable.tsx +++ b/src/client/components/DataTable.tsx @@ -148,3 +148,4 @@ export function DataTable({ ); } +DataTable.displayName = 'DataTable'; diff --git a/src/client/components/VirtualizedInfiniteDataTable.tsx b/src/client/components/VirtualizedInfiniteDataTable.tsx new file mode 100644 index 0000000..9109652 --- /dev/null +++ b/src/client/components/VirtualizedInfiniteDataTable.tsx @@ -0,0 +1,202 @@ +import React from 'react'; + +//3 TanStack Libraries!!! +import { + ColumnDef, + flexRender, + getCoreRowModel, + Row, + useReactTable, +} from '@tanstack/react-table'; +import { InfiniteData } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; + +interface VirtualizedInfiniteDataTableProps { + columns: ColumnDef[]; + data: InfiniteData<{ items: TData[] }> | undefined; + onFetchNextPage: () => void; + isFetching: boolean; + isLoading: boolean; + hasNextPage: boolean | undefined; +} + +export function VirtualizedInfiniteDataTable( + props: VirtualizedInfiniteDataTableProps +) { + const { columns, data, onFetchNextPage, isFetching, isLoading, hasNextPage } = + props; + + //we need a reference to the scrolling element for logic down below + const tableContainerRef = React.useRef(null); + + //flatten the array of arrays from the useInfiniteQuery hook + const flatData = React.useMemo( + () => data?.pages?.flatMap((page) => page.items) ?? [], + [data] + ); + + //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table + const fetchMoreOnBottomReached = React.useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + //once the user has scrolled within 500px of the bottom of the table, fetch more data if we can + if ( + scrollHeight - scrollTop - clientHeight < 500 && + !isFetching && + hasNextPage + ) { + onFetchNextPage(); + } + } + }, + [onFetchNextPage, isFetching, hasNextPage] + ); + + //a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data + React.useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached]); + + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const { rows } = table.getRowModel(); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 40, //estimate row height for accurate scrollbar dragging + getScrollElement: () => tableContainerRef.current, + //measure dynamic row height, except in firefox because it measures table border height incorrectly + measureElement: + typeof window !== 'undefined' && + navigator.userAgent.indexOf('Firefox') === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, + overscan: 5, + }); + + if (isLoading) { + return
Loading...
; + } + + return ( +
fetchMoreOnBottomReached(e.target as HTMLDivElement)} + ref={tableContainerRef} + > + {/* Even though we're still using sematic table tags, we must use CSS grid and flexbox for dynamic row heights */} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index] as Row; + return ( + rowVirtualizer.measureElement(node)} //measure dynamic row height + key={row.id} + style={{ + display: 'flex', + position: 'absolute', + transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll + width: '100%', + }} + > + {row.getVisibleCells().map((cell) => { + const content = flexRender( + cell.column.columnDef.cell, + cell.getContext() + ); + const value = cell.getValue(); + const useSystemTooltip = + typeof value === 'string' || typeof value === 'number'; + + return ( + + {useSystemTooltip ? ( +
+ {content} +
+ ) : ( + + +
+ {content} +
+
+ {content} +
+ )} +
+ ); + })} +
+ ); + })} +
+
+ {isFetching &&
Fetching More...
} +
+ ); +} +VirtualizedInfiniteDataTable.displayName = 'VirtualizedInfiniteDataTable'; diff --git a/src/client/routes/survey/$surveyId/index.tsx b/src/client/routes/survey/$surveyId/index.tsx index af15ab6..fe04785 100644 --- a/src/client/routes/survey/$surveyId/index.tsx +++ b/src/client/routes/survey/$surveyId/index.tsx @@ -22,6 +22,7 @@ import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn'; import dayjs from 'dayjs'; import { SurveyUsageBtn } from '@/components/survey/SurveyUsageBtn'; import { Scrollbar } from '@radix-ui/react-scroll-area'; +import { VirtualizedInfiniteDataTable } from '@/components/VirtualizedInfiniteDataTable'; type SurveyResultItem = AppRouterOutput['survey']['resultList']['items'][number]; @@ -45,10 +46,21 @@ function PageComponent() { workspaceId, surveyId, }); - const { data: resultList } = trpc.survey.resultList.useInfiniteQuery({ - workspaceId, - surveyId, - }); + const { + data: resultList, + hasNextPage, + fetchNextPage, + isFetching, + isLoading, + } = trpc.survey.resultList.useInfiniteQuery( + { + workspaceId, + surveyId, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); const deleteMutation = trpc.survey.delete.useMutation({ onSuccess: defaultSuccessHandler, onError: defaultErrorHandler, @@ -65,13 +77,11 @@ function PageComponent() { }); }); - const dataSource = resultList?.pages.map((p) => p.items).flat() ?? []; - const columns = useMemo(() => { return [ columnHelper.accessor('id', { header: t('ID'), - size: 150, + size: 230, }), ...(info?.payload.items.map((item) => columnHelper.accessor(`payload.${item.name}`, { @@ -80,7 +90,7 @@ function PageComponent() { ) ?? []), columnHelper.accessor('createdAt', { header: t('Created At'), - size: 130, + size: 200, cell: (props) => dayjs(props.getValue()).format('YYYY-MM-DD HH:mm:ss'), }), ]; @@ -123,7 +133,7 @@ function PageComponent() { /> } > -
+
@@ -141,11 +151,16 @@ function PageComponent() {
{t('Preview')}
- - - - - +
+ +
); diff --git a/src/server/trpc/routers/survey.ts b/src/server/trpc/routers/survey.ts index 9a8ec12..af3ee16 100644 --- a/src/server/trpc/routers/survey.ts +++ b/src/server/trpc/routers/survey.ts @@ -126,7 +126,7 @@ export const surveyRouter = router({ payload: z.record(z.string(), z.any()), }) ) - .output(z.any()) + .output(z.string()) .mutation(async ({ input, ctx }) => { const { req } = ctx; const { workspaceId, surveyId, payload } = input; @@ -166,6 +166,8 @@ export const surveyRouter = router({ accuracyRadius, }, }); + + return 'success'; }), create: workspaceOwnerProcedure .meta(