diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 007ba5274f..f5d603da24 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1523,6 +1523,7 @@ "contextMenu.deselectAll": "Clear selection", "contextMenu.tidyUpWorkflow": "Tidy up workflow", "contextMenu.tidyUpSelection": "Tidy up selection", + "contextMenu.extract": "Extract node into sub-workflow | Extract {count} nodes into sub-workflow", "contextMenu.duplicate": "Duplicate | Duplicate {count} {subject}", "contextMenu.open": "Open...", "contextMenu.test": "Execute step", @@ -2370,6 +2371,21 @@ "workflowDetails.active": "Active", "workflowDetails.addTag": "Add tag", "workflowDetails.chooseOrCreateATag": "Choose or create a tag", + "workflowExtraction.error.failure": "Sub-workflow extraction failed", + "workflowExtraction.error.selectionGraph.inputEdgeToNonRoot": "Non-input node '{node}' has a connection from a node outside the current selection.", + "workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf": "Non-output node '{node}' has a connection to a node outside the current selection.", + "workflowExtraction.error.selectionGraph.multipleInputNodes": "Multiple nodes [{nodes}] have inputs from outside the selection.", + "workflowExtraction.error.selectionGraph.multipleOutputNodes": "Multiple nodes [{nodes}] have outputs to outside the selection.", + "workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf": "First node '{start}' has no selected path to last node '{end}'.", + "workflowExtraction.error.selectionGraph.listHeader": "Selection is invalid because of these errors:

{body}

