diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5467044c..2744b7099d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to ### Added +- Keystrokes for `⌘+Enter` or `Ctrl+Enter` to run a workflow from the canvas + [3320](https://github.com/OpenFn/lightning/issues/3320) + ### Changed ### Fixed diff --git a/assets/js/hooks/KeyHandlers.ts b/assets/js/hooks/KeyHandlers.ts index 750434dffa..45445ce6c9 100644 --- a/assets/js/hooks/KeyHandlers.ts +++ b/assets/js/hooks/KeyHandlers.ts @@ -139,7 +139,7 @@ function createKeyCombinationHook( const target = e.target as HTMLElement; const focusedScope = target - ?.closest('[data-keybinding-scope]') + .closest('[data-keybinding-scope]') ?.getAttribute('data-keybinding-scope') || null; const keyMatchingHandlers = Array.from(keyHandlers).filter(h => @@ -160,7 +160,7 @@ function createKeyCombinationHook( const maxPriority = Math.max(...matchingHandlers.map(h => h.priority)); const topPriorityHandlers = matchingHandlers.filter( - h => h.priority === maxPriority + h => h.priority === maxPriority as PriorityLevel ); // Take the last handler if there are more than one with the same priority. @@ -259,10 +259,12 @@ const submitAction = (_e: KeyboardEvent, el: HTMLElement) => { /** * Simulates a "close" action, used to close modals, panels, or other UI components. * - * @param e - The keyboard event that triggered the action. + * @param _e - The keyboard event that triggered the action. * @param el - The DOM element associated with the hook. */ -const closeAction = (_e: KeyboardEvent, el: HTMLElement) => el.click(); +const closeAction = (_e: KeyboardEvent, el: HTMLElement) => { + el.click(); +}; /** * Hook to trigger a form submission when "Ctrl+S" (or "Cmd+S" on macOS) is pressed. @@ -366,3 +368,84 @@ export const CloseNodePanelViaEscape = createKeyCombinationHook( closeAction, PRIORITY.NORMAL ); + +/** + * Handles Ctrl+Enter to trigger run actions directly based on current state. + * + * BEHAVIOR (based purely on URL state): + * 1. If in inspector (URL contains 'm=expand'): + * → Click #save-and-run button to execute the workflow + * 2. If in run panel (URL contains 'm=workflow_input'): + * → Click #run-from-input-selector button to execute the workflow + * 3. If step selected but not in inspector (URL contains 's=' but no 'm=expand'): + * → Click the appropriate run button (#run-from-step or #run-from-trigger) + * 4. If no step selected and no panel: + * → Click #run-from-top button to run from trigger + */ +const openRunPanelAction = () => { + const url = window.location.href; + + // Only work on workflow pages + if (!url.includes('/w/')) { + return; + } + + // Parse URL state + const hasStepSelected = url.includes('s='); + const isInInspector = url.includes('m=expand'); + const isInRunPanel = url.includes('m=workflow_input'); + + if (isInInspector) { + // Inspector mode - click the save-and-run button + const runButton = document.querySelector('#save-and-run:not([disabled])'); + if (runButton instanceof HTMLElement) { + runButton.click(); + } + } else if (isInRunPanel) { + // Run panel mode - click the run-from-input-selector button + const runButton = document.querySelector('#run-from-input-selector:not([disabled])'); + if (runButton instanceof HTMLElement) { + runButton.click(); + } + } else if (hasStepSelected) { + // Step selected but not in inspector - click the appropriate run button + // Try run-from-step first (for jobs), then run-from-trigger (for triggers) + const runFromStepButton = document.querySelector('#run-from-step:not([disabled])'); + if (runFromStepButton instanceof HTMLElement) { + runFromStepButton.click(); + } else { + const runFromTriggerButton = document.querySelector('#run-from-trigger:not([disabled])'); + if (runFromTriggerButton instanceof HTMLElement) { + runFromTriggerButton.click(); + } + } + } else { + // No step selected - trigger the same navigation as the run button + const runButton = document.querySelector('#run-from-top:not([disabled])'); + if (runButton instanceof HTMLElement) { + runButton.click(); + } + } +}; + +/** + * Hook to open the Run panel when "Ctrl+Enter" (or "Cmd+Enter" on macOS) is pressed. + * + * This hook is scoped to the workflow editor and navigates to the run panel URL, which opens the + * workflow input interface for running the workflow. + * + * Priority: `PRIORITY.HIGH` within its scope, ensuring it takes precedence over other handlers in the workflow editor. + * Scope: `"workflow-editor"`, meaning this hook only applies within the workflow editor context. + */ +export const OpenRunPanelViaCtrlEnter = createKeyCombinationHook( + (e) => { + console.log('OpenRunPanelViaCtrlEnter: Key check triggered'); + return isCtrlOrMetaEnter(e); + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_e, _el) => { + console.log('OpenRunPanelViaCtrlEnter: Action triggered'); + openRunPanelAction(); + }, + PRIORITY.HIGH +); diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 8ee654e8af..149798c0f2 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -19,6 +19,7 @@ import { SaveViaCtrlS, InspectorSaveViaCtrlS, OpenSyncModalViaCtrlShiftS, + OpenRunPanelViaCtrlEnter, SendMessageViaCtrlEnter, DefaultRunViaCtrlEnter, AltRunViaCtrlShiftEnter, @@ -40,6 +41,7 @@ export { SaveViaCtrlS, InspectorSaveViaCtrlS, OpenSyncModalViaCtrlShiftS, + OpenRunPanelViaCtrlEnter, SendMessageViaCtrlEnter, DefaultRunViaCtrlEnter, AltRunViaCtrlShiftEnter, diff --git a/assets/js/panel/panels/WorkflowRunPanel.tsx b/assets/js/panel/panels/WorkflowRunPanel.tsx index ccd476455a..452fb03814 100644 --- a/assets/js/panel/panels/WorkflowRunPanel.tsx +++ b/assets/js/panel/panels/WorkflowRunPanel.tsx @@ -65,6 +65,7 @@ export const WorkflowRunPanel: WithActionProps = (props) => { className="rounded-md text-sm font-semibold shadow-xs phx-submit-loading:opacity-75 bg-primary-600 hover:bg-primary-500 text-white disabled:bg-primary-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 px-3 py-2 flex items-center gap-1" disabled={is_edge ? true : runDisabled} onClick={startRun} + id="run-from-input-selector" > Run Workflow Now diff --git a/lib/lightning_web/live/workflow_live/edit.ex b/lib/lightning_web/live/workflow_live/edit.ex index a9c3af2d34..bdd2237803 100644 --- a/lib/lightning_web/live/workflow_live/edit.ex +++ b/lib/lightning_web/live/workflow_live/edit.ex @@ -213,8 +213,12 @@ defmodule LightningWeb.WorkflowLive.Edit do class="transition-all duration-300 ease-in-out" />
<.selected_template_label :if={@selected_template && @show_new_workflow_panel} @@ -225,13 +229,17 @@ defmodule LightningWeb.WorkflowLive.Edit do
Run @@ -590,6 +599,7 @@ defmodule LightningWeb.WorkflowLive.Edit do patch={"#{@base_url}?s=#{@selected_trigger.id}&m=workflow_input"} type="button" theme="primary" + id="run-from-trigger" > <.icon name="hero-play-solid" class="w-4 h-4" /> Run @@ -3163,6 +3173,7 @@ defmodule LightningWeb.WorkflowLive.Edit do patch={"#{@base_url}?s=#{@trigger_id}&m=workflow_input"} type="button" theme="primary" + id="run-from-top" > Run diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs index 46600c1add..329cdca240 100644 --- a/test/lightning_web/live/workflow_live/edit_test.exs +++ b/test/lightning_web/live/workflow_live/edit_test.exs @@ -3580,6 +3580,95 @@ defmodule LightningWeb.WorkflowLive.EditTest do end end + describe "keyboard shortcuts" do + setup %{project: project} do + workflow = + insert(:simple_workflow, project: project) + |> with_snapshot() + + {:ok, workflow: workflow} + end + + test "OpenRunPanelViaCtrlEnter hook is attached to workflow editor", %{ + conn: conn, + project: project, + workflow: workflow + } do + {:ok, view, _html} = + live( + conn, + ~p"/projects/#{project.id}/w/#{workflow.id}", + on_error: :raise + ) + + # Check that the OpenRunPanelViaCtrlEnter hook is present on the workflow container + workflow_container = view |> element("#workflow-edit-#{workflow.id}") + assert has_element?(workflow_container) + + assert render(workflow_container) =~ + "phx-hook=\"OpenRunPanelViaCtrlEnter\"" + + assert render(workflow_container) =~ + "data-keybinding-scope=\"workflow-editor\"" + end + + test "OpenRunPanelViaCtrlEnter hook is present with job selected", %{ + conn: conn, + project: project, + workflow: workflow + } do + # Use the first job from the workflow that was created with snapshot + job = workflow.jobs |> List.first() + + {:ok, view, _html} = + live( + conn, + ~p"/projects/#{project.id}/w/#{workflow.id}?s=#{job.id}", + on_error: :raise + ) + + # Hook should still be present when a job is selected + workflow_container = view |> element("#workflow-edit-#{workflow.id}") + assert has_element?(workflow_container) + + assert render(workflow_container) =~ + "phx-hook=\"OpenRunPanelViaCtrlEnter\"" + + assert render(workflow_container) =~ + "data-keybinding-scope=\"workflow-editor\"" + + # Run button should be present for step-aware behavior + run_button = view |> element("#run-from-step-#{job.id}") + assert has_element?(run_button) + end + + test "OpenRunPanelViaCtrlEnter hook is present in expand mode", %{ + conn: conn, + project: project, + workflow: workflow + } do + # Use the first job from the workflow that was created with snapshot + job = workflow.jobs |> List.first() + + {:ok, view, _html} = + live( + conn, + ~p"/projects/#{project.id}/w/#{workflow.id}?s=#{job.id}&m=expand", + on_error: :raise + ) + + # Hook should be present even in expand mode + workflow_container = view |> element("#workflow-edit-#{workflow.id}") + assert has_element?(workflow_container) + + assert render(workflow_container) =~ + "phx-hook=\"OpenRunPanelViaCtrlEnter\"" + + assert render(workflow_container) =~ + "data-keybinding-scope=\"workflow-editor\"" + end + end + defp log_viewer_selected_level(log_viewer) do log_viewer |> render()