diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 2598b3f0f9a92..b2d7830962773 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -1,5 +1,6 @@ import { v4 as uuid } from 'uuid'; import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; +import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; const workflowPage = new WorkflowPageClass(); const executionsTab = new WorkflowExecutionsTab(); @@ -409,5 +410,83 @@ describe('Execution', () => { .should('have.class', 'pinned') .should('have.class', 'has-run'); }); + + it('when connecting pinned node by output drag and drop', () => { + cy.drag( + workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), + [-200, -300], + ); + workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); + cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { + clickToFinish: true, + }); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('not.have.class', 'has-run'); + + workflowPage.actions.executeWorkflow(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + + cy.drag(workflowPage.getters.getEndpointSelector('output', 'Edit Fields2'), [-200, -300]); + workflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); + cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [150, 200], { + clickToFinish: true, + }); + + workflowPage.getters + .getConnectionBetweenNodes('Edit Fields2', 'Edit Fields11') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + }); + + it('when connecting pinned node after adding an unconnected node', () => { + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + + cy.draganddrop( + workflowPage.getters.getEndpointSelector('output', SCHEDULE_TRIGGER_NODE_NAME), + workflowPage.getters.getEndpointSelector('input', 'Edit Fields8'), + ); + workflowPage.getters.zoomToFitButton().click(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('not.have.class', 'has-run'); + + workflowPage.actions.executeWorkflow(); + + workflowPage.getters + .getConnectionBetweenNodes('Schedule Trigger', 'Edit Fields8') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + + workflowPage.actions.deselectAll(); + workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + workflowPage.getters.zoomToFitButton().click(); + + cy.draganddrop( + workflowPage.getters.getEndpointSelector('output', 'Edit Fields7'), + workflowPage.getters.getEndpointSelector('input', 'Edit Fields11'), + ); + + workflowPage.getters + .getConnectionBetweenNodes('Edit Fields7', 'Edit Fields11') + .should('have.class', 'success') + .should('have.class', 'pinned') + .should('have.class', 'has-run'); + }); }); }); diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index 45293cc7064ea..9b1379f8120d1 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -1,5 +1,9 @@ import { useHistoryStore } from '@/stores/history.store'; -import { CUSTOM_API_CALL_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME } from '@/constants'; +import { + CUSTOM_API_CALL_KEY, + NODE_OUTPUT_DEFAULT_KEY, + PLACEHOLDER_FILLED_AT_EXECUTION_TIME, +} from '@/constants'; import { NodeHelpers, NodeConnectionType, ExpressionEvaluatorProxy } from 'n8n-workflow'; import type { @@ -21,6 +25,7 @@ import type { INodePropertyOptions, INodeCredentialsDetails, INodeParameters, + ITaskData, } from 'n8n-workflow'; import type { @@ -43,6 +48,9 @@ import { EnableNodeToggleCommand } from '@/models/history'; import { useTelemetry } from './useTelemetry'; import { getCredentialPermissions } from '@/permissions'; import { hasPermission } from '@/rbac/permissions'; +import type { N8nPlusEndpoint } from '@/plugins/jsplumb/N8nPlusEndpointType'; +import * as NodeViewUtils from '@/utils/nodeViewUtils'; +import { useCanvasStore } from '@/stores/canvas.store'; declare namespace HttpRequestNode { namespace V2 { @@ -60,6 +68,7 @@ export function useNodeHelpers() { const nodeTypesStore = useNodeTypesStore(); const workflowsStore = useWorkflowsStore(); const i18n = useI18n(); + const canvasStore = useCanvasStore(); function hasProxyAuth(node: INodeUi): boolean { return Object.keys(node.parameters).includes('nodeCredentialType'); @@ -700,6 +709,73 @@ export function useNodeHelpers() { return undefined; } + function setSuccessOutput(data: ITaskData[], sourceNode: INodeUi | null) { + if (!sourceNode) { + throw new Error('Source node is null or not defined'); + } + + const allNodeConnections = workflowsStore.outgoingConnectionsByNodeName(sourceNode.name); + + const connectionType = Object.keys(allNodeConnections)[0]; + const nodeConnections = allNodeConnections[connectionType]; + const outputMap = NodeViewUtils.getOutputSummary( + data, + nodeConnections || [], + (connectionType as ConnectionTypes) ?? NodeConnectionType.Main, + ); + const sourceNodeType = nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion); + + Object.keys(outputMap).forEach((sourceOutputIndex: string) => { + Object.keys(outputMap[sourceOutputIndex]).forEach((targetNodeName: string) => { + Object.keys(outputMap[sourceOutputIndex][targetNodeName]).forEach( + (targetInputIndex: string) => { + if (targetNodeName) { + const targetNode = workflowsStore.getNodeByName(targetNodeName); + const connection = NodeViewUtils.getJSPlumbConnection( + sourceNode, + parseInt(sourceOutputIndex, 10), + targetNode, + parseInt(targetInputIndex, 10), + connectionType as ConnectionTypes, + sourceNodeType, + canvasStore.jsPlumbInstance, + ); + + if (connection) { + const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]; + + if (output.isArtificialRecoveredEventItem) { + NodeViewUtils.recoveredConnection(connection); + } else if (!output?.total && !output.isArtificialRecoveredEventItem) { + NodeViewUtils.resetConnection(connection); + } else { + NodeViewUtils.addConnectionOutputSuccess(connection, output); + } + } + } + + const endpoint = NodeViewUtils.getPlusEndpoint( + sourceNode, + parseInt(sourceOutputIndex, 10), + canvasStore.jsPlumbInstance, + ); + if (endpoint?.endpoint) { + const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0]; + + if (output && output.total > 0) { + (endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput( + NodeViewUtils.getRunItemsLabel(output), + ); + } else { + (endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput(); + } + } + }, + ); + }); + }); + } + return { hasProxyAuth, isCustomApiCallSelected, @@ -718,5 +794,6 @@ export function useNodeHelpers() { getNodeSubtitle, updateNodesCredentialsIssues, getNodeInputData, + setSuccessOutput, }; } diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index 7d57444fd4011..15fc1e7384654 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -13,6 +13,7 @@ import type { INodeTypeDescription, } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; +import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui'; import { EVENT_CONNECTION_MOUSEOUT, EVENT_CONNECTION_MOUSEOVER } from '@jsplumb/browser-ui'; import { useUIStore } from '@/stores/ui.store'; @@ -1083,3 +1084,63 @@ export function isElementIntersection( return isWithinVerticalBounds && isWithinHorizontalBounds; } + +export const getJSPlumbEndpoints = ( + node: INodeUi | null, + instance: BrowserJsPlumbInstance, +): Endpoint[] => { + const nodeEl = instance.getManagedElement(node?.id); + + const endpoints = instance?.getEndpoints(nodeEl); + return endpoints; +}; + +export const getPlusEndpoint = ( + node: INodeUi | null, + outputIndex: number, + instance: BrowserJsPlumbInstance, +): Endpoint | undefined => { + const endpoints = getJSPlumbEndpoints(node, instance); + return endpoints.find( + (endpoint: Endpoint) => + // @ts-ignore + endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex, + ); +}; + +export const getJSPlumbConnection = ( + sourceNode: INodeUi | null, + sourceOutputIndex: number, + targetNode: INodeUi | null, + targetInputIndex: number, + connectionType: ConnectionTypes, + sourceNodeType: INodeTypeDescription | null, + instance: BrowserJsPlumbInstance, +): Connection | undefined => { + if (!sourceNode || !targetNode) { + return; + } + + const sourceId = sourceNode.id; + const targetId = targetNode.id; + + const sourceEndpoint = getOutputEndpointUUID(sourceId, connectionType, sourceOutputIndex); + const targetEndpoint = getInputEndpointUUID(targetId, connectionType, targetInputIndex); + + const sourceNodeOutput = sourceNodeType?.outputs?.[sourceOutputIndex] || NodeConnectionType.Main; + const sourceNodeOutputName = + typeof sourceNodeOutput === 'string' ? sourceNodeOutput : sourceNodeOutput.name; + const scope = getEndpointScope(sourceNodeOutputName); + + // @ts-ignore + const connections = instance?.getConnections({ + scope, + source: sourceId, + target: targetId, + }) as Connection[]; + + return connections.find((connection: Connection) => { + const uuids = connection.getUuids(); + return uuids[0] === sourceEndpoint && uuids[1] === targetEndpoint; + }); +}; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index e611064c152fa..3c76b735368f2 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -223,7 +223,6 @@ import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRM, - NODE_OUTPUT_DEFAULT_KEY, ONBOARDING_CALL_SIGNUP_MODAL_KEY, ONBOARDING_PROMPT_TIMEBOX, PLACEHOLDER_EMPTY_WORKFLOW_ID, @@ -3625,7 +3624,7 @@ export default defineComponent({ }); setTimeout(() => { - this.addPinDataConnections(this.workflowsStore.pinData); + this.addPinDataConnections(this.workflowsStore.pinnedWorkflowData || ({} as IPinData)); }); }, __removeConnection(connection: [IConnection, IConnection], removeVisualConnection = false) { @@ -3714,70 +3713,6 @@ export default defineComponent({ const workflowData = deepCopy(await this.getNodesToSave(nodes)); await this.importWorkflowData(workflowData, 'duplicate', false); }, - getJSPlumbConnection( - sourceNodeName: string, - sourceOutputIndex: number, - targetNodeName: string, - targetInputIndex: number, - connectionType: ConnectionTypes, - ): Connection | undefined { - const sourceNode = this.workflowsStore.getNodeByName(sourceNodeName); - const targetNode = this.workflowsStore.getNodeByName(targetNodeName); - if (!sourceNode || !targetNode) { - return; - } - - const sourceId = sourceNode.id; - const targetId = targetNode.id; - - const sourceEndpoint = NodeViewUtils.getOutputEndpointUUID( - sourceId, - connectionType, - sourceOutputIndex, - ); - const targetEndpoint = NodeViewUtils.getInputEndpointUUID( - targetId, - connectionType, - targetInputIndex, - ); - - const sourceNodeType = this.nodeTypesStore.getNodeType( - sourceNode.type, - sourceNode.typeVersion, - ); - const sourceNodeOutput = - sourceNodeType?.outputs?.[sourceOutputIndex] || NodeConnectionType.Main; - const sourceNodeOutputName = - typeof sourceNodeOutput === 'string' ? sourceNodeOutput : sourceNodeOutput.name; - const scope = NodeViewUtils.getEndpointScope(sourceNodeOutputName); - - // @ts-ignore - const connections = this.instance?.getConnections({ - scope, - source: sourceId, - target: targetId, - }) as Connection[]; - - return connections.find((connection: Connection) => { - const uuids = connection.getUuids(); - return uuids[0] === sourceEndpoint && uuids[1] === targetEndpoint; - }); - }, - getJSPlumbEndpoints(nodeName: string): Endpoint[] { - const node = this.workflowsStore.getNodeByName(nodeName); - const nodeEl = this.instance.getManagedElement(node?.id); - - const endpoints = this.instance?.getEndpoints(nodeEl); - return endpoints; - }, - getPlusEndpoint(nodeName: string, outputIndex: number): Endpoint | undefined { - const endpoints = this.getJSPlumbEndpoints(nodeName); - return endpoints.find( - (endpoint: Endpoint) => - // @ts-ignore - endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex, - ); - }, getIncomingOutgoingConnections(nodeName: string): { incoming: Connection[]; outgoing: Connection[]; @@ -3850,7 +3785,7 @@ export default defineComponent({ outgoing.forEach((connection: Connection) => { NodeViewUtils.resetConnection(connection); }); - const endpoints = this.getJSPlumbEndpoints(sourceNodeName); + const endpoints = NodeViewUtils.getJSPlumbEndpoints(sourceNode, this.instance); endpoints.forEach((endpoint: Endpoint) => { if (endpoint.endpoint.type === 'N8nPlus') { (endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput(); @@ -3860,60 +3795,7 @@ export default defineComponent({ return; } - const allNodeConnections = this.workflowsStore.outgoingConnectionsByNodeName(sourceNodeName); - - const connectionType = Object.keys(allNodeConnections)[0]; - const nodeConnections = allNodeConnections[connectionType]; - const outputMap = NodeViewUtils.getOutputSummary( - data, - nodeConnections || [], - (connectionType as ConnectionTypes) ?? NodeConnectionType.Main, - ); - Object.keys(outputMap).forEach((sourceOutputIndex: string) => { - Object.keys(outputMap[sourceOutputIndex]).forEach((targetNodeName: string) => { - Object.keys(outputMap[sourceOutputIndex][targetNodeName]).forEach( - (targetInputIndex: string) => { - if (targetNodeName) { - const connection = this.getJSPlumbConnection( - sourceNodeName, - parseInt(sourceOutputIndex, 10), - targetNodeName, - parseInt(targetInputIndex, 10), - connectionType as ConnectionTypes, - ); - - if (connection) { - const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex]; - - if (output.isArtificialRecoveredEventItem) { - NodeViewUtils.recoveredConnection(connection); - } else if (!output?.total && !output.isArtificialRecoveredEventItem) { - NodeViewUtils.resetConnection(connection); - } else { - NodeViewUtils.addConnectionOutputSuccess(connection, output); - } - } - } - - const endpoint = this.getPlusEndpoint( - sourceNodeName, - parseInt(sourceOutputIndex, 10), - ); - if (endpoint?.endpoint) { - const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0]; - - if (output && output.total > 0) { - (endpoint.endpoint as N8nPlusEndpoint).setSuccessOutput( - NodeViewUtils.getRunItemsLabel(output), - ); - } else { - (endpoint.endpoint as N8nPlusEndpoint).clearSuccessOutput(); - } - } - }, - ); - }); - }); + this.nodeHelpers.setSuccessOutput(data, sourceNode); }, removeNode(nodeName: string, trackHistory = false, trackBulk = true) { if (!this.editAllowedCheck()) { @@ -4714,6 +4596,13 @@ export default defineComponent({ return; } + const hasRun = this.workflowsStore.getWorkflowResultDataByNodeName(nodeName) !== null; + const classNames = ['pinned']; + + if (hasRun) { + classNames.push('has-run'); + } + // @ts-ignore const connections = this.instance?.getConnections({ source: node.id, @@ -4723,7 +4612,7 @@ export default defineComponent({ NodeViewUtils.addConnectionOutputSuccess(connection, { total: pinData[nodeName].length, iterations: 0, - classNames: ['pinned'], + classNames, }); }); }); @@ -4836,6 +4725,8 @@ export default defineComponent({ }); }); } + + this.addPinDataConnections(this.workflowsStore.pinnedWorkflowData || ({} as IPinData)); }, async saveCurrentWorkflowExternal(callback: () => void) {