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>
|
</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 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 {
|
||||||
workspaceId,
|
data: resultList,
|
||||||
surveyId,
|
hasNextPage,
|
||||||
});
|
fetchNextPage,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
} = trpc.survey.resultList.useInfiniteQuery(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user