fix(web): snippet draft sync

This commit is contained in:
JzoNg
2026-04-16 10:31:35 +08:00
parent f1da2c76d1
commit 5bfebd371d
8 changed files with 268 additions and 54 deletions

View File

@ -8,6 +8,7 @@ const mockSyncInputFieldsDraft = vi.fn()
const mockCloseEditor = vi.fn()
const mockOpenEditor = vi.fn()
const mockReset = vi.fn()
const mockSetFields = vi.fn()
const mockSetInputPanelOpen = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockToggleInputPanel = vi.fn()
@ -38,33 +39,24 @@ const mockInspectVarsCrud = {
invalidateConversationVarValues: vi.fn(),
}
let capturedHooksStore: Record<string, unknown> | undefined
let snippetDetailStoreState: {
editingField: SnippetInputField | null
fields: SnippetInputField[]
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
closeEditor: typeof mockCloseEditor
openEditor: typeof mockOpenEditor
reset: typeof mockReset
setFields: typeof mockSetFields
setInputPanelOpen: typeof mockSetInputPanelOpen
setPublishMenuOpen: typeof mockSetPublishMenuOpen
toggleInputPanel: typeof mockToggleInputPanel
togglePublishMenu: typeof mockTogglePublishMenu
}
vi.mock('@/app/components/snippets/store', () => ({
useSnippetDetailStore: (selector: (state: {
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
closeEditor: typeof mockCloseEditor
openEditor: typeof mockOpenEditor
reset: typeof mockReset
setInputPanelOpen: typeof mockSetInputPanelOpen
setPublishMenuOpen: typeof mockSetPublishMenuOpen
toggleInputPanel: typeof mockToggleInputPanel
togglePublishMenu: typeof mockTogglePublishMenu
}) => unknown) => selector({
editingField: null,
isEditorOpen: false,
isInputPanelOpen: true,
isPublishMenuOpen: false,
closeEditor: mockCloseEditor,
openEditor: mockOpenEditor,
reset: mockReset,
setInputPanelOpen: mockSetInputPanelOpen,
setPublishMenuOpen: mockSetPublishMenuOpen,
toggleInputPanel: mockToggleInputPanel,
togglePublishMenu: mockTogglePublishMenu,
}),
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
@ -216,6 +208,21 @@ describe('SnippetMain', () => {
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue(undefined)
capturedHooksStore = undefined
snippetDetailStoreState = {
editingField: null,
fields: [...payload.inputFields],
isEditorOpen: false,
isInputPanelOpen: true,
isPublishMenuOpen: false,
closeEditor: mockCloseEditor,
openEditor: mockOpenEditor,
reset: mockReset,
setFields: mockSetFields,
setInputPanelOpen: mockSetInputPanelOpen,
setPublishMenuOpen: mockSetPublishMenuOpen,
toggleInputPanel: mockToggleInputPanel,
togglePublishMenu: mockTogglePublishMenu,
}
})
describe('Input Fields Sync', () => {

View File

@ -7,15 +7,18 @@ import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
const mockSyncInputFieldsDraft = vi.fn()
const mockCloseEditor = vi.fn()
const mockOpenEditor = vi.fn()
const mockSetFields = vi.fn()
const mockSetInputPanelOpen = vi.fn()
const mockToggleInputPanel = vi.fn()
let snippetDetailStoreState: {
editingField: SnippetInputField | null
fields: SnippetInputField[]
isEditorOpen: boolean
isInputPanelOpen: boolean
closeEditor: typeof mockCloseEditor
openEditor: typeof mockOpenEditor
setFields: typeof mockSetFields
setInputPanelOpen: typeof mockSetInputPanelOpen
toggleInputPanel: typeof mockToggleInputPanel
}
@ -49,37 +52,42 @@ describe('useSnippetInputFieldActions', () => {
vi.clearAllMocks()
snippetDetailStoreState = {
editingField: null,
fields: [],
isEditorOpen: false,
isInputPanelOpen: true,
closeEditor: mockCloseEditor,
openEditor: mockOpenEditor,
setFields: mockSetFields,
setInputPanelOpen: mockSetInputPanelOpen,
toggleInputPanel: mockToggleInputPanel,
}
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
snippetDetailStoreState.fields = fields
})
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
})
describe('Field sync', () => {
it('should remove a field and sync the draft', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
initialFields: [createField()],
}))
act(() => {
result.current.handleRemoveField(0)
})
expect(result.current.fields).toEqual([])
expect(mockSetFields).toHaveBeenCalledWith([])
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
onRefresh: expect.any(Function),
})
})
it('should append a new field and close the editor after syncing', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
initialFields: [createField()],
}))
act(() => {
@ -89,7 +97,7 @@ describe('useSnippetInputFieldActions', () => {
}))
})
expect(result.current.fields).toEqual([
expect(mockSetFields).toHaveBeenCalledWith([
createField(),
createField({
label: 'Topic',
@ -109,9 +117,9 @@ describe('useSnippetInputFieldActions', () => {
})
it('should reject duplicated variables without syncing', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
initialFields: [createField()],
}))
act(() => {
@ -124,15 +132,15 @@ describe('useSnippetInputFieldActions', () => {
expect(toast.error).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate')
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
expect(mockCloseEditor).not.toHaveBeenCalled()
expect(result.current.fields).toEqual([createField()])
expect(mockSetFields).not.toHaveBeenCalled()
})
})
describe('Panel actions', () => {
it('should close the editor before toggling the input panel when the panel is open', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
initialFields: [createField()],
}))
act(() => {
@ -144,9 +152,9 @@ describe('useSnippetInputFieldActions', () => {
})
it('should close the input panel and clear the editor state', () => {
snippetDetailStoreState.fields = [createField()]
const { result } = renderHook(() => useSnippetInputFieldActions({
snippetId: 'snippet-1',
initialFields: [createField()],
}))
act(() => {

View File

@ -1,5 +1,5 @@
import type { SnippetInputField } from '@/models/snippet'
import { useCallback, useState } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { toast } from '@/app/components/base/ui/toast'
@ -8,37 +8,38 @@ import { useSnippetDetailStore } from '../../store'
type UseSnippetInputFieldActionsOptions = {
snippetId: string
initialFields: SnippetInputField[]
}
export const useSnippetInputFieldActions = ({
snippetId,
initialFields,
}: UseSnippetInputFieldActionsOptions) => {
const { t } = useTranslation('snippet')
const [fields, setFields] = useState<SnippetInputField[]>(initialFields)
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
const {
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
closeEditor,
openEditor,
setFields,
setInputPanelOpen,
toggleInputPanel,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
fields: state.fields,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
setFields: state.setFields,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
})))
const handleSortChange = useCallback((newFields: SnippetInputField[]) => {
setFields(newFields)
}, [])
}, [setFields])
const handleRemoveField = useCallback((index: number) => {
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index)
@ -46,7 +47,7 @@ export const useSnippetInputFieldActions = ({
void syncInputFieldsDraft(nextFields, {
onRefresh: setFields,
})
}, [fields, syncInputFieldsDraft])
}, [fields, setFields, syncInputFieldsDraft])
const handleSubmitField = useCallback((field: SnippetInputField) => {
const originalVariable = editingField?.variable
@ -66,7 +67,7 @@ export const useSnippetInputFieldActions = ({
onRefresh: setFields,
})
closeEditor()
}, [closeEditor, editingField?.variable, fields, syncInputFieldsDraft, t])
}, [closeEditor, editingField?.variable, fields, setFields, syncInputFieldsDraft, t])
const handleToggleInputPanel = useCallback(() => {
if (isInputPanelOpen)

View File

@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks'
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
@ -85,7 +86,13 @@ const SnippetMain = ({
nodesMap,
}
}, [workflowAvailableNodesMetaData])
const reset = useSnippetDetailStore(state => state.reset)
const {
reset,
setFields,
} = useSnippetDetailStore(useShallow(state => ({
reset: state.reset,
setFields: state.setFields,
})))
const {
editingField,
fields,
@ -100,7 +107,6 @@ const SnippetMain = ({
handleToggleInputPanel,
} = useSnippetInputFieldActions({
snippetId,
initialFields: payload.inputFields,
})
const {
handlePublish,
@ -122,6 +128,10 @@ const SnippetMain = ({
reset()
}, [reset, snippetId])
useEffect(() => {
setFields(payload.inputFields)
}, [payload.inputFields, setFields, snippetId])
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,

View File

@ -0,0 +1,163 @@
import type { SnippetInputField } from '@/models/snippet'
import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetDetailStore } from '../../store'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
const mockGetNodesReadOnly = vi.fn()
const mockPostWithKeepalive = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
let reactFlowState: {
getNodes: typeof mockGetNodes
edges: Array<Record<string, unknown>>
transform: [number, number, number]
}
let workflowStoreState: {
syncWorkflowDraftHash: string | null
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
}
vi.mock('reactflow', () => ({
useStoreApi: () => ({ getState: () => reactFlowState }),
}))
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
}))
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise<void>, checkFn?: () => boolean) =>
(...args: unknown[]) => {
if (checkFn?.())
return
return fn(...args)
},
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => workflowStoreState,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('@/service/fetch', () => ({
postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
}))
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
vi.mock('../use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
const createInputField = (variable: string): SnippetInputField => ({
type: PipelineInputVarType.textInput,
label: variable,
variable,
required: false,
})
describe('snippet/use-nodes-sync-draft', () => {
beforeEach(() => {
vi.clearAllMocks()
reactFlowState = {
getNodes: mockGetNodes,
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
transform: [12, 24, 1.5],
}
workflowStoreState = {
syncWorkflowDraftHash: 'draft-hash',
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
}
mockGetNodesReadOnly.mockReturnValue(false)
mockGetNodes.mockReturnValue([
{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start', _temp: 'drop' } },
])
mockSyncDraftWorkflow.mockResolvedValue({
hash: 'next-hash',
updated_at: 123,
})
useSnippetDetailStore.setState({
fields: [createInputField('topic')],
})
})
it('should include current input_fields when syncing the draft graph', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
await act(async () => {
await result.current.doSyncWorkflowDraft()
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: [createInputField('topic')],
hash: 'draft-hash',
},
})
})
it('should include the latest graph when syncing input fields', async () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
const nextFields = [createInputField('summary')]
await act(async () => {
await result.current.syncInputFieldsDraft(nextFields)
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
body: {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: nextFields,
hash: 'draft-hash',
},
})
})
it('should send input_fields together with graph on page close', () => {
const { result } = renderHook(() => useNodesSyncDraft('snippet-1'))
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
})
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/snippets/snippet-1/workflows/draft', {
graph: {
nodes: [{ id: 'node-1', position: { x: 0, y: 0 }, data: { title: 'Start' } }],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { stable: true } }],
viewport: { x: 12, y: 24, zoom: 1.5 },
},
input_fields: [createInputField('topic')],
hash: 'draft-hash',
})
})
})

