Skip to content

Commit 0110e36

Browse files
committed
feat(build-from-scratch): create webhook workflow from landing screen
Adds the "Build from scratch" entry point on the new-workflow landing screen: Workflows.create_webhook_workflow/2 builds a workflow with a webhook trigger, one job, and an always-on edge; the build_from_scratch LiveView event authorizes via the role-scoped ProjectUsers :create_workflow check so viewers can't trigger it.
1 parent 05644c6 commit 0110e36

5 files changed

Lines changed: 247 additions & 2 deletions

File tree

assets/js/collaborative-editor/CollaborativeEditor.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import { VersionDropdown } from './components/VersionDropdown';
1818
import { WorkflowEditor } from './components/WorkflowEditor';
1919
import { YAMLImportModal } from './components/YAMLImportModal';
2020
import { CredentialModalProvider } from './contexts/CredentialModalContext';
21-
import { LiveViewActionsProvider } from './contexts/LiveViewActionsContext';
21+
import {
22+
LiveViewActionsProvider,
23+
useLiveViewActions,
24+
} from './contexts/LiveViewActionsContext';
2225
import { MonacoRefProvider } from './contexts/MonacoRefContext';
2326
import { SessionProvider } from './contexts/SessionProvider';
2427
import { StoreProvider } from './contexts/StoreProvider';
@@ -185,6 +188,7 @@ function LandingScreenWrapper({
185188
const showLandingScreen = useShowLandingScreen();
186189
const { openYAMLImportModal, dismissLandingScreen, openAIAssistantPanel } =
187190
useUICommands();
191+
const { pushEvent } = useLiveViewActions();
188192

189193
if (!showLandingScreen) return null;
190194

@@ -196,7 +200,7 @@ function LandingScreenWrapper({
196200
dismissLandingScreen();
197201
openAIAssistantPanel(prompt);
198202
}}
199-
onBuildFromScratch={() => {}}
203+
onBuildFromScratch={() => pushEvent('build_from_scratch', {})}
200204
onBrowseTemplates={() => {}}
201205
onImportYAML={openYAMLImportModal}
202206
/>

lib/lightning/workflows.ex

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,48 @@ defmodule Lightning.Workflows do
203203
|> save_workflow(actor, opts)
204204
end
205205

206+
@doc """
207+
Creates a new workflow with a webhook trigger, one job, and one always-on edge.
208+
Used by the "Build from Scratch" entry point on the new workflow screen.
209+
Returns `{:ok, workflow, trigger_id}` or `{:error, changeset}`.
210+
"""
211+
@spec create_webhook_workflow(String.t(), struct()) ::
212+
{:ok, Workflow.t(), String.t()} | {:error, Ecto.Changeset.t()}
213+
def create_webhook_workflow(project_id, actor) do
214+
trigger_id = Ecto.UUID.generate()
215+
job_id = Ecto.UUID.generate()
216+
217+
attrs = %{
218+
name: "New Workflow",
219+
project_id: project_id,
220+
triggers: [%{id: trigger_id, type: :webhook, enabled: true}],
221+
jobs: [
222+
%{
223+
id: job_id,
224+
name: "Transform Data",
225+
adaptor: "@openfn/language-common@latest",
226+
body: """
227+
// Check out the Job Writing Guide for help getting started:
228+
// https://docs.openfn.org/documentation/jobs/job-writing-guide
229+
"""
230+
}
231+
],
232+
edges: [
233+
%{
234+
source_trigger_id: trigger_id,
235+
target_job_id: job_id,
236+
condition_type: :always,
237+
enabled: true
238+
}
239+
]
240+
}
241+
242+
case save_workflow(attrs, actor) do
243+
{:ok, workflow} -> {:ok, workflow, trigger_id}
244+
{:error, changeset} -> {:error, changeset}
245+
end
246+
end
247+
206248
# Builds the Ecto.Multi pipeline for save_workflow. Does NOT call
207249
# Repo.transaction — that stays in the try/rescue block of the caller so
208250
# rescue wraps only the transaction, not this builder.

lib/lightning_web/live/workflow_live/collaborate.ex

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule LightningWeb.WorkflowLive.Collaborate do
1212

1313
alias Lightning.AiAssistant
1414
alias Lightning.Policies.Permissions
15+
alias Lightning.Policies.ProjectUsers
1516
alias Lightning.Projects
1617
alias Lightning.Projects.Project
1718
alias Lightning.Workflows
@@ -178,6 +179,38 @@ defmodule LightningWeb.WorkflowLive.Collaborate do
178179
)}
179180
end
180181

