diff --git a/CHANGELOG.md b/CHANGELOG.md index 3014d74af4..d7cead72a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ and this project adheres to ### Fixed +- `Cmd/Ctrl+Enter` now runs the workflow directly; `Cmd/Ctrl+Shift+Enter` opens + "run with custom input". When a retryable run is loaded, the primary action + switches to retry. [#4736](https://github.com/OpenFn/lightning/issues/4736) + - `mix lightning.install_runtime` no longer reports success when Rambo's binary fails to start; both `Rambo.run/2` calls now raise with the underlying reason. [#4735](https://github.com/OpenFn/lightning/pull/4735) diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index 3924c8167a..07a3456ef1 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -7,6 +7,7 @@ import { buildClassicalEditorUrl } from '../../utils/editorUrlConversion'; import * as dataclipApi from '../api/dataclips'; import { StoreContext } from '../contexts/StoreProvider'; import { channelRequest } from '../hooks/useChannel'; +import { getCsrfToken } from '../lib/csrf'; import { useActiveRun } from '../hooks/useHistory'; import { useSession } from '../hooks/useSession'; import { @@ -236,6 +237,12 @@ export function Header({ const [isSubmitting, setIsSubmitting] = useState(false); const activeRun = useActiveRun(); const runIsProcessing = activeRun ? !isFinalState(activeRun.state) : false; + const followedRunId = params.run ?? null; + const isRetryable = + !!followedRunId && + !!activeRun && + isFinalState(activeRun.state) && + !!activeRun.steps?.length; // Check GitHub sync limit const githubSyncLimit = limits.github_sync ?? { @@ -296,6 +303,57 @@ export function Header({ updateSearchParams, ]); + const handleRetryClick = useCallback(async () => { + if (!followedRunId || !activeRun?.steps?.length || !projectId) return; + + setIsSubmitting(true); + try { + await saveWorkflow({ silent: true }); + + const firstStep = activeRun.steps[0]; + const retryUrl = `/projects/${projectId}/runs/${followedRunId}/retry`; + const csrfToken = getCsrfToken(); + const response = await fetch(retryUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken || '', + }, + body: JSON.stringify({ step_id: firstStep.id }), + }); + + if (!response.ok) { + const error = (await response.json()) as { error?: string }; + throw new Error(error.error || 'Failed to retry run'); + } + + const result = (await response.json()) as { data: { run_id: string } }; + + notifications.success({ + title: 'Retry started', + description: 'Saved latest changes and re-running with previous input', + }); + + if (getLimits) void getLimits('new_run'); + updateSearchParams({ run: result.data.run_id }); + } catch (error) { + notifications.alert({ + title: 'Retry failed', + description: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + setIsSubmitting(false); + } + }, [ + followedRunId, + activeRun, + projectId, + saveWorkflow, + getLimits, + updateSearchParams, + ]); + const handleRunWithCustomInputClick = useCallback(() => { if (firstTriggerId) { selectNode(firstTriggerId); @@ -326,6 +384,46 @@ export function Header({ } }, [provider, projectId, workflowId, isNewWorkflow]); + useKeyboardShortcut( + 'Control+Enter, Meta+Enter', + () => { + if (isRetryable) { + void handleRetryClick(); + } else { + void handleRunClick(); + } + }, + 0, + { + enabled: + canRun && + !isRunPanelOpen && + !isIDEOpen && + !isNewWorkflow && + !!projectId && + !!workflowId && + (isRetryable || !!firstTriggerId), + } + ); + + useKeyboardShortcut( + 'Control+Shift+Enter, Meta+Shift+Enter', + () => { + handleRunWithCustomInputClick(); + }, + 0, + { + enabled: + canRun && + !isRunPanelOpen && + !isIDEOpen && + !isNewWorkflow && + !!projectId && + !!workflowId && + !!firstTriggerId, + } + ); + useKeyboardShortcut( 'Control+s, Meta+s', () => { @@ -440,10 +538,13 @@ export function Header({
{projectId && workflowId && firstTriggerId && !isNewWorkflow && ( { + void (isRetryable ? handleRetryClick() : handleRunClick()); + }} onRunWithCustomInputClick={handleRunWithCustomInputClick} disabled={!canRun || isRunPanelOpen || isIDEOpen} isRunning={isSubmitting || runIsProcessing} + text={isRetryable ? 'Run (Retry)' : 'Run'} /> )} ) : ( - + ); const splitButtonClasses = @@ -112,18 +112,33 @@ export function NewRunButton({ transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out data-leave:duration-75 data-leave:ease-in" + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + } + }} > - + > + + Run with custom input + + + )} diff --git a/assets/js/collaborative-editor/components/RunRetryButton.tsx b/assets/js/collaborative-editor/components/RunRetryButton.tsx index ffdb1c3af0..b1ff6e286c 100644 --- a/assets/js/collaborative-editor/components/RunRetryButton.tsx +++ b/assets/js/collaborative-editor/components/RunRetryButton.tsx @@ -236,31 +236,24 @@ export function RunRetryButton({ {/* Chevron dropdown button - stays visible during processing for consistency */}
- !chevronDisabled && setIsDropdownOpen(!isDropdownOpen)} + disabled={chevronDisabled} + className={cn( + 'rounded-md text-sm font-semibold shadow-xs px-1 py-2', + 'h-full rounded-l-none', + 'focus-visible:outline-2 focus-visible:outline-offset-2', + chevronDisabled + ? [styles.submitting, 'cursor-not-allowed'] + : [styles.chevronBase, styles.focus] + )} + aria-expanded={isDropdownOpen} + aria-haspopup="true" > - - + Open options + + {/* Dropdown menu */} {isDropdownOpen && ( diff --git a/assets/js/collaborative-editor/components/WorkflowEditor.tsx b/assets/js/collaborative-editor/components/WorkflowEditor.tsx index 1051ce521a..0376b21d87 100644 --- a/assets/js/collaborative-editor/components/WorkflowEditor.tsx +++ b/assets/js/collaborative-editor/components/WorkflowEditor.tsx @@ -431,33 +431,6 @@ export function WorkflowEditor({ collapseCreateWorkflowPanel(); }; - useKeyboardShortcut( - 'Control+Enter, Meta+Enter', - () => { - if (isRunPanelOpen) { - return; - } - - if (currentNode.type === 'job' && currentNode.node) { - openRunPanel({ jobId: currentNode.node.id }); - } else if (currentNode.type === 'trigger' && currentNode.node) { - openRunPanel({ triggerId: currentNode.node.id }); - } else { - const firstTrigger = workflow.triggers[0]; - if (firstTrigger?.id) { - openRunPanel({ - triggerId: firstTrigger.id, - entryPoint: 'custom-input', - }); - } - } - }, - 0, - { - enabled: !isIDEOpen && !isRunPanelOpen, - } - ); - /** * Keyboard shortcuts for new workflow creation panels. * diff --git a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx index 3093c5286a..03eb8f07bf 100644 --- a/assets/test/collaborative-editor/components/Header.keyboard.test.tsx +++ b/assets/test/collaborative-editor/components/Header.keyboard.test.tsx @@ -17,6 +17,8 @@ import userEvent from '@testing-library/user-event'; import type React from 'react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { RunDetail } from '../../../js/collaborative-editor/types/history'; + import { Header } from '../../../js/collaborative-editor/components/Header'; import { SessionContext } from '../../../js/collaborative-editor/contexts/SessionProvider'; import { StoreContext } from '../../../js/collaborative-editor/contexts/StoreProvider'; @@ -27,6 +29,7 @@ import { createMockURLState, getURLStateMockValue, } from '../__helpers__/urlStateMocks'; +import { createWorkflowYDoc } from '../__helpers__/workflowFactory'; import { createMinimalWorkflowYDoc } from '../__helpers__/workflowStoreHelpers'; // ============================================================================= @@ -50,10 +53,53 @@ vi.mock('../../../js/collaborative-editor/components/Tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })); +// Mock dataclipApi so submitManualRun can be spied on +const mockSubmitManualRun = vi.fn(); +vi.mock('../../../js/collaborative-editor/api/dataclips', () => ({ + submitManualRun: (...args: unknown[]) => mockSubmitManualRun(...args), + searchDataclips: vi.fn(() => + Promise.resolve({ + data: [], + next_cron_run_dataclip_id: null, + can_edit_dataclip: true, + }) + ), +})); + // ============================================================================= // TEST HELPERS // ============================================================================= +/** + * Creates a Y.Doc with workflow metadata AND a trigger so firstTriggerId is set. + * Used by the Cmd+Enter tests which require canRun + firstTriggerId to be truthy. + */ +function createWorkflowYDocWithTrigger( + lockVersion: number | null = 1 +): ReturnType & { triggerId: string } { + const triggerId = 'trigger-test-1'; + const ydoc = createWorkflowYDoc({ + triggers: { + [triggerId]: { id: triggerId, type: 'webhook', enabled: true }, + }, + }); + + // Merge workflow metadata into the doc (createWorkflowYDoc doesn't set it) + const workflowMap = ydoc.getMap('workflow'); + workflowMap.set('id', 'test-workflow-123'); + workflowMap.set('name', 'Test Workflow'); + workflowMap.set('lock_version', lockVersion); + workflowMap.set('deleted_at', null); + workflowMap.set('concurrency', null); + workflowMap.set('enable_job_logs', false); + + // createWorkflowYDoc doesn't initialise positions / errors maps + ydoc.getMap('positions'); + ydoc.getMap('errors'); + + return Object.assign(ydoc, { triggerId }); +} + interface WrapperOptions { permissions?: { can_edit_workflow: boolean; can_run_workflow: boolean }; latestSnapshotLockVersion?: number; @@ -965,3 +1011,416 @@ describe('Header - Guard Condition Interactions', () => { cleanup(); }); }); + +// ============================================================================= +// SHARED RUN SETUP HELPER +// ============================================================================= + +/** + * Sets up stores with a Y.Doc that already contains a trigger so that + * `firstTriggerId` is defined and the shortcut guard passes. + */ +async function createRunSetup( + options: WrapperOptions & { + isRunPanelOpen?: boolean; + isIDEOpen?: boolean; + } = {} +) { + const { + isRunPanelOpen = false, + isIDEOpen = false, + ...wrapperOptions + } = options; + + const ydoc = createWorkflowYDocWithTrigger( + wrapperOptions.workflowLockVersion ?? 1 + ); + + const { stores, sessionStore, cleanup, emitSessionContext } = + await simulateStoreProviderWithConnection( + 'test:room', + { id: 'user-1', name: 'Test User', color: '#ff0000' }, + { + workflowYDoc: ydoc, + sessionContext: { + permissions: wrapperOptions.permissions ?? { + can_edit_workflow: true, + can_run_workflow: true, + }, + latest_snapshot_lock_version: + wrapperOptions.latestSnapshotLockVersion ?? 1, + }, + emitSessionContext: true, + } + ); + + // Manually emit sync so isSynced becomes true + const provider = sessionStore.getProvider(); + if (provider) { + (provider as any).emit('sync', [true]); + } + await new Promise(resolve => setTimeout(resolve, 150)); + + vi.spyOn(stores.workflowStore, 'saveWorkflow').mockResolvedValue(null); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + + async function renderAndWait() { + const result = render( +
+ {[Breadcrumb]} +
, + { wrapper } + ); + + await act(async () => { + emitSessionContext!(); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(screen.getByTestId('save-workflow-button')).toBeInTheDocument(); + }); + + return result; + } + + return { wrapper, stores, emitSessionContext, cleanup, renderAndWait }; +} + +// ============================================================================= +// CMD+ENTER / CTRL+ENTER – SUBMIT MANUAL RUN +// ============================================================================= + +describe('Header - Submit Manual Run (Cmd+Enter / Ctrl+Enter)', () => { + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + // Default: submitManualRun resolves successfully + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-123' } }); + }); + + afterEach(async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + test('Cmd+Enter submits run when canRun is true (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await waitFor(() => expect(mockSubmitManualRun).toHaveBeenCalledTimes(1)); + expect(mockSubmitManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + projectId: 'project-1', + triggerId: 'trigger-test-1', + }) + ); + + unmount(); + cleanup(); + }); + + test('Ctrl+Enter submits run when canRun is true (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Enter}{/Control}'); + + await waitFor(() => expect(mockSubmitManualRun).toHaveBeenCalledTimes(1)); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when no run permission', async () => { + const user = userEvent.setup(); + // canRun requires hasEditPermission OR hasRunPermission — both must be false + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: false, can_run_workflow: false }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when run panel is open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isRunPanelOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Enter does NOT submit run when IDE is open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isIDEOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); +}); + +// ============================================================================= +// CMD+SHIFT+ENTER – RUN WITH CUSTOM INPUT (no run loaded) +// ============================================================================= + +describe('Header - Cmd+Shift+Enter (Run with custom input)', () => { + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-123' } }); + }); + + afterEach(async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + test('Cmd+Shift+Enter navigates to run panel with custom input when no run is loaded (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Ctrl+Shift+Enter navigates to run panel with custom input when no run is loaded (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Shift>}{Enter}{/Shift}{/Control}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + + unmount(); + cleanup(); + }); + + test('Cmd+Shift+Enter does NOT navigate to run panel when run panel is already open', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + isRunPanelOpen: true, + }); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(urlState.mockFns.updateSearchParams).not.toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ); + + unmount(); + cleanup(); + }); +}); + +// ============================================================================= +// CMD+ENTER / CMD+SHIFT+ENTER – RETRY & NEW WORK ORDER (run loaded) +// ============================================================================= + +const FOLLOWED_RUN_ID = '11111111-1111-1111-1111-111111111111'; +const STEP_ID = '22222222-2222-2222-2222-222222222222'; + +const mockRetryableRun: RunDetail = { + id: FOLLOWED_RUN_ID, + work_order_id: '33333333-3333-3333-3333-333333333333', + work_order: { + id: '33333333-3333-3333-3333-333333333333', + workflow_id: 'workflow-1', + }, + state: 'success', + created_by: null, + starting_trigger: null, + started_at: '2024-01-01T00:00:00Z', + finished_at: '2024-01-01T00:01:00Z', + inserted_at: '2024-01-01T00:00:00Z', + steps: [ + { + id: STEP_ID, + job_id: '44444444-4444-4444-4444-444444444444', + job: { name: 'Test Job' }, + exit_reason: 'normal', + error_type: null, + started_at: '2024-01-01T00:00:00Z', + finished_at: '2024-01-01T00:01:00Z', + input_dataclip_id: null, + output_dataclip_id: null, + inserted_at: '2024-01-01T00:00:00Z', + }, + ], +}; + +describe('Header - Retry shortcuts (run loaded)', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + urlState.reset(); + vi.clearAllMocks(); + mockSubmitManualRun.mockResolvedValue({ data: { run_id: 'run-new-456' } }); + + mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { run_id: 'run-retried-789' } }), + }); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + }); + + async function createRetrySetup() { + urlState.setParam('run', FOLLOWED_RUN_ID); + + const setup = await createRunSetup({ + permissions: { can_edit_workflow: true, can_run_workflow: true }, + }); + + act(() => { + setup.stores.historyStore._setActiveRunForTesting(mockRetryableRun); + }); + + return setup; + } + + test('Cmd+Enter retries the loaded run instead of creating a new work order (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Enter}{/Meta}'); + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/runs/${FOLLOWED_RUN_ID}/retry`), + expect.objectContaining({ method: 'POST' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Ctrl+Enter retries the loaded run instead of creating a new work order (Windows)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Control>}{Enter}{/Control}'); + + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/runs/${FOLLOWED_RUN_ID}/retry`), + expect.objectContaining({ method: 'POST' }) + ) + ); + expect(mockSubmitManualRun).not.toHaveBeenCalled(); + + unmount(); + cleanup(); + }); + + test('Cmd+Shift+Enter navigates to run panel with custom input even when run is loaded (Mac)', async () => { + const user = userEvent.setup(); + const { renderAndWait, cleanup } = await createRetrySetup(); + + const { unmount } = await renderAndWait(); + + await user.keyboard('{Meta>}{Shift>}{Enter}{/Shift}{/Meta}'); + + await waitFor(() => + expect(urlState.mockFns.updateSearchParams).toHaveBeenCalledWith( + expect.objectContaining({ panel: 'run' }) + ) + ); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining('/retry'), + expect.anything() + ); + + unmount(); + cleanup(); + }); +}); diff --git a/assets/test/collaborative-editor/components/NewRunButton.test.tsx b/assets/test/collaborative-editor/components/NewRunButton.test.tsx index 6af877e1ee..8d36e3af83 100644 --- a/assets/test/collaborative-editor/components/NewRunButton.test.tsx +++ b/assets/test/collaborative-editor/components/NewRunButton.test.tsx @@ -64,7 +64,7 @@ describe('NewRunButton - Disabled Prop', () => { ); - const playIcon = container.querySelector('.hero-play'); + const playIcon = container.querySelector('.hero-play-solid'); expect(playIcon).toBeInTheDocument(); }); diff --git a/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx b/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx deleted file mode 100644 index 6c69db1f41..0000000000 --- a/assets/test/collaborative-editor/components/WorkflowEditor.keyboard.test.tsx +++ /dev/null @@ -1,480 +0,0 @@ -/** - * WorkflowEditor Keyboard Shortcuts Tests - * - * Tests keyboard shortcut behavior in WorkflowEditor using a library-agnostic - * approach that tests user-facing behavior rather than implementation details. - * Tests survive library migrations and document expected user behavior. - * - * Shortcuts tested: - * - Mod+Enter: Open run panel for selected node or first trigger - * - * Note: Cmd+E / Ctrl+E IDE shortcut tests are in CollaborativeEditor.keyboard.test.tsx - * since the IDE is rendered by CollaborativeEditor, not WorkflowEditor. - */ - -import { screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { WorkflowEditor } from '../../../js/collaborative-editor/components/WorkflowEditor'; -import type { Workflow } from '../../../js/collaborative-editor/types/workflow'; -import { - expectShortcutNotToFire, - keys, - renderWithKeyboard, -} from '../../keyboard-test-utils'; -import { - createMockURLState, - getURLStateMockValue, -} from '../__helpers__/urlStateMocks'; - -// Mock dependencies -vi.mock('../../../js/collaborative-editor/api/dataclips', () => ({ - searchDataclips: vi.fn(() => - Promise.resolve({ - data: [], - next_cron_run_dataclip_id: null, - can_edit_dataclip: true, - }) - ), -})); - -// Mock MonacoEditor -vi.mock('@monaco-editor/react', () => ({ - default: ({ value }: { value: string }) => ( -
{value}
- ), -})); - -// Mock CollaborativeWorkflowDiagram -vi.mock( - '../../../js/collaborative-editor/components/diagram/CollaborativeWorkflowDiagram', - () => ({ - CollaborativeWorkflowDiagram: () => ( -
Workflow Diagram
- ), - }) -); - -// Mock Inspector -vi.mock('../../../js/collaborative-editor/components/inspector', () => ({ - Inspector: () =>
Inspector
, -})); - -// Mock LeftPanel -vi.mock('../../../js/collaborative-editor/components/left-panel', () => ({ - LeftPanel: () =>
Left Panel
, -})); - -// Mock FullScreenIDE -vi.mock( - '../../../js/collaborative-editor/components/ide/FullScreenIDE', - () => ({ - FullScreenIDE: () => ( -
Full Screen IDE
- ), - }) -); - -// Mock ManualRunPanel -vi.mock('../../../js/collaborative-editor/components/ManualRunPanel', () => ({ - ManualRunPanel: ({ - jobId, - triggerId, - }: { - jobId?: string; - triggerId?: string; - }) => ( -
- ManualRunPanel -
- ), -})); - -// Create controllable mocks -const mockOpenRunPanel = vi.fn(); -const mockCloseRunPanel = vi.fn(); -const mockSelectNode = vi.fn(); -const urlState = createMockURLState(); - -// Mock useURLState -vi.mock('../../../js/react/lib/use-url-state', () => ({ - useURLState: () => getURLStateMockValue(urlState), -})); - -// Mock session context hooks -vi.mock('../../../js/collaborative-editor/hooks/useSessionContext', () => ({ - useIsNewWorkflow: () => false, - useProjectRepoConnection: () => undefined, - useProject: () => ({ - id: 'project-1', - name: 'Test Project', - }), - useVersions: () => [], - useVersionsLoading: () => false, - useVersionsError: () => null, - useRequestVersions: () => vi.fn(), -})); - -// Create mock workflow -const mockWorkflow: Workflow = { - id: 'workflow-1', - name: 'Test Workflow', - jobs: [ - { - id: 'job-1', - name: 'Job 1', - adaptor: '@openfn/language-http@latest', - body: 'fn(state => state)', - enabled: true, - project_credential_id: null, - keychain_credential_id: null, - }, - { - id: 'job-2', - name: 'Job 2', - adaptor: '@openfn/language-http@latest', - body: 'fn(state => state)', - enabled: true, - project_credential_id: null, - keychain_credential_id: null, - }, - ], - triggers: [ - { - id: 'trigger-1', - type: 'webhook', - enabled: true, - }, - { - id: 'trigger-2', - type: 'cron', - enabled: true, - }, - ], - edges: [], -}; - -// Mock UI hooks with controllable state -const mockIsRunPanelOpen = vi.fn(() => false); -const mockRunPanelContext = vi.fn(() => null); - -vi.mock('../../../js/collaborative-editor/hooks/useUI', () => ({ - useIsRunPanelOpen: () => mockIsRunPanelOpen(), - useRunPanelContext: () => mockRunPanelContext(), - useUICommands: () => ({ - openRunPanel: mockOpenRunPanel, - closeRunPanel: mockCloseRunPanel, - toggleCreateWorkflowPanel: vi.fn(), - openAIAssistantPanel: vi.fn(), - closeAIAssistantPanel: vi.fn(), - collapseCreateWorkflowPanel: vi.fn(), - expandCreateWorkflowPanel: vi.fn(), - selectTemplate: vi.fn(), - setTemplateSearchQuery: vi.fn(), - }), - useTemplatePanel: () => ({ - templates: [], - loading: false, - error: null, - searchQuery: '', - selectedTemplate: null, - }), - useIsCreateWorkflowPanelCollapsed: () => true, - useIsAIAssistantPanelOpen: () => false, -})); - -// Mock workflow hooks with controllable node selection -let currentNode: { - type: 'job' | 'trigger' | 'edge' | null; - node: any; -} = { - type: null, - node: null, -}; - -vi.mock('../../../js/collaborative-editor/hooks/useWorkflow', () => ({ - useNodeSelection: () => ({ - currentNode, - selectNode: mockSelectNode, - }), - useWorkflowStoreContext: () => ({ - validateWorkflowName: vi.fn(), - importWorkflow: vi.fn(), - }), - useWorkflowActions: () => ({ - saveWorkflow: vi.fn(), - }), - useWorkflowState: (selector: any) => { - const state = { - workflow: mockWorkflow, - jobs: mockWorkflow.jobs, - triggers: mockWorkflow.triggers, - edges: mockWorkflow.edges, - positions: {}, - }; - return typeof selector === 'function' ? selector(state) : state; - }, - useCanRun: () => ({ - canRun: true, - tooltipMessage: '', - }), -})); - -describe('WorkflowEditor keyboard shortcuts', () => { - beforeEach(() => { - vi.clearAllMocks(); - urlState.reset(); - - // Reset state - mockIsRunPanelOpen.mockReturnValue(false); - mockRunPanelContext.mockReturnValue(null); - currentNode = { type: null, node: null }; - }); - - describe('Mod+Enter - Open Run Panel', () => { - test('opens run panel for selected job with Cmd+Enter on Mac', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - - test('opens run panel for selected job with Ctrl+Enter on Windows/Linux', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - - test('opens run panel for selected trigger', async () => { - currentNode = { - type: 'trigger', - node: mockWorkflow.triggers[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-1', - }); - }); - }); - - test('falls back to first trigger when nothing selected', async () => { - currentNode = { type: null, node: null }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-1', - entryPoint: 'custom-input', - }); - }); - }); - - test('delegates to ManualRunPanel when run panel already open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - // Run panel is already open - mockIsRunPanelOpen.mockReturnValue(true); - mockRunPanelContext.mockReturnValue({ jobId: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('manual-run-panel')).toBeInTheDocument(); - }); - - container.focus(); - - // Try to open run panel again - should not call openRunPanel - // (ManualRunPanel's shortcut handler will execute instead) - await expectShortcutNotToFire(keys.run('cmd'), mockOpenRunPanel, user); - }); - - test('does not trigger when IDE is open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - // IDE is open - urlState.setParams({ panel: 'editor', job: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('cmd'), mockOpenRunPanel, user); - }); - - test('works in form fields (enableOnFormTags)', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - // Create and focus a textarea - const textarea = document.createElement('textarea'); - container.appendChild(textarea); - textarea.focus(); - - await shortcuts.run('cmd'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-1' }); - }); - }); - }); - - describe('guard conditions', () => { - test('Mod+Enter disabled when IDE open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - urlState.setParams({ panel: 'editor', job: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('ctrl'), mockOpenRunPanel, user); - }); - - test('Mod+Enter disabled when run panel already open', async () => { - currentNode = { - type: 'job', - node: mockWorkflow.jobs[0], - }; - - mockIsRunPanelOpen.mockReturnValue(true); - mockRunPanelContext.mockReturnValue({ jobId: 'job-1' }); - - const { container, user } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('manual-run-panel')).toBeInTheDocument(); - }); - - container.focus(); - - await expectShortcutNotToFire(keys.run('ctrl'), mockOpenRunPanel, user); - }); - }); - - describe('behavior with different node selections', () => { - test('Mod+Enter opens run panel for job selection', async () => { - // Test with job selection - currentNode = { - type: 'job', - node: mockWorkflow.jobs[1], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ jobId: 'job-2' }); - }); - }); - - test('Mod+Enter opens run panel for trigger selection', async () => { - // Test with trigger selection - currentNode = { - type: 'trigger', - node: mockWorkflow.triggers[1], - }; - - const { container, shortcuts } = renderWithKeyboard(); - - await waitFor(() => { - expect(screen.getByTestId('workflow-diagram')).toBeInTheDocument(); - }); - - container.focus(); - - await shortcuts.run('ctrl'); - - await waitFor(() => { - expect(mockOpenRunPanel).toHaveBeenCalledWith({ - triggerId: 'trigger-2', - }); - }); - }); - }); -});