feat(editor): Add support for connection validation (no-changelog) (#10059)
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { computed, h, provide, toRef, useCssModule } from 'vue';
|
import { computed, h, provide, toRef, useCssModule } from 'vue';
|
||||||
import type { CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
|
import type { CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
|
||||||
|
|
||||||
|
import type { ValidConnectionFunc } from '@vue-flow/core';
|
||||||
import { Handle } from '@vue-flow/core';
|
import { Handle } from '@vue-flow/core';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
|
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
|
||||||
@@ -16,6 +17,7 @@ const props = defineProps<{
|
|||||||
index: CanvasConnectionPort['index'];
|
index: CanvasConnectionPort['index'];
|
||||||
position: CanvasElementPortWithPosition['position'];
|
position: CanvasElementPortWithPosition['position'];
|
||||||
offset: CanvasElementPortWithPosition['offset'];
|
offset: CanvasElementPortWithPosition['offset'];
|
||||||
|
isValidConnection: ValidConnectionFunc;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
@@ -66,6 +68,7 @@ provide(CanvasNodeHandleKey, {
|
|||||||
:style="offset"
|
:style="offset"
|
||||||
:connectable-start="isConnectableStart"
|
:connectable-start="isConnectableStart"
|
||||||
:connectable-end="isConnectableEnd"
|
:connectable-end="isConnectableEnd"
|
||||||
|
:is-valid-connection="isValidConnection"
|
||||||
>
|
>
|
||||||
<Render :label="label" />
|
<Render :label="label" />
|
||||||
</Handle>
|
</Handle>
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ const nodeTypesStore = useNodeTypesStore();
|
|||||||
const inputs = computed(() => props.data.inputs);
|
const inputs = computed(() => props.data.inputs);
|
||||||
const outputs = computed(() => props.data.outputs);
|
const outputs = computed(() => props.data.outputs);
|
||||||
const connections = computed(() => props.data.connections);
|
const connections = computed(() => props.data.connections);
|
||||||
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs } = useNodeConnections({
|
const { mainInputs, nonMainInputs, mainOutputs, nonMainOutputs, isValidConnection } =
|
||||||
inputs,
|
useNodeConnections({
|
||||||
outputs,
|
inputs,
|
||||||
connections,
|
outputs,
|
||||||
});
|
connections,
|
||||||
|
});
|
||||||
|
|
||||||
const isDisabled = computed(() => props.data.disabled);
|
const isDisabled = computed(() => props.data.disabled);
|
||||||
|
|
||||||
@@ -39,13 +40,6 @@ const nodeType = computed(() => {
|
|||||||
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.selected,
|
|
||||||
(selected) => {
|
|
||||||
emit('select', props.id, selected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inputs
|
* Inputs
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +62,14 @@ const outputsWithPosition = computed(() => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node icon
|
||||||
|
*/
|
||||||
|
|
||||||
|
const nodeIconSize = computed(() =>
|
||||||
|
'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoints
|
* Endpoints
|
||||||
*/
|
*/
|
||||||
@@ -89,26 +91,9 @@ const mapEndpointWithPosition =
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide
|
* Events
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const id = toRef(props, 'id');
|
|
||||||
const data = toRef(props, 'data');
|
|
||||||
const label = toRef(props, 'label');
|
|
||||||
const selected = toRef(props, 'selected');
|
|
||||||
|
|
||||||
provide(CanvasNodeKey, {
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
label,
|
|
||||||
selected,
|
|
||||||
nodeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeIconSize = computed(() =>
|
|
||||||
'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40,
|
|
||||||
);
|
|
||||||
|
|
||||||
function onDelete() {
|
function onDelete() {
|
||||||
emit('delete', props.id);
|
emit('delete', props.id);
|
||||||
}
|
}
|
||||||
@@ -132,6 +117,34 @@ function onUpdate(parameters: Record<string, unknown>) {
|
|||||||
function onMove(position: XYPosition) {
|
function onMove(position: XYPosition) {
|
||||||
emit('move', props.id, position);
|
emit('move', props.id, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide
|
||||||
|
*/
|
||||||
|
|
||||||
|
const id = toRef(props, 'id');
|
||||||
|
const data = toRef(props, 'data');
|
||||||
|
const label = toRef(props, 'label');
|
||||||
|
const selected = toRef(props, 'selected');
|
||||||
|
|
||||||
|
provide(CanvasNodeKey, {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
nodeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle
|
||||||
|
*/
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selected,
|
||||||
|
(selected) => {
|
||||||
|
emit('select', props.id, selected);
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,6 +158,7 @@ function onMove(position: XYPosition) {
|
|||||||
:index="source.index"
|
:index="source.index"
|
||||||
:position="source.position"
|
:position="source.position"
|
||||||
:offset="source.offset"
|
:offset="source.offset"
|
||||||
|
:is-valid-connection="isValidConnection"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,6 +171,7 @@ function onMove(position: XYPosition) {
|
|||||||
:index="target.index"
|
:index="target.index"
|
||||||
:position="target.position"
|
:position="target.position"
|
||||||
:offset="target.offset"
|
:offset="target.offset"
|
||||||
|
:is-valid-connection="isValidConnection"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ref } from 'vue';
|
|||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useNodeConnections } from '@/composables/useNodeConnections';
|
import { useNodeConnections } from '@/composables/useNodeConnections';
|
||||||
import type { CanvasNodeData } from '@/types';
|
import type { CanvasNodeData } from '@/types';
|
||||||
|
import { CanvasConnectionMode } from '@/types';
|
||||||
|
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||||
|
|
||||||
describe('useNodeConnections', () => {
|
describe('useNodeConnections', () => {
|
||||||
const defaultConnections = { input: {}, output: {} };
|
const defaultConnections = { input: {}, output: {} };
|
||||||
@@ -158,4 +160,87 @@ describe('useNodeConnections', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isValidConnection', () => {
|
||||||
|
const inputs = ref<CanvasNodeData['inputs']>([]);
|
||||||
|
const outputs = ref<CanvasNodeData['outputs']>([]);
|
||||||
|
|
||||||
|
const { isValidConnection } = useNodeConnections({
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
connections: defaultConnections,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if source and target nodes are the same', () => {
|
||||||
|
const connection = {
|
||||||
|
source: 'node1',
|
||||||
|
target: 'node1',
|
||||||
|
sourceHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Output,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
targetHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Input,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(isValidConnection(connection)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if source and target handles are of the same mode', () => {
|
||||||
|
const connection = {
|
||||||
|
source: 'node1',
|
||||||
|
target: 'node2',
|
||||||
|
sourceHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Output,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
targetHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Output,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(isValidConnection(connection)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if source and target handles are of different types', () => {
|
||||||
|
const connection = {
|
||||||
|
source: 'node1',
|
||||||
|
target: 'node2',
|
||||||
|
sourceHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Output,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
targetHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Input,
|
||||||
|
type: NodeConnectionType.AiMemory,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(isValidConnection(connection)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if source and target nodes are different, modes are different, and types are the same', () => {
|
||||||
|
const connection = {
|
||||||
|
source: 'node1',
|
||||||
|
target: 'node2',
|
||||||
|
sourceHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Output,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
targetHandle: createCanvasConnectionHandleString({
|
||||||
|
mode: CanvasConnectionMode.Input,
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(isValidConnection(connection)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { CanvasNodeData } from '@/types';
|
|||||||
import type { MaybeRef } from 'vue';
|
import type { MaybeRef } from 'vue';
|
||||||
import { computed, unref } from 'vue';
|
import { computed, unref } from 'vue';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import type { Connection } from '@vue-flow/core';
|
||||||
|
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||||
|
|
||||||
export function useNodeConnections({
|
export function useNodeConnections({
|
||||||
inputs,
|
inputs,
|
||||||
@@ -48,6 +50,25 @@ export function useNodeConnections({
|
|||||||
() => unref(connections).output[NodeConnectionType.Main] ?? [],
|
() => unref(connections).output[NodeConnectionType.Main] ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
function isValidConnection(connection: Connection) {
|
||||||
|
const { type: sourceType, mode: sourceMode } = parseCanvasConnectionHandleString(
|
||||||
|
connection.sourceHandle,
|
||||||
|
);
|
||||||
|
const { type: targetType, mode: targetMode } = parseCanvasConnectionHandleString(
|
||||||
|
connection.targetHandle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSameNode = connection.source === connection.target;
|
||||||
|
const isSameMode = sourceMode === targetMode;
|
||||||
|
const isSameType = sourceType === targetType;
|
||||||
|
|
||||||
|
return !isSameNode && !isSameMode && isSameType;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mainInputs,
|
mainInputs,
|
||||||
nonMainInputs,
|
nonMainInputs,
|
||||||
@@ -56,5 +77,6 @@ export function useNodeConnections({
|
|||||||
mainOutputs,
|
mainOutputs,
|
||||||
nonMainOutputs,
|
nonMainOutputs,
|
||||||
mainOutputConnections,
|
mainOutputConnections,
|
||||||
|
isValidConnection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user