Skip to content

add keystrokes to run from canvas #3384

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 87 additions & 4 deletions assets/js/hooks/KeyHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To run the workflow from the run-panel. we don't need to search for the run button using selectors which will be problematic with time. We seem to already have a function in the js world that does that. It'll be best if we can register our key events close to this function and then just call it directly instead of depending on searching for a button.

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');
Comment on lines +394 to +396
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we've ever made keyHandlers depend on url params. It's mostly dependent on element that are available when we're on the screen we want the key events to be active.

eg. Undo/Redo Key handlers are registered when the workflowDiagram is mounted and unregistered when it's unmounted. Hence we wouldn't have to depend on query params.


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();
}
Comment on lines +398 to +427
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using ids here is brittle. we might want to actually call events directly when they exist.

}
};

/**
* 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
);
2 changes: 2 additions & 0 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
SaveViaCtrlS,
InspectorSaveViaCtrlS,
OpenSyncModalViaCtrlShiftS,
OpenRunPanelViaCtrlEnter,
SendMessageViaCtrlEnter,
DefaultRunViaCtrlEnter,
AltRunViaCtrlShiftEnter,
Expand All @@ -40,6 +41,7 @@ export {
SaveViaCtrlS,
InspectorSaveViaCtrlS,
OpenSyncModalViaCtrlShiftS,
OpenRunPanelViaCtrlEnter,
SendMessageViaCtrlEnter,
DefaultRunViaCtrlEnter,
AltRunViaCtrlShiftEnter,
Expand Down
1 change: 1 addition & 0 deletions assets/js/panel/panels/WorkflowRunPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const WorkflowRunPanel: WithActionProps<WorkflowRunPanel> = (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"
>
<span className="hero-play-solid w-4 h-4"></span> Run Workflow Now
</button>
Expand Down
15 changes: 13 additions & 2 deletions lib/lightning_web/live/workflow_live/edit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,12 @@ defmodule LightningWeb.WorkflowLive.Edit do
class="transition-all duration-300 ease-in-out"
/>
<div
class={"relative h-full flex grow transition-all duration-300 ease-in-out #{if @show_new_workflow_panel, do: "w-2/3", else: ""}"}
class={"relative h-full flex grow transition-all duration-300 ease-in-out #{if @show_new_workflow_panel, do: "w-2/3", else: ""} focus:outline-none"}
id={"workflow-edit-#{@workflow.id}"}
phx-hook="OpenRunPanelViaCtrlEnter"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keyhandler registration doesn't seem to get unregistered.

data-keybinding-scope="workflow-editor"
tabindex="0"
phx-mounted={JS.focus()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering why we need to focus here.

>
<.selected_template_label
:if={@selected_template && @show_new_workflow_panel}
Expand All @@ -225,13 +229,17 @@ defmodule LightningWeb.WorkflowLive.Edit do
<div class="flex-none" id="job-editor-pane">
<div
:if={@selected_job && @selection_mode == "expand"}
id={"inspector-panel-#{@selected_job.id}"}
class={[
"fixed left-0 top-0 right-0 bottom-0 flex-wrap",
"hidden opacity-0",
"bg-white inset-0 z-30 overflow-hidden drop-shadow-[0_35px_35px_rgba(0,0,0,0.25)]"
"bg-white inset-0 z-30 overflow-hidden drop-shadow-[0_35px_35px_rgba(0,0,0,0.25)] focus:outline-none"
]}
phx-mounted={fade_in()}
phx-remove={fade_out()}
data-keybinding-scope="workflow-editor"
tabindex="0"
phx-hook="AutoFocusOnMount"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another focusing of the inspector. wondering once again.

>
<LightningWeb.WorkflowLive.JobView.job_edit_view
job={@selected_job}
Expand Down Expand Up @@ -505,6 +513,7 @@ defmodule LightningWeb.WorkflowLive.Edit do
patch={"#{@base_url}?s=#{@selected_job.id}&m=workflow_input"}
type="button"
theme="primary"
id={"run-from-step"}
>
Run
</.button_link>
Expand Down Expand Up @@ -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
</.button_link>
Expand Down Expand Up @@ -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
</.button_link>
Expand Down
89 changes: 89 additions & 0 deletions test/lightning_web/live/workflow_live/edit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down