Skip to content

Commit

Permalink
feat(Switch Node): Add support for infinite Switch outputs (#7499)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
OlegIvaniv authored Oct 25, 2023
1 parent 6f45298 commit 2febc61
Show file tree
Hide file tree
Showing 19 changed files with 1,935 additions and 753 deletions.
42 changes: 31 additions & 11 deletions cypress/e2e/10-undo-redo.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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}');
Expand All @@ -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);
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion cypress/e2e/12-canvas-actions.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
20 changes: 13 additions & 7 deletions cypress/e2e/12-canvas.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

for (let i = 0; i < desiredOutputs; i++) {
cy.contains('Add Routing Rule').click()
}

// Switch has 4 output endpoints
for (let i = 0; i < 4; i++) {
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);
Expand All @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down
49 changes: 45 additions & 4 deletions packages/editor-ui/src/components/Node.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<ConnectionTypes | INodeInputConfiguration>);
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,
);
Expand All @@ -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<ConnectionTypes | INodeOutputConfiguration>);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const mainOutputs = outputTypes.filter((output) => output === NodeConnectionType.Main);
styles['--node-main-output-count'] = mainOutputs.length;
return styles;
},
nodeClass(): object {
Expand Down Expand Up @@ -698,7 +720,12 @@ export default defineComponent({
<style lang="scss" scoped>
.node-wrapper {
--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-input-width: 65px;
Expand Down Expand Up @@ -1158,6 +1185,7 @@ export default defineComponent({
--endpoint-size-small: 14px;
--endpoint-size-medium: 18px;
--stalk-size: 40px;
--stalk-switch-size: 60px;
--stalk-success-size: 87px;
--stalk-success-size-without-label: 40px;
--stalk-long-size: 127px;
Expand Down Expand Up @@ -1405,6 +1433,15 @@ export default defineComponent({
margin-top: calc(var(--spacing-l) * -1);
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 {
Expand Down Expand Up @@ -1446,6 +1483,7 @@ export default defineComponent({
opacity: 1;
}
}
.long-stalk {
--stalk-size: var(--stalk-long-size);
}
Expand All @@ -1455,4 +1493,7 @@ export default defineComponent({
.ep-success--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>
30 changes: 24 additions & 6 deletions packages/editor-ui/src/components/RunData.vue
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ import type {
IBinaryKeyData,
IDataObject,
INodeExecutionData,
INodeOutputConfiguration,
INodeTypeDescription,
IRunData,
IRunExecutionData,
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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++) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/editor-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
5 changes: 5 additions & 0 deletions packages/editor-ui/src/mixins/nodeBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId,
index: typeIndex,
totalEndpoints: inputsOfSameRootType.length,
nodeType: node.type,
};
}

Expand Down Expand Up @@ -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
Expand All @@ -439,6 +441,7 @@ export const nodeBase = defineComponent({
nodeId: this.nodeId,
index: typeIndex,
totalEndpoints: outputsOfSameRootType.length,
nodeType: node.type,
};
}

Expand All @@ -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'),
},
},
Expand Down Expand Up @@ -486,6 +490,7 @@ export const nodeBase = defineComponent({
nodeName: node.name,
nodeId: this.nodeId,
index: typeIndex,
nodeType: node.type,
totalEndpoints: outputsOfSameRootType.length,
};
}
Expand Down
Loading

0 comments on commit 2febc61

Please sign in to comment.