feat: add VirtualList support for feed events

This commit is contained in:
moonrailgun 2024-07-03 00:12:50 +08:00
parent 1b859e3176
commit caf7e9ca72
3 changed files with 90 additions and 63 deletions

View File

@ -5,44 +5,51 @@ import dayjs from 'dayjs';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { MarkdownViewer } from '../MarkdownEditor'; import { MarkdownViewer } from '../MarkdownEditor';
import { FeedIcon } from './FeedIcon'; import { FeedIcon } from './FeedIcon';
import { cn } from '@/utils/style';
type FeedEventItemType = AppRouterOutput['feed']['events'][number]; type FeedEventItemType = AppRouterOutput['feed']['events'][number];
export const FeedEventItem: React.FC<{ event: FeedEventItemType }> = React.memo( export const FeedEventItem: React.FC<{
({ event }) => { className?: string;
return ( event: FeedEventItemType;
<div className="border-muted flex items-center rounded-lg border px-4 py-2"> }> = React.memo(({ className, event }) => {
<div className="flex-1 gap-2"> return (
<div className="mb-2 flex items-center gap-2 text-sm"> <div
<div className="border-muted rounded-lg border p-2"> className={cn(
<FeedIcon source={event.source} size={24} /> 'border-muted flex items-center rounded-lg border px-4 py-2',
</div> className
<div> )}
<MarkdownViewer value={event.eventContent} /> >
</div> <div className="flex-1 gap-2">
<div className="mb-2 flex items-center gap-2 text-sm">
<div className="border-muted rounded-lg border p-2">
<FeedIcon source={event.source} size={24} />
</div> </div>
<div>
<div className="flex flex-wrap gap-2"> <MarkdownViewer value={event.eventContent} />
<Badge>{event.source}</Badge>
<Badge variant="secondary">{event.eventName}</Badge>
{event.tags.map((tag) => (
<Badge variant="outline">{tag}</Badge>
))}
</div> </div>
</div> </div>
<Tooltip> <div className="flex flex-wrap gap-2">
<TooltipTrigger className="cursor-default self-end text-xs opacity-60"> <Badge>{event.source}</Badge>
<div>{dayjs(event.createdAt).fromNow()}</div>
</TooltipTrigger> <Badge variant="secondary">{event.eventName}</Badge>
<TooltipContent>
<p>{dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss')}</p> {event.tags.map((tag) => (
</TooltipContent> <Badge variant="outline">{tag}</Badge>
</Tooltip> ))}
</div>
</div> </div>
);
} <Tooltip>
); <TooltipTrigger className="cursor-default self-end text-xs opacity-60">
<div>{dayjs(event.createdAt).fromNow()}</div>
</TooltipTrigger>
<TooltipContent>
<p>{dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss')}</p>
</TooltipContent>
</Tooltip>
</div>
);
});
FeedEventItem.displayName = 'FeedEventItem'; FeedEventItem.displayName = 'FeedEventItem';

View File

@ -1,7 +1,6 @@
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc'; import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -16,6 +15,7 @@ import { FeedIntegration } from '@/components/feed/FeedIntegration';
import { DialogWrapper } from '@/components/DialogWrapper'; import { DialogWrapper } from '@/components/DialogWrapper';
import { useSocketSubscribeList } from '@/api/socketio'; import { useSocketSubscribeList } from '@/api/socketio';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { SimpleVirtualList } from '@/components/SimpleVirtualList';
export const Route = createFileRoute('/feed/$channelId/')({ export const Route = createFileRoute('/feed/$channelId/')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -30,10 +30,18 @@ function PageComponent() {
workspaceId, workspaceId,
channelId, channelId,
}); });
const { data: events = [] } = trpc.feed.events.useQuery({
workspaceId, const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
channelId, trpc.feed.fetchEventsByCursor.useInfiniteQuery(
}); {
workspaceId,
channelId,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const deleteMutation = trpc.feed.deleteChannel.useMutation({ const deleteMutation = trpc.feed.deleteChannel.useMutation({
onSuccess: defaultSuccessHandler, onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler, onError: defaultErrorHandler,
@ -55,8 +63,8 @@ function PageComponent() {
}); });
const fullEvents = useMemo( const fullEvents = useMemo(
() => [...realtimeEvents, ...events], () => [...realtimeEvents, ...(data?.pages.flatMap((p) => p.items) ?? [])],
[realtimeEvents, events] [realtimeEvents, data]
); );
return ( return (
@ -102,19 +110,21 @@ function PageComponent() {
/> />
} }
> >
{fullEvents && fullEvents.length === 0 ? ( <div className="h-full w-full overflow-hidden p-4">
<div className="w-full overflow-hidden p-4"> <SimpleVirtualList
<FeedApiGuide channelId={channelId} /> allData={fullEvents}
</div> estimateSize={100}
) : ( hasNextPage={hasNextPage}
<ScrollArea className="h-full overflow-hidden p-4"> isFetchingNextPage={isFetchingNextPage}
<div className="space-y-2"> onFetchNextPage={fetchNextPage}
{(fullEvents ?? []).map((event) => ( renderItem={(item) => <FeedEventItem className="mb-2" event={item} />}
<FeedEventItem key={event.id} event={event} /> renderEmpty={() => (
))} <div className="w-full overflow-hidden p-4">
</div> <FeedApiGuide channelId={channelId} />
</ScrollArea> </div>
)} )}
/>
</div>
</CommonWrapper> </CommonWrapper>
); );
} }

View File

@ -15,6 +15,7 @@ import {
import { prisma } from '../../../model/_client'; import { prisma } from '../../../model/_client';
import _ from 'lodash'; import _ from 'lodash';
import { buildFeedPublicOpenapi, feedIntegrationRouter } from './integration'; import { buildFeedPublicOpenapi, feedIntegrationRouter } from './integration';
import { fetchDataByCursor } from '../../../utils/prisma';
export const feedRouter = router({ export const feedRouter = router({
channels: workspaceProcedure channels: workspaceProcedure
@ -113,33 +114,42 @@ export const feedRouter = router({
return channel; return channel;
}), }),
events: workspaceProcedure fetchEventsByCursor: workspaceProcedure
.meta( .meta(
buildFeedOpenapi({ buildFeedOpenapi({
method: 'GET', method: 'GET',
path: '/{channelId}/events', path: '/{channelId}/fetchEventsByCursor',
description: 'Fetch workspace feed channel events',
}) })
) )
.input( .input(
z.object({ z.object({
channelId: z.string(), channelId: z.string(),
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
})
)
.output(
z.object({
items: z.array(FeedEventModelSchema),
nextCursor: z.string().optional(),
}) })
) )
.output(z.array(FeedEventModelSchema))
.query(async ({ input }) => { .query(async ({ input }) => {
const { channelId } = input; const { channelId, cursor, limit } = input;
const events = await prisma.feedEvent.findMany({ const { items, nextCursor } = await fetchDataByCursor(prisma.feedEvent, {
where: { where: {
channelId: channelId, channelId,
},
take: 50,
orderBy: {
createdAt: 'desc',
}, },
limit,
cursor,
}); });
return events; return {
items,
nextCursor,
};
}), }),
createChannel: workspaceOwnerProcedure createChannel: workspaceOwnerProcedure
.meta( .meta(