From 655efeaf669e9722895b66fef47f000507459210 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 30 Oct 2023 18:42:47 +0100 Subject: [PATCH] feat(core): Add optional Error-Output (#7460) Add an additional optional error output to which all items get sent that could not be processed. ![Screenshot from 2023-10-18 17-29-15](https://github.com/n8n-io/n8n/assets/6249596/e9732807-ab2b-4662-a5f6-bdff24f7ad55) Github issue / Community forum post (link here to close automatically): https://community.n8n.io/t/error-connector-for-nodes/3094 https://community.n8n.io/t/error-handling-at-node-level-detect-node-execution-status/26791 --------- Co-authored-by: OlegIvaniv --- .../handlers/workflows/spec/schemas/node.yml | 6 +- packages/core/src/NodeExecuteFunctions.ts | 8 +- packages/core/src/WorkflowExecute.ts | 117 ++- .../core/test/workflows/error_outputs.json | 734 ++++++++++++++++++ packages/editor-ui/src/components/Node.vue | 20 +- .../src/components/NodeDetailsView.vue | 5 +- .../editor-ui/src/components/NodeSettings.vue | 56 +- packages/editor-ui/src/mixins/nodeBase.ts | 63 +- .../editor-ui/src/mixins/workflowHelpers.ts | 7 +- packages/editor-ui/src/n8n-theme.scss | 1 + .../src/plugins/i18n/locales/en.json | 10 +- .../plugins/jsplumb/N8nPlusEndpointType.ts | 9 +- packages/editor-ui/src/utils/nodeViewUtils.ts | 31 +- packages/editor-ui/src/views/NodeView.vue | 12 +- packages/nodes-base/nodes/Code/Code.node.ts | 7 +- packages/nodes-base/nodes/Code/Sandbox.ts | 2 +- packages/workflow/src/Interfaces.ts | 8 + packages/workflow/src/NodeHelpers.ts | 49 +- packages/workflow/src/RoutingNode.ts | 5 +- packages/workflow/src/WorkflowDataProxy.ts | 1 + 20 files changed, 1090 insertions(+), 61 deletions(-) create mode 100644 packages/core/test/workflows/error_outputs.json diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml index b05a105b90..f22511bfe8 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/schemas/node.yml @@ -34,9 +34,9 @@ properties: type: number waitBetweenTries: type: number - continueOnFail: - type: boolean - example: false + onError: + type: string + example: 'stopWorkflow' position: type: array items: diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 8bca9abe02..95b92bb3a1 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2289,7 +2289,13 @@ export function getNodeParameter( * */ export function continueOnFail(node: INode): boolean { - return get(node, 'continueOnFail', false); + const onError = get(node, 'onError', undefined); + + if (onError === undefined) { + return get(node, 'continueOnFail', false); + } + + return ['continueRegularOutput', 'continueErrorOutput'].includes(onError); } /** diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 421f1bc06e..aa63280151 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -9,6 +9,7 @@ import PCancelable from 'p-cancelable'; import type { ExecutionError, ExecutionStatus, + GenericValue, IConnection, IDataObject, IExecuteData, @@ -36,6 +37,8 @@ import { IRunExecutionData, IWorkflowExecuteAdditionalData, WorkflowExecuteMode, + NodeHelpers, + NodeConnectionType, } from 'n8n-workflow'; import get from 'lodash/get'; import * as NodeExecuteFunctions from './NodeExecuteFunctions'; @@ -1041,6 +1044,7 @@ export class WorkflowExecute { node: executionNode.name, workflowId: workflow.id, }); + const runNodeData = await workflow.runNode( executionData, this.runExecutionData, @@ -1051,6 +1055,112 @@ export class WorkflowExecute { ); nodeSuccessData = runNodeData.data; + if (nodeSuccessData && executionData.node.onError === 'continueErrorOutput') { + // If errorOutput is activated check all the output items for error data. + // If any is found, route them to the last output as that will be the + // error output. + + const nodeType = workflow.nodeTypes.getByNameAndVersion( + executionData.node.type, + executionData.node.typeVersion, + ); + const outputs = NodeHelpers.getNodeOutputs( + workflow, + executionData.node, + nodeType.description, + ); + const outputTypes = NodeHelpers.getConnectionTypes(outputs); + const mainOutputTypes = outputTypes.filter( + (output) => output === NodeConnectionType.Main, + ); + + const errorItems: INodeExecutionData[] = []; + const successItems: INodeExecutionData[] = []; + + // Create a WorkflowDataProxy instance that we can get the data of the + // item which did error + const executeFunctions = NodeExecuteFunctions.getExecuteFunctions( + workflow, + this.runExecutionData, + runIndex, + [], + executionData.data, + executionData.node, + this.additionalData, + executionData, + this.mode, + ); + const dataProxy = executeFunctions.getWorkflowDataProxy(0); + + // Loop over all outputs except the error output as it would not contain data by default + for ( + let outputIndex = 0; + outputIndex < mainOutputTypes.length - 1; + outputIndex++ + ) { + successItems.length = 0; + const items = nodeSuccessData.length ? nodeSuccessData[0] : []; + + while (items.length) { + const item = items.pop(); + if (item === undefined) { + continue; + } + + let errorData: GenericValue | undefined; + if (item.error) { + errorData = item.error; + item.error = undefined; + } else if (item.json.error && Object.keys(item.json).length === 1) { + errorData = item.json.error; + } + + if (errorData) { + const pairedItemData = + item.pairedItem && typeof item.pairedItem === 'object' + ? Array.isArray(item.pairedItem) + ? item.pairedItem[0] + : item.pairedItem + : undefined; + + if (executionData!.source === null || pairedItemData === undefined) { + // Source data is missing for some reason so we can not figure out the item + errorItems.push(item); + } else { + const pairedItemInputIndex = pairedItemData.input || 0; + + const sourceData = + executionData!.source[NodeConnectionType.Main][pairedItemInputIndex]; + + const constPairedItem = dataProxy.$getPairedItem( + sourceData!.previousNode, + sourceData, + pairedItemData, + ); + + if (constPairedItem === null) { + errorItems.push(item); + } else { + errorItems.push({ + ...item, + json: { + ...constPairedItem.json, + ...item.json, + }, + }); + } + } + } else { + successItems.push(item); + } + } + + nodeSuccessData[outputIndex] = successItems; + } + + nodeSuccessData[mainOutputTypes.length - 1] = errorItems; + } + if (runNodeData.closeFunction) { // Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode @@ -1180,7 +1290,12 @@ export class WorkflowExecute { taskData.error = executionError; taskData.executionStatus = 'error'; - if (executionData.node.continueOnFail === true) { + if ( + executionData.node.continueOnFail === true || + ['continueRegularOutput', 'continueErrorOutput'].includes( + executionData.node.onError || '', + ) + ) { // Workflow should continue running even if node errors if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) { // Simply get the input data of the node if it has any and pass it through diff --git a/packages/core/test/workflows/error_outputs.json b/packages/core/test/workflows/error_outputs.json new file mode 100644 index 0000000000..f8e698bd52 --- /dev/null +++ b/packages/core/test/workflows/error_outputs.json @@ -0,0 +1,734 @@ +{ + "name": "Error Output - Test Workflow", + "nodes": [ + { + "parameters": {}, + "id": "c41b46f0-3e76-4655-b5ea-4d15af58c138", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [-680, 460] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "247f4118-d80f-49ab-8d9a-0cdbbb9271df", + "name": "Success", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [200, 860] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "311e3650-d89c-405a-9c8d-c238f48a8a5a", + "name": "Error", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [200, 1040] + }, + { + "parameters": { + "jsCode": "return [\n {\n \"id\": \"23423532\",\n \"name\": \"Jay Gatsby\",\n \"email\": \"gatsby@west-egg.com\",\n \"notes\": \"Keeps asking about a green light??\",\n \"country\": \"US\",\n \"created\": \"1925-04-10\"\n },\n {\n \"id\": \"23423533\",\n \"name\": \"José Arcadio Buendía\",\n \"email\": \"jab@macondo.co\",\n \"notes\": \"Lots of people named after him. Very confusing\",\n \"country\": \"CO\",\n \"created\": \"1967-05-05\"\n },\n {\n \"id\": \"23423534\",\n \"name\": \"Max Sendak\",\n \"email\": \"info@in-and-out-of-weeks.org\",\n \"notes\": \"Keeps rolling his terrible eyes\",\n \"country\": \"US\",\n \"created\": \"1963-04-09\"\n },\n {\n \"id\": \"23423535\",\n \"name\": \"Zaphod Beeblebrox\",\n \"email\": \"captain@heartofgold.com\",\n \"notes\": \"Felt like I was talking to more than one person\",\n \"country\": null,\n \"created\": \"1979-10-12\"\n },\n {\n \"id\": \"23423536\",\n \"name\": \"Edmund Pevensie\",\n \"email\": \"edmund@narnia.gov\",\n \"notes\": \"Passionate sailor\",\n \"country\": \"UK\",\n \"created\": \"1950-10-16\"\n }\n]" + }, + "id": "179d4fe7-1ae7-4957-a77d-12c3ca6d141b", + "name": "Mock Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-460, 460] + }, + { + "parameters": { + "content": "## On Error: Continue (using error output)", + "height": 414, + "width": 564 + }, + "id": "1ec2a8b6-54e2-4319-90b3-30b387855b36", + "name": "Sticky Note", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [-160, 780] + }, + { + "parameters": { + "content": "## Continue On Fail (deprecated)", + "height": 279, + "width": 564 + }, + "id": "49a2b7d9-8bd1-4cdf-9649-2d93668b0f8f", + "name": "Sticky Note1", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [-160, 140] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "9852f1d9-95b4-4ef7-bb18-8f0bab81a0bc", + "name": "Combined", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [180, 240] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;" + }, + "id": "40d4dba3-3db7-4eb5-aa27-e76f955a5e09", + "name": "Throw Error", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-140, 960], + "errorOutput": true, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "content": "## On Error: Continue", + "height": 279, + "width": 564 + }, + "id": "8eb3dd54-c1dd-4167-abfa-c06d044c63f3", + "name": "Sticky Note2", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [-160, 460] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;" + }, + "id": "19a3d6ac-e610-4296-9b7a-9ed19d242bdb", + "name": "Throw Error2", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-120, 560], + "onError": "continueRegularOutput" + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "5f803fdc-7d88-4c12-8886-6092cfbc03c6", + "name": "Combined1", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [180, 560] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;" + }, + "id": "c2696c1f-1abd-4549-9ad9-e62017dc14b8", + "name": "Throw Error1", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-120, 240], + "continueOnFail": true + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "01740d7e-e572-408a-9fae-729068803113", + "name": "Success1", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [200, 1320] + }, + { + "parameters": { + "content": "## On Error: Continue (using error output) + Make sure error data gets removed", + "height": 509.71047006830065, + "width": 1183.725293692246 + }, + "id": "ed409181-4847-4d65-af45-f45078a6343e", + "name": "Sticky Note3", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [-160, 1240] + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "// Add a new field called 'myNewField' to the JSON of the item\n$input.item.json.myNewField = 1;\n\nif ($input.item.json.country === 'US') {\n throw new Error('This is an error');\n}\n\nreturn $input.item;" + }, + "id": "93d03f38-b928-4b4b-832a-3f1a5deebb2d", + "name": "Throw Error3", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-140, 1420], + "errorOutput": true, + "onError": "continueErrorOutput" + }, + { + "parameters": { + "options": {} + }, + "id": "c92a6ce5-41ea-4fb9-a07b-c4e98f905b12", + "name": "Edit Fields", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [420, 1500], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "ab838cc1-0987-4b41-bdc5-fe17f38e0691", + "name": "Success2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [700, 1360] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "22e04172-19b9-4735-9dd0-a3e2fa3bf000", + "name": "Error2", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [700, 1580] + }, + { + "parameters": { + "fields": { + "values": [ + { + "name": "originalName", + "stringValue": "={{ $('Mock Data').item.json.name }}" + } + ] + }, + "options": {} + }, + "id": "69e7257a-1ba8-46ba-9394-d38d65b2e567", + "name": "Error1", + "type": "n8n-nodes-base.set", + "typeVersion": 3.2, + "position": [200, 1500] + } + ], + "pinData": { + "Error": [ + { + "json": { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09", + "error": "This is an error [line 5, for item 2]", + "originalName": "Max Sendak" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10", + "error": "This is an error [line 5, for item 0]", + "originalName": "Jay Gatsby" + }, + "pairedItem": { + "item": 1 + } + } + ], + "Success": [ + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16", + "myNewField": 1, + "originalName": "Edmund Pevensie" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12", + "myNewField": 1, + "originalName": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05", + "myNewField": 1, + "originalName": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 2 + } + } + ], + "Combined": [ + { + "json": { + "error": "This is an error [line 5, for item 0]", + "originalName": "Jay Gatsby" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05", + "myNewField": 1, + "originalName": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "error": "This is an error [line 5, for item 2]", + "originalName": "Max Sendak" + }, + "pairedItem": { + "item": 2 + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12", + "myNewField": 1, + "originalName": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 3 + } + }, + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16", + "myNewField": 1, + "originalName": "Edmund Pevensie" + }, + "pairedItem": { + "item": 4 + } + } + ], + "Combined1": [ + { + "json": { + "error": "This is an error [line 5, for item 0]", + "originalName": "Jay Gatsby" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05", + "myNewField": 1, + "originalName": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "error": "This is an error [line 5, for item 2]", + "originalName": "Max Sendak" + }, + "pairedItem": { + "item": 2 + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12", + "myNewField": 1, + "originalName": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 3 + } + }, + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16", + "myNewField": 1, + "originalName": "Edmund Pevensie" + }, + "pairedItem": { + "item": 4 + } + } + ], + "Success1": [ + { + "json": { + "id": "23423536", + "name": "Edmund Pevensie", + "email": "edmund@narnia.gov", + "notes": "Passionate sailor", + "country": "UK", + "created": "1950-10-16", + "myNewField": 1, + "originalName": "Edmund Pevensie" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423535", + "name": "Zaphod Beeblebrox", + "email": "captain@heartofgold.com", + "notes": "Felt like I was talking to more than one person", + "country": null, + "created": "1979-10-12", + "myNewField": 1, + "originalName": "Zaphod Beeblebrox" + }, + "pairedItem": { + "item": 1 + } + }, + { + "json": { + "id": "23423533", + "name": "José Arcadio Buendía", + "email": "jab@macondo.co", + "notes": "Lots of people named after him. Very confusing", + "country": "CO", + "created": "1967-05-05", + "myNewField": 1, + "originalName": "José Arcadio Buendía" + }, + "pairedItem": { + "item": 2 + } + } + ], + "Error1": [ + { + "json": { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09", + "error": "This is an error [line 5, for item 2]", + "originalName": "Max Sendak" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10", + "error": "This is an error [line 5, for item 0]", + "originalName": "Jay Gatsby" + }, + "pairedItem": { + "item": 1 + } + } + ], + "Success2": [ + { + "json": { + "id": "23423532", + "name": "Jay Gatsby", + "email": "gatsby@west-egg.com", + "notes": "Keeps asking about a green light??", + "country": "US", + "created": "1925-04-10", + "error": "This is an error [line 5, for item 0]", + "originalName": "Jay Gatsby" + }, + "pairedItem": { + "item": 0 + } + }, + { + "json": { + "id": "23423534", + "name": "Max Sendak", + "email": "info@in-and-out-of-weeks.org", + "notes": "Keeps rolling his terrible eyes", + "country": "US", + "created": "1963-04-09", + "error": "This is an error [line 5, for item 2]", + "originalName": "Max Sendak" + }, + "pairedItem": { + "item": 1 + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Mock Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mock Data": { + "main": [ + [ + { + "node": "Throw Error", + "type": "main", + "index": 0 + }, + { + "node": "Throw Error2", + "type": "main", + "index": 0 + }, + { + "node": "Throw Error1", + "type": "main", + "index": 0 + }, + { + "node": "Throw Error3", + "type": "main", + "index": 0 + } + ] + ] + }, + "Throw Error": { + "main": [ + [ + { + "node": "Success", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error", + "type": "main", + "index": 0 + } + ] + ] + }, + "Throw Error2": { + "main": [ + [ + { + "node": "Combined1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Throw Error1": { + "main": [ + [ + { + "node": "Combined", + "type": "main", + "index": 0 + } + ] + ] + }, + "Throw Error3": { + "main": [ + [ + { + "node": "Success1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields": { + "main": [ + [ + { + "node": "Success2", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Error2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Error1": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "e73e1eda-293c-4ee2-87b9-923873241774", + "id": "UgoluWRMeg7fPLCB", + "meta": { + "instanceId": "021d3c82ba2d3bc090cbf4fc81c9312668bcc34297e022bb3438c5c88a43a5ff" + }, + "tags": [] +} diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 1b7c70ede5..5f3cd36f2c 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -1297,6 +1297,15 @@ export default defineComponent({ stroke: var(--color-foreground-xdark); } + &.error { + path { + fill: var(--node-error-output-color); + } + rect { + stroke: var(--node-error-output-color); + } + } + &.small { margin-left: calc((var(--stalk-size) + var(--plus-endpoint-box-size-small) / 2)); g { @@ -1427,6 +1436,10 @@ export default defineComponent({ } } +.node-output-endpoint-label.node-connection-category-error { + color: var(--node-error-output-color); +} + .node-output-endpoint-label { margin-left: calc(var(--endpoint-size-small) + var(--spacing-2xs)); @@ -1436,9 +1449,9 @@ export default defineComponent({ margin-left: 0; } - // Switch node allows for dynamic connection labels + // Some nodes allow for dynamic connection labels // so we need to make sure the label does not overflow - &[data-endpoint-node-type='n8n-nodes-base.switch'] { + &[data-endpoint-label-length='medium'] { max-width: calc(var(--stalk-size) - (var(--endpoint-size-small))); overflow: hidden; text-overflow: ellipsis; @@ -1495,7 +1508,8 @@ export default defineComponent({ .ep-success--without-label { --stalk-size: var(--stalk-success-size-without-label); } -[data-endpoint-node-type='n8n-nodes-base.switch'] { + +[data-endpoint-label-length='medium'] { --stalk-size: var(--stalk-switch-size); } diff --git a/packages/editor-ui/src/components/NodeDetailsView.vue b/packages/editor-ui/src/components/NodeDetailsView.vue index 3eff69140f..1585bf4171 100644 --- a/packages/editor-ui/src/components/NodeDetailsView.vue +++ b/packages/editor-ui/src/components/NodeDetailsView.vue @@ -106,6 +106,7 @@ @valueChanged="valueChanged" @execute="onNodeExecute" @stopExecution="onStopExecution" + @redrawRequired="redrawRequired = true" @activate="onWorkflowActivate" /> { + let maxLabelLength = 0; + const outputConfigurations: INodeOutputConfiguration[] = []; + this.outputs.forEach((value, i) => { let outputConfiguration: INodeOutputConfiguration; if (typeof value === 'string') { outputConfiguration = { @@ -354,6 +355,24 @@ export const nodeBase = defineComponent({ } else { outputConfiguration = value; } + if (nodeTypeData.outputNames?.[i]) { + outputConfiguration.displayName = nodeTypeData.outputNames[i]; + } + + if (outputConfiguration.displayName) { + maxLabelLength = + outputConfiguration.displayName.length > maxLabelLength + ? outputConfiguration.displayName.length + : maxLabelLength; + } + + outputConfigurations.push(outputConfiguration); + }); + + const endpointLabelLength = maxLabelLength < 4 ? 'short' : 'medium'; + + this.outputs.forEach((value, i) => { + const outputConfiguration = outputConfigurations[i]; const outputName: ConnectionTypes = outputConfiguration.type; @@ -376,7 +395,7 @@ export const nodeBase = defineComponent({ const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName]; const typeIndex = typeIndexData[outputName]; - const outputsOfSameRootType = outputs.filter((outputData) => { + const outputsOfSameRootType = this.outputs.filter((outputData) => { const thisOutputName: string = typeof outputData === 'string' ? outputData : outputData.type; return outputName === NodeConnectionType.Main @@ -417,7 +436,7 @@ export const nodeBase = defineComponent({ hoverClass: 'dot-output-endpoint-hover', connectionsDirected: true, dragAllowedWhenFull: false, - ...this.__getOutputConnectionStyle(outputName, nodeTypeData), + ...this.__getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData), }; const endpoint = this.instance.addEndpoint( @@ -426,11 +445,12 @@ export const nodeBase = defineComponent({ ); this.__addEndpointTestingData(endpoint, 'output', typeIndex); - if (outputConfiguration.displayName || nodeTypeData.outputNames?.[i]) { + if (outputConfiguration.displayName) { // Apply output names if they got set const overlaySpec = NodeViewUtils.getOutputNameOverlay( - outputConfiguration.displayName || nodeTypeData.outputNames[i], + outputConfiguration.displayName, outputName, + outputConfiguration?.category, ); endpoint.addOverlay(overlaySpec); } @@ -441,7 +461,7 @@ export const nodeBase = defineComponent({ nodeId: this.nodeId, index: typeIndex, totalEndpoints: outputsOfSameRootType.length, - nodeType: node.type, + endpointLabelLength, }; } @@ -455,9 +475,9 @@ export const nodeBase = defineComponent({ options: { dimensions: 24, connectedEndpoint: endpoint, - showOutputLabel: outputs.length === 1, - size: outputs.length >= 3 ? 'small' : 'medium', - nodeType: node.type, + showOutputLabel: this.outputs.length === 1, + size: this.outputs.length >= 3 ? 'small' : 'medium', + endpointLabelLength, hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'), }, }, @@ -475,10 +495,16 @@ export const nodeBase = defineComponent({ nodeId: this.nodeId, type: outputName, index: typeIndex, + category: outputConfiguration?.category, }, cssClass: 'plus-draggable-endpoint', dragAllowedWhenFull: false, }; + + if (outputConfiguration?.category) { + plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`; + } + const plusEndpoint = this.instance.addEndpoint( this.$refs[this.data.name] as Element, plusEndpointData, @@ -538,6 +564,7 @@ export const nodeBase = defineComponent({ }, __getOutputConnectionStyle( connectionType: ConnectionTypes, + outputConfiguration: INodeOutputConfiguration, nodeTypeData: INodeTypeDescription, ): EndpointOptions { const type = 'output'; @@ -557,6 +584,18 @@ export const nodeBase = defineComponent({ }); if (connectionType === NodeConnectionType.Main) { + if (outputConfiguration.category === 'error') { + return { + paintStyle: { + ...NodeViewUtils.getOutputEndpointStyle( + nodeTypeData, + this.__getEndpointColor(NodeConnectionType.Main), + ), + fill: getStyleTokenValue('--node-error-output-color', true), + }, + cssClass: `dot-${type}-endpoint`, + }; + } return { paintStyle: NodeViewUtils.getOutputEndpointStyle( nodeTypeData, diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index 388de5ac30..b695a06e0c 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -649,6 +649,7 @@ export const workflowHelpers = defineComponent({ 'credentials', 'disabled', 'issues', + 'onError', 'notes', 'parameters', 'status', @@ -725,14 +726,16 @@ export const workflowHelpers = defineComponent({ } } - // Save the disabled property and continueOnFail only when is set + // Save the disabled property, continueOnFail and onError only when is set if (node.disabled === true) { nodeData.disabled = true; } if (node.continueOnFail === true) { nodeData.continueOnFail = true; } - + if (node.onError !== 'stopWorkflow') { + nodeData.onError = node.onError; + } // Save the notes only if when they contain data if (![undefined, ''].includes(node.notes)) { nodeData.notes = node.notes; diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index b05f1d20b8..2aaf36c07a 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -174,6 +174,7 @@ var(--node-type-ai_vectorStore-color-s), var(--node-type-background-l) ); + --node-error-output-color: #991818; } .clickable { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 7e2c183dd7..5d5aa38746 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -941,8 +941,14 @@ "nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.", "nodeSettings.alwaysOutputData.displayName": "Always Output Data", "nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io", - "nodeSettings.continueOnFail.description": "If active, the workflow continues even if this node's execution fails. When this occurs, the node passes along input data from previous nodes - so your workflow should account for unexpected output data.", - "nodeSettings.continueOnFail.displayName": "Continue On Fail", + "nodeSettings.onError.description": "Action to take when the node execution fails", + "nodeSettings.onError.displayName": "On Error", + "nodeSettings.onError.options.continueRegularOutput.description": "Pass error message as item in regular output", + "nodeSettings.onError.options.continueRegularOutput.displayName": "Continue", + "nodeSettings.onError.options.continueErrorOutput.description": "Pass item to an extra `error` output", + "nodeSettings.onError.options.continueErrorOutput.displayName": "Continue (using error output)", + "nodeSettings.onError.options.stopWorkflow.description": "Halt execution and fail workflow", + "nodeSettings.onError.options.stopWorkflow.displayName": "Stop Workflow", "nodeSettings.docs": "Docs", "nodeSettings.executeOnce.description": "If active, the node executes only once, with data from the first item it receives", "nodeSettings.executeOnce.displayName": "Execute Once", diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts index e7928ee2ed..917a5d3036 100644 --- a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts +++ b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts @@ -14,7 +14,7 @@ interface N8nPlusEndpointParams extends EndpointRepresentationParams { dimensions: number; connectedEndpoint: Endpoint; hoverMessage: string; - nodeType: string; + endpointLabelLength: 'small' | 'medium'; size: 'small' | 'medium'; showOutputLabel: boolean; } @@ -55,7 +55,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation { const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`); @@ -69,7 +69,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation { const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`); @@ -199,7 +199,8 @@ export const N8nPlusEndpointHandler: EndpointHandler { +export const getConnectorColor = (type: ConnectionTypes, category?: string): string => { + if (category === 'error') { + return '--node-error-output-color'; + } + if (type === NodeConnectionType.Main) { return '--node-type-main-color'; } @@ -121,7 +125,10 @@ export const getConnectorColor = (type: ConnectionTypes): string => { }; export const getConnectorPaintStylePull = (connection: Connection): PaintStyle => { - const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes); + const connectorColor = getConnectorColor( + connection.parameters.type as ConnectionTypes, + connection.parameters.category, + ); const additionalStyles: PaintStyle = {}; if (connection.parameters.type !== NodeConnectionType.Main) { additionalStyles.dashstyle = '5 3'; @@ -134,15 +141,21 @@ export const getConnectorPaintStylePull = (connection: Connection): PaintStyle = }; export const getConnectorPaintStyleDefault = (connection: Connection): PaintStyle => { - const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes); + const connectorColor = getConnectorColor( + connection.parameters.type as ConnectionTypes, + connection.parameters.category, + ); return { ...CONNECTOR_PAINT_STYLE_DEFAULT, ...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}), }; }; -export const getConnectorPaintStyleData = (connection: Connection): PaintStyle => { - const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes); +export const getConnectorPaintStyleData = ( + connection: Connection, + category?: string, +): PaintStyle => { + const connectorColor = getConnectorColor(connection.parameters.type as ConnectionTypes, category); return { ...CONNECTOR_PAINT_STYLE_DATA, ...(connectorColor ? { stroke: getStyleTokenValue(connectorColor, true) } : {}), @@ -292,6 +305,7 @@ export const getOutputEndpointStyle = ( export const getOutputNameOverlay = ( labelText: string, outputName: ConnectionTypes, + category?: string, ): OverlaySpec => ({ type: 'Custom', options: { @@ -302,11 +316,16 @@ export const getOutputNameOverlay = ( label.innerHTML = labelText; label.classList.add('node-output-endpoint-label'); - label.setAttribute('data-endpoint-node-type', ep?.__meta?.nodeType); + if (ep?.__meta?.endpointLabelLength) { + label.setAttribute('data-endpoint-label-length', ep?.__meta?.endpointLabelLength); + } if (outputName !== NodeConnectionType.Main) { label.classList.add('node-output-endpoint-label--data'); label.classList.add(`node-connection-type-${getScope(outputName)}`); } + if (category) { + label.classList.add(`node-connection-category-${category}`); + } return label; }, }, diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 20cc2a9243..ea5cdbebc5 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1978,21 +1978,25 @@ export default defineComponent({ lastSelectedNode.type, lastSelectedNode.typeVersion, ); + if (sourceNodeType) { const offsets = [ [-100, 100], [-140, 0, 140], [-240, -100, 100, 240], ]; + const sourceNodeOutputs = NodeHelpers.getNodeOutputs( workflow, lastSelectedNode, sourceNodeType, ); const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs); + const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter( (output) => output === NodeConnectionType.Main, ); + if (sourceNodeOutputMainOutputs.length > 1) { const offset = offsets[sourceNodeOutputMainOutputs.length - 2]; const sourceOutputIndex = lastSelectedConnection.__meta @@ -2005,9 +2009,9 @@ export default defineComponent({ let outputs: Array = []; try { - // It fails when the outputs are an expression. As those node have + // It fails when the outputs are an expression. As those nodes have // normally no outputs by default and the only reason we need the - // outputs here is to calculate the position it is fine to assume + // outputs here is to calculate the position, it is fine to assume // that they have no outputs and are so treated as a regular node // with only "main" outputs. outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeData); @@ -2571,7 +2575,9 @@ export default defineComponent({ ); if (sourceInfo.type !== NodeConnectionType.Main) { // Not "main" connections get a different connection style - info.connection.setPaintStyle(getConnectorPaintStyleData(info.connection)); + info.connection.setPaintStyle( + getConnectorPaintStyleData(info.connection, info.sourceEndpoint.parameters.category), + ); endpointArrow?.setVisible(false); } } diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index 12fed0ec34..a1ba44abb2 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -160,7 +160,12 @@ export class Code implements INodeType { result = await sandbox.runCodeEachItem(); } catch (error) { if (!this.continueOnFail()) throw error; - returnData.push({ json: { error: error.message } }); + returnData.push({ + json: { error: error.message }, + pairedItem: { + item: index, + }, + }); } if (result) { diff --git a/packages/nodes-base/nodes/Code/Sandbox.ts b/packages/nodes-base/nodes/Code/Sandbox.ts index a13dc209af..9e6e19e08c 100644 --- a/packages/nodes-base/nodes/Code/Sandbox.ts +++ b/packages/nodes-base/nodes/Code/Sandbox.ts @@ -16,7 +16,7 @@ export interface SandboxContext extends IWorkflowDataProxyData { helpers: IExecuteFunctions['helpers']; } -export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem']); +export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error']); export function getSandboxContext(this: IExecuteFunctions, index: number): SandboxContext { return { diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 71929e7517..fcc85ede86 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -920,6 +920,7 @@ export interface INodeCredentials { [key: string]: INodeCredentialsDetails; } +export type OnError = 'continueErrorOutput' | 'continueRegularOutput' | 'stopWorkflow'; export interface INode { id: string; name: string; @@ -934,6 +935,7 @@ export interface INode { waitBetweenTries?: number; alwaysOutputData?: boolean; executeOnce?: boolean; + onError?: OnError; continueOnFail?: boolean; parameters: INodeParameters; credentials?: INodeCredentials; @@ -1546,6 +1548,7 @@ export interface INodeInputConfiguration { } export interface INodeOutputConfiguration { + category?: string; displayName?: string; required?: boolean; type: ConnectionTypes; @@ -1653,6 +1656,11 @@ export interface IWorkflowDataProxyData { $thisItemIndex: number; $now: any; $today: any; + $getPairedItem: ( + destinationNodeName: string, + incomingSourceData: ISourceData | null, + pairedItem: IPairedItemData, + ) => INodeExecutionData | null; constructor: any; } diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 34e0f50a8c..295ab81265 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1045,21 +1045,48 @@ export function getNodeOutputs( node: INode, nodeTypeData: INodeTypeDescription, ): Array { + let outputs: Array = []; + if (Array.isArray(nodeTypeData.outputs)) { - return nodeTypeData.outputs; + outputs = nodeTypeData.outputs; + } else { + // Calculate the outputs dynamically + try { + outputs = (workflow.expression.getSimpleParameterValue( + node, + nodeTypeData.outputs, + 'internal', + {}, + ) || []) as ConnectionTypes[]; + } catch (e) { + throw new Error(`Could not calculate outputs dynamically for node "${node.name}"`); + } } - // Calculate the outputs dynamically - try { - return (workflow.expression.getSimpleParameterValue( - node, - nodeTypeData.outputs, - 'internal', - {}, - ) || []) as ConnectionTypes[]; - } catch (e) { - throw new Error(`Could not calculate outputs dynamically for node "${node.name}"`); + if (node.onError === 'continueErrorOutput') { + // Copy the data to make sure that we do not change the data of the + // node type and so change the displayNames for all nodes in the flow + outputs = deepCopy(outputs); + if (outputs.length === 1) { + // Set the displayName to "Success" + if (typeof outputs[0] === 'string') { + outputs[0] = { + type: outputs[0], + }; + } + outputs[0].displayName = 'Success'; + } + return [ + ...outputs, + { + category: 'error', + type: 'main', + displayName: 'Error', + }, + ]; } + + return outputs; } /** diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index 4768ee816f..48259bb98b 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -121,8 +121,9 @@ export class RoutingNode { // TODO: Think about how batching could be handled for REST APIs which support it for (let i = 0; i < items.length; i++) { + let thisArgs: IExecuteSingleFunctions | undefined; try { - const thisArgs = nodeExecuteFunctions.getExecuteSingleFunctions( + thisArgs = nodeExecuteFunctions.getExecuteSingleFunctions( this.workflow, this.runExecutionData, runIndex, @@ -209,7 +210,7 @@ export class RoutingNode { returnData.push(...responseData); } catch (error) { - if (get(this.node, 'continueOnFail', false)) { + if (thisArgs !== undefined && thisArgs.continueOnFail()) { returnData.push({ json: {}, error: error.message }); continue; } diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index cf46c4bbd7..d1c6570885 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -1211,6 +1211,7 @@ export class WorkflowDataProxy { // eslint-disable-next-line @typescript-eslint/naming-convention Duration, ...that.additionalKeys, + $getPairedItem: getPairedItem, // deprecated $jmespath: jmespathWrapper,