fix: restore workflow versions via backend API (#35817)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作 2026-05-06 10:56:10 +08:00 committed by GitHub
parent 5c68f12bb8
commit b83f296634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 192 additions and 556 deletions

View File

@ -3642,7 +3642,7 @@
},
"web/app/components/workflow/hooks/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 26
"count": 25
}
},
"web/app/components/workflow/hooks/use-checklist.ts": {

View File

@ -27,6 +27,12 @@ const reactFlowBridge = vi.hoisted(() => ({
const collaborationBridge = vi.hoisted(() => ({
graphImportHandler: null as null | ((payload: { nodes: Node[], edges: Edge[] }) => void),
historyActionHandler: null as null | ((payload: unknown) => void),
restoreIntentHandler: null as null | ((payload: {
versionId: string
versionName?: string
initiatorUserId: string
initiatorName: string
}) => void),
}))
const toastInfoMock = vi.hoisted(() => vi.fn())
@ -180,6 +186,10 @@ vi.mock('../collaboration/core/collaboration-manager', () => ({
collaborationBridge.historyActionHandler = handler
return vi.fn()
},
onRestoreIntent: (handler: typeof collaborationBridge.restoreIntentHandler) => {
collaborationBridge.restoreIntentHandler = handler
return vi.fn()
},
},
}))
@ -401,7 +411,6 @@ vi.mock('../hooks', () => ({
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
useLeaderRestoreListener: vi.fn(),
}))
vi.mock('../hooks/use-workflow-search', () => ({
@ -479,6 +488,7 @@ describe('Workflow edge event wiring', () => {
reactFlowBridge.store = null
collaborationBridge.graphImportHandler = null
collaborationBridge.historyActionHandler = null
collaborationBridge.restoreIntentHandler = null
workflowCommentState.comments = []
workflowCommentState.pendingComment = null
workflowCommentState.activeComment = null
@ -626,6 +636,26 @@ describe('Workflow edge event wiring', () => {
})
})
it('should show restore intent toast when another collaborator restores a workflow version', async () => {
renderSubject()
act(() => {
collaborationBridge.restoreIntentHandler?.({
versionId: 'version-1',
versionName: 'Version One',
initiatorUserId: 'user-1',
initiatorName: 'Alice',
})
})
await waitFor(() => {
expect(toastInfoMock).toHaveBeenCalledWith(
'workflow.versionHistory.action.restoreInProgress:{"userName":"Alice","versionName":"Version One"}',
{ timeout: 3000 },
)
})
})
it('should render comment overlays and execute comment actions in comment mode', async () => {
workflowCommentState.comments = [
{ id: 'comment-1', resolved: false },

View File

@ -1,5 +1,5 @@
import type { LoroMap } from 'loro-crdt'
import type { OnlineUser, RestoreRequestData } from '../../types/collaboration'
import type { OnlineUser } from '../../types/collaboration'
import type { NoteNodeType } from '@/app/components/workflow/note-node/types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { LoroDoc } from 'loro-crdt'
@ -67,18 +67,6 @@ const createEdge = (id: string, source: string, target: string): Edge => ({
},
})
const createRestoreRequestData = (): RestoreRequestData => ({
versionId: 'version-1',
versionName: 'Version One',
initiatorUserId: 'user-1',
initiatorName: 'Alice',
graphData: {
nodes: [createNode('n-restore')],
edges: [],
viewport: { x: 1, y: 2, zoom: 0.5 },
},
})
const setupManagerWithDoc = () => {
const manager = new CollaborationManager()
const doc = new LoroDoc()
@ -275,7 +263,6 @@ describe('CollaborationManager logs and event helpers', () => {
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('undo')
manager.emitRestoreRequest(createRestoreRequestData())
manager.emitRestoreIntent({
versionId: 'version-1',
versionName: 'Version One',
@ -289,13 +276,11 @@ describe('CollaborationManager logs and event helpers', () => {
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('undo')
manager.emitRestoreRequest(createRestoreRequestData())
expect(sendSpy).not.toHaveBeenCalled()
isConnectedSpy.mockReturnValue(true)
manager.emitCommentsUpdate('app-1')
manager.emitHistoryAction('redo')
manager.emitRestoreRequest(createRestoreRequestData())
manager.emitRestoreIntent({
versionId: 'version-2',
initiatorUserId: 'u-2',
@ -309,7 +294,6 @@ describe('CollaborationManager logs and event helpers', () => {
expect(eventTypes).toEqual([
'comments_update',
'workflow_history_action',
'workflow_restore_request',
'workflow_restore_intent',
'workflow_restore_complete',
])

View File

@ -203,7 +203,6 @@ describe('CollaborationManager socket and subscription behavior', () => {
const mcpHandler = vi.fn()
const workflowUpdateHandler = vi.fn()
const commentsHandler = vi.fn()
const restoreRequestHandler = vi.fn()
const restoreIntentHandler = vi.fn()
const restoreCompleteHandler = vi.fn()
const historyHandler = vi.fn()
@ -218,7 +217,6 @@ describe('CollaborationManager socket and subscription behavior', () => {
manager.onMcpServerUpdate(mcpHandler)
manager.onWorkflowUpdate(workflowUpdateHandler)
manager.onCommentsUpdate(commentsHandler)
manager.onRestoreRequest(restoreRequestHandler)
manager.onRestoreIntent(restoreIntentHandler)
manager.onRestoreComplete(restoreCompleteHandler)
manager.onHistoryAction(historyHandler)
@ -296,11 +294,6 @@ describe('CollaborationManager socket and subscription behavior', () => {
type: 'graph_resync_request',
data: {},
} satisfies CollaborationUpdate)
socket.trigger('collaboration_update', {
...baseUpdate,
type: 'workflow_restore_request',
data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice', graphData: { nodes: [], edges: [] } } as unknown as Record<string, unknown>,
} satisfies CollaborationUpdate)
socket.trigger('collaboration_update', {
...baseUpdate,
type: 'workflow_restore_intent',
@ -335,7 +328,6 @@ describe('CollaborationManager socket and subscription behavior', () => {
expect(latestPresence).toMatchObject({ 'n-1': { 'socket-events': { userId: 'u-1' } } })
expect(syncRequestHandler).toHaveBeenCalledTimes(1)
expect(broadcastSpy).toHaveBeenCalledTimes(1)
expect(restoreRequestHandler).toHaveBeenCalledTimes(1)
expect(restoreIntentHandler).toHaveBeenCalledWith({ versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } satisfies RestoreIntentData)
expect(restoreCompleteHandler).toHaveBeenCalledWith({ versionId: 'v1', success: true } satisfies RestoreCompleteData)
expect(historyHandler).toHaveBeenCalledWith({ action: 'undo', userId: 'u-1' })

View File

@ -16,7 +16,6 @@ import type {
OnlineUser,
RestoreCompleteData,
RestoreIntentData,
RestoreRequestData,
} from '../types/collaboration'
import { cloneDeep } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
@ -770,17 +769,6 @@ export class CollaborationManager {
return this.eventEmitter.on('historyAction', callback)
}
emitRestoreRequest(data: RestoreRequestData): void {
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId))
return
this.sendCollaborationEvent({
type: 'workflow_restore_request',
data: data as unknown as Record<string, unknown>,
timestamp: Date.now(),
})
}
emitRestoreIntent(data: RestoreIntentData): void {
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId))
return
@ -803,10 +791,6 @@ export class CollaborationManager {
})
}
onRestoreRequest(callback: (data: RestoreRequestData) => void): () => void {
return this.eventEmitter.on('restoreRequest', callback)
}
onRestoreIntent(callback: (data: RestoreIntentData) => void): () => void {
return this.eventEmitter.on('restoreIntent', callback)
}
@ -1476,10 +1460,6 @@ export class CollaborationManager {
if (this.isLeader)
this.broadcastCurrentGraph()
}
else if (update.type === 'workflow_restore_request') {
if (this.isLeader)
this.eventEmitter.emit('restoreRequest', update.data as RestoreRequestData)
}
else if (update.type === 'workflow_restore_intent') {
this.eventEmitter.emit('restoreIntent', update.data as RestoreIntentData)
}

View File

@ -1,7 +1,3 @@
import type { Viewport } from 'reactflow'
import type { ConversationVariable, Edge, EnvironmentVariable, Node } from '../../types'
import type { Features } from '@/app/components/base/features/types'
export type OnlineUser = {
user_id: string
username: string
@ -51,7 +47,6 @@ type CollaborationEventType
| 'node_panel_presence'
| 'app_publish_update'
| 'graph_resync_request'
| 'workflow_restore_request'
| 'workflow_restore_intent'
| 'workflow_restore_complete'
| 'workflow_history_action'
@ -63,21 +58,6 @@ export type CollaborationUpdate = {
timestamp: number
}
export type RestoreRequestData = {
versionId: string
versionName?: string
initiatorUserId: string
initiatorName: string
graphData: {
nodes: Node[]
edges: Edge[]
viewport?: Viewport
}
features?: Features
environmentVariables?: EnvironmentVariable[]
conversationVariables?: ConversationVariable[]
}
export type RestoreIntentData = {
versionId: string
versionName?: string

View File

@ -7,9 +7,9 @@ import HeaderInRestoring from '../header-in-restoring'
const mockRestoreWorkflow = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockRequestRestore = vi.fn()
vi.mock('@/hooks/use-theme', () => ({
default: () => ({
@ -31,6 +31,7 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
@ -43,9 +44,6 @@ vi.mock('../../hooks', () => ({
useWorkflowRefreshDraft: () => ({
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
}),
useLeaderRestore: () => ({
requestRestore: mockRequestRestore,
}),
}))
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
@ -92,7 +90,7 @@ describe('HeaderInRestoring', () => {
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
})
it('should enable restore when version and flow config are both ready', () => {
it('should enable restore when version and flow id are both ready', () => {
renderWorkflowComponent(<HeaderInRestoring />, {
initialStoreState: {
currentVersion: createVersion(),
@ -100,7 +98,7 @@ describe('HeaderInRestoring', () => {
hooksStoreProps: {
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
flowType: undefined as never,
fileSettings: {} as never,
},
},

View File

@ -14,7 +14,11 @@ const mockHandleNodeSelect = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockCloseAllInputFieldPanels = vi.fn()
const mockInvalidAllLastRun = vi.fn()
const mockRequestRestore = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockResetWorkflowVersionHistory = vi.fn()
const mockEmitRestoreIntent = vi.fn()
const mockEmitRestoreComplete = vi.fn()
const mockEmitWorkflowUpdate = vi.fn()
const mockNotify = vi.fn()
const mockRunAndHistory = vi.fn()
const mockViewHistory = vi.fn()
@ -33,9 +37,6 @@ vi.mock('../../hooks', () => ({
handleBackupDraft: mockHandleBackupDraft,
handleLoadBackupDraft: mockHandleLoadBackupDraft,
}),
useLeaderRestore: () => ({
requestRestore: mockRequestRestore,
}),
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: vi.fn(),
}),
@ -58,6 +59,18 @@ vi.mock('@/hooks/use-theme', () => ({
vi.mock('@/service/use-workflow', () => ({
useInvalidAllLastRun: () => mockInvalidAllLastRun,
useResetWorkflowVersionHistory: () => mockResetWorkflowVersionHistory,
useRestoreWorkflow: () => ({
mutateAsync: mockRestoreWorkflow,
}),
}))
vi.mock('../../collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitRestoreIntent: mockEmitRestoreIntent,
emitRestoreComplete: mockEmitRestoreComplete,
emitWorkflowUpdate: mockEmitWorkflowUpdate,
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -166,13 +179,7 @@ describe('Header layout components', () => {
mockNodesReadOnly = false
mockTheme = 'light'
mockUseNodes.mockReturnValue([])
mockRequestRestore.mockImplementation((_payload: unknown, callbacks?: {
onSuccess?: () => void
onSettled?: () => void
}) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
mockRestoreWorkflow.mockResolvedValue({})
})
describe('HeaderInNormal', () => {
@ -277,7 +284,7 @@ describe('Header layout components', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await waitFor(() => {
expect(mockRequestRestore).toHaveBeenCalledTimes(1)
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/flow-1/workflows/version-1/restore')
expect(store.getState().showWorkflowVersionHistoryPanel).toBe(false)
expect(store.getState().isRestoring).toBe(false)
expect(store.getState().backupDraft).toBeUndefined()
@ -289,8 +296,53 @@ describe('Header layout components', () => {
message: 'workflow.versionHistory.action.restoreSuccess',
})
})
expect(mockEmitRestoreIntent).toHaveBeenCalledWith({
versionId: currentVersion.id,
versionName: currentVersion.marked_name,
initiatorUserId: '',
initiatorName: '',
})
expect(mockEmitRestoreComplete).toHaveBeenCalledWith({
versionId: currentVersion.id,
success: true,
})
expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('flow-1')
expect(mockResetWorkflowVersionHistory).toHaveBeenCalledTimes(1)
expect(onRestoreSettled).toHaveBeenCalledTimes(1)
})
it('should restore rag pipeline versions without emitting collaboration events', async () => {
const currentVersion = createCurrentVersion()
renderWorkflowComponent(
<HeaderInRestoring />,
{
initialStoreState: {
isRestoring: true,
showWorkflowVersionHistoryPanel: true,
backupDraft: createBackupDraft(),
currentVersion,
},
hooksStoreProps: {
configsMap: {
flowType: FlowType.ragPipeline,
flowId: 'pipeline-1',
fileSettings: {},
},
},
},
)
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
await waitFor(() => {
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/rag/pipelines/pipeline-1/workflows/version-1/restore')
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalledTimes(1)
})
expect(mockEmitRestoreIntent).not.toHaveBeenCalled()
expect(mockEmitRestoreComplete).not.toHaveBeenCalled()
expect(mockEmitWorkflowUpdate).not.toHaveBeenCalled()
})
})
describe('HeaderInHistory', () => {

View File

@ -6,12 +6,11 @@ import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import useTheme from '@/hooks/use-theme'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow'
import { FlowType } from '@/types/common'
import {
useLeaderRestore,
useWorkflowRefreshDraft,
useWorkflowRun,
} from '../hooks'
@ -35,7 +34,6 @@ const HeaderInRestoring = ({
const { theme } = useTheme()
const workflowStore = useWorkflowStore()
const userProfile = useAppContextSelector(s => s.userProfile)
const featuresStore = useFeaturesStore()
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const {
@ -47,9 +45,11 @@ const HeaderInRestoring = ({
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const { requestRestore } = useLeaderRestore()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
@ -57,47 +57,86 @@ const HeaderInRestoring = ({
setShowWorkflowVersionHistoryPanel(false)
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
const restoreVersionUrl = useCallback((versionId: string) => {
if (!configsMap?.flowId)
return ''
if (configsMap.flowType === FlowType.ragPipeline)
return `/rag/pipelines/${configsMap.flowId}/workflows/${versionId}/restore`
return `/apps/${configsMap.flowId}/workflows/${versionId}/restore`
}, [configsMap?.flowId, configsMap?.flowType])
const emitRestoreIntent = useCallback(async () => {
if (!currentVersion || !canEmitCollaborationEvents)
return
try {
const { collaborationManager } = await import('../collaboration/core/collaboration-manager')
collaborationManager.emitRestoreIntent({
versionId: currentVersion.id,
versionName: currentVersion.marked_name,
initiatorUserId: userProfile.id,
initiatorName: userProfile.name,
})
}
catch (error) {
console.error('Failed to emit restore intent:', error)
}
}, [canEmitCollaborationEvents, currentVersion, userProfile.id, userProfile.name])
const emitRestoreComplete = useCallback(async (success: boolean, errorMessage?: string) => {
if (!currentVersion || !canEmitCollaborationEvents)
return
try {
const { collaborationManager } = await import('../collaboration/core/collaboration-manager')
collaborationManager.emitRestoreComplete({
versionId: currentVersion.id,
success,
...(errorMessage ? { error: errorMessage } : {}),
})
}
catch (error) {
console.error('Failed to emit restore complete:', error)
}
}, [canEmitCollaborationEvents, currentVersion])
const emitWorkflowUpdate = useCallback(async () => {
if (!configsMap?.flowId || !canEmitCollaborationEvents)
return
try {
const { collaborationManager } = await import('../collaboration/core/collaboration-manager')
collaborationManager.emitWorkflowUpdate(configsMap.flowId)
}
catch (error) {
console.error('Failed to emit workflow update:', error)
}
}, [canEmitCollaborationEvents, configsMap?.flowId])
const handleRestore = useCallback(async () => {
if (!canRestore || !currentVersion)
return
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
await emitRestoreIntent()
const { graph } = currentVersion
const features = featuresStore?.getState().features
const environmentVariables = currentVersion.environment_variables || []
const conversationVariables = currentVersion.conversation_variables || []
requestRestore({
versionId: currentVersion.id,
versionName: currentVersion.marked_name,
initiatorUserId: userProfile.id,
initiatorName: userProfile.name,
graphData: {
nodes: graph.nodes,
edges: graph.edges,
viewport: graph.viewport,
},
features,
environmentVariables,
conversationVariables,
}, {
onSuccess: () => {
handleRefreshWorkflowDraft()
toast.success(t('versionHistory.action.restoreSuccess', { ns: 'workflow' }))
deleteAllInspectVars()
invalidAllLastRun()
},
onError: () => {
toast.error(t('versionHistory.action.restoreFailure', { ns: 'workflow' }))
},
onSettled: () => {
onRestoreSettled?.()
},
})
}, [canRestore, currentVersion, setShowWorkflowVersionHistoryPanel, workflowStore, featuresStore, requestRestore, userProfile, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
try {
await restoreWorkflow(restoreVersionUrl(currentVersion.id))
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleRefreshWorkflowDraft()
toast.success(t('versionHistory.action.restoreSuccess', { ns: 'workflow' }))
deleteAllInspectVars()
invalidAllLastRun()
await emitRestoreComplete(true)
await emitWorkflowUpdate()
}
catch {
toast.error(t('versionHistory.action.restoreFailure', { ns: 'workflow' }))
await emitRestoreComplete(false, 'restore failed')
}
finally {
resetWorkflowVersionHistory()
onRestoreSettled?.()
}
}, [canRestore, currentVersion, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled])
return (
<>

View File

@ -1,262 +0,0 @@
import type { RestoreIntentData, RestoreRequestData } from '../../collaboration/types/collaboration'
import type { SyncDraftCallback } from '../../hooks-store/store'
import type { Edge, Node } from '../../types'
import { act, renderHook } from '@testing-library/react'
import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { ChatVarType } from '../../panel/chat-variable-panel/type'
import { useLeaderRestore, useLeaderRestoreListener } from '../use-leader-restore'
const mockSetViewport = vi.hoisted(() => vi.fn())
const mockSetFeatures = vi.hoisted(() => vi.fn())
const mockSetEnvironmentVariables = vi.hoisted(() => vi.fn())
const mockSetConversationVariables = vi.hoisted(() => vi.fn())
const mockDoSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockToastInfo = vi.hoisted(() => vi.fn())
const mockEmitRestoreIntent = vi.hoisted(() => vi.fn())
const mockEmitRestoreComplete = vi.hoisted(() => vi.fn())
const mockEmitWorkflowUpdate = vi.hoisted(() => vi.fn())
const mockEmitRestoreRequest = vi.hoisted(() => vi.fn())
const mockIsConnected = vi.hoisted(() => vi.fn())
const mockGetIsLeader = vi.hoisted(() => vi.fn())
const mockSetNodes = vi.hoisted(() => vi.fn())
const mockSetEdges = vi.hoisted(() => vi.fn())
const mockRefreshGraphSynchronously = vi.hoisted(() => vi.fn())
const mockGetNodes = vi.hoisted(() => vi.fn(() => [{ id: 'old-node' } as unknown as Node]))
const mockGetEdges = vi.hoisted(() => vi.fn(() => [{ id: 'old-edge' } as unknown as Edge]))
let restoreCompleteCallback: ((data: { versionId: string, success: boolean }) => void) | null = null
let restoreRequestCallback: ((data: RestoreRequestData) => void) | null = null
let restoreIntentCallback: ((data: RestoreIntentData) => void) | null = null
const unsubscribeRestoreComplete = vi.hoisted(() => vi.fn())
const unsubscribeRestoreRequest = vi.hoisted(() => vi.fn())
const unsubscribeRestoreIntent = vi.hoisted(() => vi.fn())
let isCollaborationEnabled = true
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, userName?: string, versionName?: string }) => {
const ns = options?.ns ? `${options.ns}.` : ''
const extra = options?.userName ? `:${options.userName}:${options.versionName}` : ''
return `${ns}${key}${extra}`
},
}),
}))
vi.mock('reactflow', () => ({
useReactFlow: () => ({
setViewport: mockSetViewport,
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: {
getState: () => ({
appDetail: { id: 'app-1' },
}),
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeaturesStore: () => ({
getState: () => ({
setFeatures: mockSetFeatures,
}),
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
info: (...args: unknown[]) => mockToastInfo(...args),
},
}))
vi.mock('../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setEnvironmentVariables: mockSetEnvironmentVariables,
setConversationVariables: mockSetConversationVariables,
}),
}),
}))
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: (...args: unknown[]) => mockDoSyncWorkflowDraft(...args),
}),
}))
vi.mock('../../collaboration/core/collaboration-manager', () => ({
collaborationManager: {
emitRestoreIntent: (...args: unknown[]) => mockEmitRestoreIntent(...args),
emitRestoreComplete: (...args: unknown[]) => mockEmitRestoreComplete(...args),
emitWorkflowUpdate: (...args: unknown[]) => mockEmitWorkflowUpdate(...args),
emitRestoreRequest: (...args: unknown[]) => mockEmitRestoreRequest(...args),
isConnected: (...args: unknown[]) => mockIsConnected(...args),
getIsLeader: (...args: unknown[]) => mockGetIsLeader(...args),
setNodes: (...args: unknown[]) => mockSetNodes(...args),
setEdges: (...args: unknown[]) => mockSetEdges(...args),
refreshGraphSynchronously: (...args: unknown[]) => mockRefreshGraphSynchronously(...args),
getNodes: () => mockGetNodes(),
getEdges: () => mockGetEdges(),
onRestoreComplete: (callback: (data: { versionId: string, success: boolean }) => void) => {
restoreCompleteCallback = callback
return unsubscribeRestoreComplete
},
onRestoreRequest: (callback: (data: RestoreRequestData) => void) => {
restoreRequestCallback = callback
return unsubscribeRestoreRequest
},
onRestoreIntent: (callback: (data: RestoreIntentData) => void) => {
restoreIntentCallback = callback
return unsubscribeRestoreIntent
},
},
}))
describe('useLeaderRestore', () => {
const restoreData: RestoreRequestData = {
versionId: 'v-1',
versionName: 'Version One',
initiatorUserId: 'u-1',
initiatorName: 'Alice',
features: { moreLikeThis: { enabled: true } },
environmentVariables: [{ id: 'env-1', name: 'A', value: '1', value_type: ChatVarType.String, description: '' }],
conversationVariables: [{ id: 'conv-1', name: 'B', value: '2', value_type: ChatVarType.String, description: '' }],
graphData: {
nodes: [{ id: 'new-node' } as unknown as Node],
edges: [{ id: 'new-edge' } as unknown as Edge],
viewport: { x: 1, y: 2, zoom: 0.5 },
},
}
beforeEach(() => {
vi.clearAllMocks()
restoreCompleteCallback = null
restoreRequestCallback = null
restoreIntentCallback = null
isCollaborationEnabled = true
mockIsConnected.mockReturnValue(true)
mockGetIsLeader.mockReturnValue(false)
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
it('performs restore locally when collaboration is disabled', async () => {
isCollaborationEnabled = false
const onSuccess = vi.fn()
const onSettled = vi.fn()
const { result } = renderHookWithSystemFeatures(() => useLeaderRestore(), {
systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
})
await act(async () => {
result.current.requestRestore(restoreData, { onSuccess, onSettled })
})
expect(mockEmitRestoreIntent).toHaveBeenCalledWith(expect.objectContaining({
versionId: 'v-1',
initiatorName: 'Alice',
}))
expect(mockSetFeatures).toHaveBeenCalledWith({ moreLikeThis: { enabled: true } })
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
expect(mockSetConversationVariables).toHaveBeenCalled()
expect(mockSetNodes).toHaveBeenCalledWith([{ id: 'old-node' }], [{ id: 'new-node' }], 'leader-restore:apply-graph')
expect(mockSetEdges).toHaveBeenCalledWith([{ id: 'old-edge' }], [{ id: 'new-edge' }])
expect(mockRefreshGraphSynchronously).toHaveBeenCalled()
expect(mockSetViewport).toHaveBeenCalledWith({ x: 1, y: 2, zoom: 0.5 })
expect(mockEmitRestoreComplete).toHaveBeenCalledWith({ versionId: 'v-1', success: true })
expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1')
expect(onSuccess).toHaveBeenCalled()
expect(onSettled).toHaveBeenCalled()
})
it('emits restore request and resolves callbacks from restore-complete events', async () => {
isCollaborationEnabled = true
mockIsConnected.mockReturnValue(true)
mockGetIsLeader.mockReturnValue(false)
const onSuccess = vi.fn()
const onError = vi.fn()
const onSettled = vi.fn()
const { result } = renderHookWithSystemFeatures(() => useLeaderRestore(), {
systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
})
act(() => {
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
})
expect(mockEmitRestoreRequest).toHaveBeenCalledWith(restoreData)
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
act(() => {
restoreCompleteCallback?.({ versionId: 'v-1', success: true })
})
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(1)
act(() => {
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
restoreCompleteCallback?.({ versionId: 'v-1', success: false })
})
expect(onError).toHaveBeenCalledTimes(1)
expect(onSettled).toHaveBeenCalledTimes(2)
})
})
describe('useLeaderRestoreListener', () => {
beforeEach(() => {
vi.clearAllMocks()
restoreRequestCallback = null
restoreIntentCallback = null
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
callbacks?.onSuccess?.()
callbacks?.onSettled?.()
})
})
it('shows restore notifications for request and intent events', () => {
const { unmount } = renderHook(() => useLeaderRestoreListener())
act(() => {
restoreRequestCallback?.({
...{
versionId: 'v-2',
versionName: 'Version Two',
initiatorUserId: 'u-2',
initiatorName: 'Bob',
graphData: { nodes: [], edges: [] },
},
})
})
expect(mockToastInfo).toHaveBeenCalledWith(
'workflow.versionHistory.action.restoreInProgress:Bob:Version Two',
{ timeout: 3000 },
)
expect(mockEmitRestoreIntent).toHaveBeenCalled()
act(() => {
restoreIntentCallback?.({
versionId: 'v-3',
versionName: 'Version Three',
initiatorUserId: 'u-3',
initiatorName: 'Carol',
})
})
expect(mockToastInfo).toHaveBeenCalledWith(
'workflow.versionHistory.action.restoreInProgress:Carol:Version Three',
{ timeout: 3000 },
)
unmount()
expect(unsubscribeRestoreRequest).toHaveBeenCalled()
expect(unsubscribeRestoreIntent).toHaveBeenCalled()
})
})

View File

@ -4,7 +4,6 @@ export * from './use-checklist'
export * from './use-DSL'
export * from './use-edges-interactions'
export * from './use-inspect-vars-crud'
export * from './use-leader-restore'
export * from './use-node-data-update'
export * from './use-nodes-interactions'
export * from './use-nodes-meta-data'

View File

@ -1,164 +0,0 @@
import type { RestoreCompleteData, RestoreIntentData, RestoreRequestData } from '../collaboration/types/collaboration'
import type { SyncCallback } from './use-nodes-sync-draft'
import { toast } from '@langgenius/dify-ui/toast'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useWorkflowStore } from '../store'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
type RestoreCallbacks = SyncCallback
const usePerformRestore = () => {
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const appDetail = useAppStore.getState().appDetail
const featuresStore = useFeaturesStore()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
return useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
collaborationManager.emitRestoreIntent({
versionId: data.versionId,
versionName: data.versionName,
initiatorUserId: data.initiatorUserId,
initiatorName: data.initiatorName,
})
if (data.features && featuresStore) {
const { setFeatures } = featuresStore.getState()
setFeatures(data.features)
}
if (data.environmentVariables) {
workflowStore.getState().setEnvironmentVariables(data.environmentVariables)
}
if (data.conversationVariables) {
workflowStore.getState().setConversationVariables(data.conversationVariables)
}
const { nodes, edges, viewport } = data.graphData
const currentNodes = collaborationManager.getNodes()
const currentEdges = collaborationManager.getEdges()
collaborationManager.setNodes(currentNodes, nodes, 'leader-restore:apply-graph')
collaborationManager.setEdges(currentEdges, edges)
collaborationManager.refreshGraphSynchronously()
if (viewport)
reactflow.setViewport(viewport)
doSyncWorkflowDraft(false, {
onSuccess: () => {
collaborationManager.emitRestoreComplete({
versionId: data.versionId,
success: true,
})
if (appDetail)
collaborationManager.emitWorkflowUpdate(appDetail.id)
callbacks?.onSuccess?.()
},
onError: () => {
collaborationManager.emitRestoreComplete({
versionId: data.versionId,
success: false,
error: 'Failed to sync restore to server',
})
callbacks?.onError?.()
},
onSettled: () => {
callbacks?.onSettled?.()
},
})
}, [appDetail, doSyncWorkflowDraft, featuresStore, reactflow, workflowStore])
}
export const useLeaderRestoreListener = () => {
const { t } = useTranslation()
const performRestore = usePerformRestore()
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreRequest((data: RestoreRequestData) => {
toast.info(t('versionHistory.action.restoreInProgress', {
ns: 'workflow',
userName: data.initiatorName,
versionName: data.versionName || data.versionId,
}), { timeout: 3000 })
performRestore(data)
})
return unsubscribe
}, [performRestore, t])
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreIntent((data: RestoreIntentData) => {
toast.info(t('versionHistory.action.restoreInProgress', {
ns: 'workflow',
userName: data.initiatorName,
versionName: data.versionName || data.versionId,
}), { timeout: 3000 })
})
return unsubscribe
}, [t])
}
export const useLeaderRestore = () => {
const performRestore = usePerformRestore()
const pendingCallbacksRef = useRef<{
versionId: string
callbacks: RestoreCallbacks | null
} | null>(null)
const { data: isCollaborationEnabled } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_collaboration_mode,
})
const requestRestore = useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
if (!isCollaborationEnabled || !collaborationManager.isConnected() || collaborationManager.getIsLeader()) {
performRestore(data, callbacks)
return
}
pendingCallbacksRef.current = {
versionId: data.versionId,
callbacks: callbacks || null,
}
collaborationManager.emitRestoreRequest(data)
}, [isCollaborationEnabled, performRestore])
useEffect(() => {
const unsubscribe = collaborationManager.onRestoreComplete((data: RestoreCompleteData) => {
const pending = pendingCallbacksRef.current
if (!pending || pending.versionId !== data.versionId)
return
const callbacks = pending.callbacks
if (!callbacks) {
pendingCallbacksRef.current = null
return
}
if (data.success)
callbacks.onSuccess?.()
else
callbacks.onError?.()
callbacks.onSettled?.()
pendingCallbacksRef.current = null
})
return unsubscribe
}, [])
return {
requestRestore,
}
}

View File

@ -81,7 +81,6 @@ import EdgeContextmenu from './edge-contextmenu'
import HelpLine from './help-line'
import {
useEdgesInteractions,
useLeaderRestoreListener,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
@ -277,6 +276,17 @@ export const Workflow: FC<WorkflowProps> = memo(({
toast.info(t('collaboration.historyAction.generic', { ns: 'workflow' }))
})
}, [t])
useEffect(() => {
return collaborationManager.onRestoreIntent((data) => {
toast.info(t('versionHistory.action.restoreInProgress', {
ns: 'workflow',
userName: data.initiatorName,
versionName: data.versionName || data.versionId,
}), { timeout: 3000 })
})
}, [t])
const {
handleSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
@ -533,8 +543,6 @@ export const Workflow: FC<WorkflowProps> = memo(({
// Initialize workflow node search functionality
useWorkflowSearch()
useLeaderRestoreListener()
// Set up scroll to node event listener using the utility function
useEffect(() => {
return setupScrollToNodeListener(nodes, reactflow)