From 6876c8041c808d81de5d100d27b0b2f05cad0256 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Thu, 26 Mar 2026 20:58:42 +0800 Subject: [PATCH] feat(web): snippet list data fetching in block selector --- .../workflow/block-selector/snippets.tsx | 221 ----------------- .../block-selector/snippets/index.tsx | 223 ++++++++++++++++++ .../{ => snippets}/snippet-detail-card.tsx | 23 +- .../snippets/snippet-empty-state.tsx | 31 +++ .../{ => snippets}/snippet-list-item.tsx | 4 +- 5 files changed, 260 insertions(+), 242 deletions(-) delete mode 100644 web/app/components/workflow/block-selector/snippets.tsx create mode 100644 web/app/components/workflow/block-selector/snippets/index.tsx rename web/app/components/workflow/block-selector/{ => snippets}/snippet-detail-card.tsx (63%) create mode 100644 web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx rename web/app/components/workflow/block-selector/{ => snippets}/snippet-list-item.tsx (91%) diff --git a/web/app/components/workflow/block-selector/snippets.tsx b/web/app/components/workflow/block-selector/snippets.tsx deleted file mode 100644 index 37f571e587..0000000000 --- a/web/app/components/workflow/block-selector/snippets.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import type { CreateSnippetDialogPayload } from '../create-snippet-dialog' -import type { Snippet as SnippetDetail } from '@/types/snippet' -import { - memo, - useDeferredValue, - useMemo, - useState, -} from 'react' -import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' -import { toast } from '@/app/components/base/ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/app/components/base/ui/tooltip' -import { useRouter } from '@/next/navigation' -import { consoleClient } from '@/service/client' -import { useCreateSnippetMutation } from '@/service/use-snippets' -import { cn } from '@/utils/classnames' -import CreateSnippetDialog from '../create-snippet-dialog' -import { BlockEnum } from '../types' -import SnippetDetailCard from './snippet-detail-card' -import SnippetListItem from './snippet-list-item' - -type SnippetsProps = { - loading?: boolean - searchText: string -} - -type StaticSnippet = SnippetDetail & { - relatedBlocks?: BlockEnum[] -} - -const STATIC_SNIPPETS: StaticSnippet[] = [ - { - id: 'customer-review', - name: 'Customer Review', - description: 'Collects customer review context, classifies request intent, and routes the workflow through the right generation branch.', - author: 'Evan', - type: 'group', - is_published: true, - version: '1.0.0', - use_count: 128, - input_fields: [], - created_at: 1742889600, - updated_at: 1742976000, - icon_info: { - icon_type: 'emoji', - icon: '🧾', - icon_background: '#FFEAD5', - icon_url: '', - }, - relatedBlocks: [ - BlockEnum.LLM, - BlockEnum.Code, - BlockEnum.KnowledgeRetrieval, - BlockEnum.QuestionClassifier, - BlockEnum.IfElse, - ], - }, -] as const - -const LoadingSkeleton = () => { - return ( -
-
- {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => ( -
-
-
-
-
-
- ))} -
-
-
- ) -} - -const Snippets = ({ - loading = false, - searchText, -}: SnippetsProps) => { - const { t } = useTranslation() - const { push } = useRouter() - const createSnippetMutation = useCreateSnippetMutation() - const deferredSearchText = useDeferredValue(searchText) - const [hoveredSnippetId, setHoveredSnippetId] = useState(null) - const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) - const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) - - const snippets = useMemo(() => { - return STATIC_SNIPPETS.map(item => ({ - ...item, - })) - }, []) - - const handleCloseCreateSnippetDialog = () => { - setIsCreateSnippetDialogOpen(false) - } - - const handleCreateSnippet = async ({ - name, - description, - icon, - graph, - }: CreateSnippetDialogPayload) => { - setIsCreatingSnippet(true) - - try { - const snippet = await createSnippetMutation.mutateAsync({ - body: { - name, - description: description || undefined, - icon_info: { - icon: icon.type === 'emoji' ? icon.icon : icon.fileId, - icon_type: icon.type, - icon_background: icon.type === 'emoji' ? icon.background : undefined, - icon_url: icon.type === 'image' ? icon.url : undefined, - }, - }, - }) - - await consoleClient.snippets.syncDraftWorkflow({ - params: { snippetId: snippet.id }, - body: { graph }, - }) - - toast.success(t('snippet.createSuccess', { ns: 'workflow' })) - handleCloseCreateSnippetDialog() - push(`/snippets/${snippet.id}/orchestrate`) - } - catch (error) { - toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) - } - finally { - setIsCreatingSnippet(false) - } - } - - const filteredSnippets = useMemo(() => { - const normalizedSearch = deferredSearchText.trim().toLowerCase() - if (!normalizedSearch) - return snippets - - return snippets.filter(item => item.name.toLowerCase().includes(normalizedSearch)) - }, [deferredSearchText, snippets]) - - if (loading) - return - - if (!filteredSnippets.length) { - return ( - <> -
- -
- {t('tabs.noSnippetsFound', { ns: 'workflow' })} -
- -
- - - ) - } - - return ( -
- {filteredSnippets.map((item) => { - const row = ( - setHoveredSnippetId(item.id)} - onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} - /> - ) - - if (!item.description) - return
{row}
- - return ( - - - - - - - ) - })} -
- ) -} - -export default memo(Snippets) diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx new file mode 100644 index 0000000000..50598eb304 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/index.tsx @@ -0,0 +1,223 @@ +import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog' +import { useInfiniteScroll } from 'ahooks' +import { + memo, + useDeferredValue, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@/app/components/base/ui/scroll-area' +import { toast } from '@/app/components/base/ui/toast' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/app/components/base/ui/tooltip' +import { useRouter } from '@/next/navigation' +import { consoleClient } from '@/service/client' +import { + useCreateSnippetMutation, + useInfiniteSnippetList, +} from '@/service/use-snippets' +import { cn } from '@/utils/classnames' +import CreateSnippetDialog from '../../create-snippet-dialog' +import SnippetDetailCard from './snippet-detail-card' +import SnippetEmptyState from './snippet-empty-state' +import SnippetListItem from './snippet-list-item' + +type SnippetsProps = { + loading?: boolean + searchText: string +} + +const LoadingSkeleton = () => { + return ( +
+
+ {['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ) +} + +const Snippets = ({ + loading = false, + searchText, +}: SnippetsProps) => { + const { t } = useTranslation() + const { push } = useRouter() + const createSnippetMutation = useCreateSnippetMutation() + const deferredSearchText = useDeferredValue(searchText) + const viewportRef = useRef(null) + const [hoveredSnippetId, setHoveredSnippetId] = useState(null) + const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) + const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) + + const keyword = deferredSearchText.trim() || undefined + + const { + data, + isLoading, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteSnippetList({ + page: 1, + limit: 30, + keyword, + is_published: true, + }) + + const snippets = useMemo(() => { + return (data?.pages ?? []).flatMap(({ data }) => data) + }, [data?.pages]) + + const isNoMore = hasNextPage === false + + useInfiniteScroll( + async () => { + if (!hasNextPage || isFetchingNextPage) + return { list: [] } + + await fetchNextPage() + return { list: [] } + }, + { + target: viewportRef, + isNoMore: () => isNoMore, + reloadDeps: [isNoMore, isFetchingNextPage, keyword], + }, + ) + + const handleCloseCreateSnippetDialog = () => { + setIsCreateSnippetDialogOpen(false) + } + + const handleCreateSnippet = async ({ + name, + description, + icon, + graph, + }: CreateSnippetDialogPayload) => { + setIsCreatingSnippet(true) + + try { + const snippet = await createSnippetMutation.mutateAsync({ + body: { + name, + description: description || undefined, + icon_info: { + icon: icon.type === 'emoji' ? icon.icon : icon.fileId, + icon_type: icon.type, + icon_background: icon.type === 'emoji' ? icon.background : undefined, + icon_url: icon.type === 'image' ? icon.url : undefined, + }, + }, + }) + + await consoleClient.snippets.syncDraftWorkflow({ + params: { snippetId: snippet.id }, + body: { graph }, + }) + + toast.success(t('snippet.createSuccess', { ns: 'workflow' })) + handleCloseCreateSnippetDialog() + push(`/snippets/${snippet.id}/orchestrate`) + } + catch (error) { + toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' })) + } + finally { + setIsCreatingSnippet(false) + } + } + + if (loading || isLoading || (isFetching && snippets.length === 0)) + return + + return ( + <> + {!snippets.length + ? ( + setIsCreateSnippetDialogOpen(true)} /> + ) + : ( + + + + {snippets.map((item) => { + const row = ( + setHoveredSnippetId(item.id)} + onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)} + /> + ) + + if (!item.description) + return
{row}
+ + return ( + + + + + + + ) + })} + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ + + +
+ )} + + + ) +} + +export default memo(Snippets) diff --git a/web/app/components/workflow/block-selector/snippet-detail-card.tsx b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx similarity index 63% rename from web/app/components/workflow/block-selector/snippet-detail-card.tsx rename to web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx index 65f6ba3d9e..44a44ee4ad 100644 --- a/web/app/components/workflow/block-selector/snippet-detail-card.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-detail-card.tsx @@ -1,21 +1,17 @@ import type { FC } from 'react' -import type { BlockEnum } from '../types' -import type { Snippet as SnippetDetail } from '@/types/snippet' +import type { SnippetListItem } from '@/types/snippet' import AppIcon from '@/app/components/base/app-icon' -import BlockIcon from '../block-icon' -export type PublishedSnippetDetail = SnippetDetail & { - relatedBlocks?: BlockEnum[] -} +export type PublishedSnippetListItem = SnippetListItem type SnippetDetailCardProps = { - snippet: PublishedSnippetDetail + snippet: PublishedSnippetListItem } const SnippetDetailCard: FC = ({ snippet, }) => { - const { author, description, icon_info, name, relatedBlocks = [] } = snippet + const { author, description, icon_info, name } = snippet return (
@@ -35,17 +31,6 @@ const SnippetDetailCard: FC = ({ {description}
)} - {!!relatedBlocks.length && ( -
- {relatedBlocks.map(block => ( - - ))} -
- )}
{!!author && (
diff --git a/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx new file mode 100644 index 0000000000..f0c00115c8 --- /dev/null +++ b/web/app/components/workflow/block-selector/snippets/snippet-empty-state.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' + +type SnippetEmptyStateProps = { + onCreate: () => void +} + +const SnippetEmptyState: FC = ({ + onCreate, +}) => { + const { t } = useTranslation() + + return ( +
+ +
+ {t('tabs.noSnippetsFound', { ns: 'workflow' })} +
+ +
+ ) +} + +export default SnippetEmptyState diff --git a/web/app/components/workflow/block-selector/snippet-list-item.tsx b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx similarity index 91% rename from web/app/components/workflow/block-selector/snippet-list-item.tsx rename to web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx index ba76a84ae4..0ddddbfc0b 100644 --- a/web/app/components/workflow/block-selector/snippet-list-item.tsx +++ b/web/app/components/workflow/block-selector/snippets/snippet-list-item.tsx @@ -2,14 +2,14 @@ import type { ComponentPropsWithoutRef, Ref, } from 'react' -import type { PublishedSnippetDetail } from './snippet-detail-card' +import type { PublishedSnippetListItem } from './snippet-detail-card' import AppIcon from '@/app/components/base/app-icon' import { cn } from '@/utils/classnames' type SnippetListItemProps = { isHovered: boolean ref?: Ref - snippet: PublishedSnippetDetail + snippet: PublishedSnippetListItem } & ComponentPropsWithoutRef<'div'> const SnippetListItem = ({