182+
def handle_event("build_from_scratch", _params, socket) do
183+
with :ok <-
184+
Permissions.can(
185+
ProjectUsers,
186+
:create_workflow,
187+
socket.assigns.current_user,
188+
socket.assigns.project
189+
),
190+
{:ok, workflow, trigger_id} <-
191+
Workflows.create_webhook_workflow(
192+
socket.assigns.project.id,
193+
socket.assigns.current_user
194+
) do
195+
{:noreply,
196+
push_navigate(socket,
197+
to:
198+
~p"/projects/#{socket.assigns.project}/w/#{workflow}?#{%{trigger: trigger_id}}"
199+
)}
200+
else
201+
{:error, :unauthorized} ->
202+
{:noreply,
203+
put_flash(
204+
socket,
205+
:error,
206+
"You don't have permission to create workflows"
207+
)}
208+
209+
{:error, _changeset} ->
210+
{:noreply, put_flash(socket, :error, "Failed to create workflow")}
211+
end
212+
end
213+
181214
@impl true
182215
def render(assigns) do
183216
~H"""

test/lightning/workflows_test.exs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,57 @@ defmodule Lightning.WorkflowsTest do
932932
end
933933
end
934934

935+
describe "create_webhook_workflow/2" do
936+
test "creates a workflow with a webhook trigger, a job, and an always edge" do
937+
project = insert(:project)
938+
user = insert(:user)
939+
940+
assert {:ok, workflow, trigger_id} =
941+
Workflows.create_webhook_workflow(project.id, user)
942+
943+
workflow =
944+
Repo.preload(workflow, [:triggers, :jobs, :edges])
945+
946+
assert workflow.name == "New Workflow"
947+
assert workflow.project_id == project.id
948+
949+
assert [%Trigger{id: ^trigger_id, type: :webhook, enabled: true}] =
950+
workflow.triggers
951+
952+
assert [
953+
%{
954+
name: "Transform Data",
955+
adaptor: "@openfn/language-common@latest",
956+
body: body
957+
}
958+
] = workflow.jobs
959+
960+
assert body =~ "Job Writing Guide"
961+
962+
[job] = workflow.jobs
963+
964+
assert [
965+
%{
966+
source_trigger_id: ^trigger_id,
967+
target_job_id: job_id,
968+
condition_type: :always,
969+
enabled: true
970+
}
971+
] = workflow.edges
972+
973+
assert job_id == job.id
974+
end
975+
976+
test "returns {:error, changeset} for an invalid project_id" do
977+
user = insert(:user)
978+
979+
assert {:error, %Ecto.Changeset{} = changeset} =
980+
Workflows.create_webhook_workflow(Ecto.UUID.generate(), user)
981+
982+
assert %{project: ["does not exist"]} = errors_on(changeset)
983+
end
984+
end
985+
935986
describe "save_workflow/3 rescue" do
936987
setup do
937988
Mimic.copy(Lightning.WorkflowVersions)

test/lightning_web/live/workflow_live/collaborate_test.exs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
defmodule LightningWeb.WorkflowLive.CollaborateTest do
22
use LightningWeb.ConnCase, async: false
33

4+
import Ecto.Query
45
import Lightning.Factories
56
import Lightning.WorkflowsFixtures
67
import Phoenix.LiveViewTest
@@ -2723,4 +2724,118 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do
27232724
assert html =~ "collaborative-editor-react"
27242725
end
27252726
end
2727+
2728+
describe "build_from_scratch event" do
2729+
test "creates a webhook workflow and navigates to it with the trigger param",
2730+
%{conn: conn} do
2731+
user = insert(:user)
2732+
2733+
project =
2734+
insert(:project,
2735+
project_users: [%{user_id: user.id, role: :owner}]
2736+
)
2737+
2738+
workflow = workflow_fixture(project_id: project.id)
2739+
2740+
conn = log_in_user(conn, user)
2741+
2742+
{:ok, view, _html} =
2743+
live(
2744+
conn,
2745+
~p"/projects/#{project.id}/w/#{workflow.id}"
2746+
)
2747+
2748+
view
2749+
|> element("#collaborative-editor-react")
2750+
|> render_hook("build_from_scratch", %{})
2751+
2752+
{path, _flash} = assert_redirect(view)
2753+
2754+
assert %{"trigger" => trigger_id} =
2755+
URI.decode_query(URI.parse(path).query)
2756+
2757+
assert path =~ ~r{^/projects/#{project.id}/w/[0-9a-f-]+\?}
2758+
2759+
new_workflow_id =
2760+
path
2761+
|> String.split("/w/")
2762+
|> List.last()
2763+
|> String.split("?")
2764+
|> List.first()
2765+
2766+
new_workflow =
2767+
Lightning.Workflows.get_workflow!(new_workflow_id)
2768+
|> Lightning.Repo.preload(:triggers)
2769+
2770+
assert new_workflow.project_id == project.id
2771+
assert [%{id: ^trigger_id, type: :webhook}] = new_workflow.triggers
2772+
end
2773+
2774+
test "user without project access gets a flash error", %{conn: conn} do
2775+
user = insert(:user)
2776+
2777+
project =
2778+
insert(:project,
2779+
project_users: [%{user_id: user.id, role: :owner}]
2780+
)
2781+
2782+
workflow = workflow_fixture(project_id: project.id)
2783+
2784+
conn = log_in_user(conn, user)
2785+
2786+
{:ok, view, _html} =
2787+
live(
2788+
conn,
2789+
~p"/projects/#{project.id}/w/#{workflow.id}"
2790+
)
2791+
2792+
# Revoke the user's membership after mount (e.g. removed from the
2793+
# project mid-session) so the LiveView's cached `project`/`current_user`
2794+
# assigns remain, but Bodyguard's live authorization check now fails.
2795+
Lightning.Repo.delete_all(
2796+
from(pu in Lightning.Projects.ProjectUser,
2797+
where: pu.project_id == ^project.id and pu.user_id == ^user.id
2798+
)
2799+
)
2800+
2801+
view
2802+
|> element("#collaborative-editor-react")
2803+
|> render_hook("build_from_scratch", %{})
2804+
2805+
assert %{"error" => "You don't have permission to create workflows"} =
2806+
:sys.get_state(view.pid).socket.assigns.flash
2807+
end
2808+
2809+
test "viewer gets a flash error and no workflow is created", %{conn: conn} do
2810+
user = insert(:user)
2811+
2812+
project =
2813+
insert(:project,
2814+
project_users: [%{user_id: user.id, role: :viewer}]
2815+
)
2816+
2817+
workflow = workflow_fixture(project_id: project.id)
2818+
2819+
conn = log_in_user(conn, user)
2820+
2821+
{:ok, view, _html} =
2822+
live(
2823+
conn,
2824+
~p"/projects/#{project.id}/w/#{workflow.id}"
2825+
)
2826+
2827+
workflow_count_before =
2828+
Lightning.Repo.aggregate(Lightning.Workflows.Workflow, :count)
2829+
2830+
view
2831+
|> element("#collaborative-editor-react")
2832+
|> render_hook("build_from_scratch", %{})
2833+
2834+
assert %{"error" => "You don't have permission to create workflows"} =
2835+
:sys.get_state(view.pid).socket.assigns.flash
2836+
2837+
assert Lightning.Repo.aggregate(Lightning.Workflows.Workflow, :count) ==
2838+
workflow_count_before
2839+
end
2840+
end
27262841
end

0 commit comments

Comments
 (0)