feat: add VirtualizedInfiniteDataTable and refactor survey result list

This commit is contained in:
moonrailgun 2024-07-28 00:03:47 +08:00
parent 827cf07c2a
commit b2dccec283
4 changed files with 235 additions and 15 deletions

View File

@ -148,3 +148,4 @@ export function DataTable<TData>({
</div> </div>
); );
} }
DataTable.displayName = 'DataTable';

View File

@ -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<TData> {
columns: ColumnDef<TData, any>[];
data: InfiniteData<{ items: TData[] }> | undefined;
onFetchNextPage: () => void;
isFetching: boolean;
isLoading: boolean;
hasNextPage: boolean | undefined;
}
export function VirtualizedInfiniteDataTable<TData>(
props: VirtualizedInfiniteDataTableProps<TData>
) {
const { columns, data, onFetchNextPage, isFetching, isLoading, hasNextPage } =
props;
//we need a reference to the scrolling element for logic down below
const tableContainerRef = React.useRef<HTMLDivElement>(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 <div>Loading...</div>;
}
return (
<div
className="relative h-full overflow-auto"
onScroll={(e) => 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 style={{ display: 'grid' }}>
<TableHeader
style={{
display: 'grid',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-background flex w-full"
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{
width: header.getSize(),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody
style={{
display: 'grid',
height: `${rowVirtualizer.getTotalSize()}px`, //tells scrollbar how big the table is
position: 'relative', //needed for absolute positioning of rows
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
data-index={virtualRow.index} //needed for dynamic row height measurement
ref={(node) => 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 (
<TableCell
key={cell.id}
style={{
display: 'flex',
width: cell.column.getSize(),
}}
>
{useSystemTooltip ? (
<div
className="w-full cursor-default overflow-hidden text-ellipsis whitespace-nowrap text-left"
title={String(value)}
>
{content}
</div>
) : (
<Tooltip>
<TooltipTrigger asChild={true}>
<div className="w-full cursor-default overflow-hidden text-ellipsis whitespace-nowrap text-left">
{content}
</div>
</TooltipTrigger>
<TooltipContent>{content}</TooltipContent>
</Tooltip>
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</table>
{isFetching && <div>Fetching More...</div>}
</div>
);
}
VirtualizedInfiniteDataTable.displayName = 'VirtualizedInfiniteDataTable';

View File

@ -22,6 +22,7 @@ import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { SurveyUsageBtn } from '@/components/survey/SurveyUsageBtn'; import { SurveyUsageBtn } from '@/components/survey/SurveyUsageBtn';
import { Scrollbar } from '@radix-ui/react-scroll-area'; import { Scrollbar } from '@radix-ui/react-scroll-area';
import { VirtualizedInfiniteDataTable } from '@/components/VirtualizedInfiniteDataTable';
type SurveyResultItem = type SurveyResultItem =
AppRouterOutput['survey']['resultList']['items'][number]; AppRouterOutput['survey']['resultList']['items'][number];
@ -45,10 +46,21 @@ function PageComponent() {
workspaceId, workspaceId,
surveyId, surveyId,
}); });
const { data: resultList } = trpc.survey.resultList.useInfiniteQuery({ const {
data: resultList,
hasNextPage,
fetchNextPage,
isFetching,
isLoading,
} = trpc.survey.resultList.useInfiniteQuery(
{
workspaceId, workspaceId,
surveyId, surveyId,
}); },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const deleteMutation = trpc.survey.delete.useMutation({ const deleteMutation = trpc.survey.delete.useMutation({
onSuccess: defaultSuccessHandler, onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler, onError: defaultErrorHandler,
@ -65,13 +77,11 @@ function PageComponent() {
}); });
}); });
const dataSource = resultList?.pages.map((p) => p.items).flat() ?? [];
const columns = useMemo(() => { const columns = useMemo(() => {
return [ return [
columnHelper.accessor('id', { columnHelper.accessor('id', {
header: t('ID'), header: t('ID'),
size: 150, size: 230,
}), }),
...(info?.payload.items.map((item) => ...(info?.payload.items.map((item) =>
columnHelper.accessor(`payload.${item.name}`, { columnHelper.accessor(`payload.${item.name}`, {
@ -80,7 +90,7 @@ function PageComponent() {
) ?? []), ) ?? []),
columnHelper.accessor('createdAt', { columnHelper.accessor('createdAt', {
header: t('Created At'), header: t('Created At'),
size: 130, size: 200,
cell: (props) => dayjs(props.getValue()).format('YYYY-MM-DD HH:mm:ss'), cell: (props) => dayjs(props.getValue()).format('YYYY-MM-DD HH:mm:ss'),
}), }),
]; ];
@ -123,7 +133,7 @@ function PageComponent() {
/> />
} }
> >
<div className="h-full overflow-hidden p-4"> <div className="flex h-full flex-col overflow-hidden p-4">
<div className="mb-4 w-full"> <div className="mb-4 w-full">
<Card> <Card>
<CardHeader> <CardHeader>
@ -141,11 +151,16 @@ function PageComponent() {
<div className="mb-2 text-lg font-bold">{t('Preview')}</div> <div className="mb-2 text-lg font-bold">{t('Preview')}</div>
<ScrollArea className="w-full"> <div className="flex-1 overflow-hidden">
<Scrollbar orientation="horizontal" /> <VirtualizedInfiniteDataTable
columns={columns}
<DataTable columns={columns} data={dataSource} /> data={resultList}
</ScrollArea> onFetchNextPage={fetchNextPage}
isFetching={isFetching}
isLoading={isLoading}
hasNextPage={hasNextPage}
/>
</div>
</div> </div>
</CommonWrapper> </CommonWrapper>
); );

View File

@ -126,7 +126,7 @@ export const surveyRouter = router({
payload: z.record(z.string(), z.any()), payload: z.record(z.string(), z.any()),
}) })
) )
.output(z.any()) .output(z.string())
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { req } = ctx; const { req } = ctx;
const { workspaceId, surveyId, payload } = input; const { workspaceId, surveyId, payload } = input;
@ -166,6 +166,8 @@ export const surveyRouter = router({
accuracyRadius, accuracyRadius,
}, },
}); });
return 'success';
}), }),
create: workspaceOwnerProcedure create: workspaceOwnerProcedure
.meta( .meta(