From a96973a5da866ac7de4c0ebf3725bb271314a36b Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 10:12:03 +0100 Subject: [PATCH 1/3] feat(#4871): add showYAMLImportModal state to UIStore Add showYAMLImportModal boolean to UIStore with openYAMLImportModal and closeYAMLImportModal commands. useShowYAMLImportModal hook for reading state; both commands exposed via useUICommands. closeYAMLImportModal also clears importPanel state so the modal opens clean every time. --- assets/js/collaborative-editor/hooks/useUI.ts | 13 +++++++++++++ .../stores/createUIStore.ts | 18 ++++++++++++++++++ assets/js/collaborative-editor/types/ui.ts | 9 +++++++++ .../__helpers__/storeMocks.ts | 3 +++ 4 files changed, 43 insertions(+) diff --git a/assets/js/collaborative-editor/hooks/useUI.ts b/assets/js/collaborative-editor/hooks/useUI.ts index d381cbef40..1a2efb5470 100644 --- a/assets/js/collaborative-editor/hooks/useUI.ts +++ b/assets/js/collaborative-editor/hooks/useUI.ts @@ -61,6 +61,8 @@ export const useUICommands = () => { selectTemplate: uiStore.selectTemplate, setTemplateSearchQuery: uiStore.setTemplateSearchQuery, dismissLandingScreen: uiStore.dismissLandingScreen, + openYAMLImportModal: uiStore.openYAMLImportModal, + closeYAMLImportModal: uiStore.closeYAMLImportModal, }; }; @@ -141,6 +143,17 @@ export const useShowLandingScreen = (): boolean => { return useSyncExternalStore(uiStore.subscribe, selectShowLandingScreen); }; +/** + * Hook to check if the YAML import modal is open + */ +export const useShowYAMLImportModal = (): boolean => { + const uiStore = useUIStore(); + const selectShowYAMLImportModal = uiStore.withSelector( + state => state.showYAMLImportModal + ); + return useSyncExternalStore(uiStore.subscribe, selectShowYAMLImportModal); +}; + /** * Hook to get the entire template panel state * Returns properly typed state - no type assertions needed diff --git a/assets/js/collaborative-editor/stores/createUIStore.ts b/assets/js/collaborative-editor/stores/createUIStore.ts index 2c6d653f15..d569f06a13 100644 --- a/assets/js/collaborative-editor/stores/createUIStore.ts +++ b/assets/js/collaborative-editor/stores/createUIStore.ts @@ -119,6 +119,7 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { aiAssistantInitialMessage: null, createWorkflowPanelCollapsed, showLandingScreen: isNewWorkflow, + showYAMLImportModal: false, templatePanel: { templates: [], loading: false, @@ -330,6 +331,21 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { notify('dismissLandingScreen'); }; + const openYAMLImportModal = () => { + state = produce(state, draft => { + draft.showYAMLImportModal = true; + }); + notify('openYAMLImportModal'); + }; + + const closeYAMLImportModal = () => { + state = produce(state, draft => { + draft.showYAMLImportModal = false; + draft.importPanel = { yamlContent: '', importState: 'initial' }; + }); + notify('closeYAMLImportModal'); + }; + devtools.connect(); // =========================================================================== @@ -364,6 +380,8 @@ export const createUIStore = (isNewWorkflow: boolean = false): UIStore => { setImportState, clearImportPanel, dismissLandingScreen, + openYAMLImportModal, + closeYAMLImportModal, }; }; diff --git a/assets/js/collaborative-editor/types/ui.ts b/assets/js/collaborative-editor/types/ui.ts index 7e4aa91b8c..e321577053 100644 --- a/assets/js/collaborative-editor/types/ui.ts +++ b/assets/js/collaborative-editor/types/ui.ts @@ -47,6 +47,9 @@ export interface UIState { /** Whether the landing screen overlay is visible (only true at /new before a path is committed) */ showLandingScreen: boolean; + /** Whether the YAML import modal is open */ + showYAMLImportModal: boolean; + /** Template panel state */ templatePanel: { templates: WorkflowTemplate[]; @@ -106,6 +109,12 @@ export interface UICommands { /** Dismiss the landing screen — called by downstream issues when a path is committed */ dismissLandingScreen: () => void; + /** Open the YAML import modal */ + openYAMLImportModal: () => void; + + /** Close the YAML import modal and reset import panel content */ + closeYAMLImportModal: () => void; + /** Set templates list */ setTemplates: (templates: WorkflowTemplate[]) => void; diff --git a/assets/test/collaborative-editor/__helpers__/storeMocks.ts b/assets/test/collaborative-editor/__helpers__/storeMocks.ts index efa2351d40..e87883030f 100644 --- a/assets/test/collaborative-editor/__helpers__/storeMocks.ts +++ b/assets/test/collaborative-editor/__helpers__/storeMocks.ts @@ -100,6 +100,7 @@ export const defaultUIState: UIState = { importState: 'initial', }, showLandingScreen: false, + showYAMLImportModal: false, }; // ============================================================================= @@ -360,6 +361,8 @@ export function createMockUIStore( }; }), dismissLandingScreen: vi.fn(), + openYAMLImportModal: vi.fn(), + closeYAMLImportModal: vi.fn(), }; return { From e4d16d19b5ce436567fb1ce2a3fac07097e39e0d Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 10:12:47 +0100 Subject: [PATCH 2/3] feat(#4871): add YAML import modal and wire into landing screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAMLImportModal is a self-contained Headless UI Dialog — focus trapping, Escape key, and aria-modal included. LandingScreenWrapper wires onImportYAML and renders the modal as a sibling to LandingScreen so it opens on top without dismissing the landing screen. BreadcrumbContent refactored to read isNewWorkflow from store directly and return null itself, removing the conditional render from the parent. Cmd/Ctrl+\ opens the modal when the landing screen is active. Old left-panel import path (handleImport, handleSaveAndClose) removed. --- .../CollaborativeEditor.tsx | 35 +- .../components/WorkflowEditor.tsx | 59 +--- .../components/YAMLImportModal.tsx | 256 ++++++++++++++ .../yaml-import/YAMLImportModal.test.tsx | 329 ++++++++++++++++++ 4 files changed, 612 insertions(+), 67 deletions(-) create mode 100644 assets/js/collaborative-editor/components/YAMLImportModal.tsx create mode 100644 assets/test/collaborative-editor/components/yaml-import/YAMLImportModal.test.tsx diff --git a/assets/js/collaborative-editor/CollaborativeEditor.tsx b/assets/js/collaborative-editor/CollaborativeEditor.tsx index 355ad515da..1d878d6977 100644 --- a/assets/js/collaborative-editor/CollaborativeEditor.tsx +++ b/assets/js/collaborative-editor/CollaborativeEditor.tsx @@ -16,6 +16,7 @@ import { Toaster } from './components/ui/Toaster'; import { VersionDebugLogger } from './components/VersionDebugLogger'; import { VersionDropdown } from './components/VersionDropdown'; import { WorkflowEditor } from './components/WorkflowEditor'; +import { YAMLImportModal } from './components/YAMLImportModal'; import { CredentialModalProvider } from './contexts/CredentialModalContext'; import { LiveViewActionsProvider } from './contexts/LiveViewActionsContext'; import { MonacoRefProvider } from './contexts/MonacoRefContext'; @@ -26,7 +27,11 @@ import { useLatestSnapshotLockVersion, useProject, } from './hooks/useSessionContext'; -import { useIsRunPanelOpen, useShowLandingScreen } from './hooks/useUI'; +import { + useIsRunPanelOpen, + useShowLandingScreen, + useUICommands, +} from './hooks/useUI'; import { useVersionSelect } from './hooks/useVersionSelect'; import { useWorkflowState } from './hooks/useWorkflow'; import { KeyboardProvider } from './keyboard'; @@ -85,14 +90,12 @@ function BreadcrumbContent({ }: BreadcrumbContentProps) { const isNewWorkflow = useIsNewWorkflow(); const projectFromStore = useProject(); - const workflowFromStore = useWorkflowState(state => state.workflow); const latestSnapshotLockVersion = useLatestSnapshotLockVersion(); - const isRunPanelOpen = useIsRunPanelOpen(); - const { params } = useURLState(); const isIDEOpen = params['panel'] === 'editor'; + const handleVersionSelect = useVersionSelect(); const projectId = projectFromStore?.id ?? projectIdFallback; const projectName = projectFromStore?.name ?? projectNameFallback; @@ -102,8 +105,6 @@ function BreadcrumbContent({ const isSandbox = projectIsSandboxFallback === 'true'; const currentWorkflowName = workflowFromStore?.name ?? workflowName; - const handleVersionSelect = useVersionSelect(); - const breadcrumbElements = useMemo(() => { return [ // Project name as picker trigger @@ -182,16 +183,22 @@ function LandingScreenWrapper({ aiAssistantEnabled: boolean; }) { const showLandingScreen = useShowLandingScreen(); + const { openYAMLImportModal } = useUICommands(); + if (!showLandingScreen) return null; - // TODO-AI-FIRST Stubs — wired up in Issues #4857 (Build with AI), #4858 (Browse Templates), #4859 (Import YAML) + return ( - {}} - onBuildFromScratch={() => {}} - onBrowseTemplates={() => {}} - onImportYAML={() => {}} - /> + <> + {/* TODO-AI-FIRST Stubs — wired up in Issues #4857 (Build with AI), #4858 (Browse Templates) */} + {}} + onBuildFromScratch={() => {}} + onBrowseTemplates={() => {}} + onImportYAML={openYAMLImportModal} + /> + + ); } diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index b6f5db3e0b..a08e97868f 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -8,7 +8,6 @@ import { useURLState } from '#/react/lib/use-url-state'; import { cn } from '#/utils/cn'; import { Tooltip } from '../../components/Tooltip'; -import type { WorkflowState as YAMLWorkflowState } from '../../yaml/types'; import { useResizablePanel } from '../hooks/useResizablePanel'; import { useIsNewWorkflow, useProject } from '../hooks/useSessionContext'; import { @@ -16,7 +15,6 @@ import { useRunPanelContext, useTemplatePanel, useUICommands, - //useIsCreateWorkflowPanelCollapsed, // TODO-AI-FIRST clean up useIsAIAssistantPanelOpen, useShowLandingScreen, } from '../hooks/useUI'; @@ -30,7 +28,6 @@ import { useKeyboardShortcut } from '../keyboard'; import { Z_INDEX } from '../utils/constants'; import { CollaborativeWorkflowDiagram } from './diagram/CollaborativeWorkflowDiagram'; -import flowEvents from './diagram/react-flow-events'; import { FullScreenIDE } from './ide/FullScreenIDE'; import { Inspector } from './inspector'; import { LeftPanel } from './left-panel'; @@ -65,6 +62,7 @@ export function WorkflowEditor({ collapseCreateWorkflowPanel, expandCreateWorkflowPanel, setTemplateSearchQuery, + openYAMLImportModal, } = useUICommands(); // TODO-AI-FIRST: remove in #4856 const isCreateWorkflowPanelCollapsed = true; @@ -404,36 +402,6 @@ export function WorkflowEditor({ updateSearchParams({ method, template: null, search: null }); }; - /** - * Imports a workflow state into the canvas. - * - * This function validates the workflow name to ensure uniqueness, then imports - * the workflow. If validation fails, it still imports the workflow (the server - * will handle name conflicts on save). - * - * Note: This is intentionally synchronous from the caller's perspective. - * The async validation happens in the background, but import proceeds - * immediately after validation completes or fails. - */ - const handleImport = useCallback( - (workflowState: YAMLWorkflowState) => { - void workflowStore - .importWorkflow(workflowState) - .then(() => { - flowEvents.dispatch('fit-view'); - }) - .catch((error: unknown) => { - console.error('Failed to import workflow:', error); - }); - }, - [workflowStore] - ); - - const handleSaveAndClose = async () => { - await saveWorkflow(); - collapseCreateWorkflowPanel(); - }; - /** * Keyboard shortcuts for new workflow creation panels. * @@ -472,31 +440,18 @@ export function WorkflowEditor({ } ); - // Cmd/Ctrl+\ to toggle import panel (see JSDoc above for full shortcut docs) + // Cmd/Ctrl+\ to open YAML import modal when on landing screen (see JSDoc above for full shortcut docs) useKeyboardShortcut( 'Control+\\, Meta+\\', () => { if (!isNewWorkflow) return; - - if (leftPanelMethod === 'import' && !isCreateWorkflowPanelCollapsed) { - // Already open in import mode - collapse it - collapseCreateWorkflowPanel(); - } else { - // Close AI Assistant panel when expanding create panel - if (isAIAssistantPanelOpen) { - closeAIAssistantPanel(); - } - // Open in import mode - handleMethodChange('import'); - if (isCreateWorkflowPanelCollapsed) { - expandCreateWorkflowPanel(); - } + if (showLandingScreen) { + openYAMLImportModal(); } + // left-panel path removed — see #4876 }, 0, - { - enabled: isNewWorkflow, - } + { enabled: isNewWorkflow } ); useKeyboardShortcut( @@ -532,8 +487,6 @@ export function WorkflowEditor({ + + + {errors.length > 0 && ( +
+ +
+ )} + + {/* Content area */} +
+ {mode === 'upload' ? ( + + ) : ( + + )} +
+ + {/* Footer */} +
+ + + + + + +
+ + ); +} diff --git a/assets/test/collaborative-editor/components/yaml-import/YAMLImportModal.test.tsx b/assets/test/collaborative-editor/components/yaml-import/YAMLImportModal.test.tsx new file mode 100644 index 0000000000..57e408d2c2 --- /dev/null +++ b/assets/test/collaborative-editor/components/yaml-import/YAMLImportModal.test.tsx @@ -0,0 +1,329 @@ +/** + * YAMLImportModal Component Tests + * + * Covers modal-specific behaviour: visibility gating, Cancel/Escape close, + * successful import flow, state machine, mode toggling, and debounced validation. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +import { YAMLImportModal } from '../../../../js/collaborative-editor/components/YAMLImportModal'; +import { StoreContext } from '../../../../js/collaborative-editor/contexts/StoreProvider'; +import { useKeyboardShortcut } from '../../../../js/collaborative-editor/keyboard'; +import { createMockStoreContextValue } from '../../__helpers__'; + +vi.mock('../../../../js/collaborative-editor/hooks/useAwareness', () => ({ + useAwareness: () => [], +})); + +const mockImportWorkflow = vi.fn().mockResolvedValue(undefined); +const mockSaveWorkflow = vi.fn().mockResolvedValue(undefined); + +vi.mock('../../../../js/collaborative-editor/hooks/useWorkflow', () => ({ + useWorkflowActions: () => ({ + importWorkflow: mockImportWorkflow, + saveWorkflow: mockSaveWorkflow, + }), +})); + +const mockCloseYAMLImportModal = vi.fn(); +const mockDismissLandingScreen = vi.fn(); + +let mockIsOpen = true; + +vi.mock('../../../../js/collaborative-editor/hooks/useUI', () => ({ + useUICommands: () => ({ + closeYAMLImportModal: mockCloseYAMLImportModal, + dismissLandingScreen: mockDismissLandingScreen, + collapseCreateWorkflowPanel: vi.fn(), + expandCreateWorkflowPanel: vi.fn(), + toggleCreateWorkflowPanel: vi.fn(), + openRunPanel: vi.fn(), + closeRunPanel: vi.fn(), + openAIAssistantPanel: vi.fn(), + closeAIAssistantPanel: vi.fn(), + toggleAIAssistantPanel: vi.fn(), + openGitHubSyncModal: vi.fn(), + closeGitHubSyncModal: vi.fn(), + selectTemplate: vi.fn(), + setTemplateSearchQuery: vi.fn(), + openYAMLImportModal: vi.fn(), + }), + useShowYAMLImportModal: () => mockIsOpen, +})); + +vi.mock('../../../../js/collaborative-editor/keyboard', () => ({ + useKeyboardShortcut: vi.fn(), +})); + +const validYAML = ` +name: Test Workflow +jobs: + test-job: + name: Test Job + adaptor: '@openfn/language-http@latest' + body: | + get('/api/data') +triggers: + webhook: + type: webhook + enabled: true +edges: + webhook->test-job: + source_trigger: webhook + target_job: test-job + condition_type: always + enabled: true +`; + +function renderModal() { + const mockStore = createMockStoreContextValue(); + return render( + + + + ); +} + +describe('YAMLImportModal', () => { + beforeEach(() => { + mockIsOpen = true; + vi.clearAllMocks(); + mockImportWorkflow.mockResolvedValue(undefined); + mockSaveWorkflow.mockResolvedValue(undefined); + }); + + describe('Modal visibility', () => { + test('renders dialog content when open', () => { + renderModal(); + + expect(screen.getByText(/Import a workflow/i)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Create/i }) + ).toBeInTheDocument(); + }); + + test('Create button is disabled initially', () => { + renderModal(); + + expect(screen.getByRole('button', { name: /Create/i })).toBeDisabled(); + }); + + test('shows Upload/Paste toggle in upload mode by default', () => { + renderModal(); + + expect( + screen.getByRole('button', { name: /Paste text/i }) + ).toBeInTheDocument(); + expect( + screen.getByText(/Upload or drop a YAML file/i) + ).toBeInTheDocument(); + }); + + test('renders nothing meaningful when closed', () => { + mockIsOpen = false; + renderModal(); + + expect(screen.queryByText(/Import a workflow/i)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Cancel/i }) + ).not.toBeInTheDocument(); + }); + }); + + describe('Cancel closes modal', () => { + test('clicking Cancel calls closeYAMLImportModal', () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + + expect(mockCloseYAMLImportModal).toHaveBeenCalledOnce(); + }); + }); + + describe('Escape closes modal', () => { + test('registers Escape shortcut with closeYAMLImportModal when open', () => { + renderModal(); + + expect(useKeyboardShortcut).toHaveBeenCalledWith( + 'Escape', + mockCloseYAMLImportModal, + 100, + { enabled: true } + ); + }); + + test('registers Escape shortcut as disabled when closed', () => { + mockIsOpen = false; + renderModal(); + + expect(useKeyboardShortcut).toHaveBeenCalledWith( + 'Escape', + mockCloseYAMLImportModal, + 100, + { enabled: false } + ); + }); + }); + + describe('Successful import', () => { + test('calls importWorkflow, saveWorkflow, then dismissLandingScreen on Create', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + fireEvent.change(textarea, { target: { value: validYAML } }); + + await waitFor( + () => + expect( + screen.getByRole('button', { name: /Create/i }) + ).not.toBeDisabled(), + { timeout: 500 } + ); + + fireEvent.click(screen.getByRole('button', { name: /Create/i })); + + await waitFor(() => { + expect(mockImportWorkflow).toHaveBeenCalledOnce(); + expect(mockSaveWorkflow).toHaveBeenCalledWith({ silent: true }); + expect(mockDismissLandingScreen).toHaveBeenCalledOnce(); + expect(mockCloseYAMLImportModal).not.toHaveBeenCalled(); + }); + }); + }); + + describe('State machine (representative)', () => { + test('transitions to valid state after successful YAML validation', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + fireEvent.change(textarea, { target: { value: validYAML } }); + + await waitFor( + () => + expect( + screen.getByRole('button', { name: /Create/i }) + ).not.toBeDisabled(), + { timeout: 500 } + ); + }); + + test('keeps Create disabled for invalid YAML', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + fireEvent.change(textarea, { target: { value: 'invalid: [syntax' } }); + + await waitFor( + () => + expect( + screen.getByRole('button', { name: /Create/i }) + ).toBeDisabled(), + { timeout: 600 } + ); + }); + + test('shows button states during validation (Validating... text then enabled)', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + + const createButton = screen.getByRole('button', { name: /Create/i }); + expect(createButton).toBeDisabled(); + + fireEvent.change(textarea, { target: { value: validYAML } }); + + await waitFor( + () => { + expect( + screen.getByRole('button', { name: /Create/i }) + ).not.toBeDisabled(); + }, + { timeout: 600 } + ); + }); + }); + + describe('Mode toggling', () => { + test('switches to paste mode then back to upload mode', () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + + expect( + screen.queryByText(/Upload or drop a YAML file/i) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Upload a file/i }) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Upload a file/i })); + + expect( + screen.getByText(/Upload or drop a YAML file/i) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Paste text/i }) + ).toBeInTheDocument(); + }); + }); + + describe('Debounced validation', () => { + test('does not validate immediately on input (< 100ms)', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + fireEvent.change(textarea, { target: { value: 'name:' } }); + + await waitFor( + () => { + expect( + screen.queryByText(/Validation Error/i) + ).not.toBeInTheDocument(); + }, + { timeout: 100 } + ); + }); + + test('validates after 300ms delay', async () => { + renderModal(); + + fireEvent.click(screen.getByRole('button', { name: /Paste text/i })); + const textarea = screen.getByPlaceholderText( + /Paste your YAML content here/i + ); + const createButton = screen.getByRole('button', { name: /Create/i }); + + expect(createButton).toBeDisabled(); + + fireEvent.change(textarea, { target: { value: 'invalid: [syntax' } }); + + await waitFor( + () => { + expect(createButton).toBeDisabled(); + }, + { timeout: 500, interval: 50 } + ); + }); + }); +}); From be5870352195f6c9eab2c2498815168579b73378 Mon Sep 17 00:00:00 2001 From: Lucy Macartney Date: Thu, 25 Jun 2026 10:13:58 +0100 Subject: [PATCH 3/3] chore(#4871): remove YAMLImportPanel, transfer tests, polish dropzone UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAMLImportPanel deleted — the modal owns all YAML import content now. Test coverage transferred to YAMLImportModal.test.tsx (mode toggling, debounced validation, state machine, successful import flow). LeftPanel no longer handles the import method case. Dropzone height controlled via h-80 container in the modal so both dropzone and code editor share one source of truth. Icon box styled with gray background. Error display only renders when errors are present. --- .../components/left-panel/YAMLImportPanel.tsx | 280 --------------- .../components/left-panel/index.tsx | 28 +- .../yaml-import/YAMLFileDropzone.tsx | 34 +- .../components/left-panel/LeftPanel.test.tsx | 94 +---- .../yaml-import/YAMLFileDropzone.test.tsx | 11 +- .../yaml-import/YAMLImportPanel.test.tsx | 335 ------------------ 6 files changed, 41 insertions(+), 741 deletions(-) delete mode 100644 assets/js/collaborative-editor/components/left-panel/YAMLImportPanel.tsx delete mode 100644 assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx diff --git a/assets/js/collaborative-editor/components/left-panel/YAMLImportPanel.tsx b/assets/js/collaborative-editor/components/left-panel/YAMLImportPanel.tsx deleted file mode 100644 index 1f1724908b..0000000000 --- a/assets/js/collaborative-editor/components/left-panel/YAMLImportPanel.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * YAMLImportPanel - YAML-based workflow import - * - * Architecture: - * - Supports drag-drop and manual YAML editing - * - State machine: initial -> parsing -> valid/invalid -> importing - */ - -import pDebounce from 'p-debounce'; -import { useState, useCallback, useContext, useEffect, useRef } from 'react'; - -import type { WorkflowState as YAMLWorkflowState } from '../../../yaml/types'; -import { - parseWorkflowYAML, - convertWorkflowSpecToState, -} from '../../../yaml/util'; -import { WorkflowError } from '../../../yaml/workflow-errors'; -import { StoreContext } from '../../contexts/StoreProvider'; -import { useUICommands } from '../../hooks/useUI'; -import { Tooltip } from '../../../components/Tooltip'; -import { ValidationErrorDisplay } from '../yaml-import/ValidationErrorDisplay'; -import { YAMLCodeEditor } from '../yaml-import/YAMLCodeEditor'; -import { YAMLFileDropzone } from '../yaml-import/YAMLFileDropzone'; - -// Import state is managed in UI store - see UIState['importPanel']['importState'] - -interface YAMLImportPanelProps { - onImport: (workflowState: YAMLWorkflowState) => void; - onSave: () => Promise; - onBack: () => void; -} - -export function YAMLImportPanel({ - onImport, - onSave, - onBack, -}: YAMLImportPanelProps) { - const context = useContext(StoreContext); - if (!context) { - throw new Error('YAMLImportPanel must be used within StoreContext'); - } - const uiStore = context.uiStore; - - const { collapseCreateWorkflowPanel } = useUICommands(); - - // Get persisted state from store - const storedYamlContent = uiStore.withSelector( - state => state.importPanel.yamlContent - )(); - const importState = uiStore.withSelector( - state => state.importPanel.importState - )(); - const setImportState = uiStore.setImportState; - - const [yamlContent, setYamlContent] = useState(storedYamlContent); - const [errors, setErrors] = useState([]); - const [validatedState, setValidatedState] = - useState(null); - - // Debounced validation and preview (300ms) - const validateYAML = useCallback( - pDebounce((content: string) => { - if (!content.trim()) { - setImportState('initial'); - setErrors([]); - setValidatedState(null); - // Clear canvas when YAML is cleared - onImport({ - id: '', - name: '', - jobs: [], - triggers: [], - edges: [], - positions: null, - }); - return; - } - - setImportState('parsing'); - try { - const spec = parseWorkflowYAML(content); - const state = convertWorkflowSpecToState(spec); - setValidatedState(state); - setImportState('valid'); - setErrors([]); - - // Automatically preview the workflow in the diagram - onImport(state); - } catch (error) { - if (error instanceof WorkflowError) { - setErrors([error]); - } else { - console.error('Unexpected validation error:', error); - setErrors([]); - } - setImportState('invalid'); - setValidatedState(null); - // Clear canvas when YAML is invalid - onImport({ - id: '', - name: '', - jobs: [], - triggers: [], - edges: [], - positions: null, - }); - } - }, 300), - [onImport] - ); - - const handleYAMLChange = (content: string) => { - setYamlContent(content); - uiStore.setImportYamlContent(content); - void validateYAML(content); - }; - - const handleFileUpload = (content: string) => { - setYamlContent(content); - uiStore.setImportYamlContent(content); - void validateYAML(content); - }; - - // Restore and validate stored YAML on mount - const hasRestoredRef = useRef(false); - useEffect(() => { - if (hasRestoredRef.current) return; - hasRestoredRef.current = true; - - if (storedYamlContent) { - void validateYAML(storedYamlContent); - } - }, [storedYamlContent, validateYAML]); - - const handleSave = async () => { - if (!validatedState) { - return; - } - - // Set importing state to show spinner - setImportState('importing'); - - try { - await onSave(); - - // Reset state after successful save - setYamlContent(''); - setValidatedState(null); - setImportState('initial'); - uiStore.clearImportPanel(); - - // Collapse panel after successful save - collapseCreateWorkflowPanel(); - } catch (error) { - // On error, reset to valid state so user can retry - setImportState('valid'); - console.error('Failed to save workflow:', error); - } - }; - - const isButtonDisabled = - importState === 'initial' || - importState === 'parsing' || - importState === 'invalid' || - importState === 'importing'; - - const buttonText = - importState === 'parsing' - ? 'Validating...' - : importState === 'importing' - ? 'Importing...' - : 'Create'; - - const tooltipMessage = - importState === 'initial' - ? 'Enter YAML content to create workflow' - : importState === 'invalid' - ? 'Fix validation errors to continue' - : null; - - return ( -
- {/* Header */} -
-
-

