mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 19:21:05 +08:00
fix: adding a restore API for version control on workflow draft (#33582)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import { usePluginInstallation } from '@/hooks/use-query-params'
|
||||
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
|
||||
import PluginPageWithContext from '../index'
|
||||
|
||||
let mockEnableMarketplace = true
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@/service/plugins', () => ({
|
||||
fetchManifestFromMarketPlace: vi.fn(),
|
||||
@@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
enable_marketplace: mockEnableMarketplace,
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
@@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({
|
||||
describe('PluginPage Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEnableMarketplace = true
|
||||
// Reset to default mock values
|
||||
vi.mocked(usePluginInstallation).mockReturnValue([
|
||||
{ packageId: null, bundleInfo: null },
|
||||
@@ -630,18 +633,7 @@ describe('PluginPage Component', () => {
|
||||
})
|
||||
|
||||
it('should handle marketplace disabled', () => {
|
||||
// Mock marketplace disabled
|
||||
vi.mock('@/context/global-public-context', async () => ({
|
||||
useGlobalPublicStore: vi.fn((selector) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}
|
||||
return selector(state)
|
||||
}),
|
||||
}))
|
||||
|
||||
mockEnableMarketplace = false
|
||||
vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()])
|
||||
|
||||
render(<PluginPageWithContext {...createDefaultProps()} />)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
|
||||
import Conversion from '../conversion'
|
||||
@@ -347,11 +348,67 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: function MockAppIconPicker({ onSelect, onClose }: {
|
||||
onSelect?: (payload:
|
||||
| { type: 'emoji', icon: string, background: string }
|
||||
| { type: 'image', fileId: string, url: string },
|
||||
) => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'emoji' | 'image'>('emoji')
|
||||
const [selectedEmoji, setSelectedEmoji] = useState({ icon: '😀', background: '#FFFFFF' })
|
||||
|
||||
return (
|
||||
<div data-testid="app-icon-picker">
|
||||
<button type="button" onClick={() => setActiveTab('emoji')}>iconPicker.emoji</button>
|
||||
<button type="button" onClick={() => setActiveTab('image')}>iconPicker.image</button>
|
||||
{activeTab === 'emoji' && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="picker-emoji-option"
|
||||
onClick={() => setSelectedEmoji({ icon: '🎯', background: '#FFAA00' })}
|
||||
>
|
||||
picker-emoji-option
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'image' && <div data-testid="picker-image-panel">picker-image-panel</div>}
|
||||
<button type="button" onClick={() => onClose?.()}>iconPicker.cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeTab === 'emoji') {
|
||||
onSelect?.({
|
||||
type: 'emoji',
|
||||
icon: selectedEmoji.icon,
|
||||
background: selectedEmoji.background,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onSelect?.({
|
||||
type: 'image',
|
||||
fileId: 'test-file-id',
|
||||
url: 'https://example.com/icon.png',
|
||||
})
|
||||
}}
|
||||
>
|
||||
iconPicker.ok
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Silence expected console.error from Dialog/Modal rendering
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Helper to find the name input in PublishAsKnowledgePipelineModal
|
||||
function getNameInput() {
|
||||
return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder')
|
||||
@@ -708,10 +765,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
const appIcon = getAppIcon()
|
||||
fireEvent.click(appIcon)
|
||||
|
||||
// Click the first emoji in the grid (search full document since Dialog uses portal)
|
||||
const gridEmojis = document.querySelectorAll('.grid em-emoji')
|
||||
expect(gridEmojis.length).toBeGreaterThan(0)
|
||||
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
|
||||
fireEvent.click(screen.getByTestId('picker-emoji-option'))
|
||||
|
||||
// Click OK to confirm selection
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
@@ -1031,11 +1085,8 @@ describe('Integration Tests', () => {
|
||||
// Open picker and select an emoji
|
||||
const appIcon = getAppIcon()
|
||||
fireEvent.click(appIcon)
|
||||
const gridEmojis = document.querySelectorAll('.grid em-emoji')
|
||||
if (gridEmojis.length > 0) {
|
||||
fireEvent.click(gridEmojis[0].parentElement!.parentElement!)
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('picker-emoji-option'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i }))
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const RagPipelinePanel = () => {
|
||||
return {
|
||||
getVersionListUrl: `/rag/pipelines/${pipelineId}/workflows`,
|
||||
deleteVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
|
||||
restoreVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`,
|
||||
updateVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`,
|
||||
latestVersionId: '',
|
||||
}
|
||||
|
||||
@@ -231,6 +231,25 @@ describe('useNodesSyncDraft', () => {
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not include source_workflow_id in sync payloads', async () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
source_workflow_id: expect.anything(),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should call onSuccess callback when sync succeeds', async () => {
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([
|
||||
@@ -421,6 +440,21 @@ describe('useNodesSyncDraft', () => {
|
||||
expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }])
|
||||
})
|
||||
|
||||
it('should not include source_workflow_id when syncing on page close', () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
const sentParams = mockPostWithKeepalive.mock.calls[0][1]
|
||||
expect(sentParams.source_workflow_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remove underscore-prefixed keys from edges', () => {
|
||||
mockStoreGetState.mockReturnValue({
|
||||
getNodes: mockGetNodes,
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('usePipelineRefreshDraft', () => {
|
||||
const mockSetIsSyncingWorkflowDraft = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
const mockSetEnvSecrets = vi.fn()
|
||||
const mockSetRagPipelineVariables = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -45,6 +46,7 @@ describe('usePipelineRefreshDraft', () => {
|
||||
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
setEnvSecrets: mockSetEnvSecrets,
|
||||
setRagPipelineVariables: mockSetRagPipelineVariables,
|
||||
})
|
||||
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
@@ -55,6 +57,7 @@ describe('usePipelineRefreshDraft', () => {
|
||||
},
|
||||
hash: 'new-hash',
|
||||
environment_variables: [],
|
||||
rag_pipeline_variables: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +119,29 @@ describe('usePipelineRefreshDraft', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should update rag pipeline variables after fetch', async () => {
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
hash: 'new-hash',
|
||||
environment_variables: [],
|
||||
rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePipelineRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('should set syncing state to false after completion', async () => {
|
||||
const { result } = renderHook(() => usePipelineRefreshDraft())
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
@@ -83,11 +84,7 @@ export const useNodesSyncDraft = () => {
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
@@ -16,6 +16,7 @@ export const usePipelineRefreshDraft = () => {
|
||||
setIsSyncingWorkflowDraft,
|
||||
setEnvironmentVariables,
|
||||
setEnvSecrets,
|
||||
setRagPipelineVariables,
|
||||
} = workflowStore.getState()
|
||||
setIsSyncingWorkflowDraft(true)
|
||||
fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`).then((response) => {
|
||||
@@ -34,6 +35,7 @@ export const usePipelineRefreshDraft = () => {
|
||||
return acc
|
||||
}, {} as Record<string, string>))
|
||||
setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || [])
|
||||
setRagPipelineVariables?.(response.rag_pipeline_variables || [])
|
||||
}).finally(() => setIsSyncingWorkflowDraft(false))
|
||||
}, [handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ const WorkflowPanel = () => {
|
||||
return {
|
||||
getVersionListUrl: `/apps/${appId}/workflows`,
|
||||
deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
|
||||
restoreVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}/restore`,
|
||||
updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`,
|
||||
latestVersionId: appDetail?.workflow?.id,
|
||||
}
|
||||
|
||||
@@ -108,4 +108,18 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
|
||||
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not include source_workflow_id in draft sync payloads', async () => {
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false)
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: expect.not.objectContaining({
|
||||
source_workflow_id: expect.anything(),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
@@ -91,11 +92,7 @@ export const useNodesSyncDraft = () => {
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { WorkflowVersion } from '../../types'
|
||||
import HeaderInRestoring from '../header-in-restoring'
|
||||
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockInvalidAllLastRun = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({
|
||||
theme: 'light',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn(() => '09:30:00'),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: vi.fn(() => '3 hours ago'),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => mockInvalidAllLastRun,
|
||||
useRestoreWorkflow: () => ({
|
||||
mutateAsync: mockRestoreWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useWorkflowRun: () => ({
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createVersion = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-1',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
created_at: 1_700_000_000,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
hash: 'hash-1',
|
||||
updated_at: 1_700_000_100,
|
||||
updated_by: {
|
||||
id: 'user-2',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
},
|
||||
tool_published: false,
|
||||
version: 'v1',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('HeaderInRestoring', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should disable restore when the flow id is not ready yet', () => {
|
||||
renderWorkflowComponent(<HeaderInRestoring />, {
|
||||
initialStoreState: {
|
||||
currentVersion: createVersion(),
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable restore when version and flow config are both ready', () => {
|
||||
renderWorkflowComponent(<HeaderInRestoring />, {
|
||||
initialStoreState: {
|
||||
currentVersion: createVersion(),
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should keep restore disabled for draft versions even when flow config is ready', () => {
|
||||
renderWorkflowComponent(<HeaderInRestoring />, {
|
||||
initialStoreState: {
|
||||
currentVersion: createVersion({
|
||||
version: WorkflowVersion.Draft,
|
||||
}),
|
||||
},
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useInvalidAllLastRun } from '@/service/use-workflow'
|
||||
import { useInvalidAllLastRun, useRestoreWorkflow } from '@/service/use-workflow'
|
||||
import { getFlowPrefix } from '@/service/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowRefreshDraft,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import { useHooksStore } from '../hooks-store'
|
||||
@@ -42,7 +43,9 @@ const HeaderInRestoring = ({
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
} = useWorkflowRun()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
|
||||
const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft
|
||||
|
||||
const handleCancelRestore = useCallback(() => {
|
||||
handleLoadBackupDraft()
|
||||
@@ -50,30 +53,35 @@ const HeaderInRestoring = ({
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!canRestore)
|
||||
return
|
||||
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleSyncWorkflowDraft(true, false, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
onRestoreSettled?.()
|
||||
},
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
const restoreUrl = `/${getFlowPrefix(configsMap.flowType)}/${configsMap.flowId}/workflows/${currentVersion.id}/restore`
|
||||
|
||||
try {
|
||||
await restoreWorkflow(restoreUrl)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleRefreshWorkflowDraft()
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}
|
||||
catch {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
onRestoreSettled?.()
|
||||
}
|
||||
}, [canRestore, currentVersion?.id, configsMap, setShowWorkflowVersionHistoryPanel, workflowStore, restoreWorkflow, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -83,7 +91,7 @@ const HeaderInRestoring = ({
|
||||
<div className=" flex items-center justify-end gap-x-2">
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
|
||||
disabled={!canRestore}
|
||||
variant="primary"
|
||||
className={cn(
|
||||
'rounded-lg border border-transparent',
|
||||
|
||||
@@ -22,14 +22,15 @@ export type AvailableNodesMetaData = {
|
||||
nodes: NodeDefault[]
|
||||
nodesMap?: Record<BlockEnum, NodeDefault<any>>
|
||||
}
|
||||
export type SyncDraftCallback = {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
}
|
||||
export type CommonHooksFnMap = {
|
||||
doSyncWorkflowDraft: (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
callback?: SyncDraftCallback,
|
||||
) => Promise<void>
|
||||
syncWorkflowDraftWhenPageClose: () => void
|
||||
handleRefreshWorkflowDraft: () => void
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { SyncDraftCallback } from '../hooks-store'
|
||||
import { useCallback } from 'react'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { useStore } from '../store'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
|
||||
export type SyncCallback = {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
}
|
||||
export type SyncCallback = SyncDraftCallback
|
||||
|
||||
export const useNodesSyncDraft = () => {
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
@@ -18,7 +15,7 @@ export const useNodesSyncDraft = () => {
|
||||
const handleSyncWorkflowDraft = useCallback((
|
||||
sync?: boolean,
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncCallback,
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal file
115
web/app/components/workflow/panel/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { PanelProps } from '../index'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
|
||||
import Panel from '../index'
|
||||
|
||||
const mockVersionHistoryPanel = vi.hoisted(() => vi.fn())
|
||||
|
||||
class MockResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
}
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => (props: { latestVersionId?: string }) => {
|
||||
mockVersionHistoryPanel(props)
|
||||
return <div data-testid="version-history-panel">{props.latestVersionId}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const mod = await import('../../__tests__/reactflow-mock-state')
|
||||
const base = mod.createReactFlowModuleMock()
|
||||
|
||||
return {
|
||||
...base,
|
||||
useStore: vi.fn(selector => selector({
|
||||
getNodes: () => mod.rfState.nodes,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../env-panel', () => ({
|
||||
default: () => <div data-testid="env-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../../nodes', () => ({
|
||||
Panel: ({ id }: { id: string }) => <div data-testid="node-panel">{id}</div>,
|
||||
}))
|
||||
|
||||
const versionHistoryPanelProps = {
|
||||
latestVersionId: 'version-1',
|
||||
restoreVersionUrl: (versionId: string) => `/workflows/${versionId}/restore`,
|
||||
} satisfies NonNullable<PanelProps['versionHistoryPanelProps']>
|
||||
|
||||
describe('Panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetReactFlowMockState()
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Version History Panel', () => {
|
||||
it('should render the version history panel when the panel is open and props are provided', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('version-history-panel')).toHaveTextContent('version-1')
|
||||
expect(mockVersionHistoryPanel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
latestVersionId: 'version-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not render the version history panel when the panel is open but props are missing', () => {
|
||||
renderWorkflowComponent(
|
||||
<Panel />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(mockVersionHistoryPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render the version history panel when the panel is closed', () => {
|
||||
rfState.nodes = [
|
||||
createNode({
|
||||
id: 'selected-node',
|
||||
data: {
|
||||
selected: true,
|
||||
},
|
||||
}),
|
||||
] as typeof rfState.nodes
|
||||
|
||||
renderWorkflowComponent(
|
||||
<Panel versionHistoryPanelProps={versionHistoryPanelProps} />,
|
||||
{
|
||||
initialStoreState: {
|
||||
showWorkflowVersionHistoryPanel: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('version-history-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-panel')).toHaveTextContent('selected-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -140,7 +140,7 @@ const Panel: FC<PanelProps> = ({
|
||||
components?.right
|
||||
}
|
||||
{
|
||||
showWorkflowVersionHistoryPanel && (
|
||||
showWorkflowVersionHistoryPanel && versionHistoryPanelProps && (
|
||||
<VersionHistoryPanel {...versionHistoryPanelProps} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { WorkflowVersion } from '../../../types'
|
||||
import type { Shape } from '../../../store'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
|
||||
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
const mockRestoreWorkflow = vi.fn()
|
||||
const mockSetCurrentVersion = vi.fn()
|
||||
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
|
||||
type MockWorkflowStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: ReturnType<typeof vi.fn>
|
||||
currentVersion: null
|
||||
setCurrentVersion: typeof mockSetCurrentVersion
|
||||
const createVersionHistory = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
|
||||
id: 'version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
graph: { nodes: [], edges: [] },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
|
||||
hash: 'test-hash',
|
||||
updated_at: Date.now() / 1000,
|
||||
updated_by: { id: 'user-1', name: 'User 1', email: 'user-1@example.com' },
|
||||
tool_published: false,
|
||||
environment_variables: [],
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
let mockCurrentVersion: VersionHistory | null = null
|
||||
|
||||
type MockVersionStoreState = Pick<Shape, 'currentVersion' | 'setCurrentVersion' | 'setShowWorkflowVersionHistoryPanel'>
|
||||
type MockRestoreConfirmModalProps = {
|
||||
isOpen: boolean
|
||||
versionInfo: VersionHistory
|
||||
onRestore: (item: VersionHistory) => void
|
||||
}
|
||||
type MockVersionHistoryItemProps = {
|
||||
item: VersionHistory
|
||||
onClick: (item: VersionHistory) => void
|
||||
handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void
|
||||
}
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@@ -19,52 +60,23 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
useResetWorkflowVersionHistory: () => vi.fn(),
|
||||
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
|
||||
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
|
||||
useWorkflowVersionHistory: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
createVersionHistory({
|
||||
id: 'draft-version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
graph: { nodes: [], edges: [], viewport: null },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1' },
|
||||
environment_variables: [],
|
||||
marked_name: '',
|
||||
marked_comment: '',
|
||||
},
|
||||
{
|
||||
}),
|
||||
createVersionHistory({
|
||||
id: 'published-version-id',
|
||||
version: '2024-01-01T00:00:00Z',
|
||||
graph: { nodes: [], edges: [], viewport: null },
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { image: { enabled: false } },
|
||||
},
|
||||
created_at: Date.now() / 1000,
|
||||
created_by: { id: 'user-1', name: 'User 1' },
|
||||
environment_variables: [],
|
||||
marked_name: 'v1.0',
|
||||
marked_comment: 'First release',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -77,7 +89,7 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useDSL: () => ({ handleExportDSL: vi.fn() }),
|
||||
useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn() }),
|
||||
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
|
||||
useWorkflowRun: () => ({
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
@@ -92,10 +104,10 @@ vi.mock('../../../hooks-store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useStore: <T,>(selector: (state: MockWorkflowStoreState) => T) => {
|
||||
const state: MockWorkflowStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: vi.fn(),
|
||||
currentVersion: null,
|
||||
useStore: <T,>(selector: (state: MockVersionStoreState) => T) => {
|
||||
const state: MockVersionStoreState = {
|
||||
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
|
||||
currentVersion: mockCurrentVersion,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}
|
||||
return selector(state)
|
||||
@@ -103,10 +115,10 @@ vi.mock('../../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
deleteAllInspectVars: vi.fn(),
|
||||
setShowWorkflowVersionHistoryPanel: vi.fn(),
|
||||
setShowWorkflowVersionHistoryPanel: mockSetShowWorkflowVersionHistoryPanel,
|
||||
setCurrentVersion: mockSetCurrentVersion,
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
setState: mockWorkflowStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -115,16 +127,54 @@ vi.mock('../delete-confirm-modal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../restore-confirm-modal', () => ({
|
||||
default: () => null,
|
||||
default: (props: MockRestoreConfirmModalProps) => {
|
||||
const MockRestoreConfirmModal = () => {
|
||||
const { isOpen, versionInfo, onRestore } = props
|
||||
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return <button onClick={() => onRestore(versionInfo)}>confirm restore</button>
|
||||
}
|
||||
|
||||
return <MockRestoreConfirmModal />
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../version-history-item', () => ({
|
||||
default: (props: MockVersionHistoryItemProps) => {
|
||||
const MockVersionHistoryItem = () => {
|
||||
const { item, onClick, handleClickMenuItem } = props
|
||||
|
||||
useEffect(() => {
|
||||
if (item.version === WorkflowVersion.Draft)
|
||||
onClick(item)
|
||||
}, [item, onClick])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onClick(item)}>{item.marked_name || item.version}</button>
|
||||
{item.version !== WorkflowVersion.Draft && (
|
||||
<button onClick={() => handleClickMenuItem(VersionHistoryContextMenuOptions.restore)}>
|
||||
{`restore-${item.id}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <MockVersionHistoryItem />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('VersionHistoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentVersion = null
|
||||
})
|
||||
|
||||
describe('Version Click Behavior', () => {
|
||||
@@ -134,10 +184,10 @@ describe('VersionHistoryPanel', () => {
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Draft version auto-clicks on mount via useEffect in VersionHistoryItem
|
||||
expect(mockHandleLoadBackupDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -148,17 +198,72 @@ describe('VersionHistoryPanel', () => {
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Clear mocks after initial render (draft version auto-clicks on mount)
|
||||
vi.clearAllMocks()
|
||||
|
||||
const publishedItem = screen.getByText('v1.0')
|
||||
fireEvent.click(publishedItem)
|
||||
fireEvent.click(screen.getByText('v1.0'))
|
||||
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled()
|
||||
expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should set current version before confirming restore from context menu', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.click(screen.getByText('restore-published-version-id'))
|
||||
fireEvent.click(screen.getByText('confirm restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'published-version-id',
|
||||
}))
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false })
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined })
|
||||
expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep restore mode backup state when restore request fails', async () => {
|
||||
const { VersionHistoryPanel } = await import('../index')
|
||||
mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed'))
|
||||
mockCurrentVersion = createVersionHistory({
|
||||
id: 'draft-version-id',
|
||||
version: WorkflowVersion.Draft,
|
||||
})
|
||||
|
||||
render(
|
||||
<VersionHistoryPanel
|
||||
latestVersionId="published-version-id"
|
||||
restoreVersionUrl={versionId => `/apps/app-1/workflows/${versionId}/restore`}
|
||||
/>,
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
fireEvent.click(screen.getByText('restore-published-version-id'))
|
||||
fireEvent.click(screen.getByText('confirm restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore')
|
||||
})
|
||||
|
||||
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false })
|
||||
expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined })
|
||||
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
|
||||
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,8 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
|
||||
import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks'
|
||||
import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow'
|
||||
import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks'
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useStore, useWorkflowStore } from '../../store'
|
||||
import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types'
|
||||
@@ -27,12 +27,14 @@ const INITIAL_PAGE = 1
|
||||
export type VersionHistoryPanelProps = {
|
||||
getVersionListUrl?: string
|
||||
deleteVersionUrl?: (versionId: string) => string
|
||||
restoreVersionUrl: (versionId: string) => string
|
||||
updateVersionUrl?: (versionId: string) => string
|
||||
latestVersionId?: string
|
||||
}
|
||||
export const VersionHistoryPanel = ({
|
||||
getVersionListUrl,
|
||||
deleteVersionUrl,
|
||||
restoreVersionUrl,
|
||||
updateVersionUrl,
|
||||
latestVersionId,
|
||||
}: VersionHistoryPanelProps) => {
|
||||
@@ -43,8 +45,8 @@ export const VersionHistoryPanel = ({
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { handleExportDSL } = useDSL()
|
||||
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
|
||||
const currentVersion = useStore(s => s.currentVersion)
|
||||
@@ -144,32 +146,33 @@ export const VersionHistoryPanel = ({
|
||||
}, [])
|
||||
|
||||
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
|
||||
const { mutateAsync: restoreWorkflow } = useRestoreWorkflow()
|
||||
|
||||
const handleRestore = useCallback((item: VersionHistory) => {
|
||||
const handleRestore = useCallback(async (item: VersionHistory) => {
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
handleRestoreFromPublishedWorkflow(item)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleSyncWorkflowDraft(true, false, {
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
type: 'success',
|
||||
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
},
|
||||
onError: () => {
|
||||
toast.add({
|
||||
type: 'error',
|
||||
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
resetWorkflowVersionHistory()
|
||||
},
|
||||
})
|
||||
}, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
|
||||
try {
|
||||
await restoreWorkflow(restoreVersionUrl(item.id))
|
||||
setCurrentVersion(item)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleRefreshWorkflowDraft()
|
||||
toast.add({
|
||||
type: 'success',
|
||||
title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }),
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}
|
||||
catch {
|
||||
toast.add({
|
||||
type: 'error',
|
||||
title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
resetWorkflowVersionHistory()
|
||||
}
|
||||
}, [setShowWorkflowVersionHistoryPanel, setCurrentVersion, workflowStore, restoreWorkflow, restoreVersionUrl, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory])
|
||||
|
||||
const { mutateAsync: deleteWorkflow } = useDeleteWorkflow()
|
||||
|
||||
|
||||
@@ -113,6 +113,13 @@ export const useDeleteWorkflow = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useRestoreWorkflow = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'restore'],
|
||||
mutationFn: (url: string) => post<CommonResponse & { updated_at: number, hash: string }>(url, {}, { silent: true }),
|
||||
})
|
||||
}
|
||||
|
||||
export const usePublishWorkflow = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'publish'],
|
||||
|
||||
Reference in New Issue
Block a user