refactor(editor): Migrate nodeBase mixin to composable (no-changelog) (#9742)

This commit is contained in:
Alex Grozav 2024-06-18 16:35:26 +03:00 committed by GitHub
parent c58621ab79
commit b0d7cfa2ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1015 additions and 702 deletions

View file

@ -10,7 +10,7 @@ import aiNodeTypesJson from '../../../@n8n/nodes-langchain/dist/types/nodes.json
const allNodeTypes = [...nodeTypesJson, ...aiNodeTypesJson];
function findNodeWithName(name: string): INodeTypeDescription {
export function findNodeTypeDescriptionByName(name: string): INodeTypeDescription {
return allNodeTypes.find((node) => node.name === name) as INodeTypeDescription;
}
@ -18,25 +18,25 @@ export const testingNodeTypes: INodeTypeData = {
[MANUAL_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(MANUAL_TRIGGER_NODE_TYPE),
description: findNodeTypeDescriptionByName(MANUAL_TRIGGER_NODE_TYPE),
},
},
[SET_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(SET_NODE_TYPE),
description: findNodeTypeDescriptionByName(SET_NODE_TYPE),
},
},
[CHAT_TRIGGER_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(CHAT_TRIGGER_NODE_TYPE),
description: findNodeTypeDescriptionByName(CHAT_TRIGGER_NODE_TYPE),
},
},
[AGENT_NODE_TYPE]: {
sourcePath: '',
type: {
description: findNodeWithName(AGENT_NODE_TYPE),
description: findNodeTypeDescriptionByName(AGENT_NODE_TYPE),
},
},
};

View file

