mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
fix(web): snippet init
This commit is contained in:
parent
63dcb4dd6c
commit
dbeaf79d77
@ -25,6 +25,10 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store'
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import {
|
||||
useEffect,
|
||||
@ -197,7 +198,7 @@ const SnippetMain = ({
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport ?? graph.viewport}
|
||||
hooksStore={hooksStore as any}
|
||||
hooksStore={hooksStore as unknown as Partial<HooksStoreShape>}
|
||||
>
|
||||
<SnippetChildren
|
||||
snippetId={snippetId}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { SnippetWorkflow } from '@/types/snippet'
|
||||
import {
|
||||
renderHook,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSnippetInit } from '../use-snippet-init'
|
||||
|
||||
@ -7,7 +11,7 @@ const mockSetPublishedAt = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockUseSnippetApiDetail = vi.fn()
|
||||
const mockUseSnippetDraftWorkflow = vi.fn()
|
||||
const mockFetchSnippetDraftWorkflow = vi.fn()
|
||||
const mockUseSnippetDefaultBlockConfigs = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
@ -32,11 +36,22 @@ vi.mock('@/service/use-snippets', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess),
|
||||
fetchSnippetDraftWorkflow: (snippetId: string) => mockFetchSnippetDraftWorkflow(snippetId),
|
||||
useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess),
|
||||
useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess),
|
||||
}))
|
||||
|
||||
const createDraftWorkflow = (overrides: Partial<SnippetWorkflow> = {}): SnippetWorkflow => ({
|
||||
id: 'draft-1',
|
||||
graph: {},
|
||||
features: {},
|
||||
input_fields: [],
|
||||
hash: 'draft-hash',
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_345_678,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useSnippetInit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -63,10 +78,7 @@ describe('useSnippetInit', () => {
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined)
|
||||
mockUseSnippetDefaultBlockConfigs.mockReturnValue({
|
||||
data: undefined,
|
||||
})
|
||||
@ -75,16 +87,20 @@ describe('useSnippetInit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return snippet detail query result', () => {
|
||||
it('should return snippet detail query result', async () => {
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
|
||||
expect(mockFetchSnippetDraftWorkflow).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 use draft input_fields for snippet inputs', () => {
|
||||
it('should use draft input_fields for snippet inputs', async () => {
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'snippet-1',
|
||||
@ -114,28 +130,23 @@ describe('useSnippetInit', () => {
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: {
|
||||
id: 'draft-1',
|
||||
graph: {},
|
||||
features: {},
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
hash: 'draft-hash',
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_345_678,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.data?.inputFields).toEqual([
|
||||
{
|
||||
label: 'Draft field',
|
||||
@ -146,19 +157,96 @@ describe('useSnippetInit', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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 }
|
||||
it('should sync draft metadata before returning initialized data', async () => {
|
||||
mockFetchSnippetDraftWorkflow.mockResolvedValue(createDraftWorkflow({
|
||||
hash: 'fetched-draft-hash',
|
||||
updated_at: 1_712_345_678,
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1' }],
|
||||
edges: [],
|
||||
viewport: { x: 10, y: 20, zoom: 1.2 },
|
||||
},
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('fetched-draft-hash')
|
||||
expect(result.current.data?.graph.viewport).toEqual({ x: 10, y: 20, zoom: 1.2 })
|
||||
})
|
||||
|
||||
it('should not return stale draft data while the draft workflow request is pending', () => {
|
||||
mockFetchSnippetDraftWorkflow.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should initialize with empty graph when the draft workflow does not exist', async () => {
|
||||
mockFetchSnippetDraftWorkflow.mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.data?.graph).toEqual({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore outdated draft workflow response when snippet changes', async () => {
|
||||
let resolveFirstDraft: (workflow: SnippetWorkflow) => void = () => {}
|
||||
mockFetchSnippetDraftWorkflow.mockImplementation((snippetId: string) => {
|
||||
if (snippetId === 'snippet-1') {
|
||||
return new Promise<SnippetWorkflow>((resolve) => {
|
||||
resolveFirstDraft = resolve
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve(createDraftWorkflow({
|
||||
id: 'draft-2',
|
||||
hash: 'snippet-2-hash',
|
||||
graph: {
|
||||
nodes: [{ id: 'snippet-2-node' }],
|
||||
edges: [],
|
||||
viewport: { x: 2, y: 2, zoom: 1 },
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
const { result, rerender } = renderHook(({ snippetId }) => useSnippetInit(snippetId), {
|
||||
initialProps: { snippetId: 'snippet-1' },
|
||||
})
|
||||
|
||||
rerender({ snippetId: 'snippet-2' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
resolveFirstDraft(createDraftWorkflow({
|
||||
hash: 'stale-snippet-1-hash',
|
||||
graph: {
|
||||
nodes: [{ id: 'stale-node' }],
|
||||
edges: [],
|
||||
viewport: { x: 1, y: 1, zoom: 1 },
|
||||
},
|
||||
}))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(result.current.data?.graph.nodes).toEqual([{ id: 'snippet-2-node' }])
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('snippet-2-hash')
|
||||
expect(mockSetSyncWorkflowDraftHash).not.toHaveBeenCalledWith('stale-snippet-1-hash')
|
||||
})
|
||||
|
||||
it('should normalize array default block configs into workflow store state', () => {
|
||||
@ -220,16 +308,4 @@ describe('useSnippetInit', () => {
|
||||
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
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,8 +1,13 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { SnippetWorkflow } from '@/types/snippet'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
fetchSnippetDraftWorkflow,
|
||||
useSnippetDefaultBlockConfigs,
|
||||
useSnippetDraftWorkflow,
|
||||
useSnippetPublishedWorkflow,
|
||||
} from '@/service/use-snippet-workflows'
|
||||
import {
|
||||
@ -36,17 +41,18 @@ const isNotFoundError = (error: unknown) => {
|
||||
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
|
||||
}
|
||||
|
||||
type DraftWorkflowState = {
|
||||
snippetId: string
|
||||
data?: SnippetWorkflow
|
||||
isLoaded: boolean
|
||||
}
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const snippetApiDetail = useSnippetApiDetail(snippetId)
|
||||
const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
setDraftUpdatedAt(draftWorkflow.updated_at)
|
||||
setSyncWorkflowDraftHash(draftWorkflow.hash)
|
||||
const [draftWorkflowState, setDraftWorkflowState] = useState<DraftWorkflowState>({
|
||||
snippetId: '',
|
||||
isLoaded: false,
|
||||
})
|
||||
useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => {
|
||||
workflowStore.setState({
|
||||
@ -64,19 +70,60 @@ export const useSnippetInit = (snippetId: string) => {
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflowQuery.data?.created_at ?? 0)
|
||||
}, [publishedWorkflowQuery.data?.created_at, publishedWorkflowQuery.isLoading, workflowStore])
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
|
||||
if (!snippetId)
|
||||
return
|
||||
|
||||
fetchSnippetDraftWorkflow(snippetId)
|
||||
.then((response) => {
|
||||
if (ignore)
|
||||
return
|
||||
|
||||
if (response) {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
}
|
||||
|
||||
setDraftWorkflowState({
|
||||
snippetId,
|
||||
data: response,
|
||||
isLoaded: true,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the canvas gated on unexpected draft fetch failures.
|
||||
// `fetchSnippetDraftWorkflow` resolves with undefined for 404, so this
|
||||
// branch represents a real initialization failure rather than "no draft".
|
||||
})
|
||||
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [snippetId, workflowStore])
|
||||
|
||||
const isDraftWorkflowLoading = !!snippetId && (!draftWorkflowState.isLoaded || draftWorkflowState.snippetId !== snippetId)
|
||||
const draftWorkflow = draftWorkflowState.snippetId === snippetId ? draftWorkflowState.data : undefined
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (snippetApiDetail.data && !draftWorkflowQuery.isLoading)
|
||||
return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data)
|
||||
if (snippetApiDetail.data && !isDraftWorkflowLoading)
|
||||
return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflow)
|
||||
|
||||
if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error))
|
||||
return null
|
||||
|
||||
return undefined
|
||||
}, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, snippetApiDetail.data, snippetApiDetail.error])
|
||||
}, [draftWorkflow, isDraftWorkflowLoading, snippetApiDetail.data, snippetApiDetail.error])
|
||||
|
||||
return {
|
||||
...snippetApiDetail,
|
||||
data,
|
||||
isLoading: snippetApiDetail.isLoading || draftWorkflowQuery.isLoading,
|
||||
isLoading: snippetApiDetail.isLoading || isDraftWorkflowLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ const SnippetPage = ({ snippetId }: SnippetPageProps) => {
|
||||
const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => {
|
||||
return (
|
||||
<SnippetAndEvaluationPlanGuard fallbackHref="/apps">
|
||||
<WorkflowContextProvider>
|
||||
<WorkflowContextProvider key={snippetId}>
|
||||
<SnippetPage snippetId={snippetId} />
|
||||
</WorkflowContextProvider>
|
||||
</SnippetAndEvaluationPlanGuard>
|
||||
|
||||
@ -7,6 +7,18 @@ const isNotFoundError = (error: unknown) => {
|
||||
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
|
||||
}
|
||||
|
||||
export const fetchSnippetDraftWorkflow = async (snippetId: string) => {
|
||||
try {
|
||||
return await get<SnippetWorkflow>(`/snippets/${snippetId}/workflows/draft`, {}, { silent: true })
|
||||
}
|
||||
catch (error) {
|
||||
if (isNotFoundError(error))
|
||||
return undefined
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateSnippetWorkflowQueries = async (
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
snippetId: string,
|
||||
@ -49,17 +61,10 @@ export const useSnippetDraftWorkflow = (
|
||||
return useQuery({
|
||||
...queryOptions,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const draftWorkflow = await get<SnippetWorkflow>(`/snippets/${snippetId}/workflows/draft`, {}, { silent: true })
|
||||
const draftWorkflow = await fetchSnippetDraftWorkflow(snippetId)
|
||||
if (draftWorkflow)
|
||||
onSuccess?.(draftWorkflow)
|
||||
return draftWorkflow
|
||||
}
|
||||
catch (error) {
|
||||
if (isNotFoundError(error))
|
||||
return undefined
|
||||
|
||||
throw error
|
||||
}
|
||||
return draftWorkflow
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user