feat: add VirtualList support for feed events
This commit is contained in:
parent
1b859e3176
commit
caf7e9ca72
@ -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';
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user