diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 5800499bc2..d986fe6577 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -43,7 +43,9 @@ describe('Undo/Redo', () => { WorkflowPage.actions.zoomToFit(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') + WorkflowPage.actions.hitUndo(); WorkflowPage.getters.canvasNodes().should('have.have.length', 2); WorkflowPage.getters.nodeConnections().should('have.length', 1); @@ -59,7 +61,8 @@ describe('Undo/Redo', () => { // Last node should be added back to original position WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') }); it('should undo/redo deleting node using delete button', () => { @@ -133,15 +136,19 @@ describe('Undo/Redo', () => { cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true }); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') + WorkflowPage.actions.hitUndo(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 640px; top: 220px;'); + .should('have.css', 'left', '640px') + .should('have.css', 'top', '220px') WorkflowPage.actions.hitRedo(); WorkflowPage.getters .canvasNodeByName('Code') - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') }); it('should undo/redo deleting a connection by pressing delete button', () => { @@ -269,8 +276,8 @@ describe('Undo/Redo', () => { }); it('should undo/redo multiple steps', () => { - const initialPosition = 'left: 420px; top: 220px;'; - const movedPosition = 'left: 540px; top: 360px;'; + const initialPosition = {left: '420px', top: '220px'}; + const movedPosition = {left: '540px', top: '360px'}; WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); @@ -283,10 +290,17 @@ describe('Undo/Redo', () => { WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.actions.hitDisableNodeShortcut(); // Move first one - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', initialPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', initialPosition.left) + .should('have.css', 'top', initialPosition.top) + WorkflowPage.getters.canvasNodes().first().click(); cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true }); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', movedPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', movedPosition.left) + .should('have.css', 'top', movedPosition.top) // Delete the set node WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click(); cy.get('body').type('{backspace}'); @@ -297,7 +311,10 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 3); // Second undo: Should move first node to it's original position WorkflowPage.actions.hitUndo(); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', initialPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', initialPosition.left) + .should('have.css', 'top', initialPosition.top) // Third undo: Should enable last node WorkflowPage.actions.hitUndo(); WorkflowPage.getters.disabledNodes().should('have.length', 0); @@ -307,7 +324,10 @@ describe('Undo/Redo', () => { WorkflowPage.getters.disabledNodes().should('have.length', 1); // Second redo: Should move the first node WorkflowPage.actions.hitRedo(); - WorkflowPage.getters.canvasNodes().first().should('have.attr', 'style', movedPosition); + WorkflowPage.getters.canvasNodes() + .first() + .should('have.css', 'left', movedPosition.left) + .should('have.css', 'top', movedPosition.top) // Third redo: Should delete the Set node WorkflowPage.actions.hitRedo(); WorkflowPage.getters.canvasNodes().should('have.length', 3); diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index bf45b7da6b..c34a918970 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -133,7 +133,8 @@ describe('Canvas Actions', () => { WorkflowPage.getters .canvasNodes() .last() - .should('have.attr', 'style', 'left: 860px; top: 220px;'); + .should('have.css', 'left', '860px') + .should('have.css', 'top', '220px') }); it('should delete connections by pressing the delete button', () => { diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 9e09d27089..9e2b8abe06 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -8,11 +8,11 @@ import { MERGE_NODE_NAME, } from './../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { WorkflowExecutionsTab } from '../pages'; +import { NDV, WorkflowExecutionsTab } from '../pages'; const WorkflowPage = new WorkflowPageClass(); const ExecutionsTab = new WorkflowExecutionsTab(); - +const NDVDialog = new NDV(); const DEFAULT_ZOOM_FACTOR = 1; const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks @@ -29,10 +29,15 @@ describe('Canvas Node Manipulation and Navigation', () => { }); it('should add switch node and test connections', () => { - WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true); + const desiredOutputs = 4; + WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true, true); - // Switch has 4 output endpoints - for (let i = 0; i < 4; i++) { + for (let i = 0; i < desiredOutputs; i++) { + cy.contains('Add Routing Rule').click() + } + + NDVDialog.actions.close() + for (let i = 0; i < desiredOutputs; i++) { WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true }); WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); @@ -42,7 +47,7 @@ describe('Canvas Node Manipulation and Navigation', () => { cy.reload(); cy.waitForLoad(); // Make sure all connections are there after reload - for (let i = 0; i < 4; i++) { + for (let i = 0; i < desiredOutputs; i++) { const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`; WorkflowPage.getters .canvasNodeInputEndpointByName(setName) @@ -167,7 +172,8 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.getters .canvasNodes() .last() - .should('have.attr', 'style', 'left: 740px; top: 320px;'); + .should('have.css', 'left', '740px') + .should('have.css', 'top', '320px') }); it('should zoom in', () => { diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 179332bfbd..91ddec569d 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -184,7 +184,14 @@ import { nodeHelpers } from '@/mixins/nodeHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { pinData } from '@/mixins/pinData'; -import type { IExecutionsSummary, INodeTypeDescription, ITaskData } from 'n8n-workflow'; +import type { + ConnectionTypes, + IExecutionsSummary, + INodeInputConfiguration, + INodeOutputConfiguration, + INodeTypeDescription, + ITaskData, +} from 'n8n-workflow'; import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; import NodeIcon from '@/components/NodeIcon.vue'; @@ -357,9 +364,15 @@ export default defineComponent({ top: this.position[1] + 'px', }; - const nonMainInputs = this.inputs.filter((input) => input !== NodeConnectionType.Main); + const workflow = this.workflowsStore.getCurrentWorkflow(); + const inputs = + NodeHelpers.getNodeInputs(workflow, this.node, this.nodeType) || + ([] as Array); + const inputTypes = NodeHelpers.getConnectionTypes(inputs); + + const nonMainInputs = inputTypes.filter((input) => input !== NodeConnectionType.Main); if (nonMainInputs.length) { - const requiredNonMainInputs = this.inputs.filter( + const requiredNonMainInputs = inputs.filter( (input) => typeof input !== 'string' && input.required, ); @@ -373,6 +386,15 @@ export default defineComponent({ styles['--configurable-node-input-count'] = nonMainInputs.length + spacerCount; } + const outputs = + NodeHelpers.getNodeOutputs(workflow, this.node, this.nodeType) || + ([] as Array); + + const outputTypes = NodeHelpers.getConnectionTypes(outputs); + + const mainOutputs = outputTypes.filter((output) => output === NodeConnectionType.Main); + styles['--node-main-output-count'] = mainOutputs.length; + return styles; }, nodeClass(): object { @@ -698,7 +720,12 @@ export default defineComponent({ diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index 139b3f2592..976161ea5f 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -502,6 +502,7 @@ import type { IBinaryKeyData, IDataObject, INodeExecutionData, + INodeOutputConfiguration, INodeTypeDescription, IRunData, IRunExecutionData, @@ -543,6 +544,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useToast } from '@/composables'; +import { isObject } from 'lodash-es'; const RunDataTable = defineAsyncComponent(async () => import('@/components/RunDataTable.vue')); const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue')); @@ -891,9 +893,8 @@ export default defineComponent({ return this.outputIndex; }, branches(): ITab[] { - function capitalize(name: string) { - return name.charAt(0).toLocaleUpperCase() + name.slice(1); - } + const capitalize = (name: string) => name.charAt(0).toLocaleUpperCase() + name.slice(1); + const branches: ITab[] = []; for (let i = 0; i <= this.maxOutputIndex; i++) { @@ -903,6 +904,7 @@ export default defineComponent({ const itemsCount = this.getDataCount(this.runIndex, i); const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount }); let outputName = this.getOutputName(i); + if (`${outputName}` === `${i}`) { outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`; } else { @@ -936,6 +938,18 @@ export default defineComponent({ }, }, methods: { + getResolvedNodeOutputs() { + if (this.node && this.nodeType) { + const workflow = this.workflowsStore.getCurrentWorkflow(); + const workflowNode = workflow.getNode(this.node.name); + + if (workflowNode) { + const outputs = NodeHelpers.getNodeOutputs(workflow, workflowNode, this.nodeType); + return outputs; + } + } + return []; + }, onItemHover(itemIndex: number | null) { if (itemIndex === null) { this.$emit('itemHover', null); @@ -1287,9 +1301,7 @@ export default defineComponent({ this.closeBinaryDataDisplay(); let outputTypes: ConnectionTypes[] = []; if (this.nodeType !== null && this.node !== null) { - const workflow = this.workflowsStore.getCurrentWorkflow(); - const workflowNode = workflow.getNode(this.node.name); - const outputs = NodeHelpers.getNodeOutputs(workflow, workflowNode, this.nodeType); + const outputs = this.getResolvedNodeOutputs(); outputTypes = NodeHelpers.getConnectionTypes(outputs); } this.connectionType = outputTypes.length === 0 ? NodeConnectionType.Main : outputTypes[0]; @@ -1367,6 +1379,12 @@ export default defineComponent({ } const nodeType = this.nodeType; + const outputs = this.getResolvedNodeOutputs(); + const outputConfiguration = outputs?.[outputIndex] as INodeOutputConfiguration; + + if (outputConfiguration && isObject(outputConfiguration)) { + return outputConfiguration?.displayName; + } if (!nodeType?.outputNames || nodeType.outputNames.length <= outputIndex) { return outputIndex + 1; } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 4e48a4e342..0dab6b84f8 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -595,7 +595,7 @@ export const MFA_AUTHENTICATION_TOKEN_INPUT_MAX_LENGTH = 6; export const MFA_AUTHENTICATION_RECOVERY_CODE_INPUT_MAX_LENGTH = 36; -export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE]; +export const NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND = [FILTER_NODE_TYPE, SWITCH_NODE_TYPE]; export const ALLOWED_HTML_ATTRIBUTES = ['href', 'name', 'target', 'title', 'class', 'id', 'style']; diff --git a/packages/editor-ui/src/mixins/nodeBase.ts b/packages/editor-ui/src/mixins/nodeBase.ts index 1b29f0f072..d7a1fba092 100644 --- a/packages/editor-ui/src/mixins/nodeBase.ts +++ b/packages/editor-ui/src/mixins/nodeBase.ts @@ -261,6 +261,7 @@ export const nodeBase = defineComponent({ nodeId: this.nodeId, index: typeIndex, totalEndpoints: inputsOfSameRootType.length, + nodeType: node.type, }; } @@ -423,6 +424,7 @@ export const nodeBase = defineComponent({ this.$refs[this.data.name] as Element, newEndpointData, ); + this.__addEndpointTestingData(endpoint, 'output', typeIndex); if (outputConfiguration.displayName || nodeTypeData.outputNames?.[i]) { // Apply output names if they got set @@ -439,6 +441,7 @@ export const nodeBase = defineComponent({ nodeId: this.nodeId, index: typeIndex, totalEndpoints: outputsOfSameRootType.length, + nodeType: node.type, }; } @@ -454,6 +457,7 @@ export const nodeBase = defineComponent({ connectedEndpoint: endpoint, showOutputLabel: outputs.length === 1, size: outputs.length >= 3 ? 'small' : 'medium', + nodeType: node.type, hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'), }, }, @@ -486,6 +490,7 @@ export const nodeBase = defineComponent({ nodeName: node.name, nodeId: this.nodeId, index: typeIndex, + nodeType: node.type, totalEndpoints: outputsOfSameRootType.length, }; } diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts index 8481a2e350..e7928ee2ed 100644 --- a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts +++ b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts @@ -14,6 +14,7 @@ interface N8nPlusEndpointParams extends EndpointRepresentationParams { dimensions: number; connectedEndpoint: Endpoint; hoverMessage: string; + nodeType: string; size: 'small' | 'medium'; showOutputLabel: boolean; } @@ -23,15 +24,17 @@ export const N8nPlusEndpointType = 'N8nPlus'; export const EVENT_PLUS_ENDPOINT_CLICK = 'eventPlusEndpointClick'; export class N8nPlusEndpoint extends EndpointRepresentation { params: N8nPlusEndpointParams; + label: string; + stalkOverlay: Overlay | null; + messageOverlay: Overlay | null; constructor(endpoint: Endpoint, params: N8nPlusEndpointParams) { super(endpoint, params); this.params = params; - this.label = ''; this.stalkOverlay = null; this.messageOverlay = null; @@ -41,6 +44,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation { const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`); return stalk; @@ -61,6 +68,9 @@ export class N8nPlusEndpoint extends EndpointRepresentation { const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`); hoverMessage.innerHTML = this.params.hoverMessage; @@ -70,23 +80,27 @@ export class N8nPlusEndpoint extends EndpointRepresentation { if (!this.endpoint) return; const stalkOverlay = this.endpoint.getOverlay(PlusStalkOverlay); const messageOverlay = this.endpoint.getOverlay(HoverMessageOverlay); + if (stalkOverlay && messageOverlay) { // Increase the size of the stalk overlay if the label is too long const fnKey = this.label.length > 10 ? 'add' : 'remove'; @@ -100,21 +114,25 @@ export class N8nPlusEndpoint extends EndpointRepresentation { if (endpoint === this.endpoint) { this.instance.fire(EVENT_PLUS_ENDPOINT_CLICK, this.endpoint); } }; + setHoverMessageVisible = (endpoint: Endpoint) => { if (endpoint === this.endpoint && this.messageOverlay) { this.instance.addOverlayClass(this.messageOverlay, 'visible'); } }; + unsetHoverMessageVisible = (endpoint: Endpoint) => { if (endpoint === this.endpoint && this.messageOverlay) { this.instance.removeOverlayClass(this.messageOverlay, 'visible'); } }; + clearOverlays() { Object.keys(this.endpoint.getOverlays()).forEach((key) => { this.endpoint.removeOverlay(key); @@ -122,6 +140,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation { @@ -179,6 +199,7 @@ export const N8nPlusEndpointHandler: EndpointHandler { if (connectionType === NodeConnectionType.Main) { - const positions = { - input: { - 1: [[0.01, 0.5, -1, 0]], - 2: [ - [0.01, 0.3, -1, 0], - [0.01, 0.7, -1, 0], - ], - 3: [ - [0.01, 0.25, -1, 0], - [0.01, 0.5, -1, 0], - [0.01, 0.75, -1, 0], - ], - 4: [ - [0.01, 0.2, -1, 0], - [0.01, 0.4, -1, 0], - [0.01, 0.6, -1, 0], - [0.01, 0.8, -1, 0], - ], - }, - output: { - 1: [[0.99, 0.5, 1, 0]], - 2: [ - [0.99, 0.3, 1, 0], - [0.99, 0.7, 1, 0], - ], - 3: [ - [0.99, 0.25, 1, 0], - [0.99, 0.5, 1, 0], - [0.99, 0.75, 1, 0], - ], - 4: [ - [0.99, 0.2, 1, 0], - [0.99, 0.4, 1, 0], - [0.99, 0.6, 1, 0], - [0.99, 0.8, 1, 0], - ], - }, - }; + const anchors: ArrayAnchorSpec[] = []; + const x = type === 'input' ? 0.01 : 0.99; + const ox = type === 'input' ? -1 : 1; + const oy = 0; + const stepSize = 1 / (amount + 1); // +1 to not touch the node boundaries - return positions[type][amount] as ArrayAnchorSpec[]; + for (let i = 1; i <= amount; i++) { + const y = stepSize * i; // Multiply by index to set position + anchors.push([x, y, ox, oy]); + } + + return anchors; } const y = type === 'input' ? 0.99 : 0.01; @@ -317,15 +289,20 @@ export const getOutputEndpointStyle = ( outlineStroke: 'none', }); -export const getOutputNameOverlay = (labelText: string, outputName: string): OverlaySpec => ({ +export const getOutputNameOverlay = ( + labelText: string, + outputName: ConnectionTypes, +): OverlaySpec => ({ type: 'Custom', options: { id: OVERLAY_OUTPUT_NAME_LABEL, visible: true, - create: (component: Endpoint) => { + create: (ep: Endpoint) => { const label = document.createElement('div'); label.innerHTML = labelText; label.classList.add('node-output-endpoint-label'); + + label.setAttribute('data-endpoint-node-type', ep?.__meta?.nodeType); if (outputName !== NodeConnectionType.Main) { label.classList.add('node-output-endpoint-label--data'); label.classList.add(`node-connection-type-${getScope(outputName)}`); diff --git a/packages/nodes-base/nodes/Switch/Switch.node.ts b/packages/nodes-base/nodes/Switch/Switch.node.ts index 5cf4a923a1..d88e874ee0 100644 --- a/packages/nodes-base/nodes/Switch/Switch.node.ts +++ b/packages/nodes-base/nodes/Switch/Switch.node.ts @@ -1,686 +1,25 @@ -import type { - IExecuteFunctions, - INodeExecutionData, - INodeParameters, - INodeType, - INodeTypeDescription, - NodeParameterValue, -} from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; +import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'; +import { VersionedNodeType } from 'n8n-workflow'; -export class Switch implements INodeType { - description: INodeTypeDescription = { - displayName: 'Switch', - name: 'switch', - icon: 'fa:map-signs', - group: ['transform'], - version: 1, - description: 'Route items depending on defined expression or rules', - defaults: { - name: 'Switch', - color: '#506000', - }, - inputs: ['main'], - // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong - outputs: ['main', 'main', 'main', 'main'], - outputNames: ['0', '1', '2', '3'], - properties: [ - { - displayName: 'Mode', - name: 'mode', - type: 'options', - options: [ - { - name: 'Expression', - value: 'expression', - description: 'Expression decides how to route data', - }, - { - name: 'Rules', - value: 'rules', - description: 'Rules decide how to route data', - }, - ], - default: 'rules', - description: 'How data should be routed', - }, +import { SwitchV1 } from './V1/SwitchV1.node'; +import { SwitchV2 } from './V2/SwitchV2.node'; - // ---------------------------------- - // mode:expression - // ---------------------------------- - { - displayName: 'Output', - name: 'output', - type: 'number', - typeOptions: { - minValue: 0, - maxValue: 3, - }, - displayOptions: { - show: { - mode: ['expression'], - }, - }, - default: 0, - description: 'The index of output to which to send data to', - }, - - // ---------------------------------- - // mode:rules - // ---------------------------------- - { - displayName: 'Data Type', - name: 'dataType', - type: 'options', - displayOptions: { - show: { - mode: ['rules'], - }, - }, - options: [ - { - name: 'Boolean', - value: 'boolean', - }, - { - name: 'Date & Time', - value: 'dateTime', - }, - { - name: 'Number', - value: 'number', - }, - { - name: 'String', - value: 'string', - }, - ], - default: 'number', - description: 'The type of data to route on', - }, - - // ---------------------------------- - // dataType:boolean - // ---------------------------------- - { - displayName: 'Value 1', - name: 'value1', - type: 'boolean', - displayOptions: { - show: { - dataType: ['boolean'], - mode: ['rules'], - }, - }, - default: false, - // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether - description: 'The value to compare with the first one', - }, - { - displayName: 'Routing Rules', - name: 'rules', - placeholder: 'Add Routing Rule', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - dataType: ['boolean'], - mode: ['rules'], - }, - }, - default: {}, - options: [ - { - name: 'rules', - displayName: 'Boolean', - values: [ - // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - ], - default: 'equal', - description: 'Operation to decide where the the data should be mapped to', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'boolean', - default: false, - // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether - description: 'The value to compare with the first one', - }, - { - displayName: 'Output', - name: 'output', - type: 'number', - typeOptions: { - minValue: 0, - maxValue: 3, - }, - default: 0, - description: 'The index of output to which to send data to if rule matches', - }, - ], - }, - ], - }, - - // ---------------------------------- - // dataType:dateTime - // ---------------------------------- - { - displayName: 'Value 1', - name: 'value1', - type: 'dateTime', - displayOptions: { - show: { - dataType: ['dateTime'], - mode: ['rules'], - }, - }, - default: '', - description: 'The value to compare with the second one', - }, - { - displayName: 'Routing Rules', - name: 'rules', - placeholder: 'Add Routing Rule', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - dataType: ['dateTime'], - mode: ['rules'], - }, - }, - default: {}, - options: [ - { - name: 'rules', - displayName: 'Dates', - values: [ - // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'Occurred After', - value: 'after', - }, - { - name: 'Occurred Before', - value: 'before', - }, - ], - default: 'after', - description: 'Operation to decide where the the data should be mapped to', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'dateTime', - default: 0, - description: 'The value to compare with the first one', - }, - { - displayName: 'Output', - name: 'output', - type: 'number', - typeOptions: { - minValue: 0, - maxValue: 3, - }, - default: 0, - description: 'The index of output to which to send data to if rule matches', - }, - ], - }, - ], - }, - - // ---------------------------------- - // dataType:number - // ---------------------------------- - { - displayName: 'Value 1', - name: 'value1', - type: 'number', - displayOptions: { - show: { - dataType: ['number'], - mode: ['rules'], - }, - }, - default: 0, - description: 'The value to compare with the second one', - }, - { - displayName: 'Routing Rules', - name: 'rules', - placeholder: 'Add Routing Rule', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - dataType: ['number'], - mode: ['rules'], - }, - }, - default: {}, - options: [ - { - name: 'rules', - displayName: 'Numbers', - values: [ - // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression - { - displayName: 'Operation', - name: 'operation', - type: 'options', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'Smaller', - value: 'smaller', - }, - { - name: 'Smaller Equal', - value: 'smallerEqual', - }, - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - { - name: 'Larger', - value: 'larger', - }, - { - name: 'Larger Equal', - value: 'largerEqual', - }, - ], - default: 'smaller', - description: 'Operation to decide where the the data should be mapped to', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'number', - default: 0, - description: 'The value to compare with the first one', - }, - { - displayName: 'Output', - name: 'output', - type: 'number', - typeOptions: { - minValue: 0, - maxValue: 3, - }, - default: 0, - description: 'The index of output to which to send data to if rule matches', - }, - ], - }, - ], - }, - - // ---------------------------------- - // dataType:string - // ---------------------------------- - { - displayName: 'Value 1', - name: 'value1', - type: 'string', - displayOptions: { - show: { - dataType: ['string'], - mode: ['rules'], - }, - }, - default: '', - description: 'The value to compare with the second one', - }, - { - displayName: 'Routing Rules', - name: 'rules', - placeholder: 'Add Routing Rule', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - dataType: ['string'], - mode: ['rules'], - }, - }, - default: {}, - options: [ - { - name: 'rules', - displayName: 'Strings', - values: [ - // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression - { - displayName: 'Operation', - name: 'operation', - type: 'options', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'Contains', - value: 'contains', - }, - { - name: 'Not Contains', - value: 'notContains', - }, - { - name: 'Ends With', - value: 'endsWith', - }, - { - name: 'Not Ends With', - value: 'notEndsWith', - }, - { - name: 'Equal', - value: 'equal', - }, - { - name: 'Not Equal', - value: 'notEqual', - }, - { - name: 'Regex Match', - value: 'regex', - }, - { - name: 'Regex Not Match', - value: 'notRegex', - }, - { - name: 'Starts With', - value: 'startsWith', - }, - { - name: 'Not Starts With', - value: 'notStartsWith', - }, - ], - default: 'equal', - description: 'Operation to decide where the the data should be mapped to', - }, - { - displayName: 'Value 2', - name: 'value2', - type: 'string', - displayOptions: { - hide: { - operation: ['regex', 'notRegex'], - }, - }, - default: '', - description: 'The value to compare with the first one', - }, - { - displayName: 'Regex', - name: 'value2', - type: 'string', - displayOptions: { - show: { - operation: ['regex', 'notRegex'], - }, - }, - default: '', - placeholder: '/text/i', - description: 'The regex which has to match', - }, - { - displayName: 'Output', - name: 'output', - type: 'number', - typeOptions: { - minValue: 0, - maxValue: 3, - }, - default: 0, - description: 'The index of output to which to send data to if rule matches', - }, - ], - }, - ], - }, - - { - displayName: 'Fallback Output', - name: 'fallbackOutput', - type: 'options', - displayOptions: { - show: { - mode: ['rules'], - }, - }, - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'None', - value: -1, - }, - { - name: '0', - value: 0, - }, - { - name: '1', - value: 1, - }, - { - name: '2', - value: 2, - }, - { - name: '3', - value: 3, - }, - ], - default: -1, - description: 'The output to which to route all items which do not match any of the rules', - }, - ], - }; - - async execute(this: IExecuteFunctions): Promise { - const returnData: INodeExecutionData[][] = [[], [], [], []]; - - const items = this.getInputData(); - - let compareOperationResult: boolean; - let item: INodeExecutionData; - let mode: string; - let outputIndex: number; - let ruleData: INodeParameters; - - // The compare operations - const compareOperationFunctions: { - [key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean; - } = { - after: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) > (value2 || 0), - before: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) < (value2 || 0), - contains: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || '').toString().includes((value2 || '').toString()), - notContains: (value1: NodeParameterValue, value2: NodeParameterValue) => - !(value1 || '').toString().includes((value2 || '').toString()), - endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 as string).endsWith(value2 as string), - notEndsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - !(value1 as string).endsWith(value2 as string), - equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2, - notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2, - larger: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) > (value2 || 0), - largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) >= (value2 || 0), - smaller: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) < (value2 || 0), - smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 || 0) <= (value2 || 0), - startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - (value1 as string).startsWith(value2 as string), - notStartsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => - !(value1 as string).startsWith(value2 as string), - regex: (value1: NodeParameterValue, value2: NodeParameterValue) => { - const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); - - let regex: RegExp; - if (!regexMatch) { - regex = new RegExp((value2 || '').toString()); - } else if (regexMatch.length === 1) { - regex = new RegExp(regexMatch[1]); - } else { - regex = new RegExp(regexMatch[1], regexMatch[2]); - } - - return !!(value1 || '').toString().match(regex); - }, - notRegex: (value1: NodeParameterValue, value2: NodeParameterValue) => { - const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); - - let regex: RegExp; - if (!regexMatch) { - regex = new RegExp((value2 || '').toString()); - } else if (regexMatch.length === 1) { - regex = new RegExp(regexMatch[1]); - } else { - regex = new RegExp(regexMatch[1], regexMatch[2]); - } - - return !(value1 || '').toString().match(regex); - }, +export class Switch extends VersionedNodeType { + constructor() { + const baseDescription: INodeTypeBaseDescription = { + displayName: 'Switch', + name: 'switch', + icon: 'fa:map-signs', + group: ['transform'], + description: 'Route items depending on defined expression or rules', + defaultVersion: 2, }; - // Converts the input data of a dateTime into a number for easy compare - const convertDateTime = (value: NodeParameterValue): number => { - let returnValue: number | undefined = undefined; - if (typeof value === 'string') { - returnValue = new Date(value).getTime(); - } else if (typeof value === 'number') { - returnValue = value; - } - if ((value as unknown as object) instanceof Date) { - returnValue = (value as unknown as Date).getTime(); - } - - if (returnValue === undefined || isNaN(returnValue)) { - throw new NodeOperationError( - this.getNode(), - `The value "${value}" is not a valid DateTime.`, - ); - } - - return returnValue; + const nodeVersions: IVersionedNodeType['nodeVersions'] = { + 1: new SwitchV1(baseDescription), + 2: new SwitchV2(baseDescription), }; - const checkIndexRange = (index: number) => { - if (index < 0 || index >= returnData.length) { - throw new NodeOperationError( - this.getNode(), - `The ouput ${index} is not allowed. It has to be between 0 and ${returnData.length - 1}!`, - ); - } - }; - - // Iterate over all items to check to which output they should be routed to - itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { - try { - item = items[itemIndex]; - mode = this.getNodeParameter('mode', itemIndex) as string; - - if (mode === 'expression') { - // One expression decides how to route item - - outputIndex = this.getNodeParameter('output', itemIndex) as number; - checkIndexRange(outputIndex); - - returnData[outputIndex].push(item); - } else if (mode === 'rules') { - // Rules decide how to route item - - const dataType = this.getNodeParameter('dataType', 0) as string; - - let value1 = this.getNodeParameter('value1', itemIndex) as NodeParameterValue; - if (dataType === 'dateTime') { - value1 = convertDateTime(value1); - } - - for (ruleData of this.getNodeParameter( - 'rules.rules', - itemIndex, - [], - ) as INodeParameters[]) { - // Check if the values passes - - let value2 = ruleData.value2 as NodeParameterValue; - if (dataType === 'dateTime') { - value2 = convertDateTime(value2); - } - - compareOperationResult = compareOperationFunctions[ruleData.operation as string]( - value1, - value2, - ); - - if (compareOperationResult) { - // If rule matches add it to the correct output and continue with next item - checkIndexRange(ruleData.output as number); - returnData[ruleData.output as number].push(item); - continue itemLoop; - } - } - - // Check if a fallback output got defined and route accordingly - outputIndex = this.getNodeParameter('fallbackOutput', itemIndex) as number; - if (outputIndex !== -1) { - checkIndexRange(outputIndex); - returnData[outputIndex].push(item); - } - } - } catch (error) { - if (this.continueOnFail()) { - returnData[0].push({ json: { error: error.message } }); - continue; - } - throw error; - } - } - - return returnData; + super(nodeVersions, baseDescription); } } diff --git a/packages/nodes-base/nodes/Switch/V1/SwitchV1.node.ts b/packages/nodes-base/nodes/Switch/V1/SwitchV1.node.ts new file mode 100644 index 0000000000..e84226e8aa --- /dev/null +++ b/packages/nodes-base/nodes/Switch/V1/SwitchV1.node.ts @@ -0,0 +1,686 @@ +import type { + IExecuteFunctions, + INodeExecutionData, + INodeParameters, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + NodeParameterValue, +} from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +export class SwitchV1 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: [1], + defaults: { + name: 'Switch', + color: '#506000', + }, + inputs: ['main'], + outputs: ['main', 'main', 'main', 'main'], + outputNames: ['0', '1', '2', '3'], + properties: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Expression', + value: 'expression', + description: 'Expression decides how to route data', + }, + { + name: 'Rules', + value: 'rules', + description: 'Rules decide how to route data', + }, + ], + default: 'rules', + description: 'How data should be routed', + }, + + // ---------------------------------- + // mode:expression + // ---------------------------------- + { + displayName: 'Output', + name: 'output', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 3, + }, + displayOptions: { + show: { + mode: ['expression'], + }, + }, + default: 0, + description: 'The index of output to which to send data to', + }, + + // ---------------------------------- + // mode:rules + // ---------------------------------- + { + displayName: 'Data Type', + name: 'dataType', + type: 'options', + displayOptions: { + show: { + mode: ['rules'], + }, + }, + options: [ + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Date & Time', + value: 'dateTime', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'String', + value: 'string', + }, + ], + default: 'number', + description: 'The type of data to route on', + }, + + // ---------------------------------- + // dataType:boolean + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'boolean', + displayOptions: { + show: { + dataType: ['boolean'], + mode: ['rules'], + }, + }, + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: 'The value to compare with the first one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['boolean'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Boolean', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'boolean', + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: 'The value to compare with the first one', + }, + { + displayName: 'Output', + name: 'output', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 3, + }, + default: 0, + description: 'The index of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:dateTime + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'dateTime', + displayOptions: { + show: { + dataType: ['dateTime'], + mode: ['rules'], + }, + }, + default: '', + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['dateTime'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Dates', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Occurred After', + value: 'after', + }, + { + name: 'Occurred Before', + value: 'before', + }, + ], + default: 'after', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'dateTime', + default: 0, + description: 'The value to compare with the first one', + }, + { + displayName: 'Output', + name: 'output', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 3, + }, + default: 0, + description: 'The index of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:number + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'number', + displayOptions: { + show: { + dataType: ['number'], + mode: ['rules'], + }, + }, + default: 0, + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['number'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Numbers', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Smaller', + value: 'smaller', + }, + { + name: 'Smaller Equal', + value: 'smallerEqual', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Larger', + value: 'larger', + }, + { + name: 'Larger Equal', + value: 'largerEqual', + }, + ], + default: 'smaller', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'number', + default: 0, + description: 'The value to compare with the first one', + }, + { + displayName: 'Output', + name: 'output', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 3, + }, + default: 0, + description: 'The index of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:string + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'string', + displayOptions: { + show: { + dataType: ['string'], + mode: ['rules'], + }, + }, + default: '', + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['string'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Strings', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Contains', + value: 'contains', + }, + { + name: 'Not Contains', + value: 'notContains', + }, + { + name: 'Ends With', + value: 'endsWith', + }, + { + name: 'Not Ends With', + value: 'notEndsWith', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Regex Match', + value: 'regex', + }, + { + name: 'Regex Not Match', + value: 'notRegex', + }, + { + name: 'Starts With', + value: 'startsWith', + }, + { + name: 'Not Starts With', + value: 'notStartsWith', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'string', + displayOptions: { + hide: { + operation: ['regex', 'notRegex'], + }, + }, + default: '', + description: 'The value to compare with the first one', + }, + { + displayName: 'Regex', + name: 'value2', + type: 'string', + displayOptions: { + show: { + operation: ['regex', 'notRegex'], + }, + }, + default: '', + placeholder: '/text/i', + description: 'The regex which has to match', + }, + { + displayName: 'Output', + name: 'output', + type: 'number', + typeOptions: { + minValue: 0, + maxValue: 3, + }, + default: 0, + description: 'The index of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + { + displayName: 'Fallback Output', + name: 'fallbackOutput', + type: 'options', + displayOptions: { + show: { + mode: ['rules'], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'None', + value: -1, + }, + { + name: '0', + value: 0, + }, + { + name: '1', + value: 1, + }, + { + name: '2', + value: 2, + }, + { + name: '3', + value: 3, + }, + ], + default: -1, + description: 'The output to which to route all items which do not match any of the rules', + }, + ], + }; + } + + async execute(this: IExecuteFunctions): Promise { + const returnData: INodeExecutionData[][] = [[], [], [], []]; + + const items = this.getInputData(); + + let compareOperationResult: boolean; + let item: INodeExecutionData; + let mode: string; + let outputIndex: number; + let ruleData: INodeParameters; + + // The compare operations + const compareOperationFunctions: { + [key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean; + } = { + after: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) > (value2 || 0), + before: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) < (value2 || 0), + contains: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || '').toString().includes((value2 || '').toString()), + notContains: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 || '').toString().includes((value2 || '').toString()), + endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).endsWith(value2 as string), + notEndsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 as string).endsWith(value2 as string), + equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2, + notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2, + larger: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) > (value2 || 0), + largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) >= (value2 || 0), + smaller: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) < (value2 || 0), + smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) <= (value2 || 0), + startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).startsWith(value2 as string), + notStartsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 as string).startsWith(value2 as string), + regex: (value1: NodeParameterValue, value2: NodeParameterValue) => { + const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); + + let regex: RegExp; + if (!regexMatch) { + regex = new RegExp((value2 || '').toString()); + } else if (regexMatch.length === 1) { + regex = new RegExp(regexMatch[1]); + } else { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } + + return !!(value1 || '').toString().match(regex); + }, + notRegex: (value1: NodeParameterValue, value2: NodeParameterValue) => { + const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); + + let regex: RegExp; + if (!regexMatch) { + regex = new RegExp((value2 || '').toString()); + } else if (regexMatch.length === 1) { + regex = new RegExp(regexMatch[1]); + } else { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } + + return !(value1 || '').toString().match(regex); + }, + }; + + // Converts the input data of a dateTime into a number for easy compare + const convertDateTime = (value: NodeParameterValue): number => { + let returnValue: number | undefined = undefined; + if (typeof value === 'string') { + returnValue = new Date(value).getTime(); + } else if (typeof value === 'number') { + returnValue = value; + } + if ((value as unknown as object) instanceof Date) { + returnValue = (value as unknown as Date).getTime(); + } + + if (returnValue === undefined || isNaN(returnValue)) { + throw new NodeOperationError( + this.getNode(), + `The value "${value}" is not a valid DateTime.`, + ); + } + + return returnValue; + }; + + const checkIndexRange = (index: number) => { + if (index < 0 || index >= returnData.length) { + throw new NodeOperationError( + this.getNode(), + `The ouput ${index} is not allowed. It has to be between 0 and ${returnData.length - 1}!`, + ); + } + }; + + // Iterate over all items to check to which output they should be routed to + itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + item = items[itemIndex]; + mode = this.getNodeParameter('mode', itemIndex) as string; + + if (mode === 'expression') { + // One expression decides how to route item + + outputIndex = this.getNodeParameter('output', itemIndex) as number; + checkIndexRange(outputIndex); + + returnData[outputIndex].push(item); + } else if (mode === 'rules') { + // Rules decide how to route item + + const dataType = this.getNodeParameter('dataType', 0) as string; + + let value1 = this.getNodeParameter('value1', itemIndex) as NodeParameterValue; + if (dataType === 'dateTime') { + value1 = convertDateTime(value1); + } + + for (ruleData of this.getNodeParameter( + 'rules.rules', + itemIndex, + [], + ) as INodeParameters[]) { + // Check if the values passes + + let value2 = ruleData.value2 as NodeParameterValue; + if (dataType === 'dateTime') { + value2 = convertDateTime(value2); + } + + compareOperationResult = compareOperationFunctions[ruleData.operation as string]( + value1, + value2, + ); + + if (compareOperationResult) { + // If rule matches add it to the correct output and continue with next item + checkIndexRange(ruleData.output as number); + returnData[ruleData.output as number].push(item); + continue itemLoop; + } + } + + // Check if a fallback output got defined and route accordingly + outputIndex = this.getNodeParameter('fallbackOutput', itemIndex) as number; + if (outputIndex !== -1) { + checkIndexRange(outputIndex); + returnData[outputIndex].push(item); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData[0].push({ json: { error: error.message } }); + continue; + } + throw error; + } + } + + return returnData; + } +} diff --git a/packages/nodes-base/nodes/Switch/test/switch.expression.workflow.json b/packages/nodes-base/nodes/Switch/V1/test/switch.expression.workflow.json similarity index 100% rename from packages/nodes-base/nodes/Switch/test/switch.expression.workflow.json rename to packages/nodes-base/nodes/Switch/V1/test/switch.expression.workflow.json diff --git a/packages/nodes-base/nodes/Switch/test/switch.node.test.ts b/packages/nodes-base/nodes/Switch/V1/test/switch.node.test.ts similarity index 100% rename from packages/nodes-base/nodes/Switch/test/switch.node.test.ts rename to packages/nodes-base/nodes/Switch/V1/test/switch.node.test.ts diff --git a/packages/nodes-base/nodes/Switch/test/switch.rules.workflow.json b/packages/nodes-base/nodes/Switch/V1/test/switch.rules.workflow.json similarity index 100% rename from packages/nodes-base/nodes/Switch/test/switch.rules.workflow.json rename to packages/nodes-base/nodes/Switch/V1/test/switch.rules.workflow.json diff --git a/packages/nodes-base/nodes/Switch/V2/SwitchV2.node.ts b/packages/nodes-base/nodes/Switch/V2/SwitchV2.node.ts new file mode 100644 index 0000000000..5fa67e83db --- /dev/null +++ b/packages/nodes-base/nodes/Switch/V2/SwitchV2.node.ts @@ -0,0 +1,706 @@ +import type { + IExecuteFunctions, + ILoadOptionsFunctions, + INodeExecutionData, + INodeParameters, + INodePropertyOptions, + INodeType, + INodeTypeBaseDescription, + INodeTypeDescription, + NodeParameterValue, +} from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; + +export class SwitchV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: [2], + defaults: { + name: 'Switch', + color: '#506000', + }, + inputs: ['main'], + // eslint-disable-next-line prettier/prettier + outputs: `={{ + ((parameters) => { + const rules = parameters.rules?.rules ?? []; + const mode = parameters.mode; + + if (mode === 'expression') { + return Array + .from( + { length: parameters.outputsAmount }, + (_, i) => ({ type: "${NodeConnectionType.Main}", displayName: i.toString() }) + ) + } + + + return rules.map(value => { + return { type: "${NodeConnectionType.Main}", displayName: value.outputKey } + }) + })($parameter) + }}`, + + properties: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Expression', + value: 'expression', + description: 'Expression decides how to route data', + }, + { + name: 'Rules', + value: 'rules', + description: 'Rules decide how to route data', + }, + ], + default: 'rules', + description: 'How data should be routed', + }, + + // ---------------------------------- + // mode:expression + // ---------------------------------- + { + displayName: 'Output', + name: 'output', + type: 'string', + displayOptions: { + show: { + mode: ['expression'], + }, + }, + default: '', + description: 'The index of output to which to send data to', + }, + + { + displayName: 'Outputs Amount', + name: 'outputsAmount', + type: 'number', + displayOptions: { + show: { + mode: ['expression'], + }, + }, + default: 4, + description: 'Amount of outputs to create', + }, + + // ---------------------------------- + // mode:rules + // ---------------------------------- + { + displayName: 'Data Type', + name: 'dataType', + type: 'options', + displayOptions: { + show: { + mode: ['rules'], + }, + }, + options: [ + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Date & Time', + value: 'dateTime', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'String', + value: 'string', + }, + ], + default: 'number', + description: 'The type of data to route on', + }, + + // ---------------------------------- + // dataType:boolean + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'boolean', + displayOptions: { + show: { + dataType: ['boolean'], + mode: ['rules'], + }, + }, + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: 'The value to compare with the first one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['boolean'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Boolean', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'boolean', + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: 'The value to compare with the first one', + }, + { + displayName: 'Output Key', + name: 'outputKey', + type: 'string', + default: '', + description: 'The label of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:dateTime + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'dateTime', + displayOptions: { + show: { + dataType: ['dateTime'], + mode: ['rules'], + }, + }, + default: '', + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['dateTime'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Dates', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Occurred After', + value: 'after', + }, + { + name: 'Occurred Before', + value: 'before', + }, + ], + default: 'after', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'dateTime', + default: 0, + description: 'The value to compare with the first one', + }, + { + displayName: 'Output Key', + name: 'outputKey', + type: 'string', + default: '', + description: 'The label of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:number + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'number', + displayOptions: { + show: { + dataType: ['number'], + mode: ['rules'], + }, + }, + default: 0, + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['number'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Numbers', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Smaller', + value: 'smaller', + }, + { + name: 'Smaller Equal', + value: 'smallerEqual', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Larger', + value: 'larger', + }, + { + name: 'Larger Equal', + value: 'largerEqual', + }, + ], + default: 'smaller', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'number', + default: 0, + description: 'The value to compare with the first one', + }, + { + displayName: 'Output Key', + name: 'outputKey', + type: 'string', + default: '', + description: 'The label of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + // ---------------------------------- + // dataType:string + // ---------------------------------- + { + displayName: 'Value 1', + name: 'value1', + type: 'string', + displayOptions: { + show: { + dataType: ['string'], + mode: ['rules'], + }, + }, + default: '', + description: 'The value to compare with the second one', + }, + { + displayName: 'Routing Rules', + name: 'rules', + placeholder: 'Add Routing Rule', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + dataType: ['string'], + mode: ['rules'], + }, + }, + default: {}, + options: [ + { + name: 'rules', + displayName: 'Strings', + values: [ + // eslint-disable-next-line n8n-nodes-base/node-param-operation-without-no-data-expression + { + displayName: 'Operation', + name: 'operation', + type: 'options', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: [ + { + name: 'Contains', + value: 'contains', + }, + { + name: 'Not Contains', + value: 'notContains', + }, + { + name: 'Ends With', + value: 'endsWith', + }, + { + name: 'Not Ends With', + value: 'notEndsWith', + }, + { + name: 'Equal', + value: 'equal', + }, + { + name: 'Not Equal', + value: 'notEqual', + }, + { + name: 'Regex Match', + value: 'regex', + }, + { + name: 'Regex Not Match', + value: 'notRegex', + }, + { + name: 'Starts With', + value: 'startsWith', + }, + { + name: 'Not Starts With', + value: 'notStartsWith', + }, + ], + default: 'equal', + description: 'Operation to decide where the the data should be mapped to', + }, + { + displayName: 'Value 2', + name: 'value2', + type: 'string', + displayOptions: { + hide: { + operation: ['regex', 'notRegex'], + }, + }, + default: '', + description: 'The value to compare with the first one', + }, + { + displayName: 'Regex', + name: 'value2', + type: 'string', + displayOptions: { + show: { + operation: ['regex', 'notRegex'], + }, + }, + default: '', + placeholder: '/text/i', + description: 'The regex which has to match', + }, + { + displayName: 'Output Key', + name: 'outputKey', + type: 'string', + default: '', + description: 'The label of output to which to send data to if rule matches', + }, + ], + }, + ], + }, + + { + displayName: 'Fallback Output Name or ID', + name: 'fallbackOutput', + type: 'options', + displayOptions: { + show: { + mode: ['rules'], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + typeOptions: { + loadOptionsDependsOn: ['rules.rules'], + loadOptionsMethod: 'getFallbackOutputOptions', + }, + default: -1, + description: + 'The output to which to route all items which do not match any of the rules. Choose from the list, or specify an ID using an expression.', + }, + ], + }; + } + + methods = { + loadOptions: { + async getFallbackOutputOptions(this: ILoadOptionsFunctions): Promise { + const rules = (this.getCurrentNodeParameter('rules.rules') as INodeParameters[]) ?? []; + const options = rules.map((rule, index) => ({ + name: `${index} ${rule.outputKey as string}`, + value: index, + })); + + options.unshift({ + name: 'None', + value: -1, + }); + + return options; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + let returnData: INodeExecutionData[][] = []; + + const items = this.getInputData(); + + let compareOperationResult: boolean; + let item: INodeExecutionData; + let mode: string; + let outputIndex: number; + let ruleData: INodeParameters; + + // The compare operations + const compareOperationFunctions: { + [key: string]: (value1: NodeParameterValue, value2: NodeParameterValue) => boolean; + } = { + after: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) > (value2 || 0), + before: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) < (value2 || 0), + contains: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || '').toString().includes((value2 || '').toString()), + notContains: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 || '').toString().includes((value2 || '').toString()), + endsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).endsWith(value2 as string), + notEndsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 as string).endsWith(value2 as string), + equal: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 === value2, + notEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => value1 !== value2, + larger: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) > (value2 || 0), + largerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) >= (value2 || 0), + smaller: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) < (value2 || 0), + smallerEqual: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 || 0) <= (value2 || 0), + startsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + (value1 as string).startsWith(value2 as string), + notStartsWith: (value1: NodeParameterValue, value2: NodeParameterValue) => + !(value1 as string).startsWith(value2 as string), + regex: (value1: NodeParameterValue, value2: NodeParameterValue) => { + const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); + + let regex: RegExp; + if (!regexMatch) { + regex = new RegExp((value2 || '').toString()); + } else if (regexMatch.length === 1) { + regex = new RegExp(regexMatch[1]); + } else { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } + + return !!(value1 || '').toString().match(regex); + }, + notRegex: (value1: NodeParameterValue, value2: NodeParameterValue) => { + const regexMatch = (value2 || '').toString().match(new RegExp('^/(.*?)/([gimusy]*)$')); + + let regex: RegExp; + if (!regexMatch) { + regex = new RegExp((value2 || '').toString()); + } else if (regexMatch.length === 1) { + regex = new RegExp(regexMatch[1]); + } else { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } + + return !(value1 || '').toString().match(regex); + }, + }; + + // Converts the input data of a dateTime into a number for easy compare + const convertDateTime = (value: NodeParameterValue): number => { + let returnValue: number | undefined = undefined; + if (typeof value === 'string') { + returnValue = new Date(value).getTime(); + } else if (typeof value === 'number') { + returnValue = value; + } + if ((value as unknown as object) instanceof Date) { + returnValue = (value as unknown as Date).getTime(); + } + + if (returnValue === undefined || isNaN(returnValue)) { + throw new NodeOperationError( + this.getNode(), + `The value "${value}" is not a valid DateTime.`, + ); + } + + return returnValue; + }; + + const checkIndexRange = (index: number) => { + if (index < 0 || index >= returnData.length) { + throw new NodeOperationError( + this.getNode(), + `The ouput ${index} is not allowed. It has to be between 0 and ${returnData.length - 1}!`, + ); + } + }; + + // Iterate over all items to check to which output they should be routed to + itemLoop: for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + item = items[itemIndex]; + const rules = this.getNodeParameter('rules.rules', itemIndex, []) as INodeParameters[]; + mode = this.getNodeParameter('mode', itemIndex) as string; + + if (mode === 'expression') { + const outputsAmount = this.getNodeParameter('outputsAmount', itemIndex) as number; + if (itemIndex === 0) { + returnData = new Array(outputsAmount).fill(0).map(() => []); + } + // One expression decides how to route item + outputIndex = this.getNodeParameter('output', itemIndex) as number; + checkIndexRange(outputIndex); + + returnData[outputIndex].push(item); + } else if (mode === 'rules') { + // Rules decide how to route item + if (itemIndex === 0) { + returnData = new Array(rules.length).fill(0).map(() => []); + } + const dataType = this.getNodeParameter('dataType', 0) as string; + + let value1 = this.getNodeParameter('value1', itemIndex) as NodeParameterValue; + if (dataType === 'dateTime') { + value1 = convertDateTime(value1); + } + + for (ruleData of rules) { + // Check if the values passes + + let value2 = ruleData.value2 as NodeParameterValue; + if (dataType === 'dateTime') { + value2 = convertDateTime(value2); + } + + compareOperationResult = compareOperationFunctions[ruleData.operation as string]( + value1, + value2, + ); + + if (compareOperationResult) { + // If rule matches add it to the correct output and continue with next item + checkIndexRange(ruleData.output as number); + + const ruleIndex = rules.indexOf(ruleData); + returnData[ruleIndex].push(item); + continue itemLoop; + } + } + + // Check if a fallback output got defined and route accordingly + outputIndex = this.getNodeParameter('fallbackOutput', itemIndex) as number; + if (outputIndex !== -1) { + checkIndexRange(outputIndex); + returnData[outputIndex].push(item); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData[0].push({ json: { error: error.message } }); + continue; + } + throw error; + } + } + + return returnData; + } +} diff --git a/packages/nodes-base/nodes/Switch/V2/test/switch.expression.workflow.json b/packages/nodes-base/nodes/Switch/V2/test/switch.expression.workflow.json new file mode 100644 index 0000000000..18498d4d07 --- /dev/null +++ b/packages/nodes-base/nodes/Switch/V2/test/switch.expression.workflow.json @@ -0,0 +1,171 @@ +{ + "name": "My workflow 109", + "nodes": [ + { + "parameters": {}, + "id": "7ae16f96-5c2c-44a3-9f96-167e426336f9", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 620, + 720 + ] + }, + { + "parameters": { + "jsCode": "return [{\n \"output\": \"third\",\n \"text\": \"third output text\"\n}, {\n \"output\": \"fourth\",\n \"text\": \"fourth output text\"\n}, {\n \"output\": \"first\",\n \"text\": \"first output text\"\n}, {\n \"output\": \"second\",\n \"text\": \"second output text\"\n}]" + }, + "id": "31e9aada-7aa2-4c62-8e15-0cecb91788e4", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 840, + 720 + ] + }, + { + "parameters": {}, + "id": "cf10b4c7-16a6-4c16-a17c-7b83f954f7b9", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 560 + ] + }, + { + "parameters": {}, + "id": "3e7e7f4a-bff9-4ce1-a5e5-58505853260f", + "name": "No Operation, do nothing1", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 720 + ] + }, + { + "parameters": {}, + "id": "205f59d6-52f5-4412-9511-b680a91d0be2", + "name": "No Operation, do nothing2", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 880 + ] + }, + { + "parameters": { + "mode": "expression", + "output": "={{ Math.max(0, ['first', 'second', 'third'].indexOf( $json.output)) }}", + "outputsAmount": 3 + }, + "id": "9c3dc163-0103-45c2-8455-e6ab3e84679c", + "name": "Switch1", + "type": "n8n-nodes-base.switch", + "typeVersion": 2, + "position": [ + 1100, + 720 + ] + } + ], + "pinData": { + "No Operation, do nothing2": [ + { + "json": { + "output": "third", + "text": "third output text" + } + } + ], + "No Operation, do nothing1": [ + { + "json": { + "output": "second", + "text": "second output text" + } + + } + ], + "No Operation, do nothing": [ + { + "json": { + "output": "fourth", + "text": "fourth output text" + } + + }, + { + "json": { + "output": "first", + "text": "first output text" + } + + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "Switch1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch1": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Operation, do nothing1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Operation, do nothing2", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "cca0f0b9-d01e-435b-9125-9616007f4aea", + "id": "xjPY8ZYJK53G6nQ1", + "meta": { + "instanceId": "ec7a5f4ffdb34436e59d23eaccb5015b5238de2a877e205b28572bf1ffecfe04" + }, + "tags": [] +} diff --git a/packages/nodes-base/nodes/Switch/V2/test/switch.node.test.ts b/packages/nodes-base/nodes/Switch/V2/test/switch.node.test.ts new file mode 100644 index 0000000000..ab506aa481 --- /dev/null +++ b/packages/nodes-base/nodes/Switch/V2/test/switch.node.test.ts @@ -0,0 +1,4 @@ +import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers'; +const workflows = getWorkflowFilenames(__dirname); + +describe('Execute Switch Node', () => testWorkflows(workflows)); diff --git a/packages/nodes-base/nodes/Switch/V2/test/switch.rules.workflow.json b/packages/nodes-base/nodes/Switch/V2/test/switch.rules.workflow.json new file mode 100644 index 0000000000..516f9cac74 --- /dev/null +++ b/packages/nodes-base/nodes/Switch/V2/test/switch.rules.workflow.json @@ -0,0 +1,187 @@ +{ + "name": "My workflow 109", + "nodes": [ + { + "parameters": {}, + "id": "7ae16f96-5c2c-44a3-9f96-167e426336f9", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 620, + 720 + ] + }, + { + "parameters": { + "jsCode": "return [{\n \"output\": \"third\",\n \"text\": \"third output text\"\n}, {\n \"output\": \"fourth\",\n \"text\": \"fourth output text\"\n}, {\n \"output\": \"first\",\n \"text\": \"first output text\"\n}, {\n \"output\": \"second\",\n \"text\": \"second output text\"\n}]" + }, + "id": "31e9aada-7aa2-4c62-8e15-0cecb91788e4", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 840, + 720 + ] + }, + { + "parameters": { + "dataType": "string", + "value1": "={{ $json.output }}", + "rules": { + "rules": [ + { + "value2": "first", + "outputKey": "First Output" + }, + { + "value2": "second", + "outputKey": "Second Output" + }, + { + "value2": "third", + "outputKey": "Third Output" + } + ] + }, + "fallbackOutput": 2 + }, + "id": "0dd6e98a-2830-42fb-9a9d-6d4ff8678cbd", + "name": "Switch", + "type": "n8n-nodes-base.switch", + "typeVersion": 2, + "position": [ + 1120, + 720 + ] + }, + { + "parameters": {}, + "id": "cf10b4c7-16a6-4c16-a17c-7b83f954f7b9", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 560 + ] + }, + { + "parameters": {}, + "id": "3e7e7f4a-bff9-4ce1-a5e5-58505853260f", + "name": "No Operation, do nothing1", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 720 + ] + }, + { + "parameters": {}, + "id": "205f59d6-52f5-4412-9511-b680a91d0be2", + "name": "No Operation, do nothing2", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 1380, + 880 + ] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "output": "first", + "text": "first output text" + } + + } + ], + "No Operation, do nothing1": [ + { + "json": { + "output": "second", + "text": "second output text" + } + + } + ], + "No Operation, do nothing2": [ + { + "json": { + "output": "third", + "text": "third output text" + } + + }, + { + "json": { + "output": "fourth", + "text": "fourth output text" + } + } + ] + }, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "Switch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Operation, do nothing1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "No Operation, do nothing2", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "627af20f-47fc-47a7-8da6-a7e7b21225df", + "id": "xjPY8ZYJK53G6nQ1", + "meta": { + "instanceId": "ec7a5f4ffdb34436e59d23eaccb5015b5238de2a877e205b28572bf1ffecfe04" + }, + "tags": [] +} diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts index 9ccc55d4b0..e1ae6e5365 100644 --- a/packages/workflow/src/NodeHelpers.ts +++ b/packages/workflow/src/NodeHelpers.ts @@ -1027,7 +1027,7 @@ export function getNodeInputs( node: INode, nodeTypeData: INodeTypeDescription, ): Array { - if (Array.isArray(nodeTypeData.inputs)) { + if (Array.isArray(nodeTypeData?.inputs)) { return nodeTypeData.inputs; }