diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3c86fb2b7c..258584c7f3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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": { diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 28be928a82..817c397d5d 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -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 }, diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts index 89888bf1cb..e56aa1d82c 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts @@ -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', ]) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 6768e5b4e2..63487432dc 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -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, - } 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' }) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 5000886222..a80e493da9 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -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, - 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) } diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index 3a5b71e2d1..7c083bca18 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -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 diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index 60d55a931c..87e1db69bf 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -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 => ({ @@ -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(, { initialStoreState: { currentVersion: createVersion(), @@ -100,7 +98,7 @@ describe('HeaderInRestoring', () => { hooksStoreProps: { configsMap: { flowId: 'app-1', - flowType: FlowType.appFlow, + flowType: undefined as never, fileSettings: {} as never, }, }, diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index 1ddf013f8d..19833b4fa7 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -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( + , + { + 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', () => { diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index f07d28ff13..8ce061eb2a 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -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 ( <> diff --git a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts b/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts deleted file mode 100644 index b0fb43f768..0000000000 --- a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index e0e24ef994..8dc5def829 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -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' diff --git a/web/app/components/workflow/hooks/use-leader-restore.ts b/web/app/components/workflow/hooks/use-leader-restore.ts deleted file mode 100644 index 9083767f40..0000000000 --- a/web/app/components/workflow/hooks/use-leader-restore.ts +++ /dev/null @@ -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, - } -} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 8598904cb7..b10e168c25 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -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 = 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 = memo(({ // Initialize workflow node search functionality useWorkflowSearch() - useLeaderRestoreListener() - // Set up scroll to node event listener using the utility function useEffect(() => { return setupScrollToNodeListener(nodes, reactflow)