mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 20:36:14 +08:00
feat(web): init snippet graph
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 = {}) => {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user