View File

@ -10,6 +10,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
import { consoleClient } from '@/service/client'
import { postWithKeepalive } from '@/service/fetch'
import { useSnippetDetailStore } from '../store'
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
@ -30,7 +31,13 @@ export const useNodesSyncDraft = (snippetId: string) => {
const { getNodesReadOnly } = useNodesReadOnly()
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const getGraphSyncPayload = useCallback(() => {
const getInputFieldsSyncPayload = useCallback((inputFields?: SnippetInputField[]) => {
return {
input_fields: inputFields ?? useSnippetDetailStore.getState().fields,
}
}, [])
const getDraftSyncPayload = useCallback((inputFields?: SnippetInputField[]) => {
const {
getNodes,
edges,
@ -59,13 +66,14 @@ export const useNodesSyncDraft = (snippetId: string) => {
})
return {
...getInputFieldsSyncPayload(inputFields),
graph: {
nodes: producedNodes,
edges: producedEdges,
viewport: { x, y, zoom },
},
}
}, [snippetId, store])
}, [getInputFieldsSyncPayload, snippetId, store])
const syncDraft = useCallback(async (
payload: Omit<SnippetDraftSyncPayload, 'hash'>,
@ -116,34 +124,38 @@ export const useNodesSyncDraft = (snippetId: string) => {
if (getNodesReadOnly())
return
const graphPayload = getGraphSyncPayload()
if (!graphPayload)
const draftPayload = getDraftSyncPayload()
if (!draftPayload)
return
const { syncWorkflowDraftHash } = workflowStore.getState()
postWithKeepalive(`${API_PREFIX}/snippets/${snippetId}/workflows/draft`, {
...graphPayload,
...draftPayload,
hash: syncWorkflowDraftHash,
})
}, [getGraphSyncPayload, getNodesReadOnly, snippetId, workflowStore])
}, [getDraftSyncPayload, getNodesReadOnly, snippetId, workflowStore])
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: SyncDraftCallback,
) => {
const graphPayload = getGraphSyncPayload()
if (!graphPayload)
const draftPayload = getDraftSyncPayload()
if (!draftPayload)
return
await syncDraft(graphPayload, notRefreshWhenSyncError, callback)
}, [getGraphSyncPayload, syncDraft])
await syncDraft(draftPayload, notRefreshWhenSyncError, callback)
}, [getDraftSyncPayload, syncDraft])
const performInputFieldsSync = useCallback(async (
inputFields: SnippetInputField[],
callback?: SyncInputFieldsDraftCallback,
) => {
const draftPayload = getDraftSyncPayload(inputFields)
if (!draftPayload)
return
await syncDraft(
{ input_fields: inputFields },
draftPayload,
false,
callback,
(draftWorkflow) => {
@ -153,7 +165,7 @@ export const useNodesSyncDraft = (snippetId: string) => {
callback?.onRefresh?.(refreshedInputFields)
},
)
}, [syncDraft])
}, [getDraftSyncPayload, syncDraft])
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync)

