diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx new file mode 100644 index 0000000000..505f46b4d7 --- /dev/null +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.spec.tsx @@ -0,0 +1,1045 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CreateFromDSLModalTab, useDSLImport } from './use-dsl-import' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock service hooks +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() + +vi.mock('@/service/use-pipeline', () => ({ + useImportPipelineDSL: () => ({ + mutateAsync: mockImportDSL, + }), + useImportPipelineDSLConfirm: () => ({ + mutateAsync: mockImportDSLConfirm, + }), +})) + +// Mock plugin dependencies hook +const mockHandleCheckPluginDependencies = vi.fn() + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +// Mock toast context +const mockNotify = vi.fn() + +vi.mock('use-context-selector', async () => { + const actual = await vi.importActual('use-context-selector') + return { + ...actual, + useContext: vi.fn(() => ({ notify: mockNotify })), + } +}) + +// Test data builders +const createImportDSLResponse = (overrides = {}) => ({ + id: 'import-123', + status: 'completed' as const, + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + current_dsl_version: '1.0.0', + imported_dsl_version: '1.0.0', + ...overrides, +}) + +// Helper function to create QueryClient wrapper +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useDSLImport', () => { + beforeEach(() => { + vi.clearAllMocks() + mockImportDSL.mockReset() + mockImportDSLConfirm.mockReset() + mockPush.mockReset() + mockNotify.mockReset() + mockHandleCheckPluginDependencies.mockReset() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_FILE) + expect(result.current.dslUrlValue).toBe('') + expect(result.current.showConfirmModal).toBe(false) + expect(result.current.versions).toBeUndefined() + expect(result.current.buttonDisabled).toBe(true) + expect(result.current.isConfirming).toBe(false) + }) + + it('should use provided activeTab', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL }), + { wrapper: createWrapper() }, + ) + + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_URL) + }) + + it('should use provided dslUrl', () => { + const { result } = renderHook( + () => useDSLImport({ dslUrl: 'https://example.com/test.pipeline' }), + { wrapper: createWrapper() }, + ) + + expect(result.current.dslUrlValue).toBe('https://example.com/test.pipeline') + }) + }) + + describe('setCurrentTab', () => { + it('should update current tab', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.setCurrentTab(CreateFromDSLModalTab.FROM_URL) + }) + + expect(result.current.currentTab).toBe(CreateFromDSLModalTab.FROM_URL) + }) + }) + + describe('setDslUrlValue', () => { + it('should update DSL URL value', () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + act(() => { + result.current.setDslUrlValue('https://new-url.com/pipeline') + }) + + expect(result.current.dslUrlValue).toBe('https://new-url.com/pipeline') + }) + }) + + describe('handleFile', () => { + it('should set file and trigger file reading', async () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + expect(result.current.buttonDisabled).toBe(false) + }) + + it('should clear file when undefined is passed', async () => { + const { result } = renderHook( + () => useDSLImport({}), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['test content'], 'test.pipeline', { type: 'application/octet-stream' }) + + // First set a file + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + + // Then clear it + await act(async () => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.buttonDisabled).toBe(true) + }) + }) + + describe('buttonDisabled', () => { + it('should be true when file tab is active and no file is selected', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(true) + }) + + it('should be false when file tab is active and file is selected', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.buttonDisabled).toBe(false) + }) + + it('should be true when URL tab is active and no URL is entered', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(true) + }) + + it('should be false when URL tab is active and URL is entered', () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_URL, dslUrl: 'https://example.com' }), + { wrapper: createWrapper() }, + ) + + expect(result.current.buttonDisabled).toBe(false) + }) + }) + + describe('handleCreateApp with URL mode', () => { + it('should call importDSL with URL mode', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse()) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) // Wait for debounce + }) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: 'yaml-url', + yaml_url: 'https://example.com/test.pipeline', + }) + }) + + vi.useRealTimers() + }) + + it('should handle successful import with COMPLETED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed' })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-789/pipeline') + }) + + vi.useRealTimers() + }) + + it('should handle import with COMPLETED_WITH_WARNINGS status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'completed-with-warnings' })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'warning', + })) + }) + + vi.useRealTimers() + }) + + it('should handle import with PENDING status and show confirm modal', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'pending', + imported_dsl_version: '0.9.0', + current_dsl_version: '1.0.0', + })) + + const onClose = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onClose, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + }) + + // Wait for setTimeout to show confirm modal + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + expect(result.current.versions).toEqual({ + importedVersion: '0.9.0', + systemVersion: '1.0.0', + }) + + vi.useRealTimers() + }) + + it('should handle API error (null response)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(null) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should handle FAILED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ status: 'failed' })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should check plugin dependencies when pipeline_id is present', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + pipeline_id: 'pipeline-123', + })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipeline-123', true) + }) + + vi.useRealTimers() + }) + + it('should not check plugin dependencies when pipeline_id is null', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + pipeline_id: null, + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled() + }) + + vi.useRealTimers() + }) + + it('should return early when URL tab is active but no URL is provided', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: '', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + describe('handleCreateApp with FILE mode', () => { + it('should call importDSL with file content mode', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse()) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), + { wrapper: createWrapper() }, + ) + + const fileContent = 'test yaml content' + const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + + // Set up file and wait for FileReader to complete + await act(async () => { + result.current.handleFile(mockFile) + // Give FileReader time to process + await new Promise(resolve => setTimeout(resolve, 100)) + }) + + // Trigger create + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: 'yaml-content', + yaml_content: fileContent, + }) + }) + + vi.useRealTimers() + }) + + it('should return early when file tab is active but no file is selected', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_FILE, + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + describe('onDSLConfirm', () => { + it('should call importDSLConfirm and handle success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + // First, trigger pending status to get importId + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const onSuccess = vi.fn() + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + onSuccess, + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Wait for confirm modal to show + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-123') + expect(onSuccess).toHaveBeenCalled() + expect(result.current.showConfirmModal).toBe(false) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + vi.useRealTimers() + }) + + it('should handle confirm API error', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue(null) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should handle confirm with FAILED status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'failed', + pipeline_id: 'pipeline-456', + dataset_id: 'dataset-789', + }) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + vi.useRealTimers() + }) + + it('should return early when importId is not set', async () => { + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Call onDSLConfirm without triggering pending status + await act(async () => { + result.current.onDSLConfirm() + }) + + expect(mockImportDSLConfirm).not.toHaveBeenCalled() + }) + + it('should check plugin dependencies on confirm success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-789', + dataset_id: 'dataset-789', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipeline-789', true) + }) + + vi.useRealTimers() + }) + + it('should set isConfirming during confirm process', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + let resolveConfirm: (value: unknown) => void + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockImplementation(() => new Promise((resolve) => { + resolveConfirm = resolve + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.isConfirming).toBe(false) + + // Start confirm + let confirmPromise: Promise + act(() => { + confirmPromise = result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(result.current.isConfirming).toBe(true) + }) + + // Resolve confirm + await act(async () => { + resolveConfirm!({ + status: 'completed', + pipeline_id: 'pipeline-789', + dataset_id: 'dataset-789', + }) + }) + + await confirmPromise! + + expect(result.current.isConfirming).toBe(false) + + vi.useRealTimers() + }) + }) + + describe('handleCancelConfirm', () => { + it('should close confirm modal', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status to show confirm modal + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + expect(result.current.showConfirmModal).toBe(true) + + // Cancel confirm + act(() => { + result.current.handleCancelConfirm() + }) + + expect(result.current.showConfirmModal).toBe(false) + + vi.useRealTimers() + }) + }) + + describe('duplicate submission prevention', () => { + it('should prevent duplicate submissions while creating', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + let resolveImport: (value: unknown) => void + mockImportDSL.mockImplementation(() => new Promise((resolve) => { + resolveImport = resolve + })) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // First call + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Second call should be ignored + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Third call should be ignored + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + // Only one call should be made + expect(mockImportDSL).toHaveBeenCalledTimes(1) + + // Resolve the first call + await act(async () => { + resolveImport!(createImportDSLResponse()) + }) + + vi.useRealTimers() + }) + }) + + describe('file reading', () => { + it('should read file content using FileReader', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const fileContent = 'yaml content here' + const mockFile = new File([fileContent], 'test.pipeline', { type: 'application/octet-stream' }) + + await act(async () => { + result.current.handleFile(mockFile) + }) + + expect(result.current.currentFile).toBe(mockFile) + }) + + it('should clear file content when file is removed', async () => { + const { result } = renderHook( + () => useDSLImport({ activeTab: CreateFromDSLModalTab.FROM_FILE }), + { wrapper: createWrapper() }, + ) + + const mockFile = new File(['content'], 'test.pipeline', { type: 'application/octet-stream' }) + + // Set file + await act(async () => { + result.current.handleFile(mockFile) + }) + + // Clear file + await act(async () => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + }) + }) + + describe('navigation after import', () => { + it('should navigate to pipeline page after successful import', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + status: 'completed', + dataset_id: 'test-dataset-id', + })) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/pipeline') + }) + + vi.useRealTimers() + }) + + it('should navigate to pipeline page after confirm success', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue(createImportDSLResponse({ + id: 'import-123', + status: 'pending', + })) + + mockImportDSLConfirm.mockResolvedValue({ + status: 'completed', + pipeline_id: 'pipeline-456', + dataset_id: 'confirm-dataset-id', + }) + + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + + const { result } = renderHook( + () => useDSLImport({ + activeTab: CreateFromDSLModalTab.FROM_URL, + dslUrl: 'https://example.com/test.pipeline', + }), + { wrapper: createWrapper() }, + ) + + // Trigger pending status + await act(async () => { + result.current.handleCreateApp() + vi.advanceTimersByTime(400) + }) + + await act(async () => { + vi.advanceTimersByTime(400) + }) + + // Call onDSLConfirm + await act(async () => { + result.current.onDSLConfirm() + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('datasets/confirm-dataset-id/pipeline') + }) + + vi.useRealTimers() + }) + }) + + describe('enum export', () => { + it('should export CreateFromDSLModalTab enum with correct values', () => { + expect(CreateFromDSLModalTab.FROM_FILE).toBe('from-file') + expect(CreateFromDSLModalTab.FROM_URL).toBe('from-url') + }) + }) +}) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts new file mode 100644 index 0000000000..aafa8ebed1 --- /dev/null +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -0,0 +1,218 @@ +'use client' +import { useDebounceFn } from 'ahooks' +import { useRouter } from 'next/navigation' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' +import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' + +export enum CreateFromDSLModalTab { + FROM_FILE = 'from-file', + FROM_URL = 'from-url', +} + +export type UseDSLImportOptions = { + activeTab?: CreateFromDSLModalTab + dslUrl?: string + onSuccess?: () => void + onClose?: () => void +} + +export type DSLVersions = { + importedVersion: string + systemVersion: string +} + +export const useDSLImport = ({ + activeTab = CreateFromDSLModalTab.FROM_FILE, + dslUrl = '', + onSuccess, + onClose, +}: UseDSLImportOptions) => { + const { push } = useRouter() + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + + const [currentFile, setDSLFile] = useState() + const [fileContent, setFileContent] = useState() + const [currentTab, setCurrentTab] = useState(activeTab) + const [dslUrlValue, setDslUrlValue] = useState(dslUrl) + const [showConfirmModal, setShowConfirmModal] = useState(false) + const [versions, setVersions] = useState() + const [importId, setImportId] = useState() + const [isConfirming, setIsConfirming] = useState(false) + + const { handleCheckPluginDependencies } = usePluginDependencies() + const isCreatingRef = useRef(false) + + const { mutateAsync: importDSL } = useImportPipelineDSL() + const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + + const readFile = useCallback((file: File) => { + const reader = new FileReader() + reader.onload = (event) => { + const content = event.target?.result + setFileContent(content as string) + } + reader.readAsText(file) + }, []) + + const handleFile = useCallback((file?: File) => { + setDSLFile(file) + if (file) + readFile(file) + if (!file) + setFileContent('') + }, [readFile]) + + const onCreate = useCallback(async () => { + if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) + return + if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) + return + if (isCreatingRef.current) + return + + isCreatingRef.current = true + + let response + if (currentTab === CreateFromDSLModalTab.FROM_FILE) { + response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent || '', + }) + } + if (currentTab === CreateFromDSLModalTab.FROM_URL) { + response = await importDSL({ + mode: DSLImportMode.YAML_URL, + yaml_url: dslUrlValue || '', + }) + } + + if (!response) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + isCreatingRef.current = false + return + } + + const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response + + if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + onSuccess?.() + onClose?.() + + notify({ + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), + children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), + }) + + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + + push(`/datasets/${dataset_id}/pipeline`) + isCreatingRef.current = false + } + else if (status === DSLImportStatus.PENDING) { + setVersions({ + importedVersion: imported_dsl_version ?? '', + systemVersion: current_dsl_version ?? '', + }) + onClose?.() + setTimeout(() => { + setShowConfirmModal(true) + }, 300) + setImportId(id) + isCreatingRef.current = false + } + else { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + isCreatingRef.current = false + } + }, [ + currentTab, + currentFile, + dslUrlValue, + fileContent, + importDSL, + notify, + t, + onSuccess, + onClose, + handleCheckPluginDependencies, + push, + ]) + + const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) + + const onDSLConfirm = useCallback(async () => { + if (!importId) + return + + setIsConfirming(true) + const response = await importDSLConfirm(importId) + setIsConfirming(false) + + if (!response) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + return + } + + const { status, pipeline_id, dataset_id } = response + + if (status === DSLImportStatus.COMPLETED) { + onSuccess?.() + setShowConfirmModal(false) + + notify({ + type: 'success', + message: t('creation.successTip', { ns: 'datasetPipeline' }), + }) + + if (pipeline_id) + await handleCheckPluginDependencies(pipeline_id, true) + + push(`datasets/${dataset_id}/pipeline`) + } + else if (status === DSLImportStatus.FAILED) { + notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) + } + }, [importId, importDSLConfirm, notify, t, onSuccess, handleCheckPluginDependencies, push]) + + const handleCancelConfirm = useCallback(() => { + setShowConfirmModal(false) + }, []) + + const buttonDisabled = useMemo(() => { + if (currentTab === CreateFromDSLModalTab.FROM_FILE) + return !currentFile + if (currentTab === CreateFromDSLModalTab.FROM_URL) + return !dslUrlValue + return false + }, [currentTab, currentFile, dslUrlValue]) + + return { + // State + currentFile, + currentTab, + dslUrlValue, + showConfirmModal, + versions, + buttonDisabled, + isConfirming, + + // Actions + setCurrentTab, + setDslUrlValue, + handleFile, + handleCreateApp, + onDSLConfirm, + handleCancelConfirm, + } +} diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx index 2d187010b8..079ea90687 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx @@ -1,24 +1,18 @@ 'use client' -import { useDebounceFn, useKeyPress } from 'ahooks' +import { useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' -import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { ToastContext } from '@/app/components/base/toast' -import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' -import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' +import DSLConfirmModal from './dsl-confirm-modal' import Header from './header' +import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import' import Tab from './tab' import Uploader from './uploader' +export { CreateFromDSLModalTab } + type CreateFromDSLModalProps = { show: boolean onSuccess?: () => void @@ -27,11 +21,6 @@ type CreateFromDSLModalProps = { dslUrl?: string } -export enum CreateFromDSLModalTab { - FROM_FILE = 'from-file', - FROM_URL = 'from-url', -} - const CreateFromDSLModal = ({ show, onSuccess, @@ -39,149 +28,33 @@ const CreateFromDSLModal = ({ activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', }: CreateFromDSLModalProps) => { - const { push } = useRouter() const { t } = useTranslation() - const { notify } = useContext(ToastContext) - const [currentFile, setDSLFile] = useState() - const [fileContent, setFileContent] = useState() - const [currentTab, setCurrentTab] = useState(activeTab) - const [dslUrlValue, setDslUrlValue] = useState(dslUrl) - const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() - const [importId, setImportId] = useState() - const { handleCheckPluginDependencies } = usePluginDependencies() - const readFile = (file: File) => { - const reader = new FileReader() - reader.onload = function (event) { - const content = event.target?.result - setFileContent(content as string) - } - reader.readAsText(file) - } - - const handleFile = (file?: File) => { - setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - } - - const isCreatingRef = useRef(false) - - const { mutateAsync: importDSL } = useImportPipelineDSL() - - const onCreate = async () => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) - return - if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) - return - if (isCreatingRef.current) - return - isCreatingRef.current = true - let response - if (currentTab === CreateFromDSLModalTab.FROM_FILE) { - response = await importDSL({ - mode: DSLImportMode.YAML_CONTENT, - yaml_content: fileContent || '', - }) - } - if (currentTab === CreateFromDSLModalTab.FROM_URL) { - response = await importDSL({ - mode: DSLImportMode.YAML_URL, - yaml_url: dslUrlValue || '', - }) - } - - if (!response) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - isCreatingRef.current = false - return - } - const { id, status, pipeline_id, dataset_id, imported_dsl_version, current_dsl_version } = response - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - if (onSuccess) - onSuccess() - if (onClose) - onClose() - - notify({ - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - message: t(status === DSLImportStatus.COMPLETED ? 'creation.successTip' : 'creation.caution', { ns: 'datasetPipeline' }), - children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`/datasets/${dataset_id}/pipeline`) - isCreatingRef.current = false - } - else if (status === DSLImportStatus.PENDING) { - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - if (onClose) - onClose() - setTimeout(() => { - setShowErrorModal(true) - }, 300) - setImportId(id) - isCreatingRef.current = false - } - else { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - isCreatingRef.current = false - } - } - - const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 }) - - useKeyPress('esc', () => { - if (show && !showErrorModal) - onClose() + const { + currentFile, + currentTab, + dslUrlValue, + showConfirmModal, + versions, + buttonDisabled, + isConfirming, + setCurrentTab, + setDslUrlValue, + handleFile, + handleCreateApp, + onDSLConfirm, + handleCancelConfirm, + } = useDSLImport({ + activeTab, + dslUrl, + onSuccess, + onClose, }) - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() - - const onDSLConfirm = async () => { - if (!importId) - return - const response = await importDSLConfirm(importId) - - if (!response) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - return - } - - const { status, pipeline_id, dataset_id } = response - - if (status === DSLImportStatus.COMPLETED) { - if (onSuccess) - onSuccess() - if (onClose) - onClose() - - notify({ - type: 'success', - message: t('creation.successTip', { ns: 'datasetPipeline' }), - }) - if (pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - push(`datasets/${dataset_id}/pipeline`) - } - else if (status === DSLImportStatus.FAILED) { - notify({ type: 'error', message: t('creation.errorTip', { ns: 'datasetPipeline' }) }) - } - } - - const buttonDisabled = useMemo(() => { - if (currentTab === CreateFromDSLModalTab.FROM_FILE) - return !currentFile - if (currentTab === CreateFromDSLModalTab.FROM_URL) - return !dslUrlValue - return false - }, [currentTab, currentFile, dslUrlValue]) + useKeyPress('esc', () => { + if (show && !showConfirmModal) + onClose() + }) return ( <> @@ -196,29 +69,25 @@ const CreateFromDSLModal = ({ setCurrentTab={setCurrentTab} />
- { - currentTab === CreateFromDSLModalTab.FROM_FILE && ( - - ) - } - { - currentTab === CreateFromDSLModalTab.FROM_URL && ( -
-
- DSL URL -
- setDslUrlValue(e.target.value)} - /> + {currentTab === CreateFromDSLModalTab.FROM_FILE && ( + + )} + {currentTab === CreateFromDSLModalTab.FROM_URL && ( +
+
+ DSL URL
- ) - } + setDslUrlValue(e.target.value)} + /> +
+ )}
- setShowErrorModal(false)} - className="w-[480px]" - > -
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - {versions?.importedVersion} -
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - {versions?.systemVersion} -
-
-
-
- - -
-
+ {showConfirmModal && ( + + )} ) }