From 17d07a5a433aea0c5d432c6a4e266e822597ada0 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Fri, 27 Mar 2026 15:23:03 +0800 Subject: [PATCH] feat(web): init snippet graph --- .../snippets/__tests__/index.spec.tsx | 42 ++++- .../hooks/__tests__/use-snippet-init.spec.ts | 162 ++++++++++++++++++ .../snippets/hooks/use-snippet-init.ts | 75 +++++++- web/app/components/snippets/index.tsx | 16 +- web/service/use-snippet-workflows.ts | 67 +++++++- web/service/use-snippets.ts | 13 +- 6 files changed, 329 insertions(+), 46 deletions(-) create mode 100644 web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 5b9aa9daf6..c16b98f3e7 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -4,12 +4,39 @@ import { PipelineInputVarType } from '@/models/pipeline' import SnippetPage from '..' import { useSnippetDetailStore } from '../store' -const mockUseSnippetDetail = vi.fn() +const mockUseSnippetInit = vi.fn() -vi.mock('@/service/use-snippets', () => ({ - useSnippetDetail: (snippetId: string) => mockUseSnippetDetail(snippetId), +vi.mock('../hooks/use-snippet-init', () => ({ + useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId), })) +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: vi.fn(), + push: vi.fn(), + }), +})) + +vi.mock('@/service/use-snippets', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useUpdateSnippetMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useExportSnippetMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + useDeleteSnippetMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), + } +}) + vi.mock('@/service/use-common', () => ({ useFileUploadConfig: () => ({ data: undefined, @@ -122,7 +149,7 @@ describe('SnippetPage', () => { beforeEach(() => { vi.clearAllMocks() useSnippetDetailStore.getState().reset() - mockUseSnippetDetail.mockReturnValue({ + mockUseSnippetInit.mockReturnValue({ data: mockSnippetDetail, isLoading: false, }) @@ -157,15 +184,14 @@ describe('SnippetPage', () => { expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument() }) - it('should render a controlled not found state', () => { - mockUseSnippetDetail.mockReturnValue({ + it('should render loading fallback when snippet data is unavailable', () => { + mockUseSnippetInit.mockReturnValue({ data: null, isLoading: false, }) render() - expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument() - expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() }) }) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts new file mode 100644 index 0000000000..e53f1c13fe --- /dev/null +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnippetInit } from '../use-snippet-init' + +const mockWorkflowStoreSetState = vi.fn() +const mockSetPublishedAt = vi.fn() +const mockSetDraftUpdatedAt = vi.fn() +const mockSetSyncWorkflowDraftHash = vi.fn() +const mockUseSnippetApiDetail = vi.fn() +const mockUseSnippetDraftWorkflow = vi.fn() +const mockUseSnippetDefaultBlockConfigs = vi.fn() +const mockUseSnippetPublishedWorkflow = vi.fn() + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + setState: mockWorkflowStoreSetState, + getState: () => ({ + setDraftUpdatedAt: mockSetDraftUpdatedAt, + setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash, + setPublishedAt: mockSetPublishedAt, + }), + }), +})) + +vi.mock('@/service/use-snippets', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId), + } +}) + +vi.mock('@/service/use-snippet-workflows', () => ({ + useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess), + useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess), + useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess), +})) + +describe('useSnippetInit', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseSnippetApiDetail.mockReturnValue({ + data: { + id: 'snippet-1', + name: 'Tone Rewriter', + description: 'A static snippet mock.', + type: 'node', + is_published: false, + version: '1', + use_count: 0, + icon_info: { + icon_type: null, + icon: '🪄', + icon_background: '#E0EAFF', + }, + input_fields: [], + created_at: 1_712_300_000, + updated_at: 1_712_300_000, + author: 'Evan', + }, + error: null, + isLoading: false, + }) + mockUseSnippetDraftWorkflow.mockReturnValue({ + data: undefined, + isLoading: false, + }) + mockUseSnippetDefaultBlockConfigs.mockReturnValue({ + data: undefined, + }) + mockUseSnippetPublishedWorkflow.mockReturnValue({ + data: undefined, + }) + }) + + it('should return snippet detail query result', () => { + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1') + expect(result.current.data?.snippet.id).toBe('snippet-1') + expect(result.current.data?.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 }) + expect(result.current.isLoading).toBe(false) + }) + + it('should sync draft metadata into workflow store', () => { + mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => { + onSuccess?.({ + updated_at: 1_712_345_678, + hash: 'draft-hash', + }) + return { data: undefined, isLoading: false } + }) + + renderHook(() => useSnippetInit('snippet-1')) + + expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678) + expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash') + }) + + it('should normalize array default block configs into workflow store state', () => { + mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => { + onSuccess?.([ + { type: 'llm', config: { model: 'gpt-4.1' } }, + { type: 'code', config: { language: 'python3' } }, + ]) + return { data: undefined, isLoading: false } + }) + + renderHook(() => useSnippetInit('snippet-1')) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + nodesDefaultConfigs: { + llm: { model: 'gpt-4.1' }, + code: { language: 'python3' }, + }, + }) + }) + + it('should keep object default block configs as-is', () => { + mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => { + onSuccess?.({ + llm: { model: 'gpt-4.1' }, + }) + return { data: undefined, isLoading: false } + }) + + renderHook(() => useSnippetInit('snippet-1')) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + nodesDefaultConfigs: { + llm: { model: 'gpt-4.1' }, + }, + }) + }) + + it('should sync published created_at into workflow store', () => { + mockUseSnippetPublishedWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => { + onSuccess?.({ + created_at: 1_712_345_678, + }) + return { data: undefined, isLoading: false } + }) + + renderHook(() => useSnippetInit('snippet-1')) + + expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678) + }) + + it('should stay loading while draft workflow is still fetching', () => { + mockUseSnippetDraftWorkflow.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + expect(result.current.data).toBeUndefined() + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts index 1468c65beb..c98e46c446 100644 --- a/web/app/components/snippets/hooks/use-snippet-init.ts +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -1,6 +1,75 @@ -// import { useSnippetDetail } from '@/service/use-snippets' -import { useSnippetDetail } from '@/service/use-snippets.mock' +import { useMemo } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { + useSnippetDefaultBlockConfigs, + useSnippetDraftWorkflow, + useSnippetPublishedWorkflow, +} from '@/service/use-snippet-workflows' +import { + buildSnippetDetailPayload, + useSnippetApiDetail, +} from '@/service/use-snippets' + +const normalizeNodesDefaultConfigs = (nodesDefaultConfigs: unknown) => { + if (!nodesDefaultConfigs || typeof nodesDefaultConfigs !== 'object') + return {} + + if (!Array.isArray(nodesDefaultConfigs)) + return nodesDefaultConfigs as Record + + return nodesDefaultConfigs.reduce((acc, item) => { + if ( + item + && typeof item === 'object' + && 'type' in item + && 'config' in item + && typeof item.type === 'string' + ) { + acc[item.type] = item.config + } + + return acc + }, {} as Record) +} + +const isNotFoundError = (error: unknown) => { + return !!error && typeof error === 'object' && 'status' in error && error.status === 404 +} export const useSnippetInit = (snippetId: string) => { - return useSnippetDetail(snippetId) + const workflowStore = useWorkflowStore() + const snippetApiDetail = useSnippetApiDetail(snippetId) + const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => { + const { + setDraftUpdatedAt, + setSyncWorkflowDraftHash, + } = workflowStore.getState() + + setDraftUpdatedAt(draftWorkflow.updated_at) + setSyncWorkflowDraftHash(draftWorkflow.hash) + }) + useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => { + workflowStore.setState({ + nodesDefaultConfigs: normalizeNodesDefaultConfigs(nodesDefaultConfigs), + }) + }) + useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => { + workflowStore.getState().setPublishedAt(publishedWorkflow.created_at) + }) + + const data = useMemo(() => { + if (snippetApiDetail.data && !draftWorkflowQuery.isLoading) + return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data) + + if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error)) + return null + + return undefined + }, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, snippetApiDetail.data, snippetApiDetail.error]) + + return { + ...snippetApiDetail, + data, + isLoading: snippetApiDetail.isLoading || draftWorkflowQuery.isLoading, + } } diff --git a/web/app/components/snippets/index.tsx b/web/app/components/snippets/index.tsx index 63392a24b5..706fdf7a27 100644 --- a/web/app/components/snippets/index.tsx +++ b/web/app/components/snippets/index.tsx @@ -2,7 +2,6 @@ import type { SnippetSection } from '@/models/snippet' import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import WorkflowWithDefaultContext from '@/app/components/workflow' import { WorkflowContextProvider } from '@/app/components/workflow/context' @@ -22,7 +21,6 @@ const SnippetPage = ({ snippetId, section = 'orchestrate', }: SnippetPageProps) => { - const { t } = useTranslation('snippet') const { data, isLoading } = useSnippetInit(snippetId) const nodesData = useMemo(() => { if (!data) @@ -37,7 +35,7 @@ const SnippetPage = ({ return initialEdges(data.graph.edges, data.graph.nodes) }, [data]) - if (isLoading) { + if (!data || isLoading) { return (
@@ -45,18 +43,6 @@ const SnippetPage = ({ ) } - if (!data) { - return ( -
-
-
404
-
{t('notFoundTitle')}
-
{t('notFoundDescription')}
-
-
- ) - } - return ( { + return !!error && typeof error === 'object' && 'status' in error && error.status === 404 +} + const invalidateSnippetWorkflowQueries = async ( queryClient: ReturnType, snippetId: string, @@ -34,13 +39,33 @@ const invalidateSnippetWorkflowQueries = async ( ]) } -export const useSnippetDraftWorkflow = (snippetId: string) => { - return useQuery(consoleQuery.snippets.draftWorkflow.queryOptions({ +export const useSnippetDraftWorkflow = ( + snippetId: string, + onSuccess?: (draftWorkflow: SnippetWorkflow) => void, +) => { + const queryOptions = consoleQuery.snippets.draftWorkflow.queryOptions({ input: { params: { snippetId }, }, enabled: !!snippetId, - })) + }) + + return useQuery({ + ...queryOptions, + queryFn: async (context) => { + try { + const draftWorkflow = await queryOptions.queryFn(context) + onSuccess?.(draftWorkflow) + return draftWorkflow + } + catch (error) { + if (isNotFoundError(error)) + return undefined + + throw error + } + }, + }) } export const useSnippetDraftConfig = (snippetId: string) => { @@ -52,22 +77,46 @@ export const useSnippetDraftConfig = (snippetId: string) => { })) } -export const useSnippetPublishedWorkflow = (snippetId: string) => { - return useQuery(consoleQuery.snippets.publishedWorkflow.queryOptions({ +export const useSnippetPublishedWorkflow = ( + snippetId: string, + onSuccess?: (publishedWorkflow: SnippetWorkflow) => void, +) => { + const queryOptions = consoleQuery.snippets.publishedWorkflow.queryOptions({ input: { params: { snippetId }, }, enabled: !!snippetId, - })) + }) + + return useQuery({ + ...queryOptions, + queryFn: async (context) => { + const publishedWorkflow = await queryOptions.queryFn(context) + onSuccess?.(publishedWorkflow) + return publishedWorkflow + }, + }) } -export const useSnippetDefaultBlockConfigs = (snippetId: string) => { - return useQuery(consoleQuery.snippets.defaultBlockConfigs.queryOptions({ +export const useSnippetDefaultBlockConfigs = ( + snippetId: string, + onSuccess?: (nodesDefaultConfigs: unknown) => void, +) => { + const queryOptions = consoleQuery.snippets.defaultBlockConfigs.queryOptions({ input: { params: { snippetId }, }, enabled: !!snippetId, - })) + }) + + return useQuery({ + ...queryOptions, + queryFn: async (context) => { + const nodesDefaultConfigs = await queryOptions.queryFn(context) + onSuccess?.(nodesDefaultConfigs) + return nodesDefaultConfigs + }, + }) } export const useSnippetWorkflowRuns = (snippetId: string, params: WorkflowRunsParams = {}) => { diff --git a/web/service/use-snippets.ts b/web/service/use-snippets.ts index 076510a5c9..6b5ddc780f 100644 --- a/web/service/use-snippets.ts +++ b/web/service/use-snippets.ts @@ -6,12 +6,10 @@ import type { SnippetListItem as SnippetListItemUIModel, } from '@/models/snippet' import type { - CreateSnippetPayload, Snippet as SnippetContract, SnippetDSLImportResponse, SnippetListResponse, SnippetWorkflow, - UpdateSnippetPayload, } from '@/types/snippet' import { keepPreviousData, @@ -103,7 +101,7 @@ const toSnippetCanvasData = (workflow?: SnippetWorkflow): SnippetCanvasData => { } } -const toSnippetDetailPayload = (snippet: SnippetContract, workflow?: SnippetWorkflow): SnippetDetailPayload => { +export const buildSnippetDetailPayload = (snippet: SnippetContract, workflow?: SnippetWorkflow): SnippetDetailPayload => { const inputFields = Array.isArray(snippet.input_fields) ? snippet.input_fields as SnippetInputFieldUIModel[] : [] @@ -211,7 +209,7 @@ export const useSnippetDetail = (snippetId: string) => { }), ]) - return toSnippetDetailPayload(snippet, workflow) + return buildSnippetDetailPayload(snippet, workflow) } catch (error) { if (isNotFoundError(error)) @@ -341,10 +339,3 @@ export const useConfirmSnippetImportMutation = () => { }, }) } - -export type { - CreateSnippetPayload, - SnippetDSLImportResponse, - SnippetListResponse, - UpdateSnippetPayload, -}