View File

@ -1,9 +1,11 @@
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
import type { SnippetInputField } from '@/models/snippet'
import type { SnippetWorkflow } from '@/types/snippet'
import { useCallback } from 'react'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { consoleClient } from '@/service/client'
import { useSnippetDetailStore } from '../store'
export const useSnippetRefreshDraft = (snippetId: string) => {
const workflowStore = useWorkflowStore()
@ -23,12 +25,19 @@ export const useSnippetRefreshDraft = (snippetId: string) => {
consoleClient.snippets.draftWorkflow({
params: { snippetId },
}).then((response) => {
const inputFields = Array.isArray(response.input_fields)
? response.input_fields as SnippetInputField[]
: []
handleUpdateWorkflowCanvas({
...response.graph,
nodes: response.graph?.nodes || [],
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
} as WorkflowDataUpdater)
useSnippetDetailStore.setState({
fields: inputFields,
})
setSyncWorkflowDraftHash(response.hash)
setDraftUpdatedAt(response.updated_at)
onSuccess?.(response)

View File

@ -5,12 +5,14 @@ import { create } from 'zustand'
type SnippetDetailUIState = {
activeSection: SnippetSection
fields: SnippetInputField[]
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPreviewMode: boolean
isEditorOpen: boolean
editingField: SnippetInputField | null
setActiveSection: (section: SnippetSection) => void
setFields: (fields: SnippetInputField[]) => void
setInputPanelOpen: (value: boolean) => void
toggleInputPanel: () => void
setPublishMenuOpen: (value: boolean) => void
@ -23,6 +25,7 @@ type SnippetDetailUIState = {
const initialState = {
activeSection: 'orchestrate' as SnippetSection,
fields: [] as SnippetInputField[],
isInputPanelOpen: false,
isPublishMenuOpen: false,
isPreviewMode: false,
@ -33,6 +36,7 @@ const initialState = {
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setActiveSection: activeSection => set({ activeSection }),
setFields: fields => set({ fields }),
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),