See docs for more info.", + "workflowExtraction.error.inputNodeHasMultipleInputBranches": "First node '{node}' has multiple input branches, which sub-workflows do not support.", + "workflowExtraction.error.outputNodeHasMultipleOutputBranches": "Last node '{node}' has multiple output branches, which sub-workflows do not support.", + "workflowExtraction.error.triggerSelected": "Triggers cannot be extracted into a sub-workflow. Please unselect {nodes}.", + "workflowExtraction.error.subworkflowCreationFailed": "Sub-workflow creation failed, aborting conversion.", + "workflowExtraction.success.title": "Created sub-workflow", + "workflowExtraction.success.message": "Open in new Tab", + "workflowExtraction.modal.title": "Move to sub-workflow", + "workflowExtraction.modal.description": "Extract {nodeCount} node into a new sub-workflow | Extract {nodeCount} nodes into a new sub-workflow", "workflowHelpers.showMessage.title": "Problem saving workflow", "workflowOpen.active": "Active", "workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows", diff --git a/packages/frontend/editor-ui/src/components/Modals.vue b/packages/frontend/editor-ui/src/components/Modals.vue index 1d1b7fa908..ffc1898520 100644 --- a/packages/frontend/editor-ui/src/components/Modals.vue +++ b/packages/frontend/editor-ui/src/components/Modals.vue @@ -38,6 +38,7 @@ import { WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY, FROM_AI_PARAMETERS_MODAL_KEY, IMPORT_WORKFLOW_URL_MODAL_KEY, + WORKFLOW_EXTRACTION_NAME_MODAL_KEY, } from '@/constants'; import AboutModal from '@/components/AboutModal.vue'; @@ -316,5 +317,11 @@ import type { EventBus } from '@n8n/utils/event-bus'; + + + + diff --git a/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts new file mode 100644 index 0000000000..33c2a91777 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import WorkflowExtractionNameModal from '@/components/WorkflowExtractionNameModal.vue'; +import { WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants'; +import type { INodeUi } from '@/Interface'; +import type { ExtractableSubgraphData } from 'n8n-workflow'; +import { cloneDeep } from 'lodash-es'; +import userEvent from '@testing-library/user-event'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; + +const extractNodesIntoSubworkflow = vi.fn(); +vi.mock('@/composables/useWorkflowExtraction', () => { + return { + useWorkflowExtraction: () => ({ + extractNodesIntoSubworkflow, + }), + }; +}); + +const ModalStub = { + template: ` +
+ + + + +
+ `, +}; + +const global = { + stubs: { + Modal: ModalStub, + }, +}; + +const renderModal = createComponentRenderer(WorkflowExtractionNameModal); +let pinia: ReturnType; + +const DEFAULT_PROPS = { + modalName: WORKFLOW_EXTRACTION_NAME_MODAL_KEY, + data: { + subGraph: Symbol() as unknown as INodeUi[], + selection: Symbol() as ExtractableSubgraphData, + }, +}; + +describe('WorkflowExtractionNameModal.vue', () => { + let props = DEFAULT_PROPS; + beforeEach(() => { + pinia = createTestingPinia(); + props = cloneDeep(DEFAULT_PROPS); + vi.resetAllMocks(); + }); + + it('emits "close" event when the cancel button is clicked', async () => { + const { getByTestId } = renderModal({ + props, + global, + pinia, + }); + await userEvent.click(getByTestId('cancel-button')); + expect(extractNodesIntoSubworkflow).not.toHaveBeenCalled(); + }); + + it('emits "submit" event with the correct name when the form is submitted', async () => { + const { getByTestId, getByRole } = renderModal({ + props, + global, + pinia, + }); + + const input = getByRole('textbox'); + // The auto-select isn't working for the test, so this doesn't clear the input + await userEvent.type(input, ' 2'); + await userEvent.click(getByTestId('submit-button')); + + expect(extractNodesIntoSubworkflow).toHaveBeenCalledWith( + DEFAULT_PROPS.data.selection, + DEFAULT_PROPS.data.subGraph, + 'My Sub-workflow 2', + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue new file mode 100644 index 0000000000..962351acd2 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/WorkflowExtractionNameModal.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index 7e88b82fcf..c84a0c5d16 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -105,6 +105,7 @@ const emit = defineEmits<{ 'selection:end': [position: XYPosition]; 'open:sub-workflow': [nodeId: string]; 'start-chat': []; + 'extract-workflow': [ids: string[]]; }>(); const props = withDefaults( @@ -314,6 +315,7 @@ const keyMap = computed(() => { ctrl_enter: () => emit('run:workflow'), ctrl_s: () => emit('save:workflow'), shift_alt_t: async () => await onTidyUp({ source: 'keyboard-shortcut' }), + alt_x: emitWithSelectedNodes((ids) => emit('extract-workflow', ids)), c: () => emit('start-chat'), }; return fullKeymap; @@ -695,6 +697,8 @@ async function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) return props.eventBus.emit('nodes:action', { ids: nodeIds, action: 'update:sticky:color' }); case 'tidy_up': return await onTidyUp({ source: 'context-menu' }); + case 'extract_sub_workflow': + return emit('extract-workflow', nodeIds); case 'open_sub_workflow': { return emit('open:sub-workflow', nodeIds[0]); } diff --git a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap index ab38f536f1..dbfe62cffd 100644 --- a/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap +++ b/packages/frontend/editor-ui/src/composables/__snapshots__/useContextMenu.test.ts.snap @@ -79,6 +79,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe "shiftKey": true, }, }, + { + "disabled": true, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -160,6 +172,18 @@ exports[`useContextMenu > Read-only mode > should return the correct actions whe "shiftKey": true, }, }, + { + "disabled": true, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -270,6 +294,18 @@ exports[`useContextMenu > should return the correct actions opening the menu fro "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -380,6 +416,18 @@ exports[`useContextMenu > should return the correct actions when right clicking "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -461,6 +509,18 @@ exports[`useContextMenu > should return the correct actions when right clicking "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -583,6 +643,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -705,6 +777,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (disabled) whe "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -827,6 +911,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -949,6 +1045,18 @@ exports[`useContextMenu > should show "Go to Sub-workflow" action (enabled) when "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract node into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, @@ -1035,6 +1143,18 @@ exports[`useContextMenu > should support opening and closing (default = right cl "shiftKey": true, }, }, + { + "disabled": false, + "divided": true, + "id": "extract_sub_workflow", + "label": "Extract 2 nodes into sub-workflow", + "shortcut": { + "altKey": true, + "keys": [ + "X", + ], + }, + }, { "disabled": false, "divided": true, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index e0be5d0f7d..78f0fca39b 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -40,6 +40,7 @@ import { RemoveConnectionCommand, RemoveNodeCommand, RenameNodeCommand, + ReplaceNodeParametersCommand, } from '@/models/history'; import { useCanvasStore } from '@/stores/canvas.store'; import { useCredentialsStore } from '@/stores/credentials.store'; @@ -97,6 +98,7 @@ import type { NodeParameterValueType, Workflow, NodeConnectionType, + INodeParameters, } from 'n8n-workflow'; import { deepCopy, NodeConnectionTypes, NodeHelpers, TelemetryHelpers } from 'n8n-workflow'; import { computed, nextTick, ref } from 'vue'; @@ -124,6 +126,7 @@ type AddNodesBaseOptions = { trackHistory?: boolean; keepPristine?: boolean; telemetry?: boolean; + forcePosition?: boolean; viewport?: ViewportBoundaries; }; @@ -245,6 +248,42 @@ export function useCanvasOperations({ router }: { router: ReturnType([0, 0]); const isOpen = ref(false); @@ -47,7 +48,6 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = const nodeTypesStore = useNodeTypesStore(); const workflowsStore = useWorkflowsStore(); const sourceControlStore = useSourceControlStore(); - const i18n = useI18n(); const workflowPermissions = computed( @@ -164,6 +164,16 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = }, ]; + const extractionActions: ActionDropdownItem[] = [ + { + id: 'extract_sub_workflow', + divided: true, + label: i18n.baseText('contextMenu.extract', { adjustToNumber: nodes.length }), + shortcut: { altKey: true, keys: ['X'] }, + disabled: isReadOnly.value, + }, + ]; + const layoutActions: ActionDropdownItem[] = [ { id: 'tidy_up', @@ -222,6 +232,7 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = disabled: isReadOnly.value || !nodes.every(canDuplicateNode), }, ...layoutActions, + ...extractionActions, ...selectionActions, { id: 'delete', diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts new file mode 100644 index 0000000000..e6bdab6af4 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts @@ -0,0 +1,549 @@ +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { + buildAdjacencyList, + parseExtractableSubgraphSelection, + type ExtractableSubgraphData, + type ExtractableErrorResult, + extractReferencesInNodeExpressions, + type IConnections, + type INode, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + NodeHelpers, +} from 'n8n-workflow'; +import { computed } from 'vue'; +import { useToast } from './useToast'; +import { useRouter } from 'vue-router'; +import { VIEWS, WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants'; +import { useHistoryStore } from '@/stores/history.store'; +import { useCanvasOperations } from './useCanvasOperations'; + +import type { AddedNode, INodeUi, IWorkflowDataCreate, IWorkflowDb } from '@/Interface'; +import { useI18n } from '@n8n/i18n'; +import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils'; +import { useUIStore } from '@/stores/ui.store'; +import { useNodeTypesStore } from '@/stores/nodeTypes.store'; +import { useTelemetry } from './useTelemetry'; +import { isEqual } from 'lodash-es'; +import { v4 as uuidv4 } from 'uuid'; + +const CANVAS_HISTORY_OPTIONS = { + trackBulk: false, + trackHistory: true, +}; + +export function useWorkflowExtraction() { + const uiStore = useUIStore(); + const workflowsStore = useWorkflowsStore(); + const toast = useToast(); + const router = useRouter(); + const historyStore = useHistoryStore(); + const canvasOperations = useCanvasOperations({ router }); + const i18n = useI18n(); + const telemetry = useTelemetry(); + + const adjacencyList = computed(() => buildAdjacencyList(workflowsStore.workflow.connections)); + + function showError(message: string) { + toast.showMessage({ + type: 'error', + message, + title: i18n.baseText('workflowExtraction.error.failure'), + duration: 15 * 1000, + }); + } + + function extractableErrorResultToMessage(result: ExtractableErrorResult) { + switch (result.errorCode) { + case 'Input Edge To Non-Root Node': + return i18n.baseText('workflowExtraction.error.selectionGraph.inputEdgeToNonRoot', { + interpolate: { node: result.node }, + }); + case 'Output Edge From Non-Leaf Node': + return i18n.baseText('workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf', { + interpolate: { node: result.node }, + }); + case 'Multiple Input Nodes': + return i18n.baseText('workflowExtraction.error.selectionGraph.multipleInputNodes', { + interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') }, + }); + case 'Multiple Output Nodes': + return i18n.baseText('workflowExtraction.error.selectionGraph.multipleOutputNodes', { + interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') }, + }); + case 'No Continuous Path From Root To Leaf In Selection': + return i18n.baseText( + 'workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf', + { interpolate: { start: result.start, end: result.end } }, + ); + } + } + + function makeExecuteWorkflowNode( + workflowId: string, + name: string, + position: [number, number], + variables: Map, + ): Omit { + return { + parameters: { + workflowId: { + __rl: true, + value: workflowId, + mode: 'list', + }, + workflowInputs: { + mappingMode: 'defineBelow', + value: Object.fromEntries(variables.entries().map(([k, v]) => [k, `={{ ${v} }}`])), + matchingColumns: [...variables.keys()], + schema: [ + ...variables.keys().map((x) => ({ + id: x, + displayName: x, + required: false, + defaultMatch: false, + display: true, + canBeUsedToMatch: true, + removed: false, + // omitted type implicitly uses our `any` type + })), + ], + attemptToConvertTypes: false, + convertFieldsToString: true, + }, + options: {}, + }, + type: 'n8n-nodes-base.executeWorkflow', + typeVersion: 1.2, + position, + name, + }; + } + + function makeSubworkflow( + newWorkflowName: string, + { start, end }: ExtractableSubgraphData, + nodes: INodeUi[], + connections: IConnections, + selectionVariables: Map, + selectionChildrenVariables: Map, + startNodeName: string, + returnNodeName: string, + ): IWorkflowDataCreate { + const newConnections = Object.fromEntries( + Object.entries(connections).filter(([k]) => nodes.some((x) => x.name === k)), + ); + if (end) { + // this is necessary because the new workflow may crash looking for the + // nodes in these connections + delete newConnections[end]; + } + + const startNodeTarget = nodes.find((x) => x.name === start); + const firstNode = startNodeTarget ?? nodes.sort((a, b) => a.position[1] - b.position[1])[0]; + const startNodePosition: [number, number] = [ + firstNode.position[0] - PUSH_NODES_OFFSET, + firstNode.position[1], + ]; + + const endNodeTarget = nodes.find((x) => x.name === end); + const lastNode = endNodeTarget ?? nodes.sort((a, b) => b.position[1] - a.position[1])[0]; + const endNodePosition: [number, number] = [ + lastNode.position[0] + PUSH_NODES_OFFSET, + lastNode.position[1], + ]; + + const shouldInsertReturnNode = selectionChildrenVariables.size > 0; + + const startNodeConnection = startNodeTarget + ? ({ + [startNodeName]: { + main: [ + [ + { + node: startNodeTarget.name, + type: 'main', + index: 0, + }, + ], + ], + }, + } satisfies IConnections) + : {}; + + const endNodeConnection = + endNodeTarget && shouldInsertReturnNode + ? ({ + [endNodeTarget.name]: { + main: [ + [ + { + node: returnNodeName, + type: 'main', + index: 0, + }, + ], + ], + }, + } satisfies IConnections) + : {}; + + const returnNode = shouldInsertReturnNode + ? [ + { + parameters: { + assignments: { + assignments: [ + ...selectionChildrenVariables.entries().map((x) => ({ + id: uuidv4(), + name: x[0], + value: `={{ ${x[1]} }}`, + type: 'string', + })), + ], + }, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: endNodePosition, + id: uuidv4(), + name: returnNodeName, + } satisfies INode, + ] + : []; + const triggerParameters = + selectionVariables.size > 0 + ? { + workflowInputs: { + values: [...selectionVariables.keys().map((k) => ({ name: k, type: 'any' }))], + }, + } + : { + inputSource: 'passthrough', + }; + + const triggerNode: INode = { + id: uuidv4(), + typeVersion: 1.1, + name: startNodeName, + type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + position: startNodePosition, + parameters: triggerParameters, + }; + + return { + name: newWorkflowName, + nodes: [...nodes, ...returnNode, triggerNode], + connections: { + ...newConnections, + ...startNodeConnection, + ...endNodeConnection, + }, + settings: { executionOrder: 'v1' }, + projectId: workflowsStore.workflow.homeProject?.id, + parentFolderId: workflowsStore.workflow.parentFolder?.id ?? undefined, + }; + } + + function computeAveragePosition(nodes: INode[]): [number, number] { + const summedUp = nodes.reduce( + (acc, v) => [acc[0] + v.position[0], acc[1] + v.position[1], acc[2] + 1], + [0, 0, 0], + ); + return [summedUp[0] / summedUp[2], summedUp[1] / summedUp[2]]; + } + + async function tryCreateWorkflow(workflowData: IWorkflowDataCreate): Promise { + try { + const createdWorkflow = await workflowsStore.createNewWorkflow(workflowData); + + const { href } = router.resolve({ + name: VIEWS.WORKFLOW, + params: { + name: createdWorkflow.id, + }, + }); + + toast.showMessage({ + title: i18n.baseText('workflowExtraction.success.title'), + message: i18n.baseText('workflowExtraction.success.message', { + interpolate: { url: href }, + }), + type: 'success', + duration: 10 * 1000, + }); + return createdWorkflow; + } catch (e) { + toast.showError(e, i18n.baseText('workflowExtraction.error.subworkflowCreationFailed')); + return null; + } + } + + function checkExtractableSelectionValidity( + selection: ReturnType, + ): selection is ExtractableSubgraphData { + if (Array.isArray(selection)) { + showError( + i18n.baseText('workflowExtraction.error.selectionGraph.listHeader', { + interpolate: { + body: selection + .map(extractableErrorResultToMessage) + .map((x) => `- ${x}`) + .join('
'), + }, + }), + ); + return false; + } + const { start, end } = selection; + + const isSingleIO = ( + nodeName: string, + getIOs: ( + ...x: Parameters + ) => ReturnType, + ) => { + const node = workflowsStore.getNodeByName(nodeName); + if (!node) return true; // invariant broken -> abort onto error path + const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion); + if (!nodeType) return true; // invariant broken -> abort onto error path + + const ios = getIOs(workflowsStore.getCurrentWorkflow(), node, nodeType); + return ( + ios.filter((x) => (typeof x === 'string' ? x === 'main' : x.type === 'main')).length <= 1 + ); + }; + + if (start && !isSingleIO(start, NodeHelpers.getNodeInputs)) { + showError( + i18n.baseText('workflowExtraction.error.inputNodeHasMultipleInputBranches', { + interpolate: { node: start }, + }), + ); + return false; + } + if (end && !isSingleIO(end, NodeHelpers.getNodeOutputs)) { + showError( + i18n.baseText('workflowExtraction.error.outputNodeHasMultipleOutputBranches', { + interpolate: { node: end }, + }), + ); + return false; + } + + // Returns an array of errors + return !Array.isArray(selection); + } + + async function replaceSelectionWithNode( + executeWorkflowNodeData: AddedNode, + startId: string | undefined, + endId: string | undefined, + selection: INode[], + selectionChildNodes: INode[], + ) { + historyStore.startRecordingUndo(); + + // In most cases we're about to move the selection anyway + // One remarkable edge case is when a single node is right-clicked on + // This allows extraction, but does not necessarily select the node + uiStore.resetLastInteractedWith(); + + const executeWorkflowNode = ( + await canvasOperations.addNodes([executeWorkflowNodeData], { + ...CANVAS_HISTORY_OPTIONS, + forcePosition: true, + }) + )[0]; + + if (endId) + canvasOperations.replaceNodeConnections(endId, executeWorkflowNode.id, { + ...CANVAS_HISTORY_OPTIONS, + replaceInputs: false, + }); + + if (startId) + canvasOperations.replaceNodeConnections(startId, executeWorkflowNode.id, { + ...CANVAS_HISTORY_OPTIONS, + replaceOutputs: false, + }); + + canvasOperations.deleteNodes( + selection.map((x) => x.id), + CANVAS_HISTORY_OPTIONS, + ); + + for (const node of selectionChildNodes) { + const currentNode = workflowsStore.workflow.nodes.find((x) => x.id === node.id); + + if (isEqual(node, currentNode)) continue; + + canvasOperations.replaceNodeParameters( + node.id, + { ...currentNode?.parameters }, + { ...node.parameters }, + CANVAS_HISTORY_OPTIONS, + ); + } + + uiStore.stateIsDirty = true; + historyStore.stopRecordingUndo(); + } + + function tryExtractNodesIntoSubworkflow(nodeIds: string[]): boolean { + const subGraph = nodeIds.map(workflowsStore.getNodeById).filter((x) => x !== undefined); + + const triggers = subGraph.filter((x) => + useNodeTypesStore().getNodeType(x.type, x.typeVersion)?.group.includes('trigger'), + ); + if (triggers.length > 0) { + showError( + i18n.baseText('workflowExtraction.error.triggerSelected', { + interpolate: { nodes: triggers.map((x) => `'${x.name}'`).join(', ') }, + }), + ); + return false; + } + + const selection = parseExtractableSubgraphSelection( + new Set(subGraph.map((x) => x.name)), + adjacencyList.value, + ); + + if (!checkExtractableSelectionValidity(selection)) return false; + + uiStore.openModalWithData({ + name: WORKFLOW_EXTRACTION_NAME_MODAL_KEY, + data: { subGraph, selection }, + }); + return true; + } + + async function doExtractNodesIntoSubworkflow( + selection: ExtractableSubgraphData, + subGraph: INodeUi[], + newWorkflowName: string, + ) { + const { start, end } = selection; + + const currentWorkflow = workflowsStore.getCurrentWorkflow(); + const allNodeNames = workflowsStore.workflow.nodes.map((x) => x.name); + + let startNodeName = 'Start'; + const subGraphNames = subGraph.map((x) => x.name); + while (subGraphNames.includes(startNodeName)) startNodeName += '_1'; + + let returnNodeName = 'Return'; + while (subGraphNames.includes(returnNodeName)) returnNodeName += '_1'; + + const directAfterEndNodeNames = end + ? currentWorkflow + .getChildNodes(end, 'main', 1) + .map((x) => currentWorkflow.getNode(x)?.name) + .filter((x) => x !== undefined) + : []; + + const allAfterEndNodes = end + ? currentWorkflow + .getChildNodes(end, 'ALL') + .map((x) => currentWorkflow.getNode(x)) + .filter((x) => x !== null) + : []; + + const { nodes, variables } = extractReferencesInNodeExpressions( + subGraph, + allNodeNames, + startNodeName, + start ? [start] : undefined, + ); + + let executeWorkflowNodeName = `Call ${newWorkflowName}`; + while (allNodeNames.includes(executeWorkflowNodeName)) executeWorkflowNodeName += '_1'; + + const { nodes: afterNodes, variables: afterVariables } = extractReferencesInNodeExpressions( + allAfterEndNodes, + allAfterEndNodes + .map((x) => x.name) + .concat(subGraphNames), // this excludes nodes that will remain in the parent workflow + executeWorkflowNodeName, + directAfterEndNodeNames, + ); + + const workflowData = makeSubworkflow( + newWorkflowName, + selection, + nodes, + workflowsStore.workflow.connections, + variables, + afterVariables, + startNodeName, + returnNodeName, + ); + const createdWorkflow = await tryCreateWorkflow(workflowData); + if (createdWorkflow === null) return false; + + const executeWorkflowPosition = computeAveragePosition(subGraph); + const executeWorkflowNode = makeExecuteWorkflowNode( + createdWorkflow.id, + executeWorkflowNodeName, + executeWorkflowPosition, + variables, + ); + await replaceSelectionWithNode( + executeWorkflowNode, + subGraph.find((x) => x.name === start)?.id, + subGraph.find((x) => x.name === end)?.id, + subGraph, + afterNodes, + ); + + return true; + } + + /** + * This mutates the current workflow and creates a new one. + * Intended to be called from @WorkflowExtractionNameModal spawned + * by @tryExtractNodesIntoSubworkflow + */ + async function extractNodesIntoSubworkflow( + selection: ExtractableSubgraphData, + subGraph: INodeUi[], + newWorkflowName: string, + ) { + const success = await doExtractNodesIntoSubworkflow(selection, subGraph, newWorkflowName); + trackExtractWorkflow(subGraph.length, success); + } + + /** + * This starts the extraction process by checking whether the selection is extractable + * and spawning a pop up asking for a sub-workflow name. + * If confirmed, the modal calls @extractNodesIntoSubworkflow to handle the actual mutation + * + * @param nodeIds the ids to be extracted from the current workflow into a sub-workflow + */ + async function extractWorkflow(nodeIds: string[]) { + const success = tryExtractNodesIntoSubworkflow(nodeIds); + trackStartExtractWorkflow(nodeIds.length, success); + } + + function trackStartExtractWorkflow(nodeCount: number, success: boolean) { + telemetry.track('User started nodes to sub-workflow extraction', { + node_count: nodeCount, + success, + }); + } + + function trackExtractWorkflow(nodeCount: number, success: boolean) { + telemetry.track('User extracted nodes to sub-workflow', { + node_count: nodeCount, + success, + }); + } + + return { + adjacencyList, + extractWorkflow, + tryExtractNodesIntoSubworkflow, + extractNodesIntoSubworkflow, + }; +} diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index e267dfbda6..99053fe11c 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -82,6 +82,7 @@ export const MOVE_FOLDER_MODAL_KEY = 'moveFolder'; export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY = 'workflowActivationConflictingWebhook'; export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters'; +export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName'; export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = { UNINSTALL: 'uninstall', diff --git a/packages/frontend/editor-ui/src/models/history.ts b/packages/frontend/editor-ui/src/models/history.ts index 8fa521ce38..ff92fc454d 100644 --- a/packages/frontend/editor-ui/src/models/history.ts +++ b/packages/frontend/editor-ui/src/models/history.ts @@ -1,5 +1,5 @@ import type { INodeUi, XYPosition } from '@/Interface'; -import type { IConnection } from 'n8n-workflow'; +import type { IConnection, INodeParameters } from 'n8n-workflow'; import { createEventBus } from '@n8n/utils/event-bus'; // Command names don't serve any particular purpose in the app @@ -13,6 +13,7 @@ export const enum COMMANDS { REMOVE_CONNECTION = 'removeConnection', ENABLE_NODE_TOGGLE = 'enableNodeToggle', RENAME_NODE = 'renameNode', + REPLACE_NODE_PARAMETERS = 'replaceNodeParameters', } // Triggering multiple canvas actions in sequence leaves @@ -281,3 +282,43 @@ export class RenameNodeCommand extends Command { }); } } + +export class ReplaceNodeParametersCommand extends Command { + constructor( + private nodeId: string, + private currentParameters: INodeParameters, + private newParameters: INodeParameters, + timestamp: number, + ) { + super(COMMANDS.REPLACE_NODE_PARAMETERS, timestamp); + } + + getReverseCommand(timestamp: number): Command { + return new ReplaceNodeParametersCommand( + this.nodeId, + this.newParameters, + this.currentParameters, + timestamp, + ); + } + + isEqualTo(anotherCommand: Command): boolean { + return ( + anotherCommand instanceof ReplaceNodeParametersCommand && + anotherCommand.nodeId === this.nodeId && + anotherCommand.currentParameters === this.currentParameters && + anotherCommand.newParameters === this.newParameters + ); + } + + async revert(): Promise { + return await new Promise((resolve) => { + historyBus.emit('revertReplaceNodeParameters', { + nodeId: this.nodeId, + currentProperties: this.currentParameters, + newProperties: this.newParameters, + }); + resolve(); + }); + } +} diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts index 84a601a798..98edc27037 100644 --- a/packages/frontend/editor-ui/src/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/stores/ui.store.ts @@ -41,6 +41,7 @@ import { WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY, FROM_AI_PARAMETERS_MODAL_KEY, IMPORT_WORKFLOW_URL_MODAL_KEY, + WORKFLOW_EXTRACTION_NAME_MODAL_KEY, } from '@/constants'; import { STORES } from '@n8n/stores'; import type { @@ -199,6 +200,12 @@ export const useUIStore = defineStore(STORES.UI, () => { url: '', }, }, + [WORKFLOW_EXTRACTION_NAME_MODAL_KEY]: { + open: false, + data: { + workflowName: '', + }, + }, }); const modalStack = ref([]); diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index 584fac988d..da1ffde2e8 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -390,6 +390,35 @@ describe('useWorkflowsStore', () => { const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode'); expect(result).toBe(false); }); + + it('should return true if connection is indirect within `depth`', () => { + workflowsStore.workflow.connections = { + RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] }, + IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] }, + }; + + const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 2); + expect(result).toBe(true); + }); + + it('should return false if connection is indirect beyond `depth`', () => { + workflowsStore.workflow.connections = { + RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] }, + IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] }, + }; + + const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 1); + expect(result).toBe(false); + }); + + it('should return false if depth is 0', () => { + workflowsStore.workflow.connections = { + RootNode: { main: [[{ node: 'SearchNode' } as IConnection]] }, + }; + + const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode', 0); + expect(result).toBe(false); + }); }); describe('getPinDataSize()', () => { diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 4ab4bddbd7..401be3f65e 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -334,14 +334,21 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return workflow.value.connections.hasOwnProperty(nodeName); } - function isNodeInOutgoingNodeConnections(rootNodeName: string, searchNodeName: string): boolean { + function isNodeInOutgoingNodeConnections( + rootNodeName: string, + searchNodeName: string, + depth = -1, + ): boolean { + if (depth === 0) return false; const firstNodeConnections = outgoingConnectionsByNodeName(rootNodeName); if (!firstNodeConnections?.main?.[0]) return false; const connections = firstNodeConnections.main[0]; if (connections.some((node) => node.node === searchNodeName)) return true; - return connections.some((node) => isNodeInOutgoingNodeConnections(node.node, searchNodeName)); + return connections.some((node) => + isNodeInOutgoingNodeConnections(node.node, searchNodeName, depth - 1), + ); } function getWorkflowById(id: string): IWorkflowDb { diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 926c46dae5..5d2a17d42d 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -76,7 +76,13 @@ import { EVALUATION_TRIGGER_NODE_TYPE, EVALUATION_NODE_TYPE, } from 'n8n-workflow'; -import type { NodeConnectionType, IDataObject, ExecutionSummary, IConnection } from 'n8n-workflow'; +import type { + NodeConnectionType, + IDataObject, + ExecutionSummary, + IConnection, + INodeParameters, +} from 'n8n-workflow'; import { useToast } from '@/composables/useToast'; import { useSettingsStore } from '@/stores/settings.store'; import { useCredentialsStore } from '@/stores/credentials.store'; @@ -123,6 +129,7 @@ import { useWorkflowSaving } from '@/composables/useWorkflowSaving'; import { useBuilderStore } from '@/stores/builder.store'; import { useFoldersStore } from '@/stores/folders.store'; import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue'; +import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction'; import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore'; import { needsAgentInput } from '@/utils/nodes/nodeTransforms'; import { useLogsStore } from '@/stores/logs.store'; @@ -195,6 +202,7 @@ const { revertUpdateNodePosition, renameNode, revertRenameNode, + revertReplaceNodeParameters, setNodeActive, setNodeSelected, toggleNodesDisabled, @@ -230,6 +238,7 @@ const { lastClickPosition, startChat, } = useCanvasOperations({ router }); +const { extractWorkflow } = useWorkflowExtraction(); const { applyExecutionData } = useExecutionDebugging(); useClipboard({ onPaste: onClipboardPaste }); @@ -635,6 +644,10 @@ function onTidyUp(event: CanvasLayoutEvent) { tidyUp(event); } +function onExtractWorkflow(nodeIds: string[]) { + void extractWorkflow(nodeIds); +} + function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) { updateNodesPosition(events, { trackHistory: true }); } @@ -891,6 +904,18 @@ async function onRevertRenameNode({ await revertRenameNode(currentName, newName); } +async function onRevertReplaceNodeParameters({ + nodeId, + currentProperties, + newProperties, +}: { + nodeId: string; + currentProperties: INodeParameters; + newProperties: INodeParameters; +}) { + await revertReplaceNodeParameters(nodeId, currentProperties, newProperties); +} + function onUpdateNodeParameters(id: string, parameters: Record) { setNodeParameters(id, parameters); } @@ -1400,6 +1425,7 @@ function addUndoRedoEventBindings() { historyBus.on('revertAddConnection', onRevertCreateConnection); historyBus.on('revertRemoveConnection', onRevertDeleteConnection); historyBus.on('revertRenameNode', onRevertRenameNode); + historyBus.on('revertReplaceNodeParameters', onRevertReplaceNodeParameters); historyBus.on('enableNodeToggle', onRevertToggleNodeDisabled); } @@ -1410,6 +1436,7 @@ function removeUndoRedoEventBindings() { historyBus.off('revertAddConnection', onRevertCreateConnection); historyBus.off('revertRemoveConnection', onRevertDeleteConnection); historyBus.off('revertRenameNode', onRevertRenameNode); + historyBus.off('revertReplaceNodeParameters', onRevertReplaceNodeParameters); historyBus.off('enableNodeToggle', onRevertToggleNodeDisabled); } @@ -1977,6 +2004,7 @@ onBeforeUnmount(() => { @selection:end="onSelectionEnd" @drag-and-drop="onDragAndDrop" @tidy-up="onTidyUp" + @extract-workflow="onExtractWorkflow" @start-chat="startChat()" > diff --git a/packages/workflow/src/graph/graph-utils.ts b/packages/workflow/src/graph/graph-utils.ts index 1bdd084d75..547c5bac68 100644 --- a/packages/workflow/src/graph/graph-utils.ts +++ b/packages/workflow/src/graph/graph-utils.ts @@ -1,3 +1,5 @@ +import type { IConnection, IConnections } from '../interfaces'; + type MultipleInputNodesError = { errorCode: 'Multiple Input Nodes'; nodes: Set; @@ -31,21 +33,21 @@ export type ExtractableErrorResult = | OutputEdgeFromNonLeafNode | NoContinuousPathFromRootToLeaf; -type AdjacencyList = Map>; +export type IConnectionAdjacencyList = Map>; /** * Find all edges leading into the graph described in `graphIds`. */ export function getInputEdges( graphIds: Set, - adjacencyList: AdjacencyList, -): Array<[string, string]> { - const result: Array<[string, string]> = []; + adjacencyList: IConnectionAdjacencyList, +): Array<[string, IConnection]> { + const result: Array<[string, IConnection]> = []; for (const [from, tos] of adjacencyList.entries()) { if (graphIds.has(from)) continue; for (const to of tos) { - if (graphIds.has(to)) { + if (graphIds.has(to.node)) { result.push([from, to]); } } @@ -59,14 +61,14 @@ export function getInputEdges( */ export function getOutputEdges( graphIds: Set, - adjacencyList: AdjacencyList, -): Array<[string, string]> { - const result: Array<[string, string]> = []; + adjacencyList: IConnectionAdjacencyList, +): Array<[string, IConnection]> { + const result: Array<[string, IConnection]> = []; for (const [from, tos] of adjacencyList.entries()) { if (!graphIds.has(from)) continue; for (const to of tos) { - if (!graphIds.has(to)) { + if (!graphIds.has(to.node)) { result.push([from, to]); } } @@ -98,27 +100,49 @@ function difference(minuend: Set, subtrahend: Set): Set { return result; } -export function getRootNodes(graphIds: Set, adjacencyList: AdjacencyList): Set { +export function getRootNodes( + graphIds: Set, + adjacencyList: IConnectionAdjacencyList, +): Set { // Inner nodes are all nodes with an incoming edge from another node in the graph let innerNodes = new Set(); for (const nodeId of graphIds) { - innerNodes = union(innerNodes, adjacencyList.get(nodeId) ?? new Set()); + innerNodes = union( + innerNodes, + new Set( + [...(adjacencyList.get(nodeId) ?? [])] + .filter((x) => x.type === 'main' && x.node !== nodeId) + .map((x) => x.node), + ), + ); } return difference(graphIds, innerNodes); } -export function getLeafNodes(graphIds: Set, adjacencyList: AdjacencyList): Set { +export function getLeafNodes( + graphIds: Set, + adjacencyList: IConnectionAdjacencyList, +): Set { const result = new Set(); for (const nodeId of graphIds) { - if (intersection(adjacencyList.get(nodeId) ?? new Set(), graphIds).size === 0) { + if ( + intersection( + new Set( + [...(adjacencyList.get(nodeId) ?? [])] + .filter((x) => x.type === 'main' && x.node !== nodeId) + .map((x) => x.node), + ), + graphIds, + ).size === 0 + ) { result.add(nodeId); } } return result; } -export function hasPath(start: string, end: string, adjacencyList: AdjacencyList) { +export function hasPath(start: string, end: string, adjacencyList: IConnectionAdjacencyList) { const seen = new Set(); const paths: string[] = [start]; while (true) { @@ -127,7 +151,14 @@ export function hasPath(start: string, end: string, adjacencyList: AdjacencyList if (next === undefined) return false; seen.add(next); - paths.push(...difference(adjacencyList.get(next) ?? new Set(), seen)); + paths.push( + ...difference( + new Set( + [...(adjacencyList.get(next) ?? [])].filter((x) => x.type === 'main').map((x) => x.node), + ), + seen, + ), + ); } } @@ -136,6 +167,31 @@ export type ExtractableSubgraphData = { end?: string; }; +export function buildAdjacencyList( + connectionsBySourceNode: IConnections, +): IConnectionAdjacencyList { + const result = new Map>(); + const addOrCreate = (k: string, v: IConnection) => + result.set(k, union(result.get(k) ?? new Set(), new Set([v]))); + + for (const sourceNode of Object.keys(connectionsBySourceNode)) { + for (const type of Object.keys(connectionsBySourceNode[sourceNode])) { + for (const sourceIndex of Object.keys(connectionsBySourceNode[sourceNode][type])) { + for (const connectionIndex of Object.keys( + connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)] ?? [], + )) { + const connection = + connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)]?.[ + parseInt(connectionIndex, 10) + ]; + if (connection) addOrCreate(sourceNode, connection); + } + } + } + } + return result; +} + /** * A subgraph is considered extractable if the following properties hold: * - 0-1 input nodes from outside the subgraph, to a root node @@ -152,14 +208,18 @@ export type ExtractableSubgraphData = { */ export function parseExtractableSubgraphSelection( graphIds: Set, - adjacencyList: AdjacencyList, + adjacencyList: IConnectionAdjacencyList, ): ExtractableSubgraphData | ExtractableErrorResult[] { const errors: ExtractableErrorResult[] = []; // 0-1 Input nodes const inputEdges = getInputEdges(graphIds, adjacencyList); - const inputNodes = new Set(inputEdges.map((x) => x[1])); - const rootNodes = getRootNodes(graphIds, adjacencyList); + // This filters out e.g. sub-nodes, which are technically parents + const inputNodes = new Set(inputEdges.filter((x) => x[1].type === 'main').map((x) => x[1].node)); + let rootNodes = getRootNodes(graphIds, adjacencyList); + + // this enables supporting cases where we have one input and a loop back to it from within the selection + if (rootNodes.size === 0 && inputNodes.size === 1) rootNodes = inputNodes; for (const inputNode of difference(inputNodes, rootNodes).values()) { errors.push({ errorCode: 'Input Edge To Non-Root Node', @@ -176,8 +236,13 @@ export function parseExtractableSubgraphSelection( // 0-1 Output nodes const outputEdges = getOutputEdges(graphIds, adjacencyList); - const outputNodes = new Set(outputEdges.map((x) => x[0])); - const leafNodes = getLeafNodes(graphIds, adjacencyList); + const outputNodes = new Set(outputEdges.filter((x) => x[1].type === 'main').map((x) => x[0])); + let leafNodes = getLeafNodes(graphIds, adjacencyList); + // If we have no leaf nodes, and only one output node, we can tolerate this output node + // and connect to it. + // Note that this is fairly theoretical, as return semantics in this case are not well-defined. + if (leafNodes.size === 0 && outputNodes.size === 1) leafNodes = outputNodes; + for (const outputNode of difference(outputNodes, leafNodes).values()) { errors.push({ errorCode: 'Output Edge From Non-Leaf Node', diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 2833d89ce7..ff95994efd 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -14,6 +14,7 @@ export * from './execution-status'; export * from './expression'; export * from './from-ai-parse-utils'; export * from './node-helpers'; +export * from './node-reference-parser-utils'; export * from './metadata-utils'; export * from './workflow'; export * from './workflow-data-proxy'; @@ -50,6 +51,13 @@ export { isFilterValue, } from './type-guards'; +export { + parseExtractableSubgraphSelection, + buildAdjacencyList, + type ExtractableErrorResult, + type ExtractableSubgraphData, + type IConnectionAdjacencyList as AdjacencyList, +} from './graph/graph-utils'; export { ExpressionExtensions } from './extensions'; export * as ExpressionParser from './extensions/expression-parser'; export { NativeMethods } from './native-methods'; diff --git a/packages/workflow/src/node-reference-parser-utils.ts b/packages/workflow/src/node-reference-parser-utils.ts index c5ba85bea7..30c3357d72 100644 --- a/packages/workflow/src/node-reference-parser-utils.ts +++ b/packages/workflow/src/node-reference-parser-utils.ts @@ -1,7 +1,7 @@ import { escapeRegExp, mapValues, isEqual, cloneDeep } from 'lodash'; import { OperationalError } from './errors'; -import type { INode, NodeParameterValueType } from './interfaces'; +import type { INode, INodeParameters, NodeParameterValueType } from './interfaces'; class LazyRegExp { private regExp?: RegExp; @@ -176,7 +176,6 @@ function parseExpressionMapping( for (; partsIdx < parts.length; ++partsIdx) { if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break; } - return { nodeNameInExpression: null, originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $json.valid.until, but not ['x'] after @@ -304,8 +303,12 @@ function parseCandidateMatch( // Handle matches of form `$json.path.to.value`, which is necessary for the selection input node function parse$jsonMatch(match: RegExpExecArray, expression: string, startNodeName: string) { - const candidate = extractExpressionCandidate(expression, match.index, match[0].length); - if (candidate === null) return; + const candidate = extractExpressionCandidate( + expression, + match.index, + match.index + match[0].length + 1, + ); + if (candidate === null) return null; return parseExpressionMapping(candidate, null, null, startNodeName); } @@ -439,6 +442,10 @@ function applyExtractMappingToNode(node: INode, parameterExtractMapping: Paramet return parameters; } + if (Array.isArray(parameters) && typeof mapping === 'object' && !Array.isArray(mapping)) { + return parameters.map((x, i) => applyMapping(x, mapping[i]) as INodeParameters); + } + return mapValues(parameters, (v, k) => applyMapping(v, mapping[k])) as NodeParameterValueType; }; @@ -477,17 +484,16 @@ export function extractReferencesInNodeExpressions( subGraph: INode[], nodeNames: string[], insertedStartName: string, - graphInputNodeName?: string, + graphInputNodeNames?: string[], ) { //// // STEP 1 - Validate input invariants //// - if (nodeNames.includes(insertedStartName)) - throw new OperationalError( - `StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(nodeNames)}`, - ); - const subGraphNames = subGraph.map((x) => x.name); + if (subGraphNames.includes(insertedStartName)) + throw new OperationalError( + `StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(subGraphNames)}`, + ); if (subGraphNames.some((x) => !nodeNames.includes(x))) { throw new OperationalError( @@ -516,7 +522,8 @@ export function extractReferencesInNodeExpressions( //// // This map is used to change the actual expressions once resolved - const recMapByNode = new Map(); + // The value represents fields in the actual parameters object which require change + const parameterTreeMappingByNode = new Map(); // This is used to track all candidates for change, necessary for deduplication const allData = []; @@ -527,10 +534,10 @@ export function extractReferencesInNodeExpressions( nodeRegexps, nodeNames, insertedStartName, - node.name === graphInputNodeName, + graphInputNodeNames?.includes(node.name) ?? false, ), ); - recMapByNode.set(node.name, parameterMapping); + parameterTreeMappingByNode.set(node.name, parameterMapping); allData.push(...allMappings); } @@ -560,8 +567,8 @@ export function extractReferencesInNodeExpressions( return triggerArgumentMap.get(key); }; - for (const [key, value] of recMapByNode.entries()) { - recMapByNode.set(key, applyCanonicalMapping(value, getCanonicalData)); + for (const [key, value] of parameterTreeMappingByNode.entries()) { + parameterTreeMappingByNode.set(key, applyCanonicalMapping(value, getCanonicalData)); } const allUsedMappings = []; @@ -569,7 +576,7 @@ export function extractReferencesInNodeExpressions( for (const node of subGraph) { const { result, usedMappings } = applyExtractMappingToNode( cloneDeep(node), - recMapByNode.get(node.name), + parameterTreeMappingByNode.get(node.name), ); allUsedMappings.push(...usedMappings); output.push(result); diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 33acbe22a4..6b54a5d9ae 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -927,4 +927,38 @@ export class Workflow { return this.__getStartNode(Object.keys(this.nodes)); } + + getConnectionsBetweenNodes( + sources: string[], + targets: string[], + ): Array<[IConnection, IConnection]> { + const result: Array<[IConnection, IConnection]> = []; + + for (const source of sources) { + for (const type of Object.keys(this.connectionsBySourceNode[source] ?? {})) { + for (const sourceIndex of Object.keys(this.connectionsBySourceNode[source][type])) { + for (const connectionIndex of Object.keys( + this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)] ?? [], + )) { + const targetConnectionData = + this.connectionsBySourceNode[source][type][parseInt(sourceIndex, 10)]?.[ + parseInt(connectionIndex, 10) + ]; + if (targetConnectionData && targets.includes(targetConnectionData?.node)) { + result.push([ + { + node: source, + index: parseInt(sourceIndex, 10), + type: type as NodeConnectionType, + }, + targetConnectionData, + ]); + } + } + } + } + } + + return result; + } } diff --git a/packages/workflow/test/GraphUtils/graph-utils.test.ts b/packages/workflow/test/GraphUtils/graph-utils.test.ts deleted file mode 100644 index 7c89db4173..0000000000 --- a/packages/workflow/test/GraphUtils/graph-utils.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { - getInputEdges, - getOutputEdges, - getRootNodes, - getLeafNodes, - parseExtractableSubgraphSelection, - hasPath, -} from '@/graph/graph-utils'; - -describe('graphUtils', () => { - describe('getInputEdges', () => { - it('should return edges leading into the graph', () => { - const graphIds = new Set(['B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ]); - - const result = getInputEdges(graphIds, adjacencyList); - expect(result).toEqual([['A', 'B']]); - }); - - it('should return an empty array if there are no input edges', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set()], - ]); - - const result = getInputEdges(graphIds, adjacencyList); - expect(result).toEqual([]); - }); - }); - - describe('getOutputEdges', () => { - it('should return edges leading out of the graph', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ['C', new Set()], - ]); - - const result = getOutputEdges(graphIds, adjacencyList); - expect(result).toEqual([['B', 'C']]); - }); - - it('should return an empty array if there are no output edges', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([['A', new Set(['B'])]]); - - const result = getOutputEdges(graphIds, adjacencyList); - expect(result).toEqual([]); - }); - }); - - describe('getRootNodes', () => { - it('should return root nodes of the graph', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([['A', new Set(['B'])]]); - - const result = getRootNodes(graphIds, adjacencyList); - expect(result).toEqual(new Set(['A', 'C'])); - }); - - it('should return all nodes if there are no incoming edges', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>(); - - const result = getRootNodes(graphIds, adjacencyList); - expect(result).toEqual(new Set(['A', 'B'])); - }); - }); - - describe('getLeafNodes', () => { - it('should return leaf nodes of the graph', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ['C', new Set()], - ]); - - const result = getLeafNodes(graphIds, adjacencyList); - expect(result).toEqual(new Set(['C'])); - }); - - it('should return all nodes if there are no outgoing edges', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['A', new Set()], - ['B', new Set()], - ]); - - const result = getLeafNodes(graphIds, adjacencyList); - expect(result).toEqual(new Set(['A', 'B'])); - }); - }); - - describe('parseExtractableSubgraphSelection', () => { - it('should return successfully for a valid extractable subgraph', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['C', new Set(['A'])], - ['A', new Set(['B'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toMatchObject({ start: 'A' }); - }); - - it('should return successfully for multiple edges into single input node', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['X', new Set(['A'])], - ['Y', new Set(['A'])], - ['A', new Set(['B'])], - ['B', new Set()], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toMatchObject({ start: 'A' }); - }); - - it('should return successfully for multiple edges from single output nodes', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['X', 'Y'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toMatchObject({}); - }); - - it('should return errors for input edge to non-root node', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([ - ['X', new Set(['B'])], - ['A', new Set(['B'])], - ['B', new Set()], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toEqual([ - { - errorCode: 'Input Edge To Non-Root Node', - node: 'B', - }, - ]); - }); - - it('should return errors for output edge from non-leaf node', () => { - const graphIds = new Set(['A', 'B']); - const adjacencyList = new Map>([['A', new Set(['B', 'X'])]]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toEqual([ - { - errorCode: 'Output Edge From Non-Leaf Node', - node: 'A', - }, - ]); - }); - - it('should return successfully for multiple root nodes with 1 input', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['C'])], - ['B', new Set(['C'])], - ['X', new Set(['A'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toMatchObject({}); - }); - - it('should return an error for multiple root nodes with inputs', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['C'])], - ['B', new Set(['C'])], - ['X', new Set(['A'])], - ['Y', new Set(['B'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toEqual([ - { - errorCode: 'Multiple Input Nodes', - nodes: new Set(['A', 'B']), - }, - ]); - }); - - it('should return successfully for multiple leaf nodes with 1 output', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['B', 'C'])], - ['C', new Set(['X'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toMatchObject({}); - }); - - it('should return an error for multiple leaf nodes with outputs', () => { - const graphIds = new Set(['A', 'B', 'C']); - const adjacencyList = new Map>([ - ['A', new Set(['B', 'C'])], - ['B', new Set(['X'])], - ['C', new Set(['X'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toEqual([ - { - errorCode: 'Multiple Output Nodes', - nodes: new Set(['B', 'C']), - }, - ]); - }); - - it('should return an error for a non-continuous selection', () => { - const graphIds = new Set(['A', 'D']); - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ['C', new Set(['D'])], - ]); - - const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); - expect(result).toEqual([ - { - errorCode: 'No Continuous Path From Root To Leaf In Selection', - start: 'D', - end: 'A', - }, - ]); - }); - }); - describe('hasPath', () => { - it('should return true for a direct path between start and end', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ]); - - const result = hasPath('A', 'C', adjacencyList); - expect(result).toBe(true); - }); - - it('should return false if there is no path between start and end', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['C', new Set(['D'])], - ]); - - const result = hasPath('A', 'D', adjacencyList); - expect(result).toBe(false); - }); - - it('should return true for a path with multiple intermediate nodes', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ['C', new Set(['D'])], - ]); - - const result = hasPath('A', 'D', adjacencyList); - expect(result).toBe(true); - }); - - it('should return false if the start node is not in the adjacency list', () => { - const adjacencyList = new Map>([ - ['B', new Set(['C'])], - ['C', new Set(['D'])], - ]); - - const result = hasPath('A', 'D', adjacencyList); - expect(result).toBe(false); - }); - - it('should return false if the end node is not in the adjacency list', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ]); - - const result = hasPath('A', 'D', adjacencyList); - expect(result).toBe(false); - }); - - it('should return true for a cyclic graph where a path exists', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['C'])], - ['C', new Set(['A'])], - ]); - - const result = hasPath('A', 'C', adjacencyList); - expect(result).toBe(true); - }); - - it('should return false for a cyclic graph where no path exists', () => { - const adjacencyList = new Map>([ - ['A', new Set(['B'])], - ['B', new Set(['A'])], - ['C', new Set(['D'])], - ]); - - const result = hasPath('A', 'D', adjacencyList); - expect(result).toBe(false); - }); - - it('should return true for a self-loop', () => { - const adjacencyList = new Map>([['A', new Set(['A'])]]); - - const result = hasPath('A', 'A', adjacencyList); - expect(result).toBe(true); - }); - }); -}); diff --git a/packages/workflow/test/graph/graph-utils.test.ts b/packages/workflow/test/graph/graph-utils.test.ts new file mode 100644 index 0000000000..6f05012925 --- /dev/null +++ b/packages/workflow/test/graph/graph-utils.test.ts @@ -0,0 +1,484 @@ +import { + getInputEdges, + getOutputEdges, + getRootNodes, + getLeafNodes, + parseExtractableSubgraphSelection, + hasPath, + buildAdjacencyList, +} from '@/graph/graph-utils'; +import type { IConnection, IConnections, NodeConnectionType } from '@/index'; + +function makeConnection( + node: string, + index: number = 0, + type: NodeConnectionType = 'main', +): IConnection { + return { + node, + index, + type, + }; +} + +describe('graphUtils', () => { + describe('getInputEdges', () => { + it('should return edges leading into the graph', () => { + const graphIds = new Set(['B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ]); + + const result = getInputEdges(graphIds, adjacencyList); + expect(result).toEqual([['A', makeConnection('B')]]); + }); + + it('should return an empty array if there are no input edges', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set()], + ]); + + const result = getInputEdges(graphIds, adjacencyList); + expect(result).toEqual([]); + }); + }); + + describe('getOutputEdges', () => { + it('should return edges leading out of the graph', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set()], + ]); + + const result = getOutputEdges(graphIds, adjacencyList); + expect(result).toEqual([['B', makeConnection('C')]]); + }); + + it('should return an empty array if there are no output edges', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ]); + + const result = getOutputEdges(graphIds, adjacencyList); + expect(result).toEqual([]); + }); + }); + + describe('getRootNodes', () => { + it('should return root nodes of the graph', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ]); + + const result = getRootNodes(graphIds, adjacencyList); + expect(result).toEqual(new Set(['A', 'C'])); + }); + + it('should return all nodes if there are no incoming edges', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>(); + + const result = getRootNodes(graphIds, adjacencyList); + expect(result).toEqual(new Set(['A', 'B'])); + }); + }); + + describe('getLeafNodes', () => { + it('should return leaf nodes of the graph', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set()], + ]); + + const result = getLeafNodes(graphIds, adjacencyList); + expect(result).toEqual(new Set(['C'])); + }); + + it('should return all nodes if there are no outgoing edges', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set()], + ['B', new Set()], + ]); + + const result = getLeafNodes(graphIds, adjacencyList); + expect(result).toEqual(new Set(['A', 'B'])); + }); + }); + + describe('parseExtractableSubgraphSelection', () => { + it('should return successfully for a valid extractable subgraph', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['C', new Set([makeConnection('A')])], + ['A', new Set([makeConnection('B')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'A', end: undefined }); + }); + + it('should return successfully for multiple edges into single input node', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['X', new Set([makeConnection('A')])], + ['Y', new Set([makeConnection('A')])], + ['A', new Set([makeConnection('B')])], + ['B', new Set()], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'A', end: undefined }); + }); + + it('should return successfully for multiple edges from single output nodes', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('X'), makeConnection('Y')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: undefined, end: 'B' }); + }); + + it('should return errors for input edge to non-root node', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['X', new Set([makeConnection('B')])], + ['A', new Set([makeConnection('B')])], + ['B', new Set()], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { + errorCode: 'Input Edge To Non-Root Node', + node: 'B', + }, + ]); + }); + + it('should return errors for output edge from non-leaf node', () => { + const graphIds = new Set(['A', 'B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B'), makeConnection('X')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { + errorCode: 'Output Edge From Non-Leaf Node', + node: 'A', + }, + ]); + }); + + it('should return successfully for multiple root nodes with 1 input', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('C')])], + ['B', new Set([makeConnection('C')])], + ['X', new Set([makeConnection('A')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'A', end: undefined }); + }); + + it('should return an error for multiple root nodes with inputs', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('C')])], + ['B', new Set([makeConnection('C')])], + ['X', new Set([makeConnection('A')])], + ['Y', new Set([makeConnection('B')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { + errorCode: 'Multiple Input Nodes', + nodes: new Set(['A', 'B']), + }, + ]); + }); + + it('should return successfully for multiple leaf nodes with 1 output', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B'), makeConnection('C')])], + ['C', new Set([makeConnection('X')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: undefined, end: 'C' }); + }); + + it('should return an error for multiple leaf nodes with outputs', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B'), makeConnection('C')])], + ['B', new Set([makeConnection('X')])], + ['C', new Set([makeConnection('X')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { + errorCode: 'Multiple Output Nodes', + nodes: new Set(['B', 'C']), + }, + ]); + }); + + it('should return an error for a non-continuous selection', () => { + const graphIds = new Set(['A', 'D']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('D')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { + errorCode: 'No Continuous Path From Root To Leaf In Selection', + start: 'D', + end: 'A', + }, + ]); + }); + + it('should allow loop with node itself', () => { + const graphIds = new Set(['A']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('A')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: undefined, end: undefined }); + }); + it('should allow loop with node itself with input and output', () => { + const graphIds = new Set(['B']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('B'), makeConnection('C')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'B', end: 'B' }); + }); + it('should allow loop within selection', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('A')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: undefined, end: undefined }); + }); + it('should allow loop within selection with input', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('A')])], + ['D', new Set([makeConnection('B')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'B', end: undefined }); + }); + it('should allow loop within selection with two inputs', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('A')])], + ['D', new Set([makeConnection('B')])], + ['E', new Set([makeConnection('B')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual({ start: 'B', end: undefined }); + }); + it('should not allow loop within selection with inputs to different nodes', () => { + const graphIds = new Set(['A', 'B', 'C']); + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('A')])], + ['D', new Set([makeConnection('B')])], + ['E', new Set([makeConnection('C')])], + ]); + + const result = parseExtractableSubgraphSelection(graphIds, adjacencyList); + expect(result).toEqual([ + { errorCode: 'Input Edge To Non-Root Node', node: 'B' }, + { errorCode: 'Input Edge To Non-Root Node', node: 'C' }, + ]); + }); + }); + describe('hasPath', () => { + it('should return true for a direct path between start and end', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ]); + + const result = hasPath('A', 'C', adjacencyList); + expect(result).toBe(true); + }); + + it('should return false if there is no path between start and end', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['C', new Set([makeConnection('D')])], + ]); + + const result = hasPath('A', 'D', adjacencyList); + expect(result).toBe(false); + }); + + it('should return true for a path with multiple intermediate nodes', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('D')])], + ]); + + const result = hasPath('A', 'D', adjacencyList); + expect(result).toBe(true); + }); + + it('should return false if the start node is not in the adjacency list', () => { + const adjacencyList = new Map>([ + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('D')])], + ]); + + const result = hasPath('A', 'D', adjacencyList); + expect(result).toBe(false); + }); + + it('should return false if the end node is not in the adjacency list', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ]); + + const result = hasPath('A', 'D', adjacencyList); + expect(result).toBe(false); + }); + + it('should return true for a cyclic graph where a path exists', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('C')])], + ['C', new Set([makeConnection('A')])], + ]); + + const result = hasPath('A', 'C', adjacencyList); + expect(result).toBe(true); + }); + + it('should return false for a cyclic graph where no path exists', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('B')])], + ['B', new Set([makeConnection('A')])], + ['C', new Set([makeConnection('D')])], + ]); + + const result = hasPath('A', 'D', adjacencyList); + expect(result).toBe(false); + }); + + it('should return true for a self-loop', () => { + const adjacencyList = new Map>([ + ['A', new Set([makeConnection('A')])], + ]); + + const result = hasPath('A', 'A', adjacencyList); + expect(result).toBe(true); + }); + }); + describe('buildAdjacencyList', () => { + it('should build an adjacency list from connections by source node', () => { + const connectionsBySourceNode: IConnections = { + A: { + main: [ + [ + { node: 'B', index: 0, type: 'main' }, + { node: 'C', index: 1, type: 'main' }, + ], + ], + }, + B: { + main: [[{ node: 'D', index: 0, type: 'main' }]], + }, + }; + + const result = buildAdjacencyList(connectionsBySourceNode); + + expect(result).toEqual( + new Map>([ + ['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])], + ['B', new Set([makeConnection('D', 0)])], + ]), + ); + }); + + it('should handle an empty connections object', () => { + const connectionsBySourceNode = {}; + + const result = buildAdjacencyList(connectionsBySourceNode); + + expect(result).toEqual(new Map()); + }); + + it('should handle connections with multiple types', () => { + const connectionsBySourceNode: IConnections = { + A: { + main: [[{ node: 'B', index: 0, type: 'main' }]], + ai_tool: [[{ node: 'C', index: 1, type: 'ai_tool' }]], + }, + }; + + const result = buildAdjacencyList(connectionsBySourceNode); + + expect(result).toEqual( + new Map>([ + ['A', new Set([makeConnection('B', 0, 'main'), makeConnection('C', 1, 'ai_tool')])], + ]), + ); + }); + + it('should handle connections with multiple indices', () => { + const connectionsBySourceNode: IConnections = { + A: { + main: [[{ node: 'B', index: 0, type: 'main' }], [{ node: 'C', index: 1, type: 'main' }]], + }, + }; + + const result = buildAdjacencyList(connectionsBySourceNode); + + expect(result).toEqual( + new Map>([ + ['A', new Set([makeConnection('B', 0), makeConnection('C', 1)])], + ]), + ); + }); + }); +}); diff --git a/packages/workflow/test/node-reference-parser-utils.test.ts b/packages/workflow/test/node-reference-parser-utils.test.ts index a7457cbaff..f30b61ca64 100644 --- a/packages/workflow/test/node-reference-parser-utils.test.ts +++ b/packages/workflow/test/node-reference-parser-utils.test.ts @@ -196,7 +196,7 @@ describe('NodeReferenceParserUtils', () => { nodes = [makeNode('B', ['$("D")'])]; nodeNames = ['B', 'D']; - const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B'); + const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']); expect([...result.variables.entries()]).toEqual([]); expect(result.nodes).toEqual([ { @@ -210,7 +210,7 @@ describe('NodeReferenceParserUtils', () => { nodes = [makeNode('B', ['$("E").item.json.x'])]; nodeNames = ['B']; - const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B'); + const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']); expect([...result.variables.entries()]).toEqual([]); expect(result.nodes).toEqual([ { @@ -249,7 +249,7 @@ describe('NodeReferenceParserUtils', () => { nodes = [makeNode('B', ['$json.a.b.c_d["e"]["f"]']), makeNode('C', ['$json.x.y.z'])]; nodeNames = ['A', 'B', 'C']; - const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, 'B'); + const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['B']); expect([...result.variables.entries()]).toEqual([['a_b_c_d', '$json.a.b.c_d']]); expect(result.nodes).toEqual([ { @@ -262,6 +262,31 @@ describe('NodeReferenceParserUtils', () => { }, ]); }); + it('should handle complex $json case for first node', () => { + nodes = [ + { + parameters: { + p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json', + }, + name: 'A', + } as unknown as INode, + ]; + nodeNames = ['A', 'B']; + + const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']); + expect([...result.variables.entries()]).toEqual([ + ['repo', '$json.repo'], + ['org', '$json.org'], + ]); + expect(result.nodes).toEqual([ + { + name: 'A', + parameters: { + p0: '=https://raw.githubusercontent.com/{{ $json.org }}/{{ $json.repo }}/refs/heads/master/package.json', + }, + }, + ]); + }); it('should support different node accessor patterns', () => { nodes = [ makeNode('N', ['$("A").item.json.myField']), @@ -621,6 +646,56 @@ describe('NodeReferenceParserUtils', () => { }, ]); }); + it('should handle assignments format of Set node correctly', () => { + nodes = [ + { + parameters: { + assignments: { + assignments: [ + { + id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74', + name: 'ghApiBaseUrl', + value: '={{ $("A").item.json.x.y.z }}', + type: 'string', + }, + ], + }, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [80, 80], + id: '6e2fd284-2aba-4dee-8921-18be9a291484', + name: 'Params', + }, + ]; + nodeNames = ['A', 'Params']; + const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName); + expect([...result.variables.entries()]).toEqual([['x_y_z', '$("A").item.json.x.y.z']]); + expect(result.nodes).toEqual([ + { + parameters: { + assignments: { + assignments: [ + { + id: 'cf8bd6cb-f28a-4a73-b141-02e5c22cfe74', + name: 'ghApiBaseUrl', + value: "={{ $('Start').item.json.x_y_z }}", + type: 'string', + }, + ], + }, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [80, 80], + id: '6e2fd284-2aba-4dee-8921-18be9a291484', + name: 'Params', + }, + ]); + }); + it('should carry over unrelated properties', () => { nodes = [ { diff --git a/packages/workflow/test/workflow.test.ts b/packages/workflow/test/workflow.test.ts index 74445cf76f..67782bd68e 100644 --- a/packages/workflow/test/workflow.test.ts +++ b/packages/workflow/test/workflow.test.ts @@ -2418,4 +2418,227 @@ describe('Workflow', () => { expect(nodes).toHaveLength(0); }); }); + describe('getConnectionsBetweenNodes', () => { + test('should return empty array if no connections exist between sources and targets', () => { + const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set1']); + expect(result).toEqual([]); + }); + + test('should return connections between a single source and target', () => { + const result = SIMPLE_WORKFLOW.getConnectionsBetweenNodes(['Start'], ['Set']); + expect(result).toEqual([ + [ + { node: 'Start', index: 0, type: NodeConnectionTypes.Main }, + { node: 'Set', type: NodeConnectionTypes.Main, index: 0 }, + ], + ]); + }); + + test('should return connections between multiple sources and a single target', () => { + const connections: IConnections = { + Node1: { + main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]], + }, + Node2: { + main: [[{ node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'TargetNode', + name: 'TargetNode', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getConnectionsBetweenNodes(['Node1', 'Node2'], ['TargetNode']); + expect(result).toEqual([ + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }, + ], + [ + { node: 'Node2', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }, + ], + ]); + }); + + test('should return connections between a single source and multiple targets', () => { + const connections: IConnections = { + Node1: { + main: [ + [ + { node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 }, + ], + ], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'TargetNode1', + name: 'TargetNode1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'TargetNode2', + name: 'TargetNode2', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode1', 'TargetNode2']); + expect(result).toEqual([ + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode1', type: NodeConnectionTypes.Main, index: 0 }, + ], + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode2', type: NodeConnectionTypes.Main, index: 0 }, + ], + ]); + }); + + test('should handle workflows with multiple connection types', () => { + const connections: IConnections = { + Node1: { + main: [ + [ + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 }, + ], + ], + [NodeConnectionTypes.AiAgent]: [ + [{ node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 }], + ], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'TargetNode', + name: 'TargetNode', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']); + expect(result).toEqual([ + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 0 }, + ], + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.Main }, + { node: 'TargetNode', type: NodeConnectionTypes.Main, index: 1 }, + ], + [ + { node: 'Node1', index: 0, type: NodeConnectionTypes.AiAgent }, + { node: 'TargetNode', type: NodeConnectionTypes.AiAgent, index: 0 }, + ], + ]); + }); + + test('should handle nodes with no connections', () => { + const connections: IConnections = { + Node1: { + main: [[]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'TargetNode', + name: 'TargetNode', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getConnectionsBetweenNodes(['Node1'], ['TargetNode']); + expect(result).toEqual([]); + }); + }); });