diff --git a/.env.example b/.env.example index 07e93c5b163..4d61d147744 100644 --- a/.env.example +++ b/.env.example @@ -297,10 +297,6 @@ # LOCAL_ADAPTORS=true # OPENFN_ADAPTORS_REPO=/path/to/repo/ # OPENFN_ADAPTORS_REPO=/path/to/private,/path/to/canonical -# -# Control whether metrics reported by the Workflow editor or Job editor are -# written to the Lightning logs. -# UI_METRICS_ENABLED=false # ============================================================================== # <><><> WEBHOOK RETRY SETTINGS <><><> diff --git a/CHANGELOG.md b/CHANGELOG.md index 598d1365c3d..b82fa6e3a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ and this project adheres to implicitly introduces a limit of 100 concurrent channels per Websocket connection (transport). If worker instances are set with a concurrency higher than 100, this will result in failures. +- Replaced the legacy editor with the collaborative editor for all users + [#4402](https://github.com/OpenFn/lightning/issues/4402) ### Fixed diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4a8b63e3045..ab59b7a209f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -224,7 +224,6 @@ For SMTP, the following environment variables are required: | `ADAPTORS_REGISTRY_JSON_PATH` | Path to adaptor registry file. When provided, the app will attempt to read from it then later fallback to the internet | | `SECRET_KEY_BASE` | A secret key used as a base to generate secrets for encrypting and signing data. | | `SENTRY_DSN` | If using Sentry for error monitoring, your DSN | -| `UI_METRICS_ENABLED` | Enable serverside tracking of certain metrics related to the UI. This s temporary functionality. Defaults to `false`. | | `URL_HOST` | The host used for writing URLs (e.g., `demo.openfn.org`) | | `URL_PORT` | The port, usually `443` for production | | `URL_SCHEME` | The scheme for writing URLs (e.g., `https`) | diff --git a/assets/css/app.css b/assets/css/app.css index 87eece11dd3..04fede72e31 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -942,14 +942,6 @@ min-height: 4.5rem; } - .job-editor-panel.collapsed .close-button { - @apply pb-0; - } - - .job-editor-panel.collapsed[data-editor-disabled='true'] .close-button { - min-height: 3.75rem; - } - #output-logs.collapsed .close-button { margin-bottom: 5.5rem; } diff --git a/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx b/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx index 85c54177798..26b418888f1 100644 --- a/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx +++ b/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx @@ -20,7 +20,7 @@ import { type Monaco, MonacoEditor, setTheme } from '../../monaco'; import { addKeyboardShortcutOverrides } from '../../monaco/keyboard-overrides'; import { useHandleDiffDismissed } from '../contexts/MonacoRefContext'; -import createCompletionProvider from '../../editor/magic-completion'; +import createCompletionProvider from '../utils/magic-completion'; import { LoadingIndicator } from './common/LoadingIndicator'; import { Cursors } from './Cursors'; 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/collaborative-editor/stores/createWorkflowStore.ts b/assets/js/collaborative-editor/stores/createWorkflowStore.ts index 500b45cabc8..c1444fda654 100644 --- a/assets/js/collaborative-editor/stores/createWorkflowStore.ts +++ b/assets/js/collaborative-editor/stores/createWorkflowStore.ts @@ -132,10 +132,10 @@ import { produce } from 'immer'; import type { Channel } from 'phoenix'; import type { PhoenixChannelProvider } from 'y-phoenix-channel'; import * as Y from 'yjs'; -import { z } from 'zod'; import _logger from '#/utils/logger'; +import { DEFAULT_TEXT } from '../../workflow-store/constants'; import type { WorkflowState as YAMLWorkflowState } from '../../yaml/types'; import { reconcileDanglingReferences } from '../adapters/reconcileDanglingReferences'; import { YAMLStateToYDoc } from '../adapters/YAMLStateToYDoc'; @@ -908,16 +908,11 @@ export const createWorkflowStore = () => { const jobsArray = ydoc.getArray('jobs'); const jobMap = new Y.Map(); - // Default body text shown in the Monaco editor for new jobs - const defaultBody = `// Check out the Job Writing Guide for help getting started: -// https://docs.openfn.org/documentation/jobs/job-writing-guide -`; - ydoc.transact(() => { jobMap.set('id', job.id); jobMap.set('name', job.name); // Always initialize body as Y.Text with default if empty - jobMap.set('body', new Y.Text(job.body || defaultBody)); + jobMap.set('body', new Y.Text(job.body || DEFAULT_TEXT)); // Set adaptor field (defaults to common if not provided) jobMap.set('adaptor', job.adaptor); // Initialize credential fields to null diff --git a/assets/js/editor/lib/es5.min.dts.js b/assets/js/collaborative-editor/utils/es5.min.dts.js similarity index 91% rename from assets/js/editor/lib/es5.min.dts.js rename to assets/js/collaborative-editor/utils/es5.min.dts.js index cfb74845ecf..bbe3bab9eaa 100644 --- a/assets/js/editor/lib/es5.min.dts.js +++ b/assets/js/collaborative-editor/utils/es5.min.dts.js @@ -7,5 +7,5 @@ export default ` // hack to remove undefined from code suggest // https://github.com/microsoft/monaco-editor/issues/2018 - declare module undefined + declare module undefined `; diff --git a/assets/js/collaborative-editor/utils/loadDTS.ts b/assets/js/collaborative-editor/utils/loadDTS.ts index 5feadaf97a8..052e7870306 100644 --- a/assets/js/collaborative-editor/utils/loadDTS.ts +++ b/assets/js/collaborative-editor/utils/loadDTS.ts @@ -1,6 +1,6 @@ import { fetchDTSListing, fetchFile } from '@openfn/describe-package'; -import dts_es5 from '../../editor/lib/es5.min.dts'; +import dts_es5 from './es5.min.dts'; export type Lib = { content: string; diff --git a/assets/js/editor/magic-completion.ts b/assets/js/collaborative-editor/utils/magic-completion.ts similarity index 99% rename from assets/js/editor/magic-completion.ts rename to assets/js/collaborative-editor/utils/magic-completion.ts index 6db1fea0dea..a13f8f239d2 100644 --- a/assets/js/editor/magic-completion.ts +++ b/assets/js/collaborative-editor/utils/magic-completion.ts @@ -3,7 +3,7 @@ import jp from 'jsonpath'; import { languages, editor } from 'monaco-editor'; import type ts from 'typescript'; -import type { ModelNode } from '../metadata-explorer/Model'; +import type { ModelNode } from '#/metadata-explorer/Model'; const ensureArray = (x: any) => (Array.isArray(x) ? x : [x]); diff --git a/assets/js/editor/Editor.tsx b/assets/js/editor/Editor.tsx deleted file mode 100644 index f2a0f9630a0..00000000000 --- a/assets/js/editor/Editor.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import type { EditorProps as MonacoProps } from '@monaco-editor/react'; -import { fetchDTSListing, fetchFile } from '@openfn/describe-package'; -import type { editor } from 'monaco-editor'; -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { submitOrClick } from '../common'; -import { MonacoEditor, type Monaco } from '../monaco'; - -import dts_es5 from './lib/es5.min.dts'; -import createCompletionProvider from './magic-completion'; - -// static imports for core lib - -export const DEFAULT_TEXT = `// Check out the Job Writing Guide for help getting started: -// https://docs.openfn.org/documentation/jobs/job-writing-guide -`; - -type EditorProps = { - source?: string; - adaptor?: string; // fully specified adaptor name - @ - metadata?: object; // TODO I can actually this very effectively from adaptors... - onChange?: (newSource: string) => void; - disabled?: boolean; - disabledMessage?: string; -}; - -const spinner = ( - - - - -); - -const loadingIndicator = ( -
- Loading types - {spinner} -
-); - -// https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html -const defaultOptions: MonacoProps['options'] = { - dragAndDrop: false, - lineNumbersMinChars: 3, - tabSize: 2, - minimap: { - enabled: false, - }, - scrollBeyondLastLine: false, - showFoldingControls: 'always', - // automaticLayout: true, // TODO this may impact performance as it polls - - // Hide the right-hand "overview" ruler - overviewRulerLanes: 0, - overviewRulerBorder: false, - - codeLens: false, - wordBasedSuggestions: false, - - fontFamily: 'Fira Code VF', - fontSize: 14, - fontLigatures: true, - - suggest: { - // https://microsoft.github.io/monaco-editor/docs.html#interfaces/editor.ISuggestOptions.html - showModules: true, - showKeywords: false, - showFiles: false, // This hides property names ?? - // showProperties: false, // seems to hide consts but show properties?? - showClasses: false, - showInterfaces: false, - showConstructors: false, - showDeprecated: false, - }, -}; - -type Lib = { - content: string; - filePath?: string; -}; - -async function loadDTS(specifier: string): Promise { - // Work out the module name from the specifier - // (his gets a bit tricky with @openfn/ module names) - const nameParts = specifier.split('@'); - nameParts.pop(); // remove the version - const name = nameParts.join('@'); - - const results: Lib[] = [{ content: dts_es5 }]; - - // Load common into its own module - // TODO maybe we need other dependencies too? collections? - if (name !== '@openfn/language-common') { - const pkg = await fetchFile(`${specifier}/package.json`); - const commonVersion = JSON.parse(pkg || '{}').dependencies?.[ - '@openfn/language-common' - ]; - - // jsDeliver doesn't appear to support semver range syntax (^1.0.0, 1.x, ~1.1.0) - const commonVersionMatch = commonVersion?.match(/^\d+\.\d+\.\d+/); - if (!commonVersionMatch) { - console.warn( - `@openfn/language-common@${commonVersion} contains semver range syntax.` - ); - } - - const commonSpecifier = `@openfn/language-common@${commonVersion.replace( - '^', - '' - )}`; - for await (const filePath of fetchDTSListing(commonSpecifier)) { - if (!filePath.startsWith('node_modules')) { - // Load every common typedef into the common module - let content = await fetchFile(`${commonSpecifier}${filePath}`); - content = content.replace(/\* +@(.+?)\*\//gs, '*/'); - results.push({ - content: `declare module '@openfn/language-common' { ${content} }`, - }); - } - } - } - - // This will store types.d.ts, if we can find it - let types = ''; - - // This stores string content for our adaptor - let adaptorDefs: string[] = []; - - for await (const filePath of fetchDTSListing(specifier)) { - if (!filePath.startsWith('node_modules')) { - let content = await fetchFile(`${specifier}${filePath}`); - // Convert relative paths - content = content - .replace(/from '\.\//g, `from '${name}/`) - .replace(/import '\.\//g, `import '${name}/`); - - // Remove js doc annotations - // this regex means: find a * then an @ (with 1+ space in between), then match everything up to a closing comment */ - // content = content.replace(/\* +@(.+?)\*\//gs, '*/'); - - const fileName = filePath.split('/').at(-1).replace('.d.ts', ''); - - // Import the index as the global namespace - but take care to convert all paths to absolute - if (fileName === 'index' || fileName === 'Adaptor') { - // It turns out that "export * as " seems to straight up not work in Monaco - // So this little hack will refactor import statements in a way that works - content = content.replace( - /export \* as (\w+) from '(.+)';/g, - ` - - import * as $1 from '$2'; - export { $1 };` - ); - adaptorDefs.push(`declare namespace { - {{$TYPES}} - ${content} -`); - } else if (fileName === 'types') { - types = content; - } else { - // Declare every other module as file - adaptorDefs.push(`declare module '${name}/${fileName}' { - {{$TYPES}} - ${content} -}`); - } - } - } - - // This just ensures that the global type defs appear in every scope - // This is basically a hack to work around https://github.com/OpenFn/lightning/issues/2641 - // If we find a types.d.ts, append it to every other file - adaptorDefs = adaptorDefs.map(def => def.replace('{{$TYPES}}', types)); - - results.push( - ...adaptorDefs.map(content => ({ - content, - })) - ); - - return results; -} - -export default function Editor({ - source, - adaptor, - onChange, - disabled, - disabledMessage, - metadata, -}: EditorProps) { - const [lib, setLib] = useState(); - const [loading, setLoading] = useState(false); - const [options, setOptions] = useState(defaultOptions); - const listeners = useRef<{ - insertSnippet?: EventListenerOrEventListenerObject; - updateLayout?: any; - }>({}); - - const [monaco, setMonaco] = useState(); - - const handleSourceChange = useCallback( - (newSource: string | undefined) => { - if (onChange && newSource) { - onChange(newSource); - } - }, - [onChange] - ); - - const handleEditorDidMount = useCallback( - (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { - window.monaco = monaco; - setMonaco(monaco); - - editor.addCommand( - monaco.KeyCode.Escape, - () => { - if (!(document.activeElement instanceof HTMLElement)) return; - document.activeElement.blur(); - }, - '!suggestWidgetVisible' - ); - - editor.addCommand( - // https://microsoft.github.io/monaco-editor/typedoc/classes/KeyMod.html - // https://microsoft.github.io/monaco-editor/typedoc/enums/KeyCode.html - monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, - function () { - const actionButton = document.getElementById('save-and-run')!; - submitOrClick(actionButton); - } - ); - - editor.addCommand( - // https://microsoft.github.io/monaco-editor/typedoc/classes/KeyMod.html - // https://microsoft.github.io/monaco-editor/typedoc/enums/KeyCode.html - monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, - function () { - const actionButton = document.getElementById( - 'create-new-work-order' - )!; - submitOrClick(actionButton); - } - ); - - monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ - // This seems to be needed to track the modules in d.ts files - allowNonTsExtensions: true, - - // Disables core js libs in code completion - noLib: true, - }); - - listeners.current.insertSnippet = (e: Event) => { - // Snippets are always added to the end of the job code - const model = editor.getModel(); - const lastLine = model.getLineCount(); - const eol = model.getLineLength(lastLine); - const op = { - range: new monaco.Range(lastLine, eol, lastLine, eol), - // @ts-ignore event typings - text: `\n${e.snippet}`, - forceMoveMarkers: true, - }; - - // Append the snippet - // https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.ICodeEditor.html#executeEdits - editor.executeEdits('snippets', [op]); - - // Ensure the snippet is fully visible - const newLastLine = model.getLineCount(); - editor.revealLines(lastLine + 1, newLastLine, 0); // 0 = smooth scroll - - // Set the selection to the start of the snippet - editor.setSelection(new monaco.Range(lastLine + 1, 0, lastLine + 1, 0)); - - // ensure the editor has focus - editor.focus(); - }; - - document.addEventListener( - 'insert-snippet', - listeners.current.insertSnippet - ); - }, - [lib] - ); - - useEffect(() => { - if (monaco && metadata) { - const p = monaco.languages.registerCompletionItemProvider( - 'javascript', - createCompletionProvider(monaco, metadata) - ); - return () => { - // Note: For now, whenever the adaptor changes, the editor will be un-mounted and remounted, so this is safe - // If and when the adaptor can be seamlessly changed, we'll have to be a bit smarter about how we dispose of the - // completion handler. State doesn't work very well, we probably need a ref for this - // If metadata is passed as a prop, this becomes a little bit easier to manage - p.dispose(); - }; - } - }, [monaco, metadata]); - - useEffect(() => { - // Create a node to hold overflow widgets - // This needs to be at the top level so that tooltips clip over Lightning UIs - const overflowNode = document.createElement('div'); - overflowNode.className = 'monaco-editor widgets-overflow-container'; - // Total hackage - acceptable given that the editor will be retired soon - overflowNode.style.zIndex = '9999'; - document.body.appendChild(overflowNode); - - setOptions({ - ...defaultOptions, - overflowWidgetsDomNode: overflowNode, - fixedOverflowWidgets: true, - }); - - return () => { - overflowNode.parentNode?.removeChild(overflowNode); - if (listeners.current?.insertSnippet) { - document.removeEventListener( - 'insert-snippet', - listeners.current.insertSnippet - ); - } - }; - }, []); - - useEffect(() => { - if (adaptor) { - setLoading(true); - setLib([]); // instantly clear intelligence - loadDTS(adaptor) - .then(l => { - setLib(l); - }) - .finally(() => { - setLoading(false); - }); - } - }, [adaptor]); - - useEffect(() => { - monaco?.languages.typescript.javascriptDefaults.setExtraLibs(lib!); - }, [monaco, lib]); - - return ( - <> -
- {loading && loadingIndicator} -
- Loading...
} - value={source || DEFAULT_TEXT} - options={{ - ...options, - readOnly: disabled, - readOnlyMessage: { - value: disabledMessage, - }, - enableCommandPalette: true, - }} - onMount={handleEditorDidMount} - onChange={handleSourceChange} - /> - - ); -} diff --git a/assets/js/editor/lib/es5.dts.js b/assets/js/editor/lib/es5.dts.js deleted file mode 100644 index 89415b26388..00000000000 --- a/assets/js/editor/lib/es5.dts.js +++ /dev/null @@ -1,189 +0,0 @@ -// https://github.com/microsoft/TypeScript/blob/main/lib/lib.es5.d.ts -export default ` - - -interface Array { - /** - * Gets or sets the length of the array. This is a number one higher than the highest index in the array. - */ - length: number; - /** - * Returns a string representation of an array. - */ - toString(): string; - /** - * Returns a string representation of an array. The elements are converted to string using their toLocaleString methods. - */ - toLocaleString(): string; - /** - * Removes the last element from an array and returns it. - * If the array is empty, undefined is returned and the array is not modified. - */ - pop(): T | undefined; - /** - * Appends new elements to the end of an array, and returns the new length of the array. - * @param items New elements to add to the array. - */ - push(...items: T[]): number; - /** - * Combines two or more arrays. - * This method returns a new array without modifying any existing arrays. - * @param items Additional arrays and/or items to add to the end of the array. - */ - concat(...items: ConcatArray[]): T[]; - /** - * Combines two or more arrays. - * This method returns a new array without modifying any existing arrays. - * @param items Additional arrays and/or items to add to the end of the array. - */ - concat(...items: (T | ConcatArray)[]): T[]; - /** - * Adds all the elements of an array into a string, separated by the specified separator string. - * @param separator A string used to separate one element of the array from the next in the resulting string. If omitted, the array elements are separated with a comma. - */ - join(separator?: string): string; - /** - * Reverses the elements in an array in place. - * This method mutates the array and returns a reference to the same array. - */ - reverse(): T[]; - /** - * Removes the first element from an array and returns it. - * If the array is empty, undefined is returned and the array is not modified. - */ - shift(): T | undefined; - /** - * Returns a copy of a section of an array. - * For both start and end, a negative index can be used to indicate an offset from the end of the array. - * For example, -2 refers to the second to last element of the array. - * @param start The beginning index of the specified portion of the array. - * If start is undefined, then the slice begins at index 0. - * @param end The end index of the specified portion of the array. This is exclusive of the element at the index 'end'. - * If end is undefined, then the slice extends to the end of the array. - */ - slice(start?: number, end?: number): T[]; - /** - * Sorts an array in place. - * This method mutates the array and returns a reference to the same array. - * @param compareFn Function used to determine the order of the elements. It is expected to return - * a negative value if the first argument is less than the second argument, zero if they're equal, and a positive - * value otherwise. If omitted, the elements are sorted in ascending, ASCII character order. - */ - sort(compareFn?: (a: T, b: T) => number): this; - /** - * Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. - * @param start The zero-based location in the array from which to start removing elements. - * @param deleteCount The number of elements to remove. - * @returns An array containing the elements that were deleted. - */ - splice(start: number, deleteCount?: number): T[]; - /** - * Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. - * @param start The zero-based location in the array from which to start removing elements. - * @param deleteCount The number of elements to remove. - * @param items Elements to insert into the array in place of the deleted elements. - * @returns An array containing the elements that were deleted. - */ - splice(start: number, deleteCount: number, ...items: T[]): T[]; - /** - * Inserts new elements at the start of an array, and returns the new length of the array. - * @param items Elements to insert at the start of the array. - */ - unshift(...items: T[]): number; - /** - * Returns the index of the first occurrence of a value in an array, or -1 if it is not present. - * @param searchElement The value to locate in the array. - * @param fromIndex The array index at which to begin the search. If fromIndex is omitted, the search starts at index 0. - */ - indexOf(searchElement: T, fromIndex?: number): number; - /** - * Returns the index of the last occurrence of a specified value in an array, or -1 if it is not present. - * @param searchElement The value to locate in the array. - * @param fromIndex The array index at which to begin searching backward. If fromIndex is omitted, the search starts at the last index in the array. - */ - lastIndexOf(searchElement: T, fromIndex?: number): number; - /** - * Determines whether all the members of an array satisfy the specified test. - * @param predicate A function that accepts up to three arguments. The every method calls - * the predicate function for each element in the array until the predicate returns a value - * which is coercible to the Boolean value false, or until the end of the array. - * @param thisArg An object to which the this keyword can refer in the predicate function. - * If thisArg is omitted, undefined is used as the this value. - */ - every(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): this is S[]; - /** - * Determines whether all the members of an array satisfy the specified test. - * @param predicate A function that accepts up to three arguments. The every method calls - * the predicate function for each element in the array until the predicate returns a value - * which is coercible to the Boolean value false, or until the end of the array. - * @param thisArg An object to which the this keyword can refer in the predicate function. - * If thisArg is omitted, undefined is used as the this value. - */ - every(predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: any): boolean; - /** - * Determines whether the specified callback function returns true for any element of an array. - * @param predicate A function that accepts up to three arguments. The some method calls - * the predicate function for each element in the array until the predicate returns a value - * which is coercible to the Boolean value true, or until the end of the array. - * @param thisArg An object to which the this keyword can refer in the predicate function. - * If thisArg is omitted, undefined is used as the this value. - */ - some(predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: any): boolean; - /** - * Performs the specified action for each element in an array. - * @param callbackfn A function that accepts up to three arguments. forEach calls the callbackfn function one time for each element in the array. - * @param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value. - */ - forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void; - /** - * Calls a defined callback function on each element of an array, and returns an array that contains the results. - * @param callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array. - * @param thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value. - */ - map(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[]; - /** - * Returns the elements of an array that meet the condition specified in a callback function. - * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array. - * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value. - */ - filter(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[]; - /** - * Returns the elements of an array that meet the condition specified in a callback function. - * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array. - * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value. - */ - filter(predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: any): T[]; - /** - * Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. - * @param callbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array. - * @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value. - */ - reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T; - reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T; - /** - * Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. - * @param callbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array. - * @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value. - */ - reduce(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U; - /** - * Calls the specified callback function for all the elements in an array, in descending order. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. - * @param callbackfn A function that accepts up to four arguments. The reduceRight method calls the callbackfn function one time for each element in the array. - * @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value. - */ - reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T; - reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T; - /** - * Calls the specified callback function for all the elements in an array, in descending order. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function. - * @param callbackfn A function that accepts up to four arguments. The reduceRight method calls the callbackfn function one time for each element in the array. - * @param initialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value. - */ - reduceRight(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U; - - copyWithin(target: number, start: number, end?: number): this; - - // JC note that this is the only thing we actually need to get the types to track - // we could ditch the rest of the definition (if no-one wants to code-complete array.length) - [n: number]: T; -} -`; diff --git a/assets/js/hooks/KeyHandlers.ts b/assets/js/hooks/KeyHandlers.ts index 2e70b619756..a64db2018af 100644 --- a/assets/js/hooks/KeyHandlers.ts +++ b/assets/js/hooks/KeyHandlers.ts @@ -353,21 +353,6 @@ export const CloseInspectorPanelViaEscape = createKeyCombinationHook( PRIORITY.HIGH ); -/** - * Hook to close a node panel when the "Escape" key is pressed. - * - * This hook listens globally and executes the `closeAction`, which simulates a click - * to close a node panel in the workflow canvas. It has a lower priority, ensuring it does - * not interfere with higher-priority handlers for the "Escape" key (e.g., `CloseInspectorPanelViaEscape`). - * - * Priority: `PRIORITY.NORMAL`, meaning it will yield to higher-priority handlers for the "Escape" key. - */ -export const CloseNodePanelViaEscape = createKeyCombinationHook( - isEscape, - closeAction, - PRIORITY.NORMAL -); - /** * Determines if the key combination for "Ctrl+M" (or "Cmd+M" on macOS) is pressed. * diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index 1b9e5929196..f289ffc2954 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -20,7 +20,6 @@ import { DefaultRunViaCtrlEnter, AltRunViaCtrlShiftEnter, CloseInspectorPanelViaEscape, - CloseNodePanelViaEscape, ToggleSidebarViaCtrlM, OpenProjectPickerViaCtrlP, } from './KeyHandlers'; @@ -48,7 +47,6 @@ export { DefaultRunViaCtrlEnter, AltRunViaCtrlShiftEnter, CloseInspectorPanelViaEscape, - CloseNodePanelViaEscape, ToggleSidebarViaCtrlM, OpenProjectPickerViaCtrlP, FileDropzone, 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/utils/editorUrlConversion.ts b/assets/js/utils/editorUrlConversion.ts deleted file mode 100644 index b1f4b050640..00000000000 --- a/assets/js/utils/editorUrlConversion.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * URL Conversion Utilities for Classical ↔ Collaborative Editor - * - * This module provides bidirectional query parameter conversion between - * the classical editor and the collaborative editor. The mappings are - * centralized here to ensure consistency across the application. - * - * @module editorUrlConversion - */ - -/** - * Mapping configuration for query parameter conversion - */ -const PARAM_MAPPINGS = { - // Classical -> Collaborative - classicalToCollaborative: { - a: 'run', // Followed run ID - s: ['job', 'trigger', 'edge'], // Selected step (type determined by context) - m: { - // Mode/Panel mappings - expand: 'editor', - workflow_input: 'run', - settings: 'settings', - }, - }, - - // Collaborative -> Classical - collaborativeToClassical: { - run: 'a', // Followed run ID - selection: 's', // Job/trigger/edge all map to 's' - panel: { - // Panel/Mode mappings - editor: 'expand', - run: 'workflow_input', - settings: 'settings', - }, - }, - - // Params that should be preserved as-is in both directions - preservedParams: ['v', 'method', 'w-chat', 'j-chat', 'code'], - - // Params that are editor-specific and should be skipped - classicalOnlyParams: ['m'], // Handled specially, converted to panel - collaborativeOnlyParams: ['panel', 'job', 'trigger', 'edge'], // Handled specially -} as const; - -/** - * Converts collaborative editor query params to classical editor params - * - * @param searchParams - URLSearchParams from collaborative editor - * @returns URLSearchParams formatted for classical editor - * - * @example - * const collab = new URLSearchParams('run=123&job=abc&panel=editor'); - * const classical = collaborativeToClassicalParams(collab); - * // Returns: ?a=123&s=abc&m=expand - */ -export function collaborativeToClassicalParams( - searchParams: URLSearchParams -): URLSearchParams { - const classicalParams = new URLSearchParams(); - - for (const [key, value] of searchParams.entries()) { - if (key === 'run') { - // Convert run -> a (followed run) - classicalParams.set('a', value); - } else if (key === 'job' || key === 'trigger' || key === 'edge') { - // Convert job/trigger/edge -> s (selected step) - // All three types collapse into single 's' param in classical editor - classicalParams.set('s', value); - } else if (key === 'panel') { - // Convert panel values to mode values - const panelToMode = PARAM_MAPPINGS.collaborativeToClassical - .panel as Record; - const mode = panelToMode[value]; - if (mode) { - classicalParams.set('m', mode); - } - // panel param itself is not preserved - only converted to m - } else if (PARAM_MAPPINGS.preservedParams.includes(key)) { - // Preserve params that are the same in both editors - classicalParams.set(key, value); - } else { - // Future-proof: preserve unknown params - classicalParams.set(key, value); - } - } - - return classicalParams; -} - -/** - * Converts classical editor query params to collaborative editor params - * - * Note: This is primarily used server-side (Elixir), but included here for - * completeness and potential client-side use cases. - * - * @param searchParams - URLSearchParams from classical editor - * @param context - Optional context to determine selection type for 's' param - * @returns URLSearchParams formatted for collaborative editor - * - * @example - * const classical = new URLSearchParams('a=123&s=abc&m=expand'); - * const collab = classicalToCollaborativeParams(classical, { selectedType: 'job' }); - * // Returns: ?run=123&job=abc&panel=editor - */ -export function classicalToCollaborativeParams( - searchParams: URLSearchParams, - context?: { - selectedType?: 'job' | 'trigger' | 'edge'; - } -): URLSearchParams { - const collaborativeParams = new URLSearchParams(); - - for (const [key, value] of searchParams.entries()) { - if (!value) continue; // Skip nil/empty values - - if (key === 'a') { - // Convert a -> run (followed run) - collaborativeParams.set('run', value); - } else if (key === 's') { - // Convert s -> job/trigger/edge (based on context) - // Default to 'job' if context not provided (backwards compatibility) - const selectedType = context?.selectedType ?? 'job'; - collaborativeParams.set(selectedType, value); - } else if (key === 'm') { - // Convert mode values to panel values - const modeToPanel = PARAM_MAPPINGS.classicalToCollaborative.m as Record< - string, - string - >; - const panel = modeToPanel[value]; - if (panel) { - collaborativeParams.set('panel', panel); - } - // m param itself is not preserved - only converted to panel - } else if (key === 'panel') { - // Skip panel param from classical side (shouldn't exist there) - continue; - } else if (PARAM_MAPPINGS.preservedParams.includes(key)) { - // Preserve params that are the same in both editors - collaborativeParams.set(key, value); - } else { - // Future-proof: preserve unknown params - collaborativeParams.set(key, value); - } - } - - 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 - */ -export function isValidPanelType( - value: string -): value is 'editor' | 'run' | 'settings' { - return ['editor', 'run', 'settings'].includes(value); -} - -/** - * Type guard to check if a value is a valid mode type - */ -export function isValidModeType( - value: string -): value is 'expand' | 'workflow_input' | 'settings' { - return ['expand', 'workflow_input', 'settings'].includes(value); -} - -/** - * Gets the mapping configuration (useful for testing and validation) - */ -export function getMappingConfig() { - return PARAM_MAPPINGS; -} 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/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/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 2e11d6a94c8..00000000000 --- a/assets/js/workflow-diagram/WorkflowDiagram.tsx +++ /dev/null @@ -1,641 +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 { CollaborativeEditorPromoBanner } from './CollaborativeEditorPromoBanner'; -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/usePlaceholders.ts b/assets/js/workflow-diagram/usePlaceholders.ts index 397e127f06e..628219062a7 100644 --- a/assets/js/workflow-diagram/usePlaceholders.ts +++ b/assets/js/workflow-diagram/usePlaceholders.ts @@ -5,7 +5,7 @@ import type { XYPosition } from '@xyflow/react'; import { useCallback, useEffect, useState } from 'react'; import { randomUUID } from '../common'; -import { DEFAULT_TEXT } from '../editor/Editor'; +import { DEFAULT_TEXT } from '../workflow-store/constants'; import { useWorkflowStore } from '../workflow-store/store'; import { styleEdge } from './styles'; 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/assets/js/workflow-store/WorkflowStore.tsx b/assets/js/workflow-store/WorkflowStore.tsx deleted file mode 100644 index e3f3da04e89..00000000000 --- a/assets/js/workflow-store/WorkflowStore.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import React from 'react'; - -import type { WithActionProps } from '#/react/lib/with-props'; - -import { randomUUID } from '../common'; -import { DEFAULT_TEXT } from '../editor/Editor'; - -import { - useWorkflowStore, - type ChangeArgs, - type RunInfo, - type PendingAction, - type ReplayAction, - type WorkflowProps, - type WorkflowRunHistory, -} from './store'; - -const createNewWorkflow = (): Required => { - const triggers = [ - { - id: randomUUID(), - type: 'webhook' as const, - }, - ]; - const jobs = [ - { - id: randomUUID(), - name: 'New job', - adaptor: '@openfn/language-common@latest', - body: DEFAULT_TEXT, - }, - ]; - - const edges = [ - { - id: randomUUID(), - source_trigger_id: triggers[0].id, - target_job_id: jobs[0].id, - condition_type: 'always', - }, - ]; - return { triggers, jobs, edges }; -}; - -// This component renders nothing. it just serves as a store sync to the backend -export const WorkflowStore: WithActionProps = props => { - const pendingChanges = React.useRef([]); - const { - applyPatches, - setState, - add, - setSelection, - subscribe, - setDisabled, - setForceFit, - reset, - updateRuns, - } = useWorkflowStore(); - - const pushPendingChange = React.useCallback( - (pendingChange: PendingAction) => { - return new Promise((resolve, reject) => { - console.debug('pushing change', pendingChange); - // How do we _undo_ the change if it fails? - props.pushEventTo('push-change', pendingChange, response => { - console.debug('push-change response', response); - - if (response && response.patches) - applyPatches({ - patches: response.patches || [], - inverse: response.inverse || [], - }); - resolve(true); - }); - }); - }, - [props, applyPatches] - ); - - const processPendingChanges = React.useCallback(async () => { - while (pendingChanges.current.length > 0) { - const pendingChange = pendingChanges.current[0]; - pendingChanges.current = pendingChanges.current.slice(1); - if (pendingChange) await pushPendingChange(pendingChange); - } - }, [pushPendingChange]); - - React.useEffect(() => { - subscribe(v => { - pendingChanges.current = pendingChanges.current.concat(v); - void processPendingChanges(); - }); - }, [processPendingChanges, subscribe]); - - React.useEffect(() => { - return props.handleEvent( - 'patches-applied', - (response: Partial) => { - console.debug('patches-applied', response); - - if (response && response.patches && response.patches.length) - applyPatches({ - patches: response.patches || [], - inverse: response.inverse || [], - }); - } - ); - }, [applyPatches, props]); - - React.useEffect(() => { - return props.handleEvent( - 'state-applied', - (response: { state: WorkflowProps }) => { - console.log('state-applied', response.state); - - if (response.state) - setState({ - ...response.state, - positions: response.state.positions ?? null, - }); - } - ); - }, [setState, props]); - - React.useEffect(() => { - const navigateCleanup = props.handleEvent('navigate', (e: any) => { - const id = new URL(window.location.href).searchParams.get('s'); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (e.patch) setSelection(id); - }); - // force fitting - const forcefitCleanup = props.handleEvent('force-fit', () => { - setForceFit(true); - }); - return () => { - navigateCleanup(); - forcefitCleanup(); - }; - }, [add, props.handleEvent, setSelection, setForceFit]); - - React.useEffect(() => { - return props.handleEvent( - 'patch-runs', - (response: { run_id: string; run_steps: RunInfo }) => { - updateRuns(response.run_steps, response.run_id); - } - ); - }, [props.handleEvent, updateRuns]); - - // Fetch initial state once on mount - React.useEffect(() => { - const workflowLoadParamsStart = new Date(); - const eventName = 'workflow-params load'; - console.debug( - 'get-current-state pushed', - workflowLoadParamsStart.toISOString() - ); - console.time(eventName); - - props.pushEventTo( - 'get-current-state', - {}, - (response: { - workflow_params: WorkflowProps; - run_steps: RunInfo; - run_id: string | null; - history: WorkflowRunHistory; - }) => { - const { workflow_params, run_steps, run_id, history } = response; - // workflow_params can contain an empty array of nodes and edges (empty workflow) - setState(workflow_params); - updateRuns(run_steps, run_id, history); - - const end = new Date(); - console.debug('current-worflow-params processed', end.toISOString()); - console.timeEnd(eventName); - - props.pushEventTo('workflow_editor_metrics_report', { - metrics: [ - { - event: eventName, - start: workflowLoadParamsStart.getTime(), - end: end.getTime(), - }, - ], - }); - } - ); - }, [props.pushEventTo, setState, add, updateRuns]); - - React.useEffect(() => { - return props.handleEvent( - 'set-disabled', - (response: { disabled: boolean }) => { - setDisabled(response.disabled); - } - ); - }, [props.handleEvent, setDisabled]); - - // clear store when store-component unmounted - React.useEffect(() => { - return () => { - reset(); - }; - }, [reset]); - - return <>{props.children}; -}; diff --git a/assets/js/workflow-store/constants.ts b/assets/js/workflow-store/constants.ts new file mode 100644 index 00000000000..f553eb862b5 --- /dev/null +++ b/assets/js/workflow-store/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_TEXT = `// Check out the Job Writing Guide for help getting started: +// https://docs.openfn.org/documentation/jobs/job-writing-guide +`; 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 072a60dbe21..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. * @@ -116,7 +139,7 @@ export class WorkflowCollaborativePage extends LiveViewPage { * @param options - URL components * @param options.projectId - Project ID * @param options.workflowId - Workflow ID - * @param options.path - Path suffix (e.g., '/legacy', '/edit') + * @param options.path - Path suffix (e.g., '', '/edit') * @param options.query - Optional query parameters * @param options.hash - Optional URL hash * @@ -126,7 +149,7 @@ export class WorkflowCollaborativePage extends LiveViewPage { * await page.verifyUrl({ * projectId: '123', * workflowId: '456', - * path: '/legacy' + * path: '' * }); * * // With query params and hash 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 1c7c80ec88d..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: '/legacy', - }); - - // 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(''); - }); -}); diff --git a/assets/test/utils/editorUrlConversion.test.ts b/assets/test/utils/editorUrlConversion.test.ts deleted file mode 100644 index 027e85a9de8..00000000000 --- a/assets/test/utils/editorUrlConversion.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - buildClassicalEditorUrl, - classicalToCollaborativeParams, - collaborativeToClassicalParams, - getMappingConfig, - isValidModeType, - isValidPanelType, -} from '../../js/utils/editorUrlConversion'; - -describe('editorUrlConversion', () => { - describe('collaborativeToClassicalParams', () => { - it('converts run parameter', () => { - const input = new URLSearchParams('run=run-123'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('a')).toBe('run-123'); - expect(output.get('run')).toBeNull(); - }); - - it('converts job parameter to s', () => { - const input = new URLSearchParams('job=job-abc'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('s')).toBe('job-abc'); - expect(output.get('job')).toBeNull(); - }); - - it('converts trigger parameter to s', () => { - const input = new URLSearchParams('trigger=trigger-xyz'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('s')).toBe('trigger-xyz'); - expect(output.get('trigger')).toBeNull(); - }); - - it('converts edge parameter to s', () => { - const input = new URLSearchParams('edge=edge-456'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('s')).toBe('edge-456'); - expect(output.get('edge')).toBeNull(); - }); - - it('converts panel=editor to m=expand', () => { - const input = new URLSearchParams('panel=editor'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('m')).toBe('expand'); - expect(output.get('panel')).toBeNull(); - }); - - it('converts panel=run to m=workflow_input', () => { - const input = new URLSearchParams('panel=run'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('m')).toBe('workflow_input'); - expect(output.get('panel')).toBeNull(); - }); - - it('converts panel=settings to m=settings', () => { - const input = new URLSearchParams('panel=settings'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('m')).toBe('settings'); - }); - - it('preserves standard params', () => { - const input = new URLSearchParams( - 'v=123&method=ai&w-chat=chat-1&j-chat=chat-2&code=true' - ); - const output = collaborativeToClassicalParams(input); - - expect(output.get('v')).toBe('123'); - expect(output.get('method')).toBe('ai'); - expect(output.get('w-chat')).toBe('chat-1'); - expect(output.get('j-chat')).toBe('chat-2'); - expect(output.get('code')).toBe('true'); - }); - - it('preserves unknown params for future compatibility', () => { - const input = new URLSearchParams('unknown=value'); - const output = collaborativeToClassicalParams(input); - - expect(output.get('unknown')).toBe('value'); - }); - - it('handles complex conversion with multiple params', () => { - const input = new URLSearchParams( - 'run=run-123&job=job-abc&panel=editor&v=5&method=ai' - ); - const output = collaborativeToClassicalParams(input); - - expect(output.get('a')).toBe('run-123'); - expect(output.get('s')).toBe('job-abc'); - expect(output.get('m')).toBe('expand'); - expect(output.get('v')).toBe('5'); - expect(output.get('method')).toBe('ai'); - }); - }); - - describe('classicalToCollaborativeParams', () => { - it('converts a parameter to run', () => { - const input = new URLSearchParams('a=run-123'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('run')).toBe('run-123'); - expect(output.get('a')).toBeNull(); - }); - - it('converts s parameter to job by default', () => { - const input = new URLSearchParams('s=item-123'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('job')).toBe('item-123'); - expect(output.get('s')).toBeNull(); - }); - - it('converts s parameter based on context', () => { - const input = new URLSearchParams('s=trigger-xyz'); - const output = classicalToCollaborativeParams(input, { - selectedType: 'trigger', - }); - - expect(output.get('trigger')).toBe('trigger-xyz'); - expect(output.get('job')).toBeNull(); - }); - - it('converts m=expand to panel=editor', () => { - const input = new URLSearchParams('m=expand'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('panel')).toBe('editor'); - expect(output.get('m')).toBeNull(); - }); - - it('converts m=workflow_input to panel=run', () => { - const input = new URLSearchParams('m=workflow_input'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('panel')).toBe('run'); - }); - - it('converts m=settings to panel=settings', () => { - const input = new URLSearchParams('m=settings'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('panel')).toBe('settings'); - }); - - it('skips nil/empty values', () => { - const input = new URLSearchParams('a=&s=job-123'); - const output = classicalToCollaborativeParams(input); - - expect(output.get('run')).toBeNull(); - expect(output.get('job')).toBe('job-123'); - }); - - it('preserves standard params', () => { - const input = new URLSearchParams( - 'v=123&method=ai&w-chat=chat-1&j-chat=chat-2' - ); - const output = classicalToCollaborativeParams(input); - - expect(output.get('v')).toBe('123'); - expect(output.get('method')).toBe('ai'); - expect(output.get('w-chat')).toBe('chat-1'); - expect(output.get('j-chat')).toBe('chat-2'); - }); - }); - - 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', () => { - expect(isValidPanelType('editor')).toBe(true); - expect(isValidPanelType('run')).toBe(true); - expect(isValidPanelType('settings')).toBe(true); - }); - - it('returns false for invalid panel types', () => { - expect(isValidPanelType('invalid')).toBe(false); - expect(isValidPanelType('')).toBe(false); - expect(isValidPanelType('expand')).toBe(false); - }); - }); - - describe('isValidModeType', () => { - it('returns true for valid mode types', () => { - expect(isValidModeType('expand')).toBe(true); - expect(isValidModeType('workflow_input')).toBe(true); - expect(isValidModeType('settings')).toBe(true); - }); - - it('returns false for invalid mode types', () => { - expect(isValidModeType('invalid')).toBe(false); - expect(isValidModeType('')).toBe(false); - expect(isValidModeType('editor')).toBe(false); - }); - }); - }); - - describe('getMappingConfig', () => { - it('returns the mapping configuration', () => { - const config = getMappingConfig(); - - expect(config).toHaveProperty('classicalToCollaborative'); - expect(config).toHaveProperty('collaborativeToClassical'); - expect(config).toHaveProperty('preservedParams'); - expect(config.preservedParams).toContain('v'); - expect(config.preservedParams).toContain('method'); - }); - }); - - describe('Bidirectional conversion', () => { - it('maintains consistency when converting back and forth', () => { - // Start with classical params - const classical1 = new URLSearchParams( - 'a=run-123&s=job-abc&m=expand&v=5' - ); - - // Convert to collaborative - const collaborative = classicalToCollaborativeParams(classical1, { - selectedType: 'job', - }); - - expect(collaborative.get('run')).toBe('run-123'); - expect(collaborative.get('job')).toBe('job-abc'); - expect(collaborative.get('panel')).toBe('editor'); - expect(collaborative.get('v')).toBe('5'); - - // Convert back to classical - const classical2 = collaborativeToClassicalParams(collaborative); - - expect(classical2.get('a')).toBe('run-123'); - expect(classical2.get('s')).toBe('job-abc'); - expect(classical2.get('m')).toBe('expand'); - expect(classical2.get('v')).toBe('5'); - }); - }); -}); 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/config/config.exs b/config/config.exs index ccecad5e147..22652a34c26 100644 --- a/config/config.exs +++ b/config/config.exs @@ -102,14 +102,9 @@ config :esbuild, --external:/fonts/* --external:/images/* js/app.js - 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/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/config.ex b/lib/lightning/config.ex index 86d167c983c..8f33fd15da9 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -321,15 +321,6 @@ defmodule Lightning.Config do Application.get_env(:lightning, Lightning.PromEx, []) end - @impl true - def ui_metrics_tracking_enabled? do - Keyword.get(ui_metrics_tracking_config(), :enabled) - end - - defp ui_metrics_tracking_config do - Application.get_env(:lightning, :ui_metrics_tracking, []) - end - @impl true def credential_transfer_token_validity_in_days do 2 @@ -370,14 +361,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) @@ -529,7 +512,6 @@ defmodule Lightning.Config do @callback storage() :: term() @callback storage(key :: atom()) :: term() @callback token_signer() :: Joken.Signer.t() - @callback ui_metrics_tracking_enabled?() :: boolean() @callback usage_tracking() :: Keyword.t() @callback usage_tracking_cleartext_uuids_enabled?() :: boolean() @callback usage_tracking_cron_opts() :: [Oban.Plugins.Cron.cron_input()] @@ -546,7 +528,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() @@ -786,10 +767,6 @@ defmodule Lightning.Config do impl().promex_enabled?() end - def ui_metrics_tracking_enabled? do - impl().ui_metrics_tracking_enabled?() - end - def credential_transfer_token_validity_in_days do impl().credential_transfer_token_validity_in_days() end @@ -818,10 +795,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/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 0966929b00e..28c8fb96b0d 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -808,9 +808,6 @@ defmodule Lightning.Config.Bootstrap do env!("KAFKA_NUMBER_OF_MESSAGES_PER_SECOND", :float, 1), number_of_processors: env!("KAFKA_NUMBER_OF_PROCESSORS", :integer, 1) - config :lightning, :ui_metrics_tracking, - enabled: env!("UI_METRICS_ENABLED", &Utils.ensure_boolean/1, false) - config :lightning, :broadcast_work_available, env!("BROADCAST_WORK_AVAILABLE", &Utils.ensure_boolean/1, true) 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..2f9d42593f9 --- /dev/null +++ b/lib/lightning_web/controllers/legacy_redirect_controller.ex @@ -0,0 +1,45 @@ +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. + + The collaborative editor renames the "followed run" param from `a` to `run`, + so that one is remapped to keep old run bookmarks following the run. Other + legacy params (e.g. `s`, `m`) depend on the editor's selection state to + translate correctly, which isn't available here, so they're forwarded as-is. + """ + 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 + conn = fetch_query_params(conn) + + target = + case remap_legacy_params(conn.query_params) do + params when map_size(params) == 0 -> base_path + params -> base_path <> "?" <> URI.encode_query(params) + end + + conn + |> redirect(to: target) + |> halt() + end + + defp remap_legacy_params(query_params) do + case Map.pop(query_params, "a") do + {nil, params} -> params + {run, params} -> Map.put(params, "run", run) + end + 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/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/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 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..3f505e05d97 100644 --- a/lib/lightning_web/live/workflow_live/components.ex +++ b/lib/lightning_web/live/workflow_live/components.ex @@ -4,120 +4,6 @@ defmodule LightningWeb.WorkflowLive.Components do alias Phoenix.LiveView.JS - attr :socket, :map, required: true - attr :project, :map, required: true - attr :workflow, :map, required: true - attr :disabled, :boolean, default: true - - def create_job_panel(assigns) do - ~H""" -
-
- Create your first job to get started. -
- <.button theme="primary" phx-click="create_job" disabled={@disabled}> -
- - Create job - - -
- -
- """ - end - - attr :id, :string, required: true - attr :encoded_project_space, :string, required: true - attr :selected_node, :string, default: nil - attr :base_path, :string, required: true - - def workflow_diagram(assigns) do - ~H""" -
-
- """ - end - - attr :id, :string, required: true - attr :title, :string, required: true - attr :cancel_url, :string, required: true - attr :class, :string, default: "" - attr :rest, :global - slot :inner_block, required: true - slot :header - slot :footer - - def panel(assigns) do - ~H""" -
-
-
-
- {@title} -
-
- <.link - id="close-panel" - phx-hook="CloseNodePanelViaEscape" - patch={@cancel_url} - class="justify-center hover:text-gray-500" - > - <.icon name="hero-x-mark" class="h-4 w-4 inline-block" /> - -
-
-
-
- {render_slot(@inner_block)} -
-
-
-
-
- <%= for item <- @footer do %> - {render_slot(item)} - <% end %> -
-
-
-
-
- """ - 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..d0073cd8595 100644 --- a/lib/lightning_web/live/workflow_live/helpers.ex +++ b/lib/lightning_web/live/workflow_live/helpers.ex @@ -323,192 +323,4 @@ defmodule LightningWeb.WorkflowLive.Helpers do MapSet.member?(override_names, Keyword.get(param, :name)) end) end - - @param_mappings %{ - direct: %{ - "a" => "run", - "run" => "a" - }, - mode_to_panel: %{ - "expand" => "editor", - "workflow_input" => "run", - "settings" => "settings", - "editor" => "expand", - "run" => "workflow_input" - }, - preserved: ["v", "method", "w-chat", "j-chat", "code"], - 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. - - This function uses a data-driven approach with the `@param_mappings` configuration - to convert classical editor parameters to collaborative editor equivalents. - - ## Conversion Rules - - - `a` (followed run) → `run` - - `s` (selected step) → `job`/`trigger`/`edge` (context-aware based on selection) - - `m=expand` → `panel=editor` - - `m=workflow_input` → `panel=run` - - `m=settings` → `panel=settings` - - Preserves: `v`, `method`, `w-chat`, `j-chat`, `code` - - Skips: `panel` (collaborative-only) - - ## Parameters - - - `params` - Route parameters map containing: - - `"project_id"` - Project UUID (required) - - `"id"` - Workflow UUID (required for `:edit`, absent for `:new`) - - Additional query parameters to preserve (e.g., `"s"`, `"m"`, `"v"`) - - `live_action` - Current LiveView action (`:new` or `:edit`) - - ## Returns - - A complete URL string for the collaborative editor with transformed query parameters - - ## Examples - - # Edit existing workflow with query params - iex> collaborative_editor_url(%{ - ...> "project_id" => "proj-1", - ...> "id" => "wf-1", - ...> "s" => "job-abc", - ...> "m" => "expand" - ...> }, :edit) - "/projects/proj-1/w/wf-1?job=job-abc&panel=editor" - - # New workflow - iex> collaborative_editor_url(%{ - ...> "project_id" => "proj-1" - ...> }, :new) - "/projects/proj-1/w/new?method=template" - - # With multiple query params - iex> collaborative_editor_url(%{ - ...> "project_id" => "proj-1", - ...> "id" => "wf-1", - ...> "s" => "job-123", - ...> "v" => "42", - ...> "custom" => "value" - ...> }, :edit) - "/projects/proj-1/w/wf-1?custom=value&job=job-123&v=42" - - """ - def collaborative_editor_url(params, live_action) do - collaborative_params = - params - |> Map.drop(["id", "project_id"]) - |> Enum.reduce(%{}, fn {key, value}, acc -> - convert_param(key, value, acc, params) - end) - - base_url = collaborative_base_url(params, live_action) - - build_url_with_params(base_url, collaborative_params) - end - - defp convert_param(_key, nil, acc, _assigns), do: acc - - defp convert_param("a", value, acc, _assigns) do - Map.put(acc, @param_mappings.direct["a"], value) - end - - defp convert_param("run", value, acc, _assigns) do - Map.put(acc, @param_mappings.direct["run"], value) - end - - defp convert_param("s", value, acc, assigns) do - selection_type = determine_selection_type(value, assigns) - Map.put(acc, selection_type, value) - end - - defp convert_param("job", value, acc, _assigns) do - Map.put(acc, "s", value) - end - - defp convert_param("trigger", value, acc, _assigns) do - Map.put(acc, "s", value) - end - - defp convert_param("edge", value, acc, _assigns) do - Map.put(acc, "s", value) - end - - defp convert_param("m", value, acc, _assigns) do - case Map.get(@param_mappings.mode_to_panel, value) do - nil -> acc - panel -> Map.put(acc, "panel", panel) - end - end - - defp convert_param("panel", value, acc, _assigns) do - case Map.get(@param_mappings.mode_to_panel, value) do - nil -> acc - panel -> Map.put(acc, "m", panel) - end - end - - defp convert_param(key, value, acc, _assigns) do - cond do - key in @param_mappings.collaborative_only -> - acc - - key in @param_mappings.preserved -> - Map.put(acc, key, value) - - true -> - Map.put(acc, key, value) - end - end - - defp determine_selection_type(value, assigns) do - cond do - match?(%{selected_trigger: %{id: ^value}}, assigns) -> "trigger" - match?(%{selected_job: %{id: ^value}}, assigns) -> "job" - match?(%{selected_edge: %{id: ^value}}, assigns) -> "edge" - true -> "job" - end - end - - defp collaborative_base_url(%{"project_id" => project_id}, :new) do - "/projects/#{project_id}/w/new?method=template" - end - - defp collaborative_base_url(%{"id" => id, "project_id" => project_id}, :edit) do - "/projects/#{project_id}/w/#{id}" - end - - defp build_url_with_params(base_url, params) when map_size(params) == 0 do - base_url - end - - defp build_url_with_params(base_url, params) do - query_string = URI.encode_query(params) - # Use & if base_url already has query params, otherwise use ? - separator = if String.contains?(base_url, "?"), do: "&", else: "?" - "#{base_url}#{separator}#{query_string}" - end end 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/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/lib/lightning_web/router.ex b/lib/lightning_web/router.ex index 9b242d7808d..e1e49392e28 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 except the run param which maps a -> run. + 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/lib/lightning_web/ui_metrics.ex b/lib/lightning_web/ui_metrics.ex deleted file mode 100644 index 2ac3c0cb27e..00000000000 --- a/lib/lightning_web/ui_metrics.ex +++ /dev/null @@ -1,112 +0,0 @@ -defmodule LightningWeb.UiMetrics do - @moduledoc """ - A temporary measure to allow WorkflowEditor and JobEditor UI components to - report selected metrics and have these logged. - """ - - alias Lightning.Repo - alias Lightning.Workflows.Job - - require Logger - - def log_job_editor_metrics(job, metrics) do - if Lightning.Config.ui_metrics_tracking_enabled?() do - Enum.each(metrics, fn metric -> - metric - |> enrich_job_editor_metric(job) - |> create_job_editor_log_line() - |> Logger.info() - end) - end - end - - defp enrich_job_editor_metric(metric, job) do - {job_id, workflow_id} = - job - |> case do - %{id: job_id, workflow_id: workflow_id} -> - {job_id, workflow_id} - - %{id: job_id} -> - Repo.get(Job, job_id) - |> case do - %{workflow_id: workflow_id} -> - {job_id, workflow_id} - - nil -> - {job_id, "unknown"} - end - end - - metric - |> common_enrichment() - |> Map.merge(%{ - "workflow_id" => workflow_id, - "job_id" => job_id - }) - end - - defp create_job_editor_log_line(metric) do - %{ - "event" => event, - "workflow_id" => workflow_id, - "job_id" => job_id, - "start_time" => start_time, - "end_time" => end_time, - "duration" => duration - } = metric - - "UiMetrics: [JobEditor] " <> - "event=`#{event}` " <> - "workflow_id=#{workflow_id} " <> - "job_id=#{job_id} " <> - "start_time=#{start_time} " <> - "end_time=#{end_time} " <> - "duration=#{duration} ms" - end - - def log_workflow_editor_metrics(workflow, metrics) do - if Lightning.Config.ui_metrics_tracking_enabled?() do - Enum.each(metrics, fn metric -> - metric - |> enrich_workflow_editor_metric(workflow) - |> create_workflow_editor_log_line() - |> Logger.info() - end) - end - end - - defp enrich_workflow_editor_metric(metric, %{id: workflow_id}) do - metric |> common_enrichment() |> Map.put("workflow_id", workflow_id) - end - - defp create_workflow_editor_log_line(metric) do - %{ - "event" => event, - "workflow_id" => workflow_id, - "start_time" => start_time, - "end_time" => end_time, - "duration" => duration - } = metric - - "UiMetrics: [WorkflowEditor] " <> - "event=`#{event}` " <> - "workflow_id=#{workflow_id} " <> - "start_time=#{start_time} " <> - "end_time=#{end_time} " <> - "duration=#{duration} ms" - end - - defp common_enrichment(%{"start" => start_ts, "end" => end_ts} = metric) do - metric - |> Map.merge(%{ - "start_time" => convert_ts(start_ts), - "end_time" => convert_ts(end_ts), - "duration" => end_ts - start_ts - }) - end - - defp convert_ts(ts) do - ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601() - end -end 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..cf1867c2a5e --- /dev/null +++ b/priv/repo/migrations/20260629143825_clear_prefer_legacy_editor.exs @@ -0,0 +1,12 @@ +defmodule Lightning.Repo.Migrations.ClearPreferLegacyEditor do + use Ecto.Migration + + def change do + execute( + """ + UPDATE users SET preferences = preferences - 'prefer_legacy_editor' WHERE preferences ? 'prefer_legacy_editor' + """, + "" + ) + end +end diff --git a/test/lightning/config_test.exs b/test/lightning/config_test.exs index ff3ef582fa9..c8b3459b73c 100644 --- a/test/lightning/config_test.exs +++ b/test/lightning/config_test.exs @@ -50,15 +50,6 @@ defmodule Lightning.Configtest do assert actual == not disabled end - test "indicates if the tracking of UI metrics is enabled" do - expected = - extract_from_config(:ui_metrics_tracking, :enabled) - - actual = API.ui_metrics_tracking_enabled?() - - assert expected == actual - end - test "returns module responsible for injecting external metric plugins" do expected = extract_from_config(Lightning.Extensions, :external_metrics) @@ -70,15 +61,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/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..427ab9e977d --- /dev/null +++ b/test/lightning_web/controllers/legacy_redirect_controller_test.exs @@ -0,0 +1,79 @@ +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 "remaps the legacy 'a' (followed run) param to 'run'", %{ + conn: conn, + project: project + } do + workflow = insert(:workflow, project: project) + + conn = + get(conn, "/projects/#{project.id}/w/#{workflow.id}/legacy?a=run-1") + + target = URI.parse(redirected_to(conn)) + + assert target.path == "/projects/#{project.id}/w/#{workflow.id}" + assert URI.decode_query(target.query) == %{"run" => "run-1"} + end + + test "forwards other legacy params unchanged alongside the 'a' remap", %{ + 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" + ) + + target = URI.parse(redirected_to(conn)) + + assert target.path == "/projects/#{project.id}/w/#{workflow.id}" + + assert URI.decode_query(target.query) == %{ + "run" => "run-1", + "s" => "some-job", + "m" => "expand" + } + 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/project_live_test.exs b/test/lightning_web/live/project_live_test.exs index 6669058a11f..b4b2d56b2e3 100644 --- a/test/lightning_web/live/project_live_test.exs +++ b/test/lightning_web/live/project_live_test.exs @@ -2716,6 +2716,80 @@ defmodule LightningWeb.ProjectLiveTest do assert render(view) =~ auth_method.password end + test "SSO user with MFA is only asked for a 2FA code when revealing a webhook secret", + %{conn: conn} do + project = insert(:project) + + auth_method = + insert(:webhook_auth_method, + project: project, + auth_type: :basic, + api_key: nil, + username: "testusername", + password: "someveryverystrongpassword1234" + ) + + user = + insert(:user, + hashed_password: nil, + mfa_enabled: true, + user_totp: build(:user_totp) + ) + + insert(:project_user, role: :admin, project: project, user: user) + conn = log_in_user(conn, user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings", on_error: :raise) + + view + |> element("a#edit_auth_method_link_#{auth_method.id}") + |> render_click() + + form_id = "webhook_auth_method" + + html = + view |> element("##{form_id}_password_action_button") |> render_click() + + assert html =~ "2FA Code" + # Signing in via SSO means there's no password, so none is requested + refute has_element?(view, "#reauthentication-form input[type='password']") + end + + test "SSO user without MFA is told to set a password or enable 2FA when revealing a webhook secret", + %{conn: conn} do + project = insert(:project) + + auth_method = + insert(:webhook_auth_method, + project: project, + auth_type: :basic, + api_key: nil, + username: "testusername", + password: "someveryverystrongpassword1234" + ) + + user = insert(:user, hashed_password: nil, mfa_enabled: false) + + insert(:project_user, role: :admin, project: project, user: user) + conn = log_in_user(conn, user) + + {:ok, view, _html} = + live(conn, ~p"/projects/#{project}/settings", on_error: :raise) + + view + |> element("a#edit_auth_method_link_#{auth_method.id}") + |> render_click() + + form_id = "webhook_auth_method" + + html = + view |> element("##{form_id}_password_action_button") |> render_click() + + assert html =~ "set a password or enable two-factor authentication" + refute has_element?(view, "#reauthentication-form") + end + test "owners and admins can delete a project webhook auth method", %{conn: conn} do project = insert(:project) 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 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/helpers_test.exs b/test/lightning_web/live/workflow_live/helpers_test.exs deleted file mode 100644 index 25d8bf02395..00000000000 --- a/test/lightning_web/live/workflow_live/helpers_test.exs +++ /dev/null @@ -1,582 +0,0 @@ -defmodule LightningWeb.WorkflowLive.HelpersTest do - use Lightning.DataCase, async: true - - alias LightningWeb.WorkflowLive.Helpers - - describe "collaborative_editor_url/2" do - test "converts classical editor URL to collaborative for new workflow" do - params = %{ - "project_id" => "proj-1" - } - - result = Helpers.collaborative_editor_url(params, :new) - assert result == "/projects/proj-1/w/new?method=template" - end - - test "converts classical editor URL to collaborative for existing workflow" do - params = %{ - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1" - end - - test "converts 'a' parameter (followed run) to 'run'" do - params = %{ - "project_id" => "proj-1", - "id" => "wf-1", - "a" => "run-123" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?run=run-123" - end - - test "defaults 's' parameter to 'job' when no context" do - params = %{ - "s" => "job-abc", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?job=job-abc" - end - - test "defaults 's' parameter to 'job' when no context (trigger case)" do - params = %{ - "s" => "trigger-xyz", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?job=trigger-xyz" - end - - test "defaults 's' parameter to 'job' when no context (edge case)" do - params = %{ - "s" => "edge-123", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?job=edge-123" - end - - test "defaults 's' parameter to 'job' when no selection context" do - params = %{ - "s" => "unknown-id", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?job=unknown-id" - end - - test "converts 'm=expand' to 'panel=editor'" do - params = %{ - "m" => "expand", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?panel=editor" - end - - test "converts 'm=workflow_input' to 'panel=run'" do - params = %{ - "m" => "workflow_input", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?panel=run" - end - - test "converts 'm=settings' to 'panel=settings'" do - params = %{ - "m" => "settings", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?panel=settings" - end - - test "preserves 'v' parameter (version tag)" do - params = %{ - "v" => "snapshot-123", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?v=snapshot-123" - end - - test "preserves 'method' parameter" do - params = %{ - "method" => "some-method", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?method=some-method" - end - - test "preserves 'w-chat' parameter" do - params = %{ - "w-chat" => "chat-123", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?w-chat=chat-123" - end - - test "preserves 'j-chat' parameter" do - params = %{ - "j-chat" => "chat-456", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?j-chat=chat-456" - end - - test "preserves 'code' parameter" do - params = %{ - "code" => "some-code", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?code=some-code" - end - - test "skips 'panel' parameter (collaborative-only)" do - params = %{ - "panel" => "run", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?m=workflow_input" - end - - test "preserves unknown parameters for future compatibility" do - params = %{ - "unknown" => "value", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1?unknown=value" - end - - test "handles multiple parameters with complex conversion" do - params = %{ - "a" => "run-123", - "s" => "job-abc", - "m" => "expand", - "v" => "latest", - "w-chat" => "chat-123", - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - - # Check that all expected parameters are present - assert result =~ "/projects/proj-1/w/wf-1?" - assert result =~ "run=run-123" - assert result =~ "job=job-abc" - assert result =~ "panel=editor" - assert result =~ "v=latest" - assert result =~ "w-chat=chat-123" - end - - test "handles nil parameter values gracefully" do - params = %{ - "a" => nil, - "s" => nil, - "project_id" => "proj-1", - "id" => "wf-1" - } - - result = Helpers.collaborative_editor_url(params, :edit) - assert result == "/projects/proj-1/w/wf-1" - end - end - - describe "workflow_enabled?/1" do - test "returns true when all triggers are enabled (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [ - %{enabled: true}, - %{enabled: true} - ] - } - - assert Helpers.workflow_enabled?(workflow) == true - end - - test "returns false when any trigger is disabled (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [ - %{enabled: true}, - %{enabled: false} - ] - } - - assert Helpers.workflow_enabled?(workflow) == false - end - - test "returns true when workflow has no triggers (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [] - } - - assert Helpers.workflow_enabled?(workflow) == true - end - - test "returns true when all triggers are enabled (Changeset)" do - changeset = - Ecto.Changeset.change(%Lightning.Workflows.Workflow{}, %{ - triggers: [ - %{enabled: true}, - %{enabled: true} - ] - }) - - assert Helpers.workflow_enabled?(changeset) == true - end - - test "returns false when any trigger is disabled (Changeset)" do - changeset = - Ecto.Changeset.change(%Lightning.Workflows.Workflow{}, %{ - triggers: [ - %{enabled: true}, - %{enabled: false} - ] - }) - - assert Helpers.workflow_enabled?(changeset) == false - end - end - - describe "workflow_state_tooltip/1" do - test "returns inactive message when no triggers configured (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [] - } - - result = Helpers.workflow_state_tooltip(workflow) - assert result == "This workflow is inactive (no triggers configured)" - end - - test "returns active message with trigger type when all enabled (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [ - %{enabled: true, type: :webhook}, - %{enabled: true, type: :cron} - ] - } - - result = Helpers.workflow_state_tooltip(workflow) - assert result == "This workflow is active (webhook trigger enabled)" - end - - test "returns inactive message when triggers disabled (Workflow struct)" do - workflow = %Lightning.Workflows.Workflow{ - triggers: [ - %{enabled: false, type: :webhook} - ] - } - - result = Helpers.workflow_state_tooltip(workflow) - assert result == "This workflow is inactive (manual runs only)" - end - - test "returns inactive message when no triggers configured (Changeset)" do - changeset = - Ecto.Changeset.change(%Lightning.Workflows.Workflow{}, %{ - triggers: [] - }) - - result = Helpers.workflow_state_tooltip(changeset) - assert result == "This workflow is inactive (no triggers configured)" - end - - test "returns active message with trigger type when all enabled (Changeset)" do - changeset = - Ecto.Changeset.change(%Lightning.Workflows.Workflow{}, %{ - triggers: [ - %{enabled: true, type: :webhook} - ] - }) - - result = Helpers.workflow_state_tooltip(changeset) - assert result == "This workflow is active (webhook trigger enabled)" - end - - test "returns inactive message when triggers disabled (Changeset)" do - changeset = - Ecto.Changeset.change(%Lightning.Workflows.Workflow{}, %{ - triggers: [ - %{enabled: false, type: :webhook} - ] - }) - - result = Helpers.workflow_state_tooltip(changeset) - assert result == "This workflow is inactive (manual runs only)" - end - end - - describe "build_url/2" do - test "builds URL with simple static values" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - params = [ - Helpers.param("foo", "bar"), - Helpers.param("baz", "qux") - ] - - result = Helpers.build_url(assigns, params) - assert result =~ "/test?" - assert result =~ "foo=bar" - assert result =~ "baz=qux" - end - - test "builds URL with function values (arity 2)" do - assigns = %{ - base_url: "/test", - query_params: %{"existing" => "value"}, - custom_field: "custom" - } - - params = [ - Helpers.param("key", fn a, _p -> a.custom_field end) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?key=custom" - end - - test "builds URL with function values (arity 1)" do - assigns = %{ - base_url: "/test", - query_params: %{}, - custom_field: "custom" - } - - params = [ - Helpers.param("key", fn a -> a.custom_field end) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?key=custom" - end - - test "builds URL with function values (arity 0)" do - params = [ - Helpers.param("key", fn -> "static" end) - ] - - assigns = %{ - base_url: "/test", - query_params: %{} - } - - result = Helpers.build_url(assigns, params) - assert result == "/test?key=static" - end - - test "builds URL with conditional when clause (boolean)" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - params = [ - Helpers.param("included", "yes", when: true), - Helpers.param("excluded", "no", when: false) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?included=yes" - refute result =~ "excluded" - end - - test "builds URL with conditional when clause (function arity 2)" do - assigns = %{ - base_url: "/test", - query_params: %{}, - should_include: true - } - - params = [ - Helpers.param("test", "value", when: fn a, _p -> a.should_include end) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?test=value" - end - - test "builds URL with conditional when clause (function arity 1)" do - assigns = %{ - base_url: "/test", - query_params: %{}, - should_include: true - } - - params = [ - Helpers.param("test", "value", when: fn a -> a.should_include end) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?test=value" - end - - test "builds URL with conditional when clause (function arity 0)" do - params = [ - Helpers.param("test", "value", when: fn -> true end) - ] - - assigns = %{ - base_url: "/test", - query_params: %{} - } - - result = Helpers.build_url(assigns, params) - assert result == "/test?test=value" - end - - test "builds URL with transform function" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - params = [ - Helpers.param("upper", "hello", transform: &String.upcase/1) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?upper=HELLO" - end - - test "excludes nil values from URL" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - params = [ - Helpers.param("present", "value"), - Helpers.param("absent", nil) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test?present=value" - refute result =~ "absent" - end - - test "returns base URL when no parameters" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - result = Helpers.build_url(assigns, []) - assert result == "/test" - end - - test "returns base URL when all parameters excluded" do - assigns = %{ - base_url: "/test", - query_params: %{} - } - - params = [ - Helpers.param("excluded", "value", when: false), - Helpers.param("nil", nil) - ] - - result = Helpers.build_url(assigns, params) - assert result == "/test" - end - end - - describe "query_param/1" do - test "creates parameter that reads from query_params" do - param = Helpers.query_param("test") - - assigns = %{query_params: %{"test" => "value"}} - value = Keyword.fetch!(param, :value) - result = value.(assigns, []) - - assert result == "value" - end - end - - describe "chat_params/0" do - test "returns w-chat and j-chat parameters" do - params = Helpers.chat_params() - - assert length(params) == 2 - assert Enum.any?(params, fn p -> Keyword.get(p, :name) == "w-chat" end) - assert Enum.any?(params, fn p -> Keyword.get(p, :name) == "j-chat" end) - end - end - - describe "with_params/1" do - test "merges override parameters with standard params" do - assigns = %{ - base_url: "/test", - query_params: %{"m" => "expand", "s" => "job-1"}, - workflow_chat_session_id: nil, - job_chat_session_id: nil - } - - params = Helpers.with_params(custom: "value") - result = Helpers.build_url(assigns, params) - - assert result =~ "custom=value" - assert result =~ "m=expand" - assert result =~ "s=job-1" - end - - test "override values replace standard params" do - assigns = %{ - base_url: "/test", - query_params: %{"m" => "expand"}, - workflow_chat_session_id: nil, - job_chat_session_id: nil - } - - params = Helpers.with_params(m: "settings") - result = Helpers.build_url(assigns, params) - - assert result == "/test?m=settings" - refute result =~ "m=expand" - end - 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 diff --git a/test/lightning_web/ui_metrics_test.exs b/test/lightning_web/ui_metrics_test.exs deleted file mode 100644 index 5045c334cd1..00000000000 --- a/test/lightning_web/ui_metrics_test.exs +++ /dev/null @@ -1,272 +0,0 @@ -defmodule LightningWeb.UiMetricsTest do - use Lightning.DataCase, async: false - - alias LightningWeb.UiMetrics - - import ExUnit.CaptureLog - - setup do - current_log_level = Logger.level() - Logger.configure(level: :info) - - on_exit(fn -> - Logger.configure(level: current_log_level) - end) - end - - describe "log_job_editor_metrics/2" do - setup do - metrics = [ - %{ - "event" => "mount to 1st render", - "start" => 1_737_635_739_914, - "end" => 1_737_635_808_890 - }, - %{ - "event" => "render before fetching metadata", - "start" => 1_737_637_606_066, - "end" => 1_737_637_623_051 - } - ] - - job = insert(:job) - - snapshot_job = build(:snapshot_job, id: job.id) - - %{job: job, metrics: metrics, snapshot_job: snapshot_job} - end - - test "logs the job editor metrics when a job is provided", %{ - job: %{id: job_id, workflow_id: workflow_id} = job, - metrics: metrics - } do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - expected_entry_1 = - job_editor_log_regex( - job_id: job_id, - workflow_id: workflow_id, - event: "mount to 1st render", - start: 1_737_635_739_914, - end: 1_737_635_808_890 - ) - - expected_entry_2 = - job_editor_log_regex( - job_id: job_id, - workflow_id: workflow_id, - event: "render before fetching metadata", - start: 1_737_637_606_066, - end: 1_737_637_623_051 - ) - - fun = fn -> UiMetrics.log_job_editor_metrics(job, metrics) end - - assert capture_log(fun) =~ expected_entry_1 - assert capture_log(fun) =~ expected_entry_2 - end - - test "logs the job editor metrics when a snapshot job is provided", %{ - job: %{id: job_id, workflow_id: workflow_id}, - metrics: metrics, - snapshot_job: snapshot_job - } do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - expected_entry_1 = - job_editor_log_regex( - job_id: job_id, - workflow_id: workflow_id, - event: "mount to 1st render", - start: 1_737_635_739_914, - end: 1_737_635_808_890 - ) - - expected_entry_2 = - job_editor_log_regex( - job_id: job_id, - workflow_id: workflow_id, - event: "render before fetching metadata", - start: 1_737_637_606_066, - end: 1_737_637_623_051 - ) - - fun = fn -> UiMetrics.log_job_editor_metrics(snapshot_job, metrics) end - - assert capture_log(fun) =~ expected_entry_1 - assert capture_log(fun) =~ expected_entry_2 - end - - test "logs the metrics when the workflow for the snapshot job is missing", %{ - metrics: metrics - } do - snapshot_job = build(:snapshot_job) - - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - expected_entry_1 = - job_editor_log_regex( - job_id: snapshot_job.id, - workflow_id: "unknown", - event: "mount to 1st render", - start: 1_737_635_739_914, - end: 1_737_635_808_890 - ) - - expected_entry_2 = - job_editor_log_regex( - job_id: snapshot_job.id, - workflow_id: "unknown", - event: "render before fetching metadata", - start: 1_737_637_606_066, - end: 1_737_637_623_051 - ) - - fun = fn -> UiMetrics.log_job_editor_metrics(snapshot_job, metrics) end - - assert capture_log(fun) =~ expected_entry_1 - assert capture_log(fun) =~ expected_entry_2 - end - - test "does not write metrics to the log if logging is disabled", %{ - job: job, - metrics: metrics - } do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - false - end) - - fun = fn -> UiMetrics.log_job_editor_metrics(job, metrics) end - - refute capture_log(fun) =~ ~r/UiMetrics/ - end - - def job_editor_log_regex(opts \\ []) do - job_id = Keyword.fetch!(opts, :job_id) - workflow_id = Keyword.fetch!(opts, :workflow_id) - - event = Keyword.fetch!(opts, :event) - - start_time = - opts - |> Keyword.fetch!(:start) - |> DateTime.from_unix!(:millisecond) - - end_time = - opts - |> Keyword.fetch!(:end) - |> DateTime.from_unix!(:millisecond) - - duration = DateTime.diff(end_time, start_time, :millisecond) - - log_line = - "UiMetrics: \\[JobEditor\\] " <> - "event=`#{event}` " <> - "workflow_id=#{workflow_id} " <> - "job_id=#{job_id} " <> - "start_time=#{DateTime.to_iso8601(start_time)} " <> - "end_time=#{DateTime.to_iso8601(end_time)} " <> - "duration=#{duration} ms" - - ~r/#{log_line}/ - end - end - - describe "log_workflow_editor_metrics/2" do - setup do - metrics = [ - %{ - "event" => "workflow-params load", - "start" => 1_737_635_739_914, - "end" => 1_737_635_808_890 - }, - %{ - "event" => "something else important", - "start" => 1_737_637_606_066, - "end" => 1_737_637_623_051 - } - ] - - workflow = insert(:workflow) - - %{metrics: metrics, workflow: workflow} - end - - test "logs the workflow metrics provided", %{ - metrics: metrics, - workflow: workflow - } do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - true - end) - - expected_entry_1 = - workflow_editor_log_regex( - workflow: workflow, - event: "workflow-params load", - start: 1_737_635_739_914, - end: 1_737_635_808_890 - ) - - expected_entry_2 = - workflow_editor_log_regex( - workflow: workflow, - event: "something else important", - start: 1_737_637_606_066, - end: 1_737_637_623_051 - ) - - fun = fn -> UiMetrics.log_workflow_editor_metrics(workflow, metrics) end - - assert capture_log(fun) =~ expected_entry_1 - assert capture_log(fun) =~ expected_entry_2 - end - - test "does not write metrics to the log if logging is disabled", %{ - metrics: metrics, - workflow: workflow - } do - Mox.stub(Lightning.MockConfig, :ui_metrics_tracking_enabled?, fn -> - false - end) - - fun = fn -> UiMetrics.log_workflow_editor_metrics(workflow, metrics) end - - refute capture_log(fun) =~ ~r/UiMetrics/ - end - - def workflow_editor_log_regex(opts \\ []) do - %{id: workflow_id} = Keyword.fetch!(opts, :workflow) - - event = Keyword.fetch!(opts, :event) - - start_time = - opts - |> Keyword.fetch!(:start) - |> DateTime.from_unix!(:millisecond) - - end_time = - opts - |> Keyword.fetch!(:end) - |> DateTime.from_unix!(:millisecond) - - duration = DateTime.diff(end_time, start_time, :millisecond) - - log_line = - "UiMetrics: \\[WorkflowEditor\\] " <> - "event=`#{event}` " <> - "workflow_id=#{workflow_id} " <> - "start_time=#{DateTime.to_iso8601(start_time)} " <> - "end_time=#{DateTime.to_iso8601(end_time)} " <> - "duration=#{duration} ms" - - ~r/#{log_line}/ - end - end -end