mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat: Add model selector node (#16371)
This commit is contained in:
@@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { INodeTypeDescription, WorkflowParameters } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, Workflow } from 'n8n-workflow';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const nodeType: INodeTypeDescription = {
|
||||
displayName: 'OpenAI',
|
||||
@@ -57,6 +58,8 @@ const workflow: WorkflowParameters = {
|
||||
};
|
||||
|
||||
const getNodeType = vi.fn();
|
||||
let mockWorkflowData = workflow;
|
||||
let mockGetNodeByName = vi.fn(() => node);
|
||||
|
||||
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
@@ -66,8 +69,8 @@ vi.mock('@/stores/nodeTypes.store', () => ({
|
||||
|
||||
vi.mock('@/stores/workflows.store', () => ({
|
||||
useWorkflowsStore: vi.fn(() => ({
|
||||
getCurrentWorkflow: vi.fn(() => new Workflow(workflow)),
|
||||
getNodeByName: vi.fn(() => node),
|
||||
getCurrentWorkflow: vi.fn(() => new Workflow(mockWorkflowData)),
|
||||
getNodeByName: mockGetNodeByName,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -88,17 +91,17 @@ describe('NDVSubConnections', () => {
|
||||
vi.advanceTimersByTime(1000); // Event debounce time
|
||||
|
||||
await waitFor(() => {});
|
||||
expect(getByTestId('subnode-connection-group-ai_tool')).toBeVisible();
|
||||
expect(getByTestId('subnode-connection-group-ai_tool-0')).toBeVisible();
|
||||
expect(html()).toEqual(
|
||||
`<div class="container">
|
||||
<div class="connections" style="--possible-connections: 1;">
|
||||
<div data-test-id="subnode-connection-group-ai_tool">
|
||||
<div data-test-id="subnode-connection-group-ai_tool-0">
|
||||
<div class="connectionType"><span class="connectionLabel">Tools</span>
|
||||
<div>
|
||||
<div class="connectedNodesWrapper" style="--nodes-length: 0;">
|
||||
<div class="plusButton">
|
||||
<n8n-tooltip placement="top" teleported="true" offset="10" show-after="300" disabled="false">
|
||||
<n8n-icon-button size="medium" icon="plus" type="tertiary" data-test-id="add-subnode-ai_tool"></n8n-icon-button>
|
||||
<n8n-icon-button size="medium" icon="plus" type="tertiary" data-test-id="add-subnode-ai_tool-0"></n8n-icon-button>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
@@ -123,4 +126,101 @@ describe('NDVSubConnections', () => {
|
||||
await waitFor(() => {});
|
||||
expect(component.html()).toEqual('<!--v-if-->');
|
||||
});
|
||||
|
||||
it('should render multiple connections of the same type separately', async () => {
|
||||
// Mock a ModelSelector-like node with multiple ai_languageModel connections
|
||||
const multiConnectionNodeType: INodeTypeDescription = {
|
||||
displayName: 'Model Selector',
|
||||
name: 'modelSelector',
|
||||
version: [1],
|
||||
inputs: [
|
||||
{ type: NodeConnectionTypes.Main },
|
||||
{
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
displayName: 'Model 1',
|
||||
required: true,
|
||||
maxConnections: 1,
|
||||
},
|
||||
{
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
displayName: 'Model 2',
|
||||
required: true,
|
||||
maxConnections: 1,
|
||||
},
|
||||
{
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
displayName: 'Model 3',
|
||||
required: true,
|
||||
maxConnections: 1,
|
||||
},
|
||||
],
|
||||
outputs: [NodeConnectionTypes.AiLanguageModel],
|
||||
properties: [],
|
||||
defaults: { color: '', name: '' },
|
||||
group: [],
|
||||
description: '',
|
||||
};
|
||||
|
||||
const multiConnectionNode: INodeUi = {
|
||||
...node,
|
||||
name: 'ModelSelector',
|
||||
type: 'modelSelector',
|
||||
};
|
||||
|
||||
// Mock connected nodes
|
||||
const mockWorkflow = {
|
||||
...workflow,
|
||||
nodes: [multiConnectionNode],
|
||||
connectionsByDestinationNode: {
|
||||
ModelSelector: {
|
||||
[NodeConnectionTypes.AiLanguageModel]: [
|
||||
null, // Main input (index 0) - no ai_languageModel connection
|
||||
[{ node: 'OpenAI1', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], // Model 1 (index 1)
|
||||
[{ node: 'Claude', type: NodeConnectionTypes.AiLanguageModel, index: 0 }], // Model 2 (index 2)
|
||||
[], // Model 3 (index 3) - no connection
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock additional nodes
|
||||
const openAI1Node: INodeUi = {
|
||||
...node,
|
||||
name: 'OpenAI1',
|
||||
type: '@n8n/n8n-nodes-langchain.openAi',
|
||||
};
|
||||
const claudeNode: INodeUi = {
|
||||
...node,
|
||||
name: 'Claude',
|
||||
type: '@n8n/n8n-nodes-langchain.claude',
|
||||
};
|
||||
|
||||
getNodeType.mockReturnValue(multiConnectionNodeType);
|
||||
|
||||
// Update mock data for this test
|
||||
mockWorkflowData = mockWorkflow;
|
||||
mockGetNodeByName = vi.fn((name: string) => {
|
||||
if (name === 'ModelSelector') return multiConnectionNode;
|
||||
if (name === 'OpenAI1') return openAI1Node;
|
||||
if (name === 'Claude') return claudeNode;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { getByTestId } = render(NDVSubConnections, {
|
||||
props: {
|
||||
rootNode: multiConnectionNode,
|
||||
},
|
||||
});
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('subnode-connection-group-ai_languageModel-0')).toBeVisible(); // Model 1
|
||||
expect(getByTestId('subnode-connection-group-ai_languageModel-1')).toBeVisible(); // Model 2
|
||||
expect(getByTestId('subnode-connection-group-ai_languageModel-2')).toBeVisible(); // Model 3
|
||||
|
||||
expect(getByTestId('add-subnode-ai_languageModel-0')).toBeVisible();
|
||||
expect(getByTestId('add-subnode-ai_languageModel-1')).toBeVisible();
|
||||
expect(getByTestId('add-subnode-ai_languageModel-2')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ interface NodeConfig {
|
||||
|
||||
const possibleConnections = ref<INodeInputConfiguration[]>([]);
|
||||
|
||||
const expandedGroups = ref<NodeConnectionType[]>([]);
|
||||
const expandedGroups = ref<string[]>([]);
|
||||
const shouldShowNodeInputIssues = ref(false);
|
||||
|
||||
const nodeType = computed(() =>
|
||||
@@ -61,41 +61,79 @@ const nodeInputIssues = computed(() => {
|
||||
return issues?.input ?? {};
|
||||
});
|
||||
|
||||
const connectedNodes = computed<Record<NodeConnectionType, NodeConfig[]>>(() => {
|
||||
const connectedNodes = computed<Record<string, NodeConfig[]>>(() => {
|
||||
const typeIndexCounters: Record<string, number> = {};
|
||||
|
||||
return possibleConnections.value.reduce(
|
||||
(acc, connection) => {
|
||||
const nodes = getINodesFromNames(
|
||||
workflow.value.getParentNodes(props.rootNode.name, connection.type),
|
||||
);
|
||||
return { ...acc, [connection.type]: nodes };
|
||||
// Track index per connection type
|
||||
const typeIndex = typeIndexCounters[connection.type] ?? 0;
|
||||
typeIndexCounters[connection.type] = typeIndex + 1;
|
||||
|
||||
// Get input-index-specific connections using the per-type index
|
||||
const nodeConnections =
|
||||
workflow.value.connectionsByDestinationNode[props.rootNode.name]?.[connection.type] ?? [];
|
||||
const inputConnections = nodeConnections[typeIndex] ?? [];
|
||||
const nodeNames = inputConnections.map((conn) => conn.node);
|
||||
const nodes = getINodesFromNames(nodeNames);
|
||||
|
||||
// Use a unique key that combines connection type and per-type index
|
||||
const connectionKey = `${connection.type}-${typeIndex}`;
|
||||
return { ...acc, [connectionKey]: nodes };
|
||||
},
|
||||
{} as Record<NodeConnectionType, NodeConfig[]>,
|
||||
{} as Record<string, NodeConfig[]>,
|
||||
);
|
||||
});
|
||||
|
||||
function getConnectionConfig(connectionType: NodeConnectionType) {
|
||||
return possibleConnections.value.find((c) => c.type === connectionType);
|
||||
function getConnectionKey(connection: INodeInputConfiguration, globalIndex: number): string {
|
||||
// Calculate the per-type index for this connection
|
||||
let typeIndex = 0;
|
||||
for (let i = 0; i < globalIndex; i++) {
|
||||
if (possibleConnections.value[i].type === connection.type) {
|
||||
typeIndex++;
|
||||
}
|
||||
}
|
||||
return `${connection.type}-${typeIndex}`;
|
||||
}
|
||||
|
||||
function isMultiConnection(connectionType: NodeConnectionType) {
|
||||
const connectionConfig = getConnectionConfig(connectionType);
|
||||
function getConnectionConfig(connectionKey: string) {
|
||||
const [type, indexStr] = connectionKey.split('-');
|
||||
const typeIndex = parseInt(indexStr, 10);
|
||||
|
||||
// Find the connection config by type and type-specific index
|
||||
let currentTypeIndex = 0;
|
||||
for (const connection of possibleConnections.value) {
|
||||
if (connection.type === type) {
|
||||
if (currentTypeIndex === typeIndex) {
|
||||
return connection;
|
||||
}
|
||||
currentTypeIndex++;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isMultiConnection(connectionKey: string) {
|
||||
const connectionConfig = getConnectionConfig(connectionKey);
|
||||
return connectionConfig?.maxConnections !== 1;
|
||||
}
|
||||
|
||||
function shouldShowConnectionTooltip(connectionType: NodeConnectionType) {
|
||||
return isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType);
|
||||
function shouldShowConnectionTooltip(connectionKey: string) {
|
||||
const [type] = connectionKey.split('-');
|
||||
return isMultiConnection(connectionKey) && !expandedGroups.value.includes(type);
|
||||
}
|
||||
|
||||
function expandConnectionGroup(connectionType: NodeConnectionType, isExpanded: boolean) {
|
||||
function expandConnectionGroup(connectionKey: string, isExpanded: boolean) {
|
||||
const [type] = connectionKey.split('-');
|
||||
// If the connection is a single connection, we don't need to expand the group
|
||||
if (!isMultiConnection(connectionType)) {
|
||||
if (!isMultiConnection(connectionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
expandedGroups.value = [...expandedGroups.value, connectionType];
|
||||
expandedGroups.value = [...expandedGroups.value, type];
|
||||
} else {
|
||||
expandedGroups.value = expandedGroups.value.filter((g) => g !== connectionType);
|
||||
expandedGroups.value = expandedGroups.value.filter((g) => g !== type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +154,9 @@ function getINodesFromNames(names: string[]): NodeConfig[] {
|
||||
.filter((n): n is NodeConfig => n !== null);
|
||||
}
|
||||
|
||||
function hasInputIssues(connectionType: NodeConnectionType) {
|
||||
return (
|
||||
shouldShowNodeInputIssues.value && (nodeInputIssues.value[connectionType] ?? []).length > 0
|
||||
);
|
||||
function hasInputIssues(connectionKey: string) {
|
||||
const [type] = connectionKey.split('-');
|
||||
return shouldShowNodeInputIssues.value && (nodeInputIssues.value[type] ?? []).length > 0;
|
||||
}
|
||||
|
||||
function isNodeInputConfiguration(
|
||||
@@ -144,27 +181,29 @@ function getPossibleSubInputConnections(): INodeInputConfiguration[] {
|
||||
return nonMainInputs;
|
||||
}
|
||||
|
||||
function onNodeClick(nodeName: string, connectionType: NodeConnectionType) {
|
||||
if (isMultiConnection(connectionType) && !expandedGroups.value.includes(connectionType)) {
|
||||
expandConnectionGroup(connectionType, true);
|
||||
function onNodeClick(nodeName: string, connectionKey: string) {
|
||||
const [type] = connectionKey.split('-');
|
||||
if (isMultiConnection(connectionKey) && !expandedGroups.value.includes(type)) {
|
||||
expandConnectionGroup(connectionKey, true);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('switchSelectedNode', nodeName);
|
||||
}
|
||||
|
||||
function onPlusClick(connectionType: NodeConnectionType) {
|
||||
const connectionNodes = connectedNodes.value[connectionType];
|
||||
function onPlusClick(connectionKey: string) {
|
||||
const [type] = connectionKey.split('-');
|
||||
const connectionNodes = connectedNodes.value[connectionKey];
|
||||
if (
|
||||
isMultiConnection(connectionType) &&
|
||||
!expandedGroups.value.includes(connectionType) &&
|
||||
isMultiConnection(connectionKey) &&
|
||||
!expandedGroups.value.includes(type) &&
|
||||
connectionNodes.length >= 1
|
||||
) {
|
||||
expandConnectionGroup(connectionType, true);
|
||||
expandConnectionGroup(connectionKey, true);
|
||||
return;
|
||||
}
|
||||
|
||||
emit('openConnectionNodeCreator', props.rootNode.name, connectionType);
|
||||
emit('openConnectionNodeCreator', props.rootNode.name, type as NodeConnectionType);
|
||||
}
|
||||
|
||||
function showNodeInputsIssues() {
|
||||
@@ -200,39 +239,41 @@ defineExpose({
|
||||
:style="`--possible-connections: ${possibleConnections.length}`"
|
||||
>
|
||||
<div
|
||||
v-for="connection in possibleConnections"
|
||||
:key="connection.type"
|
||||
:data-test-id="`subnode-connection-group-${connection.type}`"
|
||||
v-for="(connection, index) in possibleConnections"
|
||||
:key="getConnectionKey(connection, index)"
|
||||
:data-test-id="`subnode-connection-group-${getConnectionKey(connection, index)}`"
|
||||
>
|
||||
<div :class="$style.connectionType">
|
||||
<span
|
||||
:class="{
|
||||
[$style.connectionLabel]: true,
|
||||
[$style.hasIssues]: hasInputIssues(connection.type),
|
||||
[$style.hasIssues]: hasInputIssues(getConnectionKey(connection, index)),
|
||||
}"
|
||||
v-text="`${connection.displayName}${connection.required ? ' *' : ''}`"
|
||||
/>
|
||||
<OnClickOutside @trigger="expandConnectionGroup(connection.type, false)">
|
||||
<OnClickOutside
|
||||
@trigger="expandConnectionGroup(getConnectionKey(connection, index), false)"
|
||||
>
|
||||
<div
|
||||
ref="connectedNodesWrapper"
|
||||
:class="{
|
||||
[$style.connectedNodesWrapper]: true,
|
||||
[$style.connectedNodesWrapperExpanded]: expandedGroups.includes(connection.type),
|
||||
}"
|
||||
:style="`--nodes-length: ${connectedNodes[connection.type].length}`"
|
||||
@click="expandConnectionGroup(connection.type, true)"
|
||||
:style="`--nodes-length: ${connectedNodes[getConnectionKey(connection, index)].length}`"
|
||||
@click="expandConnectionGroup(getConnectionKey(connection, index), true)"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
connectedNodes[connection.type].length >= 1
|
||||
connectedNodes[getConnectionKey(connection, index)].length >= 1
|
||||
? connection.maxConnections !== 1
|
||||
: true
|
||||
"
|
||||
:class="{
|
||||
[$style.plusButton]: true,
|
||||
[$style.hasIssues]: hasInputIssues(connection.type),
|
||||
[$style.hasIssues]: hasInputIssues(getConnectionKey(connection, index)),
|
||||
}"
|
||||
@click="onPlusClick(connection.type)"
|
||||
@click="onPlusClick(getConnectionKey(connection, index))"
|
||||
>
|
||||
<n8n-tooltip
|
||||
placement="top"
|
||||
@@ -240,13 +281,13 @@ defineExpose({
|
||||
:offset="10"
|
||||
:show-after="300"
|
||||
:disabled="
|
||||
shouldShowConnectionTooltip(connection.type) &&
|
||||
connectedNodes[connection.type].length >= 1
|
||||
shouldShowConnectionTooltip(getConnectionKey(connection, index)) &&
|
||||
connectedNodes[getConnectionKey(connection, index)].length >= 1
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
Add {{ connection.displayName }}
|
||||
<template v-if="hasInputIssues(connection.type)">
|
||||
<template v-if="hasInputIssues(getConnectionKey(connection, index))">
|
||||
<TitledList
|
||||
:title="`${i18n.baseText('node.issues')}:`"
|
||||
:items="nodeInputIssues[connection.type]"
|
||||
@@ -257,24 +298,25 @@ defineExpose({
|
||||
size="medium"
|
||||
icon="plus"
|
||||
type="tertiary"
|
||||
:data-test-id="`add-subnode-${connection.type}`"
|
||||
:data-test-id="`add-subnode-${getConnectionKey(connection, index)}`"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-if="connectedNodes[connection.type].length > 0"
|
||||
v-if="connectedNodes[getConnectionKey(connection, index)].length > 0"
|
||||
:class="{
|
||||
[$style.connectedNodes]: true,
|
||||
[$style.connectedNodesMultiple]: connectedNodes[connection.type].length > 1,
|
||||
[$style.connectedNodesMultiple]:
|
||||
connectedNodes[getConnectionKey(connection, index)].length > 1,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(node, index) in connectedNodes[connection.type]"
|
||||
v-for="(node, nodeIndex) in connectedNodes[getConnectionKey(connection, index)]"
|
||||
:key="node.node.name"
|
||||
:class="{ [$style.nodeWrapper]: true, [$style.hasIssues]: node.issues }"
|
||||
data-test-id="floating-subnode"
|
||||
:data-node-name="node.node.name"
|
||||
:style="`--node-index: ${index}`"
|
||||
:style="`--node-index: ${nodeIndex}`"
|
||||
>
|
||||
<n8n-tooltip
|
||||
:key="node.node.name"
|
||||
@@ -282,7 +324,7 @@ defineExpose({
|
||||
:teleported="true"
|
||||
:offset="10"
|
||||
:show-after="300"
|
||||
:disabled="shouldShowConnectionTooltip(connection.type)"
|
||||
:disabled="shouldShowConnectionTooltip(getConnectionKey(connection, index))"
|
||||
>
|
||||
<template #content>
|
||||
{{ node.node.name }}
|
||||
@@ -296,7 +338,7 @@ defineExpose({
|
||||
|
||||
<div
|
||||
:class="$style.connectedNode"
|
||||
@click="onNodeClick(node.node.name, connection.type)"
|
||||
@click="onNodeClick(node.node.name, getConnectionKey(connection, index))"
|
||||
>
|
||||
<NodeIcon
|
||||
:node-type="node.nodeType"
|
||||
|
||||
Reference in New Issue
Block a user