feat(Switch Node): Add support for infinite Switch outputs (#7499)

Github issue / Community forum post (link here to close automatically):
https://community.n8n.io/t/add-more-outputs-to-switch-node/3864

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
OlegIvaniv 2023-10-25 14:34:47 +02:00 committed by GitHub
parent 6f45298d3d
commit 2febc61ec9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1933 additions and 751 deletions

View file

@ -43,7 +43,9 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.getters WorkflowPage.getters
.canvasNodeByName('Code') .canvasNodeByName('Code')
.should('have.attr', 'style', 'left: 860px; top: 220px;'); .should('have.css', 'left', '860px')
.should('have.css', 'top', '220px')
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2); WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 1);
@ -59,7 +61,8 @@ describe('Undo/Redo', () => {
// Last node should be added back to original position // Last node should be added back to original position
WorkflowPage.getters WorkflowPage.getters
.canvasNodeByName('Code') .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', () => { 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 }); cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true });
WorkflowPage.getters WorkflowPage.getters
.canvasNodeByName('Code') .canvasNodeByName('Code')
.should('have.attr', 'style', 'left: 740px; top: 320px;'); .should('have.css', 'left', '740px')
.should('have.css', 'top', '320px')
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters WorkflowPage.getters
.canvasNodeByName('Code') .canvasNodeByName('Code')
.should('have.attr', 'style', 'left: 640px; top: 220px;'); .should('have.css', 'left', '640px')
.should('have.css', 'top', '220px')
WorkflowPage.actions.hitRedo(); WorkflowPage.actions.hitRedo();
WorkflowPage.getters WorkflowPage.getters
.canvasNodeByName('Code') .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', () => { it('should undo/redo deleting a connection by pressing delete button', () => {
@ -269,8 +276,8 @@ describe('Undo/Redo', () => {
}); });
it('should undo/redo multiple steps', () => { it('should undo/redo multiple steps', () => {
const initialPosition = 'left: 420px; top: 220px;'; const initialPosition = {left: '420px', top: '220px'};
const movedPosition = 'left: 540px; top: 360px;'; const movedPosition = {left: '540px', top: '360px'};
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -283,10 +290,17 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.actions.hitDisableNodeShortcut();
// Move first one // 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(); WorkflowPage.getters.canvasNodes().first().click();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true }); 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 // Delete the set node
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click(); WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click();
cy.get('body').type('{backspace}'); cy.get('body').type('{backspace}');
@ -297,7 +311,10 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 3);
// Second undo: Should move first node to it's original position // Second undo: Should move first node to it's original position
WorkflowPage.actions.hitUndo(); 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 // Third undo: Should enable last node
WorkflowPage.actions.hitUndo(); WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0); WorkflowPage.getters.disabledNodes().should('have.length', 0);
@ -307,7 +324,10 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.disabledNodes().should('have.length', 1); WorkflowPage.getters.disabledNodes().should('have.length', 1);
// Second redo: Should move the first node // Second redo: Should move the first node
WorkflowPage.actions.hitRedo(); 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 // Third redo: Should delete the Set node
WorkflowPage.actions.hitRedo(); WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.canvasNodes().should('have.length', 3);

View file

@ -133,7 +133,8 @@ describe('Canvas Actions', () => {
WorkflowPage.getters WorkflowPage.getters
.canvasNodes() .canvasNodes()
.last() .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', () => { it('should delete connections by pressing the delete button', () => {

View file

@ -8,11 +8,11 @@ import {
MERGE_NODE_NAME, MERGE_NODE_NAME,
} from './../constants'; } from './../constants';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { WorkflowExecutionsTab } from '../pages'; import { NDV, WorkflowExecutionsTab } from '../pages';
const WorkflowPage = new WorkflowPageClass(); const WorkflowPage = new WorkflowPageClass();
const ExecutionsTab = new WorkflowExecutionsTab(); const ExecutionsTab = new WorkflowExecutionsTab();
const NDVDialog = new NDV();
const DEFAULT_ZOOM_FACTOR = 1; const DEFAULT_ZOOM_FACTOR = 1;
const ZOOM_IN_X1_FACTOR = 1.25; // Zoom in factor after one click 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 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', () => { 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 < desiredOutputs; i++) {
for (let i = 0; i < 4; 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.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false); WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
@ -42,7 +47,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.reload(); cy.reload();
cy.waitForLoad(); cy.waitForLoad();
// Make sure all connections are there after reload // 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 : ''}`; const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
WorkflowPage.getters WorkflowPage.getters
.canvasNodeInputEndpointByName(setName) .canvasNodeInputEndpointByName(setName)
@ -167,7 +172,8 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters WorkflowPage.getters
.canvasNodes() .canvasNodes()
.last() .last()
.should('have.attr', 'style', 'left: 740px; top: 320px;'); .should('have.css', 'left', '740px')
.should('have.css', 'top', '320px')
}); });
it('should zoom in', () => { it('should zoom in', () => {

View file

@ -184,7 +184,14 @@ import { nodeHelpers } from '@/mixins/nodeHelpers';
import { workflowHelpers } from '@/mixins/workflowHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers';
import { pinData } from '@/mixins/pinData'; 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 { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue'; import NodeIcon from '@/components/NodeIcon.vue';
@ -357,9 +364,15 @@ export default defineComponent({
top: this.position[1] + 'px', 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<ConnectionTypes | INodeInputConfiguration>);
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const nonMainInputs = inputTypes.filter((input) => input !== NodeConnectionType.Main);
if (nonMainInputs.length) { if (nonMainInputs.length) {
const requiredNonMainInputs = this.inputs.filter( const requiredNonMainInputs = inputs.filter(
(input) => typeof input !== 'string' && input.required, (input) => typeof input !== 'string' && input.required,
); );
@ -373,6 +386,15 @@ export default defineComponent({
styles['--configurable-node-input-count'] = nonMainInputs.length + spacerCount; styles['--configurable-node-input-count'] = nonMainInputs.length + spacerCount;
} }
const outputs =
NodeHelpers.getNodeOutputs(workflow, this.node, this.nodeType) ||
([] as Array<ConnectionTypes | INodeOutputConfiguration>);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const mainOutputs = outputTypes.filter((output) => output === NodeConnectionType.Main);
styles['--node-main-output-count'] = mainOutputs.length;
return styles; return styles;
}, },
nodeClass(): object { nodeClass(): object {
@ -698,7 +720,12 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.node-wrapper { .node-wrapper {
--node-width: 100px; --node-width: 100px;
--node-height: 100px; /*
Set the node height to 100px as a base.
Increase height by 20px for each output beyond the 4th one.
max(0, var(--node-main-output-count, 1) - 4) ensures that we only start counting after the 4th output.
*/
--node-height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 20px);
--configurable-node-min-input-count: 4; --configurable-node-min-input-count: 4;
--configurable-node-input-width: 65px; --configurable-node-input-width: 65px;
@ -1158,6 +1185,7 @@ export default defineComponent({
--endpoint-size-small: 14px; --endpoint-size-small: 14px;
--endpoint-size-medium: 18px; --endpoint-size-medium: 18px;
--stalk-size: 40px; --stalk-size: 40px;
--stalk-switch-size: 60px;
--stalk-success-size: 87px; --stalk-success-size: 87px;
--stalk-success-size-without-label: 40px; --stalk-success-size-without-label: 40px;
--stalk-long-size: 127px; --stalk-long-size: 127px;
@ -1405,6 +1433,15 @@ export default defineComponent({
margin-top: calc(var(--spacing-l) * -1); margin-top: calc(var(--spacing-l) * -1);
margin-left: 0; margin-left: 0;
} }
// Switch node allows for dynamic connection labels
// so we need to make sure the label does not overflow
&[data-endpoint-node-type='n8n-nodes-base.switch'] {
max-width: calc(var(--stalk-size) - (var(--endpoint-size-small)));
overflow: hidden;
text-overflow: ellipsis;
margin-left: calc(var(--endpoint-size-small) + var(--spacing-2xs) + 10px);
}
} }
.node-input-endpoint-label { .node-input-endpoint-label {
@ -1446,6 +1483,7 @@ export default defineComponent({
opacity: 1; opacity: 1;
} }
} }
.long-stalk { .long-stalk {
--stalk-size: var(--stalk-long-size); --stalk-size: var(--stalk-long-size);
} }
@ -1455,4 +1493,7 @@ export default defineComponent({
.ep-success--without-label { .ep-success--without-label {
--stalk-size: var(--stalk-success-size-without-label); --stalk-size: var(--stalk-success-size-without-label);
} }
[data-endpoint-node-type='n8n-nodes-base.switch'] {
--stalk-size: var(--stalk-switch-size);
}
</style> </style>

View file

@ -502,6 +502,7 @@ import type {
IBinaryKeyData, IBinaryKeyData,
IDataObject, IDataObject,
INodeExecutionData, INodeExecutionData,
INodeOutputConfiguration,
INodeTypeDescription, INodeTypeDescription,
IRunData, IRunData,
IRunExecutionData, IRunExecutionData,
@ -543,6 +544,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useToast } from '@/composables'; import { useToast } from '@/composables';
import { isObject } from 'lodash-es';
const RunDataTable = defineAsyncComponent(async () => import('@/components/RunDataTable.vue')); const RunDataTable = defineAsyncComponent(async () => import('@/components/RunDataTable.vue'));
const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue')); const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue'));
@ -891,9 +893,8 @@ export default defineComponent({
return this.outputIndex; return this.outputIndex;
}, },
branches(): ITab[] { branches(): ITab[] {
function capitalize(name: string) { const capitalize = (name: string) => name.charAt(0).toLocaleUpperCase() + name.slice(1);
return name.charAt(0).toLocaleUpperCase() + name.slice(1);
}
const branches: ITab[] = []; const branches: ITab[] = [];
for (let i = 0; i <= this.maxOutputIndex; i++) { for (let i = 0; i <= this.maxOutputIndex; i++) {
@ -903,6 +904,7 @@ export default defineComponent({
const itemsCount = this.getDataCount(this.runIndex, i); const itemsCount = this.getDataCount(this.runIndex, i);
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount }); const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount });
let outputName = this.getOutputName(i); let outputName = this.getOutputName(i);
if (`${outputName}` === `${i}`) { if (`${outputName}` === `${i}`) {
outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`; outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`;
} else { } else {
@ -936,6 +938,18 @@ export default defineComponent({
}, },
}, },
methods: { 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) { onItemHover(itemIndex: number | null) {
if (itemIndex === null) { if (itemIndex === null) {
this.$emit('itemHover', null); this.$emit('itemHover', null);
@ -1287,9 +1301,7 @@ export default defineComponent({
this.closeBinaryDataDisplay(); this.closeBinaryDataDisplay();
let outputTypes: ConnectionTypes[] = []; let outputTypes: ConnectionTypes[] = [];
if (this.nodeType !== null && this.node !== null) { if (this.nodeType !== null && this.node !== null) {
const workflow = this.workflowsStore.getCurrentWorkflow(); const outputs = this.getResolvedNodeOutputs();
const workflowNode = workflow.getNode(this.node.name);
const outputs = NodeHelpers.getNodeOutputs(workflow, workflowNode, this.nodeType);
outputTypes = NodeHelpers.getConnectionTypes(outputs); outputTypes = NodeHelpers.getConnectionTypes(outputs);
} }
this.connectionType = outputTypes.length === 0 ? NodeConnectionType.Main : outputTypes[0]; this.connectionType = outputTypes.length === 0 ? NodeConnectionType.Main : outputTypes[0];
@ -1367,6 +1379,12 @@ export default defineComponent({
} }
const nodeType = this.nodeType; 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) { if (!nodeType?.outputNames || nodeType.outputNames.length <= outputIndex) {
return outputIndex + 1; return outputIndex + 1;
} }

View file

@ -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 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']; export const ALLOWED_HTML_ATTRIBUTES = ['href', 'name', 'target', 'title', 'class', 'id', 'style'];

View file

@ -261,6 +261,7 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId, nodeId: this.nodeId,
index: typeIndex, index: typeIndex,
totalEndpoints: inputsOfSameRootType.length, totalEndpoints: inputsOfSameRootType.length,
nodeType: node.type,
}; };
} }
@ -423,6 +424,7 @@ export const nodeBase = defineComponent({
this.$refs[this.data.name] as Element, this.$refs[this.data.name] as Element,
newEndpointData, newEndpointData,
); );
this.__addEndpointTestingData(endpoint, 'output', typeIndex); this.__addEndpointTestingData(endpoint, 'output', typeIndex);
if (outputConfiguration.displayName || nodeTypeData.outputNames?.[i]) { if (outputConfiguration.displayName || nodeTypeData.outputNames?.[i]) {
// Apply output names if they got set // Apply output names if they got set
@ -439,6 +441,7 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId, nodeId: this.nodeId,
index: typeIndex, index: typeIndex,
totalEndpoints: outputsOfSameRootType.length, totalEndpoints: outputsOfSameRootType.length,
nodeType: node.type,
}; };
} }
@ -454,6 +457,7 @@ export const nodeBase = defineComponent({
connectedEndpoint: endpoint, connectedEndpoint: endpoint,
showOutputLabel: outputs.length === 1, showOutputLabel: outputs.length === 1,
size: outputs.length >= 3 ? 'small' : 'medium', size: outputs.length >= 3 ? 'small' : 'medium',
nodeType: node.type,
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'), hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
}, },
}, },
@ -486,6 +490,7 @@ export const nodeBase = defineComponent({
nodeName: node.name, nodeName: node.name,
nodeId: this.nodeId, nodeId: this.nodeId,
index: typeIndex, index: typeIndex,
nodeType: node.type,
totalEndpoints: outputsOfSameRootType.length, totalEndpoints: outputsOfSameRootType.length,
}; };
} }

View file

@ -14,6 +14,7 @@ interface N8nPlusEndpointParams extends EndpointRepresentationParams {
dimensions: number; dimensions: number;
connectedEndpoint: Endpoint; connectedEndpoint: Endpoint;
hoverMessage: string; hoverMessage: string;
nodeType: string;
size: 'small' | 'medium'; size: 'small' | 'medium';
showOutputLabel: boolean; showOutputLabel: boolean;
} }
@ -23,15 +24,17 @@ export const N8nPlusEndpointType = 'N8nPlus';
export const EVENT_PLUS_ENDPOINT_CLICK = 'eventPlusEndpointClick'; export const EVENT_PLUS_ENDPOINT_CLICK = 'eventPlusEndpointClick';
export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpoint> { export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpoint> {
params: N8nPlusEndpointParams; params: N8nPlusEndpointParams;
label: string; label: string;
stalkOverlay: Overlay | null; stalkOverlay: Overlay | null;
messageOverlay: Overlay | null; messageOverlay: Overlay | null;
constructor(endpoint: Endpoint, params: N8nPlusEndpointParams) { constructor(endpoint: Endpoint, params: N8nPlusEndpointParams) {
super(endpoint, params); super(endpoint, params);
this.params = params; this.params = params;
this.label = ''; this.label = '';
this.stalkOverlay = null; this.stalkOverlay = null;
this.messageOverlay = null; this.messageOverlay = null;
@ -41,6 +44,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
} }
static type = N8nPlusEndpointType; static type = N8nPlusEndpointType;
type = N8nPlusEndpoint.type; type = N8nPlusEndpoint.type;
setupOverlays() { setupOverlays() {
@ -50,6 +54,9 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
type: 'Custom', type: 'Custom',
options: { options: {
id: PlusStalkOverlay, id: PlusStalkOverlay,
attributes: {
'data-endpoint-node-type': this.params.nodeType,
},
create: () => { create: () => {
const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`); const stalk = createElement('div', {}, `${PlusStalkOverlay} ${this.params.size}`);
return stalk; return stalk;
@ -61,6 +68,9 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
options: { options: {
id: HoverMessageOverlay, id: HoverMessageOverlay,
location: 0.5, location: 0.5,
attributes: {
'data-endpoint-node-type': this.params.nodeType,
},
create: () => { create: () => {
const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`); const hoverMessage = createElement('p', {}, `${HoverMessageOverlay} ${this.params.size}`);
hoverMessage.innerHTML = this.params.hoverMessage; hoverMessage.innerHTML = this.params.hoverMessage;
@ -70,23 +80,27 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
}); });
this.endpoint.instance.setSuspendDrawing(false); this.endpoint.instance.setSuspendDrawing(false);
} }
bindEvents() { bindEvents() {
this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible); this.instance.bind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible);
this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible); this.instance.bind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible);
this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
this.instance.bind(EVENT_CONNECTION_ABORT, this.setStalkLabels); this.instance.bind(EVENT_CONNECTION_ABORT, this.setStalkLabels);
} }
unbindEvents() { unbindEvents() {
this.instance.unbind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible); this.instance.unbind(EVENT_ENDPOINT_MOUSEOVER, this.setHoverMessageVisible);
this.instance.unbind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible); this.instance.unbind(EVENT_ENDPOINT_MOUSEOUT, this.unsetHoverMessageVisible);
this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); this.instance.unbind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
this.instance.unbind(EVENT_CONNECTION_ABORT, this.setStalkLabels); this.instance.unbind(EVENT_CONNECTION_ABORT, this.setStalkLabels);
} }
setStalkLabels = () => { setStalkLabels = () => {
if (!this.endpoint) return; if (!this.endpoint) return;
const stalkOverlay = this.endpoint.getOverlay(PlusStalkOverlay); const stalkOverlay = this.endpoint.getOverlay(PlusStalkOverlay);
const messageOverlay = this.endpoint.getOverlay(HoverMessageOverlay); const messageOverlay = this.endpoint.getOverlay(HoverMessageOverlay);
if (stalkOverlay && messageOverlay) { if (stalkOverlay && messageOverlay) {
// Increase the size of the stalk overlay if the label is too long // Increase the size of the stalk overlay if the label is too long
const fnKey = this.label.length > 10 ? 'add' : 'remove'; const fnKey = this.label.length > 10 ? 'add' : 'remove';
@ -100,21 +114,25 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
} }
} }
}; };
fireClickEvent = (endpoint: Endpoint) => { fireClickEvent = (endpoint: Endpoint) => {
if (endpoint === this.endpoint) { if (endpoint === this.endpoint) {
this.instance.fire(EVENT_PLUS_ENDPOINT_CLICK, this.endpoint); this.instance.fire(EVENT_PLUS_ENDPOINT_CLICK, this.endpoint);
} }
}; };
setHoverMessageVisible = (endpoint: Endpoint) => { setHoverMessageVisible = (endpoint: Endpoint) => {
if (endpoint === this.endpoint && this.messageOverlay) { if (endpoint === this.endpoint && this.messageOverlay) {
this.instance.addOverlayClass(this.messageOverlay, 'visible'); this.instance.addOverlayClass(this.messageOverlay, 'visible');
} }
}; };
unsetHoverMessageVisible = (endpoint: Endpoint) => { unsetHoverMessageVisible = (endpoint: Endpoint) => {
if (endpoint === this.endpoint && this.messageOverlay) { if (endpoint === this.endpoint && this.messageOverlay) {
this.instance.removeOverlayClass(this.messageOverlay, 'visible'); this.instance.removeOverlayClass(this.messageOverlay, 'visible');
} }
}; };
clearOverlays() { clearOverlays() {
Object.keys(this.endpoint.getOverlays()).forEach((key) => { Object.keys(this.endpoint.getOverlays()).forEach((key) => {
this.endpoint.removeOverlay(key); this.endpoint.removeOverlay(key);
@ -122,6 +140,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
this.stalkOverlay = null; this.stalkOverlay = null;
this.messageOverlay = null; this.messageOverlay = null;
} }
getConnections() { getConnections() {
const connections = [ const connections = [
...this.endpoint.connections, ...this.endpoint.connections,
@ -130,6 +149,7 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
return connections; return connections;
} }
setIsVisible(visible: boolean) { setIsVisible(visible: boolean) {
this.instance.setSuspendDrawing(true); this.instance.setSuspendDrawing(true);
Object.keys(this.endpoint.getOverlays()).forEach((overlay) => { Object.keys(this.endpoint.getOverlays()).forEach((overlay) => {
@ -179,6 +199,7 @@ export const N8nPlusEndpointHandler: EndpointHandler<N8nPlusEndpoint, ComputedN8
ep.w = w; ep.w = w;
ep.h = h; ep.h = h;
ep.canvas?.setAttribute('data-endpoint-node-type', ep.params.nodeType);
ep.addClass('plus-endpoint'); ep.addClass('plus-endpoint');
return [x, y, w, h, ep.params.dimensions]; return [x, y, w, h, ep.params.dimensions];
}, },

View file

@ -181,46 +181,18 @@ export const getAnchorPosition = (
spacerIndexes: number[] = [], spacerIndexes: number[] = [],
): ArrayAnchorSpec[] => { ): ArrayAnchorSpec[] => {
if (connectionType === NodeConnectionType.Main) { if (connectionType === NodeConnectionType.Main) {
const positions = { const anchors: ArrayAnchorSpec[] = [];
input: { const x = type === 'input' ? 0.01 : 0.99;
1: [[0.01, 0.5, -1, 0]], const ox = type === 'input' ? -1 : 1;
2: [ const oy = 0;
[0.01, 0.3, -1, 0], const stepSize = 1 / (amount + 1); // +1 to not touch the node boundaries
[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],
],
},
};
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; const y = type === 'input' ? 0.99 : 0.01;
@ -317,15 +289,20 @@ export const getOutputEndpointStyle = (
outlineStroke: 'none', outlineStroke: 'none',
}); });
export const getOutputNameOverlay = (labelText: string, outputName: string): OverlaySpec => ({ export const getOutputNameOverlay = (
labelText: string,
outputName: ConnectionTypes,
): OverlaySpec => ({
type: 'Custom', type: 'Custom',
options: { options: {
id: OVERLAY_OUTPUT_NAME_LABEL, id: OVERLAY_OUTPUT_NAME_LABEL,
visible: true, visible: true,
create: (component: Endpoint) => { create: (ep: Endpoint) => {
const label = document.createElement('div'); const label = document.createElement('div');
label.innerHTML = labelText; label.innerHTML = labelText;
label.classList.add('node-output-endpoint-label'); label.classList.add('node-output-endpoint-label');
label.setAttribute('data-endpoint-node-type', ep?.__meta?.nodeType);
if (outputName !== NodeConnectionType.Main) { if (outputName !== NodeConnectionType.Main) {
label.classList.add('node-output-endpoint-label--data'); label.classList.add('node-output-endpoint-label--data');
label.classList.add(`node-connection-type-${getScope(outputName)}`); label.classList.add(`node-connection-type-${getScope(outputName)}`);

View file

@ -1,686 +1,25 @@
import type { import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
IExecuteFunctions, import { VersionedNodeType } from 'n8n-workflow';
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeDescription,
NodeParameterValue,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
export class Switch implements INodeType { import { SwitchV1 } from './V1/SwitchV1.node';
description: INodeTypeDescription = { import { SwitchV2 } from './V2/SwitchV2.node';
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',
},
// ---------------------------------- export class Switch extends VersionedNodeType {
// mode:expression constructor() {
// ---------------------------------- const baseDescription: INodeTypeBaseDescription = {
{ displayName: 'Switch',
displayName: 'Output', name: 'switch',
name: 'output', icon: 'fa:map-signs',
type: 'number', group: ['transform'],
typeOptions: { description: 'Route items depending on defined expression or rules',
minValue: 0, defaultVersion: 2,
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<INodeExecutionData[][]> {
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 nodeVersions: IVersionedNodeType['nodeVersions'] = {
const convertDateTime = (value: NodeParameterValue): number => { 1: new SwitchV1(baseDescription),
let returnValue: number | undefined = undefined; 2: new SwitchV2(baseDescription),
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) => { super(nodeVersions, baseDescription);
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;
} }
} }

View file

@ -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<INodeExecutionData[][]> {
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;
}
}

View file

@ -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 <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
],
};
}
methods = {
loadOptions: {
async getFallbackOutputOptions(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
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<INodeExecutionData[][]> {
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;
}
}

View file

@ -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": []
}

View file

@ -0,0 +1,4 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Execute Switch Node', () => testWorkflows(workflows));

View file

@ -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": []
}

View file

@ -1027,7 +1027,7 @@ export function getNodeInputs(
node: INode, node: INode,
nodeTypeData: INodeTypeDescription, nodeTypeData: INodeTypeDescription,
): Array<ConnectionTypes | INodeInputConfiguration> { ): Array<ConnectionTypes | INodeInputConfiguration> {
if (Array.isArray(nodeTypeData.inputs)) { if (Array.isArray(nodeTypeData?.inputs)) {
return nodeTypeData.inputs; return nodeTypeData.inputs;
} }