feat(web): init snippet graph

This commit is contained in:
JzoNg
2026-03-27 15:23:03 +08:00
parent 3bdbea99a3
commit 17d07a5a43
6 changed files with 329 additions and 46 deletions

View File

@ -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<typeof import('@/service/use-snippets')>()
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(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByText('snippet.notFoundTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.notFoundDescription')).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

View File

@ -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<typeof import('@/service/use-snippets')>()
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)
})
})

View File

@ -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<string, unknown>
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<string, unknown>)
}
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,
}
}

View File

@ -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 (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
@ -45,18 +43,6 @@ const SnippetPage = ({
)
}
if (!data) {
return (
<div className="flex h-full items-center justify-center bg-background-body px-6">
<div className="w-full max-w-md rounded-2xl border border-divider-subtle bg-components-card-bg p-8 text-center shadow-sm">
<div className="text-3xl font-semibold text-text-primary">404</div>
<div className="pt-3 text-text-primary system-md-semibold">{t('notFoundTitle')}</div>
<div className="pt-2 text-text-tertiary system-sm-regular">{t('notFoundDescription')}</div>
</div>
</div>
)
}
return (
<WorkflowWithDefaultContext
edges={edgesData}

View File

@ -1,3 +1,4 @@
import type { SnippetWorkflow } from '@/types/snippet'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
@ -6,6 +7,10 @@ type WorkflowRunsParams = {
limit?: number
}
const isNotFoundError = (error: unknown) => {
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
}
const invalidateSnippetWorkflowQueries = async (
queryClient: ReturnType<typeof useQueryClient>,
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 = {}) => {

View File

@ -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,
}