mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Add support for maxConnections
in new canvas (no-changelog) (#10994)
This commit is contained in:
parent
b20d2eb403
commit
3ff3e05e75
|
@ -1,5 +1,5 @@
|
||||||
import { CanvasNodeHandleKey, CanvasNodeKey } from '@/constants';
|
import { CanvasNodeHandleKey, CanvasNodeKey } from '@/constants';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
CanvasNode,
|
CanvasNode,
|
||||||
CanvasNodeData,
|
CanvasNodeData,
|
||||||
|
@ -143,7 +143,7 @@ export function createCanvasHandleProvide({
|
||||||
mode: ref(mode),
|
mode: ref(mode),
|
||||||
type: ref(type),
|
type: ref(type),
|
||||||
index: ref(index),
|
index: ref(index),
|
||||||
isConnected: ref(isConnected),
|
isConnected: computed(() => isConnected),
|
||||||
isConnecting: ref(isConnecting),
|
isConnecting: ref(isConnecting),
|
||||||
isReadOnly: ref(isReadOnly),
|
isReadOnly: ref(isReadOnly),
|
||||||
isRequired: ref(isRequired),
|
isRequired: ref(isRequired),
|
||||||
|
|
|
@ -14,19 +14,23 @@ import { CanvasNodeHandleKey } from '@/constants';
|
||||||
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
|
||||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<
|
||||||
mode: CanvasConnectionMode;
|
CanvasElementPortWithRenderData & {
|
||||||
isConnected?: boolean;
|
type: CanvasConnectionPort['type'];
|
||||||
isConnecting?: boolean;
|
required?: CanvasConnectionPort['required'];
|
||||||
isReadOnly?: boolean;
|
maxConnections?: CanvasConnectionPort['maxConnections'];
|
||||||
label?: string;
|
index: CanvasConnectionPort['index'];
|
||||||
required?: boolean;
|
label?: CanvasConnectionPort['label'];
|
||||||
type: CanvasConnectionPort['type'];
|
handleId: CanvasElementPortWithRenderData['handleId'];
|
||||||
index: CanvasConnectionPort['index'];
|
connectionsCount: CanvasElementPortWithRenderData['connectionsCount'];
|
||||||
position: CanvasElementPortWithRenderData['position'];
|
isConnecting: CanvasElementPortWithRenderData['isConnecting'];
|
||||||
offset: CanvasElementPortWithRenderData['offset'];
|
position: CanvasElementPortWithRenderData['position'];
|
||||||
isValidConnection: ValidConnectionFunc;
|
offset?: CanvasElementPortWithRenderData['offset'];
|
||||||
}>();
|
mode: CanvasConnectionMode;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
isValidConnection: ValidConnectionFunc;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
add: [handle: string];
|
add: [handle: string];
|
||||||
|
@ -50,15 +54,29 @@ const handleString = computed(() =>
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connectable
|
||||||
|
*/
|
||||||
|
|
||||||
|
const connectionsLimitReached = computed(() => {
|
||||||
|
return props.maxConnections && props.connectionsCount >= props.maxConnections;
|
||||||
|
});
|
||||||
|
|
||||||
const isConnectableStart = computed(() => {
|
const isConnectableStart = computed(() => {
|
||||||
|
if (connectionsLimitReached.value) return false;
|
||||||
|
|
||||||
return props.mode === CanvasConnectionMode.Output || props.type !== NodeConnectionType.Main;
|
return props.mode === CanvasConnectionMode.Output || props.type !== NodeConnectionType.Main;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isConnectableEnd = computed(() => {
|
const isConnectableEnd = computed(() => {
|
||||||
|
if (connectionsLimitReached.value) return false;
|
||||||
|
|
||||||
return props.mode === CanvasConnectionMode.Input || props.type !== NodeConnectionType.Main;
|
return props.mode === CanvasConnectionMode.Input || props.type !== NodeConnectionType.Main;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClasses = computed(() => [style.handle, style[props.type], style[props.mode]]);
|
const isConnected = computed(() => props.connectionsCount > 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run data
|
* Run data
|
||||||
|
@ -111,7 +129,6 @@ function onAdd() {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const label = toRef(props, 'label');
|
const label = toRef(props, 'label');
|
||||||
const isConnected = toRef(props, 'isConnected');
|
|
||||||
const isConnecting = toRef(props, 'isConnecting');
|
const isConnecting = toRef(props, 'isConnecting');
|
||||||
const isReadOnly = toRef(props, 'isReadOnly');
|
const isReadOnly = toRef(props, 'isReadOnly');
|
||||||
const mode = toRef(props, 'mode');
|
const mode = toRef(props, 'mode');
|
||||||
|
|
|
@ -154,7 +154,7 @@ const createEndpointMappingFn =
|
||||||
index: endpoint.index,
|
index: endpoint.index,
|
||||||
});
|
});
|
||||||
const handleType = mode === CanvasConnectionMode.Input ? 'target' : 'source';
|
const handleType = mode === CanvasConnectionMode.Input ? 'target' : 'source';
|
||||||
const isConnected = !!connections.value[mode][endpoint.type]?.[endpoint.index]?.length;
|
const connectionsCount = connections.value[mode][endpoint.type]?.[endpoint.index]?.length ?? 0;
|
||||||
const isConnecting =
|
const isConnecting =
|
||||||
connectingHandle.value?.nodeId === props.id &&
|
connectingHandle.value?.nodeId === props.id &&
|
||||||
connectingHandle.value?.handleType === handleType &&
|
connectingHandle.value?.handleType === handleType &&
|
||||||
|
@ -163,7 +163,7 @@ const createEndpointMappingFn =
|
||||||
return {
|
return {
|
||||||
...endpoint,
|
...endpoint,
|
||||||
handleId,
|
handleId,
|
||||||
isConnected,
|
connectionsCount,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
position,
|
position,
|
||||||
offset: {
|
offset: {
|
||||||
|
@ -262,36 +262,22 @@ onBeforeUnmount(() => {
|
||||||
>
|
>
|
||||||
<template v-for="source in mappedOutputs" :key="source.handleId">
|
<template v-for="source in mappedOutputs" :key="source.handleId">
|
||||||
<CanvasHandleRenderer
|
<CanvasHandleRenderer
|
||||||
data-test-id="canvas-node-output-handle"
|
v-bind="source"
|
||||||
:mode="CanvasConnectionMode.Output"
|
:mode="CanvasConnectionMode.Output"
|
||||||
:type="source.type"
|
|
||||||
:label="source.label"
|
|
||||||
:index="source.index"
|
|
||||||
:position="source.position"
|
|
||||||
:offset="source.offset"
|
|
||||||
:required="source.required"
|
|
||||||
:is-connected="source.isConnected"
|
|
||||||
:is-connecting="source.isConnecting"
|
|
||||||
:is-read-only="readOnly"
|
:is-read-only="readOnly"
|
||||||
:is-valid-connection="isValidConnection"
|
:is-valid-connection="isValidConnection"
|
||||||
|
data-test-id="canvas-node-output-handle"
|
||||||
@add="onAdd"
|
@add="onAdd"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-for="target in mappedInputs" :key="target.handleId">
|
<template v-for="target in mappedInputs" :key="target.handleId">
|
||||||
<CanvasHandleRenderer
|
<CanvasHandleRenderer
|
||||||
data-test-id="canvas-node-input-handle"
|
v-bind="target"
|
||||||
:mode="CanvasConnectionMode.Input"
|
:mode="CanvasConnectionMode.Input"
|
||||||
:type="target.type"
|
|
||||||
:label="target.label"
|
|
||||||
:index="target.index"
|
|
||||||
:position="target.position"
|
|
||||||
:offset="target.offset"
|
|
||||||
:required="target.required"
|
|
||||||
:is-connected="target.isConnected"
|
|
||||||
:is-connecting="target.isConnecting"
|
|
||||||
:is-read-only="readOnly"
|
:is-read-only="readOnly"
|
||||||
:is-valid-connection="isValidConnection"
|
:is-valid-connection="isValidConnection"
|
||||||
|
data-test-id="canvas-node-input-handle"
|
||||||
@add="onAdd"
|
@add="onAdd"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
|
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
|
||||||
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||||
import type { Ref } from 'vue';
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
import type { PartialBy } from '@/utils/typeHelpers';
|
import type { PartialBy } from '@/utils/typeHelpers';
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
|
|
||||||
|
@ -26,13 +26,14 @@ export const canvasConnectionModes = [
|
||||||
export type CanvasConnectionPort = {
|
export type CanvasConnectionPort = {
|
||||||
type: CanvasConnectionPortType;
|
type: CanvasConnectionPortType;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
maxConnections?: number;
|
||||||
index: number;
|
index: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CanvasElementPortWithRenderData extends CanvasConnectionPort {
|
export interface CanvasElementPortWithRenderData extends CanvasConnectionPort {
|
||||||
handleId: string;
|
handleId: string;
|
||||||
isConnected: boolean;
|
connectionsCount: number;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
position: Position;
|
position: Position;
|
||||||
offset?: { top?: string; left?: string };
|
offset?: { top?: string; left?: string };
|
||||||
|
@ -166,7 +167,7 @@ export interface CanvasNodeHandleInjectionData {
|
||||||
type: Ref<NodeConnectionType>;
|
type: Ref<NodeConnectionType>;
|
||||||
index: Ref<number>;
|
index: Ref<number>;
|
||||||
isRequired: Ref<boolean | undefined>;
|
isRequired: Ref<boolean | undefined>;
|
||||||
isConnected: Ref<boolean | undefined>;
|
isConnected: ComputedRef<boolean | undefined>;
|
||||||
isConnecting: Ref<boolean | undefined>;
|
isConnecting: Ref<boolean | undefined>;
|
||||||
isReadOnly: Ref<boolean | undefined>;
|
isReadOnly: Ref<boolean | undefined>;
|
||||||
runData: Ref<ExecutionOutputMapData | undefined>;
|
runData: Ref<ExecutionOutputMapData | undefined>;
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import {
|
import {
|
||||||
mapLegacyConnectionsToCanvasConnections,
|
|
||||||
mapLegacyEndpointsToCanvasConnectionPort,
|
|
||||||
getUniqueNodeName,
|
|
||||||
mapCanvasConnectionToLegacyConnection,
|
|
||||||
parseCanvasConnectionHandleString,
|
|
||||||
createCanvasConnectionHandleString,
|
createCanvasConnectionHandleString,
|
||||||
createCanvasConnectionId,
|
createCanvasConnectionId,
|
||||||
|
getUniqueNodeName,
|
||||||
|
mapCanvasConnectionToLegacyConnection,
|
||||||
|
mapLegacyConnectionsToCanvasConnections,
|
||||||
|
mapLegacyEndpointsToCanvasConnectionPort,
|
||||||
|
parseCanvasConnectionHandleString,
|
||||||
checkOverlap,
|
checkOverlap,
|
||||||
} from '@/utils/canvasUtilsV2';
|
} from '@/utils/canvasUtilsV2';
|
||||||
import { NodeConnectionType, type IConnections, type INodeTypeDescription } from 'n8n-workflow';
|
import { type IConnections, type INodeTypeDescription, NodeConnectionType } from 'n8n-workflow';
|
||||||
import type { CanvasConnection } from '@/types';
|
import type { CanvasConnection } from '@/types';
|
||||||
|
import { CanvasConnectionMode } from '@/types';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { Connection } from '@vue-flow/core';
|
import type { Connection } from '@vue-flow/core';
|
||||||
import { createTestNode } from '@/__tests__/mocks';
|
import { createTestNode } from '@/__tests__/mocks';
|
||||||
import { CanvasConnectionMode } from '@/types';
|
|
||||||
|
|
||||||
vi.mock('uuid', () => ({
|
vi.mock('uuid', () => ({
|
||||||
v4: vi.fn(() => 'mock-uuid'),
|
v4: vi.fn(() => 'mock-uuid'),
|
||||||
|
@ -802,6 +802,29 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
|
||||||
{ type: NodeConnectionType.AiTool, index: 0, label: 'Optional Tool' },
|
{ type: NodeConnectionType.AiTool, index: 0, label: 'Optional Tool' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should map maxConnections correctly', () => {
|
||||||
|
const endpoints: INodeTypeDescription['inputs'] = [
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiMemory,
|
||||||
|
maxConnections: 1,
|
||||||
|
displayName: 'Optional Tool',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = mapLegacyEndpointsToCanvasConnectionPort(endpoints);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
maxConnections: undefined,
|
||||||
|
index: 0,
|
||||||
|
label: undefined,
|
||||||
|
},
|
||||||
|
{ type: NodeConnectionType.AiMemory, maxConnections: 1, index: 0, label: 'Optional Tool' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getUniqueNodeName', () => {
|
describe('getUniqueNodeName', () => {
|
||||||
|
|
|
@ -183,11 +183,13 @@ export function mapLegacyEndpointsToCanvasConnectionPort(
|
||||||
.slice(0, endpointIndex + 1)
|
.slice(0, endpointIndex + 1)
|
||||||
.filter((e) => (typeof e === 'string' ? e : e.type) === type).length - 1;
|
.filter((e) => (typeof e === 'string' ? e : e.type) === type).length - 1;
|
||||||
const required = typeof endpoint === 'string' ? false : endpoint.required;
|
const required = typeof endpoint === 'string' ? false : endpoint.required;
|
||||||
|
const maxConnections = typeof endpoint === 'string' ? undefined : endpoint.maxConnections;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
index,
|
index,
|
||||||
label,
|
label,
|
||||||
|
...(maxConnections ? { maxConnections } : {}),
|
||||||
...(required ? { required } : {}),
|
...(required ? { required } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue