feat: add VirtualizedInfiniteDataTable and refactor survey result list
This commit is contained in:
parent
827cf07c2a
commit
b2dccec283
@ -148,3 +148,4 @@ export function DataTable<TData>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
DataTable.displayName = 'DataTable';
|
||||
|
202
src/client/components/VirtualizedInfiniteDataTable.tsx
Normal file
202
src/client/components/VirtualizedInfiniteDataTable.tsx
Normal 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';
|
@ -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({
|
||||
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() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -141,11 +151,16 @@ function PageComponent() {
|
||||
|
||||
<div className="mb-2 text-lg font-bold">{t('Preview')}</div>
|
||||
|
||||
<ScrollArea className="w-full">
|
||||
<Scrollbar orientation="horizontal" />
|
||||
|
||||
<DataTable columns={columns} data={dataSource} />
|
||||
</ScrollArea>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<VirtualizedInfiniteDataTable
|
||||
columns={columns}
|
||||
data={resultList}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CommonWrapper>
|
||||
);
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user