@ -178,7 +178,8 @@
</template>
<script lang="ts">
import { type CSSProperties, defineComponent } from 'vue';
import { defineComponent } from 'vue';
import type { PropType, CSSProperties } from 'vue';
import { mapStores } from 'pinia';
import xss from 'xss';
import { useStorage } from '@/composables/useStorage';
@ -192,7 +193,7 @@ import {
SIMULATE_TRIGGER_NODE_TYPE,
WAIT_TIME_UNLIMITED,
} from '@/constants';
import { nodeBase } from '@/mixins/nodeBase';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import type {
ConnectionTypes,
ExecutionSummary,
@ -201,8 +202,8 @@ import type {
INodeTypeDescription,
ITaskData,
NodeOperationError,
Workflow,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
import TitledList from '@/components/TitledList.vue';
@ -222,6 +223,10 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { usePinnedData } from '@/composables/usePinnedData';
import { useDeviceSupport } from 'n8n-design-system';
import { useDebounce } from '@/composables/useDebounce';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { useCanvasStore } from '@/stores/canvas.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNodeBase } from '@/composables/useNodeBase';
export default defineComponent({
name: 'Node',
@ -230,7 +235,6 @@ export default defineComponent({
FontAwesomeIcon,
NodeIcon,
},
mixins: [nodeBase],
props: {
isProductionExecutionPreview: {
type: Boolean,
@ -244,6 +248,33 @@ export default defineComponent({
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
instance: {
type: Object as PropType<BrowserJsPlumbInstance>,
required: true,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
workflow: {
type: Object as PropType<Workflow>,
required: true,
},
},
emits: {
run: null,
@ -251,7 +282,7 @@ export default defineComponent({
removeNode: null,
toggleDisableNode: null,
},
setup(props) {
setup(props, { emit }) {
const workflowsStore = useWorkflowsStore();
const contextMenu = useContextMenu();
const externalHooks = useExternalHooks();
@ -261,12 +292,21 @@ export default defineComponent({
const deviceSupport = useDeviceSupport();
const { callDebounced } = useDebounce();
const nodeBase = useNodeBase({
name: props.name,
instance: props.instance,
workflowObject: props.workflow,
isReadOnly: props.isReadOnly,
emit: emit as (event: string, ...args: unknown[]) => void,
});
return {
contextMenu,
externalHooks,
nodeHelpers,
pinnedData,
deviceSupport,
...nodeBase,
callDebounced,
};
},
@ -281,7 +321,20 @@ export default defineComponent({
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
...mapStores(
useNodeTypesStore,
useCanvasStore,
useNDVStore,
useUIStore,
useWorkflowsStore,
useHistoryStore,
),
data(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
nodeId(): string {
return this.data?.id || '';
},
showPinnedDataInfo(): boolean {
return this.pinnedData.hasData.value && !this.isProductionExecutionPreview;
},
@ -678,6 +731,16 @@ export default defineComponent({
}
},
mounted() {
// Initialize the node
if (this.data !== null) {
try {
this.addNode(this.data);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
setTimeout(() => {
this.setSubtitle();
}, 0);

View file

@ -103,10 +103,10 @@
</template>
<script lang="ts">
import { defineComponent, ref, type StyleValue } from 'vue';
import { defineComponent, ref } from 'vue';
import type { PropType, StyleValue } from 'vue';
import { mapStores } from 'pinia';
import { nodeBase } from '@/mixins/nodeBase';
import { isNumber, isString } from '@/utils/typeGuards';
import type {
INodeUi,
@ -115,7 +115,7 @@ import type {
XYPosition,
} from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
import { QUICKSTART_NOTE_NAME } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
@ -126,10 +126,13 @@ import { useDeviceSupport } from 'n8n-design-system';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { useToast } from '@/composables/useToast';
import { assert } from '@/utils/assert';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { useCanvasStore } from '@/stores/canvas.store';
import { useHistoryStore } from '@/stores/history.store';
import { useNodeBase } from '@/composables/useNodeBase';
export default defineComponent({
name: 'Sticky',
mixins: [nodeBase],
props: {
nodeViewScale: {
type: Number,
@ -139,9 +142,36 @@ export default defineComponent({
type: Number,
default: GRID_SIZE,
},
name: {
type: String,
required: true,
},
instance: {
type: Object as PropType<BrowserJsPlumbInstance>,
required: true,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
workflow: {
type: Object as PropType<Workflow>,
required: true,
},
},
emits: { removeNode: null, nodeSelected: null },
setup() {
setup(props, { emit }) {
const deviceSupport = useDeviceSupport();
const toast = useToast();
const colorPopoverTrigger = ref<HTMLDivElement>();
@ -156,12 +186,21 @@ export default defineComponent({
}
});
const nodeBase = useNodeBase({
name: props.name,
instance: props.instance,
workflowObject: props.workflow,
isReadOnly: props.isReadOnly,
emit: emit as (event: string, ...args: unknown[]) => void,
});
return {
deviceSupport,
toast,
colorPopoverTrigger,
contextMenu,
forceActions,
...nodeBase,
setForceActions,
};
},
@ -172,7 +211,20 @@ export default defineComponent({
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
...mapStores(
useNodeTypesStore,
useUIStore,
useNDVStore,
useCanvasStore,
useWorkflowsStore,
useHistoryStore,
),
data(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
nodeId(): string {
return this.data?.id || '';
},
defaultText(): string {
if (!this.nodeType) {
return '';
@ -239,6 +291,17 @@ export default defineComponent({
return this.uiStore.isActionActive('workflowRunning');
},
},
mounted() {
// Initialize the node
if (this.data !== null) {
try {
this.addNode(this.data);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
methods: {
onShowPopover() {
this.setForceActions(true);
@ -274,7 +337,7 @@ export default defineComponent({
onMarkdownClick(link: HTMLAnchorElement) {
if (link) {
const isOnboardingNote = this.name === QUICKSTART_NOTE_NAME;
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"');
const isWelcomeVideo = link.querySelector('img[alt="n8n quickstart video"]');
const type =
isOnboardingNote && isWelcomeVideo
? 'welcome_video'

View file

@ -0,0 +1,198 @@
import { useNodeBase } from '@/composables/useNodeBase';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import { createPinia, setActivePinia } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { findNodeTypeDescriptionByName } from '@/__tests__/defaults';
import { SET_NODE_TYPE } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import type { INodeTypeDescription, Workflow } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { Mock } from '@vitest/spy';
describe('useNodeBase', () => {
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let uiStore: ReturnType<typeof useUIStore>;
let instance: Record<string, Mock>;
let workflowObject: Workflow;
let emit: (event: string, ...args: unknown[]) => void;
let node: INodeUi;
let nodeTypeDescription: INodeTypeDescription;
let nodeBase: ReturnType<typeof useNodeBase>;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
uiStore = useUIStore();
instance = {
addEndpoint: vi.fn().mockReturnValue({}),
};
workflowObject = createTestWorkflowObject({ nodes: [], connections: {} });
node = createTestNode();
nodeTypeDescription = findNodeTypeDescriptionByName(SET_NODE_TYPE);
emit = vi.fn();
nodeBase = useNodeBase({
instance: instance as unknown as BrowserJsPlumbInstance,
name: node.name,
workflowObject,
isReadOnly: false,
emit,
});
});
it('should initialize correctly', () => {
const { inputs, outputs } = nodeBase;
expect(inputs.value).toEqual([]);
expect(outputs.value).toEqual([]);
});
describe('addInputEndpoints', () => {
it('should add input endpoints correctly', () => {
const { addInputEndpoints } = nodeBase;
vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
addInputEndpoints(node, nodeTypeDescription);
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
expect(workflowsStore.getNodeByName).toHaveBeenCalledWith(node.name);
expect(addEndpointCall.anchor).toEqual([0.01, 0.5, -1, 0]);
expect(addEndpointCall.cssClass).toEqual('rect-input-endpoint');
expect(addEndpointCall.dragAllowedWhenFull).toEqual(true);
expect(addEndpointCall.enabled).toEqual(true);
expect(addEndpointCall.endpoint).toEqual('Rectangle');
expect(addEndpointCall.hoverClass).toEqual('rect-input-endpoint-hover');
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
fill: 'var(--color-primary)',
height: 20,
lineWidth: 0,
stroke: 'var(--color-primary)',
width: 8,
});
expect(addEndpointCall.maxConnections).toEqual(-1);
expect(addEndpointCall.paintStyle).toMatchObject({
fill: 'var(--node-type-main-color)',
height: 20,
lineWidth: 0,
stroke: 'var(--node-type-main-color)',
width: 8,
});
expect(addEndpointCall.parameters).toMatchObject({
connection: 'target',
index: 0,
type: 'main',
});
expect(addEndpointCall.scope).toBeUndefined();
expect(addEndpointCall.source).toBeFalsy();
expect(addEndpointCall.target).toBeFalsy();
});
});
describe('addOutputEndpoints', () => {
it('should add output endpoints correctly', () => {
const { addOutputEndpoints } = nodeBase;
const getNodeByNameSpy = vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node);
addOutputEndpoints(node, nodeTypeDescription);
const addEndpointCall = instance.addEndpoint.mock.calls[0][1];
expect(getNodeByNameSpy).toHaveBeenCalledWith(node.name);
expect(addEndpointCall.anchor).toEqual([0.99, 0.5, 1, 0]);
expect(addEndpointCall.cssClass).toEqual('dot-output-endpoint');
expect(addEndpointCall.dragAllowedWhenFull).toEqual(false);
expect(addEndpointCall.enabled).toEqual(true);
expect(addEndpointCall.endpoint).toEqual({
options: {
radius: 9,
},
type: 'Dot',
});
expect(addEndpointCall.hoverClass).toEqual('dot-output-endpoint-hover');
expect(addEndpointCall.hoverPaintStyle).toMatchObject({
fill: 'var(--color-primary)',
});
expect(addEndpointCall.maxConnections).toEqual(-1);
expect(addEndpointCall.paintStyle).toMatchObject({
fill: 'var(--node-type-main-color)',
});
expect(addEndpointCall.parameters).toMatchObject({
connection: 'source',
index: 0,
type: 'main',
});
expect(addEndpointCall.scope).toBeUndefined();
expect(addEndpointCall.source).toBeTruthy();
expect(addEndpointCall.target).toBeFalsy();
});
});
describe('mouseLeftClick', () => {
it('should handle mouse left click correctly', () => {
const { mouseLeftClick } = nodeBase;
const isActionActiveFn = vi.fn().mockReturnValue(false);
// @ts-expect-error Pinia has a known issue when mocking getters, will be solved when migrating the uiStore to composition api
vi.spyOn(uiStore, 'isActionActive', 'get').mockReturnValue(isActionActiveFn);
// @ts-expect-error Pinia has a known issue when mocking getters, will be solved when migrating the uiStore to composition api
vi.spyOn(uiStore, 'isNodeSelected', 'get').mockReturnValue(() => false);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
mouseLeftClick(event);
expect(isActionActiveFn).toHaveBeenCalledWith('dragActive');
expect(emit).toHaveBeenCalledWith('deselectAllNodes');
expect(emit).toHaveBeenCalledWith('nodeSelected', node.name);
});
});
describe('getSpacerIndexes', () => {
it('should return spacer indexes when left and right group have items and spacer between groups is true', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(3, 3, true);
expect(result).toEqual([3]);
});
it('should return spacer indexes to meet the min items count if there are less items in both groups', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(1, 1, false, 5);
expect(result).toEqual([1, 2, 3]);
});
it('should return spacer indexes for left group when only left group has items and less than min items count', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(2, 0, false, 4);
expect(result).toEqual([2, 3]);
});
it('should return spacer indexes for right group when only right group has items and less than min items count', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(0, 3, false, 5);
expect(result).toEqual([0, 1]);
});
it('should return empty array when both groups have items more than min items count and spacer between groups is false', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(3, 3, false, 5);
expect(result).toEqual([]);
});
it('should return empty array when left and right group have items and spacer between groups is false', () => {
const { getSpacerIndexes } = nodeBase;
const result = getSpacerIndexes(2, 2, false, 4);
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,673 @@
import { computed, getCurrentInstance, ref } from 'vue';
import type { INodeUi } from '@/Interface';
import {
NO_OP_NODE_TYPE,
NODE_CONNECTION_TYPE_ALLOW_MULTIPLE,
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
NODE_MIN_INPUT_ITEMS_COUNT,
} from '@/constants';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
import type {
ConnectionTypes,
INodeInputConfiguration,
INodeTypeDescription,
INodeOutputConfiguration,
Workflow,
} from 'n8n-workflow';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { Endpoint, EndpointOptions } from '@jsplumb/core';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import type { EndpointSpec } from '@jsplumb/common';
import { useDeviceSupport } from 'n8n-design-system';
import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import { useI18n } from '@/composables/useI18n';
export function useNodeBase({
name,
instance,
workflowObject,
isReadOnly,
emit,
}: {
name: string;
instance: BrowserJsPlumbInstance;
workflowObject: Workflow;
isReadOnly: boolean;
emit: (event: string, ...args: unknown[]) => void;
}) {
const uiStore = useUIStore();
const deviceSupport = useDeviceSupport();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
// @TODO Remove this when Node.vue and Sticky.vue are migrated to composition API and pass refs instead
const refs = computed(() => getCurrentInstance()?.refs ?? {});
const data = computed<INodeUi | null>(() => {
return workflowsStore.getNodeByName(name);
});
const nodeId = computed<string>(() => data.value?.id ?? '');
const inputs = ref<Array<ConnectionTypes | INodeInputConfiguration>>([]);
const outputs = ref<Array<ConnectionTypes | INodeOutputConfiguration>>([]);
const createAddInputEndpointSpec = (
connectionName: NodeConnectionType,
color: string,
): EndpointSpec => {
const multiple = NODE_CONNECTION_TYPE_ALLOW_MULTIPLE.includes(connectionName);
return {
type: 'N8nAddInput',
options: {
width: 24,
height: 72,
color,
multiple,
},
};
};
const createDiamondOutputEndpointSpec = (): EndpointSpec => ({
type: 'Rectangle',
options: {
height: 10,
width: 10,
cssClass: 'diamond-output-endpoint',
},
});
const getEndpointLabelLength = (length: number): N8nEndpointLabelLength => {
if (length <= 2) return 'small';
else if (length <= 6) return 'medium';
return 'large';
};
function addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) {
if (window?.Cypress && 'canvas' in endpoint.endpoint && instance) {
const canvas = endpoint.endpoint.canvas;
instance.setAttribute(canvas, 'data-endpoint-name', data.value?.name ?? '');
instance.setAttribute(canvas, 'data-input-index', inputIndex.toString());
instance.setAttribute(canvas, 'data-endpoint-type', type);
}
}
function addInputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs
const rootTypeIndexData: {
[key: string]: number;
} = {};
const typeIndexData: {
[key: string]: number;
} = {};
inputs.value = NodeHelpers.getNodeInputs(workflowObject, data.value!, nodeTypeData) || [];
const sortedInputs = [...inputs.value];
sortedInputs.sort((a, b) => {
if (typeof a === 'string') {
return 1;
} else if (typeof b === 'string') {
return -1;
}
if (a.required && !b.required) {
return -1;
} else if (!a.required && b.required) {
return 1;
}
return 0;
});
sortedInputs.forEach((value, i) => {
let inputConfiguration: INodeInputConfiguration;
if (typeof value === 'string') {
inputConfiguration = {
type: value,
};
} else {
inputConfiguration = value;
}
const inputName: ConnectionTypes = inputConfiguration.type;
const rootCategoryInputName =
inputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
// Increment the index for inputs with current name
if (rootTypeIndexData.hasOwnProperty(rootCategoryInputName)) {
rootTypeIndexData[rootCategoryInputName]++;
} else {
rootTypeIndexData[rootCategoryInputName] = 0;
}
if (typeIndexData.hasOwnProperty(inputName)) {
typeIndexData[inputName]++;
} else {
typeIndexData[inputName] = 0;
}
const rootTypeIndex = rootTypeIndexData[rootCategoryInputName];
const typeIndex = typeIndexData[inputName];
const inputsOfSameRootType = inputs.value.filter((inputData) => {
const thisInputName: string = typeof inputData === 'string' ? inputData : inputData.type;
return inputName === NodeConnectionType.Main
? thisInputName === NodeConnectionType.Main
: thisInputName !== NodeConnectionType.Main;
});
const nonMainInputs = inputsOfSameRootType.filter((inputData) => {
return inputData !== NodeConnectionType.Main;
});
const requiredNonMainInputs = nonMainInputs.filter((inputData) => {
return typeof inputData !== 'string' && inputData.required;
});
const optionalNonMainInputs = nonMainInputs.filter((inputData) => {
return typeof inputData !== 'string' && !inputData.required;
});
const spacerIndexes = getSpacerIndexes(
requiredNonMainInputs.length,
optionalNonMainInputs.length,
);
// Get the position of the anchor depending on how many it has
const anchorPosition = NodeViewUtils.getAnchorPosition(
inputName,
'input',
inputsOfSameRootType.length,
spacerIndexes,
)[rootTypeIndex];
if (!isValidNodeConnectionType(inputName)) {
return;
}
const scope = NodeViewUtils.getEndpointScope(inputName);
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getInputEndpointUUID(nodeId.value, inputName, typeIndex),
anchor: anchorPosition,
// We potentially want to change that in the future to allow people to dynamically
// activate and deactivate connected nodes
maxConnections: inputConfiguration.maxConnections ?? -1,
endpoint: 'Rectangle',
paintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
'--color-foreground-xdark',
inputName,
),
hoverPaintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
'--color-primary',
inputName,
),
scope: NodeViewUtils.getScope(scope),
source: inputName !== NodeConnectionType.Main,
target: !isReadOnly && inputs.value.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
connection: 'target',
nodeId: nodeId.value,
type: inputName,
index: typeIndex,
},
enabled: !isReadOnly, // enabled in default case to allow dragging
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
hoverClass: 'rect-input-endpoint-hover',
...getInputConnectionStyle(inputName, nodeTypeData),
};
const endpoint = instance?.addEndpoint(
refs.value[data.value?.name ?? ''] as Element,
newEndpointData,
) as Endpoint;
addEndpointTestingData(endpoint, 'input', typeIndex);
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
// Apply input names if they got set
endpoint.addOverlay(
NodeViewUtils.getInputNameOverlay(
inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '',
inputName,
inputConfiguration.required,
),
);
}
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: nodeId.value,
index: typeIndex,
totalEndpoints: inputsOfSameRootType.length,
nodeType: node.type,
};
}
// TODO: Activate again if it makes sense. Currently makes problems when removing
// connection on which the input has a name. It does not get hidden because
// the endpoint to which it connects when letting it go over the node is
// different to the regular one (have different ids). So that seems to make
// problems when hiding the input-name.
// if (index === 0 && inputName === NodeConnectionType.Main) {
// // Make the first main-input the default one to connect to when connection gets dropped on node
// instance.makeTarget(nodeId.value, newEndpointData);
// }
});
if (sortedInputs.length === 0) {
instance?.manage(refs.value[data.value?.name ?? ''] as Element);
}
}
function getSpacerIndexes(
leftGroupItemsCount: number,
rightGroupItemsCount: number,
insertSpacerBetweenGroups = NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
minItemsCount = NODE_MIN_INPUT_ITEMS_COUNT,
): number[] {
const spacerIndexes = [];
if (leftGroupItemsCount > 0 && rightGroupItemsCount > 0) {
if (insertSpacerBetweenGroups) {
spacerIndexes.push(leftGroupItemsCount);
} else if (leftGroupItemsCount + rightGroupItemsCount < minItemsCount) {
for (
let spacerIndex = leftGroupItemsCount;
spacerIndex < minItemsCount - rightGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex);
}
}
} else {
if (
leftGroupItemsCount > 0 &&
leftGroupItemsCount < minItemsCount &&
rightGroupItemsCount === 0
) {
for (
let spacerIndex = 0;
spacerIndex < minItemsCount - leftGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex + leftGroupItemsCount);
}
} else if (
leftGroupItemsCount === 0 &&
rightGroupItemsCount > 0 &&
rightGroupItemsCount < minItemsCount
) {
for (
let spacerIndex = 0;
spacerIndex < minItemsCount - rightGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex);
}
}
}
return spacerIndexes;
}
function addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
const rootTypeIndexData: {
[key: string]: number;
} = {};
const typeIndexData: {
[key: string]: number;
} = {};
if (!data.value) {
return;
}
outputs.value = NodeHelpers.getNodeOutputs(workflowObject, data.value, nodeTypeData) || [];
// TODO: There are still a lot of references of "main" in NodesView and
// other locations. So assume there will be more problems
let maxLabelLength = 0;
const outputConfigurations: INodeOutputConfiguration[] = [];
outputs.value.forEach((value, i) => {
let outputConfiguration: INodeOutputConfiguration;
if (typeof value === 'string') {
outputConfiguration = {
type: value,
};
} else {
outputConfiguration = value;
}
if (nodeTypeData.outputNames?.[i]) {
outputConfiguration.displayName = nodeTypeData.outputNames[i];
}
if (outputConfiguration.displayName) {
maxLabelLength =
outputConfiguration.displayName.length > maxLabelLength
? outputConfiguration.displayName.length
: maxLabelLength;
}
outputConfigurations.push(outputConfiguration);
});
const endpointLabelLength = getEndpointLabelLength(maxLabelLength);
outputs.value.forEach((_value, i) => {
const outputConfiguration = outputConfigurations[i];
const outputName: ConnectionTypes = outputConfiguration.type;
const rootCategoryOutputName =
outputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
// Increment the index for outputs with current name
if (rootTypeIndexData.hasOwnProperty(rootCategoryOutputName)) {
rootTypeIndexData[rootCategoryOutputName]++;
} else {
rootTypeIndexData[rootCategoryOutputName] = 0;
}
if (typeIndexData.hasOwnProperty(outputName)) {
typeIndexData[outputName]++;
} else {
typeIndexData[outputName] = 0;
}
const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName];
const typeIndex = typeIndexData[outputName];
const outputsOfSameRootType = outputs.value.filter((outputData) => {
const thisOutputName: string =
typeof outputData === 'string' ? outputData : outputData.type;
return outputName === NodeConnectionType.Main
? thisOutputName === NodeConnectionType.Main
: thisOutputName !== NodeConnectionType.Main;
});
// Get the position of the anchor depending on how many it has
const anchorPosition = NodeViewUtils.getAnchorPosition(
outputName,
'output',
outputsOfSameRootType.length,
)[rootTypeIndex];
if (!isValidNodeConnectionType(outputName)) {
return;
}
const scope = NodeViewUtils.getEndpointScope(outputName);
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex),
anchor: anchorPosition,
maxConnections: -1,
endpoint: {
type: 'Dot',
options: {
radius: nodeTypeData && outputsOfSameRootType.length > 2 ? 7 : 9,
},
},
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
scope,
source: true,
target: outputName !== NodeConnectionType.Main,
enabled: !isReadOnly,
parameters: {
connection: 'source',
nodeId: nodeId.value,
type: outputName,
index: typeIndex,
},
hoverClass: 'dot-output-endpoint-hover',
connectionsDirected: true,
dragAllowedWhenFull: false,
...getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData),
};
const endpoint = instance?.addEndpoint(
refs.value[data.value?.name ?? ''] as Element,
newEndpointData,
);
if (!endpoint) {
return;
}
addEndpointTestingData(endpoint, 'output', typeIndex);
if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) {
// Apply output names if they got set
const overlaySpec = NodeViewUtils.getOutputNameOverlay(
outputConfiguration.displayName,
outputName,
outputConfiguration?.category,
);
endpoint.addOverlay(overlaySpec);
}
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: nodeId.value,
index: typeIndex,
totalEndpoints: outputsOfSameRootType.length,
endpointLabelLength,
};
}
if (!isReadOnly && outputName === NodeConnectionType.Main) {
const plusEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(nodeId.value, outputName, typeIndex),
anchor: anchorPosition,
maxConnections: -1,
endpoint: {
type: 'N8nPlus',
options: {
dimensions: 24,
connectedEndpoint: endpoint,
showOutputLabel: outputs.value.length === 1,
size: outputs.value.length >= 3 ? 'small' : 'medium',
endpointLabelLength,
hoverMessage: i18n.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
},
source: true,
target: false,
enabled: !isReadOnly,
paintStyle: {
outlineStroke: 'none',
},
hoverPaintStyle: {
outlineStroke: 'none',
},
parameters: {
connection: 'source',
nodeId: nodeId.value,
type: outputName,
index: typeIndex,
category: outputConfiguration?.category,
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
};
if (outputConfiguration?.category) {
plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`;
}
if (!instance || !data.value) {
return;
}
const plusEndpoint = instance.addEndpoint(
refs.value[data.value.name] as Element,
plusEndpointData,
);
addEndpointTestingData(plusEndpoint, 'plus', typeIndex);
if (!Array.isArray(plusEndpoint)) {
plusEndpoint.__meta = {
nodeName: node.name,
nodeId: nodeId.value,
index: typeIndex,
nodeType: node.type,
totalEndpoints: outputsOfSameRootType.length,
};
}
}
});
}
function addNode(node: INodeUi) {
const nodeTypeData = (nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription;
addInputEndpoints(node, nodeTypeData);
addOutputEndpoints(node, nodeTypeData);
}
function getEndpointColor(connectionType: ConnectionTypes) {
return `--node-type-${connectionType}-color`;
}
function getInputConnectionStyle(
connectionType: ConnectionTypes,
nodeTypeData: INodeTypeDescription,
): EndpointOptions {
if (connectionType === NodeConnectionType.Main) {
return {
paintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
getEndpointColor(NodeConnectionType.Main),
connectionType,
),
};
}
if (!isValidNodeConnectionType(connectionType)) {
return {};
}
const createSupplementalConnectionType = (
connectionName: ConnectionTypes,
): EndpointOptions => ({
endpoint: createAddInputEndpointSpec(
connectionName as NodeConnectionType,
getEndpointColor(connectionName),
),
});
return createSupplementalConnectionType(connectionType);
}
function getOutputConnectionStyle(
connectionType: ConnectionTypes,
outputConfiguration: INodeOutputConfiguration,
nodeTypeData: INodeTypeDescription,
): EndpointOptions {
const createSupplementalConnectionType = (
connectionName: ConnectionTypes,
): EndpointOptions => ({
endpoint: createDiamondOutputEndpointSpec(),
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
getEndpointColor(connectionName),
),
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
getEndpointColor(connectionName),
),
});
const type = 'output';
if (connectionType === NodeConnectionType.Main) {
if (outputConfiguration.category === 'error') {
return {
paintStyle: {
...NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
getEndpointColor(NodeConnectionType.Main),
),
fill: 'var(--color-danger)',
},
cssClass: `dot-${type}-endpoint`,
};
}
return {
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
getEndpointColor(NodeConnectionType.Main),
),
cssClass: `dot-${type}-endpoint`,
};
}
if (!isValidNodeConnectionType(connectionType)) {
return {};
}
return createSupplementalConnectionType(connectionType);
}
function touchEnd(_e: MouseEvent) {
if (deviceSupport.isTouchDevice && uiStore.isActionActive('dragActive')) {
uiStore.removeActiveAction('dragActive');
}
}
function mouseLeftClick(e: MouseEvent) {
// @ts-expect-error path is not defined in MouseEvent on all browsers
const path = e.path || e.composedPath?.();
for (let index = 0; index < path.length; index++) {
if (
path[index].className &&
typeof path[index].className === 'string' &&
path[index].className.includes('no-select-on-click')
) {
return;
}
}
if (!deviceSupport.isTouchDevice) {
if (uiStore.isActionActive('dragActive')) {
uiStore.removeActiveAction('dragActive');
} else {
if (!deviceSupport.isCtrlKeyPressed(e)) {
emit('deselectAllNodes');
}
if (uiStore.isNodeSelected(data.value?.name ?? '')) {
emit('deselectNode', name);
} else {
emit('nodeSelected', name);
}
}
}
}
return {
getSpacerIndexes,
addInputEndpoints,
addOutputEndpoints,
addEndpointTestingData,
addNode,
getEndpointColor,
getInputConnectionStyle,
getOutputConnectionStyle,
mouseLeftClick,
touchEnd,
inputs,
outputs,
};
}

View file

@ -1,684 +0,0 @@
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { INodeUi } from '@/Interface';
import {
NO_OP_NODE_TYPE,
NODE_CONNECTION_TYPE_ALLOW_MULTIPLE,
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
NODE_MIN_INPUT_ITEMS_COUNT,
} from '@/constants';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
import type {
ConnectionTypes,
INodeInputConfiguration,
INodeTypeDescription,
INodeOutputConfiguration,
Workflow,
} from 'n8n-workflow';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { Endpoint, EndpointOptions } from '@jsplumb/core';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { useHistoryStore } from '@/stores/history.store';
import { useCanvasStore } from '@/stores/canvas.store';
import type { EndpointSpec } from '@jsplumb/common';
import { useDeviceSupport } from 'n8n-design-system';
import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
const createAddInputEndpointSpec = (
connectionName: NodeConnectionType,
color: string,
): EndpointSpec => {
const multiple = NODE_CONNECTION_TYPE_ALLOW_MULTIPLE.includes(connectionName);
return {
type: 'N8nAddInput',
options: {
width: 24,
height: 72,
color,
multiple,
},
};
};
const createDiamondOutputEndpointSpec = (): EndpointSpec => ({
type: 'Rectangle',
options: {
height: 10,
width: 10,
cssClass: 'diamond-output-endpoint',
},
});
const getEndpointLabelLength = (length: number): N8nEndpointLabelLength => {
if (length <= 2) return 'small';
else if (length <= 6) return 'medium';
return 'large';
};
export const nodeBase = defineComponent({
data() {
return {
inputs: [] as Array<ConnectionTypes | INodeInputConfiguration>,
outputs: [] as Array<ConnectionTypes | INodeOutputConfiguration>,
};
},
mounted() {
// Initialize the node
if (this.data !== null) {
try {
this.__addNode(this.data);
} catch (error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
computed: {
...mapStores(useNodeTypesStore, useUIStore, useCanvasStore, useWorkflowsStore, useHistoryStore),
data(): INodeUi | null {
return this.workflowsStore.getNodeByName(this.name);
},
nodeId(): string {
return this.data?.id || '';
},
},
props: {
name: {
type: String,
required: true,
},
instance: {
type: Object as PropType<BrowserJsPlumbInstance>,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
workflow: {
type: Object as () => Workflow,
required: true,
},
},
methods: {
__addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) {
if (window?.Cypress && 'canvas' in endpoint.endpoint && this.instance) {
const canvas = endpoint.endpoint.canvas;
this.instance.setAttribute(canvas, 'data-endpoint-name', this.data?.name ?? '');
this.instance.setAttribute(canvas, 'data-input-index', inputIndex.toString());
this.instance.setAttribute(canvas, 'data-endpoint-type', type);
}
},
__addInputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs
const rootTypeIndexData: {
[key: string]: number;
} = {};
const typeIndexData: {
[key: string]: number;
} = {};
const inputs: Array<ConnectionTypes | INodeInputConfiguration> =
NodeHelpers.getNodeInputs(this.workflow, this.data!, nodeTypeData) || [];
this.inputs = inputs;
const sortedInputs = [...inputs];
sortedInputs.sort((a, b) => {
if (typeof a === 'string') {
return 1;
} else if (typeof b === 'string') {
return -1;
}
if (a.required && !b.required) {
return -1;
} else if (!a.required && b.required) {
return 1;
}
return 0;
});
sortedInputs.forEach((value, i) => {
let inputConfiguration: INodeInputConfiguration;
if (typeof value === 'string') {
inputConfiguration = {
type: value,
};
} else {
inputConfiguration = value;
}
const inputName: ConnectionTypes = inputConfiguration.type;
const rootCategoryInputName =
inputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
// Increment the index for inputs with current name
if (rootTypeIndexData.hasOwnProperty(rootCategoryInputName)) {
rootTypeIndexData[rootCategoryInputName]++;
} else {
rootTypeIndexData[rootCategoryInputName] = 0;
}
if (typeIndexData.hasOwnProperty(inputName)) {
typeIndexData[inputName]++;
} else {
typeIndexData[inputName] = 0;
}
const rootTypeIndex = rootTypeIndexData[rootCategoryInputName];
const typeIndex = typeIndexData[inputName];
const inputsOfSameRootType = inputs.filter((inputData) => {
const thisInputName: string = typeof inputData === 'string' ? inputData : inputData.type;
return inputName === NodeConnectionType.Main
? thisInputName === NodeConnectionType.Main
: thisInputName !== NodeConnectionType.Main;
});
const nonMainInputs = inputsOfSameRootType.filter((inputData) => {
return inputData !== NodeConnectionType.Main;
});
const requiredNonMainInputs = nonMainInputs.filter((inputData) => {
return typeof inputData !== 'string' && inputData.required;
});
const optionalNonMainInputs = nonMainInputs.filter((inputData) => {
return typeof inputData !== 'string' && !inputData.required;
});
const spacerIndexes = this.getSpacerIndexes(
requiredNonMainInputs.length,
optionalNonMainInputs.length,
);
// Get the position of the anchor depending on how many it has
const anchorPosition = NodeViewUtils.getAnchorPosition(
inputName,
'input',
inputsOfSameRootType.length,
spacerIndexes,
)[rootTypeIndex];
if (!isValidNodeConnectionType(inputName)) {
return;
}
const scope = NodeViewUtils.getEndpointScope(inputName);
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getInputEndpointUUID(this.nodeId, inputName, typeIndex),
anchor: anchorPosition,
// We potentially want to change that in the future to allow people to dynamically
// activate and deactivate connected nodes
maxConnections: inputConfiguration.maxConnections ?? -1,
endpoint: 'Rectangle',
paintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
'--color-foreground-xdark',
inputName,
),
hoverPaintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
'--color-primary',
inputName,
),
scope: NodeViewUtils.getScope(scope),
source: inputName !== NodeConnectionType.Main,
target: !this.isReadOnly && inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
connection: 'target',
nodeId: this.nodeId,
type: inputName,
index: typeIndex,
},
enabled: !this.isReadOnly, // enabled in default case to allow dragging
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
hoverClass: 'rect-input-endpoint-hover',
...this.__getInputConnectionStyle(inputName, nodeTypeData),
};
const endpoint = this.instance?.addEndpoint(
this.$refs[this.data?.name ?? ''] as Element,
newEndpointData,
) as Endpoint;
this.__addEndpointTestingData(endpoint, 'input', typeIndex);
if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) {
// Apply input names if they got set
endpoint.addOverlay(
NodeViewUtils.getInputNameOverlay(
inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '',
inputName,
inputConfiguration.required,
),
);
}
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: typeIndex,
totalEndpoints: inputsOfSameRootType.length,
nodeType: node.type,
};
}
// TODO: Activate again if it makes sense. Currently makes problems when removing
// connection on which the input has a name. It does not get hidden because
// the endpoint to which it connects when letting it go over the node is
// different to the regular one (have different ids). So that seems to make
// problems when hiding the input-name.
// if (index === 0 && inputName === NodeConnectionType.Main) {
// // Make the first main-input the default one to connect to when connection gets dropped on node
// this.instance.makeTarget(this.nodeId, newEndpointData);
// }
});
if (sortedInputs.length === 0) {
this.instance?.manage(this.$refs[this.data?.name ?? ''] as Element);
}
},
getSpacerIndexes(
leftGroupItemsCount: number,
rightGroupItemsCount: number,
insertSpacerBetweenGroups = NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
minItemsCount = NODE_MIN_INPUT_ITEMS_COUNT,
): number[] {
const spacerIndexes = [];
if (leftGroupItemsCount > 0 && rightGroupItemsCount > 0) {
if (insertSpacerBetweenGroups) {
spacerIndexes.push(leftGroupItemsCount);
} else if (leftGroupItemsCount + rightGroupItemsCount < minItemsCount) {
for (
let spacerIndex = leftGroupItemsCount;
spacerIndex < minItemsCount - rightGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex);
}
}
} else {
if (
leftGroupItemsCount > 0 &&
leftGroupItemsCount < minItemsCount &&
rightGroupItemsCount === 0
) {
for (
let spacerIndex = 0;
spacerIndex < minItemsCount - leftGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex + leftGroupItemsCount);
}
} else if (
leftGroupItemsCount === 0 &&
rightGroupItemsCount > 0 &&
rightGroupItemsCount < minItemsCount
) {
for (
let spacerIndex = 0;
spacerIndex < minItemsCount - rightGroupItemsCount;
spacerIndex++
) {
spacerIndexes.push(spacerIndex);
}
}
}
return spacerIndexes;
},
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
const rootTypeIndexData: {
[key: string]: number;
} = {};
const typeIndexData: {
[key: string]: number;
} = {};
if (!this.data) {
return;
}
this.outputs = NodeHelpers.getNodeOutputs(this.workflow, this.data, nodeTypeData) || [];
// TODO: There are still a lot of references of "main" in NodesView and
// other locations. So assume there will be more problems
let maxLabelLength = 0;
const outputConfigurations: INodeOutputConfiguration[] = [];
this.outputs.forEach((value, i) => {
let outputConfiguration: INodeOutputConfiguration;
if (typeof value === 'string') {
outputConfiguration = {
type: value,
};
} else {
outputConfiguration = value;
}
if (nodeTypeData.outputNames?.[i]) {
outputConfiguration.displayName = nodeTypeData.outputNames[i];
}
if (outputConfiguration.displayName) {
maxLabelLength =
outputConfiguration.displayName.length > maxLabelLength
? outputConfiguration.displayName.length
: maxLabelLength;
}
outputConfigurations.push(outputConfiguration);
});
const endpointLabelLength = getEndpointLabelLength(maxLabelLength);
this.outputs.forEach((_value, i) => {
const outputConfiguration = outputConfigurations[i];
const outputName: ConnectionTypes = outputConfiguration.type;
const rootCategoryOutputName =
outputName === NodeConnectionType.Main ? NodeConnectionType.Main : 'other';
// Increment the index for outputs with current name
if (rootTypeIndexData.hasOwnProperty(rootCategoryOutputName)) {
rootTypeIndexData[rootCategoryOutputName]++;
} else {
rootTypeIndexData[rootCategoryOutputName] = 0;
}
if (typeIndexData.hasOwnProperty(outputName)) {
typeIndexData[outputName]++;
} else {
typeIndexData[outputName] = 0;
}
const rootTypeIndex = rootTypeIndexData[rootCategoryOutputName];
const typeIndex = typeIndexData[outputName];
const outputsOfSameRootType = this.outputs.filter((outputData) => {
const thisOutputName: string =
typeof outputData === 'string' ? outputData : outputData.type;
return outputName === NodeConnectionType.Main
? thisOutputName === NodeConnectionType.Main
: thisOutputName !== NodeConnectionType.Main;
});
// Get the position of the anchor depending on how many it has
const anchorPosition = NodeViewUtils.getAnchorPosition(
outputName,
'output',
outputsOfSameRootType.length,
)[rootTypeIndex];
if (!isValidNodeConnectionType(outputName)) {
return;
}
const scope = NodeViewUtils.getEndpointScope(outputName);
const newEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, outputName, typeIndex),
anchor: anchorPosition,
maxConnections: -1,
endpoint: {
type: 'Dot',
options: {
radius: nodeTypeData && outputsOfSameRootType.length > 2 ? 7 : 9,
},
},
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
scope,
source: true,
target: outputName !== NodeConnectionType.Main,
enabled: !this.isReadOnly,
parameters: {
connection: 'source',
nodeId: this.nodeId,
type: outputName,
index: typeIndex,
},
hoverClass: 'dot-output-endpoint-hover',
connectionsDirected: true,
dragAllowedWhenFull: false,
...this.__getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData),
};
const endpoint = this.instance?.addEndpoint(
this.$refs[this.data?.name ?? ''] as Element,
newEndpointData,
);
if (!endpoint) {
return;
}
this.__addEndpointTestingData(endpoint, 'output', typeIndex);
if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) {
// Apply output names if they got set
const overlaySpec = NodeViewUtils.getOutputNameOverlay(
outputConfiguration.displayName,
outputName,
outputConfiguration?.category,
);
endpoint.addOverlay(overlaySpec);
}
if (!Array.isArray(endpoint)) {
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: typeIndex,
totalEndpoints: outputsOfSameRootType.length,
endpointLabelLength,
};
}
if (!this.isReadOnly && outputName === NodeConnectionType.Main) {
const plusEndpointData: EndpointOptions = {
uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, outputName, typeIndex),
anchor: anchorPosition,
maxConnections: -1,
endpoint: {
type: 'N8nPlus',
options: {
dimensions: 24,
connectedEndpoint: endpoint,
showOutputLabel: this.outputs.length === 1,
size: this.outputs.length >= 3 ? 'small' : 'medium',
endpointLabelLength,
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
},
source: true,
target: false,
enabled: !this.isReadOnly,
paintStyle: {
outlineStroke: 'none',
},
hoverPaintStyle: {
outlineStroke: 'none',
},
parameters: {
connection: 'source',
nodeId: this.nodeId,
type: outputName,
index: typeIndex,
category: outputConfiguration?.category,
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
};
if (outputConfiguration?.category) {
plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`;
}
if (!this.instance || !this.data) {
return;
}
const plusEndpoint = this.instance.addEndpoint(
this.$refs[this.data.name] as Element,
plusEndpointData,
);
this.__addEndpointTestingData(plusEndpoint, 'plus', typeIndex);
if (!Array.isArray(plusEndpoint)) {
plusEndpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: typeIndex,
nodeType: node.type,
totalEndpoints: outputsOfSameRootType.length,
};
}
}
});
},
__addNode(node: INodeUi) {
const nodeTypeData = (this.nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
this.nodeTypesStore.getNodeType(NO_OP_NODE_TYPE)) as INodeTypeDescription;
this.__addInputEndpoints(node, nodeTypeData);
this.__addOutputEndpoints(node, nodeTypeData);
},
__getEndpointColor(connectionType: ConnectionTypes) {
return `--node-type-${connectionType}-color`;
},
__getInputConnectionStyle(
connectionType: ConnectionTypes,
nodeTypeData: INodeTypeDescription,
): EndpointOptions {
if (connectionType === NodeConnectionType.Main) {
return {
paintStyle: NodeViewUtils.getInputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(NodeConnectionType.Main),
connectionType,
),
};
}
if (!isValidNodeConnectionType(connectionType)) {
return {};
}
const createSupplementalConnectionType = (
connectionName: ConnectionTypes,
): EndpointOptions => ({
endpoint: createAddInputEndpointSpec(
connectionName as NodeConnectionType,
this.__getEndpointColor(connectionName),
),
});
return createSupplementalConnectionType(connectionType);
},
__getOutputConnectionStyle(
connectionType: ConnectionTypes,
outputConfiguration: INodeOutputConfiguration,
nodeTypeData: INodeTypeDescription,
): EndpointOptions {
const type = 'output';
const createSupplementalConnectionType = (
connectionName: ConnectionTypes,
): EndpointOptions => ({
endpoint: createDiamondOutputEndpointSpec(),
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(connectionName),
),
hoverPaintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(connectionName),
),
});
if (connectionType === NodeConnectionType.Main) {
if (outputConfiguration.category === 'error') {
return {
paintStyle: {
...NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(NodeConnectionType.Main),
),
fill: 'var(--color-danger)',
},
cssClass: `dot-${type}-endpoint`,
};
}
return {
paintStyle: NodeViewUtils.getOutputEndpointStyle(
nodeTypeData,
this.__getEndpointColor(NodeConnectionType.Main),
),
cssClass: `dot-${type}-endpoint`,
};
}
if (!isValidNodeConnectionType(connectionType)) {
return {};
}
return createSupplementalConnectionType(connectionType);
},
touchEnd(_e: MouseEvent) {
const deviceSupport = useDeviceSupport();
if (deviceSupport.isTouchDevice) {
if (this.uiStore.isActionActive('dragActive')) {
this.uiStore.removeActiveAction('dragActive');
}
}
},
mouseLeftClick(e: MouseEvent) {
const deviceSupport = useDeviceSupport();
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
for (let index = 0; index < path.length; index++) {
if (
path[index].className &&
typeof path[index].className === 'string' &&
path[index].className.includes('no-select-on-click')
) {
return;
}
}
if (!deviceSupport.isTouchDevice) {
if (this.uiStore.isActionActive('dragActive')) {
this.uiStore.removeActiveAction('dragActive');
} else {
if (!deviceSupport.isCtrlKeyPressed(e)) {
this.$emit('deselectAllNodes');
}
if (this.uiStore.isNodeSelected(this.data?.name ?? '')) {
this.$emit('deselectNode', this.name);
} else {
this.$emit('nodeSelected', this.name);
}
}
}
},
},
});