- Import workflow -

- -
-
- - {/* Error Banner */} - {errors.length > 0 && ( -
- -
- )} - - {/* Content Area - Flex column */} -
- {/* File Dropzone */} -
- -
- - {/* OR Divider */} -
-
-
-
-
- OR -
-
- - {/* YAML Editor - Takes remaining space */} -
- -
-
- - {/* Footer - Fixed */} -
- - - - - - -
-
- ); -} diff --git a/assets/js/collaborative-editor/components/left-panel/index.tsx b/assets/js/collaborative-editor/components/left-panel/index.tsx index b583d4648e..a83d817a3d 100644 --- a/assets/js/collaborative-editor/components/left-panel/index.tsx +++ b/assets/js/collaborative-editor/components/left-panel/index.tsx @@ -3,26 +3,16 @@ * Shows different creation UIs based on the selected method */ -import type { WorkflowState as YAMLWorkflowState } from '../../../yaml/types'; - import { TemplatePanel } from './TemplatePanel'; -import { YAMLImportPanel } from './YAMLImportPanel'; type CreationMethod = 'template' | 'import' | 'ai' | null; interface LeftPanelProps { method: CreationMethod; onMethodChange: (method: CreationMethod) => void; - onImport: (workflowState: YAMLWorkflowState) => void; - onSave: () => Promise; } -export function LeftPanel({ - method, - onMethodChange, - onImport, - onSave, -}: LeftPanelProps) { +export function LeftPanel({ method, onMethodChange }: LeftPanelProps) { // Default to template method when panel is shown without explicit method const currentMethod = method || 'template'; @@ -30,27 +20,13 @@ export function LeftPanel({ onMethodChange('import'); }; - const handleSwitchToTemplate = () => { - onMethodChange('template'); - }; - // Don't render if no method selected if (!method) return null; return (
{currentMethod === 'template' && ( - - )} - {currentMethod === 'import' && ( - + )} {currentMethod === 'ai' && (
diff --git a/assets/js/collaborative-editor/components/yaml-import/YAMLFileDropzone.tsx b/assets/js/collaborative-editor/components/yaml-import/YAMLFileDropzone.tsx index 47933338aa..75714841cd 100644 --- a/assets/js/collaborative-editor/components/yaml-import/YAMLFileDropzone.tsx +++ b/assets/js/collaborative-editor/components/yaml-import/YAMLFileDropzone.tsx @@ -6,9 +6,10 @@ * - Click to browse files * - Visual feedback for drag states */ - import { useState, useCallback } from 'react'; +import { cn } from '#/utils/cn'; + interface YAMLFileDropzoneProps { onUpload: (content: string) => void; } @@ -93,16 +94,18 @@ export function YAMLFileDropzone({ onUpload }: YAMLFileDropzoneProps) { ); return ( -
+
-
- -
- - or drag and drop -
-

YML or YAML, up to 8MB

+
+
+

+ Upload or drop a YAML file. +

+

+ YML + {' or '} + YAML + {', up to 8MB'} +

{error &&

{error}

}
diff --git a/assets/test/collaborative-editor/components/left-panel/LeftPanel.test.tsx b/assets/test/collaborative-editor/components/left-panel/LeftPanel.test.tsx index 10bf9d9f0f..4ff41eef61 100644 --- a/assets/test/collaborative-editor/components/left-panel/LeftPanel.test.tsx +++ b/assets/test/collaborative-editor/components/left-panel/LeftPanel.test.tsx @@ -25,84 +25,43 @@ vi.mock( }) ); -vi.mock( - '../../../../js/collaborative-editor/components/left-panel/YAMLImportPanel', - () => ({ - YAMLImportPanel: vi.fn(({ onBack }) => ( -
- -
- )), - }) -); - describe('LeftPanel', () => { let mockOnMethodChange: ReturnType; - let mockOnImport: ReturnType; - let mockOnSave: ReturnType; beforeEach(() => { mockOnMethodChange = vi.fn(); - mockOnImport = vi.fn(); - mockOnSave = vi.fn().mockResolvedValue(undefined); }); describe('rendering based on method', () => { it('renders TemplatePanel when method is "template"', () => { render( - + ); expect(screen.getByTestId('template-panel')).toBeInTheDocument(); - expect(screen.queryByTestId('yaml-import-panel')).not.toBeInTheDocument(); - }); - - it('renders YAMLImportPanel when method is "import"', () => { - render( - - ); - - expect(screen.getByTestId('yaml-import-panel')).toBeInTheDocument(); - expect(screen.queryByTestId('template-panel')).not.toBeInTheDocument(); }); it('renders AI placeholder when method is "ai"', () => { - render( - - ); + render(); expect( screen.getByText('AI workflow creation coming soon...') ).toBeInTheDocument(); expect(screen.queryByTestId('template-panel')).not.toBeInTheDocument(); - expect(screen.queryByTestId('yaml-import-panel')).not.toBeInTheDocument(); + }); + + it('renders nothing for "import" method (modal handles it)', () => { + render(); + + expect(screen.queryByTestId('template-panel')).not.toBeInTheDocument(); + expect( + screen.queryByText('AI workflow creation coming soon...') + ).not.toBeInTheDocument(); }); it('renders nothing when method is null', () => { const { container } = render( - + ); expect(container.firstChild).toBeNull(); @@ -112,44 +71,19 @@ describe('LeftPanel', () => { describe('method switching', () => { it('calls onMethodChange with "import" when TemplatePanel import is clicked', () => { render( - + ); screen.getByTestId('mock-import-button').click(); expect(mockOnMethodChange).toHaveBeenCalledWith('import'); }); - - it('calls onMethodChange with "template" when YAMLImportPanel back is clicked', () => { - render( - - ); - - screen.getByTestId('mock-back-button').click(); - - expect(mockOnMethodChange).toHaveBeenCalledWith('template'); - }); }); describe('panel container', () => { it('has full width and height classes', () => { const { container } = render( - + ); const panel = container.firstChild as HTMLElement; diff --git a/assets/test/collaborative-editor/components/yaml-import/YAMLFileDropzone.test.tsx b/assets/test/collaborative-editor/components/yaml-import/YAMLFileDropzone.test.tsx index 3de631cd6b..c2596521dd 100644 --- a/assets/test/collaborative-editor/components/yaml-import/YAMLFileDropzone.test.tsx +++ b/assets/test/collaborative-editor/components/yaml-import/YAMLFileDropzone.test.tsx @@ -175,16 +175,19 @@ describe('YAMLFileDropzone', () => { const onUpload = vi.fn(); render(); - expect(screen.getByText(/Upload a file/i)).toBeInTheDocument(); - expect(screen.getByText(/or drag and drop/i)).toBeInTheDocument(); - expect(screen.getByText(/YML or YAML, up to 8MB/i)).toBeInTheDocument(); + expect( + screen.getByText(/Upload or drop a YAML file/i) + ).toBeInTheDocument(); + expect(screen.getByText('YML')).toBeInTheDocument(); + expect(screen.getByText('YAML')).toBeInTheDocument(); + expect(screen.getByText(/, up to 8MB/i)).toBeInTheDocument(); }); test('displays upload icon', () => { const onUpload = vi.fn(); const { container } = render(); - const icon = container.querySelector('span.hero-cloud-arrow-up'); + const icon = container.querySelector('span.hero-arrow-up-tray'); expect(icon).toBeInTheDocument(); }); diff --git a/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx b/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx deleted file mode 100644 index 82aaee3526..0000000000 --- a/assets/test/collaborative-editor/components/yaml-import/YAMLImportPanel.test.tsx +++ /dev/null @@ -1,335 +0,0 @@ -/** - * YAMLImportPanel Component Tests - * - * Tests state machine, debounced validation, and import flow - */ - -import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { YAMLImportPanel } from '../../../../js/collaborative-editor/components/left-panel/YAMLImportPanel'; -import { StoreContext } from '../../../../js/collaborative-editor/contexts/StoreProvider'; -import { createMockStoreContextValue } from '../../__helpers__'; - -// Mock the awareness hook -vi.mock('../../../../js/collaborative-editor/hooks/useAwareness', () => ({ - useAwareness: () => [], -})); - -// Mock UI hooks -vi.mock('../../../../js/collaborative-editor/hooks/useUI', () => ({ - useUICommands: () => ({ - collapseCreateWorkflowPanel: vi.fn(), - expandCreateWorkflowPanel: vi.fn(), - toggleCreateWorkflowPanel: vi.fn(), - openRunPanel: vi.fn(), - closeRunPanel: vi.fn(), - openAIAssistantPanel: vi.fn(), - closeAIAssistantPanel: vi.fn(), - toggleAIAssistantPanel: vi.fn(), - openGitHubSyncModal: vi.fn(), - closeGitHubSyncModal: vi.fn(), - selectTemplate: vi.fn(), - setTemplateSearchQuery: vi.fn(), - }), -})); - -const validYAML = ` -name: Test Workflow -jobs: - test-job: - name: Test Job - adaptor: '@openfn/language-http@latest' - body: | - get('/api/data') -triggers: - webhook: - type: webhook - enabled: true -edges: - webhook->test-job: - source_trigger: webhook - target_job: test-job - condition_type: always - enabled: true -`; - -const invalidYAML = ` -invalid: [syntax -`; - -describe('YAMLImportPanel', () => { - let mockOnBack: ReturnType; - let mockOnImport: ReturnType; - let mockOnSave: ReturnType; - - beforeEach(() => { - mockOnBack = vi.fn(); - mockOnImport = vi.fn(); - mockOnSave = vi.fn().mockResolvedValue(undefined); - vi.clearAllMocks(); - }); - - describe('Panel visibility', () => { - test('shows panel content', () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - expect(screen.getByText(/YML or YAML, up to 8MB/i)).toBeInTheDocument(); - }); - }); - - describe('State machine', () => { - test('starts in initial state with disabled button', () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const createButton = screen.getByRole('button', { name: /Create/i }); - expect(createButton).toBeDisabled(); - }); - - test('transitions to parsing state when YAML entered', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - fireEvent.change(textarea, { target: { value: validYAML } }); - - // Button should be disabled during parsing - const createButton = screen.getByRole('button', { - name: /Create|Validating/i, - }); - expect(createButton).toBeDisabled(); - }); - - test('transitions to valid state after successful validation', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - fireEvent.change(textarea, { target: { value: validYAML } }); - - // Wait for debounce (300ms) + validation - await waitFor( - () => { - const createButton = screen.getByRole('button', { name: /Create/i }); - expect(createButton).not.toBeDisabled(); - }, - { timeout: 500 } - ); - }); - - test('transitions to invalid state with validation errors', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - fireEvent.change(textarea, { target: { value: invalidYAML } }); - - // Wait for validation to complete - button should remain disabled - await waitFor( - () => { - const createButton = screen.getByRole('button', { name: /Create/i }); - expect(createButton).toBeDisabled(); - }, - { timeout: 600 } - ); - }); - - test('transitions to importing state when Create clicked', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - fireEvent.change(textarea, { target: { value: validYAML } }); - - // Wait for valid state - await waitFor( - () => { - const createButton = screen.getByRole('button', { name: /Create/i }); - expect(createButton).not.toBeDisabled(); - }, - { timeout: 500 } - ); - - const createButton = screen.getByRole('button', { name: /Create/i }); - fireEvent.click(createButton); - - // Wait for async save to complete - await waitFor(() => { - expect(mockOnSave).toHaveBeenCalled(); - }); - }); - }); - - describe('Debounced validation', () => { - test('does not validate immediately on input', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - fireEvent.change(textarea, { target: { value: 'name:' } }); - - // Validation shouldn't complete yet - await waitFor( - () => { - expect( - screen.queryByText(/Validation Error/i) - ).not.toBeInTheDocument(); - }, - { timeout: 100 } - ); - }); - - test('validates after 300ms delay', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - const createButton = screen.getByRole('button', { name: /Create/i }); - - // Initially disabled - expect(createButton).toBeDisabled(); - - fireEvent.change(textarea, { target: { value: invalidYAML } }); - - // Wait for debounce (300ms) + validation - button should still be disabled - await waitFor( - () => { - expect(createButton).toBeDisabled(); - }, - { timeout: 500, interval: 50 } - ); - }); - }); - - describe('User actions', () => { - test('navigates back when Back button clicked', () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const backButton = screen.getByRole('button', { name: /Back/i }); - fireEvent.click(backButton); - - expect(mockOnBack).toHaveBeenCalled(); - }); - }); - - describe('UI elements', () => { - test('shows button states during validation', async () => { - const mockStore = createMockStoreContextValue(); - render( - - - - ); - - const textarea = screen.getByPlaceholderText( - /Paste your YAML content here/i - ); - - // Initially disabled - const createButton1 = screen.getByRole('button', { name: /Create/i }); - expect(createButton1).toBeDisabled(); - - // Enter valid YAML - fireEvent.change(textarea, { target: { value: validYAML } }); - - // After validation completes, button should be enabled - await waitFor( - () => { - const createButton2 = screen.getByRole('button', { name: /Create/i }); - expect(createButton2).not.toBeDisabled(); - }, - { timeout: 600 } - ); - }); - }); -});