From 2c277310efcc20f8a130cdca9652d2331ebd9a61 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 29 Jun 2026 17:01:11 +0000 Subject: [PATCH 1/6] feat: remove legacy redirection & dead code --- .../components/Header.tsx | 43 - .../hooks/useWorkflow.tsx | 2 +- assets/js/utils/editorUrlConversion.ts | 41 - .../CollaborativeEditorPromoBanner.tsx | 53 - .../js/workflow-diagram/WorkflowDiagram.tsx | 2 - assets/test/e2e/pages/workflow-collab.page.ts | 4 +- .../collaborative-editor-navigation.spec.ts | 2 +- assets/test/utils/editorUrlConversion.test.ts | 53 - .../CollaborativeEditorPromoBanner.test.tsx | 99 - lib/lightning/collaboration/session.ex | 4 +- .../channels/workflow_channel.ex | 14 - .../controllers/legacy_redirect_controller.ex | 34 + lib/lightning_web/hooks.ex | 24 - .../live/workflow_live/collaborate.ex | 1 - .../live/workflow_live/components.ex | 17 - lib/lightning_web/live/workflow_live/edit.ex | 3915 ------------- .../live/workflow_live/editor_pane.ex | 107 - .../live/workflow_live/helpers.ex | 21 - .../live/workflow_live/job_view.ex | 328 -- .../workflow_live/new_workflow_component.ex | 768 --- lib/lightning_web/router.ex | 9 +- ...60629143825_clear_prefer_legacy_editor.exs | 9 + .../api/workflows_controller_test.exs | 11 +- .../legacy_redirect_controller_test.exs | 57 + .../live/ai_assistant_live_test.exs | 3595 ------------ .../live/workflow_live/collaborate_test.exs | 165 - .../live/workflow_live/edit_template_test.exs | 482 -- .../live/workflow_live/edit_test.exs | 5173 ----------------- .../live/workflow_live/editor_test.exs | 2003 ------- .../live/workflow_live/index_test.exs | 26 +- .../new_workflow_component_test.exs | 673 --- .../live/workflow_live/trigger_test.exs | 542 -- .../workflow_ai_chat_component_test.exs | 696 --- 33 files changed, 123 insertions(+), 18850 deletions(-) delete mode 100644 assets/js/workflow-diagram/CollaborativeEditorPromoBanner.tsx delete mode 100644 assets/test/workflow-diagram/CollaborativeEditorPromoBanner.test.tsx create mode 100644 lib/lightning_web/controllers/legacy_redirect_controller.ex delete mode 100644 lib/lightning_web/live/workflow_live/edit.ex delete mode 100644 lib/lightning_web/live/workflow_live/editor_pane.ex delete mode 100644 lib/lightning_web/live/workflow_live/job_view.ex delete mode 100644 lib/lightning_web/live/workflow_live/new_workflow_component.ex create mode 100644 priv/repo/migrations/20260629143825_clear_prefer_legacy_editor.exs create mode 100644 test/lightning_web/controllers/legacy_redirect_controller_test.exs delete mode 100644 test/lightning_web/live/ai_assistant_live_test.exs delete mode 100644 test/lightning_web/live/workflow_live/edit_template_test.exs delete mode 100644 test/lightning_web/live/workflow_live/edit_test.exs delete mode 100644 test/lightning_web/live/workflow_live/editor_test.exs delete mode 100644 test/lightning_web/live/workflow_live/new_workflow_component_test.exs delete mode 100644 test/lightning_web/live/workflow_live/trigger_test.exs delete mode 100644 test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs diff --git a/assets/js/collaborative-editor/components/Header.tsx b/assets/js/collaborative-editor/components/Header.tsx index e0fee91e9bc..58d215ac32e 100644 --- a/assets/js/collaborative-editor/components/Header.tsx +++ b/assets/js/collaborative-editor/components/Header.tsx @@ -3,13 +3,10 @@ import { useCallback, useContext, useState } from 'react'; import { useURLState } from '#/react/lib/use-url-state'; -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 { useIsNewWorkflow, useLimits, @@ -228,7 +225,6 @@ export function Header({ const isCreateWorkflowPanelCollapsed = useIsCreateWorkflowPanelCollapsed(); const importPanelState = useImportPanelState(); const { selectedTemplate } = useTemplatePanel(); - const { provider } = useSession(); const limits = useLimits(); const { isReadOnly } = useWorkflowReadOnly(); const { hasChanges } = useUnsavedChanges(); @@ -365,25 +361,6 @@ export function Header({ } }, [firstTriggerId, openRunPanel, selectNode, updateSearchParams]); - const handleSwitchToLegacyEditor = useCallback(async () => { - if (!provider?.channel || !projectId || !workflowId) return; - - try { - await channelRequest(provider.channel, 'switch_to_legacy_editor', {}); - - // Build legacy editor URL and navigate - const legacyUrl = buildClassicalEditorUrl({ - projectId, - workflowId, - searchParams: new URLSearchParams(window.location.search), - isNewWorkflow, - }); - window.location.href = legacyUrl; - } catch (error) { - console.error('Failed to switch to legacy editor:', error); - } - }, [provider, projectId, workflowId, isNewWorkflow]); - useKeyboardShortcut( 'Control+Enter, Meta+Enter', () => { @@ -452,26 +429,6 @@ export function Header({
{children} - {projectId && workflowId && ( - - Looking for the old version of the workflow builder? You can - switch back for a few more days by clicking this icon. (But it - will soon be retired!) - - } - side="bottom" - > - - - )}
diff --git a/assets/js/collaborative-editor/hooks/useWorkflow.tsx b/assets/js/collaborative-editor/hooks/useWorkflow.tsx index 30952149de3..aea64e8fcd0 100644 --- a/assets/js/collaborative-editor/hooks/useWorkflow.tsx +++ b/assets/js/collaborative-editor/hooks/useWorkflow.tsx @@ -561,7 +561,7 @@ export const useWorkflowActions = () => { const searchParams = new URLSearchParams(url.search); searchParams.delete('method'); // Close left panel const queryString = searchParams.toString(); - const newUrl = `/projects/${projectId}/w/${workflowId}/legacy${queryString ? `?${queryString}` : ''}`; + const newUrl = `/projects/${projectId}/w/${workflowId}${queryString ? `?${queryString}` : ''}`; window.history.pushState({}, '', newUrl); // Mark workflow as no longer new after first save sessionContextStore.clearIsNewWorkflow(); diff --git a/assets/js/utils/editorUrlConversion.ts b/assets/js/utils/editorUrlConversion.ts index b1f4b050640..894c1d930c0 100644 --- a/assets/js/utils/editorUrlConversion.ts +++ b/assets/js/utils/editorUrlConversion.ts @@ -149,47 +149,6 @@ export function classicalToCollaborativeParams( return collaborativeParams; } -/** - * Builds a complete classical editor URL from collaborative editor context - * - * @param options - URL building options - * @returns Complete URL string for classical editor - * - * @example - * const url = buildClassicalEditorUrl({ - * projectId: 'proj-123', - * workflowId: 'wf-456', - * searchParams: new URLSearchParams('run=789&job=abc&panel=editor'), - * isNewWorkflow: false - * }); - * // Returns: /projects/proj-123/w/wf-456?a=789&s=abc&m=expand - */ -export function buildClassicalEditorUrl(options: { - projectId: string; - workflowId: string | null; - searchParams: URLSearchParams; - isNewWorkflow?: boolean; -}): string { - const { - projectId, - workflowId, - searchParams, - isNewWorkflow = false, - } = options; - - const classicalParams = collaborativeToClassicalParams(searchParams); - const queryString = - classicalParams.toString().length > 0 - ? `?${classicalParams.toString()}` - : ''; - - const basePath = isNewWorkflow - ? `/projects/${projectId}/w/new/legacy` - : `/projects/${projectId}/w/${workflowId}/legacy`; - - return `${basePath}${queryString}`; -} - /** * Type guard to check if a value is a valid panel type */ diff --git a/assets/js/workflow-diagram/CollaborativeEditorPromoBanner.tsx b/assets/js/workflow-diagram/CollaborativeEditorPromoBanner.tsx deleted file mode 100644 index 33a2d42b891..00000000000 --- a/assets/js/workflow-diagram/CollaborativeEditorPromoBanner.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * CollaborativeEditorPromoBanner - Promotional banner encouraging users to try the collaborative editor - * - * Displays when: - * - Banner hasn't been previously dismissed (checked via cookie) - * - * Features: - * - Absolute position at bottom-center of workflow canvas - * - Dark themed design matching Tailwind sticky banner pattern - * - Dismissible via X button - * - Persists dismissal in cookies for 90 days - * - Navigation handled by LiveView via switch_to_collab_editor pushEvent - */ - -import { cn } from '#/utils/cn'; - -interface CollaborativeEditorPromoBannerProps { - className?: string; - pushEvent?: - | ((name: string, payload: Record) => void) - | undefined; -} - -export function CollaborativeEditorPromoBanner({ - className, - pushEvent, -}: CollaborativeEditorPromoBannerProps) { - return ( -
-
- -
-
- ); -} diff --git a/assets/js/workflow-diagram/WorkflowDiagram.tsx b/assets/js/workflow-diagram/WorkflowDiagram.tsx index 2e11d6a94c8..064985628ab 100644 --- a/assets/js/workflow-diagram/WorkflowDiagram.tsx +++ b/assets/js/workflow-diagram/WorkflowDiagram.tsx @@ -15,7 +15,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useWorkflowStore } from '../workflow-store/store'; import { AiAssistantToggle } from './AiAssistantToggle'; -import { CollaborativeEditorPromoBanner } from './CollaborativeEditorPromoBanner'; import MiniMapNode from './components/MiniMapNode'; import { FIT_DURATION, FIT_PADDING } from './constants'; import edgeTypes from './edges'; @@ -624,7 +623,6 @@ export default function WorkflowDiagram(props: WorkflowDiagramProps) { liveAction={props.liveAction} drawerWidth={drawerWidth} /> - {props.liveAction === 'edit' ? ( { await collabEditor.verifyUrl({ projectId: testData.projects.openhie.id, workflowId: testData.workflows.openhie.id, - path: '/legacy', + path: '', }); // Wait for React component to mount diff --git a/assets/test/utils/editorUrlConversion.test.ts b/assets/test/utils/editorUrlConversion.test.ts index 027e85a9de8..3b66fd322e0 100644 --- a/assets/test/utils/editorUrlConversion.test.ts +++ b/assets/test/utils/editorUrlConversion.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - buildClassicalEditorUrl, classicalToCollaborativeParams, collaborativeToClassicalParams, getMappingConfig, @@ -170,58 +169,6 @@ describe('editorUrlConversion', () => { }); }); - describe('buildClassicalEditorUrl', () => { - it('builds URL for existing workflow', () => { - const url = buildClassicalEditorUrl({ - projectId: 'proj-123', - workflowId: 'wf-456', - searchParams: new URLSearchParams('run=run-789&job=job-abc'), - isNewWorkflow: false, - }); - - expect(url).toBe( - '/projects/proj-123/w/wf-456/legacy?a=run-789&s=job-abc' - ); - }); - - it('builds URL for new workflow', () => { - const url = buildClassicalEditorUrl({ - projectId: 'proj-123', - workflowId: null, - searchParams: new URLSearchParams('job=job-abc&panel=editor'), - isNewWorkflow: true, - }); - - expect(url).toBe('/projects/proj-123/w/new/legacy?s=job-abc&m=expand'); - }); - - it('builds URL without query params when empty', () => { - const url = buildClassicalEditorUrl({ - projectId: 'proj-123', - workflowId: 'wf-456', - searchParams: new URLSearchParams(), - isNewWorkflow: false, - }); - - expect(url).toBe('/projects/proj-123/w/wf-456/legacy'); - }); - - it('handles complex parameter conversion', () => { - const url = buildClassicalEditorUrl({ - projectId: 'proj-1', - workflowId: 'wf-1', - searchParams: new URLSearchParams( - 'run=r-1&trigger=t-1&panel=run&v=5&method=ai' - ), - isNewWorkflow: false, - }); - - expect(url).toBe( - '/projects/proj-1/w/wf-1/legacy?a=r-1&s=t-1&m=workflow_input&v=5&method=ai' - ); - }); - }); - describe('Type guards', () => { describe('isValidPanelType', () => { it('returns true for valid panel types', () => { diff --git a/assets/test/workflow-diagram/CollaborativeEditorPromoBanner.test.tsx b/assets/test/workflow-diagram/CollaborativeEditorPromoBanner.test.tsx deleted file mode 100644 index 761d6d01a12..00000000000 --- a/assets/test/workflow-diagram/CollaborativeEditorPromoBanner.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * CollaborativeEditorPromoBanner Tests - * - * Verifies banner behavior: - * - Renders when not dismissed - * - Hides when dismissed (via cookie) - * - Cookie read/write functionality - * - Dismiss button functionality - * - pushEvent called for navigation - */ - -import { fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { CollaborativeEditorPromoBanner } from '../../js/workflow-diagram/CollaborativeEditorPromoBanner'; - -// ============================================================================= -// TEST SETUP & FIXTURES -// ============================================================================= - -const getCookie = (name: string): string | null => { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) { - return parts.pop()?.split(';').shift() || null; - } - return null; -}; - -const COOKIE_NAME = 'openfn_collaborative_editor_promo_dismissed'; - -describe('CollaborativeEditorPromoBanner', () => { - beforeEach(() => { - // Clear cookies before each test - document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - // =========================================================================== - // RENDERING TESTS - // =========================================================================== - - describe('rendering', () => { - test('renders banner when not previously dismissed', () => { - render(); - - expect( - screen.getByText(/This legacy workflow builder/) - ).toBeInTheDocument(); - }); - - test('does not render when cookie indicates dismissed', () => { - document.cookie = `${COOKIE_NAME}=true; path=/`; - - render(); - - expect( - screen.queryByText('This legacy workflow builder') - ).not.toBeInTheDocument(); - }); - - test('applies custom className', () => { - render(); - - const alert = screen.getByRole('alert'); - expect(alert).toHaveClass('custom-class'); - }); - }); - // =========================================================================== - // NAVIGATION TESTS - // =========================================================================== - - describe('navigation', () => { - test('calls pushEvent when banner button is clicked', () => { - const mockPushEvent = vi.fn(); - render(); - - const bannerButton = screen.getByRole('button', { - name: /This legacy workflow builder/, - }); - fireEvent.click(bannerButton); - - expect(mockPushEvent).toHaveBeenCalledWith('switch_to_collab_editor', {}); - }); - - test('does not error when pushEvent is not provided', () => { - render(); - - const bannerButton = screen.getByRole('button', { - name: /This legacy workflow builder/, - }); - // Should not throw - expect(() => fireEvent.click(bannerButton)).not.toThrow(); - }); - }); -}); diff --git a/lib/lightning/collaboration/session.ex b/lib/lightning/collaboration/session.ex index 577212a10ea..8e5fe914a51 100644 --- a/lib/lightning/collaboration/session.ex +++ b/lib/lightning/collaboration/session.ex @@ -119,8 +119,8 @@ defmodule Lightning.Collaboration.Session do SharedDoc.observe(shared_doc_pid) Logger.info("Joined SharedDoc for #{document_name}") - # We track the user presence here so the the original WorkflowLive.Edit - # can be stopped from editing the workflow when someone else is editing it. + # We track the user presence here so editors can see when someone else + # is editing the workflow. # Note: Presence tracking uses workflow.id, not document_name, because # presence is about showing who is editing the workflow, not which version Presence.track_user_presence( diff --git a/lib/lightning_web/channels/workflow_channel.ex b/lib/lightning_web/channels/workflow_channel.ex index c1ac23f49b4..8e7c6cdc2ca 100644 --- a/lib/lightning_web/channels/workflow_channel.ex +++ b/lib/lightning_web/channels/workflow_channel.ex @@ -184,20 +184,6 @@ defmodule LightningWeb.WorkflowChannel do end) end - @impl true - def handle_in("switch_to_legacy_editor", _payload, socket) do - user = socket.assigns[:current_user] - - # Set switch to legacy to true - Lightning.Accounts.update_user_preference( - user, - "prefer_legacy_editor", - true - ) - - {:reply, {:ok, %{}}, socket} - end - @impl true def handle_in("get_context", _payload, socket) do user = socket.assigns[:current_user] diff --git a/lib/lightning_web/controllers/legacy_redirect_controller.ex b/lib/lightning_web/controllers/legacy_redirect_controller.ex new file mode 100644 index 00000000000..eb1e62c5a02 --- /dev/null +++ b/lib/lightning_web/controllers/legacy_redirect_controller.ex @@ -0,0 +1,34 @@ +defmodule LightningWeb.LegacyRedirectController do + @moduledoc """ + Redirects retired legacy workflow editor URLs to the collaborative editor. + + The legacy editor (`WorkflowLive.Edit`) has been sunset in favour of the + collaborative editor (`WorkflowLive.Collaborate`). These actions keep old + bookmarks working by redirecting `/projects/:project_id/w/.../legacy` URLs to + their collaborative equivalents, preserving the original query string. + + The collaborative editor uses different query param names than the legacy + editor, so the raw query string is forwarded as-is rather than mapped. + """ + use LightningWeb, :controller + + def new(conn, %{"project_id" => project_id}) do + redirect_preserving_query(conn, "/projects/#{project_id}/w/new") + end + + def edit(conn, %{"project_id" => project_id, "id" => id}) do + redirect_preserving_query(conn, "/projects/#{project_id}/w/#{id}") + end + + defp redirect_preserving_query(conn, base_path) do + target = + case conn.query_string do + "" -> base_path + query -> base_path <> "?" <> query + end + + conn + |> redirect(to: target) + |> halt() + end +end diff --git a/lib/lightning_web/hooks.ex b/lib/lightning_web/hooks.ex index 56813d3268b..0db77cc6a7e 100644 --- a/lib/lightning_web/hooks.ex +++ b/lib/lightning_web/hooks.ex @@ -138,28 +138,4 @@ defmodule LightningWeb.Hooks do {:cont, socket} end end - - def on_mount(:check_legacy_preference, params, _session, socket) do - case socket.assigns do - %{current_user: user, live_action: live_action} - when live_action in [:edit, :new] -> - prefer_legacy_editor = - Lightning.Accounts.get_preference(user, "prefer_legacy_editor") - - if prefer_legacy_editor do - path = - LightningWeb.WorkflowLive.Helpers.legacy_editor_url( - params, - live_action - ) - - {:halt, push_navigate(socket, to: path)} - else - {:cont, socket} - end - - _ -> - {:cont, socket} - end - end end diff --git a/lib/lightning_web/live/workflow_live/collaborate.ex b/lib/lightning_web/live/workflow_live/collaborate.ex index b703f886ade..d6c2711cbd0 100644 --- a/lib/lightning_web/live/workflow_live/collaborate.ex +++ b/lib/lightning_web/live/workflow_live/collaborate.ex @@ -22,7 +22,6 @@ defmodule LightningWeb.WorkflowLive.Collaborate do on_mount({LightningWeb.Hooks, :project_scope}) on_mount({LightningWeb.Hooks, :check_limits}) - on_mount({LightningWeb.Hooks, :check_legacy_preference}) @impl true def mount( diff --git a/lib/lightning_web/live/workflow_live/components.ex b/lib/lightning_web/live/workflow_live/components.ex index 9ab5f93b9a4..09628a0f230 100644 --- a/lib/lightning_web/live/workflow_live/components.ex +++ b/lib/lightning_web/live/workflow_live/components.ex @@ -101,23 +101,6 @@ defmodule LightningWeb.WorkflowLive.Components do """ end - attr :id, :string, required: true - - def deprecated_warning(assigns) do - ~H""" - - """ - end - attr :form, :map, required: true attr :can_edit_run_settings, :boolean, required: true attr :project_concurrency_disabled, :boolean, required: true diff --git a/lib/lightning_web/live/workflow_live/edit.ex b/lib/lightning_web/live/workflow_live/edit.ex deleted file mode 100644 index d15d530644a..00000000000 --- a/lib/lightning_web/live/workflow_live/edit.ex +++ /dev/null @@ -1,3915 +0,0 @@ -defmodule LightningWeb.WorkflowLive.Edit do - @moduledoc false - use LightningWeb, {:live_view, container: {:div, []}} - - import LightningWeb.Components.NewInputs - import LightningWeb.Components.Icons - import LightningWeb.WorkflowLive.Components - import React - - alias Lightning.AiAssistant - alias Lightning.Invocation - alias Lightning.OauthClients - alias Lightning.Policies.Permissions - alias Lightning.Policies.ProjectUsers - alias Lightning.Projects - alias Lightning.Runs - alias Lightning.Runs.Events.DataclipUpdated - alias Lightning.Runs.Events.RunUpdated - alias Lightning.Runs.Events.StepCompleted - alias Lightning.VersionControl - alias Lightning.Workflows - alias Lightning.Workflows.Events.WorkflowUpdated - alias Lightning.Workflows.Job - alias Lightning.Workflows.Presence - alias Lightning.Workflows.Snapshot - alias Lightning.Workflows.Trigger - alias Lightning.Workflows.Workflow - alias Lightning.Workflows.WorkflowTemplate - alias Lightning.WorkflowTemplates - alias Lightning.WorkOrders - alias LightningWeb.UiMetrics - alias LightningWeb.WorkflowLive.Helpers - alias LightningWeb.WorkflowLive.NewManualRun - alias LightningWeb.WorkflowNewLive.WorkflowParams - alias Phoenix.LiveView.JS - - require Lightning.Run - require Logger - - on_mount({LightningWeb.Hooks, :project_scope}) - on_mount {LightningWeb.Hooks, :check_limits} - - attr :selection, :string, required: false - attr :aiAssistantId, :string, required: false - attr :showAiAssistant, :boolean, required: false - attr :canEditWorkflow, :boolean, required: false - attr :snapshotVersionTag, :string, required: false - attr :aiAssistantEnabled, :boolean, required: false - attr :liveAction, :string, required: false - - jsx("assets/js/workflow-editor/WorkflowEditor.tsx") - jsx("assets/js/workflow-store/WorkflowStore.tsx") - - attr :job_id, :string - jsx("assets/js/manual-run-panel/ManualRunPanel.tsx") - - attr :job_id, :string - attr :job_title, :string - attr :cancel_url, :string - attr :back_url, :string - attr :is_edge, :boolean - jsx("assets/js/panel/panels/WorkflowRunPanel.tsx") - - attr :changeset, :map, required: true - attr :project_user, :map, required: true - - @impl true - def render(assigns) do - assigns = - assigns - |> assign( - workflow_form: to_form(assigns.changeset), - save_and_run_disabled: save_and_run_disabled?(assigns), - display_banner: - !assigns.has_presence_edit_priority && - assigns.current_user.id not in assigns.view_only_users_ids && - assigns.snapshot_version_tag == "latest", - banner_message: - banner_message( - assigns.current_user_presence, - assigns.prior_user_presence - ) - ) - - ~H""" - - <:banner> - - - <:header> - - <:breadcrumbs> - - - - - <:label> -
- {@page_title} - - <%= if @project.env do %> -
- - {@project.env} - -
- <% end %> - - -
- - <.icon - name="hero-information-circle-solid" - class="h-4 w-4 text-primary-600 opacity-50" - /> Read-only - -
-
- -
-
- - - <.button - :if={@snapshot_version_tag != "latest"} - id={"version-switcher-button-#{@workflow.id}"} - type="button" - theme="primary" - phx-click="switch-version" - phx-value-type="commit" - class="mr-4" - > - Switch to latest version - - - <.with_changes_indicator - :if={@snapshot_version_tag == "latest" && !@show_new_workflow_panel} - changeset={@changeset} - > -
- <.icon - :if={!@can_edit_workflow} - name="hero-lock-closed" - class="w-5 h-5 place-self-center text-gray-300" - /> -
- <.input - id="workflow" - type="toggle" - name="workflow_state" - disabled={@sending_ai_message} - value={Helpers.workflow_enabled?(@changeset)} - tooltip={Helpers.workflow_state_tooltip(@changeset)} - on_click="toggle_workflow_state" - /> -
- <.settings_icon - :if={!@show_new_workflow_panel} - changeset={@changeset} - selection_mode={@selection_mode} - base_url={@base_url} - query_params={@query_params} - show_workflow_ai_chat={@show_workflow_ai_chat} - workflow_chat_session_id={@workflow_chat_session_id} - job_chat_session_id={@job_chat_session_id} - /> -
- <.offline_indicator /> -
- <.run_workflow_button - base_url={@base_url} - show_workflow_ai_chat={@show_workflow_ai_chat} - workflow_chat_session_id={@workflow_chat_session_id} - job_chat_session_id={@job_chat_session_id} - query_params={@query_params} - trigger_id={ - if is_list(@workflow_params["triggers"]) and - @workflow_params["triggers"] != [] do - hd(@workflow_params["triggers"])["id"] - else - "" - end - } - , - sending_ai_message={@sending_ai_message} - /> - <.save_workflow_button - id="top-bar-save-workflow-btn" - changeset={@changeset} - can_edit_workflow={@can_edit_workflow} - snapshot_version_tag={@snapshot_version_tag} - has_presence_priority={@has_presence_edit_priority} - sending_ai_message={@sending_ai_message} - project_repo_connection={@project_repo_connection} - dropdown_position={:bottom} - /> -
- -
- - <.WorkflowStore react-id="workflow-mount" /> -
- <.live_component - :if={@show_new_workflow_panel} - id={@new_workflow_panel_id} - ai_assistant_component_id={@new_workflow_ai_assistant_id} - module={LightningWeb.WorkflowLive.NewWorkflowComponent} - workflow={@workflow} - project={@project} - selected_method={@method || "template"} - base_url={@base_url} - chat_session_id={@workflow_chat_session_id} - query_params={@query_params} - user={@current_user} - can_edit={@can_edit_workflow} - class="transition-all duration-300 ease-in-out" - /> -
- <.live_component - :if={@show_workflow_ai_chat} - id={@workflow_ai_chat_id} - ai_assistant_component_id={@workflow_ai_assistant_id} - module={LightningWeb.WorkflowLive.WorkflowAiChatComponent} - workflow={@workflow} - workflow_code={@workflow_code_with_ids} - project={@project} - base_url={@base_url} - query_params={@query_params} - chat_session_id={@workflow_chat_session_id} - user={@current_user} - can_edit={@can_edit_workflow} - class="transition-all duration-300 ease-in-out" - /> - <.selected_template_label - :if={@selected_template && @show_new_workflow_panel} - template={@selected_template} - class="transition-all duration-300 ease-in-out" - /> - <.canvas_placeholder_card :if={@show_canvas_placeholder} /> -
-
- - <.collapsible_panel - id={"manual-job-#{@selected_job.id}"} - class="h-full border border-l-0 manual-job-panel" - > - <:tabs> - - <:tab hash="manual"> - Input - - <:tab hash="aichat"> - AI Assistant - - - - - <:panel hash="manual" class="overflow-auto h-full"> -
- <.ManualRunPanel - :if={@selection_mode === "expand"} - job_id={@selected_job.id} - /> -
- - <:panel hash="aichat" class="h-full"> -
- <.live_component - module={LightningWeb.AiAssistant.Component} - mode={:job} - can_edit={@can_edit_workflow} - project={@project} - user={@current_user} - chat_session_id={@job_chat_session_id} - code={nil} - query_params={@query_params} - base_url={@base_url} - action={if(@job_chat_session_id, do: :show, else: :new)} - callbacks={%{}} - selected_job={@selected_job} - follow_run={@follow_run} - id={@job_ai_assistant_id_fn.(@selected_job.id)} - /> -
- -
- - <:footer> -
- <% {is_empty, error_message} = - editor_is_empty(@workflow_form, @selected_job) %> - -
- - <.icon name="hero-lock-closed-solid" class="h-4 w-4" /> - Read-only - -
- - <.version_switcher_toggle - :if={display_switcher(@snapshot, @workflow)} - id={@selected_job.id} - label="Latest Version" - disabled={ - job_deleted?(@selected_job, @workflow) || @sending_ai_message - } - version={@snapshot_version_tag} - /> - - <.save_is_blocked_error :if={is_empty}> - {error_message} - - - <.icon - :if={!@can_edit_workflow} - name="hero-lock-closed" - class="w-5 h-5 place-self-center text-gray-300" - /> - <.run_buttons - step={@step} - manual_run_form={@manual_run_form} - selectable_dataclips={@selectable_dataclips} - follow_run={@follow_run} - save_and_run_disabled={@save_and_run_disabled} - sending_ai_message={@sending_ai_message} - snapshot_version_tag={@snapshot_version_tag} - /> - <.with_changes_indicator changeset={@changeset}> - <.save_workflow_button - id="inspector-save-workflow-btn" - changeset={@changeset} - can_edit_workflow={@can_edit_workflow} - snapshot_version_tag={@snapshot_version_tag} - has_presence_priority={@has_presence_edit_priority} - sending_ai_message={@sending_ai_message} - project_repo_connection={@project_repo_connection} - dropdown_position={:top} - /> - -
- -
-
-
- - <.WorkflowEditor - :if={!@show_canvas_placeholder} - react-portal-target="workflow-mount" - selection={ - if @selected_job || @selected_trigger || @selected_edge, - do: (@selected_job || @selected_trigger || @selected_edge).id, - else: nil - } - showAiAssistant={@show_workflow_ai_chat} - aiAssistantId={@workflow_ai_chat_id} - canEditWorkflow={@can_edit_workflow} - snapshotVersionTag={@snapshot_version_tag} - aiAssistantEnabled={@ai_assistant_enabled} - liveAction={Atom.to_string(@live_action)} - /> - - <.live_component - :if={@selected_job && @can_edit_workflow && @show_job_credential_modal} - id="new-credential-modal" - module={LightningWeb.CredentialLive.CredentialFormComponent} - action={:new} - credential_type={nil} - credential={ - %Lightning.Credentials.Credential{ - user_id: @current_user.id, - project_credentials: @new_credential_project_credentials - } - } - current_user={@current_user} - oauth_client={nil} - oauth_clients={@oauth_clients} - projects={[]} - project={@project} - on_save={ - fn credential -> - form = single_inputs_for(@workflow_form, :jobs, @selected_job.id) - - project_credential_id = - credential.project_credentials |> Enum.at(0) |> Map.get(:id) - - job_index = Integer.to_string(form.index) - - params = - LightningWeb.Utils.build_params_for_field( - form, - :project_credential_id, - project_credential_id - ) - |> put_in( - ["workflow", "jobs", job_index, "keychain_credential_id"], - nil - ) - - send_form_changed(params) - end - } - on_modal_close={JS.push("toggle_job_credential_modal")} - can_create_project_credential={@can_edit_workflow} - return_to={ - Helpers.build_url(assigns, [Helpers.param("s", @selected_job.id)]) - } - /> - - <.live_component - :if={@project_repo_connection && @show_github_sync_modal} - id="github-sync-modal" - module={LightningWeb.WorkflowLive.GithubSyncModal} - current_user={@current_user} - project_repo_connection={@project_repo_connection} - /> - <.form - :if={@selection_mode != "expand"} - id="workflow-form" - for={@workflow_form} - phx-submit="save" - phx-hook="SaveViaCtrlS" - phx-change="validate" - > - - <.panel - :if={@selection_mode == "settings"} - title="Workflow settings" - id={"workflow-settings-#{@workflow.id}"} - class="hidden" - phx-mounted={fade_in()} - phx-remove={fade_out()} - cancel_url={close_url(assigns, nil, :unselect)} - > - <.workflow_settings - can_edit_run_settings={@can_edit_run_settings} - project_id={@workflow.project_id} - code_view_url={ - Helpers.build_url(assigns, Helpers.code_view_params()) - } - project_concurrency_disabled={@workflow.project.concurrency == 1} - sending_ai_message={@sending_ai_message} - max_concurrency={@max_concurrency} - form={@workflow_form} - /> - - - <.single_inputs_for - :let={{jf}} - :if={@selected_job && @selection_mode != "workflow_input"} - form={@workflow_form} - field={:jobs} - id={@selected_job.id} - > - <.panel - title={ - jf[:name].value - |> then(fn - "" -> "Untitled Job" - name -> name - end) - } - id={"job-pane-#{@selected_job.id}"} - data-testid={"job-pane-#{jf.index}"} - cancel_url={close_url(assigns, :selected_job, :unselect)} - class="hidden" - phx-mounted={fade_in()} - phx-remove={fade_out()} - > - <.job_form - on_change={&send_form_changed/1} - editable={ - is_nil(@workflow.deleted_at) && @can_edit_workflow && - @snapshot_version_tag == "latest" && - @has_presence_edit_priority && !@sending_ai_message - } - form={jf} - project={@project} - /> - <:footer> -
-
- <.expand_job_editor - base_url={@base_url} - snapshot_lock_version={@snapshot && @snapshot.lock_version} - snapshot_version_tag={@snapshot_version_tag} - job={@selected_job} - selected_run={@selected_run} - query_params={@query_params} - form={@workflow_form} - show_workflow_ai_chat={@show_workflow_ai_chat} - workflow_chat_session_id={@workflow_chat_session_id} - job_chat_session_id={@job_chat_session_id} - sending_ai_message={@sending_ai_message} - /> - <.button_link - patch={ - Helpers.build_url( - assigns, - Helpers.workflow_input_params(@selected_job.id) - ) - } - type="button" - disabled={@sending_ai_message} - theme="primary" - > - Run - -
-
- -
-
- - - - <.single_inputs_for - :let={tf} - :if={@selected_trigger && @selection_mode != "workflow_input"} - form={@workflow_form} - field={:triggers} - id={@selected_trigger.id} - > - <.panel - id={"trigger-pane-#{@selected_trigger.id}"} - cancel_url={close_url(assigns, :selected_trigger, :unselect)} - class="hidden" - phx-mounted={fade_in()} - phx-remove={fade_out()} - title={ - Phoenix.HTML.Form.input_value(tf, :type) - |> to_string() - |> render_trigger_title() - } - > - <.trigger_form - form={tf} - on_change={&send_form_changed/1} - disabled={ - (!is_nil(@workflow.deleted_at) or !@can_edit_workflow or - @snapshot_version_tag != "latest") || - !@has_presence_edit_priority || @sending_ai_message - } - can_write_webhook_auth_method={@can_write_webhook_auth_method} - selected_trigger={@selected_trigger} - action={@live_action} - cancel_url={close_url(assigns, :selected_trigger, :unselect)} - /> - <:footer> -
-
- <.input - type="toggle" - field={tf[:enabled]} - disabled={ - (!is_nil(@workflow.deleted_at) or !@can_edit_workflow or - @snapshot_version_tag != "latest") || - !@has_presence_edit_priority || @sending_ai_message - } - label="Enabled" - /> -
- <.button_link - patch={ - Helpers.build_url( - assigns, - Helpers.workflow_input_params(@selected_trigger.id) - ) - } - disabled={@sending_ai_message} - type="button" - theme="primary" - > - <.icon name="hero-play-solid" class="w-4 h-4" /> Run - -
- - - - <.single_inputs_for - :let={ef} - :if={@selected_edge && @selection_mode != "workflow_input"} - form={@workflow_form} - field={:edges} - id={@selected_edge.id} - > - <.panel - id={"edge-pane-#{@selected_edge.id}"} - cancel_url={close_url(assigns, :selected_edge, :unselect)} - title="Path" - class="hidden" - phx-mounted={fade_in()} - phx-remove={fade_out()} - > - <.edge_form - form={ef} - disabled={ - (!is_nil(@workflow.deleted_at) or !@can_edit_workflow or - @snapshot_version_tag != "latest") || - !@has_presence_edit_priority || @sending_ai_message - } - cancel_url={close_url(assigns, :selected_edge, :unselect)} - /> - <:footer> -
-
- <%= if ef[:source_trigger_id].value do %> -

- This path will be active if its trigger is enabled -

- <% else %> - <.input - type="toggle" - field={ef[:enabled]} - disabled={ - (!is_nil(@workflow.deleted_at) or !@can_edit_workflow or - @snapshot_version_tag != "latest") || - !@has_presence_edit_priority || @sending_ai_message - } - label="Enabled" - /> - <% end %> -
-
- -
-
- - - -
- <.WorkflowRunPanel - job_id={ - if @selected_job do - @selected_job.id - else - hd(@workflow_params["jobs"])["id"] - end - } - job_title={ - if @selected_job do - @selected_job.name - else - "Trigger" - end - } - is_edge={ - if @selected_edge do - true - else - false - end - } - cancel_url={ - Helpers.build_url(assigns, Helpers.params_without_mode_selection()) - } - back_url={ - if @selected_job do - Helpers.build_url( - assigns, - [Helpers.param("s", @selected_job.id)] ++ - Helpers.orthogonal_params() - ) - else - Helpers.build_url( - assigns, - Helpers.params_without_mode_selection() - ) - end - } - /> -
- - - <.panel - :if={@selection_mode == "code"} - title={ - if @publish_template, - do: "Publish Workflow as Template", - else: "Workflow as Code" - } - id={"workflow-code-#{@workflow.id}"} - class="hidden min-w-lg" - phx-mounted={fade_in()} - phx-remove={fade_out()} - cancel_url={close_url(assigns, nil, :unselect)} - > -
- <.text_ping_loader> - Stand by - -
- <.textarea_element - :if={@workflow_code && !@publish_template} - id="workflow-code-viewer" - name="workflow-code" - value={@workflow_code} - rows="18" - disabled={true} - class="font-mono proportional-nums text-slate-200 bg-slate-700 resize-none text-nowrap overflow-x-auto" - /> - <.form - :let={f} - :if={@workflow_code && @publish_template} - for={@workflow_template_changeset} - id="workflow-template-form" - phx-change="validate" - phx-submit="save" - > -
- <.input - type="text" - field={f[:name]} - label="Name" - required={true} - placeholder="A descriptive name for your template" - /> - - <.input - type="textarea" - field={f[:description]} - label="Description" - rows="6" - class="bg-white text-slate-900" - placeholder="A detailed description of what this template does" - /> - -
- <.input - type="tag" - field={f[:tags]} - label="Tags" - placeholder="Separate tags with commas (,)" - /> -
-
- - <:footer> -
- <.button - theme="secondary" - id="download-workflow-code-btn" - data-target="#workflow-code-viewer" - data-content-type="text/yaml" - data-file-name={String.replace(@workflow.name || "workflow"," ", "-") <> ".yaml"} - phx-hook="DownloadText" - > - Download - - <.button - theme="secondary" - id="copy-workflow-code-btn" - data-to="#workflow-code-viewer" - phx-hook="Copy" - class="min-w-[6rem]" - > - Copy Code - - <.button - :if={@current_user.support_user} - theme="primary" - id="publish-template-btn" - phx-click="publish_template" - class="min-w-[8rem]" - disabled={@changeset.changes |> Enum.any?() || @sending_ai_message} - tooltip={ - if @changeset.changes |> Enum.any?(), - do: - "You must save your workflow first before #{if @has_workflow_template?, do: "updating", else: "publishing"} a template.", - else: nil - } - > - {if @has_workflow_template?, - do: "Update Template", - else: "Publish Template"} - -
-
- <.button - type="submit" - theme="primary" - form="workflow-template-form" - disabled={ - !@workflow_template_changeset.valid? || @sending_ai_message - } - > - {if @has_workflow_template?, - do: "Update Template", - else: "Publish Template"} - - <.button - id="cancel-template-publish" - type="button" - phx-click="cancel_publish_template" - theme="secondary" - > - Back - -
- - - - <.live_component - :if={ - @live_action == :edit && @can_write_webhook_auth_method && - @selected_trigger && @snapshot_version_tag == "latest" && - @active_modal == :webhook_auth_method - } - module={LightningWeb.WorkflowLive.WebhookAuthMethodModalComponent} - id="manage_webhook_auth_methods" - action={:index} - trigger={@selected_trigger} - project={@project} - current_user={@current_user} - on_close={JS.push("close_active_modal")} - on_save={ - fn trigger_or_auth_method -> - send(self(), {:webhook_auth_method_updated, trigger_or_auth_method}) - end - } - return_to={ - Helpers.build_url(assigns, [Helpers.param("s", @selected_trigger.id)]) - } - /> -
-
-
- """ - end - - defp run_buttons(assigns) do - ~H""" -
- <.save_and_run_button {assigns} /> - <.create_new_work_order_dropdown - :if={step_retryable?(@step, @manual_run_form, @selectable_dataclips)} - {assigns} - /> -
- """ - end - - defp save_and_run_button(assigns) do - ~H""" - <.button - id="save-and-run" - theme="primary" - phx-hook="DefaultRunViaCtrlEnter" - {save_and_run_attributes(assigns)} - class={save_and_run_classes(assigns)} - disabled={ - assigns.save_and_run_disabled || - processing(assigns.follow_run) || - selected_dataclip_wiped?( - assigns.manual_run_form, - assigns.selectable_dataclips - ) || - assigns.snapshot_version_tag != "latest" || @sending_ai_message - } - > - <%= if processing(@follow_run) do %> - <.icon name="hero-arrow-path" class="w-4 h-4 animate-spin mr-1" /> Processing - <% else %> - <%= if step_retryable?(@step, @manual_run_form, @selectable_dataclips) do %> - <.icon name="hero-play-mini" class="w-4 h-4 mr-1" /> Run (Retry) - <% else %> - <.icon name="hero-play-mini" class="w-4 h-4 mr-1" /> Run - <% end %> - <% end %> - - """ - end - - defp create_new_work_order_dropdown(assigns) do - ~H""" -
- <.button - type="button" - theme="primary" - class="h-full rounded-l-none pr-1 pl-1" - id="option-menu-button" - aria-expanded="true" - aria-haspopup="true" - disabled={ - @save_and_run_disabled || @sending_ai_message || - @snapshot_version_tag != "latest" - } - phx-click={show_dropdown("create-new-work-order")} - > - Open options - <.icon name="hero-chevron-down" class="w-4 h-4" /> - -
- <.button - phx-click-away={hide_dropdown("create-new-work-order")} - phx-hook="AltRunViaCtrlShiftEnter" - id="create-new-work-order" - type="submit" - form={@manual_run_form.id} - theme="secondary" - class="hidden absolute right-0 bottom-9 z-10 mb-2 w-max" - disabled={@save_and_run_disabled || @snapshot_version_tag != "latest"} - > - <.icon name="hero-play-solid" class="w-4 h-4 mr-1" /> Run (New Work Order) - -
-
- """ - end - - defp version_switcher_toggle(assigns) do - ~H""" -
- - - {@label} - - - -
- """ - end - - defp expand_job_editor(assigns) do - {is_empty, error_message} = editor_is_empty(assigns.form, assigns.job) - - params = - Helpers.with_params( - s: assigns.job.id, - m: "expand", - a: [ - value: fn a, _ -> a.selected_run end, - when: fn a, _ -> a.selected_run != nil end - ], - v: [ - value: fn a, _ -> a.snapshot_lock_version end, - when: fn a, _ -> a.snapshot_version_tag != "latest" end - ] - ) - - url = Helpers.build_url(assigns, params) - - assigns = - assign(assigns, - is_empty: is_empty, - error_message: error_message, - url: url - ) - - ~H""" - <.button_link - id={"open-inspector-#{@job.id}"} - patch={@url} - disabled={@sending_ai_message} - theme="primary" - > - Edit - - - <.save_is_blocked_error :if={@is_empty}> - {@error_message} - - """ - end - - defp save_is_blocked_error(assigns) do - ~H""" - - <.icon name="hero-exclamation-circle" class="h-5 w-5" /> - {render_slot(@inner_block)} - - """ - end - - defp single_inputs_for(form, field, id) do - %Phoenix.HTML.FormField{field: field_name, form: parent_form} = form[field] - - parent_form.impl.to_form(parent_form.source, parent_form, field_name, []) - |> Enum.find(&(Ecto.Changeset.get_field(&1.source, :id) == id)) - end - - defp single_inputs_for(%{field: :jobs} = assigns) do - %{form: form, field: field} = assigns - - %Phoenix.HTML.FormField{field: field_name, form: parent_form} = form[field] - - forms = - parent_form.impl.to_form(parent_form.source, parent_form, field_name, []) - |> Enum.filter(&(Ecto.Changeset.get_field(&1.source, :id) == assigns[:id])) - - assigns = assigns |> assign(forms: forms) - - ~H""" - <%= for f <- @forms do %> - {render_slot(@inner_block, {f})} - <% end %> - """ - end - - defp single_inputs_for(assigns) do - %{form: form, field: field} = assigns - - %Phoenix.HTML.FormField{field: field_name, form: parent_form} = form[field] - - forms = - parent_form.impl.to_form(parent_form.source, parent_form, field_name, []) - |> Enum.filter(&(Ecto.Changeset.get_field(&1.source, :id) == assigns[:id])) - - assigns = assigns |> assign(forms: forms) - - ~H""" - <%= for f <- @forms do %> - {render_slot(@inner_block, f)} - <% end %> - """ - end - - defp with_changes_indicator(assigns) do - ~H""" -
-
Enum.any?()} - class="absolute -m-1 rounded-full bg-danger-500 w-3 h-3 top-0 right-0 z-10" - data-is-dirty="true" - > -
- {render_slot(@inner_block)} -
- """ - end - - attr :id, :string, required: true - attr :can_edit_workflow, :boolean, required: true - attr :changeset, Ecto.Changeset, required: true - attr :snapshot_version_tag, :string, required: true - attr :has_presence_priority, :boolean, required: true - attr :sending_ai_message, :boolean, default: false - attr :project_repo_connection, :map, required: true - attr :dropdown_position, :atom, values: [:top, :bottom], required: true - - defp save_workflow_button(assigns) do - {disabled, tooltip} = - case assigns do - %{ - changeset: %{valid?: true, data: %{deleted_at: nil}}, - can_edit_workflow: true, - snapshot_version_tag: "latest", - has_presence_priority: true, - sending_ai_message: false - } -> - {false, nil} - - %{changeset: %{data: %{deleted_at: deleted_at}}} - when is_struct(deleted_at) -> - {true, "Workflow has been deleted"} - - %{can_edit_workflow: false} -> - {true, "You do not have permission to edit this workflow"} - - %{sending_ai_message: true} -> - {true, "AI is currently processing your request"} - - %{changeset: %{valid?: false}} -> - {true, "You have unresolved errors in your workflow"} - - %{snapshot_version_tag: tag} when tag != "latest" -> - {true, "You cannot edit an old snapshot of a workflow"} - - _other -> - {true, nil} - end - - assigns = assign(assigns, disabled: disabled, tooltip: tooltip) - - ~H""" -
- <.button - id={@id} - phx-disable-with - disabled={@disabled} - {if @disabled, do: [], else: ["phx-hook": "InspectorSaveViaCtrlS", "phx-click": JS.push("save")]} - phx-disconnected={JS.set_attribute({"disabled", ""})} - tooltip={@tooltip} - class={ - ["focus:ring-transparent"] ++ - if @project_repo_connection, do: ["rounded-r-none"], else: [] - } - phx-connected={!@disabled && JS.remove_attribute("disabled")} - theme="primary" - > - Save - -
- <.button - type="button" - class="h-full rounded-l-none pr-1 pl-1" - id={"#{@id}-save-and-sync-option-menu-button"} - aria-expanded="true" - aria-haspopup="true" - disabled={@disabled} - phx-click={show_dropdown("#{@id}-save-and-sync")} - phx-disconnected={JS.set_attribute({"disabled", ""})} - phx-connected={!@disabled && JS.remove_attribute("disabled")} - theme="primary" - > - Open options - <.icon name="hero-chevron-down" class="w-4 h-4" /> - -
- <.button - phx-click-away={hide_dropdown("#{@id}-save-and-sync")} - id={"#{@id}-save-and-sync"} - type="button" - phx-click="toggle_github_sync_modal" - theme="secondary" - class={[ - "hidden absolute right-0 z-10 w-max", - if(@dropdown_position == :top, do: "bottom-9 mb-2"), - if(@dropdown_position == :bottom, do: "top-9 mt-2") - ]} - disabled={@disabled} - phx-hook="OpenSyncModalViaCtrlShiftS" - > - Save & Sync - -
-
-
- """ - end - - defp settings_icon(assigns) do - base_icon_class = "w-5 h-5 place-self-center cursor-pointer" - - class = - if workflow_settings_errors?(assigns.changeset) do - base_icon_class <> " text-danger-500 hover:text-danger-400" - else - base_icon_class <> " text-slate-500 hover:text-slate-400" - end - - params = - if assigns.selection_mode == "settings" do - Helpers.orthogonal_params() - else - [Helpers.param("m", "settings")] ++ Helpers.orthogonal_params() - end - - url = Helpers.build_url(assigns, params) - assigns = assigns |> assign(:class, class) |> assign(:url, url) - - ~H""" - <.link patch={@url} class={@class} id="toggle-settings"> - <.icon name="hero-adjustments-vertical" /> - - """ - end - - defp selected_template_label(assigns) do - ~H""" -
-
-
-
- <.icon name="hero-document-text" class="w-5 h-5 text-white" /> -
-
- -
-

- {@template.name} -

-

- {@template.description} -

-
-
-
- """ - end - - defp canvas_placeholder_card(assigns) do - ~H""" -
-
-
-
- <.icon name="hero-bolt" class="w-12 h-12 text-white" /> -
-
-
-

- Ready for a new workflow? -

-

- Get started by selecting a template, importing a workflow, or opening a chat with the AI assistant. -

-
-

- Not sure where to start? Try browsing our template library first. -

-
-
- """ - end - - @impl true - def mount(_params, _session, %{assigns: assigns} = socket) do - view_only_users_ids = - assigns.project - |> view_only_users() - |> Enum.map(fn pu -> pu.user.id end) - - workflow_ai_chat_id = "workflow-ai-chat-panel" - new_workflow_panel_id = "new-workflow-panel" - - {:ok, - socket - |> authorize() - |> assign( - view_only_users_ids: view_only_users_ids, - active_menu_item: :overview, - expanded_job: nil, - ai_assistant_enabled: AiAssistant.enabled?(), - workflow_chat_session_id: nil, - job_chat_session_id: nil, - selected_template: nil, - follow_run: nil, - step: nil, - manual_run_form: nil, - page_title: "", - selected_edge: nil, - selected_job: nil, - selected_run: nil, - selected_trigger: nil, - selection_mode: nil, - base_url: nil, - query_params: %{ - "s" => nil, - "m" => nil, - "a" => nil, - "v" => nil, - "w-chat" => nil, - "j-chat" => nil, - "method" => nil - }, - workflow: nil, - snapshot: nil, - changeset: nil, - snapshot_version_tag: "latest", - workflow_name: "", - workflow_params: %{}, - selected_credential_type: nil, - oauth_clients: OauthClients.list_clients(assigns.project), - show_missing_dataclip_selector: false, - show_new_workflow_panel: assigns.live_action == :new, - show_canvas_placeholder: assigns.live_action == :new, - show_workflow_ai_chat: false, - show_job_credential_modal: false, - new_credential_project_credentials: - LightningWeb.CredentialLive.Helpers.default_project_credentials( - assigns.project - ), - active_modal: nil, - active_modal_assigns: nil, - admin_contacts: Projects.list_project_admin_emails(assigns.project.id), - show_github_sync_modal: false, - publish_template: false, - method: nil, - workflow_code: nil, - workflow_code_with_ids: nil, - workflow_ai_chat_id: workflow_ai_chat_id, - workflow_ai_assistant_id: "#{workflow_ai_chat_id}-assistant", - new_workflow_panel_id: new_workflow_panel_id, - new_workflow_ai_assistant_id: "#{new_workflow_panel_id}-assistant", - job_ai_assistant_id_fn: fn job_id -> "job-#{job_id}-ai-assistant" end, - ai_assistant_registry: %{}, - sending_ai_message: false, - project_repo_connection: - VersionControl.get_repo_connection_for_project(assigns.project.id), - max_concurrency: assigns.project.concurrency - ) - |> assign(initial_presence_summary(assigns.current_user))} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, - socket - |> assign( - active_modal: nil, - active_modal_assigns: nil - ) - |> apply_action(socket.assigns.live_action, params) - |> track_user_presence() - |> apply_query_params(params) - |> prepare_workflow_template() - |> maybe_show_manual_run() - |> tap(fn socket -> - if connected?(socket) do - Workflows.Events.subscribe(socket.assigns.project.id) - - if changed?(socket, :selected_job) do - Helpers.broadcast_updated_params(socket, %{ - job_id: - case socket.assigns.selected_job do - nil -> nil - job -> job.id - end - }) - end - end - end)} - end - - defp authorize(%{assigns: %{live_action: :new}} = socket) do - %{project_user: project_user, current_user: current_user, project: project} = - socket.assigns - - Permissions.can(ProjectUsers, :create_workflow, current_user, project_user) - |> then(fn - :ok -> - assign_permissions(socket, current_user, project_user) - - {:error, _} -> - socket - |> put_flash(:error, "You are not authorized to perform this action.") - |> push_navigate(to: ~p"/projects/#{project.id}/w") - end) - end - - defp authorize(%{assigns: %{live_action: :edit}} = socket) do - %{project_user: project_user, current_user: current_user} = socket.assigns - assign_permissions(socket, current_user, project_user) - end - - defp assign_permissions(socket, current_user, project_user) do - socket - |> assign( - can_write_webhook_auth_method: - Permissions.can?( - ProjectUsers, - :write_webhook_auth_method, - current_user, - project_user - ), - can_edit_workflow: - Permissions.can?( - ProjectUsers, - :edit_workflow, - current_user, - project_user - ), - can_run_workflow: - Permissions.can?(ProjectUsers, :run_workflow, current_user, project_user), - can_edit_data_retention: - Permissions.can?( - ProjectUsers, - :edit_data_retention, - current_user, - project_user - ), - can_edit_run_settings: - Permissions.can?( - ProjectUsers, - :edit_run_settings, - current_user, - project_user - ) - ) - end - - defp apply_action(socket, :new, params) do - method = Map.get(params, "method", "template") - - if socket.assigns.workflow do - socket - else - socket - |> assign_workflow(%Workflow{ - project_id: socket.assigns.project.id, - id: Ecto.UUID.generate() - }) - end - |> assign(page_title: "New Workflow") - |> assign(method: method) - |> assign(base_url: ~p"/projects/#{socket.assigns.project}/w/new/legacy") - end - - defp apply_action(socket, :edit, %{"id" => workflow_id} = params) do - case socket.assigns.workflow do - %{id: ^workflow_id} -> - socket - |> assign( - base_url: - ~p"/projects/#{socket.assigns.project}/w/#{socket.assigns.workflow}/legacy" - ) - - _ -> - # TODO we shouldn't be calling Repo from here - workflow = get_workflow_by_id(workflow_id) - - if workflow do - run_id = Map.get(params, "a") - version = Map.get(params, "v") || workflow.lock_version - - snapshot = snapshot_by_version(workflow.id, version) - - socket - |> assign(selected_run: run_id) - |> assign_workflow(workflow, snapshot) - |> assign(page_title: workflow.name) - |> assign( - base_url: - ~p"/projects/#{socket.assigns.project}/w/#{workflow}/legacy" - ) - else - socket - |> put_flash(:error, "Workflow not found") - |> push_navigate(to: ~p"/projects/#{socket.assigns.project}/w") - end - end - end - - @impl true - def handle_event("workflow_editor_metrics_report", params, socket) do - UiMetrics.log_workflow_editor_metrics( - socket.assigns.workflow, - params["metrics"] - ) - - {:noreply, socket} - end - - def handle_event("switch_to_collab_editor", _params, socket) do - Lightning.Accounts.update_user_preference( - socket.assigns.current_user, - "prefer_legacy_editor", - false - ) - - params = - Map.merge(socket.assigns.query_params, %{ - "id" => socket.assigns.workflow.id, - "project_id" => socket.assigns.project.id - }) - - collaborative_url = - Helpers.collaborative_editor_url(params, socket.assigns.live_action) - - {:noreply, push_navigate(socket, to: collaborative_url)} - end - - def handle_event("get-current-state", _params, socket) do - run_id = socket.assigns.selected_run - - %{run_steps: run_steps, history: history} = - get_run_steps_and_history( - socket.assigns.workflow.id, - run_id - ) - - # don't forget to send update state of disabled - {:reply, - %{ - workflow_params: socket.assigns.workflow_params, - run_steps: run_steps, - run_id: run_id, - history: history - }, maybe_disable_canvas(socket)} - end - - def handle_event( - "search-selectable-dataclips", - %{"job_id" => job_id, "search_text" => search_text, "limit" => limit} = - params, - socket - ) do - offset = Map.get(params, "offset") - - case NewManualRun.search_selectable_dataclips( - job_id, - search_text, - limit, - offset - ) do - {:ok, - %{ - dataclips: dataclips, - next_cron_run_dataclip_id: next_cron_run_dataclip_id - }} -> - {:reply, - %{ - dataclips: dataclips, - next_cron_run_dataclip_id: next_cron_run_dataclip_id, - can_edit_dataclip: socket.assigns.can_edit_workflow - }, socket} - - {:error, changeset} -> - {:reply, - %{ - dataclips: [], - next_cron_run_dataclip_id: nil, - errors: LightningWeb.ChangesetJSON.errors(changeset), - can_edit_dataclip: socket.assigns.can_edit_workflow - }, socket} - end - end - - def handle_event( - "get-run-step-and-input-dataclip", - %{"run_id" => run_id, "job_id" => job_id}, - socket - ) do - dataclip = Invocation.get_first_dataclip_for_run_and_job(run_id, job_id) - run_step = Invocation.get_first_step_for_run_and_job(run_id, job_id) - - {:reply, %{dataclip: dataclip, run_step: run_step}, socket} - end - - def handle_event( - "update-dataclip-name", - %{"dataclip_id" => dataclip_id, "name" => name}, - socket - ) do - if socket.assigns.can_edit_workflow do - dataclip = Invocation.get_dataclip!(dataclip_id) - current_user = socket.assigns.current_user - - case Invocation.update_dataclip_name(dataclip, name, current_user) do - {:ok, updated_dataclip} -> - flash = - if updated_dataclip.name do - "Label created. Dataclip will be saved permanently" - else - "Label deleted. Dataclip will be purged when your retention policy limit is reached" - end - - {:reply, %{dataclip: updated_dataclip}, - put_flash(socket, :info, flash)} - - {:error, _changeset} -> - {:reply, %{error: "dataclip name already in use"}, socket} - end - else - {:reply, %{error: "You are not authorized to perform this action"}, socket} - end - end - - def handle_event("switch-version", %{"type" => type}, socket) do - updated_socket = - case type do - "commit" -> commit_latest_version(socket) - "toggle" -> toggle_latest_version(socket) - end - - {:noreply, updated_socket} - end - - def handle_event("delete_node", %{"id" => id}, socket) do - %{ - changeset: changeset, - workflow_params: initial_params, - can_edit_workflow: can_edit_workflow, - has_child_edges: has_child_edges, - is_first_job: is_first_job, - snapshot_version_tag: tag, - has_presence_edit_priority: has_presence_edit_priority - } = socket.assigns - - with true <- can_edit_workflow || :not_authorized, - true <- !has_child_edges || :has_child_edges, - true <- !is_first_job || :is_first_job, - true <- tag == "latest" || :view_only, - true <- - has_presence_edit_priority || - :presence_low_priority do - edges_to_delete = - Ecto.Changeset.get_assoc(changeset, :edges, :struct) - |> Enum.filter(&(&1.target_job_id == id)) - - next_params = remove_edges_from_params(initial_params, edges_to_delete, id) - - {:noreply, - socket - |> apply_params(next_params, :workflow) - |> push_patches_applied(initial_params)} - else - :not_authorized -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to perform this action.")} - - :has_child_edges -> - {:noreply, - socket - |> put_flash(:error, "Delete all descendant steps first.")} - - :is_first_job -> - {:noreply, - socket - |> put_flash(:error, "You can't delete the first step in a workflow.")} - - :view_only -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot delete a step in snapshot mode, switch to latest" - )} - - :presence_low_priority -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot delete a step in view-only mode" - )} - end - end - - def handle_event("delete_edge", %{"id" => id}, socket) do - %{ - changeset: changeset, - workflow_params: initial_params, - can_edit_workflow: can_edit_workflow, - selected_edge: selected_edge, - snapshot_version_tag: tag, - has_presence_edit_priority: has_presence_edit_priority - } = socket.assigns - - with true <- can_edit_workflow || :not_authorized, - true <- - (selected_edge && is_nil(selected_edge.source_trigger_id)) || - :is_initial_edge, - true <- tag == "latest" || :view_only, - true <- - has_presence_edit_priority || - :presence_low_priority do - edges_to_delete = - Ecto.Changeset.get_assoc(changeset, :edges, :struct) - |> Enum.filter(&(&1.id == id)) - - next_params = remove_edges_from_params(initial_params, edges_to_delete, id) - - {:noreply, - socket - |> apply_params(next_params, :workflow) - |> push_patches_applied(initial_params)} - else - :is_initial_edge -> - {:noreply, - socket - |> put_flash(:error, "You cannot remove the first edge in a workflow.")} - - :not_authorized -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to delete edges.")} - - :view_only -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot delete an edge in snapshot mode, switch to latest" - )} - - :presence_low_priority -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot delete an edge in view-only mode" - )} - end - end - - def handle_event("validate", %{"workflow" => params}, socket) do - {:noreply, handle_new_params(socket, params, :workflow)} - end - - def handle_event("validate", %{"snapshot" => params}, socket) do - {:noreply, handle_new_params(socket, params, :snapshot)} - end - - # TODO: remove this and the matching hidden input when issue resolved in LiveView. - # The hidden input is a workaround for a bug in LiveView where the form is - # considered for recovery because it has a submit button, but skips the - # recovery because it has no inputs. - # This causes the LiveView to not be set as joined, and further diffs to - # not be applied. - def handle_event("validate", %{"_ignore_me" => _}, socket) do - {:noreply, socket} - end - - def handle_event("validate", %{"workflow_template" => template_params}, socket) do - tags = - template_params["tags"] - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - - changeset = - template_params - |> Map.merge(%{ - "code" => socket.assigns.workflow_code, - "workflow_id" => socket.assigns.workflow.id, - "tags" => tags - }) - |> then(&WorkflowTemplate.changeset(socket.assigns.workflow_template, &1)) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :workflow_template_changeset, changeset)} - end - - def handle_event("save", %{"workflow_template" => template_params}, socket) do - %{workflow: workflow, workflow_code: code} = socket.assigns - - tags = - template_params["tags"] - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - - params = - Map.merge(template_params, %{ - "code" => code, - "workflow_id" => workflow.id, - "tags" => tags, - "positions" => workflow.positions - }) - - case WorkflowTemplates.create_template(params) do - {:ok, _template} -> - flash_msg = - if socket.assigns.has_workflow_template?, - do: "Workflow template updated.", - else: "Workflow published as template." - - url_params = [Helpers.param("m", "code")] - - {:noreply, - socket - |> put_flash(:info, flash_msg) - |> push_patch(to: Helpers.build_url(socket.assigns, url_params))} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :workflow_template_changeset, changeset)} - end - end - - def handle_event("save", params, socket) do - with {:ok, %{assigns: assigns} = socket} <- save_workflow(socket, params) do - link_workflow_to_ai_session(assigns) - - flash_msg = - "Workflow saved successfully." <> - if assigns.live_action == :new and - not Helpers.workflow_enabled?(assigns.workflow) do - " Remember to enable your workflow to run it automatically." - else - "" - end - - updated_socket = - if assigns.live_action == :new do - base_url = - ~p"/projects/#{assigns.project}/w/#{assigns.workflow}/legacy" - - base_socket = - socket - |> assign(:base_url, base_url) - |> assign(:live_action, :edit) - |> assign(:selected_template, nil) - |> update(:show_new_workflow_panel, fn _ -> false end) - |> maybe_disable_canvas() - - final_socket = - if assigns.query_params["method"] == "ai" do - base_socket - |> update(:show_workflow_ai_chat, fn _ -> true end) - else - base_socket - end - - push_event(final_socket, "force-fit", %{}) - else - socket - end - - patch_url = - Helpers.build_url(updated_socket.assigns, Helpers.standard_params()) - - {:noreply, - updated_socket - |> put_flash(:info, flash_msg) - |> push_patch(to: patch_url, replace: true)} - end - end - - def handle_event("save-and-sync", %{"github_sync" => _} = params, socket) do - with {:ok, %{assigns: assigns} = socket} <- save_workflow(socket, params) do - link_workflow_to_ai_session(assigns) - - update_socket = - if assigns.live_action == :new do - base_url = - ~p"/projects/#{assigns.project}/w/#{assigns.workflow}/legacy" - - socket - |> assign(:base_url, base_url) - |> assign(:live_action, :edit) - else - socket - end - - patch_url = - Helpers.build_url(update_socket.assigns, Helpers.standard_params()) - - {:noreply, - update_socket - |> sync_to_github(params) - |> push_patch(to: patch_url, replace: true)} - end - end - - def handle_event("toggle_github_sync_modal", _params, socket) do - {:noreply, - assign(socket, - show_github_sync_modal: !socket.assigns.show_github_sync_modal - )} - end - - def handle_event("toggle_job_credential_modal", _params, socket) do - {:noreply, update(socket, :show_job_credential_modal, fn show -> !show end)} - end - - def handle_event("push-change", %{"patches" => patches}, socket) do - params = - WorkflowParams.apply_patches(socket.assigns.workflow_params, patches) - |> case do - {:ok, params} -> params - {:error, _} -> socket.assigns.workflow_params - end - - version_type = - if socket.assigns.snapshot_version_tag == "latest" do - :workflow - else - :snapshot - end - - socket = - socket - |> apply_params(params, version_type) - |> generate_workflow_code() - - # Calculate the difference between the new params and changes introduced by - # the changeset/validation. - patches = WorkflowParams.to_patches(params, socket.assigns.workflow_params) - - {:reply, %{patches: patches}, socket} - end - - def handle_event("copied_to_clipboard", _, socket) do - {:noreply, - socket - |> put_flash(:info, "Copied webhook URL to clipboard")} - end - - def handle_event("toggle_missing_dataclip_selector", _, socket) do - {:noreply, - update(socket, :show_missing_dataclip_selector, fn val -> !val end)} - end - - def handle_event("toggle-workflow-ai-chat", _params, socket) do - if socket.assigns.snapshot_version_tag != "latest" do - {:noreply, socket} - else - show_workflow_ai_chat = socket.assigns.show_workflow_ai_chat - - url_params = - Helpers.with_params(method: [value: "ai", when: !show_workflow_ai_chat]) - - {:noreply, - socket - |> assign(show_workflow_ai_chat: !show_workflow_ai_chat) - |> push_patch(to: Helpers.build_url(socket.assigns, url_params))} - end - end - - def handle_event("manual_run_change", %{"manual" => params}, socket) do - changeset = - WorkOrders.Manual.new( - params, - project: socket.assigns.project, - workflow: socket.assigns.workflow, - job: socket.assigns.selected_job, - created_by: socket.assigns.current_user - ) - |> Map.put(:action, :validate) - - {:noreply, socket |> assign_manual_run_form(changeset)} - end - - # Handle empty manual run form submission, this happens when the dataclip - # dropdown is disabled and the socket reconnects. - def handle_event("manual_run_change", _params, socket) do - {:noreply, socket} - end - - # The retry_from_run event is for creating a new run for an existing work - # order, just like clicking "rerun from here" on the history page. - def handle_event( - "rerun", - %{"run_id" => run_id, "step_id" => step_id} = params, - socket - ) do - case rerun(socket, run_id, step_id, params["via"]) do - {:ok, socket} -> - {:noreply, socket} - - {:error, _reason, %{text: error_text}} -> - {:noreply, put_flash(socket, :error, error_text)} - - {:error, %{text: message}} -> - {:noreply, put_flash(socket, :error, message)} - - {:error, :workflow_deleted} -> - {:noreply, - put_flash(socket, :error, "Cannot rerun a deleted a workflow")} - - {:error, _changeset} -> - {:noreply, - socket - |> assign_changeset(socket.assigns.changeset) - |> mark_validated() - |> put_flash(:error, "Workflow could not be saved")} - - :not_authorized -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to perform this action.")} - - :view_only -> - {:noreply, - socket - |> put_flash(:error, "Cannot rerun in snapshot mode, switch to latest.")} - end - end - - # The manual_run_submit event is for create a new work order from a dataclip and - # a job. - def handle_event("manual_run_submit", params, socket) do - %{ - project: project, - selected_job: selected_job, - workflow_params: workflow_params, - has_presence_edit_priority: has_presence_edit_priority, - workflow: workflow, - manual_run_form: form - } = socket.assigns - - manual_params = Map.get(params, "manual", %{}) - from_start? = Map.get(params, "from_start", false) - from_job = Map.get(params, "from_job", nil) - - params = - case form do - nil -> manual_params - %{params: form_params} -> Map.merge(form_params, manual_params) - end - - socket = socket |> apply_params(workflow_params, :workflow) - - workflow_or_changeset = - if has_presence_edit_priority do - socket.assigns.changeset - else - get_workflow_by_id(workflow.id) - end - - selected_job = - cond do - from_start? -> - get_starting_job(workflow_or_changeset) - - from_job != nil -> - get_job_by_id(workflow_or_changeset, from_job) - - true -> - selected_job - end - - with {:ok, %{workorder: %{runs: [run]}, workflow: workflow}} <- - manual_run_workflow( - socket, - workflow_or_changeset, - params, - selected_job - ) do - if from_start? || from_job != nil do - {:noreply, - socket - |> push_navigate(to: ~p"/projects/#{project}/runs/#{run}")} - else - Runs.subscribe(run) - - snapshot = snapshot_by_version(workflow.id, workflow.lock_version) - - # Get the dataclip for the run - dataclip = Invocation.get_dataclip_for_run(run.id) - - {:noreply, - socket - |> assign_workflow(workflow, snapshot) - |> follow_run(run) - |> push_event("push-hash", %{"hash" => "log"}) - |> push_event("manual_run_created", %{dataclip: dataclip})} - end - end - end - - def handle_event("toggle_workflow_state", %{"workflow_state" => state}, socket) do - if socket.assigns.sending_ai_message do - {:noreply, socket} - else - changeset = - Workflows.update_triggers_enabled_state( - socket.assigns.changeset, - state - ) - - params = WorkflowParams.to_map(changeset) - - {:noreply, - socket - |> assign(:changeset, changeset) - |> handle_new_params(params, :workflow)} - end - end - - def handle_event("publish_template", _params, socket) do - {:noreply, assign(socket, publish_template: true)} - end - - def handle_event("cancel_publish_template", _params, socket) do - {:noreply, assign(socket, publish_template: false)} - end - - def handle_event( - "workflow_code_generated", - %{"code" => code, "code_with_ids" => code_with_ids}, - socket - ) do - {:noreply, - assign(socket, workflow_code: code, workflow_code_with_ids: code_with_ids)} - end - - def handle_event("close_template_tooltip", _params, socket) do - {:noreply, assign(socket, selected_template: nil)} - end - - def handle_event("close_active_modal", _params, socket) do - socket - |> assign(active_modal: nil, active_modal_assigns: nil) - |> noreply() - end - - def handle_event( - "show_modal", - %{"target" => "webhook_auth_method"}, - socket - ) do - if socket.assigns.can_write_webhook_auth_method do - socket - |> assign( - active_modal: :webhook_auth_method, - active_modal_assigns: %{} - ) - |> noreply() - else - socket - |> put_flash(:error, "You are not authorized to perform this action") - |> noreply() - end - end - - def handle_event(_unhandled_event, _params, socket) do - # TODO: add a warning and/or log for unhandled events - {:noreply, socket} - end - - @impl true - def handle_info( - %WorkflowUpdated{workflow: updated_workflow}, - socket - ) do - %{ - workflow: current_workflow, - snapshot_version_tag: version_tag, - has_presence_edit_priority: has_edit_priority?, - snapshot: snapshot - } = socket.assigns - - is_same_workflow? = current_workflow.id == updated_workflow.id - is_latest_version? = version_tag == "latest" - should_update? = is_same_workflow? and not has_edit_priority? - - if should_update? do - updated_socket = - if is_latest_version? do - put_flash( - socket, - :info, - "This workflow has been updated. You're no longer on the latest version." - ) - else - socket - end - - {:noreply, assign_workflow(updated_socket, updated_workflow, snapshot)} - else - {:noreply, socket} - end - end - - def handle_info( - {:webhook_auth_method_updated, _trigger_or_auth_method}, - socket - ) do - %{ - workflow: current_workflow, - snapshot: snapshot, - workflow_params: current_params - } = - socket.assigns - - updated_workflow = get_workflow_by_id(current_workflow.id) - - socket - |> assign_workflow(updated_workflow, snapshot) - |> apply_params(current_params, :workflow) - |> apply_mode_and_selection() - |> noreply() - end - - def handle_info({:form_changed, %{"workflow" => params}}, socket) do - {:noreply, handle_new_params(socket, params, :workflow)} - end - - def handle_info({:form_changed, %{"snapshot" => params}}, socket) do - {:noreply, handle_new_params(socket, params, :snapshot)} - end - - def handle_info({:forward, mod, opts}, socket) do - send_update(mod, opts) - {:noreply, socket} - end - - def handle_info(%DataclipUpdated{dataclip: dataclip}, socket) do - dataclip = Invocation.get_dataclip!(dataclip.id) - - {:noreply, - assign_dataclips(socket, socket.assigns.selectable_dataclips, dataclip)} - end - - def handle_info( - %StepCompleted{step: step}, - socket - ) - when step.job_id === socket.assigns.selected_job.id do - {:noreply, assign(socket, step: step)} - end - - def handle_info( - %RunUpdated{run: run}, - %{assigns: %{follow_run: %{id: follow_run_id}}} = socket - ) - when run.id === follow_run_id do - {:noreply, - socket - |> assign(follow_run: run)} - end - - def handle_info(%{event: "presence_diff", payload: _diff}, socket) do - {:noreply, update_presence_summary(socket)} - end - - @impl true - def handle_info({:ai_assistant, action, payload}, socket) do - case action do - :canvas_state_changed -> - update_canvas_state(socket, payload) - - :workflow_params_changed -> - handle_workflow_params_change(socket, payload) - - :message_status_changed -> - handle_message_status_change(socket, payload) - - :register_component -> - handle_component_registration(socket, payload) - - :unregister_component -> - handle_component_unregistration(socket, payload) - end - end - - def handle_info(%{}, socket) do - {:noreply, socket} - end - - defp get_workflow_by_id(workflow_id) do - Workflows.get_workflow(workflow_id) - |> Lightning.Repo.preload([ - :project, - :edges, - triggers: Trigger.with_auth_methods_query(), - jobs: - {Workflows.jobs_ordered_subquery(), - [:credential, steps: Invocation.Query.any_step()]} - ]) - end - - defp snapshot_by_version(workflow_id, version), - do: Snapshot.get_by_version(workflow_id, version) - - defp apply_params(socket, params, type) do - changeset = - case type do - :snapshot -> - Ecto.Changeset.change(socket.assigns.snapshot) - - :workflow -> - socket.assigns.workflow - |> Workflow.changeset( - params - |> set_default_adaptors() - |> Map.put("project_id", socket.assigns.project.id) - ) - end - - assign_changeset(socket, changeset) - end - - defp format_step(step) do - %{ - id: step.id, - job_id: step.job_id, - error_type: step.error_type, - exit_reason: step.exit_reason, - started_at: step.started_at, - finished_at: step.finished_at, - input_dataclip_id: step.input_dataclip_id - } - end - - defp get_workflow_run_history(workflow_id, includes_run_id) do - WorkOrders.get_workorders_with_runs(workflow_id, includes_run_id) - |> Enum.map(fn worder -> - %{ - runs: - worder.runs - |> Enum.map(fn run -> - Map.take(run, [:id, :state, :error_type, :started_at, :finished_at]) - end), - version: worder.snapshot.lock_version, - state: worder.state, - last_activity: worder.last_activity, - id: worder.id - } - end) - end - - defp get_run_steps_and_history(workflow_id, run_id) do - empty_resp = %{start_from: nil, steps: [], isTrigger: true, inserted_at: nil} - - run_steps = - if run_id == nil do - empty_resp - else - Runs.get(run_id, include: [:created_by, :steps]) - |> case do - nil -> - empty_resp - - %{ - steps: run_steps, - starting_trigger_id: trigger_id, - starting_job_id: job_id - } = - data -> - %{ - start_from: job_id || trigger_id, - steps: run_steps, - isTrigger: !!trigger_id, - inserted_at: data.inserted_at, - run_by: - if(is_nil(data.created_by), do: nil, else: data.created_by.email) - } - end - end - |> Map.update!(:steps, fn steps -> Enum.map(steps, &format_step/1) end) - - history = get_workflow_run_history(workflow_id, run_id) - - %{run_steps: run_steps, history: history} - end - - defp save_workflow(socket, submitted_params) do - %{ - workflow_params: initial_params, - current_user: current_user - } = socket.assigns - - with :ok <- check_user_can_save_workflow(socket) do - next_params = - case submitted_params do - %{"workflow" => params} -> - WorkflowParams.apply_form_params( - initial_params, - params - ) - - %{} -> - initial_params - end - - %{assigns: %{changeset: changeset}} = - socket = socket |> apply_params(next_params, :workflow) - - case Helpers.save_workflow(changeset, current_user) do - {:ok, workflow} -> - snapshot = snapshot_by_version(workflow.id, workflow.lock_version) - - { - :ok, - socket - |> assign(page_title: workflow.name) - |> assign_workflow(workflow, snapshot) - |> push_patches_applied(initial_params) - |> maybe_push_workflow_created(workflow) - } - - {:error, %{text: message}} -> - {:noreply, put_flash(socket, :error, message)} - - {:error, :workflow_deleted} -> - {:noreply, - put_flash( - socket, - :error, - "Oops! You cannot modify a deleted workflow" - )} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, - socket - |> assign_changeset(changeset) - |> mark_validated() - |> put_flash(:error, get_error_message(socket)) - |> push_patches_applied(initial_params)} - end - end - end - - defp get_error_message(socket) do - base_message = "Workflow could not be saved" - - if socket.assigns.live_action == :new && - socket.assigns.show_canvas_placeholder do - "#{base_message}. Please make sure you select a template, or import one, or use the AI assistant to build your workflow" - else - base_message - end - end - - defp manual_run_workflow( - socket, - workflow_or_changeset, - manual_params, - selected_job - ) do - %{project: project, current_user: current_user} = socket.assigns - - with :ok <- check_user_can_manual_run_workflow(socket) do - case Helpers.run_workflow( - workflow_or_changeset, - manual_params, - project: project, - selected_job: selected_job, - created_by: current_user - ) do - {:ok, result} -> - {:ok, result} - - {:error, %Ecto.Changeset{data: %WorkOrders.Manual{}} = changeset} -> - {:noreply, - socket - |> assign_manual_run_form(changeset)} - - {:error, %Ecto.Changeset{data: %Workflow{}} = changeset} -> - { - :noreply, - socket - |> assign_changeset(changeset) - |> mark_validated() - |> put_flash(:error, "Workflow could not be saved") - } - - {:error, %{text: message}} -> - {:noreply, put_flash(socket, :error, message)} - - {:error, :workflow_deleted} -> - {:noreply, - put_flash( - socket, - :error, - "Oops! You cannot modify a deleted workflow" - )} - end - end - end - - defp check_user_can_manual_run_workflow(socket) do - case socket.assigns do - %{ - can_edit_workflow: true, - can_run_workflow: true, - snapshot_version_tag: "latest" - } -> - :ok - - %{can_edit_workflow: false} -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to perform this action.")} - - %{can_run_workflow: false} -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to perform this action.")} - - _snapshot_not_latest -> - {:noreply, - socket - |> put_flash(:error, "Cannot run in snapshot mode, switch to latest.")} - end - end - - defp check_user_can_save_workflow(socket) do - case socket.assigns do - %{ - can_edit_workflow: true, - has_presence_edit_priority: true, - snapshot_version_tag: "latest", - sending_ai_message: false - } -> - :ok - - %{can_edit_workflow: false} -> - {:noreply, - socket - |> put_flash(:error, "You are not authorized to perform this action.")} - - %{has_presence_edit_priority: false} -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot save in view-only mode" - )} - - %{sending_ai_message: true} -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot save while AI is processing" - )} - - _snapshot_not_latest -> - {:noreply, - socket - |> put_flash( - :error, - "Cannot save in snapshot mode, switch to the latest version." - )} - end - end - - defp get_job_by_id(%Workflow{} = workflow, job_id) do - Enum.find(workflow.jobs, fn job -> job.id == job_id end) - end - - defp get_job_by_id(%Ecto.Changeset{} = workflow_changeset, job_id) do - workflow_changeset - |> Ecto.Changeset.get_assoc(:jobs, :struct) - |> Enum.find(fn job -> job.id == job_id end) - end - - defp get_starting_job(%Workflow{} = workflow) do - trigger = hd(workflow.triggers) - - edge = - Enum.find(workflow.edges, fn edge -> - edge.source_trigger_id == trigger.id - end) - - get_job_by_id(workflow, edge.target_job_id) - end - - defp get_starting_job(%Ecto.Changeset{} = workflow_changeset) do - trigger = - workflow_changeset - |> Ecto.Changeset.get_assoc(:triggers, :struct) - |> hd() - - edge = - workflow_changeset - |> Ecto.Changeset.get_assoc(:edges, :struct) - |> Enum.find(fn edge -> - edge.source_trigger_id == trigger.id - end) - - get_job_by_id(workflow_changeset, edge.target_job_id) - end - - defp toggle_latest_version(socket) do - %{ - changeset: prev_changeset, - workflow: workflow, - selected_job: selected_job - } = socket.assigns - - if job_deleted?(selected_job, workflow) do - put_flash( - socket, - :info, - "Can't switch to the latest version, the job has been deleted from the workflow." - ) - else - {next_changeset, version} = switch_changeset(socket) - - prev_params = WorkflowParams.to_map(prev_changeset) - next_params = WorkflowParams.to_map(next_changeset) - - if version != "latest" do - Presence.untrack_user_presence( - socket.assigns.current_user, - socket.assigns.workflow, - self() - ) - end - - url_params = - Helpers.to_latest_params() - |> Enum.reject(fn - [name: "method", value: _] -> true - [name: "w-chat", value: _] -> true - [name: "j-chat", value: _] -> true - _ -> false - end) - - socket - |> assign(changeset: next_changeset) - |> assign(workflow_params: next_params) - |> assign(snapshot_version_tag: version) - |> push_patches_applied(prev_params) - |> maybe_disable_canvas() - |> push_patch(to: Helpers.build_url(socket.assigns, url_params)) - end - end - - defp commit_latest_version(socket) do - %{changeset: prev_changeset, workflow: workflow} = socket.assigns - - snapshot = snapshot_by_version(workflow.id, workflow.lock_version) - prev_params = WorkflowParams.to_map(prev_changeset) - - url_params = - Helpers.orthogonal_params() - |> Enum.reject(fn - [name: "method", value: _] -> true - [name: "w-chat", value: _] -> true - [name: "j-chat", value: _] -> true - _ -> false - end) - - socket - |> assign_workflow(workflow, snapshot) - |> push_patches_applied(prev_params) - |> push_patch(to: Helpers.build_url(socket.assigns, url_params)) - end - - defp maybe_switch_workflow_version(socket) do - %{ - workflow: workflow, - prior_user_presence: prior_presence, - current_user: current_user, - selected_run: selected_run - } = socket.assigns - - if prior_presence.user.id == current_user.id && - Workflows.has_newer_version?(workflow) do - reloaded_workflow = get_workflow_by_id(workflow.id) - - socket = assign(socket, workflow: reloaded_workflow) - - if selected_run do - toggle_latest_version(socket) - else - commit_latest_version(socket) - end - else - socket - end - end - - defp unselect_all(socket) do - socket - |> assign( - selected_edge: nil, - selected_job: nil, - selected_trigger: nil, - selection_mode: nil - ) - end - - defp set_selected_node(socket, type, value) do - case type do - :jobs -> - socket - |> assign( - has_child_edges: has_child_edges?(socket.assigns.changeset, value.id), - is_first_job: first_job?(socket.assigns.changeset, value.id), - selected_job: value, - selected_trigger: nil, - selected_edge: nil - ) - - :triggers -> - socket - |> assign( - selected_job: nil, - selected_trigger: value, - selected_edge: nil - ) - - :edges -> - socket - |> assign( - selected_job: nil, - selected_trigger: nil, - selected_edge: value - ) - end - end - - defp find_item(%Ecto.Changeset{} = changeset, id) do - find_item_helper(changeset, id, fn data, field -> - Ecto.Changeset.get_assoc(data, field, :struct) - end) - end - - defp find_item_helper(data, id, accessor) do - [:jobs, :triggers, :edges] - |> Enum.reduce_while(nil, fn field, _ -> - accessor.(data, field) - |> Enum.find(&(&1.id == id)) - |> case do - nil -> - {:cont, nil} - - %Job{} = job -> - {:halt, - [ - field, - job - |> Lightning.Repo.preload([ - :credential, - steps: Invocation.Query.any_step() - ]) - ]} - - %Trigger{} = trigger -> - {:halt, - [field, Lightning.Repo.preload(trigger, :webhook_auth_methods)]} - - item -> - {:halt, [field, item]} - end - end) - end - - @spec close_url(map(), atom() | nil, :select | :unselect) :: String.t() - defp close_url(assigns, selection_type, action) do - mode_and_selection_params = - case action do - :unselect -> - [] - - :select - when selection_type == :selected_job and is_struct(assigns.selected_job) -> - [Helpers.param("s", assigns.selected_job.id)] - - _ -> - [] - end - - Helpers.build_url( - assigns, - mode_and_selection_params ++ Helpers.orthogonal_params() - ) - end - - defp display_switcher(snapshot, workflow) do - snapshot && snapshot.lock_version != workflow.lock_version - end - - defp banner_message(current_user_presence, prior_user_presence) do - prior_user_name = - "#{prior_user_presence.user.first_name} #{prior_user_presence.user.last_name}" - - cond do - current_user_presence.active_sessions > 1 -> - "You have this workflow open in #{current_user_presence.active_sessions} tabs and can't edit until you close the other#{if current_user_presence.active_sessions > 2, do: "s", else: ""}." - - current_user_presence.user.id != prior_user_presence.user.id -> - "#{prior_user_name} is currently active and you can't edit this workflow until they close the editor and canvas." - - true -> - nil - end - end - - defp workflow_settings_errors?(changeset) do - errors_keys = Keyword.keys(changeset.errors) - Enum.any?([:name, :concurrency], &(&1 in errors_keys)) - end - - defp track_user_presence(socket) do - if connected?(socket) && socket.assigns.snapshot_version_tag == "latest" do - Presence.track_user_presence( - socket.assigns.current_user, - socket.assigns.workflow, - self() - ) - - update_presence_summary(socket) - else - socket - end - end - - defp initial_presence_summary(current_user) do - init_user_presence = %Presence{ - user: current_user, - active_sessions: 1 - } - - %{ - presences: [], - prior_user_presence: init_user_presence, - current_user_presence: init_user_presence, - has_presence_edit_priority: true - } - end - - defp update_presence_summary(socket) do - summary = - socket.assigns.workflow - |> Presence.list_presences_for() - |> Presence.build_presences_summary(socket.assigns) - - assign(socket, summary) - |> maybe_switch_workflow_version() - |> maybe_disable_canvas() - end - - defp view_only_users(project) do - Lightning.Repo.preload(project, project_users: [:user]) - |> Map.get(:project_users) - |> Enum.filter(fn pu -> pu.role == :viewer end) - end - - defp maybe_disable_canvas(socket) do - %{ - has_presence_edit_priority: has_edit_priority, - snapshot_version_tag: version, - can_edit_workflow: can_edit_workflow, - workflow: workflow, - show_new_workflow_panel: show_new_workflow_panel - } = socket.assigns - - disabled = - !(is_nil(workflow.deleted_at) && has_edit_priority && version == "latest" && - can_edit_workflow) - - push_event(socket, "set-disabled", %{ - disabled: show_new_workflow_panel || disabled - }) - end - - defp maybe_show_manual_run(socket) do - case socket.assigns do - %{selected_job: nil} -> - socket - |> assign( - manual_run_form: nil, - selectable_dataclips: [] - ) - - %{selected_job: job, selection_mode: "expand"} = assigns - when not is_nil(job) -> - dataclip = - assigns[:follow_run] && - get_selected_dataclip(assigns[:follow_run], job.id) - - body = - new_manual_run_form_body( - assigns.manual_run_form, - job, - dataclip - ) - - changeset = - WorkOrders.Manual.new( - %{dataclip_id: dataclip && dataclip.id, body: body}, - project: socket.assigns.project, - workflow: socket.assigns.workflow, - job: socket.assigns.selected_job, - created_by: socket.assigns.current_user - ) - - selectable_dataclips = - Invocation.list_dataclips_for_job(%Job{id: job.id}) - - socket - |> assign_manual_run_form(changeset) - |> assign_dataclips(selectable_dataclips, dataclip) - - _ -> - socket - end - end - - defp assign_manual_run_form(socket, changeset) do - assign(socket, manual_run_form: to_form(changeset, id: "manual_run_form")) - end - - defp new_manual_run_form_body( - prev_manual_run_form, - selected_job, - selected_dataclip - ) do - prev_job = - prev_manual_run_form && - Ecto.Changeset.get_embed( - prev_manual_run_form.source, - :job, - :struct - ) - - if is_nil(selected_dataclip) and is_struct(prev_job) and - prev_job.id == selected_job.id do - Ecto.Changeset.get_change(prev_manual_run_form.source, :body) - end - end - - defp assign_dataclips(socket, selectable_dataclips, step_dataclip) do - socket - |> assign( - selectable_dataclips: - maybe_add_selected_dataclip(selectable_dataclips, step_dataclip) - ) - |> assign(show_missing_dataclip_selector: is_map(step_dataclip)) - end - - defp get_selected_dataclip(run, job_id) do - dataclip = Invocation.get_first_dataclip_for_run_and_job(run.id, job_id) - - if is_nil(dataclip) and - (run.starting_job_id == job_id || - Invocation.get_step_count_for_run(run.id) == 0) do - Invocation.get_dataclip_for_run(run.id) - else - dataclip - end - end - - defp maybe_add_selected_dataclip(selectable_dataclips, nil) do - selectable_dataclips - end - - defp maybe_add_selected_dataclip(selectable_dataclips, dataclip) do - existing_index = - Enum.find_index(selectable_dataclips, fn dc -> dc.id == dataclip.id end) - - if existing_index do - List.replace_at(selectable_dataclips, existing_index, dataclip) - else - [dataclip | selectable_dataclips] - end - end - - defp save_and_run_disabled?(attrs) do - case attrs do - %{manual_run_form: nil} -> - true - - %{workflow: %{deleted_at: deleted_at}} when is_struct(deleted_at) -> - true - - %{ - manual_run_form: manual_run_form, - changeset: changeset, - can_edit_workflow: can_edit_workflow, - can_run_workflow: can_run_workflow - } -> - form_valid = manual_run_form.source.valid? - - !form_valid or - !changeset.valid? or - !(can_edit_workflow or can_run_workflow) - end - end - - defp editor_is_empty(form, job) do - %Phoenix.HTML.FormField{field: field_name, form: parent_form} = form[:jobs] - - found_job = - parent_form.impl.to_form(parent_form.source, parent_form, field_name, []) - |> Enum.find(fn f -> Ecto.Changeset.get_field(f.source, :id) == job.id end) - - if found_job do - errors = - found_job - |> Map.get(:source) - |> Map.get(:errors) - - error_message = LightningWeb.CoreComponents.translate_errors(errors, :body) - - is_empty? = Keyword.has_key?(errors, :body) - - {is_empty?, error_message} - else - {false, nil} - end - end - - defp handle_new_params(socket, params, type, push_patches \\ true) do - %{workflow_params: initial_params, can_edit_workflow: can_edit_workflow} = - socket.assigns - - if can_edit_workflow do - next_params = - WorkflowParams.apply_form_params(socket.assigns.workflow_params, params) - - updated_socket = - socket - |> apply_params(next_params, type) - |> mark_validated() - - if push_patches do - push_patches_applied(updated_socket, initial_params) - else - updated_socket - end - else - socket - |> put_flash(:error, "You are not authorized to perform this action.") - end - end - - defp assign_workflow(socket, workflow) do - workflow = Lightning.Repo.preload(workflow, :project) - - alloted_concurrency = - workflow.project_id - |> Workflows.list_project_workflows() - |> Enum.map(fn %{id: workflow_id, concurrency: concurrency} -> - if workflow_id == workflow.id, do: 0, else: concurrency || 0 - end) - |> Enum.sum() - - project_concurrency = workflow.project.concurrency || 0 - - socket - |> assign( - workflow: workflow, - max_concurrency: max(0, project_concurrency - alloted_concurrency) - ) - |> apply_params(socket.assigns.workflow_params, :workflow) - end - - defp assign_workflow(socket, workflow, snapshot) do - {changeset, version} = - if snapshot.lock_version == workflow.lock_version do - {Ecto.Changeset.change(workflow), "latest"} - else - {Ecto.Changeset.change(snapshot), String.slice(snapshot.id, 0..6)} - end - - show_workflow_ai_chat = - if version == "latest" do - Map.get(socket.assigns, :show_workflow_ai_chat, false) - else - false - end - - socket - |> assign(workflow: workflow) - |> assign(snapshot: snapshot) - |> assign(snapshot_version_tag: version) - |> assign(show_workflow_ai_chat: show_workflow_ai_chat) - |> assign_changeset(changeset) - |> maybe_disable_canvas() - |> generate_workflow_code() - end - - defp apply_query_params(socket, params) do - taken = - Map.take(params, ["s", "m", "a", "v", "w-chat", "j-chat", "code", "method"]) - - query_params = - Enum.into(taken, %{ - "s" => nil, - "m" => nil, - "a" => nil, - "v" => nil, - "w-chat" => nil, - "j-chat" => nil, - "code" => nil, - "method" => nil - }) - - socket - |> assign(query_params: query_params) - |> apply_query_params() - end - - defp apply_query_params(socket) do - socket - |> apply_mode_and_selection() - |> handle_new_workflow_panel() - |> assign_follow_run(socket.assigns.query_params) - |> assign_chat_session_id(socket.assigns.query_params) - |> assign_show_workflow_ai_chat() - end - - defp apply_mode_and_selection( - %{assigns: %{query_params: %{"m" => "workflow_input", "s" => s}}} = - socket - ) - when not is_nil(s) do - handle_selection_with_mode(socket, s, "workflow_input") - end - - defp apply_mode_and_selection( - %{assigns: %{query_params: %{"m" => "expand", "s" => s}}} = socket - ) - when not is_nil(s) do - handle_selection_with_mode(socket, s, "expand") - end - - defp apply_mode_and_selection( - %{assigns: %{query_params: %{"m" => "settings"}}} = socket - ) do - handle_settings_mode(socket) - end - - defp apply_mode_and_selection( - %{assigns: %{query_params: %{"m" => "code"}}} = socket - ) do - handle_code_mode(socket) - end - - defp apply_mode_and_selection( - %{ - assigns: %{ - query_params: %{"m" => "history", "v" => v, "a" => a, "s" => s} - } - } = socket - ) - when not is_nil(v) do - handle_run_selection_history(socket, a, v, s) - end - - defp apply_mode_and_selection( - %{assigns: %{query_params: %{"s" => s} = params}} = socket - ) - when not is_nil(s) do - handle_selection_with_mode(socket, s, params["m"]) - end - - defp apply_mode_and_selection(socket) do - handle_no_selection(socket) - end - - defp handle_selection_with_mode(socket, nil, mode) do - socket - |> set_mode( - if mode in ["expand", "workflow_input", "history"], do: mode, else: nil - ) - end - - defp handle_selection_with_mode(socket, selected_id, mode) do - case find_item(socket.assigns.changeset, selected_id) do - [type, selected] -> - socket - |> set_selected_node(type, selected) - |> set_mode( - if mode in ["expand", "workflow_input", "settings", "history"], - do: mode, - else: nil - ) - - nil -> - socket |> unselect_all() - end - end - - defp handle_settings_mode(socket) do - socket |> unselect_all() |> set_mode("settings") - end - - defp handle_code_mode(socket) do - socket - |> unselect_all() - |> set_mode("code") - |> assign(publish_template: false) - end - - defp handle_no_selection(socket) do - socket |> unselect_all() |> set_mode(nil) - end - - defp handle_new_workflow_panel(socket) do - if socket.assigns.show_new_workflow_panel do - socket |> unselect_all() |> set_mode(nil) - else - socket - end - end - - # version_tag will never be nil here - defp handle_run_selection_history(socket, run_id, version_tag, selected_id) do - workflow_id = socket.assigns.workflow.id - - %{run_steps: run_steps} = - get_run_steps_and_history(workflow_id, run_id) - - snapshot = snapshot_by_version(workflow_id, version_tag) - - # pushing the snapshot state before pushing the runs for it - socket - |> handle_selection_with_mode(selected_id, "history") - |> assign(selected_run: run_id) - |> assign_workflow(socket.assigns.workflow, snapshot) - |> push_patches_applied(socket.assigns.workflow_params, false) - |> push_event("patch-runs", %{ - run_id: run_id, - run_steps: run_steps - }) - |> maybe_disable_canvas() - end - - defp switch_changeset(socket) do - %{changeset: changeset, workflow: workflow, snapshot: snapshot} = - socket.assigns - - case changeset do - %Ecto.Changeset{data: %Snapshot{}} -> - {Ecto.Changeset.change(workflow), "latest"} - - %Ecto.Changeset{data: %Workflow{}} -> - {Ecto.Changeset.change(snapshot), String.slice(snapshot.id, 0..6)} - end - end - - defp assign_changeset(socket, changeset) do - workflow_params = WorkflowParams.to_map(changeset) - - socket - |> assign( - changeset: changeset, - workflow_params: workflow_params - ) - end - - defp push_patches_applied(socket, initial_params, inverse \\ true) do - next_params = socket.assigns.workflow_params - - patches = - WorkflowParams.to_patches(initial_params, next_params) - - inverse_patches = - if inverse == true, - do: WorkflowParams.to_patches(next_params, initial_params), - else: [] - - if length(patches) > 0 do - socket - |> push_event("patches-applied", %{ - patches: patches, - inverse: inverse_patches - }) - |> generate_workflow_code() - else - socket - end - end - - defp step_retryable?(assigns), - do: - step_retryable?( - assigns.step, - assigns.manual_run_form, - assigns.selectable_dataclips - ) - - defp step_retryable?(step, form, selectable_dataclips) do - step_dataclip_id = step && step.input_dataclip_id - - selected_dataclip = - Enum.find(selectable_dataclips, fn dataclip -> - dataclip.id == form[:dataclip_id].value - end) - - selected_dataclip && selected_dataclip.id == step_dataclip_id && - is_nil(selected_dataclip.wiped_at) - end - - defp selected_dataclip_wiped?(form, selectable_dataclips) do - selected_dataclip = - Enum.find(selectable_dataclips, fn dataclip -> - dataclip.id == form[:dataclip_id].value - end) - - selected_dataclip && !is_nil(selected_dataclip.wiped_at) - end - - defp set_mode(socket, mode) do - if mode in [nil, "expand", "settings", "code", "workflow_input", "history"] do - socket - |> assign(selection_mode: mode) - else - socket - end - end - - defp processing(%{state: state}) do - !(state in Lightning.Run.final_states()) - end - - defp processing(_run), do: false - - defp follow_run(socket, run) do - %{changeset: changeset, workflow: workflow, selection_mode: current_mode} = - socket.assigns - - version = Ecto.Changeset.get_field(changeset, :lock_version) - mode = current_mode || "expand" - - selection = - case socket.assigns do - %{selected_job: %{id: job_id}} -> job_id - _ -> nil - end - - params = - Helpers.with_params( - a: run.id, - v: [ - value: version, - when: fn _, _ -> workflow.lock_version != version end - ], - m: [value: mode, when: fn _, _ -> mode != nil end], - s: [value: selection, when: fn _, _ -> selection != nil end] - ) - - push_patch(socket, to: Helpers.build_url(socket.assigns, params)) - end - - defp assign_follow_run(socket, %{"a" => run_id}) when is_binary(run_id) do - assign_follow_run(socket, run_id) - end - - defp assign_follow_run(socket, query_params) when is_map(query_params) do - assign(socket, follow_run: nil) - end - - defp assign_follow_run(%{assigns: %{selected_job: nil}} = socket, _run_id) do - assign(socket, follow_run: nil) - end - - defp assign_follow_run(%{assigns: %{selected_job: job}} = socket, run_id) - when is_binary(run_id) do - run = Runs.get(run_id) - step = Invocation.get_first_step_for_run_and_job(run_id, job.id) - - Runs.subscribe(run) - - assign(socket, follow_run: run, step: step) - end - - defp mark_validated(socket) do - socket - |> assign(changeset: socket.assigns.changeset |> Map.put(:action, :validate)) - end - - defp run_workflow_button(assigns) do - params = Helpers.workflow_input_params(assigns.trigger_id) - url = Helpers.build_url(assigns, params) - assigns = assign(assigns, :url, url) - - ~H""" - <.button_link - disabled={@sending_ai_message} - patch={@url} - type="button" - theme="primary" - > - Run - - """ - end - - defp rerun(socket, run_id, step_id, via) do - %{ - can_run_workflow: can_run_workflow?, - current_user: current_user, - changeset: changeset, - project: %{id: project_id}, - snapshot_version_tag: tag, - has_presence_edit_priority: has_edit_priority?, - workflow: %{id: workflow_id} - } = socket.assigns - - save_or_get_workflow = - if has_edit_priority? do - Helpers.save_workflow(%{changeset | action: :update}, current_user) - else - {:ok, get_workflow_by_id(workflow_id)} - end - - with true <- can_run_workflow? || :not_authorized, - true <- tag == "latest" || :view_only, - :ok <- WorkOrders.limit_run_creation(project_id), - {:ok, workflow} <- save_or_get_workflow, - {:ok, run} <- - WorkOrders.retry(run_id, step_id, created_by: current_user) do - if via == "job_panel" do - {:ok, push_navigate(socket, to: ~p"/projects/#{project_id}/runs/#{run}")} - else - Runs.subscribe(run) - - snapshot = Snapshot.get_by_version(workflow.id, workflow.lock_version) - - {:ok, - socket - |> assign_workflow(workflow, snapshot) - |> follow_run(run) - |> push_event("push-hash", %{"hash" => "log"})} - end - end - end - - defp has_child_edges?(workflow_changeset, job_id) do - workflow_changeset - |> get_filtered_edges(&(&1.source_job_id == job_id)) - |> Enum.any?() - end - - defp first_job?(workflow_changeset, job_id) do - workflow_changeset - |> get_filtered_edges(&(&1.source_trigger_id && &1.target_job_id == job_id)) - |> Enum.any?() - end - - defp get_filtered_edges(workflow_changeset, filter_func) do - workflow_changeset - |> Ecto.Changeset.get_assoc(:edges, :struct) - |> Enum.filter(filter_func) - end - - defp job_deleted?(selected_job, workflow) do - not Enum.any?(workflow.jobs, fn job -> job.id == selected_job.id end) - end - - defp job_deletion_tooltip_message( - workflow_deleted, - can_edit_job, - has_child_edges, - is_first_job - ) do - cond do - workflow_deleted -> - "You cannot modify a deleted workflow" - - !can_edit_job -> - "You are not authorized to delete this step." - - has_child_edges -> - "You can't delete a step that other downstream steps depend on." - - is_first_job -> - "You can't delete the first step in a workflow." - - true -> - nil - end - end - - defp remove_edges_from_params(initial_params, edges_to_delete, id) do - Map.update!(initial_params, "edges", fn edges -> - edges - |> Enum.reject(fn edge -> - edge["id"] in Enum.map(edges_to_delete, & &1.id) - end) - end) - |> Map.update!("jobs", &Enum.reject(&1, fn job -> job["id"] == id end)) - end - - defp assign_chat_session_id(socket, params) do - socket - |> assign( - workflow_chat_session_id: params["w-chat"], - job_chat_session_id: params["j-chat"] - ) - end - - defp assign_show_workflow_ai_chat(socket) do - %{ - live_action: live_action, - query_params: query_params, - snapshot_version_tag: version - } = socket.assigns - - show_workflow_ai_chat = - (live_action == :edit && query_params["method"] == "ai" && - version == "latest") || - Map.get(socket.assigns, :show_workflow_ai_chat, false) - - assign(socket, show_workflow_ai_chat: show_workflow_ai_chat) - end - - defp update_canvas_state(socket, payload) do - show_canvas_placeholder = - Map.get( - payload, - :show_canvas_placeholder, - socket.assigns.show_canvas_placeholder - ) - - selected_template = - Map.get( - payload, - :show_template_tooltip, - socket.assigns.selected_template - ) - - sending_ai_message = Map.get(payload, :sending_ai_message, false) - - {:noreply, - socket - |> push_event("set-disabled", %{ - disabled: sending_ai_message - }) - |> assign( - show_canvas_placeholder: show_canvas_placeholder, - selected_template: selected_template, - sending_ai_message: sending_ai_message - ) - |> then(fn socket -> - if show_canvas_placeholder do - assign(socket, :workflow_params, %{}) - else - socket - end - end)} - end - - defp handle_workflow_params_change(socket, %{"workflow" => incoming_params}) do - create_action? = socket.assigns.live_action == :new - - {:noreply, - socket - |> handle_new_params(incoming_params, :workflow, !create_action?) - |> push_event("set-disabled", %{disabled: create_action?}) - |> push_event("force-fit", %{})} - end - - defp handle_component_registration(socket, %{ - component_id: component_id, - session_id: session_id - }) do - registry = socket.assigns.ai_assistant_registry - - if connected?(socket) && !Map.has_key?(registry, session_id) do - Lightning.subscribe("ai_session:#{session_id}") - end - - updated_registry = Map.put(registry, session_id, component_id) - - {:noreply, assign(socket, :ai_assistant_registry, updated_registry)} - end - - defp handle_component_unregistration(socket, %{component_id: component_id}) do - registry = socket.assigns.ai_assistant_registry - - session_id = - Enum.find_value(registry, fn {sid, cid} -> - if cid == component_id, do: sid - end) - - updated_registry = Map.delete(registry, session_id) - - if session_id && connected?(socket) do - Lightning.unsubscribe("ai_session:#{session_id}") - end - - {:noreply, assign(socket, :ai_assistant_registry, updated_registry)} - end - - defp handle_message_status_change(socket, %{ - status: status, - session_id: session_id - }) do - registry = socket.assigns.ai_assistant_registry - - case Map.get(registry, session_id) do - nil -> - {:noreply, socket} - - component_id -> - send_update(LightningWeb.AiAssistant.Component, - id: component_id, - message_status_changed: status - ) - - {:noreply, socket} - end - end - - defp link_workflow_to_ai_session(%{ - live_action: :new, - query_params: %{"method" => "ai", "w-chat" => chat_id}, - workflow: workflow - }) - when is_binary(chat_id) do - case Lightning.AiAssistant.get_session(chat_id) do - {:ok, session} -> - Lightning.AiAssistant.associate_workflow(session, workflow) - - {:error, reason} -> - Logger.warning( - "Failed to associate workflow with chat session #{chat_id}: #{inspect(reason)}" - ) - end - end - - defp link_workflow_to_ai_session(_assigns), do: :ok - - defp sync_to_github(socket, %{ - "github_sync" => %{"commit_message" => commit_message} - }) do - case VersionControl.initiate_sync( - socket.assigns.project_repo_connection, - commit_message - ) do - :ok -> - link_to_actions = - "https://www.github.com/" <> - socket.assigns.project_repo_connection.repo <> "/actions" - - socket - |> assign(show_github_sync_modal: false) - |> put_flash( - :info, - %DynamicComponent{ - function: &github_sync_successfull_flash/1, - args: %{link_to_actions: link_to_actions} - } - ) - - {:error, _github_error} -> - put_flash( - socket, - :error, - "Workflow saved but not synced to GitHub. Check the project GitHub connection settings" - ) - end - end - - defp prepare_workflow_template( - %{assigns: %{workflow: workflow, workflow_code: workflow_code}} = socket - ) do - template = - WorkflowTemplates.get_template_by_workflow_id(workflow.id) || - %WorkflowTemplate{ - workflow_id: workflow.id, - tags: [] - } - - changeset = - WorkflowTemplate.changeset(template, %{ - name: template.name || workflow.name, - code: workflow_code - }) - - socket - |> assign( - workflow_template: template, - workflow_template_changeset: changeset, - current_template_tag: nil, - has_workflow_template?: template.id != nil - ) - end - - # In situations where a new job is added, specifically by the WorkflowDiagram - # component, the job will not have an adaptor set. This function will set the - # adaptor to the current latest version of the adaptor, instead of the - # `@latest` version. - defp set_default_adaptors(params) do - case params do - %{"jobs" => job_params} -> - params - |> Map.put("jobs", job_params |> Enum.map(&maybe_add_default_adaptor/1)) - - _ -> - params - end - end - - defp maybe_add_default_adaptor(job_param) do - if Map.keys(job_param) == ["id"] do - job_param - |> Map.put( - "adaptor", - Lightning.AdaptorRegistry.resolve_adaptor(%Job{}.adaptor) - ) - else - job_param - end - end - - defp send_form_changed(params) do - send(self(), {:form_changed, params}) - end - - defp maybe_push_workflow_created(socket, workflow) do - if socket.assigns.live_action == :new do - push_event(socket, "workflow_created", %{id: workflow.id}) - else - socket - end - end - - defp render_trigger_title(trigger_type) do - case trigger_type do - "" -> - "New Trigger" - - "webhook" -> - "Webhook Trigger" - - "cron" -> - "Cron Trigger" - - "kafka" -> - kafka_trigger_title(%{id: "kafka-trigger-title"}) - - _ -> - "Unknown Trigger" - end - end - - defp generate_workflow_code(socket) do - push_event(socket, "generate_workflow_code", %{}) - end - - defp save_and_run_attributes(assigns) do - if step_retryable?(assigns) do - [ - type: "button", - "phx-click": "rerun", - "phx-value-run_id": assigns.follow_run.id, - "phx-value-step_id": assigns.step.id - ] - else - [type: "submit", form: assigns.manual_run_form.id] - end - end - - defp save_and_run_classes(assigns) do - base_class = "relative inline-flex items-center" - - if step_retryable?(assigns) do - [base_class, "rounded-r-none"] - else - [base_class] - end - end - - def collaborative_editor_base_url(assigns) do - if assigns.live_action == :new do - "/projects/#{assigns.project.id}/w/new" - else - "/projects/#{assigns.project.id}/w/#{assigns.workflow.id}" - end - end -end diff --git a/lib/lightning_web/live/workflow_live/editor_pane.ex b/lib/lightning_web/live/workflow_live/editor_pane.ex deleted file mode 100644 index b8023c6b257..00000000000 --- a/lib/lightning_web/live/workflow_live/editor_pane.ex +++ /dev/null @@ -1,107 +0,0 @@ -defmodule LightningWeb.WorkflowLive.EditorPane do - use LightningWeb, :live_component - - alias Lightning.Credentials - alias LightningWeb.JobLive.JobBuilderComponents - alias LightningWeb.UiMetrics - - attr :id, :string, required: true - attr :disabled, :boolean, default: false - attr :disabled_message, :string, required: true - attr :class, :string, default: "" - attr :on_change, :any, required: true - attr :adaptor, :string, required: true - attr :source, :string, required: true - attr :job_id, :string, required: true - - @impl true - def render(assigns) do - ~H""" -
- -
- """ - end - - @impl true - def update(%{event: :metadata_ready, metadata: metadata}, socket) do - {:ok, socket |> push_event("metadata_ready", metadata)} - end - - def update(%{form: form} = assigns, socket) do - socket = - socket - |> assign( - adaptor: - form[:adaptor].value - |> Lightning.AdaptorRegistry.resolve_adaptor(), - source: form.source.data.body, - job_id: form[:id].value - ) - - {:ok, socket |> assign(assigns)} - end - - @impl true - def handle_event("request_metadata", _params, socket) do - pid = self() - - %{adaptor: adaptor, id: id, form: form} = socket.assigns - - credential = fetch_credential(form[:project_credential_id].value) - - Task.start(fn -> - metadata = - Lightning.MetadataService.fetch(adaptor, credential) - |> case do - {:error, %{type: error_type}} -> - %{"error" => error_type} - - {:ok, metadata} -> - metadata - end - - send_update(pid, __MODULE__, - id: id, - metadata: metadata, - event: :metadata_ready - ) - end) - - {:noreply, socket} - end - - @impl true - def handle_event("job_editor_metrics_report", params, socket) do - UiMetrics.log_job_editor_metrics(socket.assigns.form.data, params["metrics"]) - - {:noreply, socket} - end - - # TODO: This is dead code and should probably be removed. All events are - # being handled by 'push-change' in the parent liveview - def handle_event("job_body_changed", %{"source" => source}, socket) do - params = - {socket.assigns.form[:body].name, source} - |> LightningWeb.Utils.decode_one() - - send(self(), {"form_changed", params}) - - {:noreply, socket} - end - - defp fetch_credential(id) do - case Ecto.UUID.cast(id) do - {:ok, _uuid} -> Credentials.get_credential_by_project_credential(id) - :error -> nil - end - end -end diff --git a/lib/lightning_web/live/workflow_live/helpers.ex b/lib/lightning_web/live/workflow_live/helpers.ex index 4f32430eb7f..11277a01f71 100644 --- a/lib/lightning_web/live/workflow_live/helpers.ex +++ b/lib/lightning_web/live/workflow_live/helpers.ex @@ -340,27 +340,6 @@ defmodule LightningWeb.WorkflowLive.Helpers do collaborative_only: ["panel"] } - def legacy_editor_url(params, live_action) do - base_url = legacy_base_url(params, live_action) - - final_params = - params - |> Map.drop(["id", "project_id"]) - |> Enum.reduce(%{}, fn {key, value}, acc -> - convert_param(key, value, acc, params) - end) - - build_url_with_params(base_url, final_params) - end - - defp legacy_base_url(%{"project_id" => project_id}, :new) do - "/projects/#{project_id}/w/new/legacy?method=template" - end - - defp legacy_base_url(%{"id" => id, "project_id" => project_id}, :edit) do - "/projects/#{project_id}/w/#{id}/legacy" - end - @doc """ Builds a URL to the collaborative editor with converted query parameters. diff --git a/lib/lightning_web/live/workflow_live/job_view.ex b/lib/lightning_web/live/workflow_live/job_view.ex deleted file mode 100644 index 59e37e929b8..00000000000 --- a/lib/lightning_web/live/workflow_live/job_view.ex +++ /dev/null @@ -1,328 +0,0 @@ -defmodule LightningWeb.WorkflowLive.JobView do - use LightningWeb, :component - - import LightningWeb.WorkflowLive.Components - - alias Lightning.Credentials - alias LightningWeb.Components.Tabbed - alias LightningWeb.WorkflowLive.EditorPane - - attr :id, :string, required: true - slot :top - - slot :inner_block, required: false - - slot :bottom - - slot :column do - attr :class, :string, doc: "Extra CSS classes for the column" - end - - def container(assigns) do - ~H""" -
-
-
- {render_slot(@top)} -
- -
- {render_slot(@inner_block)} -
-
- {render_slot(@bottom)} -
-
-
- """ - end - - slot :inner_block, required: true - attr :class, :string, default: "" - attr :id, :string, required: true - - defp column(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ - end - - attr :job, :map, required: true - attr :form, :map, required: true, doc: "A form built from a job" - attr :current_user, :map, required: true - attr :project, :map, required: true - attr :close_url, :any, required: true - attr :socket, :any, required: true - attr :follow_run_id, :any, default: nil - attr :snapshot, :any, required: true - attr :snapshot_version, :any, required: true - attr :display_banner, :boolean, default: false - attr :banner_message, :string, default: "" - attr :presences, :list, required: true - attr :prior_user_presence, :any, required: true - attr :query_params, :map, default: %{} - - slot :footer - - slot :collapsible_panel do - attr :id, :string, required: true - attr :panel_title, :string, required: true - attr :class, :string, doc: "Extra CSS classes for the column" - end - - def job_edit_view(assigns) do - {editor_disabled?, editor_disabled_message} = editor_disabled?(assigns) - - assigns = - assigns - |> assign( - editor_disabled?: editor_disabled?, - editor_disabled_message: editor_disabled_message - ) - - ~H""" - <.container id={"job-edit-view-#{@job.id}"}> - <:top> -
-
- {@job.name} -
- <.adaptor_block adaptor={@job.adaptor} /> - <.credential_block credential={ - fetch_credential( - @form[:project_credential_id].value, - @form[:keychain_credential_id].value - ) - } /> -
- - <%= if @project.env do %> -
- - {@project.env} - -
- <% end %> - - -
-
- <.offline_indicator /> - <.link - id={"close-job-edit-view-#{@job.id}"} - phx-disconnected={ - Phoenix.LiveView.JS.set_attribute( - {"data-confirm", - "You're currently disconnected.\nBy closing you will lose any unsaved changes.\nAre you sure you want to close this job?"} - ) - } - phx-connected={Phoenix.LiveView.JS.remove_attribute("data-confirm")} - patch={@close_url} - phx-hook="CloseInspectorPanelViaEscape" - > - - -
-
- - You are currently working in the sandbox - {@project.name} - - - <%= for slot <- @collapsible_panel do %> - <.collapsible_panel - id={slot[:id]} - panel_title={slot[:panel_title]} - class={"#{slot[:class]} h-full border border-l-0"} - > - {render_slot(slot)} - - <% end %> - {render_slot(@inner_block)} - <.collapsible_panel - id="job-editor-panel" - class="h-full border border-l-0 job-editor-panel" - panel_title={if(@editor_disabled?, do: "Editor (read-only)", else: "Editor")} - data-editor-disabled={"#{@editor_disabled?}"} - > - <.live_component - module={EditorPane} - id={"job-editor-pane-#{@job.id}"} - form={@form} - disabled={@editor_disabled?} - disabled_message={@editor_disabled_message} - class="h-full p-2" - /> - - <.collapsible_panel id="output-logs" class="h-full border border-l-0"> - <:tabs> - - <:tab hash="run"> - Run - - <:tab hash="log"> - Log - - <:tab hash="input"> - Input - - <:tab hash="output"> - Output - - - - - <%= if @follow_run_id do %> - {live_render( - @socket, - LightningWeb.RunLive.RunViewerLive, - id: "run-viewer-#{@follow_run_id}", - session: %{ - "run_id" => @follow_run_id, - "job_id" => @job.id, - "project_id" => @project.id, - "user_id" => @current_user.id, - "socket_id" => @socket.id - }, - container: {:div, class: "h-full p-2"} - )} - <% else %> -
-
- After you click run, the logs and output will be visible here. -
-
- <% end %> - - <:bottom> - {render_slot(@footer)} - - - """ - end - - defp editor_disabled?(params) do - cond do - is_struct(params.form.source.data, Lightning.Workflows.Snapshot.Job) -> - {true, - "You can't edit while viewing a snapshot, switch to the latest version."} - - params.display_banner -> - {true, params.banner_message} - - true -> - {false, ""} - end - end - - defp credential_block(assigns) do - ~H""" - - """ - end - - defp adaptor_block(assigns) do - {package_name, version} = - Lightning.AdaptorRegistry.resolve_package_name(assigns.adaptor) - - assigns = - assigns - |> assign( - package_name: package_name, - version: version - ) - - ~H""" - - """ - end - - defp fetch_credential(project_credential_id, keychain_credential_id) do - cond do - project_credential_id && byte_size(project_credential_id) > 0 -> - Credentials.get_credential_by_project_credential(project_credential_id) - - keychain_credential_id && byte_size(keychain_credential_id) > 0 -> - Credentials.get_keychain_credential(keychain_credential_id) - - true -> - nil - end - end -end diff --git a/lib/lightning_web/live/workflow_live/new_workflow_component.ex b/lib/lightning_web/live/workflow_live/new_workflow_component.ex deleted file mode 100644 index 08757ab098d..00000000000 --- a/lib/lightning_web/live/workflow_live/new_workflow_component.ex +++ /dev/null @@ -1,768 +0,0 @@ -defmodule LightningWeb.WorkflowLive.NewWorkflowComponent do - @moduledoc """ - Comprehensive LiveView component for creating new workflows through multiple methods. - - This component provides a unified interface for workflow creation, supporting three - distinct creation methods while maintaining a consistent user experience and - validation pipeline. It serves as the primary entry point for all workflow - creation workflows within Lightning. - """ - use LightningWeb, :live_component - - alias Lightning.Projects - alias Lightning.Workflows.Workflow - alias Lightning.WorkflowTemplates - alias LightningWeb.API.ProvisioningJSON - alias LightningWeb.Live.AiAssistant.ModeRegistry - alias Phoenix.LiveView.JS - - require Logger - - @impl true - def mount(socket) do - base_templates = base_templates() - users_templates = WorkflowTemplates.list_templates() - - {:ok, - socket - |> assign(base_url: nil) - |> assign(search_term: "") - |> assign(chat_session_id: nil) - |> assign(selected_template: nil) - |> assign(workflow_code: nil) - |> assign(session_or_message: nil) - |> assign(validation_failed: true) - |> assign(selected_method: "template") - |> assign(base_templates: base_templates) - |> assign(users_templates: users_templates) - |> assign(filtered_templates: users_templates) - |> assign(all_templates: base_templates ++ users_templates)} - end - - @impl true - def update( - %{ - action: :workflow_code_generated, - workflow_code: code, - session_or_message: session_or_message - }, - socket - ) do - {:ok, - socket - |> assign(session_or_message: session_or_message) - |> assign(workflow_code: code) - |> then(fn s -> - if code, - do: push_event(s, "template_selected", %{template: code}), - else: s - end)} - end - - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_new(:changeset, fn %{workflow: workflow} -> - Workflow.changeset(workflow, %{}) - end)} - end - - @impl true - def handle_event("choose-another-method", %{"method" => method}, socket) do - socket = - assign(socket, changeset: Workflow.changeset(socket.assigns.workflow, %{})) - - case method do - "ai" -> - handle_ai_method_selection(socket) - - _ -> - handle_regular_method_selection(socket, method) - end - end - - def handle_event("search-templates", %{"search" => search_term}, socket) do - filtered_templates = - filter_templates(socket.assigns.users_templates, search_term) - - {:noreply, - socket - |> assign(search_term: search_term) - |> assign(filtered_templates: filtered_templates)} - end - - def handle_event("select-template", %{"template_id" => template_id}, socket) do - template = Enum.find(socket.assigns.all_templates, &(&1.id == template_id)) - - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: false, - show_template_tooltip: template - }) - - {:noreply, - socket - |> assign(selected_template: template) - |> push_event("template_selected", %{template: template.code})} - end - - def handle_event( - event_name, - %{"workflow" => params}, - %{assigns: %{project: project, selected_template: template}} = socket - ) - when event_name in ["workflow-parsed", "template-parsed"] do - params = ensure_unique_name(params, project) - changeset = Workflow.changeset(socket.assigns.workflow, params) - - if changeset.valid? do - template_for_tooltip = get_template_for_tooltip(event_name, template) - - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: false, - show_template_tooltip: template_for_tooltip - }) - - notify_parent(:workflow_params_changed, %{"workflow" => params}) - - {:noreply, - socket - |> assign(changeset: changeset) - |> assign(validation_failed: false) - |> push_event("workflow-validated", %{}) - |> push_event("state-applied", %{"state" => params}) - |> push_event("force-fit", %{})} - else - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - {:noreply, - socket - |> assign(changeset: changeset) - |> assign(validation_failed: true) - |> assign_error_changeset(changeset, event_name) - |> push_event( - "workflow-validation-errors", - ProvisioningJSON.error(%{changeset: changeset}) - )} - end - end - - def handle_event("template-parse-error", %{"error" => error}, socket) do - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - {:noreply, send_error(socket, error)} - end - - def handle_event( - "workflow-parsing-failed", - %{"error" => error_message}, - socket - ) do - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - {:noreply, - socket - |> assign(validation_failed: true) - |> push_event("show-parsing-error", %{error: error_message})} - end - - def handle_event(_event, _params, socket) do - {:noreply, socket} - end - - defp send_error(socket, error) do - Logger.error("Workflow code parse failed: #{inspect(error)}") - - send_update( - LightningWeb.AiAssistant.Component, - id: socket.assigns.ai_assistant_component_id, - action: :code_error, - error: error, - session_or_message: socket.assigns.session_or_message - ) - - socket - end - - defp ensure_unique_name(params, project) do - workflow_name = - params["name"] - |> to_string() - |> String.trim() - |> case do - "" -> "Untitled workflow" - name -> name - end - - existing_workflows = Projects.list_workflows(project) - unique_name = generate_unique_name(workflow_name, existing_workflows) - - Map.put(params, "name", unique_name) - end - - defp get_template_for_tooltip("template-parsed", template), do: template - defp get_template_for_tooltip("workflow-parsed", _template), do: nil - - defp assign_error_changeset(socket, changeset, "workflow-parsed"), - do: assign(socket, changeset: changeset) - - defp assign_error_changeset(socket, _changeset, "template-parsed"), do: socket - - defp generate_unique_name(base_name, existing_workflows) do - existing_names = MapSet.new(existing_workflows, & &1.name) - - if MapSet.member?(existing_names, base_name) do - find_available_name(base_name, existing_names) - else - base_name - end - end - - defp find_available_name(base_name, existing_names) do - 1 - |> Stream.iterate(&(&1 + 1)) - |> Stream.map(&"#{base_name} #{&1}") - |> Enum.find(&name_available?(&1, existing_names)) - end - - defp name_available?(name, existing_names) do - not MapSet.member?(existing_names, name) - end - - defp filter_templates(templates, search_term) - when is_binary(search_term) and search_term != "" do - search_term = String.downcase(search_term) - - matches_search_term = fn template -> - name_match = - template.name && - String.contains?(String.downcase(template.name), search_term) - - description_match = - template.description && - String.contains?(String.downcase(template.description), search_term) - - tags_match = - template.tags && - Enum.any?( - template.tags, - &String.contains?(String.downcase(&1), search_term) - ) - - name_match || description_match || tags_match - end - - Enum.filter(templates, matches_search_term) - end - - defp filter_templates(templates, _), do: templates - - defp notify_parent(action, payload) do - send(self(), {:ai_assistant, action, payload}) - end - - defp handle_ai_method_selection(socket) do - search_term = socket.assigns.search_term - - if search_term && String.trim(search_term) != "" do - case create_ai_session_for_input(socket.assigns, search_term) do - {:ok, session_id} -> - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - {:noreply, - socket - |> assign(selected_method: "ai") - |> assign(selected_template: nil) - |> assign(chat_session_id: session_id) - |> assign(search_term: nil) - |> push_patch( - to: - "/projects/#{socket.assigns.project.id}/w/new/legacy?method=ai&w-chat=#{session_id}" - )} - - {:error, reason} -> - {:noreply, - socket - |> put_flash(:error, "Failed to create AI session: #{reason}") - |> handle_regular_method_selection("ai")} - end - else - handle_regular_method_selection(socket, "ai") - end - end - - defp handle_regular_method_selection(socket, method) do - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - {:noreply, - socket - |> assign(selected_method: method) - |> assign(selected_template: nil) - |> assign(chat_session_id: nil) - |> push_patch( - to: "/projects/#{socket.assigns.project.id}/w/new/legacy?method=#{method}" - )} - end - - defp create_ai_session_for_input(assigns, input_value) do - handler = ModeRegistry.get_handler(:workflow) - - session_assigns = %{ - project: assigns.project, - user: assigns.user - } - - case handler.create_session(session_assigns, input_value) do - {:ok, session} -> {:ok, session.id} - {:error, reason} -> {:error, inspect(reason)} - end - end - - @impl true - def render(assigns) do - assigns = - assign(assigns, - filtered_templates: - Enum.sort_by( - assigns.filtered_templates, - & &1.name - ) - ) - - ~H""" -
-
-
-
- <.create_workflow_from_template - :if={@selected_method == "template"} - myself={@myself} - search_term={@search_term} - filtered_templates={@filtered_templates} - base_templates={@base_templates} - selected_template={@selected_template} - project={@project} - /> - <.create_workflow_via_import - :if={@selected_method == "import"} - myself={@myself} - changeset={@changeset} - /> -
- <.create_workflow_via_ai - :if={@selected_method == "ai"} - parent_id={@id} - project={@project} - user={@user} - can_edit={@can_edit} - chat_session_id={@chat_session_id} - query_params={@query_params} - workflow_code={@workflow_code} - base_url={@base_url} - search_term={@search_term} - ai_assistant_component_id={@ai_assistant_component_id} - /> -
-
- <.button - :if={@selected_method == "template"} - id="import-workflow-btn" - type="button" - theme="secondary" - class="inline-flex gap-x-1 px-4" - phx-click="choose-another-method" - phx-value-method="import" - phx-target={@myself} - > - <.icon name="hero-document-arrow-up" class="size-5" /> Import - - <.button - :if={@selected_method != "template"} - id="move-back-to-templates-btn" - type="button" - theme="secondary" - class="inline-flex gap-x-1 px-4" - phx-click="choose-another-method" - phx-value-method="template" - phx-target={@myself} - > - Back - - <.button - id="create_workflow_btn" - type="button" - theme="primary" - class="inline-flex gap-x-1 px-4" - {if !create_disabled?(assigns), do: ["phx-click": JS.push("save")], else: []} - phx-disconnected={JS.set_attribute({"disabled", ""})} - phx-connected={ - !create_disabled?(assigns) && JS.remove_attribute("disabled") - } - disabled={create_disabled?(assigns)} - > - Create - -
-
-
- """ - end - - attr :selected_template, :map, required: true - attr :myself, :any, required: true - attr :search_term, :string, required: true - attr :project, :any, required: true - - defp ai_template_card(assigns) do - ~H""" - - """ - end - - defp create_workflow_from_template(assigns) do - ~H""" -
-
- <.form - id="search-templates-form" - phx-change="search-templates" - phx-target={@myself} - phx-debounce="300" - for={to_form(%{"search" => @search_term})} - > -
- <.input_element - type="text" - name="search" - placeholder="Describe your workflow" - class="block w-full rounded-md border-0 py-2 pl-10 pr-4 text-gray-900 ring-1 ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm" - value={@search_term} - /> -
- <.icon name="hero-magnifying-glass" class="size-5 text-gray-400" /> -
-
- -
- - - - <.form - id="choose-workflow-template-form" - phx-change="select-template" - phx-target={@myself} - for={to_form(%{})} - class="flex-grow overflow-hidden flex flex-col" - > - - -
- """ - end - - attr :changeset, Ecto.Changeset, required: true - attr :myself, :any, required: true - - defp create_workflow_via_import(assigns) do - ~H""" -
- -
-
- -
- -

or drag and drop

-
-

YML or YAML, up to 8MB

-
-
-
- -
- OR -
-
-
- <.textarea_element - id="workflow-code-viewer" - phx-update="ignore" - name="workflow-code" - value="" - class="font-mono proportional-nums text-slate-200 bg-slate-700 resize-none text-nowrap overflow-x-auto flex-grow" - placeholder="Paste your YAML content here" - /> -
-
- """ - end - - @spec build_ai_callbacks(String.t()) :: map() - defp build_ai_callbacks(parent_id) do - %{ - on_session_close: fn -> - notify_parent(:canvas_state_changed, %{ - show_canvas_placeholder: true, - show_template_tooltip: nil - }) - - send_workflow_update(parent_id, nil, nil) - end, - on_session_open: &send_workflow_update(parent_id, &1, &2), - on_message_selected: &send_workflow_update(parent_id, &1, &2), - on_message_received: &send_workflow_update(parent_id, &1, &2) - } - end - - @spec send_workflow_update(String.t(), String.t() | nil, any()) :: :ok - defp send_workflow_update(parent_id, code, session_or_message) do - send_update(__MODULE__, - id: parent_id, - action: :workflow_code_generated, - workflow_code: code, - session_or_message: session_or_message - ) - end - - defp create_workflow_via_ai(assigns) do - assigns = assign(assigns, :callbacks, build_ai_callbacks(assigns.parent_id)) - - ~H""" -
- <.live_component - module={LightningWeb.AiAssistant.Component} - mode={:workflow} - can_edit={@can_edit} - project={@project} - user={@user} - chat_session_id={@chat_session_id} - code={@workflow_code} - query_params={@query_params} - base_url={@base_url} - action={if(@chat_session_id, do: :show, else: :new)} - callbacks={@callbacks} - id={@ai_assistant_component_id} - /> -
- """ - end - - defp base_templates do - [ - %{ - id: "base-webhook-template", - name: "Event-based Workflow", - description: "The basic structure for a webhook-triggered workflow", - tags: ["webhook", "event", "workflow"], - code: """ - name: "Event-based Workflow" - jobs: - Step-1: - name: Transform data - adaptor: "@openfn/language-common@latest" - body: | - // Check out the Job Writing Guide for help getting started: - // https://docs.openfn.org/documentation/jobs/job-writing-guide - triggers: - webhook: - type: webhook - enabled: true - edges: - webhook->Step-1: - source_trigger: webhook - target_job: Step-1 - condition_type: always - enabled: true - """ - }, - %{ - id: "base-cron-template", - name: "Scheduled Workflow", - description: "The basic structure for a cron-triggered workflow", - tags: ["cron", "scheduled", "workflow"], - code: """ - name: "Scheduled Workflow" - jobs: - Get-data: - name: Get data - adaptor: "@openfn/language-http@7.0.3" - body: | - // Check out the Job Writing Guide for help getting started: - // https://docs.openfn.org/documentation/jobs/job-writing-guide - get('https://docs.openfn.org/documentation'); - triggers: - cron: - type: cron - cron_expression: "*/15 * * * *" - enabled: true - edges: - cron->Get-data: - source_trigger: cron - target_job: Get-data - condition_type: always - enabled: true - """ - } - ] - end - - defp create_disabled?(assigns) do - case assigns.selected_method do - "import" -> !assigns.changeset.valid? or assigns.validation_failed - "template" -> is_nil(assigns.selected_template) - "ai" -> is_nil(assigns.workflow_code) or !assigns.changeset.valid? - end - end -end diff --git a/lib/lightning_web/router.ex b/lib/lightning_web/router.ex index 9b242d7808d..175f045a1f7 100644 --- a/lib/lightning_web/router.ex +++ b/lib/lightning_web/router.ex @@ -251,11 +251,16 @@ defmodule LightningWeb.Router do live "/dataclips/:id/show", DataclipLive.Show, :show live "/w", WorkflowLive.Index, :index - live "/w/new/legacy", WorkflowLive.Edit, :new live "/w/new", WorkflowLive.Collaborate, :new - live "/w/:id/legacy", WorkflowLive.Edit, :edit live "/w/:id", WorkflowLive.Collaborate, :edit + # Redirect retired legacy editor URLs to the collaborative editor, + # preserving the query string. The collaborative editor uses different + # query param names than the legacy editor; the raw query string is + # forwarded as-is. + get "/w/new/legacy", LegacyRedirectController, :new + get "/w/:id/legacy", LegacyRedirectController, :edit + live "/channels", ChannelLive.Index, :index live "/channels/new", ChannelLive.Index, :new live "/channels/:id/edit", ChannelLive.Index, :edit diff --git a/priv/repo/migrations/20260629143825_clear_prefer_legacy_editor.exs b/priv/repo/migrations/20260629143825_clear_prefer_legacy_editor.exs new file mode 100644 index 00000000000..7d0c74eebed --- /dev/null +++ b/priv/repo/migrations/20260629143825_clear_prefer_legacy_editor.exs @@ -0,0 +1,9 @@ +defmodule Lightning.Repo.Migrations.ClearPreferLegacyEditor do + use Ecto.Migration + + def up do + execute(""" + UPDATE users SET preferences = preferences - 'prefer_legacy_editor' WHERE preferences ? 'prefer_legacy_editor' + """) + end +end diff --git a/test/lightning_web/controllers/api/workflows_controller_test.exs b/test/lightning_web/controllers/api/workflows_controller_test.exs index fcd15ca5c4f..62378626a74 100644 --- a/test/lightning_web/controllers/api/workflows_controller_test.exs +++ b/test/lightning_web/controllers/api/workflows_controller_test.exs @@ -3,7 +3,6 @@ defmodule LightningWeb.API.WorkflowsControllerTest do import Lightning.Factories import Lightning.WorkflowsFixtures - import Phoenix.LiveViewTest alias Lightning.Extensions.Message alias Lightning.Workflows @@ -1010,8 +1009,9 @@ defmodule LightningWeb.API.WorkflowsControllerTest do refute Presence.has_any_presence?(workflow) - {:ok, _view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") + # Simulate a user editing the workflow in the collaborative editor by + # tracking their presence on the workflow. + Presence.track_user_presence(user, workflow, self()) patch = %{name: "work1.1"} @@ -1534,8 +1534,9 @@ defmodule LightningWeb.API.WorkflowsControllerTest do refute Presence.has_any_presence?(workflow) - {:ok, _view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") + # Simulate a user editing the workflow in the collaborative editor by + # tracking their presence on the workflow. + Presence.track_user_presence(user, workflow, self()) workflow_update = %{workflow | name: "work1.1"} diff --git a/test/lightning_web/controllers/legacy_redirect_controller_test.exs b/test/lightning_web/controllers/legacy_redirect_controller_test.exs new file mode 100644 index 00000000000..83d0501431b --- /dev/null +++ b/test/lightning_web/controllers/legacy_redirect_controller_test.exs @@ -0,0 +1,57 @@ +defmodule LightningWeb.LegacyRedirectControllerTest do + use LightningWeb.ConnCase, async: true + + import Lightning.Factories + + setup :register_and_log_in_user + setup :create_project_for_current_user + + describe "legacy editor redirects" do + test "redirects /w/new/legacy to the collaborative new workflow editor", %{ + conn: conn, + project: project + } do + conn = get(conn, ~p"/projects/#{project.id}/w/new/legacy") + + assert redirected_to(conn) == "/projects/#{project.id}/w/new" + end + + test "redirects /w/new/legacy preserving the query string", %{ + conn: conn, + project: project + } do + conn = get(conn, "/projects/#{project.id}/w/new/legacy?method=template") + + assert redirected_to(conn) == + "/projects/#{project.id}/w/new?method=template" + end + + test "redirects /w/:id/legacy to the collaborative editor", %{ + conn: conn, + project: project + } do + workflow = insert(:workflow, project: project) + + conn = get(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") + + assert redirected_to(conn) == + "/projects/#{project.id}/w/#{workflow.id}" + end + + test "redirects /w/:id/legacy preserving the (legacy) query string", %{ + conn: conn, + project: project + } do + workflow = insert(:workflow, project: project) + + conn = + get( + conn, + "/projects/#{project.id}/w/#{workflow.id}/legacy?s=some-job&m=expand&a=run-1" + ) + + assert redirected_to(conn) == + "/projects/#{project.id}/w/#{workflow.id}?s=some-job&m=expand&a=run-1" + end + end +end diff --git a/test/lightning_web/live/ai_assistant_live_test.exs b/test/lightning_web/live/ai_assistant_live_test.exs deleted file mode 100644 index 02e4367d2c5..00000000000 --- a/test/lightning_web/live/ai_assistant_live_test.exs +++ /dev/null @@ -1,3595 +0,0 @@ -defmodule LightningWeb.AiAssistantLiveTest do - use LightningWeb.ConnCase, async: true - - @moduletag :capture_log - - import Lightning.Factories - import Lightning.WorkflowLive.Helpers - import Mox - import Phoenix.Component - import Phoenix.LiveViewTest - - setup :verify_on_exit! - setup :register_and_log_in_user - setup :create_project_for_current_user - - setup do - Process.put(:oban_testing, :manual) - :ok - end - - defp skip_disclaimer(user, read_at \\ DateTime.utc_now() |> DateTime.to_unix()) do - Ecto.Changeset.change(user, %{ - preferences: %{"ai_assistant.disclaimer_read_at" => read_at} - }) - |> Lightning.Repo.update!() - end - - describe "AI Assistant - Job Code Mode" do - setup :create_workflow - - test "non openfn.org users can access the AI Assistant", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - refute String.match?(user.email, ~r/@openfn\.org/i) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - html = view |> element("#job-#{job_1.id}-ai-assistant") |> render() - assert html =~ "Get started with the AI Assistant" - end - - @tag email: "user@openfn.org" - test "correct information is displayed when the assistant is not configured", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow, - user: user - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> nil - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: "/"}, _opts -> - {:error, :econnrefused} - - %{method: :get, url: "http://localhost:4001/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - assert render(view) =~ - "AI Assistant has not been configured for your instance" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - - refute render(view) =~ - "AI Assistant has not been configured for your instance" - end - - @tag email: "user@openfn.org" - test "disclaimer ui is displayed when user has not read it", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - refute user.preferences["ai_assistant.disclaimer_read_at"] - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - html = view |> element("#job-#{job_1.id}-ai-assistant") |> render() - assert html =~ "Get started with the AI Assistant" - - refute has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - - view |> element("#get-started-with-ai-btn") |> render_click() - html = view |> element("#job-#{job_1.id}-ai-assistant") |> render() - refute html =~ "Get started with the AI Assistant" - - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - - assert Lightning.Repo.reload(user).preferences[ - "ai_assistant.disclaimer_read_at" - ] - end - - @tag email: "user@openfn.org" - test "disclaimer ui is displayed when user read it more than 24 hours ago", - %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - date = DateTime.utc_now() |> DateTime.add(-24, :hour) |> DateTime.to_unix() - - skip_disclaimer(user, date) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - html = view |> element("#job-#{job_1.id}-ai-assistant") |> render() - assert html =~ "Get started with the AI Assistant" - - refute has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - end - - @tag email: "user@openfn.org" - test "disclaimer ui is NOT displayed when user read it less than 24 hours ago", - %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - html = - view - |> element("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render() - - refute html =~ "Get started with the AI Assistant" - - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - end - - @tag email: "user@openfn.org" - test "authorized users can send a message", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{"history" => [%{"role" => "assistant", "content" => "Hello!"}]} - ) - - [:owner, :admin, :editor] - |> Enum.map(fn role -> - timestamp = DateTime.utc_now() |> DateTime.to_unix() - - user = - insert(:user, - email: "email-#{Enum.random(1..1_000)}@testemail.org", - preferences: %{"ai_assistant.disclaimer_read_at" => timestamp} - ) - - insert(:project_user, project: project, user: user, role: role) - - user - end) - |> Enum.each(fn user -> - conn = log_in_user(conn, user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - assert view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> has_element?() - - input_element = - element( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant textarea" - ) - - submit_btn = - element( - view, - "#ai-assistant-form-submit-btn-chat-input-job-#{job_1.id}-ai-assistant" - ) - - assert has_element?(input_element) - refute render(input_element) =~ "disabled=\"disabled\"" - assert has_element?(submit_btn) - # Submit button should be disabled when no content is entered - assert render(submit_btn) =~ "disabled=\"disabled\"" - - html = - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Hello"}) - - refute html =~ "You are not authorized to use the Ai Assistant" - - assert_patch(view) - end) - - [:viewer] - |> Enum.map(fn role -> - timestamp = DateTime.utc_now() |> DateTime.to_unix() - - user = - insert(:user, - email: "email-#{Enum.random(1..1_000)}@openfn.org", - preferences: %{"ai_assistant.disclaimer_read_at" => timestamp} - ) - - insert(:project_user, project: project, user: user, role: role) - - user - end) - |> Enum.each(fn user -> - conn = log_in_user(conn, user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - assert view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> has_element?() - - input_element = - element( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant textarea" - ) - - submit_btn = - element( - view, - "#ai-assistant-form-submit-btn-chat-input-job-#{job_1.id}-ai-assistant" - ) - - assert has_element?(input_element) - assert render(input_element) =~ "disabled=\"disabled\"" - assert has_element?(submit_btn) - assert render(submit_btn) =~ "disabled=\"disabled\"" - - html = - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Hello"}) - - assert html =~ "You are not authorized to use the AI Assistant" - end) - end - - @tag email: "user@openfn.org" - test "submit btn is disabled in case the job isnt saved yet", %{ - conn: conn, - project: project, - user: user, - workflow: workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - job_id = Ecto.UUID.generate() - push_patches_to_view(view, [add_job_patch("new job", job_id)]) - - select_node(view, %{id: job_id}) - view |> element("a#open-inspector-#{job_id}") |> render_click() - - render_async(view) - - assert view - |> form("#ai-assistant-form-job-#{job_id}-ai-assistant") - |> has_element?() - - input_element = - element(view, "#ai-assistant-form-job-#{job_id}-ai-assistant textarea") - - submit_btn = - element( - view, - "#ai-assistant-form-submit-btn-chat-input-job-#{job_id}-ai-assistant" - ) - - assert has_element?(input_element) - assert render(input_element) =~ "disabled=\"disabled\"" - assert has_element?(submit_btn) - assert render(submit_btn) =~ "disabled=\"disabled\"" - - assert render(input_element) =~ - ~s(placeholder="Save your workflow first to use the AI Assistant") - end - - @tag email: "user@openfn.org" - test "form accepts phx-change", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - random_text = "Ping12345678" - - html = - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_change(assistant: %{content: random_text}) - - assert html =~ random_text - end - - @tag email: "user@openfn.org" - test "users can start a new session", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "user", "content" => "Ping"}, - %{"role" => "assistant", "content" => "Pong"} - ] - } - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - - assert_patch(view) - - # In test environment with inline Oban, response appears immediately - html = render(view) - assert html =~ "Pong" - end - - @tag email: "user@openfn.org" - test "users can resume a session", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - expected_question = "Can you help me with this?" - expected_answer = "No, I am a robot" - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "user", "content" => "Ping"}, - %{"role" => "assistant", "content" => "Pong"}, - %{"role" => "user", "content" => expected_question}, - %{"role" => "assistant", "content" => expected_answer} - ] - } - ) - - session = - insert(:job_chat_session, - user: user, - job: job_1, - messages: [ - %{role: :user, content: "Ping", user: user}, - %{role: :assistant, content: "Pong"} - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - refute render_async(view) =~ session.title - - view |> element("#get-started-with-ai-btn") |> render_click() - - assert render_async(view) =~ session.title - - view |> element("#session-#{session.id}") |> render_click() - - assert_patch(view) - - # Submit the form - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: expected_question}) - - # In test environment with inline Oban, the response appears immediately - html = render(view) - assert html =~ expected_answer - end - - @tag email: "user@openfn.org" - test "an error is displayed incase the assistant does not return 200", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:ok, %Tesla.Env{status: 400, body: %{"message" => "Bad request"}}} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - - assert_patch(view) - - # Error appears immediately in test environment - html = render(view) - - # The user message should show as failed - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "an error is displayed when the assistant query fails", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - # Return an error response - {:ok, - %Tesla.Env{ - status: 500, - body: %{"message" => "Internal server error"} - }} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - end) - - assert log =~ "AI query failed" - assert log =~ "Internal server error" - - assert_patch(view) - - html = render(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "shows a flash error when limit has reached", %{ - conn: conn, - project: %{id: project_id} = project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001/health_check" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - error_message = "You have reached your quota of AI queries" - - Mox.stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn %{ - type: - :ai_usage - }, - %{ - project_id: - ^project_id - } -> - {:error, :exceeds_limit, - %Lightning.Extensions.Message{text: error_message}} - end) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - assert has_element?(view, "#ai-assistant-error", error_message) - - input_element = - element(view, "#ai-assistant-form-job-#{job_1.id}-ai-assistant textarea") - - assert render(input_element) =~ - ~s(placeholder="#{error_message}") - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - - assert has_element?(view, "#ai-assistant-error", error_message) - end - - @tag email: "user@openfn.org" - test "displays apollo server error messages", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - error_message = "Server is temporarily unavailable" - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:ok, - %Tesla.Env{ - status: 503, - body: %{ - "code" => 503, - "message" => error_message - } - }} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - view |> element("#get-started-with-ai-btn") |> render_click() - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - end) - - assert log =~ "AI query failed for session" - assert log =~ "Server is temporarily unavailable" - - assert_patch(view) - render_async(view) - - html = render(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "handles timeout errors from Apollo", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:error, :timeout} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - end) - - assert log =~ "AI query timed out for session" - assert log =~ "Request timed out. Please try again." - - assert_patch(view) - render_async(view) - - html = render(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "handles connection refused errors from Apollo", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:error, :econnrefused} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - end) - - assert log =~ "Connection refused to AI server for session" - assert log =~ "Unable to reach the AI server. Please try again later." - - html = render_async(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "handles unexpected errors from Apollo", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:error, :unknown_error} - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - view |> element("#get-started-with-ai-btn") |> render_click() - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - - assert_patch(view) - render_async(view) - - html = render(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - assert has_element?(view, "[phx-click='retry_message']") - end - - @tag email: "user@openfn.org" - test "users can sort chat sessions", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - - older_session = - insert(:job_chat_session, - user: user, - job: job_1, - updated_at: ~N[2024-01-01 10:00:00], - title: "January Session", - messages: [ - %{role: :user, content: "First message", user: user}, - %{role: :assistant, content: "First response"} - ] - ) - - newer_session = - insert(:job_chat_session, - user: user, - job: job_1, - updated_at: ~N[2024-02-01 10:00:00], - title: "February Session", - messages: [ - %{role: :user, content: "Second message", user: user}, - %{role: :assistant, content: "Second response"} - ] - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - html = render(view) - assert html =~ "Latest" - - links = - Floki.find( - Floki.parse_document!(html), - "a[id^='session-']" - ) - - assert length(links) == 2 - [first_link, second_link] = links - - assert first_link |> Floki.attribute("id") == [ - "session-#{newer_session.id}" - ] - - assert second_link |> Floki.attribute("id") == [ - "session-#{older_session.id}" - ] - - view |> element("button[phx-click='toggle_sort']") |> render_click() - render_async(view) - html = render(view) - - links = - Floki.find( - Floki.parse_document!(html), - "a[id^='session-']" - ) - - assert length(links) == 2 - [first_link, second_link] = links - - assert first_link |> Floki.attribute("id") == [ - "session-#{older_session.id}" - ] - - assert second_link |> Floki.attribute("id") == [ - "session-#{newer_session.id}" - ] - - view |> element("button[phx-click='toggle_sort']") |> render_click() - render_async(view) - html = render(view) - - links = - Floki.find( - Floki.parse_document!(html), - "a[id^='session-']" - ) - - assert length(links) == 2 - [first_link, second_link] = links - - assert first_link |> Floki.attribute("id") == [ - "session-#{newer_session.id}" - ] - - assert second_link |> Floki.attribute("id") == [ - "session-#{older_session.id}" - ] - end - - @tag email: "user@openfn.org" - test "input field is cleared after sending a message", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "assistant", "content" => "Response!"} - ] - } - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - assert view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> has_element?() - - input_element = - element(view, "#ai-assistant-form-job-#{job_1.id}-ai-assistant textarea") - - assert has_element?(input_element) - - message = "Hello, AI Assistant!" - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: message}) - - render_async(view) - - refute render(input_element) =~ message - end - - @tag email: "user@openfn.org" - test "users can retry failed messages", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:ok, %Tesla.Env{status: 500}} - end) - - session = - insert(:job_chat_session, - user: user, - job: job_1, - messages: [ - %{role: :user, content: "Hello", status: :error, user: user} - ] - ) - - timestamp = DateTime.utc_now() |> DateTime.to_unix() - - Ecto.Changeset.change(user, %{ - preferences: %{"ai_assistant.disclaimer_read_at" => timestamp} - }) - |> Lightning.Repo.update!() - - # Use manual testing mode to control job execution - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand", "j-chat": session.id]}", - on_error: :raise - ) - - render_async(view) - - assert has_element?( - view, - "#retry-message-#{List.first(session.messages).id}" - ) - - refute has_element?( - view, - "#cancel-message-#{List.first(session.messages).id}" - ) - - # Update the mock for successful response - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "user", "content" => "Hello"}, - %{"role" => "assistant", "content" => "Hi there!"} - ] - } - ) - - # Click retry - view - |> element("#retry-message-#{List.first(session.messages).id}") - |> render_click() - - # The message should now be in pending status - html = render(view) - assert html =~ "Sending" - - # Verify job was enqueued - assert [job] = - all_enqueued(worker: Lightning.AiAssistant.MessageProcessor) - - assert job.args["message_id"] == List.first(session.messages).id - - # Process the job - assert %{success: 1} = - Oban.drain_queue(Lightning.Oban, queue: :ai_assistant) - - # Re-render to see the updated state - html = render(view) - - # Now check for the successful response - assert html =~ "Hi there!" - refute html =~ "Failed" - refute has_element?(view, "#assistant-failed-message") - end - - @tag email: "user@openfn.org" - test "cancel buttons are available until only one message remains", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - session = - insert(:job_chat_session, - user: user, - job: job_1, - messages: [ - %{role: :user, content: "First message", status: :error, user: user}, - %{role: :assistant, content: "First response"}, - %{ - role: :user, - content: "Second message", - status: :error, - user: user - }, - %{role: :assistant, content: "Second response"}, - %{role: :user, content: "Third message", status: :error, user: user} - ] - ) - - timestamp = DateTime.utc_now() |> DateTime.to_unix() - - Ecto.Changeset.change(user, %{ - preferences: %{"ai_assistant.disclaimer_read_at" => timestamp} - }) - |> Lightning.Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand", "j-chat": session.id]}", - on_error: :raise - ) - - render_async(view) - - failed_messages = Enum.filter(session.messages, &(&1.status == :error)) - - Enum.each(failed_messages, fn message -> - assert has_element?(view, "#retry-message-#{message.id}") - assert has_element?(view, "#cancel-message-#{message.id}") - end) - - Enum.take(failed_messages, length(failed_messages) - 1) - |> Enum.each(fn message -> - view - |> element("#cancel-message-#{message.id}") - |> render_click() - - refute has_element?(view, "#retry-message-#{message.id}") - refute has_element?(view, "#cancel-message-#{message.id}") - - updated_session = Lightning.AiAssistant.get_session!(session.id) - - refute Enum.any?(updated_session.messages, &(&1.id == message.id)) - end) - - updated_session = Lightning.AiAssistant.get_session!(session.id) - - user_messages = Enum.filter(updated_session.messages, &(&1.role == :user)) - assert length(user_messages) == 1 - - current_failed_messages = - Enum.filter(updated_session.messages, &(&1.status == :error)) - - assert length(current_failed_messages) == 1 - - last_remaining_message = List.first(user_messages) - - assert has_element?(view, "#retry-message-#{last_remaining_message.id}") - refute has_element?(view, "#cancel-message-#{last_remaining_message.id}") - - single_message_session = - insert(:job_chat_session, - user: user, - job: job_1, - messages: [ - %{role: :user, content: "Hello", status: :error, user: user} - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand", "j-chat": single_message_session.id]}", - on_error: :raise - ) - - render_async(view) - - assert has_element?( - view, - "#retry-message-#{List.first(single_message_session.messages).id}" - ) - - refute has_element?( - view, - "#cancel-message-#{List.first(single_message_session.messages).id}" - ) - end - - @tag email: "user@openfn.org" - test "AI Assistant renders custom component for collecting feedback", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - on_exit(fn -> Application.delete_env(:lightning, :ai_feedback) end) - - Application.put_env(:lightning, :ai_feedback, %{ - component: fn assigns -> - ~H""" -
Hello from AI Feedback
- """ - end - }) - - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "assistant", "content" => "Hello, World!"} - ] - } - ) - - skip_disclaimer(user) - - session = - insert(:job_chat_session, - user: user, - job: job_1, - messages: [ - %{role: :assistant, content: "Hello, World!"} - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}", - on_error: :raise - ) - - render_async(view) - - view |> element("#session-#{session.id}") |> render_click() - - assert_patch(view) - - feedback_el = element(view, "#ai-feedback") - - assert has_element?(feedback_el) - - assert render(feedback_el) =~ "Hello from AI Feedback" - end - - test "job code attachment is selected by default", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - job_expression = "fn(state => state);\n" - adaptor = "@openfn/language-http@7.0.6" - - job_1 - |> Ecto.Changeset.change(%{adaptor: adaptor, body: job_expression}) - |> Lightning.Repo.update!() - - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post, body: json_body, url: url}, _opts -> - decoded = Jason.decode!(json_body) - assert decoded["context"]["expression"] == job_expression - assert decoded["context"]["adaptor"] == adaptor - - resp_body = %{ - "history" => [ - %{"role" => "user", "content" => "Ping"}, - %{"role" => "assistant", "content" => "Pong"} - ] - } - - if String.contains?(url, "/stream") do - {:ok, - %Tesla.Env{ - status: 200, - headers: [{"content-type", "text/event-stream"}], - body: "event: complete\ndata: #{Jason.encode!(resp_body)}\n\n" - }} - else - {:ok, %Tesla.Env{status: 200, body: resp_body}} - end - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - # checkbox is checked - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][code]'][value='true']:checked" - ) - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping"}) - - assert_patch(view) - - render_async(view) - - # checkbox is still checked even after patching - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][code]'][value='true']:checked" - ) - end - - test "job code attachment can be turned off", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _]} = workflow - } do - job_expression = "fn(state => state);\n" - adaptor = "@openfn/language-http@7.0.6" - - job_1 - |> Ecto.Changeset.change(%{adaptor: adaptor, body: job_expression}) - |> Lightning.Repo.update!() - - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post, body: json_body, url: url}, _opts -> - decoded = Jason.decode!(json_body) - refute Map.has_key?(decoded["context"], "expression") - assert decoded["context"]["adaptor"] == adaptor - - resp_body = %{ - "history" => [ - %{"role" => "user", "content" => "Ping"}, - %{"role" => "assistant", "content" => "Pong"} - ] - } - - if String.contains?(url, "/stream") do - {:ok, - %Tesla.Env{ - status: 200, - headers: [{"content-type", "text/event-stream"}], - body: "event: complete\ndata: #{Jason.encode!(resp_body)}\n\n" - }} - else - {:ok, %Tesla.Env{status: 200, body: resp_body}} - end - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - # checkbox is checked - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][code]'][value='true']:checked" - ) - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping", options: %{code: "false"}}) - - assert_patch(view) - - render_async(view) - - # checkbox is not checked - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][code]'][value='true']:not(:checked)" - ) - end - - test "logs attachment option is disabled by default", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _], triggers: [trigger]} = workflow - } do - workflow = Lightning.Repo.reload(workflow) - - snapshot = Lightning.Workflows.Snapshot.get_current_for(workflow) - - dataclip = build(:http_request_dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - state: "failed", - error_type: "CompileError", - dataclip: dataclip, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: dataclip, - exit_reason: "fail", - error_type: "CompileError", - started_at: DateTime.utc_now(), - finished_at: DateTime.utc_now() - ) - ] - ) - - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - - # without following a run - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job_1.id, m: "expand"]}" - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - # checkbox is unchecked - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][logs]']:not(:checked)" - ) - - GenServer.stop(view.pid) - - # when following a run - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{%{a: run.id, m: "expand", s: job_1.id}}", - on_error: :raise - ) - - render_async(view) - - # checkbox exists - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant input[type='checkbox'][name='assistant[options][logs]']:not(:checked)" - ) - end - - test "logs attachment are sent when toggled on", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _], triggers: [trigger]} = workflow - } do - workflow = Lightning.Repo.reload(workflow) - - snapshot = Lightning.Workflows.Snapshot.get_current_for(workflow) - - dataclip = build(:http_request_dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - state: "failed", - error_type: "CompileError", - dataclip: dataclip, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: dataclip, - exit_reason: "fail", - error_type: "CompileError", - started_at: DateTime.utc_now(), - finished_at: DateTime.utc_now() - ) - ] - ) - - insert(:log_line, run: run) - log1 = insert(:log_line, run: run, step: hd(run.steps)) - log2 = insert(:log_line, run: run, step: hd(run.steps)) - - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.Tesla.Mock - |> expect( - :call, - 2, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post, body: json_body, url: url}, _opts -> - decoded = Jason.decode!(json_body) - assert Map.has_key?(decoded["context"], "log") - - assert decoded["context"]["log"] == - log1.message <> "\n" <> log2.message - - resp_body = %{ - "history" => [ - %{"role" => "user", "content" => "Ping"}, - %{"role" => "assistant", "content" => "Pong"} - ] - } - - if String.contains?(url, "/stream") do - {:ok, - %Tesla.Env{ - status: 200, - headers: [{"content-type", "text/event-stream"}], - body: "event: complete\ndata: #{Jason.encode!(resp_body)}\n\n" - }} - else - {:ok, %Tesla.Env{status: 200, body: resp_body}} - end - end - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{%{a: run.id, m: "expand", s: job_1.id}}", - on_error: :raise - ) - - render_async(view) - - view |> element("#get-started-with-ai-btn") |> render_click() - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Ping", options: %{logs: "true"}}) - - assert_patch(view) - - render_async(view) - end - end - - describe "AI Assistant - Workflow Template Mode" do - setup :create_project_for_user - - test "workflow mode displays correctly for template generation", %{ - conn: conn, - project: project, - user: user - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: "http://localhost:4001" <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - html = render(view) - - assert has_element?( - view, - "#ai-assistant-form-new-workflow-panel-assistant" - ) - - assert html =~ "Describe the workflow you want to create..." - - refute html =~ "Ask about your job code, debugging, or OpenFn adaptors..." - end - - test "workflow mode creates sessions correctly", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "response" => "I'll help you create a Salesforce sync workflow", - "response_yaml" => nil, - "usage" => %{}, - "history" => [ - %{ - "role" => "user", - "content" => "Create a Salesforce sync workflow" - } - ] - } - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit( - assistant: %{content: "Create a Salesforce sync workflow"} - ) - - assert_patch(view) - - render_async(view) - - html = render(view) - assert html =~ "I'll help you create a Salesforce sync workflow" - end - - test "workflow mode generates and applies templates", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - workflow_yaml = """ - name: "Salesforce Sync Workflow" - jobs: - fetch-data: - name: Fetch Salesforce data - adaptor: "@openfn/language-salesforce@latest" - body: | - getRecords('Contact', { - fields: ['Id', 'Name', 'Email'], - limit: 100 - }); - triggers: - webhook: - type: webhook - enabled: true - edges: - webhook->fetch-data: - source_trigger: webhook - target_job: fetch-data - condition_type: always - enabled: true - """ - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "response" => "Here's your Salesforce sync workflow:", - "response_yaml" => workflow_yaml, - "usage" => %{} - } - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit( - assistant: %{content: "Create a Salesforce sync workflow"} - ) - - assert_patch(view) - render_async(view) - - html = render(view) - - assert html =~ "Click to restore workflow to here" - assert html =~ "Here's your Salesforce sync workflow:" - - workflow_sessions = - Lightning.AiAssistant.list_sessions(project, :desc, limit: 5) - - assert %{sessions: [session | _]} = workflow_sessions - assert session.session_type == "workflow_template" - - session = Lightning.Repo.preload(session, :messages) - - assistant_message = - session.messages - |> Enum.find(fn message -> - message.role == :assistant && - not is_nil(message.code) && - message.code != "" - end) - - assert assistant_message, - "Should find assistant message with workflow_code" - - message_id = assistant_message.id - - assert assistant_message.code =~ "Salesforce Sync Workflow" - assert assistant_message.code =~ "fetch-data" - assert assistant_message.code =~ "getRecords" - - view - |> element("[phx-value-message-id='#{message_id}']") - |> render_click() - - assert_push_event(view, "template_selected", %{template: template_code}) - - job_id = Ecto.UUID.generate() - trigger_id = Ecto.UUID.generate() - - view - |> with_target("#new-workflow-panel") - |> render_hook("template-parsed", %{ - "workflow" => %{ - "name" => "Salesforce Sync Workflow", - "jobs" => [ - %{ - "id" => job_id, - "name" => "Fetch Salesforce data", - "adaptor" => "@openfn/language-salesforce@latest", - "body" => - "getRecords('Contact', {\n fields: ['Id', 'Name', 'Email'],\n limit: 100\n});" - } - ], - "triggers" => [ - %{ - "id" => trigger_id, - "type" => "webhook", - "enabled" => true - } - ], - "edges" => [ - %{ - "id" => Ecto.UUID.generate(), - "source_trigger_id" => trigger_id, - "target_job_id" => job_id, - "condition_type" => "always", - "enabled" => true - } - ] - } - }) - - assert template_code == workflow_yaml - assert template_code =~ "Salesforce Sync Workflow" - assert template_code =~ "fetch-data" - assert template_code =~ "@openfn/language-salesforce" - assert template_code =~ "getRecords" - assert template_code =~ "webhook" - - render_async(view) - - create_btn_after = element(view, "#create_workflow_btn") - create_btn_html_after = render(create_btn_after) - - refute create_btn_html_after =~ "disabled=\"disabled\"", - "Create button should be enabled after template selection" - end - - test "workflow mode handles template generation errors", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:ok, - %Tesla.Env{ - status: 503, - body: %{"message" => "Service temporarily unavailable"} - }} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create a workflow"}) - - assert_patch(view) - - html = render(view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - assert has_element?(view, "[phx-click='retry_message']") - end - - test "workflow mode lists project-scoped sessions", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - session1 = - insert(:workflow_chat_session, - user: user, - project: project, - title: "API Integration Workflow", - messages: [ - %{role: :user, content: "Create API workflow", user: user}, - %{role: :assistant, content: "Here's your API workflow"} - ] - ) - - session2 = - insert(:workflow_chat_session, - user: user, - project: project, - title: "Data Sync Workflow", - messages: [ - %{role: :user, content: "Create sync workflow", user: user}, - %{role: :assistant, content: "Here's your sync workflow"} - ] - ) - - other_project = insert(:project) - - _other_session = - insert(:workflow_chat_session, - user: user, - project: other_project, - title: "Other Project Workflow" - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - html = render(view) - - assert html =~ "API Integration Workflow" - assert html =~ "Data Sync Workflow" - refute html =~ "Other Project Workflow" - - assert has_element?(view, "#session-#{session1.id}") - assert has_element?(view, "#session-#{session2.id}") - end - - test "workflow mode respects permissions like job mode", %{ - conn: conn, - project: project - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - viewer_user = insert(:user, email: "viewer@test.org") - skip_disclaimer(viewer_user) - insert(:project_user, project: project, user: viewer_user, role: :viewer) - - conn = log_in_user(conn, viewer_user) - - {:ok, _view, html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - |> follow_redirect(conn) - - assert html =~ "You are not authorized to perform this action." - end - - test "workflow mode handles usage limits", %{ - conn: conn, - project: %{id: project_id} = project, - user: user - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - error_message = "Monthly workflow generation limit reached" - - Mox.stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn - %{type: :ai_usage}, %{project_id: ^project_id} -> - {:error, :exceeds_limit, - %Lightning.Extensions.Message{text: error_message}} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - assert has_element?(view, "#ai-assistant-error", error_message) - - input_element = - element(view, "#content-chat-input-new-workflow-panel-assistant") - - assert render(input_element) =~ "placeholder=\"#{error_message}\"" - - view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create workflow"}) - - assert has_element?(view, "#ai-assistant-error", error_message) - end - - test "workflow mode session titles use project context", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - session_with_title = - insert(:workflow_chat_session, - user: user, - project: project, - title: "Custom Workflow Template" - ) - - session_without_title = - insert(:workflow_chat_session, - user: user, - project: project, - title: nil - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/new/legacy?method=ai&w-chat=#{session_with_title.id}" - ) - - render_async(view) - - html = render(view) - assert html =~ "Custom Workflow Template" - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/new/legacy?method=ai&chat=#{session_without_title.id}" - ) - - render_async(view) - - html = render(view) - assert html =~ "#{project.name} Workflow" - end - - test "workflow mode doesn't validate job save state", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - input_element = - element(view, "#ai-assistant-form-new-workflow-panel-assistant textarea") - - submit_btn = - element( - view, - "#ai-assistant-form-submit-btn-chat-input-new-workflow-panel-assistant" - ) - - refute render(input_element) =~ "disabled=\"disabled\"" - # Submit button should be disabled when no content is entered - assert render(submit_btn) =~ "disabled=\"disabled\"" - - refute render(input_element) =~ "Save your workflow first" - end - - test "workflow mode clears template state on session start", %{ - conn: conn, - project: project, - user: user - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view) - - # The component should have called send_update to clear any existing template - # In a real test, we'd verify the parent component received the clear template message - # For now, just verify the interface loads correctly - assert has_element?(view, "#new-workflow-panel-assistant") - end - - test "workflow mode handles concurrent users correctly", %{ - conn: conn, - project: project - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{ - "role" => "assistant", - "content" => "Workflow created for user" - } - ] - } - ) - - user1 = insert(:user, email: "user1@test.org") - user2 = insert(:user, email: "user2@test.org") - - skip_disclaimer(user1) - skip_disclaimer(user2) - - insert(:project_user, project: project, user: user1, role: :editor) - insert(:project_user, project: project, user: user2, role: :editor) - - _session1 = - insert(:workflow_chat_session, - user: user1, - project: project, - title: "User 1 Workflow" - ) - - _session2 = - insert(:workflow_chat_session, - user: user2, - project: project, - title: "User 2 Workflow" - ) - - conn1 = log_in_user(conn, user1) - - {:ok, view1, _} = - live(conn1, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view1) - - conn2 = log_in_user(conn, user2) - - {:ok, view2, _} = - live(conn2, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(view2) - - html1 = render(view1) - html2 = render(view2) - - assert html1 =~ "User 1 Workflow" - assert html1 =~ "User 2 Workflow" - - assert html2 =~ "User 1 Workflow" - assert html2 =~ "User 2 Workflow" - end - end - - describe "AI Assistant - Both modes" do - setup :create_workflow - - test "mode registry returns correct handlers", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: "http://localhost:4001" <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = - job_view - |> element("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render() - - assert job_html =~ "Ask about your job code, debugging, or OpenFn adaptors" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ "Describe the workflow you want to create" - end - - test "error handling is consistent across modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - {:error, :timeout} - end) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - log = - ExUnit.CaptureLog.capture_log(fn -> - job_view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Help with code"}) - end) - - assert log =~ "AI query timed out for session" - assert log =~ "Request timed out. Please try again." - - html = render_async(job_view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - - assert has_element?(job_view, "[phx-click='retry_message']") - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - log = - ExUnit.CaptureLog.capture_log(fn -> - workflow_view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create workflow"}) - end) - - assert log =~ "AI query timed out for session" - assert log =~ "Request timed out. Please try again." - - html = render_async(workflow_view) - - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - - assert has_element?(job_view, "[phx-click='retry_message']") - end - - test "session management works independently for different modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [%{"role" => "assistant", "content" => "Response"}] - } - ) - - _job_session = - insert(:job_chat_session, - user: user, - job: job_1, - title: "Job Debugging Session" - ) - - _workflow_session = - insert(:workflow_chat_session, - user: user, - project: project, - title: "Workflow Creation Session" - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = job_view |> element("#job-#{job_1.id}-ai-assistant") |> render() - assert job_html =~ "Job Debugging Session" - refute job_html =~ "Workflow Creation Session" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - refute workflow_html =~ "Job Debugging Session" - assert workflow_html =~ "Workflow Creation Session" - end - - test "usage limits apply to both modes equally", %{ - conn: conn, - project: %{id: project_id} = project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - error_message = "AI usage limit reached" - - Mox.stub(Lightning.Extensions.MockUsageLimiter, :limit_action, fn - %{type: :ai_usage}, %{project_id: ^project_id} -> - {:error, :exceeds_limit, - %Lightning.Extensions.Message{text: error_message}} - end) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = - job_view - |> element("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render() - - assert job_html =~ error_message - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ error_message - end - - test "keyboard shortcuts work consistently across modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "assistant", "content" => "Message sent"} - ] - } - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_form = - job_view |> element("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - - assert has_element?(job_form) - assert render(job_form) =~ "phx-hook=\"SendMessageViaCtrlEnter\"" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_form = - workflow_view - |> element("#new-workflow-panel-assistant") - - assert has_element?(workflow_form) - assert render(workflow_form) =~ "phx-hook=\"SendMessageViaCtrlEnter\"" - end - - test "disclaimer flow works for both modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - refute user.preferences["ai_assistant.disclaimer_read_at"] - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = - job_view - |> element("#job-#{job_1.id}-ai-assistant") - |> render() - - assert job_html =~ "Get started with the AI Assistant" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ "Get started with the AI Assistant" - - job_view |> element("#get-started-with-ai-btn") |> render_click() - - user = Lightning.Repo.reload(user) - assert user.preferences["ai_assistant.disclaimer_read_at"] - - {:ok, new_workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(new_workflow_view) - - new_workflow_html = render(new_workflow_view) - refute new_workflow_html =~ "Get started with the AI Assistant" - assert has_element?(new_workflow_view, "#new-workflow-panel-assistant") - end - - test "both modes handle markdown formatting consistently", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - markdown_response = """ - Here's your solution: - - ## Code Example - - ```javascript - fn((state) => { - console.log("Hello world"); - return state; - }); - ``` - - ### Next Steps - 1. Test the code - 2. Deploy to production - """ - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "response" => markdown_response, - "history" => [ - %{"role" => "assistant", "content" => markdown_response} - ] - } - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Help me"}) - - render_async(job_view) - - job_html = render(job_view) - assert job_html =~ " form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create workflow"}) - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ " apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - response_content = "Here's some code you can copy" - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "response" => response_content, - "response_yaml" => nil, - "usage" => %{}, - "history" => [ - %{"role" => "assistant", "content" => response_content} - ] - } - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Help"}) - - assert_patch(job_view) - render_async(job_view) - - job_html = render(job_view) - - assert job_html =~ "Here's some code you can copy" - - job_copy_btn = job_view |> element("[phx-hook='Copy']") - assert has_element?(job_copy_btn) - - job_copy_html = render(job_copy_btn) - assert job_copy_html =~ "Copy" - - assert job_copy_html =~ - "data-content=\"Here's some code you can copy\">" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create"}) - - assert_patch(workflow_view) - render_async(workflow_view) - - workflow_html = render(workflow_view) - - assert workflow_html =~ "Here's some code you can copy" - - workflow_copy_btn = workflow_view |> element("[phx-hook='Copy']") - assert has_element?(workflow_copy_btn) - - workflow_copy_html = render(workflow_copy_btn) - assert workflow_copy_html =~ "Copy" - - assert workflow_copy_html =~ - "data-content=\"Here's some code you can copy\">" - end - - test "both modes handle user avatars and timestamps correctly", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "history" => [ - %{"role" => "user", "content" => "Test message"}, - %{"role" => "assistant", "content" => "Test response"} - ] - } - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Test"}) - - render_async(job_view) - - job_html = render(job_view) - - initials = - "#{String.first(user.first_name)}#{String.first(user.last_name)}" - - assert job_html =~ initials - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Test"}) - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ initials - end - - test "pagination works consistently across modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - for i <- 1..25 do - insert(:job_chat_session, - user: user, - job: job_1, - title: "Job Session #{i}", - updated_at: DateTime.add(DateTime.utc_now(), -i, :hour) - ) - - insert(:workflow_chat_session, - user: user, - project: project, - title: "Workflow Session #{i}", - updated_at: DateTime.add(DateTime.utc_now(), -i, :hour) - ) - end - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = render(job_view) - - assert job_html =~ "Load more conversations" - assert job_html =~ "20 of 25" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - - assert workflow_html =~ "Load more conversations" - assert workflow_html =~ "20 of 25" - - job_view |> element("[phx-click='load_more_sessions']") |> render_click() - - workflow_view - |> element("[phx-click='load_more_sessions']") - |> render_click() - - render_async(job_view) - render_async(workflow_view) - - job_html_after_load = render(job_view) - workflow_html_after_load = render(workflow_view) - - refute job_html_after_load =~ "Load more conversations" - assert job_html_after_load =~ "25 of 25" - refute workflow_html_after_load =~ "Load more conversations" - assert workflow_html_after_load =~ "25 of 25" - end - - test "sorting functionality works in both modes", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end) - - _older_job_session = - insert(:job_chat_session, - user: user, - job: job_1, - title: "Older Job Session", - updated_at: ~N[2024-01-01 10:00:00] - ) - - _newer_job_session = - insert(:job_chat_session, - user: user, - job: job_1, - title: "Newer Job Session", - updated_at: ~N[2024-02-01 10:00:00] - ) - - _older_workflow_session = - insert(:workflow_chat_session, - user: user, - project: project, - title: "Older Workflow Session", - updated_at: ~N[2024-01-01 10:00:00] - ) - - _newer_workflow_session = - insert(:workflow_chat_session, - user: user, - project: project, - title: "Newer Workflow Session", - updated_at: ~N[2024-02-01 10:00:00] - ) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - job_html = job_view |> element("#job-#{job_1.id}-ai-assistant") |> render() - assert job_html =~ "Latest" - - job_view |> element("[phx-click='toggle_sort']") |> render_click() - - job_html_after_sort = - job_view |> element("#job-#{job_1.id}-ai-assistant") |> render() - - assert job_html_after_sort =~ "Oldest" - - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ "Latest" - - workflow_view |> element("[phx-click='toggle_sort']") |> render_click() - - workflow_html_after_sort = render(workflow_view) - assert workflow_html_after_sort =~ "Oldest" - end - end - - describe "AI Assistant - Component State Management:" do - setup :create_workflow - - test "component state persists across navigation", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post, url: url}, _opts when is_binary(url) -> - body = - cond do - String.contains?(url, "/query") -> - %{ - "history" => [ - %{"role" => "assistant", "content" => "Response content"} - ] - } - - String.contains?(url, "/workflow_chat") -> - %{ - "response" => "Response content", - "response_yaml" => nil, - "usage" => %{} - } - - true -> - %{ - "history" => [ - %{"role" => "assistant", "content" => "Response content"} - ], - "response" => "Response content", - "response_yaml" => nil, - "usage" => %{} - } - end - - if String.contains?(url, "/stream") do - {:ok, - %Tesla.Env{ - status: 200, - headers: [{"content-type", "text/event-stream"}], - body: "event: complete\ndata: #{Jason.encode!(body)}\n\n" - }} - else - {:ok, %Tesla.Env{status: 200, body: body}} - end - end) - - skip_disclaimer(user) - - {:ok, job_view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(job_view) - - # Submit the form to create a chat session - job_view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Help with debugging"}) - - # This creates a session and navigates to include j-chat parameter - current_path = assert_patch(job_view) - render_async(job_view) - - job_html = render(job_view) - assert job_html =~ "Response content" - assert job_html =~ "Help with debugging" - - # Navigate to workflow creation - {:ok, workflow_view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - render_async(workflow_view) - - workflow_view - |> form("#ai-assistant-form-new-workflow-panel-assistant") - |> render_submit(assistant: %{content: "Create new workflow"}) - - assert_patch(workflow_view) - render_async(workflow_view) - - workflow_html = render(workflow_view) - assert workflow_html =~ "Response content" - - # Navigate back to the job view using the path with chat session - {:ok, new_job_view, _html} = live(conn, current_path) - - render_async(new_job_view) - - # The entire view should show the chat history - new_job_html = render(new_job_view) - - # The original conversation should be visible - assert new_job_html =~ "Help with debugging" - assert new_job_html =~ "Response content" - end - - test "async result states work correctly", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Lightning.AiAssistantHelpers.stub_ai_with_health_check( - apollo_endpoint, - %{ - "response" => "Delayed response", - "response_yaml" => nil, - "usage" => %{}, - "history" => [ - %{"role" => "assistant", "content" => "Delayed response"} - ] - } - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(view) - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Test async"}) - - assert_patch(view) - - html = render(view) - assert html =~ "Delayed response" - refute html =~ "Processing..." - end - - test "error boundaries work correctly", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job_1 | _]} = workflow - } do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub(Lightning.Tesla.Mock, :call, fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - - %{method: :post}, _opts -> - # Return a server error - {:ok, - %Tesla.Env{ - status: 500, - body: %{ - "error" => "Internal server error", - "message" => "Service crashed" - } - }} - end) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job_1.id}&m=expand" - ) - - render_async(view) - - view - |> form("#ai-assistant-form-job-#{job_1.id}-ai-assistant") - |> render_submit(assistant: %{content: "Trigger error"}) - - assert_patch(view) - - # In test environment, error appears immediately - html = render(view) - - # The user message should show as failed - assert html =~ "ai-bg-gradient-error" - assert html =~ "Failed" - - # The form should still be functional - assert has_element?( - view, - "#ai-assistant-form-job-#{job_1.id}-ai-assistant" - ) - - # User should be able to retry - assert has_element?(view, "[phx-click='retry_message']") - end - end - - describe "user avatar rendering" do - setup :create_workflow - setup :stub_rate_limiter_ok - setup :stub_usage_limiter_ok - - @tag email: "user@openfn.org" - test "renders avatar with user initials for messages with users", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job | _]} = workflow - } do - stub_apollo_endpoint() - - # Create a session with a message that has a user with full name - session = - insert(:job_chat_session, - user: user, - job: job, - messages: [ - %{role: :user, content: "Test message", user: user}, - %{role: :assistant, content: "Test response"} - ] - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job.id, m: "expand"]}&j-chat=#{session.id}" - ) - - render_async(view) - html = render(view) - - # User avatar should show initials from first_name and last_name - first_initial = String.first(user.first_name) - assert html =~ first_initial - end - - @tag email: "user@openfn.org" - test "renders avatar with partial name when user has nil last_name", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job | _]} = workflow - } do - stub_apollo_endpoint() - - # Create a user with nil last_name - user_with_partial_name = - insert(:user, first_name: "Alice", last_name: nil) - - insert(:project_user, project: project, user: user_with_partial_name) - - session = - insert(:job_chat_session, - user: user_with_partial_name, - job: job, - messages: [ - %{ - role: :user, - content: "Test message", - user: user_with_partial_name - }, - %{role: :assistant, content: "Test response"} - ] - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job.id, m: "expand"]}&j-chat=#{session.id}" - ) - - render_async(view) - html = render(view) - - # Avatar should render with just first initial - assert html =~ "A" - assert html =~ "Alice" - end - - @tag email: "user@openfn.org" - test "renders avatar with question mark when user has empty string first_name", - %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job | _]} = workflow - } do - stub_apollo_endpoint() - - # Create a user with empty string first_name and last_name - user_with_empty_name = - insert(:user, first_name: "", last_name: "") - - insert(:project_user, project: project, user: user_with_empty_name) - - session = - insert(:job_chat_session, - user: user_with_empty_name, - job: job, - messages: [ - %{ - role: :user, - content: "Test message", - user: user_with_empty_name - }, - %{role: :assistant, content: "Test response"} - ] - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job.id, m: "expand"]}&j-chat=#{session.id}" - ) - - render_async(view) - html = render(view) - - # Avatar should render with "?" when first_name is empty string - assert html =~ "?" - end - - @tag email: "user@openfn.org" - test "renders question mark avatar for messages without user association", %{ - conn: conn, - project: project, - user: user, - workflow: %{jobs: [job | _]} = workflow - } do - stub_apollo_endpoint() - - # Create a session with a message that has no user (nil) - session = - insert(:job_chat_session, - user: user, - job: job, - messages: [ - %{role: :user, content: "Message without user", user: nil}, - %{role: :assistant, content: "Test response"} - ] - ) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, s: job.id, m: "expand"]}&j-chat=#{session.id}" - ) - - render_async(view) - html = render(view) - - # Avatar for nil user should show "?" - assert html =~ "?" - end - end - - defp stub_apollo_endpoint do - apollo_endpoint = "http://localhost:4001" - - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> apollo_endpoint - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - Mox.stub( - Lightning.Tesla.Mock, - :call, - fn - %{method: :get, url: ^apollo_endpoint <> "/"}, _opts -> - {:ok, %Tesla.Env{status: 200}} - end - ) - end - - defp create_project_for_user(%{user: user}) do - project = insert(:project, project_users: [%{user: user, role: :owner}]) - %{project: project} - end -end diff --git a/test/lightning_web/live/workflow_live/collaborate_test.exs b/test/lightning_web/live/workflow_live/collaborate_test.exs index a98816facbe..d3df4a085e2 100644 --- a/test/lightning_web/live/workflow_live/collaborate_test.exs +++ b/test/lightning_web/live/workflow_live/collaborate_test.exs @@ -2558,169 +2558,4 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do refute html =~ "data-initial-run-data=" end end - - describe "legacy editor preference redirect" do - test "redirects to legacy editor when user prefers legacy editor", %{ - conn: conn - } do - user = insert(:user) - - project = - insert(:project, - name: "Test Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - - # Set user preference to prefer legacy editor - user_with_prefs = - user - |> Ecto.Changeset.change(%{ - preferences: %{ - "prefer_legacy_editor" => true - } - }) - |> Lightning.Repo.update!() - - # Try to navigate to collaborative editor - {:error, {:live_redirect, %{to: redirect_path}}} = - conn - |> log_in_user(user_with_prefs) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}") - - # Should redirect to legacy editor - assert redirect_path == "/projects/#{project.id}/w/#{workflow.id}/legacy" - end - - test "redirects to legacy editor for new workflow when user prefers legacy editor", - %{conn: conn} do - user = insert(:user) - - project = - insert(:project, - name: "Test Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - # Set user preference to prefer legacy editor - user_with_prefs = - user - |> Ecto.Changeset.change(%{ - preferences: %{ - "prefer_legacy_editor" => true - } - }) - |> Lightning.Repo.update!() - - # Try to navigate to collaborative editor for new workflow - {:error, {:live_redirect, %{to: redirect_path}}} = - conn - |> log_in_user(user_with_prefs) - |> live(~p"/projects/#{project.id}/w/new") - - # Should redirect to legacy editor with method=template - assert redirect_path == - "/projects/#{project.id}/w/new/legacy?method=template" - end - - test "redirects to legacy editor with query params transformed", %{ - conn: conn - } do - user = insert(:user) - - project = - insert(:project, - name: "Test Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - job = insert(:job, workflow: workflow) - - user_with_prefs = - user - |> Ecto.Changeset.change(%{ - preferences: %{ - "prefer_legacy_editor" => true - } - }) - |> Lightning.Repo.update!() - - # Try to navigate to collaborative editor with query params - {:error, {:live_redirect, %{to: redirect_path}}} = - conn - |> log_in_user(user_with_prefs) - |> live( - ~p"/projects/#{project.id}/w/#{workflow.id}?job=#{job.id}&panel=editor" - ) - - # Should redirect to legacy editor with query params transformed - assert String.starts_with?( - redirect_path, - "/projects/#{project.id}/w/#{workflow.id}/legacy" - ) - - assert redirect_path =~ "s=#{job.id}" - assert redirect_path =~ "m=expand" - end - - test "does not redirect when user does not prefer legacy editor", %{ - conn: conn - } do - user = insert(:user) - - project = - insert(:project, - name: "Test Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - - # Set user preference to NOT prefer legacy editor - user_with_prefs = - user - |> Ecto.Changeset.change(%{ - preferences: %{ - "prefer_legacy_editor" => false - } - }) - |> Lightning.Repo.update!() - - # Navigate to collaborative editor - {:ok, _view, html} = - conn - |> log_in_user(user_with_prefs) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}") - - # Should stay on collaborative editor (no redirect) - assert html =~ "collaborative-editor-react" - end - - test "does not redirect when preference is not set", %{conn: conn} do - user = insert(:user) - - project = - insert(:project, - name: "Test Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - - # No preference set (default behavior) - conn = log_in_user(conn, user) - - # Navigate to collaborative editor - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}" - ) - - # Should stay on collaborative editor (no redirect) - assert html =~ "collaborative-editor-react" - end - end end diff --git a/test/lightning_web/live/workflow_live/edit_template_test.exs b/test/lightning_web/live/workflow_live/edit_template_test.exs deleted file mode 100644 index 79bab693b15..00000000000 --- a/test/lightning_web/live/workflow_live/edit_template_test.exs +++ /dev/null @@ -1,482 +0,0 @@ -defmodule LightningWeb.WorkflowLive.EditTemplateTest do - use LightningWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - import Lightning.Factories - import Lightning.WorkflowLive.Helpers - - setup :register_and_log_in_support_user - setup :create_project_for_current_user - setup :create_workflow - - describe "template publishing" do - test "publishes a new template", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - # Send tags in the form submit directly - template_params = %{ - "workflow_template" => %{ - "name" => "My Template", - "description" => "A template description", - "tags" => "tag1,tag2" - } - } - - assert view - |> form("#workflow-template-form") - |> render_submit(template_params) =~ - "Workflow published as template" - - template = - Lightning.WorkflowTemplates.get_template_by_workflow_id(workflow.id) - - assert template.name == "My Template" - assert template.description == "A template description" - assert template.tags == ["tag1", "tag2"] - assert is_nil(template.positions) - end - - test "saves node positions to templates", %{ - conn: conn, - project: project, - workflow: workflow - } do - workflow_positions = %{"some-uuid" => %{"x" => 100, "y" => 100}} - - workflow - |> Ecto.Changeset.change(%{positions: workflow_positions}) - |> Lightning.Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - assert view - |> form("#workflow-template-form") - |> render_submit(%{ - "workflow_template" => %{ - "name" => "My Template", - "description" => "A template description", - "tags" => "tag1,tag2" - } - }) =~ - "Workflow published as template" - - template = - Lightning.WorkflowTemplates.get_template_by_workflow_id(workflow.id) - - assert template.name == "My Template" - assert template.description == "A template description" - assert template.tags == ["tag1", "tag2"] - assert template.positions == workflow_positions - end - - test "updates an existing template", %{ - conn: conn, - project: project, - workflow: workflow - } do - template = - insert(:workflow_template, - workflow: workflow, - name: "Old Name", - tags: [] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - template_params = %{ - "workflow_template" => %{ - "name" => "Updated Name", - "description" => "Updated description", - "tags" => "updated,tags" - } - } - - assert view - |> form("#workflow-template-form") - |> render_submit(template_params) =~ "Workflow template updated" - - updated_template = Lightning.WorkflowTemplates.get_template(template.id) - assert updated_template.name == "Updated Name" - assert updated_template.description == "Updated description" - assert updated_template.tags == ["updated", "tags"] - end - - test "validates template form", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - assert view - |> form("#workflow-template-form", %{ - "workflow_template" => %{"name" => ""} - }) - |> render_submit() =~ "This field can't be blank" - end - - test "cancels template publishing", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - view |> element("#cancel-template-publish") |> render_click() - - refute view |> element("#workflow-template-form") |> has_element?() - end - - test "disables publish button when workflow has unsaved changes", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: "New Name"}) - - assert view |> element("#publish-template-btn[disabled]") |> has_element?() - end - - test "validates template name length", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - long_name = String.duplicate("a", 256) - - assert view - |> form("#workflow-template-form", %{ - "workflow_template" => %{"name" => long_name} - }) - |> render_submit() =~ "Name must be less than 255 characters" - end - - test "validates template description length", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - view |> element("#publish-template-btn") |> render_click() - - long_description = String.duplicate("a", 1001) - - assert view - |> form("#workflow-template-form", %{ - "workflow_template" => %{ - "name" => "Valid Name", - "description" => long_description - } - }) - |> render_submit() =~ - "Description must be less than 1000 characters" - end - - test "prevents publishing with unsaved changes", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - # Make unsaved changes - view - |> form("#workflow-form") - |> render_change(workflow: %{name: "New Name"}) - - # Verify the publish button is disabled - assert view |> element("#publish-template-btn[disabled]") |> has_element?() - - # Verify the template form is not rendered - refute view |> element("#workflow-template-form") |> has_element?() - end - - test "does not show publish button for non-support users", %{ - conn: conn, - project: project, - workflow: workflow - } do - # Create a non-support user and add them to the project - user = insert(:user, support_user: false) - insert(:project_user, user: user, project: project, role: :editor) - conn = log_in_user(conn, user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - ) - - render_hook(view, "workflow_code_generated", %{ - "code" => "test workflow code", - "code_with_ids" => "test workflow code with ids" - }) - - refute view |> element("#publish-template-btn") |> has_element?() - end - end - - describe "tag input component" do - test "renders with empty tags" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - value: [] - }) - - assert html =~ ~s{id="test-tags-container"} - assert html =~ ~s{id="test-tags_raw"} - assert html =~ ~s{id="test-tags"} - assert html =~ ~s{value=""} - assert html =~ ~s{class="tag-list mt-2"} - refute html =~ ~s{} - - assert html =~ - ~s{} - - assert html =~ - ~s{} - end - - test "renders with comma-separated string of tags" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - value: "tag1,tag2,tag3" - }) - - assert html =~ ~s{id="test-tags-container"} - assert html =~ ~s{id="test-tags_raw"} - assert html =~ ~s{id="test-tags"} - assert html =~ ~s{value="tag1,tag2,tag3"} - assert html =~ ~s{class="tag-list mt-2"} - - assert html =~ - ~s{} - - assert html =~ - ~s{} - - assert html =~ - ~s{} - end - - test "renders with trimmed tags" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - value: " tag1 , tag2 , tag3 " - }) - - assert html =~ ~s{id="test-tags-container"} - assert html =~ ~s{id="test-tags_raw"} - assert html =~ ~s{id="test-tags"} - assert html =~ ~s{value="tag1,tag2,tag3"} - assert html =~ ~s{class="tag-list mt-2"} - - assert html =~ - ~s{} - - assert html =~ - ~s{} - - assert html =~ - ~s{} - end - - test "renders with label and required indicator" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - label: "Tags", - required: true - }) - - assert html =~ ~s{} - end - - test "renders with sublabel" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - sublabel: "Add tags separated by commas" - }) - - assert html =~ ~s{} - assert html =~ ~s{Add tags separated by commas} - end - - test "renders with placeholder" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - placeholder: "Enter tags..." - }) - - assert html =~ ~s{placeholder="Enter tags..."} - end - - test "renders with errors" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - errors: ["Tags are required"] - }) - - assert html =~ - ~s{border-danger-400 focus:border-danger-400 focus:outline-danger-400} - - assert html =~ ~s{Tags are required} - end - - test "renders with standalone mode" do - html = - render_component(&LightningWeb.Components.NewInputs.input/1, %{ - type: "tag", - id: "test-tags", - name: "test_tags", - standalone: true - }) - - assert html =~ ~s{data-standalone-mode} - end - end -end diff --git a/test/lightning_web/live/workflow_live/edit_test.exs b/test/lightning_web/live/workflow_live/edit_test.exs deleted file mode 100644 index 658774325eb..00000000000 --- a/test/lightning_web/live/workflow_live/edit_test.exs +++ /dev/null @@ -1,5173 +0,0 @@ -defmodule LightningWeb.WorkflowLive.EditTest do - use LightningWeb.ConnCase, async: true - - import Ecto.Query - import Eventually - import ExUnit.CaptureLog - import Lightning.Factories - import Lightning.JobsFixtures - import Lightning.WorkflowLive.Helpers - import Lightning.WorkflowsFixtures - import Lightning.GithubHelpers - import Phoenix.LiveViewTest - import Mox - - alias Lightning.Auditing.Audit - alias Lightning.Helpers - alias Lightning.Repo - - setup :stub_apollo_unavailable - alias Lightning.Workflows - alias Lightning.Workflows.Presence - alias Lightning.Workflows.Snapshot - alias Lightning.Workflows.Workflow - alias LightningWeb.CredentialLiveHelpers - - setup :register_and_log_in_user - setup :create_project_for_current_user - - describe "initial YAML generation" do - setup :create_workflow - - test "pushes generate_workflow_code on first mount", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert_push_event(view, "generate_workflow_code", %{}) - end - - test "fires after a new workflow is created on the canvas", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - - select_template(view, "base-webhook-template") - render_click(view, "save") - - assert_push_event(view, "generate_workflow_code", %{}) - end - end - - describe "New credential from project context " do - setup %{project: project} do - %{job: job} = workflow_job_fixture(project_id: project.id) - workflow = Repo.get(Workflow, job.workflow_id) - - {:ok, snapshot} = Workflows.Snapshot.create(workflow) - - %{job: job, workflow: workflow, snapshot: snapshot} - end - - test "open credential modal from the job inspector (edit_workflow)", %{ - conn: conn, - project: project, - job: job, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{job.workflow_id}/legacy?s=#{job.id}&v=#{workflow.lock_version}", - on_error: :raise - ) - - assert has_element?(view, "#job-pane-#{job.id}") - - view |> element("#new-credential-button") |> render_click() - - assert has_element?(view, "#credential-schema-picker") - view |> CredentialLiveHelpers.select_credential_type("http") - view |> CredentialLiveHelpers.click_continue() - - refute has_element?(view, "#project_list") - end - - test "create new credential from job inspector and update the job form", %{ - conn: conn, - project: project, - job: job, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{job.workflow_id}/legacy?s=#{job.id}&v=#{workflow.lock_version}", - on_error: :raise - ) - - view |> element("#new-credential-button") |> render_click() - - view |> CredentialLiveHelpers.select_credential_type("raw") - view |> CredentialLiveHelpers.click_continue() - - view - |> form("#credential-form-new", - credential: %{ - name: "newly created credential", - body: Jason.encode!(%{"a" => 1}) - } - ) - |> render_submit() - - refute has_element?(view, "#credential-form") - - assert view - |> has_element?( - ~S{select[name='credential_selector'] option}, - "newly created credential" - ), - "Should have the project credential available" - end - end - - describe "new" do - test "builds a new workflow", %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - - select_template(view, "base-webhook-template") - - # Naively add a job via the editor (calling the push-change event) - assert view - |> push_patches_to_view([add_job_patch()]) - - # The server responds with a patch with any further changes - assert_reply( - view, - %{ - patches: [ - %{op: "add", path: "/jobs/0/project_credential_id", value: nil}, - %{op: "add", path: "/jobs/0/keychain_credential_id", value: nil}, - %{ - op: "add", - path: "/jobs/0/errors", - value: %{ - "body" => ["Code editor cannot be empty."], - "name" => ["Job name can't be blank."] - } - }, - %{op: "add", path: "/jobs/0/body", value: ""}, - %{ - op: "add", - path: "/jobs/0/adaptor", - value: "@openfn/language-common@latest" - }, - %{ - op: "add", - path: "/errors/jobs", - value: [ - %{ - "body" => ["Code editor cannot be empty."], - "name" => ["Job name can't be blank."] - }, - %{} - ] - } - ] - } - ) - end - - @tag role: :editor - test "creating a new workflow", %{conn: conn, project: project} do - Mox.verify_on_exit!() - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - - {view, parsed_template} = select_template(view, "base-webhook-template") - - workflow_name = view |> get_workflow_params() |> Map.get("name") - - assert workflow_name == parsed_template["name"] - - # save button is not present - refute view - |> element("button[type='submit'][form='workflow-form'][disabled]") - |> has_element?() - - refute view - |> element("button[type='submit'][form='workflow-form']") - |> has_element?() - - # settings panel is not preset - refute has_element?(view, "#toggle-settings") - - # selecting a job doesn't open the panel - {job, _, _} = select_first_job(view) - path = assert_patch(view) - - # this v=0 is not actually what happens in the UI. The test helper select_first_job blindly - # passes the workflow_version - assert path == - ~p"/projects/#{project.id}/w/new/legacy?s=#{job.id}&v=0" - - refute render(view) =~ "Job Name" - refute has_element?(view, "input[name='workflow[jobs][0][name]']") - - # the panel for creating workflow appears - html = render(view) - assert html =~ "Describe your workflow" - assert has_element?(view, "form#search-templates-form") - assert has_element?(view, "form#choose-workflow-template-form") - - # click continue - view |> render_click("save") - - workflow = get_assigns(view) |> Map.get(:workflow) - - # now let's fill in the name - workflow_name = "My Workflow" - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: workflow_name}) - - # the panel disappears - html = render(view) - refute html =~ "Describe your workflow" - refute has_element?(view, "form#search-templates-form") - refute has_element?(view, "form#choose-workflow-template-form") - - # save button is now present - assert view - |> element("button", "Save") - |> has_element?() - - # toggle settings panel button is now preset - assert has_element?(view, "#toggle-settings") - - # selecting a job now opens the panel - {job, _, _} = select_first_job(view) - path = assert_patch(view) - - assert path == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&v=#{workflow.lock_version - 1}" - - assert render(view) =~ "Job Name" - assert has_element?(view, "input[name='workflow[jobs][0][name]']") - - view |> fill_job_fields(job, %{name: "My Job"}) - - # this has been inversed. ideally, it should not select latest by default - # but given that @latest is set in the Job schema, it will alwasy get selected - assert view |> selected_adaptor_version_element(job) |> render() =~ - ~r(value="@openfn/[a-z-]+@latest"), - "should have @latest selected by default" - - view |> element("#new-credential-button") |> render_click() - - view |> CredentialLiveHelpers.select_credential_type("dhis2") - - view |> CredentialLiveHelpers.click_continue() - - # Creating a new credential from the Job panel - view - |> CredentialLiveHelpers.fill_credential(%{ - name: "My Credential", - body: %{username: "foo", password: "bar", hostUrl: "http://someurl"} - }) - - view |> CredentialLiveHelpers.click_save() - - assert view |> selected_credential_name(job) == "My Credential" - - # Editing the Jobs' body - view |> click_edit(job) - - view |> change_editor_text("some body") - - close_job_edit_view(view, job) - - # By default, workflows are disabled to ensure a controlled setup. - # Here, we enable the workflow to test the :too_many_workflows limit action - view - |> element("#toggle-control-workflow") - |> render_click() - - refute view |> save_is_disabled?() - - assert view |> has_pending_changes() - - # Try saving with the limitter - error_msg = "Oopsie Doopsie! An error occured" - - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 1, - fn %{type: :activate_workflow}, _context -> - {:error, :too_many_workflows, %{text: error_msg}} - end - ) - - html = click_save(view) - - assert html =~ error_msg - - # let return ok with the limitter - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 1, - fn %{type: :activate_workflow}, _context -> :ok end - ) - - # subscribe to workflow events - Lightning.Workflows.subscribe(project.id) - - click_save(view) - - assert %{id: workflow_id} = - Lightning.Repo.one( - from(w in Workflow, - where: - w.project_id == ^project.id and w.name == ^workflow_name - ) - ) - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow_id}/legacy?#{[s: job.id]}" - ) - - assert render(view) =~ "Workflow saved" - - # workflow updated event is emitted - assert_received %Lightning.Workflows.Events.WorkflowUpdated{ - workflow: %{id: ^workflow_id} - } - end - - @tag role: :editor - test "creating a new workflow via template copies the name of the template", - %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - - select_template(view, "base-webhook-template") - - # the panel for creating workflow is visible - html = render(view) - assert html =~ "Describe your workflow" - assert has_element?(view, "form#search-templates-form") - assert has_element?(view, "form#choose-workflow-template-form") - - # lets select the cron one - template_id = "base-cron-template" - cron_template_name = "Scheduled Workflow" - - view - |> form("#choose-workflow-template-form", %{template_id: template_id}) - |> render_change() - - assert view - |> element( - "form#choose-workflow-template-form label[data-selected='true']" - ) - |> render() =~ cron_template_name - - # lets dummy send the content or base template - job_id = Ecto.UUID.generate() - trigger_id = Ecto.UUID.generate() - - payload = %{ - "triggers" => [%{"id" => trigger_id, "type" => "webhook"}], - "jobs" => [ - %{ - "id" => job_id, - "name" => "random job", - "body" => "// comment" - } - ], - "edges" => [ - %{ - "id" => Ecto.UUID.generate(), - "source_trigger_id" => trigger_id, - "condition_type" => "always", - "target_job_id" => job_id - } - ] - } - - view - |> with_target("#new-workflow-panel") - |> render_click("template-parsed", %{"workflow" => payload}) - - # click continue - view |> element("button#create_workflow_btn") |> render_click() - - click_save(view) - - expected_workflow_name = "Untitled workflow" - - assert Lightning.Repo.exists?( - from(w in Workflow, - where: - w.project_id == ^project.id and - w.name == ^expected_workflow_name - ) - ) - end - - @tag role: :editor - test "creating a new workflow via import handles empty name", - %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy?method=import", - on_error: :raise - ) - - # Generate IDs for the workflow components - job_id = Ecto.UUID.generate() - trigger_id = Ecto.UUID.generate() - - # Send workflow with empty name - view - |> with_target("#new-workflow-panel") - |> render_click("workflow-parsed", %{ - "workflow" => %{ - "name" => "", - "triggers" => [%{"id" => trigger_id, "type" => "webhook"}], - "jobs" => [ - %{ - "id" => job_id, - "name" => "random job", - "body" => "// comment" - } - ], - "edges" => [ - %{ - "id" => Ecto.UUID.generate(), - "source_trigger_id" => trigger_id, - "condition_type" => "always", - "target_job_id" => job_id - } - ] - } - }) - - # click continue - view |> element("button#create_workflow_btn") |> render_click() - - click_save(view) - - expected_workflow_name = "Untitled workflow" - - assert Lightning.Repo.exists?( - from(w in Workflow, - where: - w.project_id == ^project.id and - w.name == ^expected_workflow_name - ) - ) - end - - @tag role: :editor - test "creating a new workflow via import", %{conn: conn, project: project} do - {:ok, view, _html} = - conn - |> live(~p"/projects/#{project}/w/new/legacy") - - assert view - |> element("#import-workflow-btn") - |> render_click() =~ "Paste your YAML content here" - - # Test with valid payload - job_id = Ecto.UUID.generate() - trigger_id = Ecto.UUID.generate() - edge_id = Ecto.UUID.generate() - - valid_payload = %{ - "name" => "Test Workflow", - "jobs" => [ - %{ - "id" => job_id, - "name" => "Test Job", - "adaptor" => "@openfn/language-common@latest", - "body" => "fn(state => state)" - } - ], - "triggers" => [ - %{ - "id" => trigger_id, - "type" => "webhook", - "enabled" => true - } - ], - "edges" => [ - %{ - "id" => edge_id, - "source_trigger_id" => trigger_id, - "target_job_id" => job_id, - "condition_type" => "always", - "enabled" => true - } - ] - } - - view - |> with_target("#new-workflow-panel") - |> render_click("workflow-parsed", %{"workflow" => valid_payload}) - - refute view - |> element("#create_workflow_btn") - |> render() =~ "disabled=\"disabled\"" - - # Test with invalid payload (missing required fields) - invalid_payload = %{ - "jobs" => [ - %{ - "id" => Ecto.UUID.generate(), - "name" => "Test Job" - } - ] - } - - view |> render_click("choose-another-method", %{"method" => "import"}) - - view - |> with_target("#new-workflow-panel") - |> render_click("workflow-parsed", %{"workflow" => invalid_payload}) - - assert view - |> element("#create_workflow_btn") - |> render() =~ "disabled=\"disabled\"" - end - - @tag role: :editor - test "auditing snapshot creation", %{ - conn: conn, - project: project, - user: %{id: user_id} - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - {view, _parsed_workflow} = select_template(view, "base-cron-template") - - view |> render_click("save") - - workflow_name = "My Workflow" - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: workflow_name}) - - {job, _, _} = view |> select_first_job() - - view |> fill_job_fields(job, %{name: "My Job"}) - - view |> element("#new-credential-button") |> render_click() - - view |> CredentialLiveHelpers.select_credential_type("dhis2") - - view |> CredentialLiveHelpers.click_continue() - - # Creating a new credential from the Job panel - view - |> CredentialLiveHelpers.fill_credential(%{ - name: "My Credential", - body: %{username: "foo", password: "bar", hostUrl: "http://someurl"} - }) - - view |> CredentialLiveHelpers.click_save() - - # Editing the Jobs' body - view |> click_edit(job) - - view |> change_editor_text("some body") - - view |> render_click("save") - - assert %{id: workflow_id} = - Lightning.Repo.one( - from(w in Workflow, - where: - w.project_id == ^project.id and w.name == ^workflow_name - ) - ) - - audit_query = from(a in Audit, where: a.event == "snapshot_created") - - audit_events = Lightning.Repo.all(audit_query) - - # There should be 2 audit events - one for initial creation, one for save-and-sync - assert length(audit_events) == 2 - - Enum.each(audit_events, fn audit_event -> - assert %{ - actor_id: ^user_id, - item_id: ^workflow_id, - item_type: "workflow" - } = audit_event - end) - end - - @tag role: :viewer - test "viewers can't create new workflows", %{conn: conn, project: project} do - {:ok, _view, html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - |> follow_redirect(conn, ~p"/projects/#{project.id}/w") - - assert html =~ "You are not authorized to perform this action." - end - end - - describe "edit" do - setup :create_workflow - - test "renders breadcrumb navigation with Workflows link", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, _view, html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - # Verify the Workflows breadcrumb is rendered (else branch of breadcrumb loop) - assert html =~ "Workflows" - # Verify the project picker is rendered (if branch of breadcrumb loop) - assert html =~ "breadcrumb-project-picker-trigger" - end - - test "Editing tracks user presence", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - assert [] = Presence.list_presences_for(workflow) - refute Presence.has_any_presence?(workflow) - - {:ok, _view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - user = Map.put(user, :password, nil) - - assert [%Presence{user: ^user}] = Presence.list_presences_for(workflow) - assert Presence.has_any_presence?(workflow) - end - - test "Switching trigger types doesn't erase webhook URL input content", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - select_trigger(view) - - trigger = List.first(workflow.triggers) - webhook_url = url(LightningWeb.Endpoint, ~p"/i/#{trigger.id}") - - view - |> form("#workflow-form", %{ - "workflow" => %{"triggers" => %{"0" => %{"type" => "cron"}}} - }) - |> render_change() - - click_save(view) - - refute view |> has_element?("#webhookUrlInput[value='#{webhook_url}']") - - select_trigger(view) - - view - |> form("#workflow-form", %{ - "workflow" => %{"triggers" => %{"0" => %{"type" => "webhook"}}} - }) - |> render_change() - - click_save(view) - - assert view |> has_element?("#webhookUrlInput[value='#{webhook_url}']") - end - - test "Switching between workflow versions maintains correct read-only and edit modes", - %{ - conn: conn, - project: project, - snapshot: snapshot, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert snapshot.lock_version == workflow.lock_version - - assert view - |> has_element?( - "[id='canvas-workflow-version'][aria-label='This is the latest version of this workflow']", - "latest" - ) - - refute view - |> has_element?( - "[id='version-switcher-canvas-#{workflow.id}][data-version='latest']" - ) - - view |> fill_workflow_name("#{workflow.name} v2") - - workflow.jobs - |> Enum.with_index() - |> Enum.each(fn {job, idx} -> - view |> select_node(job, workflow.lock_version) - - refute view - |> has_element?("[id='workflow_jobs_#{idx}_name'][disabled]") - - refute view |> has_element?("[id='adaptor-name'][disabled]") - refute view |> has_element?("[id='adaptor-version'][disabled]") - - refute view - |> has_element?( - "[id='workflow_jobs_#{idx}_project_credential_id'][disabled]" - ) - - view |> click_edit(job) - - assert view - |> has_element?( - "[id='inspector-workflow-version'][aria-label='This is the latest version of this workflow']", - "latest" - ) - - refute view - |> has_element?("[id='manual_run_form_dataclip_id'][disabled]") - - refute view - |> has_element?( - "[id='job-editor-#{job.id}'][data-disabled='true']" - ) - - refute view - |> has_element?("[id='version-switcher-inspector-#{job.id}]") - - refute view - |> has_element?( - "[type='submit'][form='workflow-form'][disabled]", - "Save" - ) - end) - - workflow.edges - |> Enum.with_index() - |> Enum.each(fn {edge, idx} -> - view |> select_node(edge, workflow.lock_version) - - refute view - |> has_element?( - "[id='workflow_edges_#{idx}_condition_type'][disabled]" - ) - end) - - workflow.triggers - |> Enum.with_index() - |> Enum.each(fn {trigger, idx} -> - view |> select_node(trigger, workflow.lock_version) - - refute view - |> has_element?("[id='triggerType'][disabled]") - - refute view - |> has_element?( - "[id='workflow_triggers_#{idx}_enabled'][disabled]" - ) - end) - - job_1 = List.first(workflow.jobs) - - view |> select_node(job_1, workflow.lock_version) - - view - |> form("#workflow-form", %{ - "workflow" => %{ - "jobs" => %{ - "0" => %{ - "name" => "#{job_1.name} v2" - } - } - } - }) - |> render_change() - - view - |> form("#workflow-form") - |> render_submit() - - workflow = Repo.reload!(workflow) - - assert snapshot.lock_version < workflow.lock_version - - version = String.slice(snapshot.id, 0..6) - - view - |> element( - "a[href='/projects/#{project.id}/w'][data-phx-link='redirect']", - "Workflows" - ) - |> render_click() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: snapshot.lock_version]}", - on_error: :raise - ) - - assert view - |> has_element?( - "[id='canvas-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(snapshot.inserted_at, "%F at %T")}']", - version - ) - - assert view - |> has_element?( - "[id='version-switcher-button-#{workflow.id}']", - "Switch to latest version" - ) - - snapshot.jobs - |> Enum.with_index() - |> Enum.each(fn {job, idx} -> - view |> select_node(job, workflow.lock_version) - - assert view - |> has_element?( - "input[name='snapshot[jobs][#{idx}][name]'][disabled]" - ) - - assert view |> has_element?("[id='adaptor-name'][disabled]") - assert view |> has_element?("[id='adaptor-version'][disabled]") - - assert view - |> has_element?("select[name='credential_selector'][disabled]") - - view |> click_edit(job) - - assert view - |> has_element?( - "[id='inspector-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(snapshot.inserted_at, "%F at %T")}']", - version - ) - - view - |> has_element?("[id='manual_run_form_dataclip_id'][disabled]") - - # TODO: There is an issue with the new jsx approach, this attribute - # is no longer present in the DOM. It looks like LiveView doesn't - # render script tags while testing. - # It should look a little bit like this when runnin the server: - # - - # assert view - # |> has_element?( - # "[id='job-editor-#{job.id}'][data-disabled='true'][data-disabled-message=\"You can't edit while viewing a snapshot, switch to the latest version.\"" - # ) - - assert view - |> has_element?("[id='version-switcher-toggle-#{job.id}]") - - assert view |> save_is_disabled?() - end) - - snapshot.edges - |> Enum.with_index() - |> Enum.each(fn {edge, idx} -> - view |> select_node(edge, workflow.lock_version) - - assert view - |> has_element?( - "select[name='snapshot[edges][#{idx}][condition_type]'][disabled]" - ) - end) - - snapshot.triggers - |> Enum.with_index() - |> Enum.each(fn {trigger, idx} -> - view |> select_node(trigger, workflow.lock_version) - - assert view - |> has_element?("[id='triggerType'][disabled]") - - assert view - |> has_element?( - "input[name='snapshot[triggers][#{idx}][enabled]'][disabled]" - ) - end) - - last_job = List.last(snapshot.jobs) - last_edge = List.last(snapshot.edges) - - assert force_event(view, :save) =~ - "Cannot save in snapshot mode, switch to the latest version." - - assert force_event(view, :delete_node, last_job) =~ - "Cannot delete a step in snapshot mode, switch to latest" - - view |> select_node(last_edge, snapshot.lock_version) - - assert force_event(view, :delete_edge, last_edge) =~ - "Cannot delete an edge in snapshot mode, switch to latest" - - assert force_event(view, :manual_run_submit, %{}) =~ - "Cannot run in snapshot mode, switch to latest." - - assert force_event(view, :rerun, nil, nil) =~ - "Cannot rerun in snapshot mode, switch to latest." - - assert view |> element("#edit-disabled-warning") |> render() =~ - "You cannot edit or run an old snapshot of a workflow" - - assert view - |> element("#version-switcher-button-#{workflow.id}") - |> has_element?() - - refute view |> element("[type='submit']", "Save") |> has_element?() - - view - |> element("#version-switcher-button-#{workflow.id}") - |> render_click() - - refute view |> has_element?("#edit-disabled-warning") - - refute render(view) =~ - "You cannot edit or run an old snapshot of a workflow" - - refute view - |> element("#version-switcher-button-#{workflow.id}") - |> has_element?() - - refute view |> save_is_disabled?() - end - - test "Creating an audit event on rerun", %{ - conn: conn, - project: project, - snapshot: snapshot, - user: %{id: user_id}, - workflow: %{id: workflow_id} = workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - view |> fill_workflow_name("#{workflow.name} v2") - - job_1 = List.first(workflow.jobs) - - view |> select_node(job_1, workflow.lock_version) - - view - |> form("#workflow-form", %{ - "workflow" => %{ - "jobs" => %{ - "0" => %{ - "name" => "#{job_1.name} v2" - } - } - } - }) - |> render_change() - - view - |> form("#workflow-form") - |> render_submit() - - workflow = Repo.reload!(workflow) - - view - |> element( - "a[href='/projects/#{project.id}/w'][data-phx-link='redirect']", - "Workflows" - ) - |> render_click() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: snapshot.lock_version]}", - on_error: :raise - ) - - last_edge = List.last(snapshot.edges) - - existing_audit_ids = Audit |> Repo.all() |> Enum.map(& &1.id) - existing_snapshot_ids = Snapshot |> Repo.all() |> Enum.map(& &1.id) - - view |> select_node(last_edge, snapshot.lock_version) - - force_event(view, :manual_run_submit, %{}) - - force_event(view, :rerun, nil, nil) - - snapshots_query = - from(s in Snapshot, where: s.id not in ^existing_snapshot_ids) - - [%{id: latest_snapshot_id}] = Lightning.Repo.all(snapshots_query) - - audit_query = - from(a in Audit, where: a.id not in ^existing_audit_ids) - - [audit] = Lightning.Repo.all(audit_query) - - assert %{ - event: "snapshot_created", - actor_id: ^user_id, - item_id: ^workflow_id, - item_type: "workflow", - changes: %{ - after: %{"snapshot_id" => ^latest_snapshot_id} - } - } = audit - end - - test "Inspector renders run thru their snapshots and allows switching to the latest versions for editing", - %{ - conn: conn, - project: project, - snapshot: earliest_snapshot, - user: user, - workflow: workflow - } do - run_1 = - insert(:run, - work_order: build(:workorder, workflow: workflow), - starting_trigger: build(:trigger), - dataclip: build(:dataclip), - finished_at: build(:timestamp), - snapshot: earliest_snapshot, - state: :started - ) - - jobs_attrs = - workflow.jobs - |> Enum.with_index() - |> Enum.map(fn {job, idx} -> - %{ - id: job.id, - name: "job-number-#{idx}", - body: - ~s[fn(state => { console.log("job body number #{idx}"); return state; })] - } - end) - - {:ok, workflow} = - Workflows.change_workflow(workflow, %{jobs: jobs_attrs}) - |> Workflows.save_workflow(user) - - latest_snapshot = Snapshot.get_current_for(workflow) - - run_2 = - insert(:run, - work_order: build(:workorder, workflow: workflow), - starting_trigger: build(:trigger), - dataclip: build(:dataclip), - finished_at: build(:timestamp), - snapshot: latest_snapshot, - state: :started - ) - - job_1 = List.last(run_1.snapshot.jobs) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run_1, s: job_1, m: "expand", v: run_1.snapshot.lock_version]}", - on_error: :raise - ) - - run_1_version = String.slice(run_1.snapshot.id, 0..6) - - assert view - |> has_element?( - "[id='inspector-workflow-version'][aria-label='You are viewing a snapshot of this workflow that was taken on #{Helpers.format_date(run_1.snapshot.inserted_at, "%F at %T")}']", - run_1_version - ) - - assert view - |> has_element?( - "#job-editor-panel-panel-header-title", - "Editor (read-only)" - ) - - # See: line 563 - # assert view - # |> has_element?( - # "[id='job-editor-#{job_1.id}'][data-disabled='true'][data-source='#{job_1.body}'][data-disabled-message=\"You can't edit while viewing a snapshot, switch to the latest version.\"]" - # ) - - assert view |> has_element?("div", job_1.name) - - view |> element("#version-switcher-toggle-#{job_1.id}") |> render_click() - - job_2 = List.last(run_2.snapshot.jobs) - - assert view - |> has_element?( - "[id='inspector-workflow-version'][aria-label='This is the latest version of this workflow']", - "latest" - ) - - assert view - |> has_element?( - "#job-editor-panel-panel-header-title", - "Editor" - ) - - # See: line 563 - # assert view - # |> has_element?( - # "[id='job-editor-#{job_1.id}'][data-disabled-message=''][data-disabled='false'][data-source='#{job_2.body}']" - # ) - - refute view - |> has_element?("select[name='manual[dataclip_id]'][disabled]") - - assert view |> has_element?("div", job_2.name) - end - - test "Can't switch to the latest version from a deleted step", %{ - conn: conn, - project: project, - snapshot: snapshot, - user: user, - workflow: workflow - } do - run = - insert(:run, - work_order: build(:workorder, workflow: workflow), - starting_trigger: build(:trigger), - dataclip: build(:dataclip), - finished_at: build(:timestamp), - snapshot: snapshot, - state: :started - ) - - jobs_attrs = - workflow.jobs - |> Enum.with_index() - |> Enum.map(fn {job, idx} -> - %{ - id: job.id, - name: "job-number-#{idx}", - body: - ~s[fn(state => { console.log("job body number #{idx}"); return state; })] - } - end) - - {:ok, workflow} = - Workflows.change_workflow(workflow, %{jobs: jobs_attrs}) - |> Workflows.save_workflow(user) - - latest_snapshot = Snapshot.get_current_for(workflow) - - insert(:run, - work_order: build(:workorder, workflow: workflow), - starting_trigger: build(:trigger), - dataclip: build(:dataclip), - finished_at: build(:timestamp), - snapshot: latest_snapshot, - state: :started - ) - - job_to_delete = workflow.jobs |> List.last() |> Repo.delete!() - - workflow = Repo.reload(workflow) |> Repo.preload(:jobs) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job_to_delete, m: "expand", v: run.snapshot.lock_version]}", - on_error: :raise - ) - - assert view - |> has_element?( - "[id='version-switcher-toggle-#{job_to_delete.id}'][disabled]" - ) - - assert view - |> render_click("switch-version", %{"type" => "toggle"}) =~ - "Can't switch to the latest version, the job has been deleted from the workflow." - end - - test "click on pencil icon activates workflow name edit mode", %{ - conn: conn, - project: project, - workflow: workflow - } do - another_workflow = - workflow_fixture(name: "A random workflow", project_id: project.id) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version, m: "settings"]}", - on_error: :raise - ) - - assert view |> has_element?(~s(input[name="workflow[name]"])) - - assert view - |> form("#workflow-form", %{"workflow" => %{"name" => ""}}) - |> render_change() =~ "can't be blank" - - html = - view - |> form("#workflow-form", %{ - "workflow" => %{"name" => another_workflow.name} - }) - |> render_submit() - - assert html =~ - "A workflow with this name already exists (possibly pending deletion) in this project." - - assert html =~ "Workflow could not be saved" - - assert view - |> form("#workflow-form", %{ - "workflow" => %{"name" => "some new name"} - }) - |> render_submit() =~ "Workflow saved" - end - - test "using the settings panel", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - refute has_element?(view, "#workflow-settings-#{workflow.id}") - - view - |> element("#toggle-settings") - |> render_click() - - path = assert_patch(view) - - assert path == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings" - - assert has_element?(view, "#workflow-settings-#{workflow.id}") - html = render(view) - assert html =~ "Workflow settings" - assert html =~ "Unlimited (up to max available)" - - assert view - |> form("#workflow-form", %{"workflow" => %{"concurrency" => "0"}}) - |> render_change() =~ "must be greater than or equal to 1" - - assert view |> element("#workflow-form") |> render_submit() =~ - "Workflow could not be saved" - - assert view - |> form("#workflow-form", %{"workflow" => %{"concurrency" => "1"}}) - |> render_change() =~ "No more than one run at a time" - - assert view - |> form("#workflow-form", %{"workflow" => %{"concurrency" => "5"}}) - |> render_change() =~ "No more than 5 runs at a time" - - # the current implmentation simply sends `save` the event, it does - # not submit the form. I'm mimicking that here - assert view |> render_submit("save") =~ "Workflow saved" - - assert Lightning.Repo.reload(workflow).concurrency == 5 - - assert assert_patch(view) =~ - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings" - - assert view - |> form("#workflow-form", %{"workflow" => %{"concurrency" => ""}}) - |> render_change() =~ "Unlimited" - - view |> element("#toggle-settings") |> render() - - view - |> element("#toggle-settings") - |> render_click() - - refute has_element?(view, "#workflow-settings-#{workflow.id}") - - assert assert_patch(view) == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy" - - # bring the settings panel back, so we can test that selecting something - # else will close it - view - |> element("#toggle-settings") - |> render_click() - - assert_patch(view) - - job = workflow.jobs |> Enum.at(1) - - view |> select_node(job) - - assert assert_patch(view) == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}" - - refute has_element?(view, "#workflow-settings-#{workflow.id}"), - "should not have settings panel present" - - # bring it back again to test the close button - view - |> element("#toggle-settings") - |> render_click() - - refute has_element?(view, "#job-pane-#{job.id}"), - "should not have job pane anymore" - - assert assert_patch(view) == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings" - - view - |> element("#close-panel") - |> render_click() - - refute has_element?(view, "#workflow-settings-#{workflow.id}") - - assert assert_patch(view) == - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy" - end - - test "toggling run log settings in the settings panel", %{ - conn: conn, - project: project, - workflow: workflow - } do - for {conn, _user} <- setup_project_users(conn, project, [:viewer, :editor]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - view - |> element("#toggle-settings") - |> render_click() - - assert view - |> element("#toggle-control-toggle-workflow-logs-btn") - |> render() =~ "opacity-50 cursor-not-allowed" - - assert has_element?( - view, - "#toggle-workflow-logs-btn" - ) - - assert_raise ArgumentError, - ~r/cannot click element "#toggle-workflow-logs-btn" because it is disabled/, - fn -> - view - |> element("#toggle-workflow-logs-btn") - |> render_click() - end - - GenServer.stop(view.pid) - end - - for {conn, _user} <- setup_project_users(conn, project, [:admin, :owner]) do - workflow = - workflow - |> Repo.reload() - |> Ecto.Changeset.change(%{enable_job_logs: true}) - |> Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - view - |> element("#toggle-settings") - |> render_click() - - refute view - |> element("#toggle-control-toggle-workflow-logs-btn") - |> render() =~ "opacity-50 cursor-not-allowed" - - assert has_element?( - view, - "#toggle-workflow-logs-btn" - ) - - view - |> form("#workflow-form") - |> render_change(workflow: %{enable_job_logs: "false"}) - - assert workflow.enable_job_logs == true - - # send a save event - view |> render_submit("save") - - assert assert_patch(view) =~ - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings" - - assert Repo.reload(workflow).enable_job_logs == false - - GenServer.stop(view.pid) - end - end - - test "users can view workflow as code", %{ - conn: conn, - project: project, - workflow: workflow - } do - for {conn, _user} <- - setup_project_users(conn, project, [:viewer, :editor, :admin, :owner]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - view - |> element("#toggle-settings") - |> render_click() - - assert assert_patch(view) =~ - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=settings" - - view |> element("a#view-workflow-as-yaml-link") |> render_click() - - assert assert_patch(view) =~ - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?m=code" - - expected_download_name = - String.replace(workflow.name, " ", "-") <> ".yaml" - - assert has_element?( - view, - "#download-workflow-code-btn[data-file-name='#{expected_download_name}']" - ) - - assert has_element?(view, "#copy-workflow-code-btn") - - GenServer.stop(view.pid) - end - end - - test "renders error message when a job has an empty body", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - job = workflow.jobs |> Enum.at(1) - - view |> select_node(job, workflow.lock_version) - - view |> click_edit(job) - - view |> change_editor_text("some body") - - refute view |> render() =~ - "Code editor cannot be empty." - - view |> change_editor_text("") - - assert view |> render() =~ - "Code editor cannot be empty." - end - - test "allows editing job name", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert view |> page_title() =~ workflow.name - - view |> fill_workflow_name("") - - job_2 = workflow.jobs |> Enum.at(1) - - view |> select_node(job_2, workflow.lock_version) - view |> fill_job_fields(job_2, %{name: ""}) - - assert view |> job_form_has_error(job_2, "name", "can't be blank") - assert view |> save_is_disabled?() - - new_job_name = "My Other Job" - - assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~ - new_job_name - - assert view |> save_is_disabled?() - end - - test "renders the job form correctly when local_adaptors_repos is NOT set", - %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - job_1 = hd(workflow.jobs) - - view |> select_node(job_1, workflow.lock_version) - - adaptor_name_label = - view |> element("label[for='adaptor-name']") |> render() - - assert adaptor_name_label =~ "Adaptor" - refute adaptor_name_label =~ "Adaptor (local)" - - # adapter version picker is available - assert has_element?(view, "#adaptor-version") - end - - @tag :tmp_dir - test "renders the job form correctly when local_adaptors_repos is set", %{ - conn: conn, - project: project, - workflow: workflow, - tmp_dir: tmp_dir - } do - Mox.stub(Lightning.MockConfig, :adaptor_registry, fn -> - [local_adaptors_repos: [tmp_dir]] - end) - - expected_adaptors = ["foo", "bar", "baz"] - - Enum.each(expected_adaptors, fn adaptor -> - [tmp_dir, "packages", adaptor] |> Path.join() |> File.mkdir_p!() - end) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - job_1 = hd(workflow.jobs) - - view |> select_node(job_1, workflow.lock_version) - - adaptor_name_label = - view |> element("label[for='adaptor-name']") |> render() - - assert adaptor_name_label =~ "Adaptor (local)" - - # version picker is not present - refute has_element?(view, "#adaptor-version") - end - - test "Save button is disabled when workflow is deleted", %{ - conn: conn, - project: project, - workflow: workflow - } do - workflow - |> Ecto.Changeset.change(%{ - deleted_at: DateTime.utc_now() |> DateTime.truncate(:second) - }) - |> Lightning.Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert view |> page_title() =~ workflow.name - - assert view |> save_is_disabled?() - - # try changing the workflow name anyway - assert render_click(view, "save", %{name: "updatename"}) =~ - "Oops! You cannot modify a deleted workflow" - end - - test "opens edge Path form and saves the JS expression", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - form_html = - view |> select_node(Enum.at(workflow.jobs, 0), workflow.lock_version) - - assert form_html =~ "Job Name" - refute form_html =~ "Path" - - form_html = - view |> select_node(Enum.at(workflow.edges, 0), workflow.lock_version) - - assert form_html =~ "Path" - - assert form_html =~ "Label" - - assert form_html =~ - ~S[] - - edge_on_edit = Enum.at(workflow.edges, 1) - form_html = view |> select_node(edge_on_edit, workflow.lock_version) - - assert form_html =~ - ~S[] - - form_html = - view - |> form("#workflow-form", %{ - "workflow" => %{ - "edges" => %{"1" => %{"condition_type" => "js_expression"}} - } - }) - |> render_change() - - assert form_html =~ "Label" - - assert form_html =~ - ~S[] - - view - |> form("#workflow-form", %{ - "workflow" => %{ - "edges" => %{ - "1" => %{ - "condition_label" => "My JS Expression", - "condition_expression" => "state.data.field === 33" - } - } - } - }) - |> render_change() - - view - |> form("#workflow-form") - |> render_submit() - - assert Map.delete(Repo.reload!(edge_on_edit), :updated_at) == - Map.delete( - Map.merge(edge_on_edit, %{ - condition_type: :js_expression, - condition_label: "My JS Expression", - condition_expression: "state.data.field === 33" - }), - :updated_at - ) - end - - test "displays warning when js expression contains unwanted words", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - warning_text = - "Warning: this expression appears to contain unsafe functions (eval, require, import, process, await) that may cause your workflow to fail" - - edge_to_edit = Enum.at(workflow.edges, 1) - view |> select_node(edge_to_edit) - - # change to js_expression - html = - view - |> form("#workflow-form", %{ - "workflow" => %{ - "edges" => %{"1" => %{"condition_type" => "js_expression"}} - } - }) - |> render_change() - - assert html =~ "Matches a Javascript Expression" - refute html =~ warning_text - - html = - view - |> form("#workflow-form", %{ - "workflow" => %{ - "edges" => %{ - "1" => %{ - "condition_label" => "My JS Expression", - "condition_expression" => "eval" - } - } - } - }) - |> render_change() - - assert html =~ warning_text - end - - @tag role: :editor - test "can delete a job", %{conn: conn, project: project, workflow: workflow} do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - [job_1, job_2] = workflow.jobs - view |> select_node(job_1, workflow.lock_version) - - assert view |> delete_job_button_is_disabled?(job_1) - - # Test that the delete event doesn't work even if the button is disabled. - assert view |> force_event(:delete_node, job_1) =~ - "Delete all descendant steps first." - - view |> select_node(job_2, workflow.lock_version) - - assert_patched( - view, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_2}&v=#{workflow.lock_version}" - ) - - refute view |> delete_job_button_is_disabled?(job_2) - - view |> click_delete_job(job_2) - - assert_push_event(view, "patches-applied", %{ - patches: [ - %{op: "remove", path: "/jobs/1"}, - %{op: "remove", path: "/edges/1"} - ] - }) - end - - @tag role: :editor - test "cannot delete an edge between a trigger and a job", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - [trigger_edge, other_edge] = workflow.edges - - assert view |> select_node(other_edge, workflow.lock_version) =~ - "Delete Path" - - refute view |> select_node(trigger_edge, workflow.lock_version) =~ - "Delete Path" - - assert view |> force_event(:delete_edge, trigger_edge) =~ - "You cannot remove the first edge in a workflow." - end - - @tag role: :editor - test "can delete an edge between two jobs", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - [_trigger_edge, other_edge] = workflow.edges - - assert view |> select_node(other_edge, workflow.lock_version) =~ - "Delete Path" - - view |> select_node(other_edge, workflow.lock_version) - - assert_patched( - view, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{other_edge}&v=#{workflow.lock_version}" - ) - - view |> click_delete_edge(other_edge) - - assert_push_event(view, "patches-applied", %{ - patches: [ - %{op: "remove", path: "/edges/1"} - ] - }) - end - - @tag role: :viewer - test "cannot delete edges", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - [_trigger_edge, other_edge] = workflow.edges - - assert view |> select_node(other_edge, workflow.lock_version) =~ - "Delete Path" - - view |> select_node(other_edge, workflow.lock_version) - - assert_patched( - view, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{other_edge}&v=#{workflow.lock_version}" - ) - - assert view |> delete_edge_button_is_disabled?(other_edge) - - assert view |> force_event(:delete_edge, other_edge) =~ - "You are not authorized to delete edges." - end - - @tag role: :editor - test "can't delete the first step in a workflow", %{ - conn: conn, - project: project - } do - trigger = build(:trigger, type: :webhook) - - job = - build(:job, - body: ~s[fn(state => { return {...state, extra: "data"} })], - name: "First Job" - ) - - workflow = - build(:workflow, project: project) - |> with_job(job) - |> with_trigger(trigger) - |> with_edge({trigger, job}) - |> insert() - |> with_snapshot() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - view |> select_node(job, workflow.lock_version) - - assert view |> delete_job_button_is_disabled?(job) - - assert view |> force_event(:delete_node, job) =~ - "You can't delete the first step in a workflow." - end - - @tag role: :editor - test "can delete a step that has already been ran", %{ - conn: conn, - project: project - } do - trigger = build(:trigger, type: :webhook) - - [job_a, job_b] = insert_list(2, :job) - - workflow = - build(:workflow) - |> with_job(job_a) - |> with_job(job_b) - |> with_trigger(trigger) - |> with_edge({trigger, job_a}) - |> with_edge({job_a, job_b}) - |> insert() - |> with_snapshot() - - insert(:step, job: job_b) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - view |> select_node(job_b, workflow.lock_version) - - assert_patched( - view, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_b}&v=#{workflow.lock_version}" - ) - - refute view |> delete_job_button_is_disabled?(job_b) - - view |> click_delete_job(job_b) - - project_id = project.id - - assert_push_event(view, "patches-applied", %{ - patches: [ - %{value: ^project_id, path: "/project_id", op: "replace"}, - %{op: "remove", path: "/jobs/1"}, - %{op: "remove", path: "/edges/1"} - ] - }) - end - - @tag role: :editor - test "can't delete any job that has downstream jobs", - %{ - conn: conn, - project: project - } do - trigger = build(:trigger, type: :webhook) - - [job_a, job_b, job_c] = build_list(3, :job) - - workflow = - build(:workflow) - |> with_job(job_a) - |> with_job(job_b) - |> with_job(job_c) - |> with_trigger(trigger) - |> with_edge({trigger, job_a}) - |> with_edge({job_a, job_b}) - |> with_edge({job_b, job_c}) - |> insert() - |> with_snapshot() - - {:ok, view, html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_a}&v=#{workflow.lock_version}", - on_error: :raise - ) - - assert view |> delete_job_button_is_disabled?(job_a) - - assert html =~ - "You can't delete a step that other downstream steps depend on" - - assert view |> force_event(:delete_node, job_a) =~ - "Delete all descendant steps first" - - {:ok, view, html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?s=#{job_b}&v=#{workflow.lock_version}", - on_error: :raise - ) - - assert view |> delete_job_button_is_disabled?(job_b) - - assert html =~ - "You can't delete a step that other downstream steps depend on" - - assert view |> force_event(:delete_node, job_a) =~ - "Delete all descendant steps first" - end - - @tag role: :viewer - test "viewers can't edit existing jobs", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - view |> select_node(workflow.triggers |> Enum.at(0), workflow.lock_version) - - assert view |> input_is_disabled?("[name='workflow[triggers][0][type]']") - - view |> select_node(workflow.edges |> Enum.at(0), workflow.lock_version) - - assert view - |> input_is_disabled?("[name='workflow[edges][0][condition_type]']") - - assert view |> save_is_disabled?() - job_1 = workflow.jobs |> Enum.at(0) - - view |> select_node(job_1, workflow.lock_version) - - assert view |> input_is_disabled?(job_1, "name") - - assert view |> input_is_disabled?("[name='adaptor_picker[adaptor_name]']") - assert view |> input_is_disabled?(job_1, "adaptor") - assert view |> input_is_disabled?(job_1, "project_credential_id") - - assert view |> delete_job_button_is_disabled?(job_1) - - # Test that the delete event doesn't work even if the button is disabled. - assert view |> force_event(:delete_node, job_1) =~ - "You are not authorized to perform this action." - - assert view |> save_is_disabled?() - - view |> click_close_error_flash() - - assert view |> force_event(:save) =~ - "You are not authorized to perform this action." - - view |> click_close_error_flash() - - assert view |> force_event(:form_changed) =~ - "You are not authorized to perform this action." - - view |> click_close_error_flash() - - assert view |> force_event(:validate) =~ - "You are not authorized to perform this action." - end - - test "can enable/disable any edge between two jobs", %{ - conn: conn, - project: project, - workflow: workflow - } do - edge = - Enum.find(workflow.edges, fn edge -> edge.source_job_id != nil end) - - assert edge.enabled - - {:ok, view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{edge.id}&v=#{workflow.lock_version}", - on_error: :raise - ) - - idx = get_index_of_edge(view, edge) - - assert html =~ "Enabled" - - assert view - |> element("input[name='workflow[edges][#{idx}][enabled]']") - |> has_element?() - - view - |> form("#workflow-form", %{ - "workflow" => %{"edges" => %{to_string(idx) => %{"enabled" => false}}} - }) - |> render_change() - - view - |> form("#workflow-form") - |> render_submit() - - edge = Repo.reload!(edge) - - refute edge.enabled - - refute view - |> element( - "input[name='workflow[edges][#{idx}][enabled]'][checked]" - ) - |> has_element?() - end - - test "does not call the limiter when the trigger is not enabled", %{ - conn: conn, - project: project, - workflow: workflow - } do - Mox.verify_on_exit!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - # We expect zero calls - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 0, - fn %{type: :activate_workflow}, _context -> - {:error, :too_many_workflows, %{text: "some error message"}} - end - ) - - job_2 = workflow.jobs |> Enum.at(1) - - view |> select_node(job_2, workflow.lock_version) - - new_job_name = "My Other Job" - - assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~ - new_job_name - - click_save(view) - - assert Lightning.Repo.reload(job_2).name == new_job_name - end - - test "calls the limiter when the trigger is enabled", %{ - conn: conn, - project: project, - workflow: workflow - } do - Mox.verify_on_exit!() - - workflow.triggers - |> hd() - |> Ecto.Changeset.change(%{enabled: false}) - |> Lightning.Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - # We expect 1 call - error_msg = "Oopsie Doopsie! An error occured" - - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 1, - fn %{type: :activate_workflow}, _context -> - {:error, :too_many_workflows, %{text: error_msg}} - end - ) - - select_trigger(view) - - view - |> form("#workflow-form", %{ - "workflow" => %{"triggers" => %{"0" => %{"enabled" => true}}} - }) - |> render_change() - - html = click_save(view) - - assert html =~ error_msg - end - - test "workflows are disabled by default", %{ - conn: conn, - user: user - } do - project = insert(:project, project_users: [%{user: user, role: :editor}]) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise) - - select_template(view, "base-webhook-template") - - push_patches_to_view(view, initial_workflow_patchset(project)) - - # click continue - view |> element("button#create_workflow_btn") |> render_click() - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: "My Workflow"}) - - {job, _, _} = select_first_job(view) - - fill_job_fields(view, job, %{name: "My Job"}) - - click_edit(view, job) - - change_editor_text(view, "some body") - - # html = click_save(view) - html = trigger_save(view) - - assert html =~ - "Workflow saved successfully. Remember to enable your workflow to run it automatically." - - refute Workflows.get_workflows_for(project) - |> List.first() - |> Map.get(:triggers) - |> List.first() - |> Map.get(:enabled) - end - - test "when workflow is enabled, reminder flash message is not displayed for the first save", - %{ - conn: conn, - user: user - } do - project = insert(:project, project_users: [%{user: user, role: :editor}]) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise) - - select_template(view, "base-webhook-template") - - push_patches_to_view(view, initial_workflow_patchset(project)) - - # click continue - view |> element("button#create_workflow_btn") |> render_click() - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: "My Workflow"}) - - {job, _, _} = select_first_job(view) - - fill_job_fields(view, job, %{name: "My Job"}) - - click_edit(view, job) - - change_editor_text(view, "some body") - - close_job_edit_view(view, job) - - view - |> element("#toggle-control-workflow") - |> render_click() - - html = click_save(view) - - refute html =~ - "Workflow saved successfully. Remember to enable your workflow to run it automatically." - - assert html =~ - "Workflow saved successfully." - - assert Workflows.get_workflows_for(project) - |> List.first() - |> Map.get(:triggers) - |> List.first() - |> Map.get(:enabled) - end - - test "clicking on the toggle disables all the triggers of a workflow", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - assert workflow.triggers |> Enum.all?(& &1.enabled) - - view - |> element("#toggle-control-workflow") - |> render_click() - - click_save(view) - - workflow = Workflows.get_workflow(workflow.id, include: [:triggers]) - - refute workflow.triggers |> Enum.any?(& &1.enabled) - end - - test "workflow can still be disabled / enabled from the trigger form", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - assert workflow.triggers |> Enum.all?(& &1.enabled) - - select_trigger(view) - - view - |> form("#workflow-form", %{ - "workflow" => %{"triggers" => %{"0" => %{"enabled" => "false"}}} - }) - |> render_change() - - click_save(view) - - workflow = Workflows.get_workflow(workflow.id, include: [:triggers]) - - refute workflow.triggers |> Enum.any?(& &1.enabled) - end - - test "workflow state toggle tooltip messages vary by trigger type", %{ - conn: conn, - project: project - } do - cron_trigger = build(:trigger, type: :cron, enabled: false) - webhook_trigger = build(:trigger, type: :webhook, enabled: true) - - job_1 = build(:job) - job_2 = build(:job) - - cron_workflow = - build(:workflow) - |> with_job(job_1) - |> with_trigger(cron_trigger) - |> with_edge({cron_trigger, job_1}) - |> insert() - - webhook_workflow = - build(:workflow) - |> with_job(job_2) - |> with_trigger(webhook_trigger) - |> with_edge({webhook_trigger, job_2}) - |> insert() - - Lightning.Workflows.Snapshot.create(cron_workflow) - - Lightning.Workflows.Snapshot.create(webhook_workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{cron_workflow.id}/legacy", - on_error: :raise - ) - - assert view - |> has_element?( - "#toggle-container-workflow[aria-label='This workflow is inactive (manual runs only)']" - ) - - view - |> element("#toggle-control-workflow") - |> render_click() - - assert view - |> has_element?( - "#toggle-container-workflow[aria-label='This workflow is active (cron trigger enabled)']" - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{webhook_workflow.id}/legacy", - on_error: :raise - ) - - assert view - |> has_element?( - "#toggle-container-workflow[aria-label='This workflow is active (webhook trigger enabled)']" - ) - end - - @tag skip: "component moved to react" - test "manual run form body remains unchanged even after save workflow form is submitted", - %{conn: conn, project: project, test: test} do - %{jobs: [job_1, job_2 | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1, m: "expand"]}", - on_error: :raise - ) - - body = Jason.encode!(%{test: test}) - - body_part = to_string(test) - - refute view |> element("#manual_run_form") |> render() =~ body_part - - assert view - |> form("#manual_run_form") - |> render_change(manual: %{body: body}) =~ body_part - - view |> close_job_edit_view(job_1) - - # submit workflow form - view |> form("#workflow-form") |> render_submit() - - view - |> render_patch( - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[m: "expand", s: job_1.id]}" - ) - - # manual run form still has the body - assert view |> element("#manual_run_form") |> render() =~ body_part - - # select another job - select_node(view, %{id: job_2.id}) - click_edit(view, %{id: job_2.id}) - - # manual run form body is cleared - refute view |> element("#manual_run_form") |> render() =~ body_part - end - end - - describe "Tracking Workflow editor metrics" do - setup :create_workflow - - setup context do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - current_log_level = Logger.level() - Logger.configure(level: :info) - - on_exit(fn -> - Logger.configure(level: current_log_level) - end) - - context - |> Map.merge(%{ - metrics: [ - %{ - "event" => "foo-bar-event", - "start" => 1_737_635_739_914, - "end" => 1_737_635_808_890 - } - ] - }) - end - - test "logs the metrics", %{ - conn: conn, - metrics: metrics, - project: project, - workflow: %{id: workflow_id} = workflow - } do - assert [] = Presence.list_presences_for(workflow) - refute Presence.has_any_presence?(workflow) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - fun = fn -> - view - |> editor_element() - |> render_hook("workflow_editor_metrics_report", %{"metrics" => metrics}) - end - - assert capture_log(fun) =~ ~r/foo-bar-event/ - assert capture_log(fun) =~ ~r/#{workflow_id}/ - end - - @tag role: :editor - test "can change job name, adaptor, version, and credential sequentially", %{ - conn: conn, - project: project, - workflow: workflow - } do - project_credential = - insert(:project_credential, - project: project, - credential: build(:credential) - ) - - keychain_credential = - insert(:keychain_credential, project: project) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - # Select the first job - job = hd(workflow.jobs) - view |> select_node(job, workflow.lock_version) - - # Step 1: Change job name - new_job_name = "Updated Job Name" - - view - |> form("#workflow-form", %{ - "workflow" => %{ - "jobs" => %{ - "0" => %{ - "name" => new_job_name - } - } - } - }) - |> render_change() - - # Step 2: Change adaptor - view |> change_adaptor(job, "@openfn/language-dhis2") - view |> trigger_save() - - # Step 3: Change adaptor version to something specific (not @latest) - specific_version = "@openfn/language-dhis2@3.0.4" - view |> change_adaptor_version(specific_version) - - assert view - |> credential_options() - |> Enum.reject(&(&1.text == "")) == - [ - %{ - text: project_credential.credential.name, - value: project_credential.id - }, - %{text: keychain_credential.name, value: keychain_credential.id} - ] - - view |> change_credential(job, project_credential) - - assert view |> selected_credential_name() == - project_credential.credential.name - - view |> trigger_save() - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: job.id, v: workflow.lock_version]}" - ) - - job = Lightning.Repo.reload(job) - assert job.adaptor == specific_version - assert job.name == new_job_name - assert job.project_credential_id == project_credential.id - - view |> change_credential(job, keychain_credential) - assert view |> selected_credential_name() == keychain_credential.name - - view |> trigger_save() - - job = Lightning.Repo.reload(job) - assert job.project_credential_id == nil - assert job.keychain_credential_id == keychain_credential.id - end - end - - describe "Save and Sync to GitHub" do - setup :verify_on_exit! - setup :create_workflow - - setup %{project: project} do - repo_connection = - insert(:project_repo_connection, - project: project, - repo: "someaccount/somerepo", - branch: "somebranch", - github_installation_id: "1234", - access_token: "someaccesstoken" - ) - - %{repo_connection: repo_connection} - end - - @tag role: :editor - test "is not available when project isn't connected to github", %{ - conn: conn, - project: project, - workflow: workflow, - repo_connection: repo_connection - } do - Repo.delete!(repo_connection) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - refute view |> has_element?("button[phx-click='toggle_github_sync_modal']") - end - - @tag role: :editor - test "can be done when creating a new workflow", %{ - conn: conn, - project: project, - repo_connection: repo_connection - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy", on_error: :raise) - - {view, _parsed_workflow} = select_template(view, "base-webhook-template") - - view |> render_click("save") - - workflow = get_assigns(view) |> Map.get(:workflow) - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy" - ) - - workflow_name = "My Workflow" - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: workflow_name}) - - {job, _, _} = view |> select_first_job() - - view |> fill_job_fields(job, %{name: "My Job"}) - - # Editing the Jobs' body - view |> click_edit(job) - - view |> change_editor_text("some body") - - refute view |> save_is_disabled?() - - assert view |> has_pending_changes() - - # let return ok with the limitter - stub( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - fn _action, _context -> - :ok - end - ) - - # button to sync to github exists - assert view |> has_element?("button[phx-click='toggle_github_sync_modal']") - - # verify connection - repo_name = repo_connection.repo - branch_name = repo_connection.branch - installation_id = repo_connection.github_installation_id - - expected_default_branch = "main" - - expected_deploy_yml_path = - ".github/workflows/openfn-#{repo_connection.project_id}-deploy.yml" - - expected_config_json_path = - "openfn-#{repo_connection.project_id}-config.json" - - expected_secret_name = - "OPENFN_#{String.replace(repo_connection.project_id, "-", "_")}_API_KEY" - - expect(Lightning.Tesla.Mock, :call, 6, fn - # get installation access token. - %{ - url: - "https://api.github.com/app/installations/" <> - ^installation_id <> "/access_tokens" - }, - _opts -> - {:ok, - %Tesla.Env{ - status: 201, - body: %{"token" => "some-token"} - }} - - # get repo content - %{url: "https://api.github.com/repos/" <> ^repo_name}, _opts -> - {:ok, - %Tesla.Env{ - status: 200, - body: %{"default_branch" => expected_default_branch} - }} - - # check if pull yml exists in the default branch - %{ - method: :get, - query: [{:ref, "heads/" <> ^expected_default_branch}], - url: - "https://api.github.com/repos/" <> - ^repo_name <> "/contents/.github/workflows/openfn-pull.yml" - }, - _opts -> - {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}} - - # check if deploy yml exists in the target branch - %{ - method: :get, - query: [{:ref, "heads/" <> ^branch_name}], - url: - "https://api.github.com/repos/" <> - ^repo_name <> "/contents/" <> ^expected_deploy_yml_path - }, - _opts -> - {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}} - - # check if config.json exists in the target branch - %{ - method: :get, - query: [{:ref, "heads/" <> ^branch_name}], - url: - "https://api.github.com/repos/" <> - ^repo_name <> "/contents/" <> ^expected_config_json_path - }, - _opts -> - {:ok, %Tesla.Env{status: 200, body: %{"sha" => "somesha"}}} - - # check if api key secret exists - %{ - method: :get, - url: - "https://api.github.com/repos/" <> - ^repo_name <> "/actions/secrets/" <> ^expected_secret_name - }, - _opts -> - {:ok, %Tesla.Env{status: 200, body: %{}}} - end) - - # click to open the github sync modal - refute has_element?(view, "#github-sync-modal") - render_hook(view, "toggle_github_sync_modal") - assert has_element?(view, "#github-sync-modal") - # modal form exists - assert view |> has_element?("form#github-sync-modal-form") - assert render_async(view) =~ "Save and sync changes to GitHub" - - expect_create_installation_token(repo_connection.github_installation_id) - expect_get_repo(repo_connection.repo) - expect_create_workflow_dispatch(repo_connection.repo, "openfn-pull.yml") - - # submit form - view - |> form("#github-sync-modal-form") - |> render_submit(%{ - "github_sync" => %{"commit_message" => "some message"} - }) - - assert workflow = - Lightning.Repo.one( - from(w in Workflow, - where: - w.project_id == ^project.id and w.name == ^workflow_name - ) - ) - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[m: "expand", s: job.id]}" - ) - - assert render(view) =~ "Workflow saved and sync requested. Check the" - - link_to_actions = - "https://www.github.com/" <> repo_connection.repo <> "/actions" - - assert has_element?( - view, - ~s{div[data-flash-kind='info'] [href="#{link_to_actions}"][target="_blank"]}, - "GitHub actions" - ) - - refute has_element?(view, "#github-sync-modal") - end - - @tag :capture_log - test "does not close the github modal when GitHub sync fails", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert view |> page_title() =~ workflow.name - - job_2 = workflow.jobs |> Enum.at(1) - - view |> select_node(job_2, workflow.lock_version) - - new_job_name = "My Other Job" - - assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~ - new_job_name - - refute view |> save_is_disabled?() - - # let return ok with the limitter - stub( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - fn _action, _context -> - :ok - end - ) - - # return error for GitHub - stub(Lightning.Tesla.Mock, :call, fn - %{url: "https://api.github.com/app/installations" <> _rest}, _opts -> - {:ok, - %Tesla.Env{ - status: 404, - body: %{"error" => "some-error"} - }} - - %{url: "https://api.github.com/" <> _rest}, _opts -> - {:ok, - %Tesla.Env{ - status: 400, - body: %{"error" => "some-error"} - }} - end) - - # click to open the github sync modal - refute has_element?(view, "#github-sync-modal") - render_click(view, "toggle_github_sync_modal") - assert has_element?(view, "#github-sync-modal") - - # submit form - view - |> form("#github-sync-modal-form") - |> render_submit(%{"github_sync" => %{"commit_message" => "some message"}}) - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: job_2.id, v: workflow.lock_version]}" - ) - - assert render(view) =~ - "Workflow saved but not synced to GitHub. Check the project GitHub connection settings" - - # modal is still present - assert has_element?(view, "#github-sync-modal") - end - - test "save and sync button on the modal is disabled when verification is still going on", - %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - assert view |> page_title() =~ workflow.name - - job_2 = workflow.jobs |> Enum.at(1) - - view |> select_node(job_2, workflow.lock_version) - - new_job_name = "My Other Job" - - assert view |> fill_job_fields(job_2, %{name: new_job_name}) =~ - new_job_name - - refute view |> save_is_disabled?() - - # let return ok with the limitter - stub( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - fn _action, _context -> - :ok - end - ) - - stub(Lightning.Tesla.Mock, :call, fn - %{url: "https://api.github.com/app/installations" <> _rest}, _opts -> - # sleep to block the async task - Process.sleep(5000) - - {:ok, - %Tesla.Env{ - status: 201, - body: %{"token" => "some-token"} - }} - end) - - # click to open the github sync modal - refute has_element?(view, "#github-sync-modal") - render_click(view, "toggle_github_sync_modal") - assert has_element?(view, "#github-sync-modal") - - assert view - |> element("button#submit-btn-github-sync-modal") - |> render() =~ "disabled=\"disabled\"" - end - end - - describe "Allow low priority access users to retry steps and create workorders" do - setup do - project = insert(:project) - - high_priority_user = - insert(:user, - email: "amy@openfn.org", - first_name: "Amy", - last_name: "Ly" - ) - - low_priority_user = - insert(:user, - email: "ana@openfn.org", - first_name: "Ana", - last_name: "Ba" - ) - - insert(:project_user, - project: project, - user: high_priority_user, - role: :admin - ) - - insert(:project_user, - project: project, - user: low_priority_user, - role: :admin - ) - - workflow = insert(:simple_workflow, project: project) - - {:ok, snapshot} = Snapshot.create(workflow) - - %{jobs: [job], triggers: [trigger]} = workflow - - [input_dataclip, output_dataclip] = - insert_list(2, :dataclip, - body: %{player: "sadio mane"}, - project: workflow.project - ) - - %{runs: [run]} = - insert(:workorder, - trigger: trigger, - dataclip: input_dataclip, - workflow: workflow, - snapshot: snapshot, - state: :success, - runs: [ - build(:run, - starting_trigger: trigger, - dataclip: input_dataclip, - steps: [ - build(:step, - input_dataclip: input_dataclip, - output_dataclip: output_dataclip, - job: job, - inserted_at: Timex.now() |> Timex.shift(seconds: -10), - started_at: Timex.now() |> Timex.shift(seconds: -10), - snapshot: snapshot - ) - ], - inserted_at: Timex.now() |> Timex.shift(seconds: -12), - snapshot: snapshot, - state: :success - ) - ] - ) - - %{ - project: project, - high_priority_user: high_priority_user, - low_priority_user: low_priority_user, - workflow: workflow, - snapshot: snapshot, - run: run, - job: job - } - end - - test "Users with low priority access to the workflow canvas will automatically be locked in a snapshot when the high prior uses saves the workflow", - %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot, - run: run, - job: job, - high_priority_user: high_priority_user, - low_priority_user: low_priority_user - } do - {high_priority_view, low_priority_view} = - access_views( - conn, - project, - workflow, - run, - job, - high_priority_user, - low_priority_user - ) - - assert high_priority_view - |> has_element?("#inspector-workflow-version", "latest") - - assert low_priority_view - |> has_element?("#inspector-workflow-version", "latest") - - high_priority_view |> select_node(%{id: job.id}) - - high_priority_view |> click_edit(%{id: job.id}) - - high_priority_view |> change_editor_text("Job expression 1") - - trigger_save(high_priority_view) - - assert high_priority_view - |> has_element?("#inspector-workflow-version", "latest") - - refute_eventually( - low_priority_view - |> has_element?("#inspector-workflow-version", "latest"), - 30_000 - ) - - assert low_priority_view - |> has_element?( - "#inspector-workflow-version", - "#{String.slice(snapshot.id, 0..6)}" - ) - - assert low_priority_view |> render() =~ - "This workflow has been updated. You're no longer on the latest version." - - workflow = Repo.reload(workflow) - - assert workflow.lock_version == snapshot.lock_version + 1 - end - end - - describe "run viewer" do - test "user can toggle their preferred log levels", %{ - conn: conn, - project: project, - user: user - } do - %{triggers: [trigger], jobs: [job_1 | _rest]} = - workflow = insert(:simple_workflow, project: project) |> with_snapshot() - - workflow = Lightning.Repo.reload(workflow) - - snapshot = Lightning.Workflows.Snapshot.get_current_for(workflow) - - dataclip = build(:http_request_dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - state: "failed", - error_type: "CompileError", - dataclip: dataclip, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: dataclip, - exit_reason: "fail", - error_type: "CompileError", - started_at: DateTime.utc_now(), - finished_at: DateTime.utc_now() - ) - ] - ) - - insert(:log_line, run: run) - insert(:log_line, run: run, step: hd(run.steps)) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{%{a: run.id, m: "expand", s: job_1.id}}", - on_error: :raise - ) - - run_view = find_live_child(view, "run-viewer-#{run.id}") - - render_async(run_view) - - assert run_view - |> render() - |> Floki.parse_fragment!() - |> Floki.find("span.hero-adjustments-vertical + span") - |> Floki.text() == - "info" - - # when the user has not set their preference, we assume they want info - assert user.preferences["desired_log_level"] |> is_nil() - log_viewer = run_view |> element("#run-log-#{run.id}") - - # info log level is set in the viewer element - assert log_viewer_selected_level(log_viewer) == "info" - - # try choosing another level - for log_level <- ["debug", "info", "error", "warn"] do - run_view - |> element("#run-log-#{run.id}-filter-dropdown-#{log_level}-option") - |> render_click(%{}) - - # selected level is set in the viewer - assert log_viewer_selected_level(log_viewer) == log_level - - # the preference is saved with expected levels - updated_user = Repo.reload(user) - assert updated_user.preferences["desired_log_level"] == log_level - end - end - end - - describe "new manual run" do - test "gets latest selectable dataclips", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - limit = 4 - search_text = "" - - dataclips = - Enum.map(1..5, fn i -> - insert(:dataclip, - body: %{"body-field" => "body-value#{i}"}, - request: %{"headers" => "list"}, - type: :http_request, - inserted_at: DateTime.add(DateTime.utc_now(), i, :millisecond) - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - end) - |> Enum.sort_by(& &1.inserted_at, :desc) - |> Enum.take(limit) - - render_hook(view, "search-selectable-dataclips", %{ - "job_id" => job.id, - "search_text" => search_text, - "limit" => limit - }) - - assert_reply(view, %{dataclips: ^dataclips}) - end - - test "searches for dataclips by uuid", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - dataclip = - insert(:dataclip, - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :step_result - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=#{Ecto.UUID.generate()}", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: []}) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=#{dataclip.id}", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [^dataclip]}) - end - - test "searches for dataclips by uuid prefix", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - dataclip = - insert(:dataclip, - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :step_result - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=#{Ecto.UUID.generate()}", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: []}) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=#{String.slice(dataclip.id, 0..3)}", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [^dataclip]}) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=#{String.slice(dataclip.id, 0..3)}", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [^dataclip]}) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => - "query=#{String.slice(dataclip.id, 0..1)}&type=step_result", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [^dataclip]}) - end - - test "searches for dataclips by type", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - insert(:dataclip, - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :step_result - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - limit = 3 - - dataclips = - Enum.map(1..5, fn i -> - insert(:dataclip, - body: %{"body-field" => "body-value#{i}"}, - request: %{"headers" => "list"}, - type: :http_request - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - end) - |> Enum.sort_by(& &1.inserted_at, :desc) - |> Enum.take(limit) - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "type=http_request", - "limit" => limit - } - ) - - assert_reply(view, %{dataclips: ^dataclips}) - end - - test "searches for dataclips created after a datetime", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - datetime_param = DateTime.utc_now() - - dataclips = - Enum.map(-1..5, fn i -> - type = if rem(i, 2) == 0, do: :http_request, else: :step_result - request = if type == :http_request, do: %{"headers" => "list"} - - insert(:dataclip, - body: %{"body-field" => "body-value#{i}"}, - request: request, - type: type, - inserted_at: DateTime.add(datetime_param, i, :minute) - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - end) - |> Enum.sort_by(& &1.inserted_at, :desc) - |> Enum.take(5) - - search_text = - %{ - "after" => - DateTime.to_iso8601(datetime_param |> DateTime.add(1, :minute)) - |> String.slice(0..15) - } - |> URI.encode_query() - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => search_text, - "limit" => 10 - } - ) - - assert_reply(view, %{dataclips: ^dataclips}) - end - - test "searches for dataclips created after a date", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - starting_datetime = ~N[2025-05-15 00:00:00] - - dataclips = - Enum.map(-1..5, fn i -> - type = if rem(i, 2) == 0, do: :http_request, else: :step_result - request = if type == :http_request, do: %{"headers" => "list"} - - insert(:dataclip, - body: %{"body-field" => "body-value#{i}"}, - request: request, - type: type, - inserted_at: NaiveDateTime.add(starting_datetime, i * 5, :minute) - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - end) - |> Enum.drop(1) - |> Enum.sort_by(& &1.inserted_at, :desc) - - search_text = - %{ - "after" => - starting_datetime - |> NaiveDateTime.to_iso8601() - |> String.slice(0..15) - } - |> URI.encode_query() - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => search_text, - "limit" => 10 - } - ) - - assert_reply(view, %{dataclips: ^dataclips}) - end - - test "searches for dataclips from one type created after a date", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - starting_datetime = ~N[2025-05-15 00:00:00] - - dataclips = - Enum.map(-1..5, fn i -> - type = if rem(i, 2) == 0, do: :http_request, else: :step_result - request = if type == :http_request, do: %{"headers" => "list"} - - insert(:dataclip, - body: %{"body-field" => "body-value#{i}"}, - request: request, - type: type, - inserted_at: NaiveDateTime.add(starting_datetime, i * 5, :minute) - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - |> then(fn %{body: body, request: request} = dataclip -> - dataclip - |> Repo.reload!() - |> restore_listed(body, request) - |> then(&%{&1 | body: nil}) - end) - end) - |> Enum.drop(1) - |> Enum.sort_by(& &1.inserted_at, :desc) - |> Enum.filter(&(&1.type == :step_result)) - - search_text = - %{ - "after" => - NaiveDateTime.to_iso8601(starting_datetime) - |> String.slice(0..15), - "type" => "step_result" - } - |> URI.encode_query() - - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => search_text, - "limit" => 10 - } - ) - - assert_reply(view, %{dataclips: ^dataclips}) - end - - test "gets run step and input dataclip", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create a dataclip - dataclip = - insert(:dataclip, - body: %{"input-field" => "input-value"}, - request: %{"headers" => "list"}, - type: :http_request - ) - - work_order = insert(:workorder, workflow: workflow, dataclip: dataclip) - - # Create run with step in one go using the factory pattern - run = - insert(:run, - workflow: workflow, - starting_job: job, - dataclip: dataclip, - work_order: work_order, - steps: [ - build(:step, job: job, input_dataclip: dataclip) - ] - ) - - expected_dataclip = - dataclip - |> Repo.reload!() - |> restore_listed(%{"input-field" => "input-value"}, %{ - "headers" => "list" - }) - |> then(&%{&1 | body: nil}) - - expected_step_id = hd(run.steps).id - - render_hook(view, "get-run-step-and-input-dataclip", %{ - "run_id" => run.id, - "job_id" => job.id - }) - - assert_reply(view, %{ - dataclip: ^expected_dataclip, - run_step: %{id: ^expected_step_id} - }) - end - - test "returns nil when no dataclip found for run and job", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create a dataclip for the run (required by schema) - dataclip = insert(:dataclip, body: %{"some" => "data"}) - - # Create a run with a dataclip but no step for the specific job - work_order = insert(:workorder, workflow: workflow, dataclip: dataclip) - - run = - insert(:run, - workflow: workflow, - starting_job: job, - dataclip: dataclip, - work_order: work_order - ) - - # Intentionally not creating any step for this job to test the nil case - - render_hook(view, "get-run-step-and-input-dataclip", %{ - "run_id" => run.id, - "job_id" => job.id - }) - - assert_reply(view, %{dataclip: nil, run_step: nil}) - end - - test "creates run from start job", %{ - conn: conn, - project: project, - test: test - } do - %{jobs: [job_1, _job_2 | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy", - on_error: :raise - ) - - body = Jason.encode!(%{test: test}) - - refute Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_1.id) - - render_click(view, "manual_run_submit", %{ - manual: %{body: body}, - from_start: true - }) - - assert created_run = - Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_1.id) - - assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}") - end - - test "creates run from specific job", %{ - conn: conn, - project: project, - test: test - } do - %{jobs: [_job_1, job_2 | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy", - on_error: :raise - ) - - body = Jason.encode!(%{test: test}) - - refute Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_2.id) - - render_click(view, "manual_run_submit", %{ - manual: %{body: body}, - from_job: job_2.id - }) - - assert created_run = - Lightning.Repo.get_by(Lightning.Run, starting_job_id: job_2.id) - - assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}") - end - - test "can rerun", - %{conn: conn, project: project, user: user} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - # Create a dataclip - dataclip = - insert(:dataclip, - body: %{"input-field" => "input-value"}, - request: %{"headers" => "list"}, - type: :http_request - ) - - work_order = insert(:workorder, workflow: workflow, dataclip: dataclip) - - # Create run with step in one go using the factory pattern - run = - insert(:run, - workflow: workflow, - starting_job: job, - dataclip: dataclip, - work_order: work_order, - steps: [ - build(:step, job: job, input_dataclip: dataclip) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job.id, a: run.id, m: "workflow_input"]}", - on_error: :raise - ) - - render_hook(view, "rerun", %{ - "run_id" => run.id, - "step_id" => hd(run.steps).id, - "via" => "job_panel" - }) - - created_run = Lightning.Repo.get_by(Lightning.Run, created_by_id: user.id) - - assert_redirected(view, ~p"/projects/#{project}/runs/#{created_run}") - end - - test "searches for dataclips by name prefix", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create named dataclips - %{id: named_dataclip_id} = - insert(:dataclip, - name: "My Test Dataclip", - body: %{"body-field" => "body-value"}, - type: :http_request, - project: project - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - %{id: other_named_dataclip_id} = - insert(:dataclip, - name: "Another Dataclip", - body: %{"body-field" => "body-value2"}, - type: :http_request, - project: project - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - # Create dataclip without name - insert(:dataclip, - name: nil, - body: %{"body-field" => "body-value3"}, - type: :http_request, - project: project - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - # Test searching by name prefix "My" - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=My", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [%{id: ^named_dataclip_id}]}) - - # Test searching by name prefix "Another" - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=Another", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [%{id: ^other_named_dataclip_id}]}) - - # Test case insensitive search - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=my", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: [%{id: ^named_dataclip_id}]}) - - # Test no matches - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "query=nonexistent", - "limit" => 5 - } - ) - - assert_reply(view, %{dataclips: []}) - end - - test "searches for dataclips using named_only filter", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create named dataclips - %{id: named_dataclip1_id} = - insert(:dataclip, - name: "First Named", - body: %{"body-field" => "body-value1"}, - request: %{"headers" => "list"}, - type: :http_request - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - %{id: named_dataclip2_id} = - insert(:dataclip, - name: "Second Named", - body: %{"body-field" => "body-value2"}, - request: %{"headers" => "list"}, - type: :http_request - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - # Create dataclips without names - insert(:dataclip, - name: nil, - body: %{"body-field" => "body-value3"}, - request: %{"headers" => "list"}, - type: :http_request - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - insert(:dataclip, - name: nil, - body: %{"body-field" => "body-value4"}, - request: %{"headers" => "list"}, - type: :http_request - ) - |> tap(&insert(:step, input_dataclip: &1, job: job)) - - # Test named_only filter - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "named_only=true", - "limit" => 10 - } - ) - - # Should return only named dataclips, ordered by inserted_at desc - assert_reply(view, %{ - dataclips: [%{id: ^named_dataclip2_id}, %{id: ^named_dataclip1_id}] - }) - - # Test without named_only filter - should return all dataclips - render_hook( - view, - "search-selectable-dataclips", - %{ - "job_id" => job.id, - "search_text" => "", - "limit" => 10 - } - ) - - # Should return all 4 dataclips - assert_reply(view, %{dataclips: dataclips}) - assert length(dataclips) == 4 - end - - test "update-dataclip-name event fails when user cannot edit workflow", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - # Set up user with viewer permission - {conn, _user} = setup_project_user(conn, project, :viewer) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create a dataclip - dataclip = - insert(:dataclip, - name: "Original Name", - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :http_request - ) - - # Try to update the dataclip name - render_hook( - view, - "update-dataclip-name", - %{ - "dataclip_id" => dataclip.id, - "name" => "New Name" - } - ) - - # Should return error message - assert_reply(view, %{ - error: "You are not authorized to perform this action" - }) - - # Verify dataclip name was not changed in database - updated_dataclip = Lightning.Repo.reload!(dataclip) - assert updated_dataclip.name == "Original Name" - end - - test "update-dataclip-name event fails when dataclip name is already in use", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create a dataclip - dataclip = - insert(:dataclip, - name: "Original Name", - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :http_request, - project: project - ) - - another_dataclip = - insert(:dataclip, - name: "Another Name", - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :http_request, - project: project - ) - - # Try to update the dataclip name - render_hook( - view, - "update-dataclip-name", - %{ - "dataclip_id" => dataclip.id, - "name" => another_dataclip.name - } - ) - - # Should return error message - assert_reply(view, %{ - error: "dataclip name already in use" - }) - - # Verify dataclip name was not changed in database - updated_dataclip = Lightning.Repo.reload!(dataclip) - assert updated_dataclip.name == "Original Name" - end - - test "update-dataclip-name event updates dataclip name successfully", - %{conn: conn, project: project} do - %{jobs: [job | _rest]} = - workflow = insert(:complex_workflow, project: project) - - Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand"]}", - on_error: :raise - ) - - # Create a dataclip - dataclip = - insert(:dataclip, - name: "Original Name", - body: %{"body-field" => "body-value"}, - request: %{"headers" => "list"}, - type: :http_request - ) - - # Update the dataclip name - assert render_hook( - view, - "update-dataclip-name", - %{ - "dataclip_id" => dataclip.id, - "name" => "New Name" - } - ) =~ "Label created. Dataclip will be saved permanently" - - # Should return updated dataclip - assert_reply(view, %{dataclip: %{name: "New Name"}}) - - # Verify dataclip name was changed in database - updated_dataclip = Lightning.Repo.reload!(dataclip) - assert updated_dataclip.name == "New Name" - - audit = - Lightning.Repo.get_by(Lightning.Auditing.Audit, - event: "label_created", - item_id: dataclip.id - ) - - assert match?( - %{ - before: %{"name" => "Original Name"}, - after: %{"name" => "New Name"} - }, - audit.changes - ) - - # clear the dataclip name - assert render_hook( - view, - "update-dataclip-name", - %{ - "dataclip_id" => dataclip.id, - "name" => "" - } - ) =~ - "Label deleted. Dataclip will be purged when your retention policy limit is reached" - - audit = - Lightning.Repo.get_by(Lightning.Auditing.Audit, - event: "label_deleted", - item_id: dataclip.id - ) - - assert match?( - %{ - before: %{"name" => "New Name"}, - after: %{"name" => nil} - }, - audit.changes - ) - end - end - - describe "get-current-state event" do - setup :create_workflow - - test "returns workflow params when no run is selected", %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{ - workflow_params: %{}, - run_steps: %{ - start_from: nil, - steps: [], - isTrigger: true, - inserted_at: nil - }, - run_id: nil, - history: [] - }) - end - - test "returns workflow params with run steps and history when run is selected", - %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot - } do - %{triggers: [trigger], jobs: [job | _]} = workflow - - dataclip = insert(:dataclip, project: project, body: %{"test" => "data"}) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - state: :success, - last_activity: DateTime.utc_now() - ) - - started_at = DateTime.utc_now() |> DateTime.add(-60, :second) - finished_at = DateTime.utc_now() |> DateTime.add(-30, :second) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot, - state: :success, - started_at: started_at, - finished_at: finished_at, - inserted_at: started_at - ) - - insert(:step, - job: job, - # Pass as a list - runs: [run], - snapshot: snapshot, - input_dataclip: dataclip, - started_at: started_at, - finished_at: finished_at, - exit_reason: "success", - error_type: nil - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{ - workflow_params: _workflow_params, - run_steps: run_steps, - run_id: run_id, - history: history - }) - - assert run_id == run.id - assert run_steps.start_from == trigger.id - assert run_steps.isTrigger == true - assert run_steps.inserted_at == started_at - assert run_steps.run_by == nil - - assert length(run_steps.steps) == 1 - [step_data] = run_steps.steps - assert step_data.job_id == job.id - assert step_data.error_type == nil - assert step_data.exit_reason == "success" - assert step_data.started_at == started_at - assert step_data.finished_at == finished_at - - assert length(history) == 1 - [work_order_data] = history - assert work_order_data.id == work_order.id - assert work_order_data.version == snapshot.lock_version - assert work_order_data.state == :success - assert work_order_data.last_activity == work_order.last_activity - - assert length(work_order_data.runs) == 1 - [run_data] = work_order_data.runs - assert run_data.id == run.id - assert run_data.state == :success - assert run_data.error_type == nil - assert run_data.started_at == started_at - assert run_data.finished_at == finished_at - end - - test "returns run steps with created_by user email when present", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot, - user: user - } do - %{triggers: [trigger], jobs: [job | _]} = workflow - - dataclip = insert(:dataclip, project: project, body: %{"test" => "data"}) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot, - created_by: user - ) - - insert(:step, job: job, runs: [run], snapshot: snapshot) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{run_steps: %{run_by: email}}) - assert email == user.email - end - - test "handles job-started runs correctly", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot - } do - %{jobs: [job | _]} = workflow - - dataclip = insert(:dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_job: job, - starting_trigger: nil, - dataclip: dataclip, - snapshot: snapshot - ) - - insert(:step, job: job, runs: [run], snapshot: snapshot) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{run_steps: run_steps}) - assert run_steps.start_from == job.id - assert run_steps.isTrigger == false - end - - test "handles runs with multiple steps and error states", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot - } do - %{triggers: [trigger], jobs: [job1, job2 | _]} = workflow - - dataclip = insert(:dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - state: :failed - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot, - state: :failed, - error_type: "RuntimeError" - ) - - insert(:step, - job: job1, - runs: [run], - snapshot: snapshot, - exit_reason: "success", - error_type: nil - ) - - insert(:step, - job: job2, - runs: [run], - snapshot: snapshot, - exit_reason: "fail", - error_type: "RuntimeError" - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job1, m: "history"]}", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{run_steps: %{steps: steps}}) - assert length(steps) == 2 - - [step1, step2] = steps - assert step1.exit_reason == "success" - assert step1.error_type == nil - - assert step2.exit_reason == "fail" - assert step2.error_type == "RuntimeError" - end - - test "returns multiple work orders in history", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot1, - user: user - } do - %{triggers: [trigger], jobs: [job | _]} = workflow - - {:ok, updated_workflow} = - Workflows.change_workflow(workflow, %{name: "Updated Workflow"}) - |> Workflows.save_workflow(user) - - snapshot2 = Lightning.Workflows.Snapshot.get_current_for(updated_workflow) - - dataclip = insert(:dataclip, project: project) - - work_order1 = - insert(:workorder, - workflow: updated_workflow, - snapshot: snapshot1, - dataclip: dataclip, - state: :success - ) - - run1 = - insert(:run, - work_order: work_order1, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot1, - state: :success - ) - - work_order2 = - insert(:workorder, - workflow: updated_workflow, - snapshot: snapshot2, - dataclip: dataclip, - state: :pending - ) - - run2 = - insert(:run, - work_order: work_order2, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot2, - state: :started - ) - - insert(:step, job: job, runs: [run1], snapshot: snapshot1) - insert(:step, job: job, runs: [run2], snapshot: snapshot2) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{updated_workflow}/legacy?#{[a: run2, s: job, m: "expand"]}", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_reply(view, %{history: history}) - assert length(history) == 2 - - versions = Enum.map(history, & &1.version) - assert snapshot1.lock_version in versions - assert snapshot2.lock_version in versions - end - - test "canvas is disabled when appropriate", %{ - conn: conn, - project: project - } do - workflow = - insert(:simple_workflow, - project: project, - deleted_at: DateTime.utc_now() - ) - - {:ok, _snapshot} = Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy", - on_error: :raise - ) - - render_hook(view, "get-current-state", %{}) - - assert_push_event(view, "set-disabled", %{disabled: true}) - end - end - - describe "run selection history mode" do - setup :create_workflow - - test "loads historical run data when accessing history mode", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot - } do - %{triggers: [trigger], jobs: [job | _]} = workflow - - dataclip = insert(:dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot - ) - - insert(:step, - job: job, - runs: [run], - snapshot: snapshot, - exit_reason: "success" - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?m=history&v=#{snapshot.lock_version}&a=#{run.id}&s=#{job.id}" - ) - - assert_push_event(view, "patch-runs", %{ - run_id: run_id, - run_steps: run_steps - }) - - assert run_id == run.id - assert run_steps.start_from == trigger.id - assert length(run_steps.steps) == 1 - end - - test "handles history mode without selected job", %{ - conn: conn, - project: project, - workflow: workflow, - snapshot: snapshot - } do - %{triggers: [trigger]} = workflow - - dataclip = insert(:dataclip, project: project) - - work_order = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip - ) - - run = - insert(:run, - work_order: work_order, - starting_trigger: trigger, - dataclip: dataclip, - snapshot: snapshot - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?m=history&v=#{snapshot.lock_version}&a=#{run.id}" - ) - - expected_run_id = run.id - assert_push_event(view, "patch-runs", %{run_id: actual_run_id}) - assert actual_run_id == expected_run_id - end - - test "canvas is disabled when workflow is deleted", %{ - conn: conn, - project: project - } do - workflow = - insert(:simple_workflow, - project: project, - deleted_at: DateTime.utc_now() - ) - - {:ok, _snapshot} = Lightning.Workflows.Snapshot.create(workflow) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - assert_push_event(view, "set-disabled", %{disabled: true}) - end - end - - defp log_viewer_selected_level(log_viewer) do - log_viewer - |> render() - |> Floki.parse_fragment!() - |> Floki.attribute("data-log-level") - |> hd() - end - - defp access_views( - conn, - project, - workflow, - run, - job, - high_priority_user, - low_priority_user - ) do - {:ok, high_priority_view, _html} = - live( - log_in_user(conn, high_priority_user), - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}", - on_error: :raise - ) - - {:ok, low_priority_view, _html} = - live( - log_in_user(conn, low_priority_user), - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[a: run, s: job, m: "expand"]}", - on_error: :raise - ) - - {high_priority_view, low_priority_view} - end - - defp restore_listed(%{type: :http_request} = dataclip, body, request) do - dataclip - |> Map.put(:body, %{"data" => body, "request" => request}) - |> Map.put(:request, nil) - end - - defp restore_listed(dataclip, body, _request) do - dataclip - |> Map.put(:body, body) - |> Map.put(:request, nil) - end - - describe "collaborative editor toggle" do - setup :create_workflow - - test "shows collaborative editor toggle when experimental features enabled", - %{ - conn: conn, - user: user, - project: project, - workflow: workflow - } do - # Enable experimental features for the user - user_with_experimental = - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_experimental) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - # Should show the beaker icon toggle - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "shows collaborative editor toggle without experimental features", - %{ - conn: conn, - project: project, - workflow: workflow - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - # Should show the toggle (no longer gated by experimental features) - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "shows collaborative editor toggle on non-latest snapshots", %{ - conn: conn, - user: user, - project: project, - workflow: workflow, - snapshot: snapshot - } do - # Create a new snapshot to make the original non-latest - job_attrs = - workflow.jobs |> Enum.map(&%{id: &1.id, name: &1.name <> " updated"}) - - {:ok, _updated_workflow} = - Workflows.change_workflow(workflow, %{jobs: job_attrs}) - |> Workflows.save_workflow(user) - - {:ok, view, _html} = - conn - |> live( - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{snapshot.lock_version}" - ) - - # Toggle is shown even on non-latest snapshots (no longer conditionally hidden) - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "shows collaborative editor toggle only on latest snapshots with experimental features", - %{ - conn: conn, - user: user, - project: project, - workflow: workflow - } do - # Enable experimental features - user_with_experimental = - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_experimental) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - # Should show toggle on latest version - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "navigates to collaborative editor when toggle clicked and saves preference", - %{ - conn: conn, - user: user, - project: project, - workflow: workflow - } do - # Enable experimental features - user_with_experimental = - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_experimental) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - # Click the collaborative editor toggle - view - |> element("button[id*='deprecated']") - |> render_click() - - # Should navigate to collaborative editor route - assert_redirect( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}" - ) - - # Verify preference was saved - updated_user = Lightning.Repo.reload(user_with_experimental) - assert updated_user.preferences["prefer_legacy_editor"] == false - end - - test "toggle has correct styling and accessibility", %{ - conn: conn, - user: user, - project: project, - workflow: workflow - } do - # Enable experimental features - user_with_experimental = - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_experimental) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - toggle_element = - view - |> element("button[id*='deprecated']") - - toggle_html = render(toggle_element) - - # Check accessibility (aria-label) - assert toggle_html =~ "Click to switch" - end - - test "preserves existing experimental features preferences", %{ - conn: conn, - user: user, - project: project, - workflow: workflow - } do - # Set up user with experimental features and other preferences - user_with_prefs = - user - |> Ecto.Changeset.change(%{ - preferences: %{ - "experimental_features" => true, - "existing_pref" => "value", - "another_setting" => false - } - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_prefs) - |> live(~p"/projects/#{project.id}/w/#{workflow.id}/legacy") - - # Should show toggle - assert has_element?( - view, - "button[id*='deprecated']" - ) - - # Verify all preferences are preserved - updated_user = Repo.reload(user_with_prefs) - assert updated_user.preferences["experimental_features"] == true - assert updated_user.preferences["existing_pref"] == "value" - assert updated_user.preferences["another_setting"] == false - end - - test "shows collaborative editor toggle when creating new workflow with experimental features", - %{ - conn: conn, - user: user, - project: project - } do - # Enable experimental features - user_with_experimental = - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Repo.update!() - - {:ok, view, _html} = - conn - |> log_in_user(user_with_experimental) - |> live(~p"/projects/#{project.id}/w/new/legacy") - - # Should show the beaker icon toggle even on new workflow page - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "shows collaborative editor toggle when creating new workflow", - %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Should show the toggle (no longer gated by experimental features) - assert has_element?( - view, - "button[id*='deprecated']" - ) - end - - test "shows collaborative editor toggle in job inspector with experimental features", - %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - # Enable experimental features for user - user - |> Ecto.Changeset.change(%{ - preferences: %{"experimental_features" => true} - }) - |> Lightning.Repo.update!() - - job = insert(:job, workflow: workflow, name: "test-job") - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - assert html =~ "Click to switch" - end - - test "shows collaborative editor toggle in job inspector without experimental features", - %{ - conn: conn, - project: project, - workflow: workflow, - user: _user - } do - job = insert(:job, workflow: workflow, name: "test-job") - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - # Should show beaker icon in job inspector (no longer gated by experimental features) - assert html =~ "Click to switch" - end - end - - describe "sandbox indicator banner" do - test "shows banner when viewing workflow in sandbox project", %{conn: conn} do - user = insert(:user) - parent_project = insert(:project, name: "Production Project") - - sandbox = - insert(:sandbox, - parent: parent_project, - name: "test-sandbox", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: sandbox.id) - job = insert(:job, workflow: workflow, name: "test-job") - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{sandbox.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - # Banner only shows in inspector, not on canvas - assert html =~ "You are currently working in the sandbox" - assert html =~ sandbox.name - # No "Switch to parent project" link per Joe's feedback - refute html =~ "Switch to Production Project" - end - - test "does not show banner when viewing workflow in root project", %{ - conn: conn - } do - user = insert(:user) - - project = - insert(:project, - name: "Production Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{workflow.lock_version}" - ) - - refute html =~ "You are currently working in the sandbox" - # Root projects should not show sandbox-related switch links - refute html =~ "working in the sandbox" - end - - test "shows correct root project in deeply nested sandbox", %{conn: conn} do - user = insert(:user) - root_project = insert(:project, name: "Root Project") - - sandbox_a = - insert(:sandbox, - parent: root_project, - name: "sandbox-a", - project_users: [%{user_id: user.id, role: :owner}] - ) - - sandbox_b = - insert(:sandbox, - parent: sandbox_a, - name: "sandbox-b", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: sandbox_b.id) - job = insert(:job, workflow: workflow, name: "test-job") - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{sandbox_b.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - # Banner shows in inspector with current sandbox name - assert html =~ "You are currently working in the sandbox" - assert html =~ sandbox_b.name - # No "Switch to parent project" link per Joe's feedback - refute html =~ "Switch to Root Project" - end - - test "shows banner in job inspector when editing job in sandbox", %{ - conn: conn - } do - user = insert(:user) - parent_project = insert(:project, name: "Production Project") - - sandbox = - insert(:sandbox, - parent: parent_project, - name: "test-sandbox", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: sandbox.id) - job = insert(:job, workflow: workflow, name: "test-job") - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{sandbox.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - assert html =~ "You are currently working in the sandbox" - assert html =~ sandbox.name - # No "Switch to parent project" link per Joe's feedback - refute html =~ "Switch to Production Project" - end - - test "does not show banner in job inspector when editing job in root project", - %{conn: conn} do - user = insert(:user) - - project = - insert(:project, - name: "Production Project", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - job = insert(:job, workflow: workflow, name: "test-job") - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - refute html =~ "You are currently working in the sandbox" - # No sandbox banner should appear in root project - refute html =~ "working in the sandbox" - end - - test "shows env chip on canvas when project has env", %{conn: conn} do - user = insert(:user) - - project = - insert(:project, - name: "Production Project", - env: "production", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?v=#{workflow.lock_version}" - ) - - assert html =~ "canvas-project-env" - assert html =~ "production" - assert html =~ "Project environment is production" - end - - test "shows env chip in inspector when project has env", %{conn: conn} do - user = insert(:user) - - project = - insert(:project, - name: "Production Project", - env: "staging", - project_users: [%{user_id: user.id, role: :owner}] - ) - - workflow = workflow_fixture(project_id: project.id) - job = insert(:job, workflow: workflow, name: "test-job") - - conn = log_in_user(conn, user) - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?s=#{job.id}&m=expand&v=#{workflow.lock_version}" - ) - - assert html =~ "inspector-project-env" - assert html =~ "staging" - assert html =~ "Project environment is staging" - end - end - - defp stub_apollo_unavailable(_context) do - stub(Lightning.MockConfig, :apollo, fn key -> - case key do - :endpoint -> "http://localhost:3000" - :ai_assistant_api_key -> "test_api_key" - end - end) - - stub(Lightning.Tesla.Mock, :call, fn %{method: :get}, _opts -> - {:error, :econnrefused} - end) - - :ok - end -end diff --git a/test/lightning_web/live/workflow_live/editor_test.exs b/test/lightning_web/live/workflow_live/editor_test.exs deleted file mode 100644 index 0fdcea1d47b..00000000000 --- a/test/lightning_web/live/workflow_live/editor_test.exs +++ /dev/null @@ -1,2003 +0,0 @@ -defmodule LightningWeb.WorkflowLive.EditorTest do - use LightningWeb.ConnCase, async: true - - import ExUnit.CaptureLog - import Phoenix.LiveViewTest - import Lightning.WorkflowLive.Helpers - import Lightning.Factories - - import Ecto.Query - - alias Lightning.Auditing.Audit - alias Lightning.Invocation - alias Lightning.Workflows.Workflow - - setup :register_and_log_in_user - setup :create_project_for_current_user - setup :create_workflow - - test "can edit a jobs body", %{ - project: project, - workflow: workflow, - conn: conn - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[v: workflow.lock_version]}" - ) - - job = workflow.jobs |> List.first() - - view |> select_node(job, workflow.lock_version) - - view |> job_panel_element(job) - - assert view |> job_panel_element(job) |> render() =~ "First Job", - "can see the job name in the panel" - - view |> click_edit(job) - - assert view |> job_edit_view(job) |> has_element?(), - "can see the job_edit_view component" - end - - test "mounts the JobEditor with the correct attrs", %{ - conn: conn, - project: project, - workflow: workflow - } do - project_credential = - insert(:project_credential, - project: project, - credential: - build(:credential, - name: "dummytestcred", - schema: "http", - body: %{ - username: "test", - password: "test" - } - ) - ) - - job = workflow.jobs |> hd() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}" - ) - - {actual_attrs, inner_json} = - view - |> job_editor() - |> get_attrs_and_inner_html() - |> then(fn {attrs, inner_html} -> - {attrs, inner_html |> Jason.decode!()} - end) - - # The JobEditor component should be mounted with a resolved version number - assert job.adaptor == "@openfn/language-common@latest" - - assert {"type", "application/json"} in actual_attrs - assert {"id", "JobEditor-1"} in actual_attrs - assert {"phx-hook", "HeexReactComponent"} in actual_attrs - assert {"data-react-name", "JobEditor"} in actual_attrs - - assert inner_json["adaptor"] == "@openfn/language-common@1.6.2" - assert inner_json["source"] == job.body - assert inner_json["job_id"] == job.id - assert inner_json["disabled"] == "false" - - # try changing the assigned credential - - credential_block = - element(view, "#modal-header-credential-block") |> render() - - assert credential_block =~ "No Credential" - refute credential_block =~ project_credential.credential.name - - # This is a hack to change the project_credential_id while the inspector - # is open. (The workflow-form is not rendered when the inspector is open) - view - |> render_hook("validate", %{ - workflow: %{ - jobs: %{"0" => %{"project_credential_id" => project_credential.id}} - } - }) - - credential_block = - element(view, "#modal-header-credential-block") |> render() - - refute credential_block =~ "No Credential" - assert credential_block =~ project_credential.credential.name - end - - describe "manual runs" do - @tag role: :viewer - test "viewers can't run a job", %{ - conn: conn, - project: project, - workflow: workflow - } do - job = workflow.jobs |> hd() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}" - ) - - # dataclip dropdown is disabled - # TODO: test this in the react component - # assert view - # |> element( - # ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'][disabled]} - # ) - # |> has_element?() - - # assert view - # |> element( - # ~s{button[type='submit'][form='manual_run_form'][disabled]} - # ) - # |> has_element?() - - # Check that the liveview can handle an empty submit (dataclip dropdown is disabled) - # which happens on socket reconnects. - view |> render_change("manual_run_change") - - assert view |> render_click("manual_run_submit", %{"manual" => %{}}) =~ - "You are not authorized to perform this action." - end - - @tag skip: "component moved to react" - test "can see the last 3 dataclips", %{ - conn: conn, - project: project, - workflow: workflow - } do - job = workflow.jobs |> hd() - - dataclip_ids = - insert_list(4, :step, - job: job, - inserted_at: fn -> - ExMachina.sequence(:past_timestamp, fn i -> - DateTime.utc_now() |> DateTime.add(-i) - end) - end - ) - |> Enum.map(fn step -> - step.input_dataclip_id - end) - |> Enum.reverse() - - # wiped dataclip. This is the latest dataclip - wiped_dataclip = insert(:dataclip, body: nil, wiped_at: DateTime.utc_now()) - - insert(:step, job: job, input_dataclip: wiped_dataclip) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}" - ) - - assert view - |> element( - ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option}, - "Create a new input" - ) - |> has_element?() - - for dataclip_id <- dataclip_ids |> Enum.slice(0..2) do - assert view - |> element( - ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option}, - dataclip_id - ) - |> has_element?() - end - - # wiped dataclip is not listed despite being latest - refute view - |> element( - ~s{#manual-job-#{job.id} form select[name='manual[dataclip_id]'] option}, - wiped_dataclip.id - ) - |> has_element?() - end - - test "can create a new input dataclip", %{ - conn: conn, - project: p, - workflow: w - } do - job = w.jobs |> hd - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}" - ) - - assert Invocation.list_dataclips_for_job(job) |> Enum.count() == 0 - - body = %{"a" => 1} - - render_submit(view, "manual_run_submit", - manual: %{ - body: Jason.encode!(body) - } - ) - - assert where( - Lightning.Invocation.Dataclip, - [d], - d.body == ^body and d.type == :saved_input and - d.project_id == ^p.id - ) - |> Lightning.Repo.exists?() - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag role: :editor, skip: "component moved to react" - test "can't with a new dataclip if it's invalid", %{ - conn: conn, - project: p, - workflow: w - } do - job = w.jobs |> hd - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}" - ) - - view - |> form("#manual-job-#{job.id} form", %{ - "manual" => %{"body" => "1"} - }) - |> render_change() - - assert view - |> has_element?("#manual-job-#{job.id} form", "Must be an object") - - view - |> form("#manual-job-#{job.id} form", %{ - "manual" => %{"body" => "]"} - }) - |> render_change() - - assert view |> has_element?("#manual-job-#{job.id} form", "Invalid JSON") - end - - test "can't run if limit is exceeded", %{ - conn: conn, - project: %{id: project_id}, - workflow: w - } do - job = w.jobs |> hd - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project_id}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}" - ) - - assert Invocation.list_dataclips_for_job(job) |> Enum.count() == 0 - - Mox.stub( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - &Lightning.Extensions.StubUsageLimiter.limit_action/2 - ) - - assert view - |> render_submit("manual_run_submit", - manual: %{ - body: Jason.encode!(%{"a" => 1}) - } - ) - |> Floki.parse_fragment!() - - assert view - |> has_element?("[data-flash-kind='error']", "Runs limit exceeded") - end - - test "can run a job", %{conn: conn, project: p, workflow: w, user: user} do - job = w.jobs |> hd - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}" - ) - - assert view - |> element( - "button[type='submit'][form='manual_run_form'][disabled]" - ) - |> has_element?() - - render_change(view, "manual_run_change", %{ - "manual" => %{"body" => "{}"} - }) - - refute view - |> element( - "button[type='submit'][form='manual_run_form'][disabled]" - ) - |> has_element?() - - assert [] == live_children(view) - - render_submit(view, "manual_run_submit", %{}) - - assert [run_viewer] = live_children(view) - render_async(run_viewer) - - assert run_viewer - |> element("li:nth-child(6) dd", "Enqueued") - |> has_element?() - - # Check that manually triggered run shows the user's email as the starter - assert render(run_viewer) =~ user.email, - "shows user email as starter for manually triggered run" - end - - @tag skip: "component moved to react" - test "the new dataclip is selected after running job", %{ - conn: conn, - project: p, - workflow: w - } do - job = w.jobs |> hd - - existing_dataclip = insert(:dataclip, project: p) - - insert(:workorder, - workflow: w, - dataclip: existing_dataclip, - runs: [ - build(:run, - dataclip: existing_dataclip, - starting_job: job, - steps: [build(:step, job: job, input_dataclip: existing_dataclip)] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{p}/w/#{w}/legacy?#{[s: job, m: "expand", v: w.lock_version]}" - ) - - body = %{"val" => Ecto.UUID.generate()} - - dataclip_query = - where( - Lightning.Invocation.Dataclip, - [d], - d.type == :saved_input and - d.project_id == ^p.id - ) - - refute Lightning.Repo.exists?(dataclip_query) - refute render(view) =~ body["val"] - - view - |> form("#manual-job-#{job.id} form", %{ - manual: %{body: Jason.encode!(body)} - }) - |> render_submit() - - new_dataclip = Lightning.Repo.one(dataclip_query) - - assert view - |> dataclip_viewer("selected-dataclip-#{new_dataclip.id}") - |> has_element?() - - element = - view - |> element( - "select[name='manual[dataclip_id]'] option[value='#{new_dataclip.id}']" - ) - - assert render(element) =~ "selected" - - refute view - |> element("save-and-run", "Run") - |> has_element?() - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - test "creating a work order from a newly created job should save the workflow first", - %{ - conn: conn, - project: project - } do - workflow = - insert(:workflow, project: project) - |> Lightning.Repo.preload([:jobs, :work_orders]) - |> with_snapshot() - - new_job_name = "new job" - - assert workflow.jobs |> Enum.count() === 0 - - assert workflow.jobs |> Enum.find(fn job -> job.name === new_job_name end) === - nil - - assert workflow.work_orders |> Enum.count() === 0 - - %{"value" => %{"id" => job_id}} = - job_patch = add_job_patch(new_job_name) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - # add a job to it but don't save - view |> push_patches_to_view([job_patch]) - - view |> select_node(%{id: job_id}, workflow.lock_version) - - view |> click_edit(%{id: job_id}) - - view |> change_editor_text("some body") - - render_submit(view, "manual_run_submit", %{ - manual: %{body: Jason.encode!(%{})} - }) - - assert_patch(view) - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - - workflow = - Lightning.Repo.get!(Workflow, workflow.id) - |> Lightning.Repo.preload([:jobs, :work_orders]) - - assert workflow.jobs |> Enum.count() === 1 - - assert workflow.jobs - |> Enum.find(fn job -> job.name === new_job_name end) - |> Map.get(:name) === new_job_name - - assert workflow.work_orders |> Enum.count() === 1 - end - - test "creating a workorder from a newly created workflow and job saves the workflow first", - %{ - conn: conn, - user: user - } do - Mox.verify_on_exit!() - - project = - insert(:project, project_users: [%{user_id: user.id, role: :admin}]) - - workflow_name = "mytest workflow" - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/new/legacy", on_error: :raise) - - {view, parsed_workflow} = select_template(view, "base-webhook-template") - - view |> render_click("save") - - workflow = - Lightning.Repo.get_by(Lightning.Workflows.Workflow, - project_id: project.id - ) - - expected_workflow_name = parsed_workflow["name"] || "Event-based Workflow" - assert workflow.name == expected_workflow_name - initial_workflow_id = workflow.id - - view - |> form("#workflow-form") - |> render_change(workflow: %{name: workflow_name}) - - job_data = List.first(parsed_workflow["jobs"]) - job_id = job_data["id"] - original_job_name = job_data["name"] - - view |> select_node(%{id: job_id}) - - view |> click_edit(%{id: job_id}) - - new_job_body = "#{job_data["body"] || "default body"}" - view |> change_editor_text(new_job_body) - - workflow = - Lightning.Repo.get(Lightning.Workflows.Workflow, initial_workflow_id) - |> Lightning.Repo.preload(:work_orders) - - assert length(workflow.work_orders) == 0 - - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - fn %{type: :new_run}, _context -> :ok end - ) - - Lightning.Workflows.subscribe(project.id) - - render_submit(view, "manual_run_submit", %{ - manual: %{body: Jason.encode!(%{})} - }) - - assert_patch(view) - - live_children(view) |> Enum.each(&render_async/1) - - workflow = - Lightning.Repo.get(Lightning.Workflows.Workflow, initial_workflow_id) - |> Lightning.Repo.preload([:jobs, :work_orders]) - - assert workflow.name == workflow_name - - assert job = Enum.find(workflow.jobs, &(&1.id == job_id)) - assert job.name == original_job_name - assert job.body == new_job_body - - assert length(workflow.work_orders) == 1 - - assert_received %Lightning.Workflows.Events.WorkflowUpdated{ - workflow: %{id: ^initial_workflow_id} - } - end - - test "retry a work order saves the workflow first", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _], triggers: [trigger]} = workflow, - snapshot: snapshot - } do - Mox.verify_on_exit!() - - dataclip = insert(:dataclip, type: :http_request) - - # disable the trigger - trigger - |> Ecto.Changeset.change(%{enabled: false}) - |> Lightning.Repo.update!() - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - state: :failed, - runs: [ - build(:run, - dataclip: dataclip, - snapshot: snapshot, - starting_job: job_1, - state: :failed, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: dataclip, - output_dataclip: build(:dataclip), - exit_reason: "fail", - started_at: build(:timestamp), - finished_at: build(:timestamp) - ) - ] - ) - ] - ) - - assert job_1.body === "fn(state => { return {...state, extra: \"data\"} })" - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - view - |> change_editor_text("fn(state => state)") - - view - |> render_click("validate", %{ - "workflow" => %{"triggers" => %{"0" => %{"enabled" => true}}} - }) - - # Try retrying with an error from the limitter - error_msg = "Oopsie Doopsie! An error occured" - - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 2, - fn - %{type: :new_run}, _context -> - :ok - - %{type: :activate_workflow}, _context -> - {:error, :too_many_workflows, %{text: error_msg}} - end - ) - - html = - view - |> element("#save-and-run", "Run (Retry)") - |> render_click() - - assert html =~ error_msg - - # Retry with an ok from the limitter - Mox.expect( - Lightning.Extensions.MockUsageLimiter, - :limit_action, - 2, - fn - %{type: :new_run}, _context -> - :ok - - %{type: :activate_workflow}, _context -> - :ok - end - ) - - view - |> element("#save-and-run", "Run (Retry)") - |> render_click() - - assert_patch(view) - - workflow = - Lightning.Repo.reload(workflow) |> Lightning.Repo.preload([:jobs]) - - job_1 = workflow.jobs |> Enum.find(fn job -> job.id === job_1.id end) - assert job_1.body !== "fn(state => { return {...state, extra: \"data\"} })" - assert job_1.body === "fn(state => state)" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "selects the input dataclip for the step if a run is followed", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - input_dataclip = insert(:dataclip, project: project, type: :http_request) - - output_dataclip = - insert(:dataclip, - project: project, - type: :step_result, - body: %{"val" => Ecto.UUID.generate()} - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_job: job_1, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: input_dataclip, - output_dataclip: output_dataclip, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ), - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: output_dataclip, - output_dataclip: - build(:dataclip, - type: :step_result, - body: %{} - ), - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - # insert 3 new dataclips - dataclips = insert_list(3, :dataclip, project: project) - - # associate dataclips with job 2 - for dataclip <- dataclips do - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: dataclip, - starting_job: job_2, - steps: [ - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: dataclip, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: nil, - exit_reason: nil - ) - ] - ) - ] - ) - end - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # the step dataclip is different from the run dataclip. - # this assertion means that the run dataclip won't be selected - refute run.dataclip_id == output_dataclip.id - - # the form has the dataclips body - assert view - |> dataclip_viewer("selected-dataclip-#{output_dataclip.id}") - |> has_element?() - - # the step dataclip is selected - element = - view - |> element( - "select[name='manual[dataclip_id]'] option[value='#{output_dataclip.id}']" - ) - - assert render(element) =~ "selected" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - [run_viewer_live] = live_children(view) - render_async(run_viewer_live) - render_async(run_viewer_live) - end - - @tag skip: "component moved to react" - test "selects the input dataclip for the run if no step has been added yet", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest]} = workflow, - snapshot: snapshot - } do - input_dataclip = insert(:dataclip, project: project, type: :http_request) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_job: job_1, - steps: [] - ) - ] - ) - - # insert 3 new dataclips - dataclips = insert_list(3, :dataclip, project: project) - - # associate dataclips with job 1 - for dataclip <- dataclips do - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: dataclip, - starting_job: job_1, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: dataclip, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: nil, - exit_reason: nil - ) - ] - ) - ] - ) - end - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # the form has the dataclip - assert element(view, "#manual-job-#{job_1.id} form") |> render() =~ - input_dataclip.id - - # the run dataclip is selected - element = - view - |> element( - "select[name='manual[dataclip_id]'] option[value='#{input_dataclip.id}']" - ) - - assert render(element) =~ "selected" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "shows the body of selected dataclip correctly after retrying a workorder from a non-first step", - %{ - conn: conn, - project: project, - workflow: - %{jobs: [job_1, job_2 | _rest], triggers: [trigger]} = workflow, - snapshot: snapshot - } do - input_dataclip = insert(:dataclip, project: project, type: :http_request) - - output_dataclip = - insert(:dataclip, - project: project, - type: :step_result, - body: %{"uuid" => Ecto.UUID.generate()} - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - state: :failed, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_trigger: trigger, - state: :failed, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: input_dataclip, - output_dataclip: output_dataclip, - exit_reason: "success", - started_at: build(:timestamp), - finished_at: build(:timestamp) - ), - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: output_dataclip, - output_dataclip: build(:dataclip), - exit_reason: "fail", - started_at: build(:timestamp), - finished_at: build(:timestamp) - ) - ] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - - # retry workorder - view - |> element("#save-and-run", "Run (Retry)") - |> render_click() - - path = assert_patch(view) - - {:ok, view, _html} = live(conn, path, on_error: :raise) - - # the run input dataclip is selected - element = - view - |> element( - "select[name='manual[dataclip_id]'] option[value='#{output_dataclip.id}']" - ) - - assert render(element) =~ "selected" - - # the body is rendered correctly - form = "#manual-job-#{job_2.id} form" - - refute view |> element(form) |> render() =~ - "Input data for this step has not been retained" - - assert view - |> dataclip_viewer("selected-dataclip-#{output_dataclip.id}") - |> has_element?() - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "does not show the dataclip select input if the step dataclip is not available", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest], triggers: [trigger]} = workflow, - snapshot: snapshot - } do - input_dataclip = insert(:dataclip, project: project, type: :http_request) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_trigger: trigger, - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: nil, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # notice that we haven't wiped the run dataclip. - # This is intentional to assert that we dont EVER fallback to the run dataclip - # if we dont find a dataclip on the step - assert is_nil(input_dataclip.wiped_at) - - # the form does not contain the dataclip - form = element(view, "#manual-job-#{job_1.id} form") - refute render(form) =~ input_dataclip.id - - # the select input doesn't exist - refute has_element?(view, "select#manual_run_form_dataclip_id") - - assert render(form) =~ "data for this step has not been retained" - - # user can click to show the dataclip selector - assert has_element?(view, "#toggle_dataclip_selector_button") - - view |> element("#toggle_dataclip_selector_button") |> render_click() - - # the select input now exists - assert has_element?(view, "select[name='manual[dataclip_id]']") - - # the wiped message is no longer displayed - refute render(form) =~ "data for this step has not been retained" - - assert has_element?(view, "textarea[name='manual[body]']") - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "shows the wiped dataclip viewer if the step dataclip was wiped", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest]} = workflow, - snapshot: snapshot - } do - input_dataclip = - insert(:dataclip, - project: project, - type: :saved_input, - wiped_at: DateTime.utc_now(), - body: nil - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_job: job_1, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: input_dataclip, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # the form contains the dataclip - form = element(view, "#manual-job-#{job_1.id} form") - assert render(form) =~ input_dataclip.id - - # the select input exists - assert has_element?(view, "select[name='manual[dataclip_id]']") - - # the body says that it was wiped - assert render(form) =~ "data for this step has not been retained" - - refute has_element?(view, "textarea[name='manual[body]']"), - "dataclip body input is missing" - - # lets select the create new dataclip option - form |> render_change(manual: %{dataclip_id: nil}) - - # the dataclip textarea input now exists - assert has_element?(view, "textarea[name='manual[body]']"), - "dataclip body input exists" - - # the wiped message is no longer displayed - refute render(form) =~ "data for this step has not been retained" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "shows the missing dataclip viewer if the selected step wasn't executed in the run", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - input_dataclip = - insert(:dataclip, - project: project, - type: :saved_input, - wiped_at: DateTime.utc_now(), - body: %{} - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_job: job_1, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: input_dataclip, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # the form exists - form = element(view, "#manual-job-#{job_2.id} form") - assert has_element?(form) - - # the select input is not present - refute has_element?(view, "select[name='manual[dataclip_id]']") - # the textarea doesn not exist - refute has_element?(view, "textarea[name='manual[body]']") - - # the body says that the step wasn't run - assert render(form) =~ "This job was not/is not yet included in this Run" - - # the body does not say that it was wiped - refute render(form) =~ "data for this step has not been retained" - - refute has_element?(view, "textarea[name='manual[body]']"), - "dataclip body input is missing" - - # lets click the button to show the editor - view |> element("#toggle_dataclip_selector_button") |> render_click() - - # the dataclip textarea input now exists - assert has_element?(view, "textarea[name='manual[body]']"), - "dataclip body input exists" - - # the job not run message is no longer displayed - refute render(form) =~ "This job was not/is not yet included in this Run" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - test "users can retry a workorder from a followed run", - %{ - conn: conn, - project: project, - workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - {dataclips, %{runs: [run]} = workorder} = - rerun_setup(project, workflow, snapshot) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # user gets option to rerun - assert has_element?(view, "button", "Run (Retry)") - assert has_element?(view, "button", "Run (New Work Order)") - - # if we choose a different dataclip, the retry button disappears - render_change(view, "manual_run_change", - manual: %{dataclip_id: hd(dataclips).id} - ) - - refute has_element?(view, "button", "Run (Retry)") - assert has_element?(view, "button", "Run") - - # if we choose the step input dataclip, the retry button becomes available - step = Enum.find(run.steps, fn step -> step.job_id == job_2.id end) - - render_change(view, "manual_run_change", - manual: %{dataclip_id: step.input_dataclip_id} - ) - - assert has_element?(view, "button", "Run (Retry)") - assert has_element?(view, "button", "Run (New Work Order)") - - view |> element("button", "Run (Retry)") |> render_click() - - all_runs = - Lightning.Repo.preload(workorder, [:runs], force: true).runs - - assert Enum.count(all_runs) == 2 - - [new_run] = - Enum.reject(all_runs, fn a -> a.id == run.id end) - - html = render(view) - - # refute html =~ run.id - assert html =~ new_run.id - end - - test "can't retry when limit has been reached", - %{ - conn: conn, - project: project, - workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - {_dataclips, %{runs: [run]} = workorder} = - rerun_setup(project, workflow, snapshot) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # user gets option to rerun - assert has_element?(view, "button", "Run (Retry)") - assert has_element?(view, "button", "Run (New Work Order)") - - view |> element("button", "Run (Retry)") |> render_click() - - all_runs = - Lightning.Repo.preload(workorder, [:runs], force: true).runs - - assert Enum.count(all_runs) == 2 - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - test "can't retry when workflow has been deleted", - %{ - conn: conn, - project: project, - workflow: %{jobs: [_job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - {_dataclips, %{runs: [run]} = _workorder} = - rerun_setup(project, workflow, snapshot) - - workflow - |> Ecto.Changeset.change(%{ - deleted_at: DateTime.utc_now() |> DateTime.truncate(:second) - }) - |> Lightning.Repo.update!() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - live_children(view) |> Enum.each(&render_async/1) - - # user gets no option to rerun - assert has_element?(view, "button[disabled='disabled']", "Run (Retry)") - - assert has_element?( - view, - "button[disabled='disabled']", - "Run" - ) - - # submit event regardless - step = Enum.find(run.steps, fn step -> step.job_id == job_2.id end) - - assert render_click(view, "rerun", %{run_id: run.id, step_id: step.id}) =~ - "Cannot rerun a deleted a workflow" - end - - test "followed run with wiped dataclip renders the page correctly", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1, job_2 | _rest]} = workflow, - snapshot: snapshot - } do - wiped_dataclip = - insert(:dataclip, - project: project, - type: :http_request, - body: nil, - wiped_at: DateTime.utc_now() - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: wiped_dataclip, - state: :success, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: wiped_dataclip, - starting_job: job_1, - state: :success, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: nil, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ), - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: nil, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_2.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # user cannot rerun - refute has_element?(view, "button", "Run (Retry)") - - assert has_element?(view, "button:disabled", "Run"), - "create new workorder button is disabled" - - # Wait out all the async renders on RunViewerLive, avoiding Postgrex client - # disconnection warnings. - live_children(view) |> Enum.each(&render_async/1) - end - - @tag skip: "component moved to react" - test "selected dataclip viewer is updated correctly if dataclip is wiped", - %{ - conn: conn, - project: project, - snapshot: snapshot, - workflow: %{jobs: [job_1, _job_2 | _rest]} = workflow - } do - unique_val = "random" <> Ecto.UUID.generate() - - input_dataclip = - insert(:dataclip, - project: project, - type: :saved_input, - body: %{"foo" => unique_val} - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - dataclip: input_dataclip, - state: :running, - snapshot: snapshot, - runs: [ - build(:run, - dataclip: input_dataclip, - snapshot: snapshot, - starting_job: job_1, - state: :started - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # dataclip body is displayed - assert view - |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}") - |> has_element?() - - html = view |> element("#manual-job-#{job_1.id}") |> render() - refute html =~ "data for this step has not been retained" - - # let's subscribe to events to make sure we're in sync with liveview - # Lightning.Runs.subscribe(run) - - # start step without dataclip - {:ok, %{id: step_id}} = - Lightning.Runs.start_step(run, %{ - "job_id" => job_1.id, - "step_id" => Ecto.UUID.generate() - }) - - assert_received %Lightning.Runs.Events.StepStarted{ - step: %{id: ^step_id} - } - - # dataclip body is still present - assert view - |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}") - |> has_element?() - - # lets wipe the dataclip - Lightning.Runs.wipe_dataclips(run) - - dataclip_id = input_dataclip.id - - assert_received %Lightning.Runs.Events.DataclipUpdated{ - dataclip: %{id: ^dataclip_id} - } - - # make sure that the event is processed by liveview - render(view) - - # dataclip body is nolonger present - refute view - |> dataclip_viewer("selected-dataclip-#{input_dataclip.id}") - |> has_element?(), - "dataclip body has been removed" - - html = view |> element("#manual-job-#{job_1.id}") |> render() - assert html =~ "data for this step has not been retained" - end - - test "audits snapshot creation", %{ - conn: conn, - project: project, - user: %{id: user_id} - } do - workflow = - insert(:workflow, project: project) - |> Lightning.Repo.preload([:jobs, :work_orders]) - |> with_snapshot() - - new_job_name = "new job" - - %{"value" => %{"id" => job_id}} = - job_patch = add_job_patch(new_job_name) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[v: workflow.lock_version]}", - on_error: :raise - ) - - # add a job to it but don't save - view |> push_patches_to_view([job_patch]) - - view |> select_node(%{id: job_id}, workflow.lock_version) - - view |> click_edit(%{id: job_id}) - - view |> change_editor_text("some body") - - # Clear any audit entries that may have been created by fixtures - - Repo.delete_all(Audit) - - render_submit(view, "manual_run_submit", %{ - manual: %{body: Jason.encode!(%{})} - }) - - audit = Audit |> Repo.one() - - assert %{event: "snapshot_created", actor_id: ^user_id} = audit - end - - test "followed crashed run without steps renders the page correctly", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest]} = workflow, - snapshot: snapshot - } do - dataclip = - insert(:dataclip, - project: project, - type: :http_request - ) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: dataclip, - starting_job: job_1, - claimed_at: build(:timestamp), - finished_at: build(:timestamp), - started_at: nil, - state: :crashed, - error_type: "CompileError", - steps: [] - ) - ] - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # user cannot rerun - refute has_element?(view, "button", "Run (Retry)") - - # user can create new work order - assert has_element?(view, "button", "Run") - - run_view = find_live_child(view, "run-viewer-#{run.id}") - - render_async(run_view) - - # input panel shows correct information - html = run_view |> element("div#input-panel") |> render() - assert html =~ "No input/output available. This step was never started." - - # output panel shows correct information - html = run_view |> element("div#output-panel") |> render() - assert html =~ "No input/output available. This step was never started." - end - - test "viewer is updated correctly if manual run crashes", - %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest]} = workflow - } do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - # action button is rendered correctly - refute has_element?(view, "button", "Run (Retry)") - refute has_element?(view, "button", "Processing") - assert has_element?(view, "button", "Run") - - # submit the manual run form - render_submit(view, "manual_run_submit", %{ - manual: %{body: "{}"} - }) - - uri = view |> assert_patch() |> URI.parse() - run_id = Plug.Conn.Query.decode(uri.query)["a"] - run = Lightning.Repo.get!(Lightning.Run, run_id) - - # Get the Output/Logs View - run_view = find_live_child(view, "run-viewer-#{run.id}") - - # action button is rendered correctly - refute has_element?(view, "button", "Run (Retry)") - - assert has_element?(view, "button:disabled", "Processing"), - "currently processing" - - refute has_element?(view, "button", "Run") - - render_async(run_view) - # input panel shows correct information - html = run_view |> element("div#input-panel") |> render() - - assert html =~ "Nothing yet" - refute html =~ "No input/output available. This step was never started." - - # output panel shows correct information - html = run_view |> element("div#output-panel") |> render() - - assert html =~ "Nothing yet" - refute html =~ "No input/output available. This step was never started." - - # let's subscribe to events to make sure we're in sync with liveview - Lightning.Runs.subscribe(run) - - # Let's claim the run - run = - run - |> Ecto.Changeset.change(%{ - state: :claimed, - claimed_at: DateTime.utc_now() - }) - |> Lightning.Repo.update!() - - # lets crash the run - {:ok, _run} = - Lightning.Runs.complete_run(run, %{ - "error_message" => "Unexpected token (6:9)", - "error_type" => "CompileError", - "final_dataclip_id" => "", - "state" => "crashed" - }) - - assert_received %Lightning.Runs.Events.RunUpdated{ - run: %{id: ^run_id} - } - - # make sure that the event is processed by liveview - render(view) - - # action button is rendered correctly. - refute has_element?(view, "button", "Run (Retry)") - refute has_element?(view, "button", "Processing"), "no longer processing" - assert has_element?(view, "button", "Run") - - # make sure event is processed by the run viewer - render_async(run_view) - - # input panel shows correct information - html = run_view |> element("div#input-panel") |> render() - refute html =~ "Nothing yet" - assert html =~ "No input/output available. This step was never started." - - # output panel shows correct information - html = run_view |> element("div#output-panel") |> render() - refute html =~ "Nothing yet" - assert html =~ "No input/output available. This step was never started." - end - end - - describe "Editor events" do - test "can handle request_metadata event", %{ - conn: conn, - project: project, - workflow: workflow - } do - cli_stdout = """ - {"level":"error","name":"CLI","message":["No metadata helper found"],"time":"1751556807394005966"} - """ - - FakeRambo.Helpers.stub_run({:ok, %{status: 0, out: cli_stdout, err: ""}}) - - credential = - insert(:credential, schema: "http") - |> with_body(%{ - name: "main", - body: %{ - "baseUrl" => "http://localhost:4002", - "username" => "test", - "password" => "test" - } - }) - - project_credential = - insert(:project_credential, - project: project, - credential: credential - ) - - job = workflow.jobs |> hd() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - assert has_element?(view, "#job-editor-pane-#{job.id}") - - assert view - |> with_target("#job-editor-pane-#{job.id}") - |> render_click("request_metadata", %{}) - - assert_push_event(view, "metadata_ready", %{"error" => "no_credential"}) - - view - |> trigger_save(%{ - workflow: %{ - jobs: %{ - "0" => %{ - "project_credential_id" => project_credential.id - } - } - } - }) - - assert view - |> with_target("#job-editor-pane-#{job.id}") - |> render_click("request_metadata", %{}) - - # set timeout to 60 secs because of CI - assert_push_event( - view, - "metadata_ready", - %{ - "error" => "no_metadata_function" - }, - 60000 - ) - end - end - - describe "UI metrics events" do - setup context do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - current_log_level = Logger.level() - Logger.configure(level: :info) - - on_exit(fn -> - Logger.configure(level: current_log_level) - end) - - context - |> Map.merge(%{ - metrics: [ - %{ - "event" => "foo-bar-job-event", - "start" => 1_737_635_739_914, - "end" => 1_737_635_808_890 - } - ] - }) - end - - test "writes the UI metrics to the logs", %{ - conn: conn, - metrics: metrics, - project: project, - workflow: workflow - } do - %{id: job_id} = job = workflow.jobs |> hd() - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - fun = fn -> - view - |> with_target("#job-editor-pane-#{job.id}") - |> render_hook( - "job_editor_metrics_report", - %{"metrics" => metrics} - ) - end - - assert capture_log(fun) =~ ~r/foo-bar-job-event/ - assert capture_log(fun) =~ ~r/#{job_id}/ - end - end - - describe "Output & Logs" do - test "all users can view output and logs for a followed run", %{ - conn: conn, - project: project, - workflow: %{jobs: [job_1 | _rest]} = workflow, - snapshot: snapshot - } do - input_dataclip = - insert(:dataclip, - project: project, - type: :saved_input, - body: %{"input" => Ecto.UUID.generate()} - ) - - output_dataclip = - insert(:dataclip, - project: project, - type: :saved_input, - body: %{"output" => Ecto.UUID.generate()} - ) - - log_line = build(:log_line) - - %{runs: [run]} = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - state: :success, - runs: [ - build(:run, - dataclip: input_dataclip, - snapshot: snapshot, - starting_job: job_1, - state: :success, - log_lines: [log_line], - steps: [ - build(:step, - job: job_1, - snapshot: snapshot, - input_dataclip: input_dataclip, - output_dataclip: output_dataclip, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - for {conn, _user} <- - setup_project_users(conn, project, [:owner, :admin, :editor, :viewer]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?#{[s: job_1.id, a: run.id, m: "expand", v: workflow.lock_version]}", - on_error: :raise - ) - - run_view = find_live_child(view, "run-viewer-#{run.id}") - - # This ensures that async result is loaded - render_async(run_view) - # This ensures that stream messages are processed - render(run_view) - - assert has_element?( - run_view, - "div#log-panel [phx-hook='LogViewer'][data-run-id='#{run.id}']" - ) - - # input panel shows correct information - assert view - |> dataclip_viewer("step-input-dataclip-viewer") - |> has_element?() - - input_dataclip_viewer_json = - view - |> dataclip_viewer("step-input-dataclip-viewer") - |> get_attrs_and_inner_html() - |> decode_inner_json() - |> elem(1) - - assert input_dataclip_viewer_json["dataclipId"] == input_dataclip.id - - # output panel shows correct information - output_dataclip_viewer_json = - view - |> dataclip_viewer("step-output-dataclip-viewer") - |> get_attrs_and_inner_html() - |> decode_inner_json() - |> elem(1) - - assert output_dataclip_viewer_json["dataclipId"] == output_dataclip.id - end - end - end - - defp rerun_setup(project, %{jobs: [job_1, job_2 | _rest]} = workflow, snapshot) do - input_dataclip = insert(:dataclip, project: project, type: :http_request) - - output_dataclip = - insert(:dataclip, - project: project, - type: :step_result, - body: %{"val" => Ecto.UUID.generate()} - ) - - workorder = - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: input_dataclip, - state: :success, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: input_dataclip, - starting_job: job_1, - state: :success, - steps: [ - build(:step, - snapshot: snapshot, - job: job_1, - input_dataclip: input_dataclip, - output_dataclip: output_dataclip, - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ), - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: output_dataclip, - output_dataclip: - build(:dataclip, - type: :step_result, - body: %{} - ), - started_at: build(:timestamp), - finished_at: build(:timestamp), - exit_reason: "success" - ) - ] - ) - ] - ) - - # insert 3 new dataclips - dataclips = insert_list(3, :dataclip, project: project) - - # associate dataclips with job 2 - for dataclip <- dataclips do - insert(:workorder, - workflow: workflow, - snapshot: snapshot, - dataclip: dataclip, - runs: [ - build(:run, - snapshot: snapshot, - dataclip: dataclip, - starting_job: job_2, - steps: [ - build(:step, - snapshot: snapshot, - job: job_2, - input_dataclip: dataclip, - output_dataclip: nil, - started_at: build(:timestamp), - finished_at: nil, - exit_reason: nil - ) - ] - ) - ] - ) - end - - {dataclips, workorder} - end -end diff --git a/test/lightning_web/live/workflow_live/index_test.exs b/test/lightning_web/live/workflow_live/index_test.exs index 5fef16f09f0..8aac959ffca 100644 --- a/test/lightning_web/live/workflow_live/index_test.exs +++ b/test/lightning_web/live/workflow_live/index_test.exs @@ -332,13 +332,6 @@ defmodule LightningWeb.WorkflowLive.IndexTest do fn -> view |> element("#new-workflow-button") |> render_click() end - - # visit page directly - {:ok, _, html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - |> follow_redirect(conn) - - assert html =~ "You are not authorized to perform this action." end @tag role: :editor @@ -351,22 +344,11 @@ defmodule LightningWeb.WorkflowLive.IndexTest do refute has_element?(view, "#new-workflow-button:disabled") assert has_element?(view, "#new-workflow-button") - # go directly - {:ok, view, html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - assert html =~ "Describe your workflow" - assert has_element?(view, "form#search-templates-form") - - select_template(view, "base-webhook-template") - - view |> element("button#create_workflow_btn") |> render_click() - - # the panel disappears - html = render(view) + # the collaborative editor mounts for new workflows + {:ok, _view, html} = + live(conn, ~p"/projects/#{project.id}/w/new") - refute html =~ "Describe your workflow" - refute has_element?(view, "form#search-templates-form") + assert html =~ "collaborative-editor-react" end test "only users with MFA enabled can create workflows for a project with MFA requirement", diff --git a/test/lightning_web/live/workflow_live/new_workflow_component_test.exs b/test/lightning_web/live/workflow_live/new_workflow_component_test.exs deleted file mode 100644 index bfad42c2983..00000000000 --- a/test/lightning_web/live/workflow_live/new_workflow_component_test.exs +++ /dev/null @@ -1,673 +0,0 @@ -defmodule LightningWeb.WorkflowLive.NewWorkflowComponentTest do - use LightningWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - import Lightning.Factories - import Lightning.WorkflowLive.Helpers - - setup :register_and_log_in_user - setup :create_project_for_current_user - - setup %{project: project} = tags do - if Map.get(tags, :stub_apollo, true) do - Lightning.AiAssistantHelpers.stub_online() - end - - # Create 5 distinct templates using factories - templates = [ - insert(:workflow_template, %{ - name: "Webhook Data Sync", - description: "Sync data from webhook to database", - tags: ["webhook", "sync", "database"], - workflow: build(:workflow, project: project) - }), - insert(:workflow_template, %{ - name: "Scheduled Report Generator", - description: "Generate reports on a schedule", - tags: ["cron", "reports", "scheduled"], - workflow: build(:workflow, project: project) - }), - insert(:workflow_template, %{ - name: "API Data Processor", - description: "Process data from external APIs", - tags: ["api", "data", "processing"], - workflow: build(:workflow, project: project) - }), - insert(:workflow_template, %{ - name: "File Upload Handler", - description: "Handle and process file uploads", - tags: ["files", "upload", "storage"], - workflow: build(:workflow, project: project) - }), - insert(:workflow_template, %{ - name: "Notification System", - description: "Send notifications via email and SMS", - tags: ["notifications", "email", "sms"], - workflow: build(:workflow, project: project) - }) - ] - - %{templates: templates} - end - - defp skip_disclaimer(user, read_at \\ DateTime.utc_now() |> DateTime.to_unix()) do - Ecto.Changeset.change(user, %{ - preferences: %{"ai_assistant.disclaimer_read_at" => read_at} - }) - |> Lightning.Repo.update!() - end - - describe "workflow creation methods" do - @tag stub_apollo: false - test "displays template and import options", %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Initial state should show template selection - assert view |> element("#create-workflow-from-template") |> has_element?() - assert view |> element("#import-workflow-btn") |> has_element?() - refute view |> element("#workflow-importer") |> has_element?() - end - - @tag stub_apollo: false - test "switches to import view when import button is clicked", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Click import button - html = view |> element("#import-workflow-btn") |> render_click() - - # Should now show the import view - assert html =~ "Upload a file" - assert html =~ "or drag and drop" - assert view |> element("#workflow-importer") |> has_element?() - assert view |> element("#workflow-dropzone") |> has_element?() - assert view |> element("#workflow-file") |> has_element?() - end - - test "can switch back to template view from import view", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Switch to import view - view |> element("#import-workflow-btn") |> render_click() - - # Click back button - _html = view |> element("#move-back-to-templates-btn") |> render_click() - - # Should show template selection again - assert view |> element("#create-workflow-from-template") |> has_element?() - refute view |> element("#workflow-importer") |> has_element?() - end - end - - describe "template selection" do - test "displays available templates", %{conn: conn, project: project} do - {:ok, view, html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - assert html =~ "base-webhook" - assert html =~ "Event-based Workflow" - assert html =~ "base-cron" - assert html =~ "Scheduled Workflow" - - # Template selection form should be present - assert view |> element("#choose-workflow-template-form") |> has_element?() - end - - test "allows template selection", %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Select a template - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - - test "searches templates by name", %{ - conn: conn, - project: project, - templates: templates - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Search for each template name - for template <- templates do - view - |> form("#search-templates-form", %{"search" => template.name}) - |> render_change() - - # Should show the template - assert view - |> element("#template-input-#{template.id}") - |> has_element?() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - - # Clear search - view - |> form("#search-templates-form", %{"search" => ""}) - |> render_change() - - # Should show all templates again - for template <- templates do - assert view - |> element("#template-input-#{template.id}") - |> has_element?() - end - end - - test "searches templates by description", %{ - conn: conn, - project: project, - templates: templates - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Search for each template description - for template <- templates do - view - |> form("#search-templates-form", %{"search" => template.description}) - |> render_change() - - # Should show the template - assert view - |> element("#template-input-#{template.id}") - |> has_element?() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - end - - test "searches templates by tags", %{ - conn: conn, - project: project, - templates: templates - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Test searching by specific tags - view - |> form("#search-templates-form", %{"search" => "webhook"}) - |> render_change() - - # Should show webhook template - assert view - |> element( - "#template-input-#{Enum.find(templates, &(&1.name == "Webhook Data Sync")).id}" - ) - |> has_element?() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - - # Test another tag - view - |> form("#search-templates-form", %{"search" => "cron"}) - |> render_change() - - # Should show cron template - assert view - |> element( - "#template-input-#{Enum.find(templates, &(&1.name == "Scheduled Report Generator")).id}" - ) - |> has_element?() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - - test "search is case insensitive", %{ - conn: conn, - project: project, - templates: templates - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Search with uppercase - for template <- templates do - view - |> form("#search-templates-form", %{ - "search" => String.upcase(template.name) - }) - |> render_change() - - # Should still find the template - assert view - |> element("#template-input-#{template.id}") - |> has_element?() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - end - - test "search with no results shows base templates", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Search for non-existent term - view - |> form("#search-templates-form", %{"search" => "nonexistent"}) - |> render_change() - - # Should still show base templates - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - - test "search with partial matches", %{ - conn: conn, - project: project, - templates: templates - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Search with partial word - for template <- templates do - view - |> form("#search-templates-form", %{ - "search" => String.slice(template.name, 0, 3) - }) - |> render_change() - - # Should show matching templates - assert view - |> element("#template-input-#{template.id}") - |> has_element?() - - assert view - |> element("#template-input-base-webhook-template") - |> has_element?() - - assert view - |> element("#template-input-base-cron-template") - |> has_element?() - end - end - end - - describe "workflow import" do - test "shows file upload interface", %{conn: conn, project: project} do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Switch to import view - html = view |> element("#import-workflow-btn") |> render_click() - - # Verify upload interface elements - assert html =~ "Upload a file" - assert html =~ "or drag and drop" - assert html =~ "YML or YAML, up to 8MB" - assert view |> element("#workflow-dropzone") |> has_element?() - assert view |> element("#workflow-file") |> has_element?() - end - - @tag stub_apollo: false - test "dropzone has proper attributes for drag and drop", %{ - conn: conn, - project: project - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - end) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - # Switch to import view - view |> element("#import-workflow-btn") |> render_click() - - # Verify dropzone has necessary attributes for the JavaScript hook - assert view - |> element( - "#workflow-dropzone[phx-hook='FileDropzone'][data-target='#workflow-file']" - ) - |> has_element?() - end - end - - describe "AI method integration" do - @tag stub_apollo: false - test "switching to AI method without search term shows AI interface", %{ - conn: conn, - project: project, - user: user - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - ai_assistant = element(view, "#new-workflow-panel-assistant") - - refute has_element?(ai_assistant) - - skip_disclaimer(user) - - view - |> element("#template-label-ai-dynamic-template") - |> render_click() - - assert has_element?(ai_assistant) - - html = render(ai_assistant) - - assert html =~ "Start a conversation to see your chat history appear here" - end - - @tag stub_apollo: false - test "switching to AI method with search term creates session and shows AI interface", - %{ - conn: conn, - project: project, - user: user - } do - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "ai_assistant_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - ai_assistant = element(view, "#new-workflow-panel-assistant") - - refute has_element?(ai_assistant) - - view - |> form("#search-templates-form", %{"search" => "sync data from API"}) - |> render_change() - - skip_disclaimer(user) - - view - |> element("#template-label-ai-dynamic-template") - |> render_click() - - assert has_element?(ai_assistant) - - html = render(ai_assistant) - - assert html =~ "sync data from API" - end - - test "AI template card displays search term correctly", %{ - conn: conn, - project: project - } do - Oban.Testing.with_testing_mode(:manual, fn -> - Mox.stub(Lightning.MockConfig, :apollo, fn key -> - case key do - :endpoint -> "http://localhost:3000" - :ai_assistant_api_key -> "api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end - end) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - view - |> form("#search-templates-form", %{"search" => "process webhook data"}) - |> render_change() - - build_with_button = element(view, "#template-label-ai-dynamic-template") - - html = render(build_with_button) - assert html =~ "process webhook data" - assert html =~ "Build with AI ✨" - - sessions_before = - Lightning.AiAssistant.list_sessions(project) - |> Map.get(:sessions) - - assert Enum.empty?(sessions_before) - - build_with_button |> render_click() - - assert view |> element("#create_workflow_via_ai") |> has_element?() - - sessions_after = - Lightning.AiAssistant.list_sessions(project) - |> Map.get(:sessions) - - refute Enum.empty?(sessions_after) - - assert sessions_after |> Enum.any?(&(&1.title == "process webhook data")) - end) - end - - test "AI template card shows default text when no search term", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - html = render(view) - assert html =~ "Build with AI ✨" - assert html =~ "Build your workflow using the AI assistant" - end - - test "can switch back from AI method to templates", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - view |> element("#template-label-ai-dynamic-template") |> render_click() - - assert view |> element("#create_workflow_via_ai") |> has_element?() - - html = view |> element("#move-back-to-templates-btn") |> render_click() - - assert html =~ "create-workflow-from-template" - assert view |> element("#create-workflow-from-template") |> has_element?() - refute view |> element("#create_workflow_via_ai") |> has_element?() - end - end - - describe "template selection events" do - test "selecting a template notifies parent liveview", %{ - conn: conn, - project: project, - templates: [%{id: id, code: code} | _] - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - view - |> element("#choose-workflow-template-form") - |> render_change(%{"template_id" => id}) - - view |> has_element?("#selected-template-label-#{id}") - - assert_push_event(view, "template_selected", %{template: ^code}) - end - - test "selecting different templates changes the selection", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - view - |> element("#choose-workflow-template-form") - |> render_change(%{"template_id" => "base-webhook-template"}) - - assert_push_event(view, "template_selected", %{template: webhook_template}) - - view - |> element("#choose-workflow-template-form") - |> render_change(%{"template_id" => "base-cron-template"}) - - assert_push_event(view, "template_selected", %{template: cron_template}) - - refute webhook_template == cron_template - assert cron_template =~ "Scheduled Workflow" - assert webhook_template =~ "Event-based Workflow" - end - end - - describe "workflow creation validation" do - test "create button is disabled when no template selected", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - assert view - |> element("#create_workflow_btn[disabled]") - |> has_element?() - end - - test "create button is enabled when template is selected", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - {view, _parsed_template} = select_template(view, "base-webhook-template") - - refute view - |> element("#create_workflow_btn[disabled]") - |> has_element?() - - element(view, "#create_workflow_btn") |> render_click() - - refute element(view, "#new-workflow-panel") - |> has_element?() - end - - test "clicking create without template shows error message", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - assert element(view, "#create_workflow_btn[disabled]") |> has_element?() - - view - |> render_click("save", %{}) - - assert render(view) =~ "Workflow could not be saved" - end - - test "create button disabled in import mode when validation fails", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - element(view, "#import-workflow-btn") |> render_click() - - assert_patch( - view, - ~p"/projects/#{project.id}/w/new/legacy?method=import" - ) - - assert view - |> element("#create_workflow_btn[disabled]") - |> has_element?() - - view - |> with_target("#new-workflow-panel") - |> render_click("create_workflow", %{}) - end - - test "create button disabled in AI mode when no template generated", %{ - conn: conn, - project: project - } do - {:ok, view, _html} = - live(conn, ~p"/projects/#{project.id}/w/new/legacy") - - view |> element("#template-label-ai-dynamic-template") |> render_click() - - assert_patch(view, ~p"/projects/#{project.id}/w/new/legacy?method=ai") - - assert view - |> element("#create_workflow_btn[disabled]") - |> has_element?() - - view - |> render_click("save") - - assert render(view) =~ - "Workflow could not be saved" - end - end -end diff --git a/test/lightning_web/live/workflow_live/trigger_test.exs b/test/lightning_web/live/workflow_live/trigger_test.exs deleted file mode 100644 index a66c749aac6..00000000000 --- a/test/lightning_web/live/workflow_live/trigger_test.exs +++ /dev/null @@ -1,542 +0,0 @@ -defmodule LightningWeb.WorkflowLive.TriggerTest do - use LightningWeb.ConnCase, async: true - - alias Lightning.Name - alias Lightning.Repo - alias Lightning.Workflows - alias Lightning.Workflows.WebhookAuthMethod - - import Phoenix.LiveViewTest - import Lightning.Factories - - setup :register_and_log_in_user - setup :create_project_for_current_user - - setup %{project: project} do - workflow = insert(:workflow, project: project) - trigger = insert(:trigger, type: :webhook, workflow: workflow) - - {:ok, snapshot} = Workflows.Snapshot.create(workflow) - - [ - workflow: workflow, - snapshot: snapshot, - trigger: trigger - ] - end - - test "owner/admin can see link to add auth method, editor/viewer can't", %{ - project: project, - workflow: workflow, - trigger: trigger - } do - for conn <- build_project_user_conns(project, [:owner, :admin]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert view |> element("a#addAuthenticationLink") |> has_element?() - end - - for conn <- build_project_user_conns(project, [:editor, :viewer]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert view - |> element("a#addAuthenticationLink.cursor-not-allowed") - |> has_element?() - end - end - - test "all users can see existing trigger authentication methods", %{ - project: project, - workflow: workflow, - trigger: trigger - } do - for conn <- - build_project_user_conns(project, [:owner, :admin, :editor, :viewer]) do - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - auth_method = - insert(:webhook_auth_method, - project: project, - auth_type: :basic, - triggers: [trigger] - ) - - refute html =~ auth_method.name - - {:ok, _view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert html =~ auth_method.name - end - end - - test "owner/admin can successfully create a basic authentication method, editor/viewer can't", - %{ - project: project, - workflow: workflow, - trigger: trigger - } do - modal_id = "manage_webhook_auth_methods_modal" - - for conn <- build_project_user_conns(project, [:editor, :viewer]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert has_element?(view, "#addAuthenticationLink.cursor-not-allowed") - - # forcing the event results in an error - assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~ - "You are not authorized to perform this action" - - refute has_element?(view, "##{modal_id}") - end - - for conn <- build_project_user_conns(project, [:owner, :admin]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - html = view |> element("#addAuthenticationLink") |> render_click() - refute html =~ "You are not authorized to perform this action" - # modal is present - assert has_element?(view, "##{modal_id}") - - html = - view - |> form("##{modal_id} form", - webhook_auth_method: %{auth_type: "basic"} - ) - |> render_submit() - - assert html =~ "Create auth method" - - auth_method_name = Name.generate() - - refute render(view) =~ auth_method_name - - view - |> form("##{modal_id} form", - webhook_auth_method: %{ - name: auth_method_name, - username: "testusername", - password: "testpassword123" - } - ) - |> render_submit() - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}" - ) - - # modal is removed - refute has_element?(view, "##{modal_id}") - - html = render(view) - - assert html =~ "Webhook auth method created successfully" - assert html =~ auth_method_name - - assert %Postgrex.Result{num_rows: 1} = - Ecto.Adapters.SQL.query!( - Repo, - "delete from trigger_webhook_auth_methods" - ) - - Repo.get_by(WebhookAuthMethod, name: auth_method_name) - |> Repo.delete() - end - end - - test "admin can successfully create an API authentication method, editor/viewer can't", - %{ - project: project, - workflow: workflow, - trigger: trigger - } do - modal_id = "manage_webhook_auth_methods_modal" - - for conn <- build_project_user_conns(project, [:editor, :viewer]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert has_element?(view, "#addAuthenticationLink.cursor-not-allowed") - - # forcing the event results in an error - assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~ - "You are not authorized to perform this action" - - refute has_element?(view, "##{modal_id}") - end - - for conn <- build_project_user_conns(project, [:owner, :admin]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - html = view |> element("#addAuthenticationLink") |> render_click() - refute html =~ "You are not authorized to perform this action" - # modal is present - assert has_element?(view, "##{modal_id}") - - html = - view - |> form("##{modal_id} form", - webhook_auth_method: %{auth_type: "api"} - ) - |> render_submit() - - assert html =~ "Create auth method" - assert html =~ "API Key" - refute html =~ "password" - - auth_method_name = Name.generate() - - refute render(view) =~ auth_method_name - - assert view - |> form("##{modal_id} form", - webhook_auth_method: %{ - name: auth_method_name - } - ) - |> render_submit() - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}" - ) - - # modal is removed - refute has_element?(view, "##{modal_id}") - - html = render(view) - - assert html =~ "Webhook auth method created successfully" - assert html =~ auth_method_name - - assert %Postgrex.Result{num_rows: 1} = - Ecto.Adapters.SQL.query!( - Repo, - "delete from trigger_webhook_auth_methods" - ) - - Repo.get_by(WebhookAuthMethod, name: auth_method_name) - |> Repo.delete() - end - end - - test "users cannot update auth methods via the trigger form", %{ - project: project, - workflow: workflow, - trigger: trigger - } do - auth_method = - insert(:webhook_auth_method, - project: project, - auth_type: :basic, - triggers: [trigger] - ) - - modal_id = "manage_webhook_auth_methods_modal" - - for conn <- build_project_user_conns(project, [:owner, :admin]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - view |> element("#manageAuthenticationLink") |> render_click() - - # modal is present - assert has_element?(view, "##{modal_id}") - - html = - view - |> element("#view_auth_method_link_#{auth_method.id}") - |> render_click() - - assert html =~ "Webhook Authentication Method" - refute html =~ "Create a new webhook auth method" - - refute has_element?(view, "##{modal_id} form button[type='submit']") - - assert has_element?( - view, - "##{modal_id} form button[type='button']", - "Back" - ) - - html = - view - |> element("##{modal_id} form button[type='button']", "Back") - |> render_click() - - assert html =~ "Create a new webhook auth method" - end - end - - describe "revealing a webhook auth secret as an SSO user without a password" do - setup %{project: project, trigger: trigger} do - auth_method = - insert(:webhook_auth_method, - project: project, - auth_type: :basic, - triggers: [trigger] - ) - - [auth_method: auth_method] - end - - test "with mfa enabled, only the 2FA code is requested", %{ - conn: conn, - project: project, - workflow: workflow, - trigger: trigger, - auth_method: auth_method - } do - user = - insert(:user, - hashed_password: nil, - mfa_enabled: true, - user_totp: build(:user_totp) - ) - - insert(:project_user, role: :owner, project: project, user: user) - conn = log_in_user(conn, user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - view |> element("#manageAuthenticationLink") |> render_click() - - view - |> element("#view_auth_method_link_#{auth_method.id}") - |> render_click() - - component_id = "view_webhook_auth_method_#{auth_method.id}" - html = view |> element("##{component_id} button", "Show") |> render_click() - - assert html =~ "2FA Code" - refute has_element?(view, "#reauthentication-form input[type='password']") - end - - test "without mfa, the user is told to set a password or enable 2fa", %{ - conn: conn, - project: project, - workflow: workflow, - trigger: trigger, - auth_method: auth_method - } do - user = insert(:user, hashed_password: nil, mfa_enabled: false) - - insert(:project_user, role: :owner, project: project, user: user) - conn = log_in_user(conn, user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - view |> element("#manageAuthenticationLink") |> render_click() - - view - |> element("#view_auth_method_link_#{auth_method.id}") - |> render_click() - - component_id = "view_webhook_auth_method_#{auth_method.id}" - html = view |> element("##{component_id} button", "Show") |> render_click() - - assert html =~ "set a password or enable two-factor authentication" - refute has_element?(view, "#reauthentication-form") - end - end - - test "owner/admin can remove an auth method from a trigger, editor/viewer can't", - %{ - project: project, - workflow: workflow, - trigger: trigger - } do - auth_method = - insert(:webhook_auth_method, - project: project, - auth_type: :basic, - triggers: [trigger] - ) - - for conn <- build_project_user_conns(project, [:editor, :viewer]) do - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - assert has_element?(view, "#manageAuthenticationLink.cursor-not-allowed") - - # forcing the event results in an error - assert render_click(view, "show_modal", %{target: "webhook_auth_method"}) =~ - "You are not authorized to perform this action" - - refute has_element?(view, "#manage_webhook_auth_methods") - end - - for conn <- build_project_user_conns(project, [:owner, :admin]) do - {:ok, view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - # they can see it listed - assert html =~ auth_method.name - - html = view |> element("#manageAuthenticationLink") |> render_click() - refute html =~ "You are not authorized to perform this action" - # modal is present - assert has_element?(view, "#manage_webhook_auth_methods") - - view - |> element("#select_#{auth_method.id}") - |> render_click() - - view |> element("#update_trigger_auth_methods_button") |> render_click() - - assert_patched( - view, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: trigger.id]}" - ) - - html = render(view) - - assert html =~ "Trigger webhook auth methods updated successfully" - # it is no longer listed - refute html =~ auth_method.name - - # modal is closed - refute has_element?(view, "#manage_webhook_auth_methods") - - updated_trigger = - Repo.preload(trigger, [:webhook_auth_methods], force: true) - - assert updated_trigger.webhook_auth_methods == [] - - # Then we add it back for the next test role! ============================ - refute has_element?(view, "#manageAuthenticationLink") - assert has_element?(view, "#addAuthenticationLink") - view |> element("#addAuthenticationLink") |> render_click() - - # modal is present - assert has_element?(view, "#manage_webhook_auth_methods") - - view - |> element("#select_#{auth_method.id}") - |> render_click() - - view |> element("#update_trigger_auth_methods_button") |> render_click() - # ======================================================================== - end - end - - test "BETA chip appears for Kafka triggers but not for other trigger types", %{ - project: project, - workflow: workflow, - conn: conn - } do - # Create different types of triggers - webhook_trigger = insert(:trigger, type: :webhook, workflow: workflow) - cron_trigger = insert(:trigger, type: :cron, workflow: workflow) - kafka_trigger = insert(:trigger, type: :kafka, workflow: workflow) - - # Test for webhook trigger - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: webhook_trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - # Verify BETA chip is not present for webhook trigger - refute view |> element("#kafka-trigger-title-beta") |> has_element?() - - refute view - |> element("span[aria-label*='Kafka triggers are currently in beta']") - |> has_element?() - - # Test for cron trigger - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: cron_trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - # Verify BETA chip is not present for cron trigger - refute view |> element("#kafka-trigger-title-beta") |> has_element?() - - refute view - |> element("span[aria-label*='Kafka triggers are currently in beta']") - |> has_element?() - - # Test for kafka trigger - {:ok, view, html} = - live( - conn, - ~p"/projects/#{project.id}/w/#{workflow.id}/legacy?#{[s: kafka_trigger.id, v: workflow.lock_version]}", - on_error: :raise - ) - - # Verify BETA chip is present for kafka trigger - assert view |> element("#kafka-trigger-title-beta") |> has_element?() - - # Verify tooltip content - assert html =~ "Kafka triggers are currently in beta" - assert html =~ "Learn about the sharp edges" - - assert html =~ - "https://docs.openfn.org/documentation/build/triggers#known-sharp-edges-on-the-kafka-trigger-feature" - end -end diff --git a/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs b/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs deleted file mode 100644 index a8fb8d9ba97..00000000000 --- a/test/lightning_web/live/workflow_live/workflow_ai_chat_component_test.exs +++ /dev/null @@ -1,696 +0,0 @@ -defmodule LightningWeb.WorkflowLive.WorkflowAiChatComponentTest do - use LightningWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - import Lightning.Factories - import Lightning.AiAssistantHelpers - import Mox - import Ecto.Query - - setup :register_and_log_in_user - setup :create_project_for_current_user - setup :verify_on_exit! - - setup %{project: project} do - workflow = insert(:simple_workflow, project: project) - {:ok, snapshot} = Lightning.Workflows.Snapshot.create(workflow) - - # Stub Apollo as online - Mox.stub(Lightning.MockConfig, :apollo, fn - :endpoint -> "http://localhost:4001" - :ai_assistant_api_key -> "test_api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end) - - %{workflow: workflow, snapshot: snapshot} - end - - defp skip_disclaimer(user, read_at \\ DateTime.utc_now() |> DateTime.to_unix()) do - Ecto.Changeset.change(user, %{ - preferences: %{"ai_assistant.disclaimer_read_at" => read_at} - }) - |> Lightning.Repo.update!() - end - - describe "component mounting and rendering" do - test "renders the AI chat panel with correct structure", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - assert has_element?(view, "#workflow-ai-chat-panel") - assert has_element?(view, "#workflow-ai-chat-panel-assistant") - end - end - - describe "AI workflow generation" do - test "generates and applies valid workflow template", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - valid_workflow_yaml = """ - name: Updated Workflow - jobs: - fetch_data: - name: Fetch Data - adaptor: '@openfn/language-http@latest' - body: | - get('/api/data'); - triggers: - webhook: - type: webhook - enabled: true - edges: - webhook->fetch_data: - source_trigger: webhook - target_job: fetch_data - condition_type: always - """ - - stub_ai_with_health_check("http://localhost:4001", %{ - "response" => "I'll update your workflow", - "response_yaml" => valid_workflow_yaml, - "usage" => %{}, - "history" => [ - %{"role" => "user", "content" => "Add a fetch data job"}, - %{ - "role" => "assistant", - "content" => "I'll update your workflow" - } - ] - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - render_async(view) - - view - |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{"assistant" => %{"content" => "Add a fetch data job"}}) - - assert_push_event(view, "template_selected", %{template: template}) - assert template =~ "name: Updated Workflow" - assert template =~ "fetch_data" - - job_id = Ecto.UUID.generate() - trigger_id = Ecto.UUID.generate() - edge_id = Ecto.UUID.generate() - - parsed_params = %{ - "name" => "Updated Workflow", - "jobs" => [ - %{ - "id" => job_id, - "name" => "Fetch Data", - "adaptor" => "@openfn/language-http@latest", - "body" => "get('/api/data');" - } - ], - "triggers" => [ - %{ - "id" => trigger_id, - "type" => "webhook", - "enabled" => true - } - ], - "edges" => [ - %{ - "id" => edge_id, - "source_trigger_id" => trigger_id, - "target_job_id" => job_id, - "condition_type" => "always" - } - ] - } - - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parsed", %{"workflow" => parsed_params}) - end) - - assert_receive {_ref, - {:push_event, "template_selected", %{template: template}}} - - assert template =~ "Updated Workflow" - end - - test "handles YAML parse errors from JavaScript", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - invalid_yaml = """ - name: Bad Workflow - jobs: - - this is invalid yaml structure - body: | - """ - - stub_ai_with_health_check("http://localhost:4001", %{ - "response" => "Here's your workflow", - "response_yaml" => invalid_yaml, - "usage" => %{} - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - render_async(view) - - view - |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{"assistant" => %{"content" => "Create a bad workflow"}}) - - assert_push_event(view, "template_selected", %{template: template}) - assert template =~ "Bad Workflow" - - message = - Lightning.Repo.one( - from(m in Lightning.AiAssistant.ChatMessage, - where: m.role == :assistant, - order_by: [desc: m.inserted_at], - limit: 1 - ) - ) - - view - |> element( - "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']" - ) - |> render_click() - - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parse-error", %{ - "error" => "Invalid YAML: unexpected scalar at line 3" - }) - end) - - html = render(view) - assert html =~ "Error while parsing workflow" - assert html =~ "Click to view error details" - - assert has_element?(view, "#error-details-#{message.id}") - assert html =~ "Invalid YAML: unexpected scalar at line 3" - end - - test "handles validation errors when parsed workflow is invalid", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - stub_ai_with_health_check("http://localhost:4001", %{ - "response" => "Here's a workflow with validation issues", - "response_yaml" => """ - name: "" - jobs: - empty_job: - name: "" - adaptor: "" - body: "" - """, - "usage" => %{} - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - render_async(view) - - view - |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{ - "assistant" => %{"content" => "Create invalid workflow"} - }) - - assert_push_event(view, "template_selected", %{template: _}) - - render_async(view) - - message = - Lightning.Repo.one( - from(m in Lightning.AiAssistant.ChatMessage, - where: m.role == :assistant, - order_by: [desc: m.inserted_at], - limit: 1 - ) - ) - - view - |> element( - "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']" - ) - |> render_click() - - invalid_params = %{ - "name" => "", - "jobs" => [ - %{ - "id" => Ecto.UUID.generate(), - "name" => "", - "adaptor" => "", - "body" => "" - } - ] - } - - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parsed", %{"workflow" => invalid_params}) - end) - - html = render_async(view) - - assert html =~ "Error while parsing workflow" - - assert has_element?(view, "button[phx-click*='error-details']") - assert has_element?(view, "#error-details-#{message.id}") - - assert html =~ "name - can't be blank" - assert html =~ "jobs.1.body - job body can't be blank" - assert html =~ "jobs.1.name - job name can't be blank" - end - - test "clicking on AI message with code restores workflow", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - skip_disclaimer(user) - - session = - insert(:chat_session, - project: project, - workflow: workflow, - user: user, - session_type: "workflow_template" - ) - - workflow_code = """ - name: Previous Workflow - jobs: - old_job: - name: Old Job - adaptor: '@openfn/language-common@latest' - body: | - console.log("old"); - """ - - message = - insert(:chat_message, - chat_session: session, - role: :assistant, - content: "Here's your previous workflow", - code: workflow_code - ) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai&w-chat=#{session.id}" - ) - - assert_push_event(view, "template_selected", %{template: ^workflow_code}) - - view - |> element( - "[phx-click='select_assistant_message'][phx-value-message-id='#{message.id}']" - ) - |> render_click() - - assert_push_event(view, "template_selected", %{template: ^workflow_code}) - end - end - - describe "complex validation error scenarios" do - test "handles multiple job errors with proper naming", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - Oban.Testing.with_testing_mode(:manual, fn -> - workflow_yaml = """ - name: Multi-job Workflow - jobs: - first_job: - name: First Job - adaptor: "" - body: console.log('valid'); - second_job: - name: Second Job - adaptor: @openfn/language-common@latest - body: "" - third_job: - name: "" - adaptor: @openfn/language-common@latest - body: fn(state => state) - triggers: - webhook: - type: webhook - edges: - webhook->first_job: - source_trigger: webhook - target_job: first_job - """ - - Mox.stub(Lightning.MockConfig, :apollo, fn key -> - case key do - :endpoint -> "http://localhost:3000" - :ai_assistant_api_key -> "api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end - end) - - stub_ai_with_health_check("http://localhost:3000", %{ - "response" => "Here's a workflow with validation issues", - "response_yaml" => workflow_yaml, - "usage" => %{}, - "history" => [ - %{ - "role" => "user", - "content" => "Create workflow with errors" - }, - %{ - "role" => "assistant", - "content" => "Here's a workflow with validation issues" - } - ] - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai" - ) - - render_async(view) - - view - |> form("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{ - assistant: %{content: "Create workflow with errors"} - }) - - render_async(view) - - assert_push_event(view, "template_selected", %{template: template}) - assert template =~ workflow_yaml - - message = - Lightning.Repo.one( - from(m in Lightning.AiAssistant.ChatMessage, - where: m.role == :assistant, - order_by: [desc: m.inserted_at], - limit: 1 - ) - ) - - element( - view, - "div[phx-value-message-id='#{message.id}']" - ) - |> render_click() - - params_with_job_errors = %{ - "name" => "Multi-job Workflow", - "jobs" => [ - %{ - "id" => Ecto.UUID.generate(), - "name" => "First Job", - "adaptor" => "", - "body" => "console.log('valid');" - }, - %{ - "id" => Ecto.UUID.generate(), - "name" => "Second Job", - "adaptor" => "@openfn/language-common@latest", - "body" => "" - }, - %{ - "id" => Ecto.UUID.generate(), - "name" => "", - "adaptor" => "@openfn/language-common@latest", - "body" => "fn(state => state)" - } - ], - "triggers" => [ - %{ - "id" => Ecto.UUID.generate(), - "type" => "webhook" - } - ], - "edges" => [ - %{ - "id" => Ecto.UUID.generate(), - "source_trigger_id" => "trigger-id", - "target_job_id" => "job-id" - } - ] - } - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parsed", %{ - "workflow" => params_with_job_errors - }) - end) - - assert log =~ - "Workflow code parse failed:" - - html = render(view) - - assert html =~ "Error while parsing workflow" - - assert has_element?(view, "button[phx-click*='error-details']") - assert has_element?(view, "#error-details-#{message.id}") - - assert html =~ "job name can't be blank" - end) - end - - test "handles workflow yaml parse errors", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - Oban.Testing.with_testing_mode(:manual, fn -> - workflow_yaml = "unparseable workflow" - - Mox.stub(Lightning.MockConfig, :apollo, fn key -> - case key do - :endpoint -> "http://localhost:3000" - :ai_assistant_api_key -> "api_key" - :timeout -> 5_000 - :streaming_timeout -> 120_000 - end - end) - - stub_ai_with_health_check("http://localhost:3000", %{ - "response" => "Here's a workflow with validation issues", - "response_yaml" => workflow_yaml, - "usage" => %{}, - "history" => [ - %{ - "role" => "user", - "content" => "Create workflow with errors" - }, - %{ - "role" => "assistant", - "content" => "Here's a workflow with validation issues" - } - ] - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live( - conn, - ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai" - ) - - render_async(view) - - view - |> form("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{ - assistant: %{content: "Create workflow with errors"} - }) - - render_async(view) - - assert_push_event(view, "template_selected", %{template: template}) - assert template =~ workflow_yaml - - message = - Lightning.Repo.one( - from(m in Lightning.AiAssistant.ChatMessage, - where: m.role == :assistant, - order_by: [desc: m.inserted_at], - limit: 1 - ) - ) - - element( - view, - "div[phx-value-message-id='#{message.id}']" - ) - |> render_click() - - log = - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parse-error", %{ - "error" => "workflow format unknown" - }) - end) - - assert log =~ "Workflow code parse failed: \"workflow format unknown\"" - - html = render(view) - - assert html =~ "Error while parsing workflow" - - assert has_element?(view, "button[phx-click*='error-details']") - assert has_element?(view, "#error-details-#{message.id}") - - assert html =~ "workflow format unknown" - end) - end - end - - describe "AI assistant state management" do - test "shows loading state when sending message", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - stub_ai_with_health_check("http://localhost:4001", %{ - "response" => "Processing...", - "response_yaml" => nil, - "usage" => %{} - }) - - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - render_async(view) - - view - |> element("#ai-assistant-form-workflow-ai-chat-panel-assistant") - |> render_submit(%{"assistant" => %{"content" => "Update workflow"}}) - - # Canvas should be notified about sending state - we disable when we send the message - assert_receive {_ref, {:push_event, "set-disabled", %{disabled: true}}} - - # Canvas should be notified when done - we enable when we receive the message - assert_receive {_ref, {:push_event, "set-disabled", %{disabled: false}}} - end - - test "preserves workflow params between updates", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - job_id = Ecto.UUID.generate() - - initial_params = %{ - "name" => "Initial Workflow", - "jobs" => [ - %{ - "id" => job_id, - "name" => "Initial Job", - "adaptor" => "@openfn/language-common@latest", - "body" => "fn(state => state)" - } - ] - } - - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parsed", %{"workflow" => initial_params}) - end) - - assert_receive {_ref, {:push_event, "patches-applied", _patches}} - - # Send same params again - should not trigger update - ExUnit.CaptureLog.capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parsed", %{"workflow" => initial_params}) - end) - - # Should not receive another notification - refute_receive {_ref, {:push_event, "patches-applied", _patches}} - end - end - - describe "error logging" do - test "logs YAML parse errors", %{ - conn: conn, - project: project, - workflow: workflow, - user: user - } do - skip_disclaimer(user) - - {:ok, view, _html} = - live(conn, ~p"/projects/#{project}/w/#{workflow}/legacy?method=ai") - - import ExUnit.CaptureLog - - log = - capture_log(fn -> - view - |> with_target("#workflow-ai-chat-panel") - |> render_hook("template-parse-error", %{ - "error" => "YAML syntax error at line 42: unexpected end of stream" - }) - end) - - assert log =~ "Workflow code parse failed" - assert log =~ "YAML syntax error at line 42" - end - end -end From 523354dbd6807059e0d841e7daef2794675d47ca Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 1 Jul 2026 14:14:32 +0000 Subject: [PATCH 2/6] chore: remove old ai assistant --- lib/lightning/config.ex | 13 - .../live/ai_assistant/component.ex | 1691 ----------------- .../live/ai_assistant/error_handler.ex | 102 - .../live/ai_assistant/mode_behavior.ex | 268 --- .../live/ai_assistant/mode_registry.ex | 42 - .../live/ai_assistant/modes/job_code.ex | 279 --- .../ai_assistant/modes/workflow_template.ex | 254 --- lib/lightning_web/live/ai_assistant/quotes.ex | 139 -- .../workflow_ai_chat_component.ex | 284 --- test/lightning/config_test.exs | 9 - .../ai_assistant/error_handler_test.exs | 349 ---- .../ai_assistant/job_code_test.exs | 244 --- .../ai_assistant/mode_registry_test.exs | 67 - .../ai_assistant/workflow_template_test.exs | 466 ----- .../ai_assistant_component_test.exs | 445 ----- 15 files changed, 4652 deletions(-) delete mode 100644 lib/lightning_web/live/ai_assistant/component.ex delete mode 100644 lib/lightning_web/live/ai_assistant/error_handler.ex delete mode 100644 lib/lightning_web/live/ai_assistant/mode_behavior.ex delete mode 100644 lib/lightning_web/live/ai_assistant/mode_registry.ex delete mode 100644 lib/lightning_web/live/ai_assistant/modes/job_code.ex delete mode 100644 lib/lightning_web/live/ai_assistant/modes/workflow_template.ex delete mode 100644 lib/lightning_web/live/ai_assistant/quotes.ex delete mode 100644 lib/lightning_web/live/workflow_live/workflow_ai_chat_component.ex delete mode 100644 test/lightning_web/ai_assistant/error_handler_test.exs delete mode 100644 test/lightning_web/ai_assistant/job_code_test.exs delete mode 100644 test/lightning_web/ai_assistant/mode_registry_test.exs delete mode 100644 test/lightning_web/ai_assistant/workflow_template_test.exs delete mode 100644 test/lightning_web/live/workflow_live/ai_assistant_component_test.exs diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex index 86d167c983c..9a9abd5728b 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -370,14 +370,6 @@ defmodule Lightning.Config do |> Keyword.get(:external_metrics) end - @impl true - def ai_assistant_modes do - %{ - job: LightningWeb.Live.AiAssistant.Modes.JobCode, - workflow: LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate - } - end - @impl true def per_workflow_claim_limit do Application.get_env(:lightning, :per_workflow_claim_limit, 50) @@ -546,7 +538,6 @@ defmodule Lightning.Config do @callback gdpr_banner() :: map() | false @callback gdpr_preferences() :: map() | false @callback external_metrics_module() :: module() | nil - @callback ai_assistant_modes() :: %{atom() => module()} @callback per_workflow_claim_limit() :: pos_integer() @callback claim_work_mem() :: String.t() | nil @callback log_queue_queries() :: boolean() @@ -818,10 +809,6 @@ defmodule Lightning.Config do impl().external_metrics_module() end - def ai_assistant_modes do - impl().ai_assistant_modes() - end - def metrics_run_performance_age_seconds do impl().metrics_run_performance_age_seconds() end diff --git a/lib/lightning_web/live/ai_assistant/component.ex b/lib/lightning_web/live/ai_assistant/component.ex deleted file mode 100644 index 4f86cdf769c..00000000000 --- a/lib/lightning_web/live/ai_assistant/component.ex +++ /dev/null @@ -1,1691 +0,0 @@ -defmodule LightningWeb.AiAssistant.Component do - @moduledoc """ - LiveView component for AI-powered assistance. - - Provides an interactive chat interface supporting multiple AI modes, - real-time message processing, and session management. - """ - use LightningWeb, :live_component - - alias Lightning.AiAssistant - alias Lightning.AiAssistant.ChatMessage - alias Lightning.AiAssistant.ChatSession - alias Lightning.AiAssistant.Limiter - alias LightningWeb.AiAssistant.Quotes - alias LightningWeb.Live.AiAssistant.ModeRegistry - alias Phoenix.LiveView.AsyncResult - alias Phoenix.LiveView.JS - - require Logger - - @default_page_size 20 - @message_preview_length 50 - @typing_animation_delay_ms 100 - - @impl true - def mount(socket) do - {:ok, - socket - |> assign(%{ - session: nil, - ai_limit_result: nil, - pagination_meta: nil, - sort_direction: :desc, - has_read_disclaimer: false, - all_sessions: AsyncResult.ok([]), - pending_message: AsyncResult.ok(nil), - ai_enabled: AiAssistant.enabled?(), - code: nil, - code_error: nil, - alert: nil, - callbacks: %{}, - selected_message: nil, - registered_session_id: nil, - registered_component_id: nil - }) - |> assign_async(:endpoint_available, fn -> - {:ok, %{endpoint_available: AiAssistant.endpoint_available?()}} - end)} - end - - @impl true - def update(%{message_status_changed: status}, socket) do - {:ok, handle_message_status(status, socket)} - end - - def update(%{action: :code_error} = assigns, socket) do - {:ok, handle_code_error(socket, assigns)} - end - - def update(%{action: action} = assigns, socket) - when action in [:new, :show] do - {:ok, - socket - |> init_assigns(assigns) - |> register_session(assigns) - |> ensure_limit_checked() - |> apply_action(action)} - end - - def update(assigns, socket) do - {:ok, assign(socket, assigns)} - end - - defp register_session(socket, params) do - registration = build_registration(params, socket) - - case registration do - {:no_change, _} -> - socket - - {:update, current, new} -> - socket - |> handle_unregistration(current, new) - |> handle_registration(new) - |> assign_registration(new) - end - end - - defp build_registration(params, socket) do - new = %{ - session_id: params[:chat_session_id], - component_id: params[:id] || params.id - } - - current = %{ - session_id: socket.assigns[:registered_session_id], - component_id: socket.assigns[:registered_component_id], - has_pending: has_pending_operation?(socket) - } - - if current.session_id == new.session_id && - current.component_id == new.component_id do - {:no_change, current} - else - {:update, current, new} - end - end - - defp handle_unregistration(socket, %{session_id: nil}, _new), do: socket - defp handle_unregistration(socket, %{has_pending: true}, _new), do: socket - - defp handle_unregistration(socket, current, new) do - component_id = current.component_id || new.component_id - send_unregister_message(component_id) - socket - end - - defp handle_registration(socket, %{session_id: nil}), do: socket - - defp handle_registration(socket, %{ - session_id: session_id, - component_id: component_id - }) do - send_register_message(component_id, session_id) - socket - end - - defp assign_registration(socket, registration) do - socket - |> assign(:registered_session_id, registration.session_id) - |> assign(:registered_component_id, registration.component_id) - end - - defp has_pending_operation?(socket) do - case socket.assigns[:pending_message] do - %{loading: true} -> true - %{loading: _} -> false - _ -> false - end - end - - defp send_register_message(component_id, session_id) do - send(self(), { - :ai_assistant, - :register_component, - %{component_id: component_id, session_id: session_id} - }) - end - - defp send_unregister_message(component_id) do - send(self(), { - :ai_assistant, - :unregister_component, - %{component_id: component_id} - }) - end - - defp handle_message_status({:processing, session}, socket) do - assign(socket, - session: session, - pending_message: AsyncResult.loading() - ) - end - - defp handle_message_status({:success, session}, socket) do - socket - |> assign( - session: session, - pending_message: AsyncResult.ok(nil), - selected_message: nil, - code_error: nil - ) - |> delegate_to_handler(:on_message_received, [session]) - end - - defp handle_message_status({:error, session}, socket) do - assign(socket, - session: session, - pending_message: AsyncResult.ok(nil) - ) - end - - defp handle_code_error(socket, assigns) do - assign(socket, - code_error: %{ - message_id: extract_message_id(assigns.session_or_message), - details: assigns.error || "Unknown error" - } - ) - end - - defp init_assigns(socket, assigns) do - handler = ModeRegistry.get_handler(assigns.mode) - - socket - |> assign(assigns) - |> assign( - callbacks: assigns[:callbacks] || %{}, - handler: handler, - has_read_disclaimer: AiAssistant.user_has_read_disclaimer?(assigns.user) - ) - |> assign_new(:changeset, fn _ -> - handler.validate_form(%{"content" => nil}) - end) - end - - defp extract_message_id(%ChatSession{messages: messages}) do - messages - |> List.last() - |> extract_message_id() - end - - defp extract_message_id(%ChatMessage{id: id}), do: id - defp extract_message_id(_), do: nil - - @impl true - def render(assigns) do - ~H""" -
<.render_content {assigns} />
- """ - end - - defp render_content(%{ai_enabled: false} = assigns) do - ~H""" - <.render_ai_not_configured /> - """ - end - - defp render_content(%{has_read_disclaimer: false} = assigns) do - ~H""" - <.render_onboarding id={@id} myself={@myself} can_edit={@can_edit} /> - """ - end - - defp render_content(assigns) do - ~H""" - <.render_session {assigns} /> - """ - end - - @impl true - def handle_event("validate", %{"assistant" => params}, socket) do - handler = socket.assigns.handler - - {:noreply, assign(socket, changeset: handler.validate_form(params))} - end - - def handle_event( - "send_message", - %{"assistant" => %{"content" => content} = params}, - socket - ) do - cleared_params = Map.put(params, "content", nil) - trimmed_content = if is_binary(content), do: String.trim(content), else: "" - - cond do - not socket.assigns.can_edit -> - {:noreply, - socket - |> assign( - changeset: socket.assigns.handler.validate_form(cleared_params), - alert: "You are not authorized to use the AI Assistant" - )} - - socket.assigns.ai_limit_result != :ok -> - {:noreply, socket} - - trimmed_content == "" -> - changeset = socket.assigns.handler.validate_form(%{"content" => ""}) - - changeset = - Ecto.Changeset.add_error( - changeset, - :content, - "Please enter a message before sending" - ) - - {:noreply, - socket - |> assign( - changeset: changeset, - alert: "Please enter a message before sending" - )} - - true -> - {:noreply, - socket - |> assign(alert: nil, code_error: nil) - |> delegate_to_handler(:on_message_send) - |> assign( - :changeset, - socket.assigns.handler.validate_form(cleared_params) - ) - |> save_message(socket.assigns.action, trimmed_content)} - end - end - - def handle_event("mark_disclaimer_read", _params, socket) do - {:ok, _} = AiAssistant.mark_disclaimer_read(socket.assigns.user) - {:noreply, assign(socket, has_read_disclaimer: true)} - end - - def handle_event("toggle_sort", _params, socket) do - {:noreply, - socket - |> update(:sort_direction, fn - :desc -> :asc - :asc -> :desc - end) - |> apply_action(:new)} - end - - def handle_event("cancel_message", %{"message-id" => message_id}, socket) do - message = Enum.find(socket.assigns.session.messages, &(&1.id == message_id)) - - {:ok, session} = - AiAssistant.update_message_status( - socket.assigns.session, - message, - :cancelled - ) - - {:noreply, assign(socket, :session, session)} - end - - def handle_event("retry_message", %{"message-id" => message_id}, socket) do - socket.assigns.session.messages - |> Enum.find(&(&1.id == message_id)) - |> AiAssistant.retry_message() - |> case do - {:ok, {_message, _oban_job}} -> - {:ok, session} = AiAssistant.get_session(socket.assigns.session.id) - - {:noreply, - socket - |> assign(:session, session) - |> assign(:pending_message, AsyncResult.loading())} - - {:error, _changeset} -> - {:noreply, - put_flash(socket, :error, "Failed to retry message. Please try again.")} - end - end - - def handle_event( - "select_assistant_message", - %{"message-id" => message_id}, - %{assigns: assigns} = socket - ) do - message = Enum.find(assigns.session.messages, &(&1.id == message_id)) - - {:noreply, - socket - |> assign(selected_message: message) - |> delegate_to_handler(:on_message_selected, [message])} - end - - def handle_event("retry_load_sessions", _params, socket) do - {:noreply, apply_action(socket, :new)} - end - - def handle_event("load_more_sessions", _params, socket) do - %{assigns: %{sort_direction: sort_direction, handler: handler} = assigns} = - socket - - current_sessions = - case socket.assigns.all_sessions do - %AsyncResult{result: sessions} when is_list(sessions) -> sessions - _ -> [] - end - - {:noreply, - socket - |> assign_async([:all_sessions, :pagination_meta], fn -> - case handler.list_sessions(assigns, sort_direction, - offset: length(current_sessions), - limit: @default_page_size - ) do - %{sessions: new_sessions, pagination: pagination} -> - all_sessions = current_sessions ++ new_sessions - {:ok, %{all_sessions: all_sessions, pagination_meta: pagination}} - end - end)} - end - - defp apply_action(socket, action) do - %{assigns: %{sort_direction: sort_direction, handler: handler} = assigns} = - socket - - case action do - :new -> - socket - |> delegate_to_handler(:on_session_close) - |> assign_async([:all_sessions, :pagination_meta], fn -> - case handler.list_sessions(assigns, sort_direction, - limit: @default_page_size - ) do - %{sessions: sessions, pagination: pagination} -> - {:ok, %{all_sessions: sessions, pagination_meta: pagination}} - end - end) - - :show -> - session = handler.get_session!(assigns) - - message_loading = - Enum.any?(session.messages, fn msg -> - msg.role == :user && msg.status in [:pending, :processing] - end) - - socket - |> assign(:session, session) - |> assign( - :pending_message, - if message_loading do - AsyncResult.loading() - else - AsyncResult.ok(nil) - end - ) - |> delegate_to_handler(:on_session_open, [session]) - end - end - - defp save_message(socket, action, content) do - result = - case action do - :new -> create_new_session(socket, content) - :show -> add_to_existing_session(socket, content) - end - - case result do - {:ok, session} -> - handle_successful_save(socket, session, action) - - {:error, error} -> - handle_save_error(socket, error) - end - end - - defp create_new_session(socket, content) do - socket.assigns.handler.create_session(socket.assigns, content) - end - - defp add_to_existing_session(socket, content) do - socket.assigns.handler.save_message(socket.assigns, content) - end - - defp handle_successful_save(socket, session, :new) do - socket - |> assign(:session, session) - |> assign(:pending_message, AsyncResult.loading()) - |> redirect_to_session(session) - end - - defp handle_successful_save(socket, session, :show) do - socket - |> assign(:session, session) - |> assign(:pending_message, AsyncResult.loading()) - end - - defp redirect_to_session(socket, session) do - chat_param = socket.assigns.handler.metadata().chat_param - query_params = Map.put(socket.assigns.query_params, chat_param, session.id) - push_patch(socket, to: redirect_url(socket.assigns.base_url, query_params)) - end - - defp handle_save_error(socket, error) do - socket - |> assign(alert: socket.assigns.handler.error_message(error)) - |> assign(pending_message: AsyncResult.ok(nil)) - end - - defp redirect_url(base_url, query_params) do - query_string = - query_params - |> Enum.reject(fn {_k, v} -> is_nil(v) end) - |> URI.encode_query() - - "#{base_url}?#{query_string}" - end - - defp ensure_limit_checked(%{assigns: %{ai_limit_result: nil}} = socket) do - limit = Limiter.validate_quota(socket.assigns.project.id) - - alert = - if limit != :ok, do: socket.assigns.handler.error_message(limit), else: nil - - assign(socket, ai_limit_result: limit, alert: alert) - end - - defp ensure_limit_checked(socket), do: socket - - defp delegate_to_handler(socket, callback, args \\ []) do - handler = socket.assigns.handler - - if function_exported?(handler, callback, length(args) + 1) do - apply(handler, callback, [socket | args]) - else - socket - end - end - - defp maybe_show_ellipsis(title) when is_binary(title) do - if String.length(title) >= AiAssistant.title_max_length() do - "#{title}..." - else - title - end - end - - defp display_cancel_message_btn?(session) do - session.messages |> Enum.filter(&(&1.role == :user)) |> length() > 1 - end - - defp ai_feedback do - Application.get_env(:lightning, :ai_feedback) - end - - defp render_ai_not_configured(assigns) do - ~H""" -
-
-
-
- <.icon name="hero-cpu-chip" class="w-8 h-8 text-gray-400" /> -
-
- -

- AI Assistant Not Available -

- -

- AI Assistant has not been configured for your instance - contact your admin for support. -

- -

- Try the AI Assistant on OpenFn cloud for free at - - app.openfn.org - -

- -
-

To enable AI Assistant, your administrator needs to:

-
    -
  • Configure the Apollo endpoint URL
  • -
  • Set up the AI Assistant API key
  • -
  • Restart the Lightning application
  • -
-
-
-
- """ - end - - defp render_session(assigns) do - ~H""" -
- <%= case @action do %> - <% :new -> %> - <.render_all_sessions - all_sessions={@all_sessions} - query_params={@query_params} - base_url={@base_url} - sort_direction={@sort_direction} - pagination_meta={@pagination_meta} - handler={@handler} - target={@myself} - mode={@mode} - /> - <% :show -> %> - <.render_individual_session - session={@session} - pending_message={@pending_message} - query_params={@query_params} - base_url={@base_url} - target={@myself} - handler={@handler} - code_error={@code_error} - mode={@mode} - /> - <% end %> - - <.async_result :let={endpoint_available} assign={@endpoint_available}> - <:loading> -
-
- <.icon name="hero-sparkles" class="animate-pulse" /> -
-
- - - <.form - :let={form} - as={:assistant} - for={@changeset} - phx-submit="send_message" - phx-change="validate" - class="row-span-1 pl-2 pr-2 pb-1" - phx-target={@myself} - phx-hook="SendMessageViaCtrlEnter" - data-keybinding-scope="chat" - id={"ai-assistant-form-#{@id}"} - > - - - <.chat_input - id={"chat-input-#{@id}"} - disclaimer_id={"ai-assistant-disclaimer-#{@id}"} - form_id={"ai-assistant-form-#{@id}"} - form={form} - disabled={ - @handler.chat_input_disabled?(%{ - assigns - | endpoint_available: endpoint_available - }) - } - tooltip={@handler.disabled_tooltip_message(assigns)} - handler={@handler} - /> - - -
- <.disclaimer id={"ai-assistant-disclaimer-#{@id}"} /> - """ - end - - attr :id, :string - attr :disclaimer_id, :string - attr :disabled, :boolean - attr :tooltip, :string - attr :form, :map, required: true - attr :form_id, :string, required: true - attr :handler, :any, required: true - - defp chat_input(assigns) do - ~H""" -
-
- - - - -
- - Do not paste PII or sensitive data - - - <.simple_button_with_tooltip - id={"ai-assistant-form-submit-btn-#{@id}"} - type="submit" - disabled={@disabled || form_content_empty?(@form[:content].value)} - form={@form_id} - class={[ - "p-1.5 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-all duration-200 flex items-center justify-center w-10 h-10", - if(@disabled || form_content_empty?(@form[:content].value), - do: - "text-gray-400 bg-gray-300 cursor-not-allowed focus:ring-gray-300", - else: - "text-white bg-indigo-600 hover:bg-indigo-500 focus:ring-indigo-500" - ) - ]} - > - <.icon name="hero-paper-airplane-solid" class="h-3 w-3" /> - -
-
- - <%= if form = @handler.render_config_form(assigns) do %> - {form} - <% end %> - -
- <.ai_footer disclaimer_id={@disclaimer_id} /> -
-
- """ - end - - attr :all_sessions, AsyncResult, required: true - attr :query_params, :map, required: true - attr :base_url, :string, required: true - attr :sort_direction, :atom, required: true - attr :pagination_meta, :any, default: nil - attr :target, :string, required: true - attr :mode, :atom, required: true - attr :handler, :any, required: true - - defp render_all_sessions(assigns) do - chat_param = assigns.handler.metadata().chat_param - assigns = assign(assigns, :chat_param, chat_param) - - ~H""" -
- <.async_result :let={all_sessions} assign={@all_sessions}> - <:loading> -
-
- <.icon name="hero-sparkles" class="size-6 animate-pulse" /> -
-

- Loading chat history... -

-

This may take a moment

-
- - - <:failed :let={_failure}> -
-
- <.icon name="hero-exclamation-triangle" class="size-6" /> -
-

Failed to load chat history

-

- Please check your connection and try again -

- -
- - -
-
- <.icon name="hero-chat-bubble-left-right" class="size-8" /> -
-

No chat history yet

-

- Start a conversation to see your chat history appear here. All your conversations will be saved automatically. -

-
- -
0}> -
-
-

Chat History

- <.async_result :let={pagination} assign={@pagination_meta}> - - {length(all_sessions)} of {pagination.total_count} - - -
- - -
- -
- <%= for session <- all_sessions do %> - <.link - id={"session-#{session.id}"} - patch={ - redirect_url( - @base_url, - Map.put(@query_params, @chat_param, session.id) - ) - } - class="group bg-white block p-3 pb-1 rounded-lg border border-gray-200 hover:border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-200" - role="listitem" - aria-label={"Open chat: #{session.title}"} - > -
-
-
- <.user_avatar - id={"session-#{session.id}-avatar"} - user={session.user} - /> -
-
-

- {maybe_show_ellipsis(@handler.chat_title(session))} -

-

- {format_session_preview(session)} -

-
-
-
- -
- <.icon name="hero-chevron-right" class="size-4 text-gray-400" /> -
-
-
- - <% end %> -
- - <.async_result :let={pagination} assign={@pagination_meta}> -
- -
- -
- -
- """ - end - - defp format_session_preview(session) do - session - |> get_preview_source() - |> format_preview_text() - end - - defp get_preview_source(session) do - cond do - has_message_count?(session) -> {:message_count, session.message_count} - has_messages?(session) -> {:messages, length(session.messages)} - has_last_message?(session) -> {:last_message, session.last_message} - has_updated_at?(session) -> {:updated_at, session.updated_at} - true -> {:default, nil} - end - end - - defp format_preview_text({:message_count, count}), - do: format_message_count(count) - - defp format_preview_text({:messages, count}), do: format_message_count(count) - - defp format_preview_text({:last_message, message}), - do: format_last_message(message) - - defp format_preview_text({:updated_at, datetime}), - do: format_activity_status(datetime) - - defp format_preview_text({:default, _}), do: "Conversation" - - defp has_message_count?(session) do - Map.has_key?(session, :message_count) and not is_nil(session.message_count) - end - - defp has_messages?(session) do - Map.has_key?(session, :messages) and is_list(session.messages) - end - - defp has_last_message?(session) do - Map.has_key?(session, :last_message) and not is_nil(session.last_message) - end - - defp has_updated_at?(session) do - Map.has_key?(session, :updated_at) - end - - defp format_message_count(0), do: "New conversation" - defp format_message_count(1), do: "1 message" - defp format_message_count(count), do: "#{count} messages" - - defp format_last_message(message) do - message - |> String.trim() - |> String.slice(0, @message_preview_length) - |> add_ellipsis_if_needed(String.length(message)) - end - - defp add_ellipsis_if_needed("", _), do: "New conversation" - - defp add_ellipsis_if_needed(preview, original_length) - when original_length > @message_preview_length do - preview <> "..." - end - - defp add_ellipsis_if_needed(preview, _), do: preview - - defp format_activity_status(datetime) do - if recent_activity?(datetime), do: "Recent activity", else: "Conversation" - end - - defp recent_activity?(datetime) do - Timex.diff(DateTime.utc_now(), datetime, :hours) < 1 - end - - defp form_content_empty?(value) do - case value do - nil -> true - "" -> true - content when is_binary(content) -> String.trim(content) == "" - _ -> false - end - end - - defp render_onboarding(assigns) do - assigns = assign(assigns, ai_quote: Quotes.random_enabled()) - - ~H""" -
-
-

- The AI Assistant is a chat agent designed to help you write job code. -

- Remember that you, the human in control, are responsible for how its output is used. -

- - <.button - theme="primary" - id="get-started-with-ai-btn" - phx-click="mark_disclaimer_read" - phx-target={@myself} - disabled={!@can_edit} - > - Get started with the AI Assistant - - - <.disclaimer id={"ai-assistant-disclaimer-#{@id}"} /> -
- <.ai_footer disclaimer_id={"ai-assistant-disclaimer-#{@id}"} /> -
- """ - end - - defp ai_footer(assigns) do - ~H""" - - """ - end - - defp disclaimer(assigns) do - ~H""" - - """ - end - - attr :session, AiAssistant.ChatSession, required: true - attr :pending_message, AsyncResult, required: true - attr :query_params, :map, required: true - attr :base_url, :string, required: true - attr :target, :any, required: true - attr :handler, :any, required: true - attr :code_error, :any, required: true - attr :mode, :atom, required: true - - defp render_individual_session(assigns) do - assigns = assign(assigns, ai_feedback: ai_feedback()) - chat_param = assigns.handler.metadata().chat_param - assigns = assign(assigns, :chat_param, chat_param) - - ~H""" -
-
-
-
-
- <.icon name="hero-chat-bubble-left-right" class="w-5 h-5 text-white" /> -
-
-
-

- {maybe_show_ellipsis(@handler.chat_title(@session))} -

-

- {message_count_text(@session)} • {format_session_time( - @session.updated_at - )} -

-
-
- -
- <.link - id={"close-chat-session-btn-#{@session.id}"} - patch={redirect_url(@base_url, Map.put(@query_params, @chat_param, nil))} - class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors" - phx-hook="Tooltip" - aria-label="Click to close the current chat session" - > - <.icon name="hero-x-mark" class="w-6 h-6" /> - -
-
- -
- <%= for message <- @session.messages do %> - <%= if message.role == :user do %> - <.user_message message={message} session={@session} target={@target} /> - <% else %> - <.assistant_message - message={message} - handler={@handler} - session={@session} - target={@target} - ai_feedback={@ai_feedback} - code_error={@code_error} - data-message-id={message.id} - /> - <% end %> - <% end %> - - <.async_result assign={@pending_message}> - <:loading> - <.assistant_typing_indicator handler={@handler} /> - - - <:failed :let={failure}> - <.assistant_error_message failure={failure} handler={@handler} /> - - -
-
- """ - end - - defp user_message(assigns) do - msg_status = message_status_display(assigns.message.status) - assigns = assign(assigns, msg_status: msg_status) - - ~H""" -
-
-
- - - -
- -
-
-

- {@message.content} -

-
- -
- - {format_message_time(@message.inserted_at)} - - -
- <.icon - name={@msg_status.icon} - class={"w-3.5 h-3.5 #{@msg_status.color} ml-2"} - /> - {@msg_status.text} -
-
-
- -
- <.user_avatar - id={"message-#{@message.id}-avatar"} - user={@message.user} - size_class="w-8 h-8" - /> -
-
-
- """ - end - - defp assistant_message(assigns) do - code_error = assigns[:code_error] - has_error = code_error && code_error.message_id == assigns.message.id - assigns = assign(assigns, has_error: has_error) - - ~H""" -
-
-
-
- <.icon - name={@handler.metadata().icon} - class={[ - if(@has_error, - do: "w-6 h-6 text-red-600", - else: "w-6 h-6 text-white" - ) - ]} - /> -
-
- -
- "border-red-300" - - @message.code -> - "border-gray-200 cursor-pointer hover:border-indigo-200 transition-all duration-200 group" - - true -> - "border-gray-200" - end - ]} - {if @message.code && !@has_error, do: [ - "phx-click": "select_assistant_message", - "phx-value-message-id": @message.id, - "phx-target": @target - ], else: []} - > -
-
- <.icon name="hero-exclamation-triangle" class="w-4 h-4 text-red-600" /> -

- Error while parsing workflow -

-
-
- -
-
- <.icon name="hero-gift" class="w-4 h-4 text-indigo-600" /> - - Click to restore workflow to here - -
-
- -
- <.formatted_content - id={"message-#{@message.id}-content"} - content={@message.content} - /> - -
-
- - {format_message_time(@message.inserted_at)} - -
- - -
-
- -
- - -
- -
- {Phoenix.LiveView.TagEngine.component( - @ai_feedback.component, - %{session_id: @session.id, message_id: @message.id}, - {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} - )} -
-
-
-
- """ - end - - defp assistant_typing_indicator(assigns) do - assigns = assign(assigns, animation_delay: @typing_animation_delay_ms) - - ~H""" -
-
-
-
- <.icon name={@handler.metadata().icon} class="w-6 h-6 text-white" /> -
-
- -
-
-
-
-
-
-
-
-

Processing...

-
-
-
- """ - end - - defp assistant_error_message(assigns) do - ~H""" -
-
-
-
- <.icon name="hero-exclamation-triangle" class="w-4 h-4 text-red-600" /> -
-
- -
-
-
- <.icon - name="hero-exclamation-triangle" - class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" - /> -
-

- Something went wrong -

-

- {@handler.error_message(@failure)} -

-
-
-
-
-
-
- """ - end - - defp message_container_classes(status) do - case status do - :success -> "ai-bg-gradient" - :pending -> "ai-bg-gradient opacity-70" - :error -> "ai-bg-gradient-error" - _ -> "ai-bg-gradient" - end - end - - defp message_text_classes(status) do - case status do - :error -> "text-white" - _ -> "text-white" - end - end - - defp message_footer_classes(status) do - case status do - :success -> "border-indigo-500/30 bg-black/5" - :pending -> "border-indigo-400/30 bg-black/5" - :error -> "border-red-400/30 bg-black/5" - _ -> "border-indigo-500/30 bg-black/5" - end - end - - defp message_timestamp_classes(status) do - case status do - :error -> "text-red-200" - _ -> "text-indigo-200" - end - end - - defp message_count_text(session) do - case length(session.messages) do - 0 -> "No messages" - 1 -> "1 message" - count -> "#{count} messages" - end - end - - defp message_status_display(:pending), - do: %{icon: "hero-clock", text: "Sending", color: "text-indigo-300"} - - defp message_status_display(:error), - do: %{ - icon: "hero-exclamation-triangle", - text: "Failed", - color: "text-red-300" - } - - defp message_status_display(_), - do: %{icon: "hero-check-circle", text: "Sent", color: "text-indigo-300"} - - defp format_session_time(datetime) do - now = DateTime.utc_now() - diff_seconds = DateTime.diff(now, datetime, :second) - - cond do - diff_seconds < 60 -> "Just now" - diff_seconds < 3600 -> "#{div(diff_seconds, 60)}m ago" - diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)}h ago" - diff_seconds < 604_800 -> "#{div(diff_seconds, 86400)}d ago" - true -> Calendar.strftime(datetime, "%b %d") - end - end - - defp format_message_time(datetime) do - Calendar.strftime(datetime, "%I:%M %p") - end - - # Default per-element HTML attributes injected into rendered markdown so - # assistant messages pick up Tailwind styling. Keyed by HTML tag name. - @assistant_messages_attributes %{ - "a" => %{ - class: "text-primary-400 hover:text-primary-600", - target: "_blank" - }, - "h1" => %{class: "text-2xl font-bold mb-6"}, - "h2" => %{class: "text-xl font-semibold mb-4 mt-8"}, - "ol" => %{class: "list-decimal pl-8 space-y-1"}, - "ul" => %{class: "list-disc pl-8 space-y-1"}, - "li" => %{class: "text-gray-800"}, - "p" => %{class: "mt-1 mb-2 text-gray-800"}, - "pre" => %{ - class: - "rounded-md font-mono bg-slate-100 border-2 border-slate-200 text-slate-800 my-4 p-2 overflow-auto" - } - } - - # Match Earmark's default of passing raw HTML through untouched. The content - # is AI-generated markdown rendered for the same user who prompted it. - # - # Earmark defaulted to `gfm: true`, so enable the GFM extensions (tables, - # strikethrough, bare-URL autolinks, task lists) to keep assistant replies — - # which frequently contain tables — rendering as they did before. - @mdex_options [ - extension: [ - table: true, - strikethrough: true, - autolink: true, - tasklist: true - ], - render: [unsafe: true] - ] - - attr :id, :string, required: true - attr :content, :string, required: true - attr :attributes, :map, default: %{} - - def formatted_content(assigns) do - merged_attributes = - Map.merge(@assistant_messages_attributes, assigns.attributes) - - rendered = - case MDEx.to_html(assigns.content, @mdex_options) do - {:ok, html} -> - html |> inject_attributes(merged_attributes) |> raw() - - {:error, _reason} -> - assigns.content - end - - assigns = assign(assigns, :content, rendered) - - ~H""" -
{@content}
- """ - end - - # MDEx node structs cannot carry arbitrary HTML attributes, so inject the - # per-element classes into the rendered HTML instead. - defp inject_attributes(html, attributes) do - Enum.reduce(attributes, html, fn {tag, attrs}, acc -> - inject_tag_attributes(acc, tag, attrs) - end) - end - - defp inject_tag_attributes(html, tag, attrs) do - attrs_string = - Enum.map_join(attrs, " ", fn {key, value} -> ~s(#{key}="#{value}") end) - - String.replace( - html, - ~r/<#{Regex.escape(tag)}(?=[\s>])/, - ~s(<#{tag} #{attrs_string}) - ) - end - - attr :user, Lightning.Accounts.User, default: nil - attr :size_class, :string, default: "h-8 w-8" - attr :id, :string, default: nil - - defp user_avatar(%{user: nil} = assigns) do - ~H""" - - - ? - - - """ - end - - defp user_avatar(assigns) do - first_initial = get_initial(assigns.user.first_name) - last_initial = get_initial(assigns.user.last_name) - full_name = build_user_title(assigns.user.first_name, assigns.user.last_name) - - assigns = - assign(assigns, - first_initial: first_initial, - last_initial: last_initial, - full_name: full_name - ) - - ~H""" - - - {@first_initial}{@last_initial} - - - """ - end - - defp get_initial(nil), do: "" - defp get_initial(""), do: "" - defp get_initial(name) when is_binary(name), do: String.first(name) - defp get_initial(_), do: "" - - defp build_user_title(first_name, last_name) do - [first_name, last_name] - |> Enum.reject(&is_nil/1) - |> Enum.join(" ") - end - - defp time_ago(datetime) do - Timex.from_now(datetime) - end -end diff --git a/lib/lightning_web/live/ai_assistant/error_handler.ex b/lib/lightning_web/live/ai_assistant/error_handler.ex deleted file mode 100644 index 3710b099b12..00000000000 --- a/lib/lightning_web/live/ai_assistant/error_handler.ex +++ /dev/null @@ -1,102 +0,0 @@ -defmodule LightningWeb.Live.AiAssistant.ErrorHandler do - @moduledoc """ - Error handling for AI Assistant interactions. - - Transforms technical errors into user-friendly messages. - """ - - @doc """ - Formats errors into user-friendly messages. - - ## Examples - - iex> format_error({:error, "Something went wrong"}) - "Something went wrong" - - iex> format_error({:error, :timeout}) - "Request timed out. Please try again." - - """ - @spec format_error(any()) :: String.t() - def format_error({:error, message}) when is_binary(message) and message != "", - do: message - - def format_error({:error, %Ecto.Changeset{} = changeset}) do - case extract_changeset_errors(changeset) do - [] -> "Could not save message. Please try again." - errors -> Enum.join(errors, ", ") - end - end - - def format_error({:error, _reason, %{text: text_message}}) - when is_binary(text_message) and text_message != "", - do: text_message - - def format_error({:error, :timeout}), - do: "Request timed out. Please try again." - - def format_error({:error, :econnrefused}), - do: "Unable to reach the AI server. Please try again later." - - def format_error({:error, :network_error}), - do: "Network error occurred. Please check your connection." - - def format_error({:error, reason}) when is_atom(reason), - do: "An error occurred: #{reason}. Please try again." - - def format_error(_error), - do: "Oops! Something went wrong. Please try again." - - @doc """ - Formats AI usage limit errors. - - ## Examples - - iex> format_limit_error({:error, :quota_exceeded}) - "AI usage limit reached. Please try again later or contact support." - - """ - @spec format_limit_error(any()) :: String.t() - def format_limit_error({:error, _reason, %{text: text_message}}) - when is_binary(text_message) and text_message != "", - do: text_message - - def format_limit_error({:error, :quota_exceeded}), - do: "AI usage limit reached. Please try again later or contact support." - - def format_limit_error({:error, :rate_limited}), - do: "Too many requests. Please wait a moment before trying again." - - def format_limit_error({:error, :insufficient_credits}), - do: "Insufficient AI credits. Please contact your administrator." - - def format_limit_error(_), - do: "AI usage limit reached. Please try again later." - - @doc """ - Extracts errors from Ecto changesets. - """ - @spec extract_changeset_errors(Ecto.Changeset.t()) :: [String.t()] - def extract_changeset_errors(%Ecto.Changeset{errors: errors}) do - errors - |> Enum.filter(fn {_field, {message, _opts}} -> message != "" end) - |> Enum.map(fn {field, {message, opts}} -> - field_name = - field |> to_string() |> String.replace("_", " ") |> String.capitalize() - - interpolated_message = interpolate_error_message(message, opts) - "#{field_name} #{interpolated_message}" - end) - |> Enum.filter(&(&1 != "")) - end - - def extract_changeset_errors(_), do: [] - - @doc false - @spec interpolate_error_message(String.t(), Keyword.t()) :: String.t() - defp interpolate_error_message(message, opts) do - Enum.reduce(opts, message, fn {key, value}, acc -> - String.replace(acc, "%{#{key}}", inspect(value)) - end) - end -end diff --git a/lib/lightning_web/live/ai_assistant/mode_behavior.ex b/lib/lightning_web/live/ai_assistant/mode_behavior.ex deleted file mode 100644 index 188cd152f73..00000000000 --- a/lib/lightning_web/live/ai_assistant/mode_behavior.ex +++ /dev/null @@ -1,268 +0,0 @@ -defmodule LightningWeb.Live.AiAssistant.ModeBehavior do - @moduledoc """ - Defines the contract for AI Assistant interaction modes. - - Each mode implements different AI assistance functionality (e.g., job code help, workflow generation). - The component delegates all mode-specific decisions to the implementing module. - """ - - alias Lightning.AiAssistant.ChatMessage - alias Lightning.AiAssistant.ChatSession - alias LightningWeb.Live.AiAssistant.PaginationMeta - alias Phoenix.LiveView.Socket - - @type assigns :: %{atom() => any()} - @type session :: ChatSession.t() - @type session_id :: Ecto.UUID.t() - @type user :: Lightning.Accounts.User.t() - @type job :: Lightning.Workflows.Job.t() - @type project :: Lightning.Projects.Project.t() - - @type session_result :: {:ok, session()} | {:error, error_reason()} - @type error_reason :: :validation_failed | :unauthorized | :not_found | term() - @type sort_direction :: :asc | :desc - - @type list_opts :: [ - offset: non_neg_integer(), - limit: pos_integer(), - search: String.t() | nil - ] - - @type session_list :: %{ - sessions: [session()], - pagination: PaginationMeta.t() - } - - @type mode_metadata :: %{ - optional(:category) => String.t(), - optional(:features) => [String.t()], - name: String.t(), - description: String.t(), - icon: String.t(), - chat_param: String.t() - } - - @type update_result :: {:ok, Socket.t()} | {:unhandled, Socket.t()} - - @doc """ - Creates a new chat session with initial content. - """ - @callback create_session(assigns(), String.t(), opts :: Keyword.t()) :: - session_result() - - @doc """ - Retrieves an existing session by ID from assigns. - """ - @callback get_session!(assigns()) :: session() | no_return() - - @doc """ - Lists chat sessions with pagination support. - """ - @callback list_sessions(assigns(), sort_direction(), list_opts()) :: - session_list() - - @doc """ - Saves a user message to the current session. - """ - @callback save_message(assigns(), String.t()) :: session_result() - - @doc """ - Sends a query to the AI service. - """ - @callback query(session(), String.t(), Keyword.t()) :: session_result() - - @doc """ - Determines if the chat input should be disabled. - """ - @callback chat_input_disabled?(assigns()) :: boolean() - - @doc """ - Checks if more sessions are available beyond the current count. - """ - @callback more_sessions?(assigns(), current_count :: integer()) :: boolean() - - @doc """ - Returns mode metadata for UI display. - """ - @callback metadata() :: mode_metadata() - - @doc """ - Called when a message is sent. - """ - @callback on_message_send(Socket.t()) :: Socket.t() - - @doc """ - Called when an AI response is received. - """ - @callback on_message_received(Socket.t(), session()) :: Socket.t() - - @doc """ - Called when a chat session is closed. - """ - @callback on_session_close(Socket.t()) :: Socket.t() - - @doc """ - Called when a chat session is opened. - """ - @callback on_session_open(Socket.t(), session()) :: Socket.t() - - @doc """ - Called when a message is selected in the UI. - """ - @callback on_message_selected(Socket.t(), ChatMessage.t()) :: Socket.t() - - @doc """ - Renders mode-specific configuration form elements. - """ - @callback render_config_form(assigns()) :: - Phoenix.LiveView.Rendered.t() | nil - - @doc """ - Returns the form module for this mode. - """ - @callback form_module() :: module() - - @doc """ - Validates form parameters. - """ - @callback validate_form(params :: map()) :: Ecto.Changeset.t() - - @doc """ - Extracts options from a validated changeset. - """ - @callback extract_form_options(Ecto.Changeset.t()) :: Keyword.t() - - @doc """ - Returns placeholder text for the chat input. - """ - @callback input_placeholder() :: String.t() - - @doc """ - Returns a display title for the chat session. - """ - @callback chat_title(session()) :: String.t() - - @doc """ - Formats an error for display. - """ - @callback error_message(any()) :: String.t() - - @doc """ - Returns tooltip text when chat is disabled. - """ - @callback disabled_tooltip_message(assigns()) :: String.t() | nil - - @optional_callbacks [ - input_placeholder: 0, - chat_title: 1, - error_message: 1, - disabled_tooltip_message: 1, - render_config_form: 1, - on_message_received: 2, - on_session_close: 1, - on_session_open: 2 - ] - - defmacro __using__(_opts) do - quote do - @behaviour LightningWeb.Live.AiAssistant.ModeBehavior - - alias LightningWeb.Live.AiAssistant.ErrorHandler - alias Phoenix.LiveView.Socket - - @doc false - def input_placeholder do - "Open a previous session or send a message to start a new one" - end - - @doc false - def chat_title(session) do - case session do - %{title: title} when is_binary(title) and title != "" -> title - _ -> "Untitled Chat" - end - end - - @doc false - def error_message(error), do: ErrorHandler.format_error(error) - - @doc false - def disabled_tooltip_message(_assigns), do: nil - - @doc false - def render_config_form(_assigns), do: nil - - # Default event handlers - just pass through - - @doc false - def on_message_send(socket), do: socket - - @doc false - def on_message_received(socket, _session), do: socket - - @doc false - def on_session_close(socket), do: socket - - @doc false - def on_session_open(socket, _session), do: socket - - @doc false - def on_message_selected(socket, _message), do: socket - - @doc false - def form_module, do: __MODULE__.DefaultForm - - @doc false - def validate_form(params) do - form_module().changeset(params) - end - - @doc false - def extract_form_options(changeset) do - form_module().extract_options(changeset) - end - - defmodule DefaultForm do - @moduledoc false - - use Ecto.Schema - import Ecto.Changeset - - @primary_key false - embedded_schema do - field :content, :string - end - - @doc false - def changeset(params \\ %{}) do - %__MODULE__{} - |> cast(params, [:content]) - |> validate_required([:content], - message: "Please enter a message before sending" - ) - |> validate_length(:content, - min: 1, - message: "Please enter a message before sending" - ) - end - - @doc false - def extract_options(_changeset), do: [] - end - - defoverridable input_placeholder: 0, - chat_title: 1, - error_message: 1, - disabled_tooltip_message: 1, - render_config_form: 1, - on_message_send: 1, - on_message_received: 2, - on_session_close: 1, - on_session_open: 2, - on_message_selected: 2, - form_module: 0, - validate_form: 1, - extract_form_options: 1 - end - end -end diff --git a/lib/lightning_web/live/ai_assistant/mode_registry.ex b/lib/lightning_web/live/ai_assistant/mode_registry.ex deleted file mode 100644 index f9e96b18752..00000000000 --- a/lib/lightning_web/live/ai_assistant/mode_registry.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule LightningWeb.Live.AiAssistant.ModeRegistry do - @moduledoc """ - Registry for AI Assistant interaction modes. - - Manages mode discovery, metadata retrieval, and feature detection. - """ - - alias LightningWeb.Live.AiAssistant.Modes.JobCode - alias LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate - - @type mode_id :: atom() - @type mode_module :: module() - @type mode_metadata :: %{ - optional(:features) => [String.t()], - optional(:category) => String.t(), - id: mode_id(), - name: String.t(), - description: String.t(), - icon: String.t(), - chat_param: String.t() - } - - @doc """ - Returns all registered modes. - """ - @spec register_modes() :: %{mode_id() => mode_module()} - def register_modes do - Lightning.Config.ai_assistant_modes() || - %{ - job: JobCode, - workflow: WorkflowTemplate - } - end - - @doc """ - Gets the handler module for a mode. - """ - @spec get_handler(mode_id()) :: mode_module() - def get_handler(mode) do - Map.get(register_modes(), mode, JobCode) - end -end diff --git a/lib/lightning_web/live/ai_assistant/modes/job_code.ex b/lib/lightning_web/live/ai_assistant/modes/job_code.ex deleted file mode 100644 index 8cc2fc20733..00000000000 --- a/lib/lightning_web/live/ai_assistant/modes/job_code.ex +++ /dev/null @@ -1,279 +0,0 @@ -defmodule LightningWeb.Live.AiAssistant.Modes.JobCode do - @moduledoc """ - AI mode for job-specific code assistance and debugging. - - Provides context-aware help for job code, including adaptor usage, - debugging support, and optional log attachment. - """ - use LightningWeb.Live.AiAssistant.ModeBehavior - - import Phoenix.Component - import LightningWeb.Components.Icons - import LightningWeb.Components.NewInputs - - alias Lightning.Accounts.User - alias Lightning.AiAssistant - alias Lightning.AiAssistant.ChatSession - alias Lightning.Invocation - alias Lightning.Workflows.Job - alias LightningWeb.Live.AiAssistant.ErrorHandler - - defmodule Form do - @moduledoc false - - use Ecto.Schema - import Ecto.Changeset - - @primary_key false - embedded_schema do - field :content, :string - - embeds_one :options, Options, on_replace: :update do - field :code, :boolean, default: true - field :input, :boolean, default: false - field :output, :boolean, default: false - field :logs, :boolean, default: false - end - end - - @spec changeset(map()) :: Ecto.Changeset.t() - def changeset(params) do - %__MODULE__{} - |> cast(params, [:content]) - |> validate_required([:content], - message: "Please enter a message before sending" - ) - |> validate_length(:content, - min: 1, - message: "Please enter a message before sending" - ) - |> cast_embed(:options, with: &options_changeset/2) - end - - @spec options_changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() - defp options_changeset(schema, params) do - cast(schema, params, [:code, :input, :output, :logs]) - end - - @spec extract_options(Ecto.Changeset.t()) :: Keyword.t() - def extract_options(changeset) do - data = apply_changes(changeset) - - if data.options do - data.options - |> Map.from_struct() - |> Map.to_list() - else - [] - end - end - end - - @type job :: Job.t() - @type user :: User.t() - @type session :: ChatSession.t() - @type assigns :: %{atom() => any()} - - @impl true - @spec create_session(assigns(), String.t(), Keyword.t()) :: - {:ok, session()} | {:error, term()} - def create_session( - %{selected_job: job, user: user, changeset: changeset} = assigns, - content, - opts \\ [] - ) do - form_options = extract_form_options(changeset) - - meta = %{"message_options" => Enum.into(form_options, %{})} - - meta = - case assigns do - %{follow_run: %{id: run_id}} -> Map.put(meta, "follow_run_id", run_id) - _ -> meta - end - - final_opts = Keyword.put(opts, :meta, meta) - - AiAssistant.create_session(job, user, content, final_opts) - end - - @impl true - @spec get_session!(assigns()) :: session() - def get_session!(%{chat_session_id: session_id, selected_job: job} = assigns) do - AiAssistant.get_session!(session_id) - |> AiAssistant.put_expression_and_adaptor(job.body, job.adaptor) - |> maybe_add_run_logs(job, assigns[:follow_run]) - end - - @impl true - @spec list_sessions(assigns(), :asc | :desc, Keyword.t()) :: %{ - sessions: [session()], - pagination: map() - } - def list_sessions(%{selected_job: job}, sort_direction, opts \\ []) do - AiAssistant.list_sessions(job, sort_direction, opts) - end - - @impl true - @spec more_sessions?(assigns(), integer()) :: boolean() - def more_sessions?(%{selected_job: job}, current_count) do - AiAssistant.has_more_sessions?(job, current_count) - end - - @impl true - @spec save_message(assigns(), String.t()) :: - {:ok, session()} | {:error, term()} - def save_message( - %{session: session, user: user, changeset: changeset} = assigns, - content - ) do - options = extract_form_options(changeset) - - updated_meta = - session.meta - |> Kernel.||(%{}) - |> Map.put("message_options", Enum.into(options, %{})) - - updated_meta = - case assigns do - %{follow_run: %{id: run_id}} -> - Map.put(updated_meta, "follow_run_id", run_id) - - _ -> - updated_meta - end - - AiAssistant.save_message( - session, - %{ - role: :user, - content: content, - user: user - }, - meta: updated_meta - ) - end - - @impl true - @spec query(session(), String.t(), Keyword.t()) :: - {:ok, session()} | {:error, term()} - def query(session, content, opts) do - AiAssistant.query(session, content, opts) - end - - @impl true - @spec chat_input_disabled?(assigns()) :: boolean() - def chat_input_disabled?(%{ - selected_job: job, - can_edit: can_edit, - ai_limit_result: limit_result, - endpoint_available: available?, - pending_message: pending - }) do - !can_edit or - limit_result != :ok or - !available? or - !is_nil(pending.loading) or - job_is_unsaved?(job) - end - - @impl true - @spec input_placeholder() :: String.t() - def input_placeholder do - "Ask about your job code, debugging, or OpenFn adaptors..." - end - - @impl true - @spec chat_title(session()) :: String.t() - def chat_title(session) do - case session do - %{title: title} when is_binary(title) and title != "" -> - title - - %{job: %{name: job_name}} when is_binary(job_name) and job_name != "" -> - "Help with #{job_name}" - - _ -> - "Job Code Help" - end - end - - @impl true - @spec metadata() :: map() - def metadata do - %{ - name: "Job Code Assistant", - description: "Get help with job code, debugging, and OpenFn adaptors", - icon: "hero-cpu-chip", - chat_param: "j-chat" - } - end - - @impl true - @spec disabled_tooltip_message(assigns()) :: String.t() | nil - def disabled_tooltip_message(assigns) do - case {assigns.can_edit, assigns.ai_limit_result, assigns.selected_job} do - {false, _, _} -> - "You are not authorized to use the AI Assistant" - - {_, error, _} when error != :ok -> - ErrorHandler.format_limit_error(error) - - {_, _, %{__meta__: %{state: :built}}} -> - "Save your workflow first to use the AI Assistant" - - _ -> - nil - end - end - - @impl true - @spec form_module() :: module() - def form_module, do: Form - - @impl true - @spec validate_form(map()) :: Ecto.Changeset.t() - def validate_form(params) do - Form.changeset(params) - end - - @impl true - @spec extract_form_options(Ecto.Changeset.t()) :: Keyword.t() - def extract_form_options(changeset) do - Form.extract_options(changeset) - end - - @impl true - @spec render_config_form(assigns()) :: Phoenix.LiveView.Rendered.t() | nil - def render_config_form(assigns) do - if assigns[:handler] && assigns.handler.form_module() == Form do - ~H""" -
- - <.icon name="hero-paper-clip" class="size-4" /> Attach: - - <.inputs_for :let={options} field={@form[:options]}> - <.input type="checkbox" label="Code" field={options[:code]} /> - <.input type="checkbox" label="Logs" field={options[:logs]} /> - -
- """ - else - nil - end - end - - @doc false - @spec maybe_add_run_logs(session(), job(), map() | nil) :: session() - defp maybe_add_run_logs(session, _job, nil), do: session - - defp maybe_add_run_logs(session, job, run) do - logs = Invocation.assemble_logs_for_job_and_run(job.id, run.id) - %{session | logs: logs} - end - - @doc false - @spec job_is_unsaved?(job()) :: boolean() - defp job_is_unsaved?(%{__meta__: %{state: :built}}), do: true - defp job_is_unsaved?(_job), do: false -end diff --git a/lib/lightning_web/live/ai_assistant/modes/workflow_template.ex b/lib/lightning_web/live/ai_assistant/modes/workflow_template.ex deleted file mode 100644 index 52508393aca..00000000000 --- a/lib/lightning_web/live/ai_assistant/modes/workflow_template.ex +++ /dev/null @@ -1,254 +0,0 @@ -defmodule LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate do - @moduledoc """ - AI mode for generating Lightning workflow templates. - - Handles workflow creation through natural language descriptions and - supports external callbacks for workflow code integration. - """ - - use LightningWeb.Live.AiAssistant.ModeBehavior - - alias Lightning.AiAssistant - alias Lightning.AiAssistant.ChatMessage - alias Lightning.AiAssistant.ChatSession - alias LightningWeb.Live.AiAssistant.ErrorHandler - - require Logger - - @type project :: Lightning.Projects.Project.t() - @type user :: Lightning.Accounts.User.t() - @type session :: ChatSession.t() - @type workflow_yaml :: String.t() - @type assigns :: %{atom() => any()} - - @impl true - @spec create_session(assigns(), String.t(), Keyword.t()) :: - {:ok, session()} | {:error, term()} - def create_session( - %{project: project, user: user} = assigns, - content, - _opts \\ [] - ) do - AiAssistant.create_workflow_session( - project, - nil, - assigns[:workflow], - user, - content, - code: assigns[:code] - ) - end - - @impl true - @spec get_session!(assigns()) :: session() - def get_session!(%{chat_session_id: session_id}) do - AiAssistant.get_session!(session_id) - end - - @impl true - @spec list_sessions(assigns(), :asc | :desc, Keyword.t()) :: %{ - sessions: [session()], - pagination: map() - } - def list_sessions(%{project: project} = assigns, sort_direction, opts \\ []) do - workflow = assigns[:workflow] - opts_with_workflow = Keyword.put_new(opts, :workflow, workflow) - AiAssistant.list_sessions(project, sort_direction, opts_with_workflow) - end - - @impl true - @spec more_sessions?(assigns(), integer()) :: boolean() - def more_sessions?(%{project: project}, current_count) do - AiAssistant.has_more_sessions?(project, current_count) - end - - @impl true - @spec save_message(assigns(), String.t()) :: - {:ok, session()} | {:error, term()} - def save_message( - %{session: session, user: user, code: code}, - content - ) do - AiAssistant.save_message( - session, - %{role: :user, content: content, user: user}, - code: code - ) - end - - @impl true - @spec query(session(), String.t(), Keyword.t()) :: - {:ok, session()} | {:error, term()} - def query(session, content, opts \\ []) do - AiAssistant.query_workflow(session, content, opts) - end - - @impl true - @spec chat_input_disabled?(assigns()) :: boolean() - def chat_input_disabled?(%{ - can_edit: can_edit, - ai_limit_result: limit_result, - endpoint_available: available, - pending_message: pending - }) do - !can_edit or - limit_result != :ok or - !available or - !is_nil(pending.loading) - end - - @impl true - @spec input_placeholder() :: String.t() - def input_placeholder do - "Describe the workflow you want to create..." - end - - @impl true - @spec chat_title(session()) :: String.t() - def chat_title(session) do - case session do - %{title: title} when is_binary(title) and title != "" -> - title - - %{project: %{name: project_name}} when is_binary(project_name) -> - "#{project_name} Workflow" - - _ -> - "New Workflow" - end - end - - @impl true - @spec metadata() :: map() - def metadata do - %{ - name: "Workflow Builder", - description: "Generate complete workflows from your descriptions", - icon: "hero-cpu-chip", - chat_param: "w-chat" - } - end - - @impl true - @spec disabled_tooltip_message(assigns()) :: String.t() | nil - def disabled_tooltip_message(assigns) do - case {assigns.can_edit, assigns.ai_limit_result} do - {false, _} -> - "You are not authorized to use the AI Assistant" - - {_, error} when error != :ok -> - ErrorHandler.format_limit_error(error) - - _ -> - nil - end - end - - @impl true - @spec on_message_send(Phoenix.LiveView.Socket.t()) :: - Phoenix.LiveView.Socket.t() - def on_message_send(socket) do - invoke_callback(socket, :on_message_send) - end - - @impl true - @spec on_message_received(Phoenix.LiveView.Socket.t(), session()) :: - Phoenix.LiveView.Socket.t() - def on_message_received(socket, session) do - handle_code_event(socket, session, :on_message_received) - end - - @impl true - @spec on_session_close(Phoenix.LiveView.Socket.t()) :: - Phoenix.LiveView.Socket.t() - def on_session_close(socket) do - invoke_callback(socket, :on_session_close) - end - - @impl true - @spec on_session_open(Phoenix.LiveView.Socket.t(), session()) :: - Phoenix.LiveView.Socket.t() - def on_session_open(socket, session) do - if socket.assigns.selected_message do - socket - else - handle_code_event(socket, session, :on_session_open) - end - end - - @impl true - @spec on_message_selected(Phoenix.LiveView.Socket.t(), ChatMessage.t()) :: - Phoenix.LiveView.Socket.t() - def on_message_selected(socket, message) do - handle_code_event(socket, message, :on_message_selected) - end - - @impl true - @spec form_module() :: module() - def form_module, do: __MODULE__.DefaultForm - - @impl true - @spec validate_form(map()) :: Ecto.Changeset.t() - def validate_form(params) do - form_module().changeset(params) - end - - @impl true - @spec extract_form_options(Ecto.Changeset.t()) :: Keyword.t() - def extract_form_options(_changeset) do - [] - end - - @doc false - @spec invoke_callback(Phoenix.LiveView.Socket.t(), atom(), list()) :: - Phoenix.LiveView.Socket.t() - defp invoke_callback(socket, callback_name, args \\ []) do - callback = get_in(socket.assigns, [:callbacks, callback_name]) - - if callback do - apply(callback, args) - end - - socket - end - - @doc false - @spec handle_code_event( - Phoenix.LiveView.Socket.t(), - session() | ChatMessage.t(), - atom() - ) :: - Phoenix.LiveView.Socket.t() - defp handle_code_event(socket, data, callback_name) do - case extract_code(data) do - nil -> - socket - - code -> - invoke_callback(socket, callback_name, [code, data]) - end - end - - @doc false - @spec extract_code(session() | ChatMessage.t()) :: String.t() | nil - defp extract_code(%ChatSession{messages: messages}) do - messages - |> Enum.reverse() - |> Enum.find_value(&extract_message_code/1) - end - - defp extract_code(%ChatMessage{code: code}) do - if valid_code?(code), do: code - end - - @doc false - @spec extract_message_code(map()) :: String.t() | nil - defp extract_message_code(%{code: code}) do - if valid_code?(code), do: code - end - - @doc false - @spec valid_code?(any()) :: boolean() - defp valid_code?(code) when is_binary(code) and code != "", do: true - defp valid_code?(_), do: false -end diff --git a/lib/lightning_web/live/ai_assistant/quotes.ex b/lib/lightning_web/live/ai_assistant/quotes.ex deleted file mode 100644 index 1dcc07d6162..00000000000 --- a/lib/lightning_web/live/ai_assistant/quotes.ex +++ /dev/null @@ -1,139 +0,0 @@ -defmodule LightningWeb.AiAssistant.Quotes do - @moduledoc """ - Quotes about AI and human responsibility for the AI Assistant onboarding. - """ - - @type quote :: %{ - required(:quote) => String.t(), - required(:author) => String.t(), - optional(:source_attribute) => String.t(), - required(:source_link) => String.t(), - optional(:enabled) => boolean() - } - - @quotes [ - %{ - quote: "What hath God wrought?", - author: "Samuel Morse", - source_attribute: "Samuel Morse in the first telegraph message", - source_link: "https://www.history.com", - enabled: true - }, - %{ - quote: "All models are wrong, but some are useful", - author: "George Box", - source_attribute: "Wikipedia", - source_link: "https://en.wikipedia.org/wiki/All_models_are_wrong", - enabled: true - }, - %{ - quote: "AI is neither artificial nor intelligent", - author: "Kate Crawford", - source_link: - "https://www.wired.com/story/researcher-says-ai-not-artificial-intelligent/", - enabled: true - }, - %{ - quote: "With big data comes big responsibilities", - author: "Kate Crawford", - source_link: - "https://www.technologyreview.com/2011/10/05/190904/with-big-data-comes-big-responsibilities", - enabled: true - }, - %{ - quote: "AI is holding the internet hostage", - author: "Bryan Walsh", - source_link: - "https://www.vox.com/technology/352849/openai-chatgpt-google-meta-artificial-intelligence-vox-media-chatbots", - enabled: true - }, - %{ - quote: "Remember the human", - author: "OpenFn Responsible AI Policy", - source_link: "https://www.openfn.org/ai", - enabled: true - }, - %{ - quote: "Be skeptical, but don't be cynical", - author: "OpenFn Responsible AI Policy", - source_link: "https://www.openfn.org/ai", - enabled: true - }, - %{ - quote: - "Out of the crooked timber of humanity no straight thing was ever made", - author: "Emmanuel Kant", - source_link: - "https://www.goodreads.com/quotes/74482-out-of-the-crooked-timber-of-humanity-no-straight-thing" - }, - %{ - quote: "The more helpful our phones get, the harder it is to be ourselves", - author: "Brain Chrstian", - source_attribute: "The most human Human", - source_link: - "https://www.goodreads.com/book/show/8884400-the-most-human-human" - }, - %{ - quote: - "If a machine can think, it might think more intelligently than we do, and then where should we be?", - author: "Alan Turing", - source_link: - "https://turingarchive.kings.cam.ac.uk/publications-lectures-and-talks-amtb/amt-b-5" - }, - %{ - quote: - "If you make an algorithm, and let it optimise for a certain value, then it won't care what you really want", - author: "Tom Chivers", - source_link: - "https://forum.effectivealtruism.org/posts/feNJWCo4LbsoKbRon/interview-with-tom-chivers-ai-is-a-plausible-existential" - }, - %{ - quote: - "By far the greatest danger of Artificial Intelligence is that people conclude too early that they understand it", - author: "Eliezer Yudkowsky", - source_attribute: - "Artificial Intelligence as a Positive and Negative Factor in Global Risk", - source_link: - "https://zoo.cs.yale.edu/classes/cs671/12f/12f-papers/yudkowsky-ai-pos-neg-factor.pdf" - }, - %{ - quote: - "The AI does not hate you, nor does it love you, but you are made out of atoms which it can use for something else", - author: "Eliezer Yudkowsky", - source_attribute: - "Artificial Intelligence as a Positive and Negative Factor in Global Risk", - source_link: - "https://zoo.cs.yale.edu/classes/cs671/12f/12f-papers/yudkowsky-ai-pos-neg-factor.pdf" - }, - %{ - quote: - "World domination is such an ugly phrase. I prefer to call it world optimisation", - author: "Eliezer Yudkowsky", - source_link: "https://hpmor.com/" - }, - %{ - quote: "AI is not ultimately responsible for its output: we are", - author: "OpenFn Responsible AI Policy", - source_link: "https://www.openfn.org/ai" - } - ] - - @doc """ - Returns enabled quotes only. - """ - @spec enabled() :: [quote()] - def enabled do - Enum.filter(@quotes, fn quote -> - Map.get(quote, :enabled, false) - end) - end - - @doc """ - Returns a random enabled quote. - """ - @spec random_enabled() :: quote() - def random_enabled do - enabled() - |> Enum.random() - end -end diff --git a/lib/lightning_web/live/workflow_live/workflow_ai_chat_component.ex b/lib/lightning_web/live/workflow_live/workflow_ai_chat_component.ex deleted file mode 100644 index dc5a0a6ad0e..00000000000 --- a/lib/lightning_web/live/workflow_live/workflow_ai_chat_component.ex +++ /dev/null @@ -1,284 +0,0 @@ -defmodule LightningWeb.WorkflowLive.WorkflowAiChatComponent do - @moduledoc """ - LiveView component for the persistent workflow AI chat panel. - - This component provides AI assistance for existing workflows, allowing users to - modify workflows using natural language descriptions while preserving existing - job code. - """ - use LightningWeb, :live_component - - alias Phoenix.LiveView.JS - - require Logger - - @impl true - def mount(socket) do - {:ok, - assign(socket, - workflow_code: nil, - workflow_params: nil, - session_or_message: nil - )} - end - - @impl true - def update( - %{ - action: :workflow_code_generated, - workflow_code: code, - session_or_message: session_or_message - }, - socket - ) do - {:ok, - socket - |> assign(session_or_message: session_or_message) - |> push_event("template_selected", %{template: code})} - end - - def update(assigns, socket) do - {:ok, socket |> assign(assigns)} - end - - @impl true - def handle_event("template-parsed", %{"workflow" => params}, socket) do - if Lightning.Workflows.ParamsComparator.equivalent?( - socket.assigns.workflow_params, - params - ) do - {:noreply, socket} - else - changeset = - Lightning.Workflows.Workflow.changeset(socket.assigns.workflow, params) - - if changeset.valid? do - notify_parent(:workflow_params_changed, %{"workflow" => params}) - {:noreply, assign(socket, :workflow_params, params)} - else - error = error_from_changeset(changeset) - {:noreply, send_error(socket, error)} - end - end - end - - def handle_event("template-parse-error", %{"error" => error}, socket) do - {:noreply, send_error(socket, error)} - end - - defp send_error(socket, error) do - Logger.error("Workflow code parse failed: #{inspect(error)}") - - send_update( - LightningWeb.AiAssistant.Component, - id: socket.assigns.ai_assistant_component_id, - action: :code_error, - error: error, - session_or_message: socket.assigns.session_or_message - ) - - socket - end - - @spec build_ai_callbacks(String.t()) :: map() - defp build_ai_callbacks(component_id) do - %{ - on_message_selected: &send_workflow_update(component_id, &1, &2), - on_message_received: fn code, session_or_message -> - notify_parent(:canvas_state_changed, %{sending_ai_message: false}) - send_workflow_update(component_id, code, session_or_message) - end, - on_message_send: fn -> - notify_parent(:canvas_state_changed, %{sending_ai_message: true}) - end, - on_session_open: &send_workflow_update(component_id, &1, &2) - } - end - - @spec send_workflow_update(String.t(), String.t() | nil, any()) :: :ok - defp send_workflow_update(component_id, code, session_or_message) do - send_update(__MODULE__, - id: component_id, - action: :workflow_code_generated, - workflow_code: code, - session_or_message: session_or_message - ) - end - - @spec slide_in() :: JS.t() - defp slide_in do - JS.remove_class("opacity-0") - |> JS.transition( - {"transform transition-transform duration-500 ease-in-out", - "-translate-x-full", "translate-x-0"}, - time: 500 - ) - end - - @spec slide_out() :: JS.t() - defp slide_out do - JS.transition( - {"transform transition-transform duration-500 ease-in-out", - "translate-x-0", "-translate-x-full"}, - time: 500 - ) - end - - @impl true - def render(assigns) do - assigns = assign(assigns, :callbacks, build_ai_callbacks(assigns.id)) - - ~H""" -
-
-
- <.live_component - module={LightningWeb.AiAssistant.Component} - mode={:workflow} - can_edit={@can_edit} - project={@project} - workflow={@workflow} - user={@user} - chat_session_id={@chat_session_id} - code={@workflow_code} - query_params={@query_params} - base_url={@base_url} - action={if(@chat_session_id, do: :show, else: :new)} - callbacks={@callbacks} - id={@ai_assistant_component_id} - /> -
-
-
- """ - end - - defp notify_parent(action, payload) do - send(self(), {:ai_assistant, action, payload}) - end - - defp error_from_changeset(changeset) do - errors = traverse_changeset_errors(changeset) - - Enum.map_join(errors, "\n", fn {path, field, message} -> - "#{Enum.join(path ++ [field], ".")} - #{message}" - end) - end - - defp traverse_changeset_errors(changeset, path \\ []) do - current_errors = - changeset.errors - |> Enum.map(fn {field, {message, _opts}} -> - {path, field, message} - end) - - nested_errors = - [:jobs, :edges, :triggers] - |> Enum.flat_map(fn assoc -> - case Map.get(changeset.changes, assoc) do - nil -> - [] - - changesets when is_list(changesets) -> - process_changeset_list(changesets, path, assoc) - - %Ecto.Changeset{} = cs -> - traverse_changeset_errors(cs, path ++ [assoc]) - - _ -> - [] - end - end) - - current_errors ++ nested_errors - end - - defp process_changeset_list(changesets, path, assoc) do - changesets - |> Enum.with_index() - |> Enum.flat_map(fn {cs, index} -> - case cs do - %Ecto.Changeset{} -> - identifier = get_changeset_identifier(cs) - new_path = path ++ [assoc, identifier || index] - traverse_changeset_errors(cs, new_path) - - _ -> - [] - end - end) - end - - defp get_changeset_identifier(changeset) do - data = changeset.data - - cond do - Map.has_key?(data, :name) -> - get_name_identifier(changeset, data) - - Map.has_key?(data, :source_job_id) || - Map.has_key?(data, :source_trigger_id) -> - get_edge_identifier(changeset, data) - - true -> - get_id_identifier(changeset, data) - end - end - - defp get_name_identifier(changeset, data) do - case get_in(changeset.changes, [:name]) || Map.get(data, :name) do - nil -> nil - name -> "\"#{name}\"" - end - end - - defp get_edge_identifier(changeset, data) do - source = get_edge_source(changeset, data) - target = get_edge_target(changeset, data) - "#{source}→#{target}" - end - - defp get_edge_source(changeset, data) do - cond do - source_job_id = - get_in(changeset.changes, [:source_job_id]) || - Map.get(data, :source_job_id) -> - "job:#{shorten_id(source_job_id)}" - - source_trigger_id = - get_in(changeset.changes, [:source_trigger_id]) || - Map.get(data, :source_trigger_id) -> - "trigger:#{shorten_id(source_trigger_id)}" - - true -> - "unknown" - end - end - - defp get_edge_target(changeset, data) do - target = - get_in(changeset.changes, [:target_job_id]) || - Map.get(data, :target_job_id) - - if target, do: "job:#{shorten_id(target)}", else: "unknown" - end - - defp get_id_identifier(changeset, data) do - case get_in(changeset.changes, [:id]) || Map.get(data, :id) do - nil -> nil - id -> "id:#{shorten_id(id)}" - end - end - - defp shorten_id(id) when is_binary(id) do - id - end - - defp shorten_id(_), do: "unknown" -end diff --git a/test/lightning/config_test.exs b/test/lightning/config_test.exs index ff3ef582fa9..e741fc4bc39 100644 --- a/test/lightning/config_test.exs +++ b/test/lightning/config_test.exs @@ -70,15 +70,6 @@ defmodule Lightning.Configtest do assert expected == actual end - test "returns configured AI modes" do - modes = API.ai_assistant_modes() - - assert modes[:job] == LightningWeb.Live.AiAssistant.Modes.JobCode - - assert modes[:workflow] == - LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate - end - test "returns number of seconds that constitutes stalled run threshold" do expected = extract_from_config( diff --git a/test/lightning_web/ai_assistant/error_handler_test.exs b/test/lightning_web/ai_assistant/error_handler_test.exs deleted file mode 100644 index 220e08686e5..00000000000 --- a/test/lightning_web/ai_assistant/error_handler_test.exs +++ /dev/null @@ -1,349 +0,0 @@ -defmodule LightningWeb.AiAssistant.ErrorHandlerTest do - use ExUnit.Case, async: true - - @moduletag :capture_log - - alias LightningWeb.Live.AiAssistant.ErrorHandler - - describe "format_error/1" do - test "formats direct string errors" do - assert ErrorHandler.format_error({:error, "Something went wrong"}) == - "Something went wrong" - - assert ErrorHandler.format_error({:error, "Network timeout occurred"}) == - "Network timeout occurred" - end - - test "handles empty string errors" do - assert ErrorHandler.format_error({:error, ""}) == - "Oops! Something went wrong. Please try again." - end - - test "formats Ecto changeset errors" do - changeset = %Ecto.Changeset{ - errors: [ - content: {"can't be blank", [validation: :required]}, - title: - {"should be at least %{count} character(s)", - [count: 3, validation: :length]} - ] - } - - result = ErrorHandler.format_error({:error, changeset}) - - assert result == - "Content can't be blank, Title should be at least 3 character(s)" - end - - test "formats single changeset error" do - changeset = %Ecto.Changeset{ - errors: [content: {"can't be blank", [validation: :required]}] - } - - result = ErrorHandler.format_error({:error, changeset}) - assert result == "Content can't be blank" - end - - test "handles empty changeset errors" do - changeset = %Ecto.Changeset{errors: []} - - result = ErrorHandler.format_error({:error, changeset}) - assert result == "Could not save message. Please try again." - end - - test "handles malformed changeset errors" do - changeset = %Ecto.Changeset{ - errors: [invalid_field: {"malformed", []}] - } - - result = ErrorHandler.format_error({:error, changeset}) - assert result == "Invalid field malformed" - end - - test "formats structured errors with text field" do - error = - {:error, :custom_reason, %{text: "Service maintenance in progress"}} - - result = ErrorHandler.format_error(error) - assert result == "Service maintenance in progress" - end - - test "handles structured errors with empty text" do - error = {:error, :custom_reason, %{text: ""}} - result = ErrorHandler.format_error(error) - assert result == "Oops! Something went wrong. Please try again." - end - - test "formats network timeout errors" do - result = ErrorHandler.format_error({:error, :timeout}) - assert result == "Request timed out. Please try again." - end - - test "formats connection refused errors" do - result = ErrorHandler.format_error({:error, :econnrefused}) - assert result == "Unable to reach the AI server. Please try again later." - end - - test "formats network errors" do - result = ErrorHandler.format_error({:error, :network_error}) - assert result == "Network error occurred. Please check your connection." - end - - test "formats unknown atom errors" do - result = ErrorHandler.format_error({:error, :unknown_error}) - assert result == "An error occurred: unknown_error. Please try again." - end - - test "handles unexpected error formats" do - assert ErrorHandler.format_error({:unexpected, :format}) == - "Oops! Something went wrong. Please try again." - - assert ErrorHandler.format_error("plain string") == - "Oops! Something went wrong. Please try again." - - assert ErrorHandler.format_error(nil) == - "Oops! Something went wrong. Please try again." - - assert ErrorHandler.format_error(%{random: "map"}) == - "Oops! Something went wrong. Please try again." - end - end - - describe "format_limit_error/1" do - test "formats structured limit errors with text" do - error = - {:error, :quota_exceeded, %{text: "Monthly AI usage limit reached"}} - - result = ErrorHandler.format_limit_error(error) - assert result == "Monthly AI usage limit reached" - end - - test "handles structured limit errors with empty text" do - error = {:error, :quota_exceeded, %{text: ""}} - result = ErrorHandler.format_limit_error(error) - assert result == "AI usage limit reached. Please try again later." - end - - test "formats quota exceeded errors" do - result = ErrorHandler.format_limit_error({:error, :quota_exceeded}) - - assert result == - "AI usage limit reached. Please try again later or contact support." - end - - test "formats rate limited errors" do - result = ErrorHandler.format_limit_error({:error, :rate_limited}) - - assert result == - "Too many requests. Please wait a moment before trying again." - end - - test "formats insufficient credits errors" do - result = ErrorHandler.format_limit_error({:error, :insufficient_credits}) - - assert result == - "Insufficient AI credits. Please contact your administrator." - end - - test "handles unknown limit errors" do - assert ErrorHandler.format_limit_error({:error, :unknown_limit}) == - "AI usage limit reached. Please try again later." - - assert ErrorHandler.format_limit_error(:invalid_format) == - "AI usage limit reached. Please try again later." - - assert ErrorHandler.format_limit_error(nil) == - "AI usage limit reached. Please try again later." - end - end - - describe "extract_changeset_errors/1" do - test "extracts single field error" do - changeset = %Ecto.Changeset{ - errors: [content: {"can't be blank", [validation: :required]}] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == ["Content can't be blank"] - end - - test "extracts multiple field errors" do - changeset = %Ecto.Changeset{ - errors: [ - content: {"can't be blank", [validation: :required]}, - email: {"has invalid format", [validation: :format]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Content can't be blank" in result - assert "Email has invalid format" in result - assert length(result) == 2 - end - - test "handles complex field names" do - changeset = %Ecto.Changeset{ - errors: [ - user_email: {"has invalid format", [validation: :format]}, - workflow_name: {"should be at least %{count} character(s)", [count: 3]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "User email has invalid format" in result - assert "Workflow name should be at least 3 character(s)" in result - end - - test "interpolates error message parameters" do - changeset = %Ecto.Changeset{ - errors: [ - title: - {"should be at least %{count} character(s)", - [count: 5, validation: :length]}, - age: - {"must be greater than %{number}", [number: 18, validation: :number]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Title should be at least 5 character(s)" in result - assert "Age must be greater than 18" in result - end - - test "handles errors without interpolation parameters" do - changeset = %Ecto.Changeset{ - errors: [ - email: {"is invalid", []}, - password: {"confirmation does not match", []} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Email is invalid" in result - assert "Password confirmation does not match" in result - end - - test "filters out empty error messages" do - changeset = %Ecto.Changeset{ - errors: [ - content: {"can't be blank", [validation: :required]}, - empty_field: {"", []} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == ["Content can't be blank"] - end - - test "handles non-changeset input" do - assert ErrorHandler.extract_changeset_errors("not a changeset") == [] - assert ErrorHandler.extract_changeset_errors(nil) == [] - assert ErrorHandler.extract_changeset_errors(%{}) == [] - end - - test "handles changeset with no errors" do - changeset = %Ecto.Changeset{errors: []} - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == [] - end - end - - describe "interpolate_error_message/2 (private function behavior)" do - test "interpolates count parameter in validation errors" do - changeset = %Ecto.Changeset{ - errors: [title: {"should be at least %{count} character(s)", [count: 5]}] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == ["Title should be at least 5 character(s)"] - end - - test "interpolates multiple parameters" do - changeset = %Ecto.Changeset{ - errors: [ - score: {"must be between %{min} and %{max}", [min: 0, max: 100]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == ["Score must be between 0 and 100"] - end - - test "handles non-existent interpolation parameters" do - changeset = %Ecto.Changeset{ - errors: [field: {"has %{nonexistent} parameter", [count: 5]}] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert result == ["Field has %{nonexistent} parameter"] - end - - test "converts non-string values to strings for interpolation" do - changeset = %Ecto.Changeset{ - errors: [ - number_field: {"must be %{value}", [value: 42]}, - boolean_field: {"is %{flag}", [flag: true]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Number field must be 42" in result - assert "Boolean field is true" in result - end - end - - describe "edge cases and error handling" do - test "handles deeply nested changeset errors" do - changeset = %Ecto.Changeset{ - errors: [ - "user.profile.name": {"can't be blank", [validation: :required]}, - "settings.preferences.theme": {"is invalid", [validation: :inclusion]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "User.profile.name can't be blank" in result - assert "Settings.preferences.theme is invalid" in result - end - - test "handles special characters in field names" do - changeset = %Ecto.Changeset{ - errors: [ - field_with_underscores: {"is invalid", []}, - "field-with-dashes": {"is required", []} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Field with underscores is invalid" in result - assert "Field-with-dashes is required" in result - end - - test "handles nil and empty interpolation values" do - changeset = %Ecto.Changeset{ - errors: [ - nil_field: {"value is %{nil_value}", [nil_value: nil]}, - empty_field: {"value is %{empty_value}", [empty_value: ""]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - assert "Nil field value is nil" in result - assert "Empty field value is \"\"" in result - end - - test "handles errors with complex data structures" do - changeset = %Ecto.Changeset{ - errors: [ - complex_field: - {"invalid data: %{data}", [data: %{key: "value", nested: [1, 2, 3]}]} - ] - } - - result = ErrorHandler.extract_changeset_errors(changeset) - # Should convert the map to string representation - assert length(result) == 1 - assert String.starts_with?(hd(result), "Complex field invalid data:") - end - end -end diff --git a/test/lightning_web/ai_assistant/job_code_test.exs b/test/lightning_web/ai_assistant/job_code_test.exs deleted file mode 100644 index 99fcf6e4000..00000000000 --- a/test/lightning_web/ai_assistant/job_code_test.exs +++ /dev/null @@ -1,244 +0,0 @@ -defmodule LightningWeb.AiAssistant.Modes.JobCodeTest do - use Lightning.DataCase, async: true - - @moduletag :capture_log - - alias LightningWeb.Live.AiAssistant.Modes.JobCode - - describe "chat_input_disabled?/1" do - test "disables when job is unsaved (state: :built)" do - assigns = %{ - selected_job: %{__meta__: %{state: :built}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns) == true - end - - test "enables when job is saved (state: :loaded)" do - assigns = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns) == false - end - - test "disables when user lacks edit permissions" do - assigns = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: false, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns) == true - end - - test "disables when AI limits reached" do - assigns = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: {:error, :quota_exceeded}, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns) == true - end - - test "disables when endpoint unavailable" do - assigns = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: false, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns) == true - end - - test "disables when message is pending" do - assigns = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: "processing"} - } - - assert JobCode.chat_input_disabled?(assigns) == true - end - end - - describe "input_placeholder/0" do - test "returns job-specific placeholder" do - assert JobCode.input_placeholder() == - "Ask about your job code, debugging, or OpenFn adaptors..." - end - end - - describe "chat_title/1" do - test "uses custom title when available" do - session = %{title: "Debug HTTP 401 error"} - assert JobCode.chat_title(session) == "Debug HTTP 401 error" - end - - test "uses job name when available" do - session = %{job: %{name: "Fetch Salesforce Data"}} - assert JobCode.chat_title(session) == "Help with Fetch Salesforce Data" - end - - test "falls back to default title" do - session = %{} - assert JobCode.chat_title(session) == "Job Code Help" - end - - test "prefers custom title over job name" do - session = %{ - title: "Custom Debug Session", - job: %{name: "Some Job"} - } - - assert JobCode.chat_title(session) == "Custom Debug Session" - end - - test "handles empty title gracefully" do - session = %{title: "", job: %{name: "Test Job"}} - assert JobCode.chat_title(session) == "Help with Test Job" - end - - test "handles empty job name gracefully" do - session = %{job: %{name: ""}} - assert JobCode.chat_title(session) == "Job Code Help" - end - end - - describe "metadata/0" do - test "returns correct metadata" do - meta = JobCode.metadata() - - assert meta.name == "Job Code Assistant" - - assert meta.description == - "Get help with job code, debugging, and OpenFn adaptors" - - assert meta.icon == "hero-cpu-chip" - end - end - - describe "disabled_tooltip_message/1" do - test "returns permission message when user cannot edit" do - assigns = %{ - can_edit: false, - ai_limit_result: :ok, - selected_job: %{__meta__: %{state: :loaded}} - } - - message = JobCode.disabled_tooltip_message(assigns) - - assert message == "You are not authorized to use the AI Assistant" - end - - test "returns limit message when AI limits exceeded" do - assigns = %{ - can_edit: true, - ai_limit_result: {:error, :quota_exceeded}, - selected_job: %{__meta__: %{state: :loaded}} - } - - message = JobCode.disabled_tooltip_message(assigns) - - assert is_binary(message) - assert String.contains?(message, "limit") - end - - test "returns save message when job is unsaved" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - selected_job: %{__meta__: %{state: :built}} - } - - message = JobCode.disabled_tooltip_message(assigns) - - assert message == "Save your workflow first to use the AI Assistant" - end - - test "returns nil when input should be enabled" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - selected_job: %{__meta__: %{state: :loaded}} - } - - message = JobCode.disabled_tooltip_message(assigns) - - assert message == nil - end - end - - describe "error_message/1" do - test "formats errors using ErrorHandler" do - error = {:error, "Job compilation failed"} - - message = JobCode.error_message(error) - - assert message == "Job compilation failed" - end - end - - describe "job_is_unsaved? (via chat_input_disabled?)" do - test "detects unsaved jobs" do - assigns_unsaved = %{ - selected_job: %{__meta__: %{state: :built}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assigns_saved = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns_unsaved) == true - assert JobCode.chat_input_disabled?(assigns_saved) == false - end - end - - describe "has_reached_limit? (via chat_input_disabled?)" do - test "detects limit conditions" do - assigns_limited = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: {:error, :rate_limited}, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assigns_ok = %{ - selected_job: %{__meta__: %{state: :loaded}}, - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert JobCode.chat_input_disabled?(assigns_limited) == true - assert JobCode.chat_input_disabled?(assigns_ok) == false - end - end -end diff --git a/test/lightning_web/ai_assistant/mode_registry_test.exs b/test/lightning_web/ai_assistant/mode_registry_test.exs deleted file mode 100644 index d4fffbc9133..00000000000 --- a/test/lightning_web/ai_assistant/mode_registry_test.exs +++ /dev/null @@ -1,67 +0,0 @@ -defmodule LightningWeb.AiAssistant.ModeRegistryTest do - use ExUnit.Case, async: true - - @moduletag :capture_log - - import Mox - - alias LightningWeb.Live.AiAssistant.ModeRegistry - alias LightningWeb.Live.AiAssistant.Modes.{JobCode, WorkflowTemplate} - - setup :verify_on_exit! - - setup do - stub(Lightning.MockConfig, :ai_assistant_modes, fn -> - %{ - job: JobCode, - workflow: WorkflowTemplate - } - end) - - :ok - end - - describe "get_handler/1" do - test "returns correct handler for known modes" do - assert ModeRegistry.get_handler(:job) == JobCode - assert ModeRegistry.get_handler(:workflow) == WorkflowTemplate - end - - test "falls back to JobCode for unknown modes" do - assert ModeRegistry.get_handler(:nonexistent) == JobCode - assert ModeRegistry.get_handler(nil) == JobCode - end - end - - describe "register_modes/0" do - test "returns the mode registry from config" do - modes = ModeRegistry.register_modes() - - assert is_map(modes) - assert modes[:job] == JobCode - assert modes[:workflow] == WorkflowTemplate - end - - test "handles empty configuration" do - stub(Lightning.MockConfig, :ai_assistant_modes, fn -> %{} end) - - modes = ModeRegistry.register_modes() - assert modes == %{} - end - - test "handles custom modes from config" do - custom_mode_module = CustomTestMode - - stub(Lightning.MockConfig, :ai_assistant_modes, fn -> - %{ - job: JobCode, - workflow: WorkflowTemplate, - custom: custom_mode_module - } - end) - - modes = ModeRegistry.register_modes() - assert modes[:custom] == custom_mode_module - end - end -end diff --git a/test/lightning_web/ai_assistant/workflow_template_test.exs b/test/lightning_web/ai_assistant/workflow_template_test.exs deleted file mode 100644 index 672c264be24..00000000000 --- a/test/lightning_web/ai_assistant/workflow_template_test.exs +++ /dev/null @@ -1,466 +0,0 @@ -defmodule LightningWeb.AiAssistant.Modes.WorkflowTemplateTest do - use Lightning.DataCase, async: true - - @moduletag :capture_log - - alias LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate - - describe "chat_input_disabled?/1" do - test "disables when user lacks edit permissions" do - assigns = %{ - can_edit: false, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == true - end - - test "disables when AI limits reached" do - assigns = %{ - can_edit: true, - ai_limit_result: {:error, :quota_exceeded}, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == true - end - - test "disables when endpoint unavailable" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - endpoint_available: false, - pending_message: %{loading: nil} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == true - end - - test "disables when message is loading" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: "processing"} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == true - end - - test "enables when all conditions are met" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == false - end - - test "does not check job save state (unlike JobCode mode)" do - assigns = %{ - can_edit: true, - ai_limit_result: :ok, - endpoint_available: true, - pending_message: %{loading: nil}, - selected_job: %{__meta__: %{state: :built}} - } - - assert WorkflowTemplate.chat_input_disabled?(assigns) == false - end - end - - describe "input_placeholder/0" do - test "returns workflow-specific placeholder" do - assert WorkflowTemplate.input_placeholder() == - "Describe the workflow you want to create..." - end - end - - describe "chat_title/1" do - test "uses custom title when available" do - session = %{title: "Salesforce Integration Workflow"} - - assert WorkflowTemplate.chat_title(session) == - "Salesforce Integration Workflow" - end - - test "uses project name when available" do - session = %{project: %{name: "Customer Data Platform"}} - - assert WorkflowTemplate.chat_title(session) == - "Customer Data Platform Workflow" - end - - test "falls back to default title" do - session = %{} - assert WorkflowTemplate.chat_title(session) == "New Workflow" - end - - test "prefers custom title over project name" do - session = %{ - title: "Custom Workflow Name", - project: %{name: "Some Project"} - } - - assert WorkflowTemplate.chat_title(session) == "Custom Workflow Name" - end - - test "handles empty title gracefully" do - session = %{title: "", project: %{name: "Test Project"}} - assert WorkflowTemplate.chat_title(session) == "Test Project Workflow" - end - - test "handles nil project gracefully" do - session = %{project: nil} - assert WorkflowTemplate.chat_title(session) == "New Workflow" - end - end - - describe "metadata/0" do - test "returns correct metadata" do - meta = WorkflowTemplate.metadata() - - assert meta.name == "Workflow Builder" - - assert meta.description == - "Generate complete workflows from your descriptions" - - assert meta.icon == "hero-cpu-chip" - assert meta.chat_param == "w-chat" - end - end - - describe "disabled_tooltip_message/1" do - test "returns permission message when user cannot edit" do - assigns = %{can_edit: false, ai_limit_result: :ok} - - message = WorkflowTemplate.disabled_tooltip_message(assigns) - - assert message == "You are not authorized to use the AI Assistant" - end - - test "returns quota exceeded message when AI limits reached" do - assigns = %{ - can_edit: true, - ai_limit_result: {:error, :quota_exceeded} - } - - message = WorkflowTemplate.disabled_tooltip_message(assigns) - - assert message == - "AI usage limit reached. Please try again later or contact support." - end - - test "returns rate limited message when rate limited" do - assigns = %{ - can_edit: true, - ai_limit_result: {:error, :rate_limited} - } - - message = WorkflowTemplate.disabled_tooltip_message(assigns) - - assert message == - "Too many requests. Please wait a moment before trying again." - end - - test "returns nil when input should be enabled" do - assigns = %{can_edit: true, ai_limit_result: :ok} - - message = WorkflowTemplate.disabled_tooltip_message(assigns) - - assert message == nil - end - end - - describe "error_message/1" do - test "formats string errors" do - error = {:error, "Something went wrong"} - message = WorkflowTemplate.error_message(error) - assert message == "Something went wrong" - end - - test "formats timeout errors" do - error = {:error, :timeout} - message = WorkflowTemplate.error_message(error) - assert message == "Request timed out. Please try again." - end - - test "formats connection errors" do - error = {:error, :econnrefused} - message = WorkflowTemplate.error_message(error) - assert message == "Unable to reach the AI server. Please try again later." - end - - test "formats generic errors" do - error = "unexpected error" - message = WorkflowTemplate.error_message(error) - assert message == "Oops! Something went wrong. Please try again." - end - end - - describe "callbacks" do - test "on_message_send/1 invokes callback if present" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_send: fn -> send(test_pid, :message_sent) end - } - } - } - - result = WorkflowTemplate.on_message_send(socket) - assert result == socket - assert_received :message_sent - end - - test "on_message_send/1 returns socket unchanged if no callback" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{callbacks: %{}} - } - - result = WorkflowTemplate.on_message_send(socket) - assert result == socket - end - - test "on_message_received/2 extracts code from session" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_received: fn code, session -> - send(test_pid, {:received, code, session.id}) - end - } - } - } - - session = %Lightning.AiAssistant.ChatSession{ - id: "session-123", - messages: [ - %Lightning.AiAssistant.ChatMessage{code: nil}, - %Lightning.AiAssistant.ChatMessage{code: ""}, - %Lightning.AiAssistant.ChatMessage{code: "name: Test\njobs: []"} - ] - } - - result = WorkflowTemplate.on_message_received(socket, session) - assert result == socket - assert_received {:received, "name: Test\njobs: []", "session-123"} - end - - test "on_message_selected/2 extracts code from message" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_selected: fn code, message -> - send(test_pid, {:selected, code, message.id}) - end - } - } - } - - message = %Lightning.AiAssistant.ChatMessage{ - id: "msg-123", - code: "name: Selected\njobs: []" - } - - result = WorkflowTemplate.on_message_selected(socket, message) - assert result == socket - assert_received {:selected, "name: Selected\njobs: []", "msg-123"} - end - - test "callbacks handle nil code gracefully" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_selected: fn _code, _msg -> - flunk("Should not call callback for nil code") - end - } - } - } - - message = %Lightning.AiAssistant.ChatMessage{code: nil} - - result = WorkflowTemplate.on_message_selected(socket, message) - assert result == socket - end - - test "callbacks handle empty code gracefully" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_selected: fn _code, _msg -> - flunk("Should not call callback for empty code") - end - } - } - } - - message = %Lightning.AiAssistant.ChatMessage{code: ""} - - result = WorkflowTemplate.on_message_selected(socket, message) - assert result == socket - end - end - - describe "form handling" do - test "validate_form/1 creates changeset from params" do - params = %{"content" => "test content"} - changeset = WorkflowTemplate.validate_form(params) - - assert %Ecto.Changeset{} = changeset - assert Ecto.Changeset.get_field(changeset, :content) == "test content" - end - - test "extract_form_options/1 returns empty list" do - changeset = WorkflowTemplate.validate_form(%{"content" => "test"}) - options = WorkflowTemplate.extract_form_options(changeset) - - assert options == [] - end - end - - # Add these test blocks to the existing test file: - - describe "on_session_close/1" do - test "invokes callback if present" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_session_close: fn -> send(test_pid, :session_closed) end - } - } - } - - result = WorkflowTemplate.on_session_close(socket) - assert result == socket - assert_received :session_closed - end - - test "returns socket unchanged if no callback" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{callbacks: %{}} - } - - result = WorkflowTemplate.on_session_close(socket) - assert result == socket - end - end - - describe "on_session_open/2" do - test "extracts code when selected_message is nil" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - selected_message: nil, - callbacks: %{ - on_session_open: fn code, session -> - send(test_pid, {:opened, code, session.id}) - end - } - } - } - - session = %Lightning.AiAssistant.ChatSession{ - id: "session-456", - messages: [ - %Lightning.AiAssistant.ChatMessage{code: "name: Open\njobs: []"} - ] - } - - result = WorkflowTemplate.on_session_open(socket, session) - assert result == socket - assert_received {:opened, "name: Open\njobs: []", "session-456"} - end - - test "skips extraction when selected_message exists" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - selected_message: %{id: "existing"}, - callbacks: %{ - on_session_open: fn _code, _session -> - flunk("Should not call callback when message is selected") - end - } - } - } - - session = %Lightning.AiAssistant.ChatSession{ - messages: [ - %Lightning.AiAssistant.ChatMessage{code: "name: Test\njobs: []"} - ] - } - - result = WorkflowTemplate.on_session_open(socket, session) - assert result == socket - end - end - - describe "form_module/0" do - test "returns the default form module" do - assert WorkflowTemplate.form_module() == WorkflowTemplate.DefaultForm - end - end - - describe "edge cases" do - test "extract_code handles session with no messages" do - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - selected_message: nil, - callbacks: %{ - on_session_open: fn _code, _session -> - flunk("Should not call callback for empty messages") - end - } - } - } - - session = %Lightning.AiAssistant.ChatSession{ - messages: [] - } - - result = WorkflowTemplate.on_session_open(socket, session) - assert result == socket - end - - test "handles code in different positions" do - test_pid = self() - - socket = %Phoenix.LiveView.Socket{ - assigns: %{ - callbacks: %{ - on_message_received: fn code, _session -> - send(test_pid, {:found_code, code}) - end - } - } - } - - # Code should be found from the last valid message (reverse order) - session = %Lightning.AiAssistant.ChatSession{ - messages: [ - %Lightning.AiAssistant.ChatMessage{code: "name: First\njobs: []"}, - %Lightning.AiAssistant.ChatMessage{code: nil}, - %Lightning.AiAssistant.ChatMessage{code: "name: Last\njobs: []"} - ] - } - - WorkflowTemplate.on_message_received(socket, session) - assert_received {:found_code, "name: Last\njobs: []"} - end - end -end diff --git a/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs b/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs deleted file mode 100644 index db1f3f4d6c8..00000000000 --- a/test/lightning_web/live/workflow_live/ai_assistant_component_test.exs +++ /dev/null @@ -1,445 +0,0 @@ -defmodule LightningWeb.WorkflowLive.AiAssistant.ComponentTest do - use ExUnit.Case, async: true - - @moduletag :capture_log - - import Phoenix.LiveViewTest - - alias LightningWeb.Live.AiAssistant.Modes.JobCode - alias LightningWeb.AiAssistant - - describe "formatted_content/1" do - test "renders assistant messages with properly styled links" do - content = """ - Here are some links: - - [Apollo Repo](https://github.com/OpenFn/apollo) - - Plain text - - [Lightning Repo](https://github.com/OpenFn/lightning) - """ - - html = - render_component( - &AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - links = Floki.find(parsed_html, "a") - - apollo_link = - Enum.find( - links, - &(Floki.attribute(&1, "href") == ["https://github.com/OpenFn/apollo"]) - ) - - assert apollo_link != nil - - assert Floki.attribute(apollo_link, "class") == [ - "text-primary-400 hover:text-primary-600" - ] - - assert Floki.attribute(apollo_link, "target") == ["_blank"] - - lightning_link = - Enum.find( - links, - &(Floki.attribute(&1, "href") == [ - "https://github.com/OpenFn/lightning" - ]) - ) - - assert lightning_link != nil - - assert Floki.attribute(lightning_link, "class") == [ - "text-primary-400 hover:text-primary-600" - ] - - assert Floki.attribute(lightning_link, "target") == ["_blank"] - - list_items = Floki.find(parsed_html, "li") - - assert Enum.any?(list_items, fn li -> - Floki.text(li) |> String.trim() == "Plain text" - end) - end - - test "handles content with invalid markdown links" do - content = """ - Broken [link(test.com - [Another](working.com) - """ - - html = - render_component( - &AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - assert Floki.text(parsed_html) =~ "Broken [link(test.com" - - working_link = - Floki.find(parsed_html, "a") - |> Enum.find(&(Floki.attribute(&1, "href") == ["working.com"])) - - assert working_link != nil - - assert Floki.attribute(working_link, "class") == [ - "text-primary-400 hover:text-primary-600" - ] - - assert Floki.attribute(working_link, "target") == ["_blank"] - end - - test "elements without defined styles remain unchanged" do - content = """ - Some code - Preformatted text - [A link](https://weirdopierdo.com) - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - code = Floki.find(parsed_html, "weirdo") - pre = Floki.find(parsed_html, "pierdo") - assert Floki.attribute(code, "class") == [] - assert Floki.attribute(pre, "class") == [] - - link = - Floki.find(parsed_html, "a") - |> Enum.find( - &(Floki.attribute(&1, "href") == ["https://weirdopierdo.com"]) - ) - - assert link != nil - - assert Floki.attribute(link, "class") == [ - "text-primary-400 hover:text-primary-600" - ] - - assert Floki.attribute(link, "target") == ["_blank"] - end - - test "handles content that cannot be parsed as AST" do - content = """ -
Unclosed div - Unclosed span - Some text - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - assert Floki.text(parsed_html) =~ "Unclosed div" - assert Floki.text(parsed_html) =~ "Unclosed span" - assert Floki.text(parsed_html) =~ "Some text" - end - - test "applies styles to elements not defined in the default styles" do - content = """ - Custom styled content - """ - - custom_attributes = %{ - "custom-tag" => %{class: "custom-class text-green-700"} - } - - html = - render_component(&AiAssistant.Component.formatted_content/1, %{ - id: "formatted-content", - content: content, - attributes: custom_attributes - }) - - parsed_html = Floki.parse_document!(html) - custom_tag = Floki.find(parsed_html, "custom-tag") |> hd() - - assert custom_tag != nil - - assert Floki.attribute(custom_tag, "class") == [ - "custom-class text-green-700" - ] - end - - test "renders code blocks with language class" do - content = """ - Here's some code: - - ```javascript - console.log("hello"); - ``` - - And more text. - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - # Find the code element inside pre - code_elements = Floki.find(parsed_html, "code") - assert length(code_elements) > 0 - - # MDEx emits the standard `language-` prefix on fenced code blocks - code_element = hd(code_elements) - assert Floki.attribute(code_element, "class") == ["language-javascript"] - end - - test "applies styling classes to standard markdown elements" do - content = """ - # Title - - ## Subtitle - - Some paragraph text. - - - bullet one - - bullet two - - 1. step one - 2. step two - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - assert Floki.attribute(Floki.find(parsed_html, "h1"), "class") == [ - "text-2xl font-bold mb-6" - ] - - assert Floki.attribute(Floki.find(parsed_html, "h2"), "class") == [ - "text-xl font-semibold mb-4 mt-8" - ] - - assert Floki.attribute(Floki.find(parsed_html, "ul"), "class") == [ - "list-disc pl-8 space-y-1" - ] - - assert Floki.attribute(Floki.find(parsed_html, "ol"), "class") == [ - "list-decimal pl-8 space-y-1" - ] - - assert "mt-1 mb-2 text-gray-800" in Floki.attribute( - Floki.find(parsed_html, "p"), - "class" - ) - - assert Enum.all?( - Floki.find(parsed_html, "li"), - &(Floki.attribute(&1, "class") == ["text-gray-800"]) - ) - end - - test "renders GFM extensions (tables, strikethrough, autolinks)" do - content = """ - | Name | Role | - |------|------| - | Ada | Dev | - - ~~deprecated~~ and see https://example.com for details. - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - # GFM tables render as a real table, not raw pipe text - assert Floki.find(parsed_html, "table") != [] - assert Floki.find(parsed_html, "th") |> Floki.text() =~ "Name" - assert Floki.find(parsed_html, "td") |> Floki.text() =~ "Ada" - - # Strikethrough - assert Floki.find(parsed_html, "del") |> Floki.text() == "deprecated" - - # Bare URL is autolinked - assert Enum.any?( - Floki.find(parsed_html, "a"), - &(Floki.attribute(&1, "href") == ["https://example.com"]) - ) - end - end - - describe "error_message/1" do - test "renders string error message" do - assert JobCode.error_message({:error, "Something went wrong"}) == - "Something went wrong" - end - - test "renders changeset error message" do - changeset = %Ecto.Changeset{ - valid?: false, - errors: [content: {"is invalid", []}], - data: %Lightning.AiAssistant.ChatSession{} - } - - assert JobCode.error_message({:error, changeset}) == - "Content is invalid" - end - - test "renders text message from map" do - error_data = %{text: "Specific error message"} - - assert JobCode.error_message({:error, :custom_reason, error_data}) == - "Specific error message" - end - - test "renders default error message for unhandled cases" do - assert JobCode.error_message({:error, :unknown_reason}) == - "An error occurred: unknown_reason. Please try again." - - assert JobCode.error_message(:unexpected_error) == - "Oops! Something went wrong. Please try again." - end - - test "elements without defined styles remain unchanged" do - content = """ - Some code - Preformatted text - [A link](https://weirdopierdo.com) - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - code = Floki.find(parsed_html, "weirdo") - pre = Floki.find(parsed_html, "pierdo") - - assert Floki.attribute(code, "class") == [] - assert Floki.attribute(pre, "class") == [] - - link = - Floki.find(parsed_html, "a") - |> Enum.find( - &(Floki.attribute(&1, "href") == ["https://weirdopierdo.com"]) - ) - - assert link != nil - - assert Floki.attribute(link, "class") == [ - "text-primary-400 hover:text-primary-600" - ] - - assert Floki.attribute(link, "target") == ["_blank"] - end - - test "handles content that cannot be parsed as AST" do - content = """ -
Unclosed div - Unclosed span - Some text - """ - - html = - render_component(&AiAssistant.Component.formatted_content/1, - id: "formatted-content", - content: content - ) - - parsed_html = Floki.parse_document!(html) - - text = Floki.text(parsed_html) - assert text =~ "Unclosed div" - assert text =~ "Unclosed span" - assert text =~ "Some text" - end - - test "applies styles to elements not defined in the default styles" do - content = """ - Custom styled content - """ - - custom_attributes = %{ - "custom-tag" => %{class: "custom-class text-green-700"} - } - - html = - render_component(&AiAssistant.Component.formatted_content/1, %{ - id: "formatted-content", - content: content, - attributes: custom_attributes - }) - - parsed_html = Floki.parse_document!(html) - - custom_tag = Floki.find(parsed_html, "custom-tag") |> hd() - - assert custom_tag != nil - - assert Floki.attribute(custom_tag, "class") == [ - "custom-class text-green-700" - ] - end - end - - describe "form validation" do - alias LightningWeb.Live.AiAssistant.Modes.WorkflowTemplate - - test "JobCode Form validates empty content" do - changeset = JobCode.Form.changeset(%{"content" => ""}) - - assert changeset.valid? == false - assert Keyword.has_key?(changeset.errors, :content) - {msg, _opts} = changeset.errors[:content] - assert msg == "Please enter a message before sending" - end - - test "JobCode validate_form includes content validation" do - changeset = JobCode.validate_form(%{"content" => nil}) - - assert changeset.valid? == false - assert Keyword.has_key?(changeset.errors, :content) - end - - test "WorkflowTemplate DefaultForm validates empty content" do - changeset = WorkflowTemplate.DefaultForm.changeset(%{"content" => ""}) - - assert changeset.valid? == false - assert Keyword.has_key?(changeset.errors, :content) - {msg, _opts} = changeset.errors[:content] - assert msg == "Please enter a message before sending" - end - - test "form validation accepts valid content" do - # JobCode - changeset = JobCode.validate_form(%{"content" => "Help me with my code"}) - assert changeset.valid? == true - - # WorkflowTemplate - changeset = - WorkflowTemplate.validate_form(%{"content" => "Create a workflow"}) - - assert changeset.valid? == true - end - end -end From ed6f1929acb54eb09ef40774167ad2c4cb28a92d Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 1 Jul 2026 14:15:20 +0000 Subject: [PATCH 3/6] chore: remove old workflow-editor code --- assets/js/job-editor/JobEditor.tsx | 60 -- assets/js/job-editor/JobEditorComponent.tsx | 157 ----- assets/js/metadata-loader/metadata.ts | 33 - assets/js/panel/Panel.tsx | 81 --- assets/js/panel/panels/WorkflowRunPanel.tsx | 206 ------ .../js/workflow-diagram/AiAssistantToggle.tsx | 139 ---- assets/js/workflow-diagram/MiniHistory.tsx | 340 ---------- .../js/workflow-diagram/WorkflowDiagram.tsx | 639 ------------------ assets/js/workflow-diagram/index.ts | 1 - .../js/workflow-diagram/nodes/Node.styles.ts | 60 -- .../js/workflow-diagram/util/safe-bounds.ts | 49 -- assets/js/workflow-diagram/util/throttle.ts | 31 - assets/js/workflow-editor/WorkflowEditor.tsx | 119 ---- config/config.exs | 3 - .../live/job_live/job_builder_components.ex | 29 - 15 files changed, 1947 deletions(-) delete mode 100644 assets/js/job-editor/JobEditor.tsx delete mode 100644 assets/js/job-editor/JobEditorComponent.tsx delete mode 100644 assets/js/metadata-loader/metadata.ts delete mode 100644 assets/js/panel/Panel.tsx delete mode 100644 assets/js/panel/panels/WorkflowRunPanel.tsx delete mode 100644 assets/js/workflow-diagram/AiAssistantToggle.tsx delete mode 100644 assets/js/workflow-diagram/MiniHistory.tsx delete mode 100644 assets/js/workflow-diagram/WorkflowDiagram.tsx delete mode 100644 assets/js/workflow-diagram/index.ts delete mode 100644 assets/js/workflow-diagram/nodes/Node.styles.ts delete mode 100644 assets/js/workflow-diagram/util/safe-bounds.ts delete mode 100644 assets/js/workflow-diagram/util/throttle.ts delete mode 100644 assets/js/workflow-editor/WorkflowEditor.tsx delete mode 100644 lib/lightning_web/live/job_live/job_builder_components.ex diff --git a/assets/js/job-editor/JobEditor.tsx b/assets/js/job-editor/JobEditor.tsx deleted file mode 100644 index aa18eacb676..00000000000 --- a/assets/js/job-editor/JobEditor.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import pDebounce from 'p-debounce'; -import React from 'react'; - -import type { Lightning } from '#/workflow-diagram/types'; - -import { EDITOR_DEBOUNCE_MS } from '../common'; -import { sortMetadata } from '../metadata-loader/metadata'; -import type { WithActionProps } from '../react/lib/with-props'; -import { useWorkflowStore } from '../workflow-store/store'; - -import JobEditorComponent from './JobEditorComponent'; - -interface JobEditorProps { - job_id: string; - adaptor: string; - source: string; - disabled: boolean; - disabled_message: string; -} - -export const JobEditor: WithActionProps = props => { - const [metadata, setMetadata] = React.useState(false); - const [source, setSource] = React.useState(''); - - const { change, getById } = useWorkflowStore(); - - // debounce editor content update - const debouncedPushChange = pDebounce((content: string) => { - change({ jobs: [{ id: props.job_id, body: content }] }); - }, EDITOR_DEBOUNCE_MS); - - // init hook - getting metadata - React.useEffect(() => { - const cleanup = props.handleEvent('metadata_ready', payload => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const sortedMetadata = sortMetadata(payload); - setMetadata(sortedMetadata as object); - }); - props.pushEventTo('request_metadata', {}); - return cleanup; - }, [props]); - - React.useEffect(() => { - const foundJob = getById(props.job_id); - if (!foundJob) setSource(props.source); - else setSource(foundJob.body); - }, [getById, props.job_id, props.source]); - - return ( - - ); -}; diff --git a/assets/js/job-editor/JobEditorComponent.tsx b/assets/js/job-editor/JobEditorComponent.tsx deleted file mode 100644 index 6be42545218..00000000000 --- a/assets/js/job-editor/JobEditorComponent.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { - ViewColumnsIcon, - ChevronLeftIcon, - ChevronRightIcon, - ChevronUpIcon, - ChevronDownIcon, - DocumentTextIcon, - SparklesIcon, -} from '@heroicons/react/24/outline'; -import React, { useState, useCallback, useMemo, useEffect } from 'react'; - -import Docs from '../adaptor-docs/Docs'; -import { Tabs } from '../components/Tabs'; -import Editor from '../editor/Editor'; -import Metadata from '../metadata-explorer/Explorer'; -import { cn } from '../utils/cn'; - -enum SettingsKeys { - ORIENTATION = 'lightning.job-editor.orientation', - SHOW_PANEL = 'lightning.job-editor.showPanel', - ACTIVE_TAB = 'lightning.job-editor.activeTab', -} - -// TODO maybe a usePersist() hook which takes defaults as an argument and returns a) the persisted values and b) a setter (shallow merge) -const persistedSettings = localStorage.getItem('lightning.job-editor.settings'); -const settings = persistedSettings - ? JSON.parse(persistedSettings) - : { - [SettingsKeys.ORIENTATION]: 'h', - [SettingsKeys.SHOW_PANEL]: false, - }; - -const persistSettings = () => - localStorage.setItem( - 'lightning.job-editor.settings', - JSON.stringify(settings) - ); - -const iconStyle = 'inline cursor-pointer h-5 w-5 ml-1 hover:text-primary-600'; - -type JobEditorComponentProps = { - adaptor: string; - source: string; - disabled?: boolean; - disabledMessage?: string; - metadata?: object | true; - onSourceChanged?: (src: string) => void; -}; - -export default ({ - adaptor, - source, - disabled, - disabledMessage, - metadata, - onSourceChanged, -}: JobEditorComponentProps) => { - const [vertical, setVertical] = useState( - () => settings[SettingsKeys.ORIENTATION] === 'v' - ); - const [showPanel, setShowPanel] = useState( - () => settings[SettingsKeys.SHOW_PANEL] - ); - const [selectedTab, setSelectedTab] = useState('docs'); - - const toggleOrientiation = useCallback(() => { - setVertical(!vertical); - settings[SettingsKeys.ORIENTATION] = vertical ? 'h' : 'v'; - persistSettings(); - }, [vertical]); - - const toggleShowPanel = useCallback(() => { - setShowPanel(!showPanel); - settings[SettingsKeys.SHOW_PANEL] = !showPanel; - persistSettings(); - }, [showPanel]); - - const handleSelectionChange = (newSelection: string) => { - setSelectedTab(newSelection); - if (!showPanel) { - toggleShowPanel(); - } - }; - - const CollapseIcon = useMemo(() => { - if (vertical) { - return showPanel ? ChevronDownIcon : ChevronUpIcon; - } else { - return showPanel ? ChevronRightIcon : ChevronLeftIcon; - } - }, [vertical, showPanel]); - - return ( - <> -
-
-
- -
-
-
- - {/* Floating controls in top right corner */} - {showPanel && ( -
- - -
- )} -
- {showPanel && ( -
- {selectedTab === 'docs' && } - {selectedTab === 'metadata' && ( - - )} -
- )} -
-
- - ); -}; diff --git a/assets/js/metadata-loader/metadata.ts b/assets/js/metadata-loader/metadata.ts deleted file mode 100644 index 427074de32a..00000000000 --- a/assets/js/metadata-loader/metadata.ts +++ /dev/null @@ -1,33 +0,0 @@ -// TODO shoud models be pre sorted? -// Maybe! But I don't know if I want to rely on that? -const sortArr = (arr: any[]) => { - arr.sort((a, b) => { - const astr = typeof a === 'string' ? a : a.name; - const bstr = typeof b === 'string' ? b : b.name; - - if (astr === bstr) return 0; - if (astr > bstr) { - return 1; - } else { - return -1; - } - }); - return arr; -}; - -const sortDeep = (model: any) => { - if (model.children) { - if (Array.isArray(model.children)) { - model.children = sortArr(model.children.map(sortDeep)); - } else { - const keys = Object.keys(model.children).sort(); - model.children = keys.reduce((acc, key) => { - acc[key] = sortArr(model.children[key].map(sortDeep)); - return acc; - }, {}); - } - } - return model; -}; - -export { sortDeep as sortMetadata }; diff --git a/assets/js/panel/Panel.tsx b/assets/js/panel/Panel.tsx deleted file mode 100644 index 218b378b22e..00000000000 --- a/assets/js/panel/Panel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { HTMLAttributes, ReactNode } from 'react'; - -import { cn } from '../utils/cn'; - -interface PanelProps extends HTMLAttributes { - heading: ReactNode; - className?: string; - children?: ReactNode; - footer?: ReactNode; - onClose?: () => void; - onBack?: () => void; - fixedHeight?: boolean; -} - -export const Panel: React.FC = ({ - heading, - className = '', - children, - footer, - onClose, - onBack, - fixedHeight = false, -}) => { - return ( -
-
-
-
-
- -

- {heading} -

-
-
- -
-
-
-
-
{children}
-
- {footer && ( -
- {footer} -
- )} -
-
- ); -}; diff --git a/assets/js/panel/panels/WorkflowRunPanel.tsx b/assets/js/panel/panels/WorkflowRunPanel.tsx deleted file mode 100644 index 0887efc6344..00000000000 --- a/assets/js/panel/panels/WorkflowRunPanel.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import React from 'react'; - -import type { WithActionProps } from '#/react/lib/with-props'; - -import useQuery from '../../hooks/useQuery'; -import { ManualRunPanel } from '../../manual-run-panel/ManualRunPanel'; -import type { Dataclip } from '../../manual-run-panel/types'; -import type { RunStep } from '../../workflow-store/store'; -import { Panel } from '../Panel'; - -interface ManualRunBody { - manual: { - body: string | null; - dataclip_id: string | null; - }; -} - -interface WorkflowRunPanel { - job_id: string; - job_title: string; - cancel_url: string; - back_url: string; - is_edge: boolean; -} - -export const WorkflowRunPanel: WithActionProps = props => { - const { - job_id, - job_title, - cancel_url, - back_url, - is_edge, - pushEvent, - ...actionProps - } = props; - const { a: runId } = useQuery(['a']); - const [manualContent, setManualRunContent] = React.useState({ - manual: { body: null, dataclip_id: null }, - }); - const [currentRunStep, setCurrentRunStep] = React.useState( - null - ); - const [currentDataclip, setCurrentDataclip] = React.useState( - null - ); - - const runDisabled = React.useMemo(() => { - if (currentDataclip && currentDataclip.wiped_at) { - return true; - } else if (!manualContent.manual.body && !manualContent.manual.dataclip_id) - return true; - else if (manualContent.manual.body) { - try { - const parsed = JSON.parse(manualContent.manual.body); - if (Array.isArray(parsed)) return true; - return false; - } catch (e: unknown) { - return true; - } - } else if (manualContent.manual.dataclip_id) return false; - return true; - }, [ - manualContent.manual.body, - manualContent.manual.dataclip_id, - currentDataclip, - ]); - - const pushEventProxy = React.useCallback( - (title: string, payload: Record, cb: unknown) => { - // here we intercept manual_run_change events and keep the state local - if (title === 'manual_run_change') { - console.log('manual_run_change event received', payload); - setManualRunContent(payload as unknown as ManualRunBody); - return; - } - pushEvent(title, payload, cb); - }, - [pushEvent] - ); - - const handleRunStepChange = React.useCallback((runStep: RunStep | null) => { - setCurrentRunStep(runStep); - }, []); - - const handleDataclipChange = React.useCallback( - (dataclip: Dataclip | null) => { - setCurrentDataclip(dataclip); - }, - [] - ); - - const startRetry = React.useCallback(() => { - if (runId && currentRunStep) { - pushEvent('rerun', { - run_id: runId, - step_id: currentRunStep.id, - via: 'job_panel', - }); - } - }, [pushEvent, runId, currentRunStep]); - - const startRun = React.useCallback(() => { - const from = job_id ? { from_job: job_id } : { from_start: true }; - pushEvent('manual_run_submit', { ...manualContent, ...from }); - }, [pushEvent, manualContent, job_id]); - - const shouldShowRetry = React.useMemo(() => { - if (is_edge || !currentRunStep || !currentDataclip || !runId) return false; - return ( - currentDataclip.wiped_at === null && - currentRunStep.input_dataclip_id === currentDataclip.id - ); - }, [currentRunStep, runId, currentDataclip, is_edge]); - - return ( - <> - { - props.navigate(cancel_url); - }} - onBack={() => { - props.navigate(back_url); - }} - fixedHeight={true} - footer={ -
- {shouldShowRetry ? ( -
- -
- -
- -
-
-
- ) : ( - - )} -
- } - > - {is_edge ? ( -
-
Select a Step or Trigger to start a Run from
-
- ) : ( -
-
Select input to start a run
- -
- )} -
- - ); -}; diff --git a/assets/js/workflow-diagram/AiAssistantToggle.tsx b/assets/js/workflow-diagram/AiAssistantToggle.tsx deleted file mode 100644 index 19a4f120783..00000000000 --- a/assets/js/workflow-diagram/AiAssistantToggle.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import tippy, { type Instance as TippyInstance } from 'tippy.js'; - -interface AiAssistantToggleProps { - showAiAssistant?: boolean | undefined; - canEditWorkflow?: boolean | undefined; - snapshotVersionTag?: string | undefined; - aiAssistantEnabled?: boolean | undefined; - liveAction?: string | undefined; - drawerWidth: number; -} - -const ChevronLeftIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -export const AiAssistantToggle: React.FC = ({ - showAiAssistant, - canEditWorkflow, - snapshotVersionTag, - aiAssistantEnabled, - liveAction, - drawerWidth, -}) => { - const containerRef = useRef(null); - const tippyInstanceRef = useRef(null); - - const isDisabled = snapshotVersionTag !== 'latest'; - - const tooltipText = isDisabled - ? 'Switch to the latest version of this workflow to use the AI Assistant.' - : showAiAssistant === true - ? 'Click to close the AI Assistant' - : 'Click to open the AI Assistant'; - - useEffect(() => { - if (!containerRef.current) return; - - tippyInstanceRef.current = tippy(containerRef.current, { - placement: 'right', - content: tooltipText, - }); - - return () => { - if (tippyInstanceRef.current) { - tippyInstanceRef.current.destroy(); - tippyInstanceRef.current = null; - } - }; - }, []); - - useEffect(() => { - if (tippyInstanceRef.current) { - tippyInstanceRef.current.setContent(tooltipText); - } - }, [tooltipText]); - - const shouldShow = - canEditWorkflow && aiAssistantEnabled && liveAction === 'edit'; - - if (!shouldShow) return null; - - const buttonClasses = [ - 'flex items-center justify-between pl-3 pr-3 py-2 transition-all duration-200 w-full text-left', - isDisabled - ? 'bg-gray-200 text-gray-500 cursor-not-allowed opacity-60' - : 'bg-gray-50 hover:bg-gray-100 text-gray-700 hover:shadow-sm', - ].join(' '); - - const containerClasses = [ - 'absolute left-4 top-4 z-30 bg-white rounded-lg shadow-sm overflow-hidden transition-all duration-300 ease-in-out', - isDisabled ? 'border border-gray-300 opacity-75' : 'border border-gray-200', - ].join(' '); - - const iconClasses = [ - 'w-4 h-4 mr-3 transition-colors duration-200', - isDisabled ? 'text-gray-400' : 'text-gray-500', - ].join(' '); - - const textClasses = [ - 'text-sm font-medium transition-colors duration-200', - isDisabled ? 'text-gray-500' : 'text-gray-700', - ].join(' '); - - return ( -
- -
- ); -}; diff --git a/assets/js/workflow-diagram/MiniHistory.tsx b/assets/js/workflow-diagram/MiniHistory.tsx deleted file mode 100644 index 16f97de8553..00000000000 --- a/assets/js/workflow-diagram/MiniHistory.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { formatRelative } from 'date-fns'; -import React, { useState } from 'react'; - -import type { WorkflowRunHistory } from '#/workflow-store/store'; - -import { relativeLocale } from '../hooks'; -import { cn } from '../utils/cn'; -import { duration } from '../utils/duration'; -import truncateUid from '../utils/truncateUID'; - -const CHIP_STYLES: Record = { - // only workorder states... - rejected: 'bg-red-300 text-gray-800', - pending: 'bg-gray-200 text-gray-800', - running: 'bg-blue-200 text-blue-800', - // run and workorder states... - available: 'bg-gray-200 text-gray-800', - claimed: 'bg-blue-200 text-blue-800', - started: 'bg-blue-200 text-blue-800', - success: 'bg-green-200 text-green-800', - failed: 'bg-red-200 text-red-800', - crashed: 'bg-orange-200 text-orange-800', - cancelled: 'bg-gray-500 text-gray-800', - killed: 'bg-yellow-200 text-yellow-800', - exception: 'bg-gray-800 text-white', - lost: 'bg-gray-800 text-white', -}; - -const displayTextFromState = (state: string): string => - `${state[0]?.toUpperCase()}${state.substring(1)}`; - -const StatePill: React.FC<{ state: string; mini?: boolean }> = ({ - state, - mini = false, -}) => { - const classes = CHIP_STYLES[state] || CHIP_STYLES['pending']; - const text = displayTextFromState(state); - - const baseClasses = - 'my-auto whitespace-nowrap rounded-full text-center align-baseline font-medium leading-none'; - const sizeClasses = mini ? 'py-1 px-2 text-[10px]' : 'py-2 px-4 text-xs'; - - return {text}; -}; - -interface MiniHistoryProps { - collapsed: boolean; - history: WorkflowRunHistory; - drawerWidth: number; - selectRunHandler: (runId: string, version: number) => void; - onCollapseHistory: () => void; - hasSnapshotMismatch?: boolean; - missingNodeCount?: number; -} - -export default function MiniHistory({ - history, - selectRunHandler, - collapsed = true, - onCollapseHistory, - drawerWidth, - hasSnapshotMismatch = false, - missingNodeCount = 0, -}: MiniHistoryProps) { - const [expandedWorder, setExpandedWorder] = useState(''); - const [isCollapsed, setIsCollapsed] = useState(collapsed); - - const now = new Date(); - - // to ensure panel is not collapsed when there's a selected item in history - // at time this component will be rendered before data reaches store. that makes the panel collapse - const selectedItem = history.find(w => w.selected)?.id; - React.useEffect(() => { - if (selectedItem) { - setIsCollapsed(false); - } - }, [selectedItem]); - - const expandWorkorderHandler = (workorder: WorkflowRunHistory[number]) => { - if (workorder.runs.length === 1 && workorder.runs[0]) { - selectRunHandler(workorder.runs[0].id, workorder.version); - } - setExpandedWorder(prev => (prev === workorder.id ? '' : workorder.id)); - }; - - const historyToggle = () => { - setIsCollapsed(p => !p); - }; - - const gotoHistory = (e: React.MouseEvent | React.KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - const paths = nextUrl.pathname.split('/'); - const wIdx = paths.indexOf('w'); - const workflowPaths = paths.splice(wIdx, paths.length - wIdx); - nextUrl.pathname = paths.join('/') + `/history`; - nextUrl.search = `?filters[workflow_id]=${ - workflowPaths[workflowPaths.length - 1] - }`; - window.location = nextUrl.toString(); - }; - - const navigateToWorkorderHistory = ( - e: React.MouseEvent, - workorderId: string - ) => { - e.preventDefault(); - e.stopPropagation(); - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - const paths = nextUrl.pathname.split('/'); - const projectIndex = paths.indexOf('projects'); - const projectId = projectIndex !== -1 ? paths[projectIndex + 1] : null; - - if (projectId) { - nextUrl.pathname = `/projects/${projectId}/history`; - nextUrl.search = `?filters[workorder_id]=${workorderId}`; - window.location = nextUrl.toString(); - } - }; - - const navigateToRunView = (e: React.MouseEvent, runId: string) => { - e.preventDefault(); - e.stopPropagation(); - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - const paths = nextUrl.pathname.split('/'); - const projectIndex = paths.indexOf('projects'); - const projectId = projectIndex !== -1 ? paths[projectIndex + 1] : null; - - if (projectId) { - nextUrl.pathname = `/projects/${projectId}/runs/${runId}`; - window.location = nextUrl.toString(); - } - }; - - return ( -
-
historyToggle()} - > -
-

- {isCollapsed ? 'View History' : 'Recent History'} -

- -
- -
- {isCollapsed ? ( - - ) : ( - - )} -
-
- -
- {history.length === 0 ? ( -
- -

No related history

-

- Why not run it a few times to see some history? -

-
- ) : ( -
- {history.map(workorder => ( -
-
-
expandWorkorderHandler(workorder)} - > -
- - - - - {formatRelative( - new Date(workorder.last_activity), - now, - { locale: relativeLocale } - )} - -
- -
-
- - {(expandedWorder === workorder.id || workorder.selected) && - workorder.runs.map(run => ( -
- run.selected - ? onCollapseHistory() - : selectRunHandler(run.id, workorder.version) - } - > -
-
- - - {(run.started_at || run.finished_at) && ( - <> - - • - - {formatRelative( - new Date(run.started_at || run.finished_at), - now, - { - locale: relativeLocale, - } - )} - - )} - {run.started_at && run.finished_at && ( - <> - - • - - - {duration(run.started_at, run.finished_at)} - - - )} -
-
- - {hasSnapshotMismatch && run.selected && ( -
- -
- )} -
-
-
- ))} -
- ))} -
- )} -
-
- ); -} diff --git a/assets/js/workflow-diagram/WorkflowDiagram.tsx b/assets/js/workflow-diagram/WorkflowDiagram.tsx deleted file mode 100644 index 064985628ab..00000000000 --- a/assets/js/workflow-diagram/WorkflowDiagram.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import { - Background, - ControlButton, - Controls, - MiniMap, - ReactFlow, - ReactFlowProvider, - applyNodeChanges, - type NodeChange, - type ReactFlowInstance, - type Rect, -} from '@xyflow/react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -import { useWorkflowStore } from '../workflow-store/store'; - -import { AiAssistantToggle } from './AiAssistantToggle'; -import MiniMapNode from './components/MiniMapNode'; -import { FIT_DURATION, FIT_PADDING } from './constants'; -import edgeTypes from './edges'; -import layout from './layout'; -import MiniHistory from './MiniHistory'; -import nodeTypes from './nodes'; -import type { Flow, Positions } from './types'; -import useConnect from './useConnect'; -import usePlaceholders from './usePlaceholders'; -import { ensureNodePosition } from './util/ensure-node-position'; -import fromWorkflow from './util/from-workflow'; -import { - safeFitBounds, - safeGetNodesBounds, - safeFitBoundsRect, - hasXY, -} from './util/safe-bounds'; -import shouldLayout from './util/should-layout'; -import throttle from './util/throttle'; -import updateSelectionStyles from './util/update-selection'; -import { getVisibleRect, isPointInRect } from './util/viewport'; - -const controlButtonStyle = (disabled: boolean) => - disabled - ? { background: '#eee', cursor: 'not-allowed', color: '#818181' } - : { color: '#000' }; - -type WorkflowDiagramProps = { - el?: HTMLElement | null; - containerEl?: HTMLElement | null; - selection: string | null; - onSelectionChange: (id: string | null) => void; - forceFit?: boolean; - onRunChange: (id: string, version: number) => void; - onCollapseHistory: () => void; - showAiAssistant?: boolean; - aiAssistantId?: string; - canEditWorkflow?: boolean; - snapshotVersionTag?: string; - aiAssistantEnabled?: boolean; - liveAction?: string; - pushEvent?: (name: string, payload: Record) => void; -}; - -type ChartCache = { - positions: Positions; - lastSelection: string | null; - lastLayout?: string; - layoutDuration?: number; -}; - -const LAYOUT_DURATION = 300; - -export default function WorkflowDiagram(props: WorkflowDiagramProps) { - const { - jobs, - triggers, - edges, - disabled, - positions: fixedPositions, - updatePositions, - updatePosition, - undo, - redo, - runSteps, - history: someHistory, - } = useWorkflowStore(); - const isManualLayout = !!fixedPositions; - // value of select in props seems same as select in store. one in props is always set on initial render. (helps with refresh) - const { - selection, - onSelectionChange, - containerEl: el, - onRunChange, - onCollapseHistory, - } = props; - - const [model, setModel] = useState({ nodes: [], edges: [] }); - const [drawerWidth, setDrawerWidth] = useState(0); - const workflowDiagramRef = useRef(null); - - const updateSelection = useCallback( - (id?: string | null) => { - id = id || null; - - chartCache.current.lastSelection = id; - onSelectionChange(id); - }, - [onSelectionChange] - ); - - // selection can be null give 2 events - // 1. we click empty space on editor (client event) - // 2. selection prop becomes null (server event) - // on option 2. chartCache isn't updated. Hence we call updateSelection to do that - useEffect(() => { - // we know selection from server has changed when it's not equal to the one on client - if (selection !== chartCache.current.lastSelection) - updateSelection(selection); - }, [selection, updateSelection]); - - const { - placeholders, - add: addPlaceholder, - cancel: cancelPlaceholder, - updatePlaceholderPosition, - } = usePlaceholders(el, isManualLayout, updateSelection); - - const workflow = React.useMemo( - () => ({ - jobs, - triggers, - edges, - disabled, - }), - [jobs, triggers, edges, disabled] - ); - - // Check for snapshot mismatch (more run steps than visible nodes) - const hasSnapshotMismatch = React.useMemo(() => { - if (!runSteps.start_from || runSteps.steps.length === 0) return false; - - const visibleNodeIds = new Set([ - ...jobs.map(job => job.id), - ...triggers.map(trigger => trigger.id), - ]); - - const runStepJobIds = new Set(runSteps.steps.map(step => step.job_id)); - const missingNodeIds = [...runStepJobIds].filter( - id => !visibleNodeIds.has(id) - ); - - return missingNodeIds.length > 0; - }, [runSteps, jobs, triggers]); - - const missingNodeCount = React.useMemo(() => { - if (!hasSnapshotMismatch) return 0; - - const visibleNodeIds = new Set([ - ...jobs.map(job => job.id), - ...triggers.map(trigger => trigger.id), - ]); - - const runStepJobIds = new Set(runSteps.steps.map(step => step.job_id)); - const missingNodeIds = [...runStepJobIds].filter( - id => !visibleNodeIds.has(id) - ); - - return missingNodeIds.length; - }, [hasSnapshotMismatch, runSteps, jobs, triggers]); - - // Track positions and selection on a ref, as a passive cache, to prevent re-renders - const chartCache = useRef({ - positions: {}, - // This will set the initial selection into the cache - lastSelection: selection, - lastLayout: undefined, - }); - - const [flow, setFlow] = useState(); - - const forceLayout = useCallback(() => { - const viewBounds = { - width: workflowDiagramRef.current?.clientWidth ?? 0, - height: workflowDiagramRef.current?.clientHeight ?? 0, - }; - return layout(model, setModel, flow, viewBounds, { - duration: props.layoutDuration ?? LAYOUT_DURATION, - forceFit: props.forceFit, - }).then(positions => { - // Note we don't update positions until the animation has finished - chartCache.current.positions = positions; - if (isManualLayout) updatePositions(positions); - }); - }, [flow, model, isManualLayout, updatePositions]); - - // Respond to changes pushed into the component from outside - // This usually means the workflow has changed or its the first load, so we don't want to animate - // Later, if responding to changes from other users live, we may want to animate - useEffect(() => { - const { positions, lastSelection } = chartCache.current; - // create model from workflow and also apply selection styling to the model. - const newModel = updateSelectionStyles( - fromWorkflow( - workflow, - positions, - placeholders, - runSteps, - // Re-render the model based on whatever was last selected - // This handles first load and new node safely - lastSelection - ), - lastSelection - ); - if (flow && newModel.nodes.length) { - const layoutId = shouldLayout( - newModel.edges, - newModel.nodes, - isManualLayout, - chartCache.current.lastLayout - ); - - // If defaulting positions for multiple nodes, - // try to offset them a bit - // Note that we can't do anything about overlaps - const positionOffsetMap: Record = {}; - - if (layoutId) { - chartCache.current.lastLayout = layoutId; - const viewBounds = { - width: workflowDiagramRef.current?.clientWidth ?? 0, - height: workflowDiagramRef.current?.clientHeight ?? 0, - }; - if (isManualLayout) { - // give nodes positions - const nodesWPos = newModel.nodes.map(node => { - // during manualLayout. a placeholder wouldn't have position in positions in store - // hence use the position on the placeholder node - const isPlaceholder = node.type === 'placeholder'; - const newNode = { - ...node, - position: isPlaceholder ? node.position : fixedPositions[node.id], - }; - ensureNodePosition( - newModel, - { ...positions, ...fixedPositions }, - newNode, - positionOffsetMap - ); - return newNode; - }); - setModel({ ...newModel, nodes: nodesWPos }); - chartCache.current.positions = fixedPositions; - } else { - layout(newModel, setModel, flow, viewBounds, { - duration: props.layoutDuration ?? LAYOUT_DURATION, - forceFit: props.forceFit, - }).then(positions => { - // Note we don't update positions until the animation has finished - chartCache.current.positions = positions; - }); - } - } else if (isManualLayout) { - // if isManualLayout, then we use values from store instead - newModel.nodes.forEach(n => { - if (n.type !== 'placeholder') { - n.position = fixedPositions[n.id]; - } - ensureNodePosition( - newModel, - { ...positions, ...fixedPositions }, - n, - positionOffsetMap - ); - }); - setModel(newModel); - } else if (newModel.nodes.some(n => !hasXY(n))) { - // fallback: nodes lack positions → run layout now - const viewBounds = { - width: workflowDiagramRef.current?.clientWidth ?? 0, - height: workflowDiagramRef.current?.clientHeight ?? 0, - }; - layout(newModel, setModel, flow, viewBounds, { - duration: props.layoutDuration ?? LAYOUT_DURATION, - forceFit: props.forceFit, - }).then(positions => { - chartCache.current.positions = positions; - }); - } else { - // When isManualLayout is false and no layout is needed, still update the model - // to reflect changes in workflow data (like adaptor changes) - setModel(newModel); - } - } else { - chartCache.current.positions = {}; - } - }, [ - workflow, - flow, - placeholders, - el, - isManualLayout, - fixedPositions, - selection, - runSteps, - ]); - - // This effect only runs when AI assistant visibility changes, not on every selection change - useEffect(() => { - if (!props.showAiAssistant) { - setDrawerWidth(0); - - // Fit view when AI assistant panel closes - if (flow && model.nodes.length > 0) { - setTimeout(() => { - void safeFitBounds(flow, model.nodes, { - duration: FIT_DURATION, - padding: FIT_PADDING, - }); - }, 510); - } - - return; - } - - if (!props.aiAssistantId) { - return; - } - - const aiAssistantId = props.aiAssistantId; - - let observer: ResizeObserver | null = null; - - const timer = setTimeout(() => { - const drawer = document.getElementById(aiAssistantId); - if (drawer) { - observer = new ResizeObserver(entries => { - const entry = entries[0]; - if (entry) { - const width = entry.contentRect.width; - setDrawerWidth(width); - } - }); - observer.observe(drawer); - setDrawerWidth(drawer.getBoundingClientRect().width); - - // Fit view when AI assistant panel opens - if (flow && model.nodes.length > 0) { - setTimeout(() => { - void safeFitBounds(flow, model.nodes, { - duration: FIT_DURATION, - padding: FIT_PADDING, - }); - }, 510); - } - } - }, 50); - - return () => { - clearTimeout(timer); - if (observer) { - observer.disconnect(); - } - }; - }, [props.showAiAssistant, props.aiAssistantId]); - - useEffect(() => { - if (props.forceFit && flow && model.nodes.length > 0) { - // Immediately fit to bounds when forceFit becomes true - void safeFitBounds(flow, model.nodes, { - duration: FIT_DURATION, - padding: FIT_PADDING, - }).catch(error => { - console.error('Failed to fit bounds:', error); - }); - } - }, [props.forceFit, flow, model.nodes]); - - const onNodesChange = useCallback( - (changes: NodeChange[]) => { - const newNodes = applyNodeChanges(changes, model.nodes); - setModel({ nodes: newNodes, edges: model.edges }); - - // we just need to recalculate this to update the cache. - const newPositions = newNodes.reduce((obj, next) => { - obj[next.id] = next.position; - return obj; - }, {} as Positions); - chartCache.current.positions = newPositions; - }, - [setModel, model] - ); - - // update node position data only on dragstop. - const onNodeDragStop = useCallback( - (e: React.MouseEvent, node: Flow.Node) => { - if (node.type === 'placeholder') { - updatePlaceholderPosition(node.id, node.position); - } else { - updatePosition(node.id, node.position); - } - }, - [updatePosition, updatePlaceholderPosition] - ); - - const handleNodeClick = useCallback( - (event: React.MouseEvent, node: Flow.Node) => { - if ( - (event.target as HTMLElement).getAttribute('data-handleid') === - 'node-connector' - ) { - addPlaceholder(node); - return; - } - if (node.type !== 'placeholder') cancelPlaceholder(); - updateSelection(node.id); - }, - [updateSelection, cancelPlaceholder, addPlaceholder] - ); - - const handleEdgeClick = useCallback( - (_event: React.MouseEvent, edge: Flow.Edge) => { - cancelPlaceholder(); - updateSelection(edge.id); - }, - [updateSelection, cancelPlaceholder] - ); - - // Trigger a fit to bounds when the parent div changes size - // To keep the chart more stable, try and take a snapshot of the target bounds - // when a new resize starts - // This will be imperfect but stops the user completely losing context - useEffect(() => { - if (flow && el) { - let isFirstCallback = true; - - let cachedTargetBounds: Rect | null = null; - let cacheTimeout: any; - - const throttledResize = throttle(() => { - clearTimeout(cacheTimeout); - - // After 3 seconds, clear the timeout and take a new cache snapshot - cacheTimeout = setTimeout(() => { - cachedTargetBounds = null; - }, 3000); - - if (!cachedTargetBounds) { - // Take a snapshot of what bounds to try and maintain throughout the resize - const viewBounds = { - width: el.clientWidth ?? 0, - height: el.clientHeight ?? 0, - }; - const rect = getVisibleRect(flow.getViewport(), viewBounds, 1); - const visible = model.nodes.filter( - n => n?.position && isPointInRect(n.position, rect) - ); - const vb = safeGetNodesBounds(visible); - if (vb) cachedTargetBounds = vb; - } - - // Run an animated fit - void safeFitBoundsRect(flow, cachedTargetBounds, { - duration: FIT_DURATION, - padding: FIT_PADDING, - }); - }, FIT_DURATION * 2); - - const resizeOb = new ResizeObserver(function (entries) { - if (!isFirstCallback) { - // Don't fit when the listener attaches (it callsback immediately) - throttledResize(); - } - isFirstCallback = false; - }); - resizeOb.observe(el); - - return () => { - throttledResize.cancel(); - resizeOb.unobserve(el); - }; - } - }, [flow, model, el]); - - const switchLayout = async () => { - if (isManualLayout) { - updatePositions(null); - } else updatePositions(chartCache.current.positions); - }; - - const handleFitView = useCallback(() => { - void safeFitBounds(flow, model.nodes, { - duration: 200, - padding: FIT_PADDING, - }); - }, [model, flow]); - - const connectHandlers = useConnect( - model, - setModel, - addPlaceholder, - () => { - cancelPlaceholder(); - updateSelection(null); - }, - flow - ); - - // undo/redo keyboard shortcuts - React.useEffect(() => { - const keyHandler = (e: KeyboardEvent) => { - const isUndo = (e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 'z'; - const isRedo = - ((e.metaKey || e.ctrlKey) && e.key === 'y') || - ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z'); - - if (isUndo) { - e.preventDefault(); - undo(); - } - if (isRedo) { - e.preventDefault(); - redo(); - } - }; - window.addEventListener('keydown', keyHandler); - return () => { - window.removeEventListener('keydown', keyHandler); - }; - }, [redo, undo]); - - return ( - - - - - - - - - {isManualLayout ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - {props.liveAction === 'edit' ? ( - - ) : null} - - ); -} diff --git a/assets/js/workflow-diagram/index.ts b/assets/js/workflow-diagram/index.ts deleted file mode 100644 index 6543b42fb77..00000000000 --- a/assets/js/workflow-diagram/index.ts +++ /dev/null @@ -1 +0,0 @@ -export default './WorkflowDiagram'; diff --git a/assets/js/workflow-diagram/nodes/Node.styles.ts b/assets/js/workflow-diagram/nodes/Node.styles.ts deleted file mode 100644 index 59c2d50bda4..00000000000 --- a/assets/js/workflow-diagram/nodes/Node.styles.ts +++ /dev/null @@ -1,60 +0,0 @@ -interface StatusDetails { - icon: string; - color: string; - border: string; - text: string; -} - -type RunStatus = - | 'success' - | 'fail' - | 'crash' - | 'cancel' - | 'kill' - | 'exception' - | 'lost'; - -export const RUN_DATA_ICON: Record = { - success: { - icon: 'hero-check-circle', - color: 'bg-green-100', - border: 'border-green-600', - text: 'text-green-600', - }, - fail: { - icon: 'hero-x-circle', - color: 'bg-red-100', - border: 'border-red-600', - text: 'text-red-600', - }, - crash: { - icon: 'hero-exclamation-triangle', - color: 'bg-orange-100', - border: 'border-orange-600', - text: 'text-orange-600', - }, - cancel: { - icon: 'hero-no-symbo', - color: 'bg-gray-100', - border: 'border-gray-600', - text: 'text-gray-600', - }, - kill: { - icon: 'hero-shield-exclamation', - color: 'bg-purple-100', - border: 'border-purple-600', - text: 'text-purple-600', - }, - exception: { - icon: 'hero-exclamation-circle', - color: 'bg-yellow-100', - border: 'border-yellow-600', - text: 'text-yellow-600', - }, - lost: { - icon: 'hero-question-mark-circle', - color: 'bg-blue-100', - border: 'border-blue-600', - text: 'text-blue-600', - }, -}; diff --git a/assets/js/workflow-diagram/util/safe-bounds.ts b/assets/js/workflow-diagram/util/safe-bounds.ts deleted file mode 100644 index 6cc2a52da7c..00000000000 --- a/assets/js/workflow-diagram/util/safe-bounds.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ReactFlowInstance, Rect, Node } from '@xyflow/react'; -import { getNodesBounds as rawGetNodesBounds } from '@xyflow/react'; - -type MaybePos = { position?: { x?: number; y?: number } }; -type XY = { position: { x: number; y: number } }; - -/** true if node has finite x/y */ -export const hasXY = (n: MaybePos | null | undefined): n is XY => - !!n && - !!n.position && - Number.isFinite(n.position.x) && - Number.isFinite(n.position.y); - -/** Returns bounds or null if no nodes have valid positions */ -export function safeGetNodesBounds( - nodes: MaybePos[], - getNodesBounds = rawGetNodesBounds -): Rect | null { - const withPos = nodes.filter(hasXY) as unknown as Node[]; - return withPos.length ? getNodesBounds(withPos) : null; -} - -/** Fits only when bounds can be computed. Always returns a Promise. */ -export function safeFitBounds( - flow: ReactFlowInstance | undefined, - nodes: MaybePos[], - opts: { duration?: number; padding?: number } = {} -): Promise { - if (!flow) return Promise.resolve(); - const b = safeGetNodesBounds(nodes); - return b ? flow.fitBounds(b, opts) : Promise.resolve(); -} - -/** Like above, but with a precomputed rect. Always returns a Promise. */ -export function safeFitBoundsRect( - flow: ReactFlowInstance | undefined, - rect: Rect | null | undefined, - opts: { duration?: number; padding?: number } = {} -): Promise { - const ok = - !!flow && - !!rect && - Number.isFinite(rect.x) && - Number.isFinite(rect.y) && - Number.isFinite(rect.width) && - Number.isFinite(rect.height); - - return ok ? flow.fitBounds(rect, opts) : Promise.resolve(); -} diff --git a/assets/js/workflow-diagram/util/throttle.ts b/assets/js/workflow-diagram/util/throttle.ts deleted file mode 100644 index 90b7e17dbe4..00000000000 --- a/assets/js/workflow-diagram/util/throttle.ts +++ /dev/null @@ -1,31 +0,0 @@ -export default (fn: () => void, duration = 100) => { - let callAgain = false; - let timeout; - - const run = () => { - if (timeout) { - // Callback/timeout in progress, do nothing (but register another go) - callAgain = true; - } else { - // Start a new callback - callAgain = false; - - // Call back after duration ms, if neccessary - timeout = setTimeout(() => { - timeout = undefined; - if (callAgain) { - run(); - } - }, duration); - - // Run the function - fn(); - } - }; - - run.cancel = () => { - clearTimeout(timeout); - }; - - return run; -}; diff --git a/assets/js/workflow-editor/WorkflowEditor.tsx b/assets/js/workflow-editor/WorkflowEditor.tsx deleted file mode 100644 index 66d4542a28b..00000000000 --- a/assets/js/workflow-editor/WorkflowEditor.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import tippy, { type Placement } from 'tippy.js'; - -import type { WithActionProps } from '#/react/lib/with-props'; - -import WorkflowDiagram from '../workflow-diagram/WorkflowDiagram'; -import { RUNS_TMP, useWorkflowStore } from '../workflow-store/store'; - -export const WorkflowEditor: WithActionProps<{ - selection: string; - showAiAssistant?: boolean; - aiAssistantId?: string; - canEditWorkflow?: boolean; - snapshotVersionTag?: string; - aiAssistantEnabled?: boolean; - liveAction?: string; -}> = props => { - const { getItem, forceFit, updateRuns } = useWorkflowStore(); - - React.useEffect(() => { - const globalMouseEnterHandler = (e: MouseEvent) => { - const target = e.target as HTMLElement; - const content = target.dataset['tooltip']; - const placement: Placement = - target.dataset['tooltipPlacement'] || 'right'; - if (content) { - let tp: ReturnType[number] | undefined = target._tippy; - if (tp) { - tp.setContent(content); - tp.setProps({ placement }); - } else { - tp = tippy(target, { - content: content, - placement, - animation: false, - allowHTML: false, - }); - } - } - }; - window.addEventListener('mouseover', globalMouseEnterHandler); - return () => { - window.removeEventListener('mouseover', globalMouseEnterHandler); - }; - }, []); - - const onSelectionChange = (id?: string) => { - console.debug('onSelectionChange', id); - - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - - const idExists = getItem(id); - if (!idExists) { - nextUrl.searchParams.delete('s'); - nextUrl.searchParams.delete('m'); - nextUrl.searchParams.set('placeholder', 'true'); - } else { - nextUrl.searchParams.delete('placeholder'); - if (!id) { - console.debug('Unselecting'); - - nextUrl.searchParams.delete('s'); - nextUrl.searchParams.delete('m'); - } else { - console.debug('Selecting', id); - - nextUrl.searchParams.set('s', id); - } - } - - if ( - currentUrl.searchParams.toString() !== nextUrl.searchParams.toString() - ) { - props.navigate(nextUrl.toString()); - } - }; - - const onRunChangeHandler = (id: string, version: number) => { - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - nextUrl.searchParams.set('a', id); - nextUrl.searchParams.set('v', version.toString()); - nextUrl.searchParams.set('m', 'history'); - props.navigate(nextUrl.toString()); - }; - - const onCollapseHistory = () => { - // remove run steps from the store - updateRuns(RUNS_TMP, null); - - // update the url query. - const currentUrl = new URL(window.location.href); - const nextUrl = new URL(currentUrl); - nextUrl.searchParams.delete('a'); - nextUrl.searchParams.delete('v'); - nextUrl.searchParams.delete('m'); - props.navigate(nextUrl.toString()); - }; - - return ( - - ); -}; diff --git a/config/config.exs b/config/config.exs index ccecad5e147..7b8c447c8ee 100644 --- a/config/config.exs +++ b/config/config.exs @@ -105,11 +105,8 @@ config :esbuild, js/editor/Editor.tsx js/react/components/DataclipViewer.tsx js/react/components/CollectionPreviewViewer.tsx - js/job-editor/JobEditor.tsx - js/workflow-editor/WorkflowEditor.tsx js/workflow-store/WorkflowStore.tsx js/manual-run-panel/ManualRunPanel.tsx - js/panel/panels/WorkflowRunPanel.tsx js/collaborative-editor/CollaborativeEditor.tsx js/picker/Picker.tsx js/picker/PickerButton.tsx diff --git a/lib/lightning_web/live/job_live/job_builder_components.ex b/lib/lightning_web/live/job_live/job_builder_components.ex deleted file mode 100644 index b7154d07a4e..00000000000 --- a/lib/lightning_web/live/job_live/job_builder_components.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule LightningWeb.JobLive.JobBuilderComponents do - use LightningWeb, :component - - import React - - attr :adaptor, :string, required: true - attr :disabled, :boolean, default: false - attr :disabled_message, :string, required: true - attr :job_id, :string, required: true - attr :source, :string, required: true - attr :rest, :global - - jsx("assets/js/job-editor/JobEditor.tsx") - - def job_editor_component(assigns) do - assigns = assigns |> assign(disabled: assigns.disabled |> to_string()) - - ~H""" - <.JobEditor - job_id={@job_id} - adaptor={@adaptor} - source={@source} - disabled={@disabled} - disabled_message={@disabled_message} - class="flex flex-col h-full" - /> - """ - end -end From 644002b139344b8cedc676be097f1d9cb6a9bcde Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 1 Jul 2026 18:15:53 +0000 Subject: [PATCH 4/6] chore: remove e2e tests --- assets/test/e2e/pages/components/index.ts | 1 - .../e2e/pages/components/job-form.page.ts | 80 ------ assets/test/e2e/pages/index.ts | 1 - assets/test/e2e/pages/workflow-collab.page.ts | 25 +- assets/test/e2e/pages/workflow-edit.page.ts | 129 ---------- .../collaborative-editor-credentials.spec.ts | 188 +++----------- .../collaborative-editor-navigation.spec.ts | 99 ------- .../collaborative/edge-validation.spec.ts | 32 +-- .../specs/collaborative/job-step-sync.spec.ts | 28 +- .../run-retry-keyboard-shortcuts.spec.ts | 31 +-- .../e2e/specs/smoke/basic-navigation.spec.ts | 59 ++--- .../workflows/workflow-steps-creation.spec.ts | 241 ------------------ 12 files changed, 100 insertions(+), 814 deletions(-) delete mode 100644 assets/test/e2e/pages/components/job-form.page.ts delete mode 100644 assets/test/e2e/pages/workflow-edit.page.ts delete mode 100644 assets/test/e2e/specs/collaborative/collaborative-editor-navigation.spec.ts delete mode 100644 assets/test/e2e/specs/workflows/workflow-steps-creation.spec.ts diff --git a/assets/test/e2e/pages/components/index.ts b/assets/test/e2e/pages/components/index.ts index c360ba497af..7f548faa2f4 100644 --- a/assets/test/e2e/pages/components/index.ts +++ b/assets/test/e2e/pages/components/index.ts @@ -1,4 +1,3 @@ -export { JobFormPage } from './job-form.page'; export { JobInspectorPage } from './job-inspector.page'; export { WorkflowDiagramEdgesPage } from './workflow-diagram-edges.page'; export { WorkflowDiagramNodesPage } from './workflow-diagram-nodes.page'; diff --git a/assets/test/e2e/pages/components/job-form.page.ts b/assets/test/e2e/pages/components/job-form.page.ts deleted file mode 100644 index 17ad122ceb1..00000000000 --- a/assets/test/e2e/pages/components/job-form.page.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Page, Locator } from '@playwright/test'; - -/** - * Page Object Model for the Job Form component - * Handles interactions with the job configuration form - */ -export class JobFormPage { - private jobIndex: number; - - protected selectors = { - workflowForm: '#workflow-form', - workflowFormHeader: '#workflow-form div', - closeNodePanelButton: '[data-testid="CloseNodePanelViaEscape"]', - }; - - constructor( - protected page: Page, - jobIndex: number = 0 - ) { - this.jobIndex = jobIndex; - this.selectors.workflowForm = `[data-testid='job-pane-${this.jobIndex}']`; - } - - /** - * Get the dynamic selector for a job field based on the job index - */ - private getJobFieldSelector(fieldName: string): string { - return [ - `select[name="workflow[jobs][${this.jobIndex}][${fieldName}]"]`, - `input[name="workflow[jobs][${this.jobIndex}][${fieldName}]"]`, - `textarea[name="workflow[jobs][${this.jobIndex}][${fieldName}]"]`, - ].join(','); - } - - /** - * Get the workflow form container - * - * NOTE: form elements are never visible, don't use toBeVisible(); rather - * use toBeAttached() to check it's in the DOM. Or check for specific fields - * to be visible. - */ - get workflowForm(): Locator { - return this.page.locator(this.selectors.workflowForm); - } - - /** - * Get the workflow form header - */ - get header(): Locator { - return this.page.locator(`${this.selectors.workflowForm} div.font-bold`); - } - - /** - * Get the close node panel button - */ - get closeNodePanelButton(): Locator { - return this.page.locator(this.selectors.closeNodePanelButton); - } - - /** - * Get the adaptor select dropdown - */ - get adaptorSelect(): Locator { - return this.page.locator(`select[name="adaptor_picker[adaptor_name]"]`); - } - - /** - * Get the version select dropdown - */ - get versionSelect(): Locator { - return this.page.locator(this.getJobFieldSelector('adaptor')); - } - - /** - * Get the job name input - */ - get nameInput(): Locator { - return this.page.locator(this.getJobFieldSelector('name')); - } -} diff --git a/assets/test/e2e/pages/index.ts b/assets/test/e2e/pages/index.ts index b0e8d1b427d..cedc3f60e76 100644 --- a/assets/test/e2e/pages/index.ts +++ b/assets/test/e2e/pages/index.ts @@ -1,4 +1,3 @@ -export { WorkflowEditPage } from './workflow-edit.page'; export { WorkflowsPage } from './workflows.page'; export { WorkflowDiagramPage } from './components'; export { LiveViewPage } from './base'; diff --git a/assets/test/e2e/pages/workflow-collab.page.ts b/assets/test/e2e/pages/workflow-collab.page.ts index 6cdc96dfc47..79229d90a17 100644 --- a/assets/test/e2e/pages/workflow-collab.page.ts +++ b/assets/test/e2e/pages/workflow-collab.page.ts @@ -1,4 +1,5 @@ -import { expect, type Locator, type Page } from '@playwright/test'; +import { expect, type Locator } from '@playwright/test'; + import { LiveViewPage } from './base/liveview.page'; import { JobInspectorPage } from './components/job-inspector.page'; @@ -34,6 +35,28 @@ export class WorkflowCollaborativePage extends LiveViewPage { return new JobInspectorPage(this.page); } + /** + * Navigate directly to an existing workflow's collaborative editor and + * wait until it is loaded and synced. + * + * The collaborative editor is now the only editor: the workflow route + * (`/projects/:projectId/w/:workflowId`) renders it directly, so no + * legacy detour or editor toggle is required. + * + * @param options.projectId - Project ID + * @param options.workflowId - Workflow ID + */ + async open(options: { + projectId: string; + workflowId: string; + }): Promise { + await this.page.goto( + `/projects/${options.projectId}/w/${options.workflowId}` + ); + await this.waitForReactComponentLoaded(); + await this.waitForSynced(); + } + /** * Wait for the collaborative editor React component to load and render. * diff --git a/assets/test/e2e/pages/workflow-edit.page.ts b/assets/test/e2e/pages/workflow-edit.page.ts deleted file mode 100644 index 9e3c3c791f1..00000000000 --- a/assets/test/e2e/pages/workflow-edit.page.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { expect } from '@playwright/test'; -import type { Locator, Page } from '@playwright/test'; -import { WorkflowDiagramPage, JobFormPage } from './components'; -import { LiveViewPage } from './base'; - -/** - * Page Object Model for the Workflow Edit page - * Handles interactions with the workflow editor interface - */ -export class WorkflowEditPage extends LiveViewPage { - readonly diagram: WorkflowDiagramPage; - - protected selectors = { - createButton: '#create_workflow_btn', - newWorkflowPanel: '#new-workflow-panel', - runButton: '[data-testid="run-workflow-btn"]', - saveButton: 'button:has-text("Save")', - topBar: '[data-testid="top-bar"]', - unsavedChangesIndicator: - '.absolute.-m-1.rounded-full.bg-danger-500.w-3.h-3[data-is-dirty="true"]', - workflowNameInput: 'input[name="workflow[name]"]', - }; - - constructor(page: Page) { - super(page); - this.diagram = new WorkflowDiagramPage(page); - } - - /** - * Get a JobFormPage instance for a specific job index - * @param jobIndex - The index of the job (0-based) - */ - jobForm(jobIndex: number = 0): JobFormPage { - return new JobFormPage(this.page, jobIndex); - } - - /** - * Save the current workflow - */ - async clickSaveWorkflow(): Promise { - const topBar = this.page.locator(this.selectors.topBar); - const saveButton = topBar.locator(this.selectors.saveButton); - await expect(saveButton).toBeVisible(); - await saveButton.click(); - } - - /** - * Set the workflow name - * @param name - The name for the workflow - */ - async setWorkflowName(name: string): Promise { - const nameInput = this.page.locator(this.selectors.workflowNameInput); - await expect(nameInput).toBeVisible(); - await nameInput.fill(name); - } - - /** - * Assert that the unsaved changes indicator (red dot) is visible - * This appears as a small red circle near the save button when there are unsaved changes - */ - unsavedChangesIndicator(): Locator { - const topBar = this.page.locator(this.selectors.topBar); - const saveButton = topBar.locator(this.selectors.saveButton); - - // Find the save button's parent container which should contain the unsaved indicator - const saveButtonContainer = saveButton - .locator('..') - .locator('..') - .locator('..'); - const unsavedIndicator = saveButtonContainer.locator( - this.selectors.unsavedChangesIndicator - ); - - return unsavedIndicator; - } - - /** - * Click the beaker icon to navigate to the collaborative editor. - * - * This icon is only visible when: - * - User has experimental features enabled - * - Workflow is on the latest snapshot version - * - New workflow panel is not open - * - * @throws Error if the beaker icon is not visible - */ - async clickCollaborativeEditorToggle(): Promise { - // Wait for LiveView connection first - await this.waitForConnected(); - - // Locate beaker icon by aria-label (most stable selector) - const beakerIcon = this.page.locator( - 'a[aria-label="Switch to collaborative editor (experimental)"]' - ); - - // Verify visibility before clicking - await expect(beakerIcon).toBeVisible({ timeout: 5000 }); - - // Click the icon - await beakerIcon.click(); - - // Wait for navigation to complete - await this.page.waitForLoadState('networkidle'); - } - - /** - * Select workflow type from the new workflow panel - * @param typeText - The display text of the workflow type (e.g., "Event-based Workflow") - */ - async selectWorkflowType(typeText: string): Promise { - // Wait for the new workflow panel to be visible - await expect( - this.page.locator(this.selectors.newWorkflowPanel) - ).toBeVisible(); - - // Find the label containing the type text and click its associated radio button - const label = this.page.locator('label').filter({ hasText: typeText }); - await expect(label).toBeVisible(); - await label.click(); - } - - /** - * Click the Create button to create the selected workflow - */ - async clickCreateWorkflow(): Promise { - await expect(this.page.locator(this.selectors.createButton)).toBeVisible(); - await this.page.locator(this.selectors.createButton).click(); - } -} diff --git a/assets/test/e2e/specs/collaborative/collaborative-editor-credentials.spec.ts b/assets/test/e2e/specs/collaborative/collaborative-editor-credentials.spec.ts index 7ddde83df3d..fcd163eca35 100644 --- a/assets/test/e2e/specs/collaborative/collaborative-editor-credentials.spec.ts +++ b/assets/test/e2e/specs/collaborative/collaborative-editor-credentials.spec.ts @@ -1,13 +1,8 @@ import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; + import { enableExperimentalFeatures } from '../../e2e-helper'; -import { - LoginPage, - ProjectsPage, - WorkflowsPage, - WorkflowEditPage, - WorkflowCollaborativePage, -} from '../../pages'; +import { LoginPage, WorkflowCollaborativePage } from '../../pages'; +import { getTestData } from '../../test-data'; test.describe('Collaborative Editor - Job Credentials', () => { let testData: Awaited>; @@ -32,29 +27,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { test('Save job without credential in collaborative editor', async ({ page, }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill('input[name="workflow[name]"]', 'Test Workflow No Cred'); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('Configure job WITHOUT selecting a credential', async () => { @@ -101,32 +79,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { }); test('Save job with project credential', async ({ page }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill( - 'input[name="workflow[name]"]', - 'Test Workflow With Cred' - ); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('Select a project credential', async () => { @@ -183,32 +141,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { }); test('Clear credential after it was selected', async ({ page }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill( - 'input[name="workflow[name]"]', - 'Test Workflow Clear Cred' - ); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('First, select a credential', async () => { @@ -258,32 +196,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { }); test('Switch between project and keychain credentials', async ({ page }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill( - 'input[name="workflow[name]"]', - 'Test Workflow Switch Cred' - ); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('Check if keychain credentials exist', async () => { @@ -370,32 +288,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { test('Multiple jobs with different credential configurations', async ({ page, }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill( - 'input[name="workflow[name]"]', - 'Test Workflow Multi Jobs' - ); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('Configure first job with no credential', async () => { @@ -489,32 +387,12 @@ test.describe('Collaborative Editor - Job Credentials', () => { test('Job created via diagram plus button has null credentials', async ({ page, }) => { - await test.step('Navigate to project and create new workflow', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.clickNewWorkflow(); - - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await page.fill( - 'input[name="workflow[name]"]', - 'Test Workflow Plus Button' - ); - await workflowEdit.clickCreateWorkflow(); - }); - - await test.step('Navigate to collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Wait for collaborative editor to load', async () => { + await test.step('Open workflow in collaborative editor', async () => { const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); }); await test.step('Add new job via plus button', async () => { diff --git a/assets/test/e2e/specs/collaborative/collaborative-editor-navigation.spec.ts b/assets/test/e2e/specs/collaborative/collaborative-editor-navigation.spec.ts deleted file mode 100644 index d58f1d043a2..00000000000 --- a/assets/test/e2e/specs/collaborative/collaborative-editor-navigation.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; -import { enableExperimentalFeatures } from '../../e2e-helper'; -import { - LoginPage, - ProjectsPage, - WorkflowsPage, - WorkflowEditPage, - WorkflowCollaborativePage, -} from '../../pages'; - -/** - * Test suite for navigating to the collaborative editor via the beaker icon. - * - * The beaker icon appears when: - * 1. User has experimental features enabled - * 2. Workflow has a snapshot with lock_version matching the current workflow lock_version - * - * Demo data automatically creates snapshots through Workflows.save_workflow/3 and - * subsequent calls to Jobs.create_job/2 and Workflows.create_edge/2. - */ - -test.describe('Collaborative Editor Navigation', () => { - let testData: Awaited>; - - test.beforeAll(async () => { - testData = await getTestData(); - }); - - test.beforeEach(async ({ page }) => { - // Enable experimental features for the editor user - // Must be done in beforeEach because global setup resets DB - await enableExperimentalFeatures(testData.users.editor.email); - - // Login as editor user - await page.goto('/'); - const loginPage = new LoginPage(page); - await loginPage.loginIfNeeded( - testData.users.editor.email, - testData.users.editor.password - ); - }); - - test('navigate to collaborative editor via beaker icon @collaborative @smoke', async ({ - page, - }) => { - await test.step('Navigate to project', async () => { - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - }); - - await test.step('Navigate to workflow', async () => { - const workflowsPage = new WorkflowsPage(page); - - // Use POM method which handles waitForEventAttached - await workflowsPage.navigateToWorkflow(testData.workflows.openhie.name); - - // Wait for LiveView connection on edit page - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - }); - - await test.step('Verify beaker icon is visible', async () => { - // Beaker icon requires: experimental features + latest snapshot - const beakerIcon = page.locator( - 'a[aria-label="Switch to collaborative editor (experimental)"]' - ); - await expect(beakerIcon).toBeVisible({ timeout: 10000 }); - }); - - await test.step('Click beaker icon to open collaborative editor', async () => { - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.clickCollaborativeEditorToggle(); - }); - - await test.step('Verify collaborative editor loads', async () => { - const collabEditor = new WorkflowCollaborativePage(page); - - // Verify URL changed correctly - await collabEditor.verifyUrl({ - projectId: testData.projects.openhie.id, - workflowId: testData.workflows.openhie.id, - path: '', - }); - - // Wait for React component to mount - await collabEditor.waitForReactComponentLoaded(); - - // Verify main container is visible - await expect(collabEditor.container).toBeVisible(); - - // Wait for sync status (collaborative features working) - await collabEditor.waitForSynced(); - - // Verify no errors displayed - await collabEditor.verifyNoErrors(); - }); - }); -}); diff --git a/assets/test/e2e/specs/collaborative/edge-validation.spec.ts b/assets/test/e2e/specs/collaborative/edge-validation.spec.ts index 393cc33fbce..215ff835ac4 100644 --- a/assets/test/e2e/specs/collaborative/edge-validation.spec.ts +++ b/assets/test/e2e/specs/collaborative/edge-validation.spec.ts @@ -1,14 +1,9 @@ import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; + import { enableExperimentalFeatures } from '../../e2e-helper'; -import { - LoginPage, - ProjectsPage, - WorkflowsPage, - WorkflowEditPage, - WorkflowCollaborativePage, -} from '../../pages'; +import { LoginPage, WorkflowCollaborativePage } from '../../pages'; import { WorkflowDiagramPage } from '../../pages/components/workflow-diagram.page'; +import { getTestData } from '../../test-data'; /** * Edge Validation in Collaborative Editor @@ -42,23 +37,12 @@ test.describe('Edge Validation in Collaborative Editor @collaborative', () => { testData.users.editor.password ); - // Navigate to project and workflow - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.navigateToWorkflow(testData.workflows.openhie.name); - - // Wait for workflow edit page to load - const workflowEdit = new WorkflowEditPage(page); - await workflowEdit.waitForConnected(); - - // Switch to collaborative editor - await workflowEdit.clickCollaborativeEditorToggle(); - + // Navigate directly to the collaborative editor (the only editor) const collabEditor = new WorkflowCollaborativePage(page); - await collabEditor.waitForReactComponentLoaded(); - await collabEditor.waitForSynced(); + await collabEditor.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); // Wait for workflow diagram to be ready const diagram = new WorkflowDiagramPage(page); diff --git a/assets/test/e2e/specs/collaborative/job-step-sync.spec.ts b/assets/test/e2e/specs/collaborative/job-step-sync.spec.ts index 6aa26f6639a..4df5182bf63 100644 --- a/assets/test/e2e/specs/collaborative/job-step-sync.spec.ts +++ b/assets/test/e2e/specs/collaborative/job-step-sync.spec.ts @@ -1,13 +1,7 @@ import { test, expect } from '@playwright/test'; import { enableExperimentalFeatures } from '../../e2e-helper'; -import { - LoginPage, - ProjectsPage, - WorkflowsPage, - WorkflowEditPage, - WorkflowCollaborativePage, -} from '../../pages'; +import { LoginPage, WorkflowCollaborativePage } from '../../pages'; import { getTestData } from '../../test-data'; /** @@ -39,22 +33,12 @@ test.describe('Job-Step Selection Sync @collaborative', () => { testData.users.editor.password ); - // Navigate to collaborative editor - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.navigateToWorkflow(testData.workflows.openhie.name); - - // Wait for workflow edit page to load - const workflowEditPage = new WorkflowEditPage(page); - await workflowEditPage.waitForConnected(); - - // Switch to collaborative editor via beaker icon - await workflowEditPage.clickCollaborativeEditorToggle(); - + // Navigate directly to the collaborative editor (the only editor) const collabPage = new WorkflowCollaborativePage(page); - await collabPage.waitForReactComponentLoaded(); + await collabPage.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); // Wait for workflow canvas to be interactive (job nodes visible) await page diff --git a/assets/test/e2e/specs/collaborative/run-retry-keyboard-shortcuts.spec.ts b/assets/test/e2e/specs/collaborative/run-retry-keyboard-shortcuts.spec.ts index 5c14fc5adf8..2d0a955b345 100644 --- a/assets/test/e2e/specs/collaborative/run-retry-keyboard-shortcuts.spec.ts +++ b/assets/test/e2e/specs/collaborative/run-retry-keyboard-shortcuts.spec.ts @@ -1,13 +1,8 @@ import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; + import { enableExperimentalFeatures } from '../../e2e-helper'; -import { - LoginPage, - ProjectsPage, - WorkflowsPage, - WorkflowEditPage, - WorkflowCollaborativePage, -} from '../../pages'; +import { LoginPage, WorkflowCollaborativePage } from '../../pages'; +import { getTestData } from '../../test-data'; /** * E2E Test Suite: Run/Retry Keyboard Shortcuts @@ -45,22 +40,12 @@ test.describe('Run/Retry Keyboard Shortcuts @collaborative @critical', () => { testData.users.editor.password ); - // Navigate to collaborative editor - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject(testData.projects.openhie.name); - - const workflowsPage = new WorkflowsPage(page); - await workflowsPage.navigateToWorkflow(testData.workflows.openhie.name); - - // Wait for workflow edit page to load - const workflowEditPage = new WorkflowEditPage(page); - await workflowEditPage.waitForConnected(); - - // Switch to collaborative editor via beaker icon - await workflowEditPage.clickCollaborativeEditorToggle(); - + // Navigate directly to the collaborative editor (the only editor) const collabPage = new WorkflowCollaborativePage(page); - await collabPage.waitForReactComponentLoaded(); + await collabPage.open({ + projectId: testData.projects.openhie.id, + workflowId: testData.workflows.openhie.id, + }); // Wait for workflow canvas to be interactive (job nodes visible) // We don't strictly need full "Synced" state for keyboard shortcut tests diff --git a/assets/test/e2e/specs/smoke/basic-navigation.spec.ts b/assets/test/e2e/specs/smoke/basic-navigation.spec.ts index d90dce2397c..e730c3b747a 100644 --- a/assets/test/e2e/specs/smoke/basic-navigation.spec.ts +++ b/assets/test/e2e/specs/smoke/basic-navigation.spec.ts @@ -1,11 +1,13 @@ import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; + import { WorkflowsPage, - WorkflowEditPage, + WorkflowCollaborativePage, LoginPage, ProjectsPage, } from '../../pages'; +import { WorkflowDiagramPage } from '../../pages/components/workflow-diagram.page'; +import { getTestData } from '../../test-data'; test('homepage loads successfully', async ({ page }) => { await page.goto('/'); @@ -35,12 +37,13 @@ test.describe('Workflow Navigation with Dynamic Data', () => { ); }); - test('can navigate to existing workflow and see React Flow with 6 nodes', async ({ + test('can navigate to existing workflow and see React Flow with 5 nodes', async ({ page, }) => { const projectsPage = new ProjectsPage(page); const workflowsPage = new WorkflowsPage(page); - const workflowEdit = new WorkflowEditPage(page); + const collabEditor = new WorkflowCollaborativePage(page); + const diagram = new WorkflowDiagramPage(page); // Navigate to project using ProjectsPage POM await projectsPage.navigateToProject(testData.projects.openhie.name); @@ -48,47 +51,27 @@ test.describe('Workflow Navigation with Dynamic Data', () => { await expect(page.getByText('OpenHIE Workflow')).toBeVisible(); - // Navigate to the workflow using POM + // Navigate to the workflow using POM. The workflow route now renders the + // collaborative editor directly (the only editor). await workflowsPage.navigateToWorkflow(testData.workflows.openhie.name); - await workflowEdit.waitForConnected(); // Verify URL contains the actual workflow ID from database await expect(page).toHaveURL( new RegExp(`/w/${testData.workflows.openhie.id}`) ); - await workflowEdit.diagram.verifyReactFlowPresent(); - - // Assert we have 5 nodes visible in the workflow - await workflowEdit.diagram.nodes.verifyCount(5); - await expect(page.getByRole('main')).toMatchAriaSnapshot(` - - navigation "Breadcrumb": - - list: - - listitem: - - link "Home": - - /url: / - - listitem: - - link "Projects": - - /url: /projects - - listitem: - - link "openhie-project": - - /url: /projects/4adf2644-ed4e-4f97-a24c-ab35b3cb1efa/w - - listitem: - - link "Workflows": - - /url: /projects/4adf2644-ed4e-4f97-a24c-ab35b3cb1efa/w - - listitem: OpenHIE Workflow latest - - checkbox - - switch - - link: - - /url: /projects/4adf2644-ed4e-4f97-a24c-ab35b3cb1efa/w/01ec091c-c52d-44d8-81df-213505f0da2b?m=settings - - link "Run": - - /url: /projects/4adf2644-ed4e-4f97-a24c-ab35b3cb1efa/w/01ec091c-c52d-44d8-81df-213505f0da2b?m=workflow_input&s=cae544ab-03dc-4ccc-a09c-fb4edb255d7a - - button "Save" - - button "Open options user avatar": - - img "user avatar" - `); - - // Assert no error messages are shown + // Wait for the collaborative editor to load and sync + await collabEditor.waitForReactComponentLoaded(); + await collabEditor.waitForSynced(); + + await diagram.verifyReactFlowPresent(); + + // Assert we have 5 nodes visible in the workflow (4 jobs + 1 trigger) + await diagram.nodes.verifyCount(5); + + // Assert no error states are shown + await collabEditor.verifyNoErrors(); + const errorMessage = page.locator('text=Something went wrong'); await expect(errorMessage).not.toBeVisible(); diff --git a/assets/test/e2e/specs/workflows/workflow-steps-creation.spec.ts b/assets/test/e2e/specs/workflows/workflow-steps-creation.spec.ts deleted file mode 100644 index 24e55eadb57..00000000000 --- a/assets/test/e2e/specs/workflows/workflow-steps-creation.spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { getTestData } from '../../test-data'; -import { WorkflowEditPage, WorkflowsPage, ProjectsPage } from '../../pages'; - -test.describe('US-022: Workflow Steps - Add and Configure', () => { - let testData: Awaited>; - - test.beforeAll(async () => { - testData = await getTestData(); - }); - - test.beforeEach(async ({ page }) => { - // Login as editor user - await page.goto('/'); - const loginForm = page.locator('#login form'); - if (await loginForm.isVisible()) { - await page.fill('input[name="user[email]"]', testData.users.editor.email); - await page.fill( - 'input[name="user[password]"]', - testData.users.editor.password - ); - await page.click('button[type="submit"]'); - await page.waitForLoadState('networkidle'); - } - }); - - test('TC-022: Add and configure workflow steps', async ({ page }) => { - const workflowsPage = new WorkflowsPage(page); - const workflowEdit = new WorkflowEditPage(page); - - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject('openhie-project'); - - // 1. Create a new event-based workflow - await workflowsPage.clickNewWorkflow(); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await workflowEdit.clickCreateWorkflow(); - - // 2. Configure the first job - await workflowEdit.diagram.nodes.clickJobByIndex(0); - - // Verify job form is displayed in sidebar - await expect(workflowEdit.jobForm(0).workflowForm).toBeAttached(); - - await workflowEdit - .jobForm(0) - .adaptorSelect.selectOption('@openfn/language-http'); - - await expect(workflowEdit.jobForm(0).versionSelect).toHaveValue( - '@openfn/language-http@7.2.2' - ); - await workflowEdit.jobForm(0).nameInput.click(); - await workflowEdit.jobForm(0).nameInput.fill('Fetch User Data'); - - // 3. Add second job: Common adaptor - await workflowEdit.diagram.clickFitView(); - - await workflowEdit.diagram.nodes.clickPlusButtonOn('Fetch User Data'); - await workflowEdit.diagram.nodes.fillPlaceholderName('Transform Data'); - - await expect(workflowEdit.jobForm(1).header).toHaveText('Transform Data'); - - await expect(workflowEdit.jobForm(1).versionSelect).toHaveValue( - '@openfn/language-common@latest' - ); - - // 4. Verify unsaved changes indicator and save - await expect(workflowEdit.unsavedChangesIndicator()).toBeVisible(); - - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - - // 5. Add third job: PostgreSQL adaptor - await workflowEdit.diagram.nodes.clickPlusButtonOn('Transform Data'); - await workflowEdit.diagram.nodes.fillPlaceholderName('Save to Database'); - - await expect(workflowEdit.jobForm(2).header).toHaveText('Save to Database'); - - // Select PostgreSQL adaptor (or fallback to another database adaptor) - await workflowEdit.jobForm(2).adaptorSelect.selectOption('postgresql'); - - // Verify PostgreSQL adaptor version is selected - await expect(workflowEdit.jobForm(2).versionSelect).toHaveValue( - /@openfn\/language-postgresql@\d\.\d\.\d/ - ); - - // 6. Verify workflow structure - // Check that all jobs appear visually connected with arrows - await workflowEdit.diagram.nodes.verifyExists('Fetch User Data'); - await workflowEdit.diagram.nodes.verifyExists('Transform Data'); - await workflowEdit.diagram.nodes.verifyExists('Save to Database'); - - // 7. Test job selection - // Click on each job and verify job form opens in sidebar - await workflowEdit.diagram.nodes.click('Fetch User Data'); - await expect(workflowEdit.jobForm(0).header).toHaveText(/Fetch User Data/); - - await workflowEdit.diagram.nodes.click(' Transform Data '); - console.log(workflowEdit.jobForm(1).header); - await expect(workflowEdit.jobForm(1).header).toHaveText(/Transform Data/); - - await workflowEdit.diagram.nodes.click('Save to Database'); - await expect(workflowEdit.jobForm(2).header).toHaveText('Save to Database'); - - await workflowEdit.waitForSocketSettled(); - - // 8. Save and verify persistence - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - - await workflowsPage.clickMenuItem('Workflows'); - await workflowsPage.navigateToWorkflow('Event-based Workflow'); - - // Verify all jobs still exist and structure persists - await workflowEdit.diagram.nodes.verifyExists('Fetch User Data'); - await workflowEdit.diagram.nodes.verifyExists('Transform Data'); - await workflowEdit.diagram.nodes.verifyExists('Save to Database'); - - // Verify job configurations persist - await workflowEdit.diagram.nodes.click('Fetch User Data'); - await expect(workflowEdit.jobForm(0).versionSelect).toHaveValue( - '@openfn/language-http@7.2.2' - ); - - await workflowEdit.diagram.nodes.click('Transform Data'); - await expect(workflowEdit.jobForm(1).versionSelect).toHaveValue( - '@openfn/language-common@latest' - ); - - await workflowEdit.diagram.nodes.click('Save to Database'); - await expect(workflowEdit.jobForm(2).versionSelect).toHaveValue( - /@openfn\/language-postgresql@\d\.\d\.\d/ - ); - }); - - test('Save job without credential in LiveView editor', async ({ page }) => { - const workflowsPage = new WorkflowsPage(page); - const workflowEdit = new WorkflowEditPage(page); - - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject('openhie-project'); - - // Create a new workflow - await workflowsPage.clickNewWorkflow(); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await workflowEdit.clickCreateWorkflow(); - - // Configure the first job WITHOUT credential - await workflowEdit.diagram.nodes.clickJobByIndex(0); - - await expect(workflowEdit.jobForm(0).workflowForm).toBeAttached(); - - await workflowEdit - .jobForm(0) - .adaptorSelect.selectOption('@openfn/language-http'); - await workflowEdit.jobForm(0).nameInput.fill('HTTP Request No Cred'); - - // Verify credential dropdown is empty - const credentialField = page.locator( - 'select[name="workflow[jobs][0][project_credential_id]"]' - ); - - await expect(credentialField).toHaveValue(''); - - // Save and verify - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - }); - - test('Save job with credential in LiveView editor', async ({ page }) => { - const workflowsPage = new WorkflowsPage(page); - const workflowEdit = new WorkflowEditPage(page); - - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject('openhie-project'); - - await workflowsPage.clickNewWorkflow(); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await workflowEdit.clickCreateWorkflow(); - - await workflowEdit.diagram.nodes.clickJobByIndex(0); - await expect(workflowEdit.jobForm(0).workflowForm).toBeAttached(); - - await workflowEdit - .jobForm(0) - .adaptorSelect.selectOption('@openfn/language-http'); - await workflowEdit.jobForm(0).nameInput.fill('HTTP Request With Cred'); - - // Select first available credential - const credentialField = page.locator( - 'select[name="workflow[jobs][0][project_credential_id]"]' - ); - - // Select first non-empty option - await credentialField.selectOption({ index: 1 }); - const selectedValue = await credentialField.inputValue(); - expect(selectedValue).not.toBe(''); - - // Save and verify - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - - // Reload and verify persistence - await page.reload(); - await workflowEdit.diagram.nodes.clickJobByIndex(0); - await expect(credentialField).toHaveValue(selectedValue); - }); - - test('Clear credential in LiveView editor', async ({ page }) => { - const workflowsPage = new WorkflowsPage(page); - const workflowEdit = new WorkflowEditPage(page); - - const projectsPage = new ProjectsPage(page); - await projectsPage.navigateToProject('openhie-project'); - - await workflowsPage.clickNewWorkflow(); - await workflowEdit.selectWorkflowType('Event-based Workflow'); - await workflowEdit.clickCreateWorkflow(); - - await workflowEdit.diagram.nodes.clickJobByIndex(0); - - const credentialField = page.locator( - 'select[name="workflow[jobs][0][project_credential_id]"]' - ); - - // Select credential - await credentialField.selectOption({ index: 1 }); - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - - // Clear credential - await credentialField.selectOption(''); - await workflowEdit.clickSaveWorkflow(); - await workflowEdit.expectFlashMessage('Workflow saved successfully.'); - - // Verify cleared - await page.reload(); - await workflowEdit.diagram.nodes.clickJobByIndex(0); - await expect(credentialField).toHaveValue(''); - }); -}); From e709c3b3e96b51dc688a988ba48940c28aeaf8ee Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Thu, 2 Jul 2026 07:39:48 +0000 Subject: [PATCH 5/6] chore: ignore specific audits --- .circleci/config.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b80d39bf912..ef2ce670b8f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -188,8 +188,21 @@ jobs: command: | # GHSA-g2wm-735q-3f56: cowlib cookie encoder CRLF injection (low); # no patched release available yet. + # + # hackney advisories below are fixed in hackney 4.0.1, but we cannot + # upgrade yet: hackney 4.x is a breaking API change (request/body + # return shapes changed) and our dependency graph is not ready. + # tzdata (all releases, incl. latest 1.1.4) pins hackney ~> 1.17 and + # calls :hackney.get/:hackney.body directly, which crashes on 4.x; + # httpoison < 3.0 has the same incompatibility and gcs_signed_url + # pins httpoison ~> 2.0. Revisit once tzdata ships a hackney-4.x + # compatible release. + # GHSA-gp9c-pm5m-5cxr (high) ssl:connect post-handshake timeout + # GHSA-pj7v-xfvx-wmjq (moderate) SSRF allowlist bypass + # GHSA-j9wq-vxxc-94wf (moderate) CR/LF injection in query param + # GHSA-mp55-p8c9-rfw2 (low) CRLF injection via domain/path sudo -u lightning mix deps.audit \ - --ignore-advisory-ids GHSA-g2wm-735q-3f56 \ + --ignore-advisory-ids GHSA-g2wm-735q-3f56,GHSA-gp9c-pm5m-5cxr,GHSA-pj7v-xfvx-wmjq,GHSA-j9wq-vxxc-94wf,GHSA-mp55-p8c9-rfw2 \ || echo "deps.audit" >> /tmp/lint_failed - run: name: "Check for retired Hex packages" From 1626477fb451721b4a8f8faa6237a6d1598bc0f2 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Thu, 2 Jul 2026 07:57:19 +0000 Subject: [PATCH 6/6] chore: lint fail with error message --- .circleci/config.yml | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ef2ce670b8f..b334359dd6c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -171,21 +171,31 @@ jobs: - run: name: "Check code formatting" command: | - sudo -u lightning mix format --check-formatted || echo "format" >> /tmp/lint_failed + mkdir -p /tmp/lint + sudo -u lightning mix format --check-formatted > /tmp/lint/format.log 2>&1 \ + || echo "format" >> /tmp/lint_failed + cat /tmp/lint/format.log - run: name: "Check code style with Credo" when: always command: | - sudo -u lightning mix credo --strict --all || echo "credo" >> /tmp/lint_failed + mkdir -p /tmp/lint + sudo -u lightning mix credo --strict --all > /tmp/lint/credo.log 2>&1 \ + || echo "credo" >> /tmp/lint_failed + cat /tmp/lint/credo.log - run: name: "Check for security vulnerabilities" when: always command: | - sudo -u lightning mix sobelow --threshold medium || echo "sobelow" >> /tmp/lint_failed + mkdir -p /tmp/lint + sudo -u lightning mix sobelow --threshold medium > /tmp/lint/sobelow.log 2>&1 \ + || echo "sobelow" >> /tmp/lint_failed + cat /tmp/lint/sobelow.log - run: name: "Check for known-vulnerable Hex dependencies" when: always command: | + mkdir -p /tmp/lint # GHSA-g2wm-735q-3f56: cowlib cookie encoder CRLF injection (low); # no patched release available yet. # @@ -203,12 +213,17 @@ jobs: # GHSA-mp55-p8c9-rfw2 (low) CRLF injection via domain/path sudo -u lightning mix deps.audit \ --ignore-advisory-ids GHSA-g2wm-735q-3f56,GHSA-gp9c-pm5m-5cxr,GHSA-pj7v-xfvx-wmjq,GHSA-j9wq-vxxc-94wf,GHSA-mp55-p8c9-rfw2 \ + > /tmp/lint/deps_audit.log 2>&1 \ || echo "deps.audit" >> /tmp/lint_failed + cat /tmp/lint/deps_audit.log - run: name: "Check for retired Hex packages" when: always command: | - sudo -u lightning mix hex.audit || echo "hex.audit" >> /tmp/lint_failed + mkdir -p /tmp/lint + sudo -u lightning mix hex.audit > /tmp/lint/hex_audit.log 2>&1 \ + || echo "hex.audit" >> /tmp/lint_failed + cat /tmp/lint/hex_audit.log - run: name: "Verify all checks passed" when: always @@ -216,6 +231,18 @@ jobs: if [ -f /tmp/lint_failed ]; then echo "The following checks failed:" cat /tmp/lint_failed + echo + while IFS= read -r check; do + # map the check name to its captured log file + log="/tmp/lint/$(echo "$check" | tr '.' '_').log" + echo "==================== ${check} ====================" + if [ -f "$log" ]; then + cat "$log" + else + echo "(no output captured)" + fi + echo + done < /tmp/lint_failed exit 1 fi