diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts
index f58b66f603..765988a7aa 100644
--- a/cypress/e2e/5-ndv.cy.ts
+++ b/cypress/e2e/5-ndv.cy.ts
@@ -1,7 +1,8 @@
import { v4 as uuid } from 'uuid';
import { getVisibleSelect } from '../utils';
-import { MANUAL_TRIGGER_NODE_DISPLAY_NAME } from '../constants';
+import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME } from '../constants';
import { NDV, WorkflowPage } from '../pages';
+import { NodeCreator } from '../pages/features/node-creator';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@@ -386,14 +387,12 @@ describe('NDV', () => {
) {
return cy.get(`[data-node-placement=${position}]`);
}
- beforeEach(() => {
+
+ it('should traverse floating nodes with mouse', () => {
cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
workflowPage.getters.canvasNodes().first().dblclick();
getFloatingNodeByPosition('inputMain').should('not.exist');
getFloatingNodeByPosition('outputMain').should('exist');
- });
-
- it('should traverse floating nodes with mouse', () => {
// Traverse 4 connected node forwards
Array.from(Array(4).keys()).forEach((i) => {
getFloatingNodeByPosition('outputMain').click({ force: true });
@@ -411,19 +410,6 @@ describe('NDV', () => {
getFloatingNodeByPosition('outputMain').click({ force: true });
ndv.getters.nodeNameContainer().should('contain', 'Chain');
- getFloatingNodeByPosition('inputSub').should('exist');
- getFloatingNodeByPosition('inputSub').click({ force: true });
- ndv.getters.nodeNameContainer().should('contain', 'Model');
- getFloatingNodeByPosition('inputSub').should('not.exist');
- getFloatingNodeByPosition('inputMain').should('not.exist');
- getFloatingNodeByPosition('outputMain').should('not.exist');
- getFloatingNodeByPosition('outputSub').should('exist');
- ndv.actions.close();
- workflowPage.getters.selectedNodes().should('have.length', 1);
- workflowPage.getters.selectedNodes().first().should('contain', 'Model');
- workflowPage.getters.selectedNodes().first().dblclick();
- getFloatingNodeByPosition('outputSub').click({ force: true });
- ndv.getters.nodeNameContainer().should('contain', 'Chain');
// Traverse 4 connected node backwards
Array.from(Array(4).keys()).forEach((i) => {
@@ -448,7 +434,11 @@ describe('NDV', () => {
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
});
- it('should traverse floating nodes with mouse', () => {
+ it('should traverse floating nodes with keyboard', () => {
+ cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`);
+ workflowPage.getters.canvasNodes().first().dblclick();
+ getFloatingNodeByPosition('inputMain').should('not.exist');
+ getFloatingNodeByPosition('outputMain').should('exist');
// Traverse 4 connected node forwards
Array.from(Array(4).keys()).forEach((i) => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
@@ -466,19 +456,6 @@ describe('NDV', () => {
cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowRight']);
ndv.getters.nodeNameContainer().should('contain', 'Chain');
- getFloatingNodeByPosition('inputSub').should('exist');
- cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowDown']);
- ndv.getters.nodeNameContainer().should('contain', 'Model');
- getFloatingNodeByPosition('inputSub').should('not.exist');
- getFloatingNodeByPosition('inputMain').should('not.exist');
- getFloatingNodeByPosition('outputMain').should('not.exist');
- getFloatingNodeByPosition('outputSub').should('exist');
- ndv.actions.close();
- workflowPage.getters.selectedNodes().should('have.length', 1);
- workflowPage.getters.selectedNodes().first().should('contain', 'Model');
- workflowPage.getters.selectedNodes().first().dblclick();
- cy.realPress(['ShiftLeft', 'Meta', 'AltLeft', 'ArrowUp']);
- ndv.getters.nodeNameContainer().should('contain', 'Chain');
// Traverse 4 connected node backwards
Array.from(Array(4).keys()).forEach((i) => {
@@ -502,6 +479,47 @@ describe('NDV', () => {
.first()
.should('contain', MANUAL_TRIGGER_NODE_DISPLAY_NAME);
});
+
+ it('should connect floating sub-nodes', () => {
+ const nodeCreator = new NodeCreator();
+ const connectionGroups = [
+ {
+ title: 'Language Models',
+ id: 'ai_languageModel'
+ },
+ {
+ title: 'Tools',
+ id: 'ai_tool'
+ },
+ ]
+
+ workflowPage.actions.addInitialNodeToCanvas('AI Agent', { keepNdvOpen: true });
+
+ connectionGroups.forEach((group) => {
+ cy.getByTestId(`add-subnode-${group.id}`).should('exist');
+ cy.getByTestId(`add-subnode-${group.id}`).click();
+
+ cy.getByTestId('nodes-list-header').contains(group.title).should('exist');
+ nodeCreator.getters.getNthCreatorItem(1).click();
+ getFloatingNodeByPosition('outputSub').should('exist');
+ getFloatingNodeByPosition('outputSub').click({ force: true });
+
+ if (group.id === 'ai_languageModel') {
+ cy.getByTestId(`add-subnode-${group.id}`).should('not.exist');
+ } else {
+ cy.getByTestId(`add-subnode-${group.id}`).should('exist');
+ // Expand the subgroup
+ cy.getByTestId('subnode-connection-group-ai_tool').click();
+ cy.getByTestId(`add-subnode-${group.id}`).click();
+ nodeCreator.getters.getNthCreatorItem(1).click();
+ getFloatingNodeByPosition('outputSub').click({ force: true });
+ cy.getByTestId('subnode-connection-group-ai_tool').findChildByTestId('floating-subnode').should('have.length', 2);
+ }
+ });
+
+ // Since language model has no credentials set, it should show an error
+ cy.get('[class*=hasIssues]').should('have.length', 1);
+ })
});
it('should show node name and version in settings', () => {
diff --git a/packages/editor-ui/src/components/NDVDraggablePanels.vue b/packages/editor-ui/src/components/NDVDraggablePanels.vue
index 26be4016cd..2d5684503e 100644
--- a/packages/editor-ui/src/components/NDVDraggablePanels.vue
+++ b/packages/editor-ui/src/components/NDVDraggablePanels.vue
@@ -3,7 +3,6 @@
diff --git a/packages/editor-ui/src/components/NDVFloatingNodes.vue b/packages/editor-ui/src/components/NDVFloatingNodes.vue
index 0a592b1e71..f7abf5f1e1 100644
--- a/packages/editor-ui/src/components/NDVFloatingNodes.vue
+++ b/packages/editor-ui/src/components/NDVFloatingNodes.vue
@@ -46,12 +46,10 @@ import type { INodeTypeDescription } from 'n8n-workflow';
interface Props {
rootNode: INodeUi;
- type: 'input' | 'sub-input' | 'sub-output' | 'output';
}
const enum FloatingNodePosition {
top = 'outputSub',
right = 'outputMain',
- bottom = 'inputSub',
left = 'inputMain',
}
const props = defineProps
();
@@ -77,7 +75,6 @@ function onKeyDown(e: KeyboardEvent) {
const mapper = {
ArrowUp: FloatingNodePosition.top,
ArrowRight: FloatingNodePosition.right,
- ArrowDown: FloatingNodePosition.bottom,
ArrowLeft: FloatingNodePosition.left,
};
/* eslint-enable @typescript-eslint/naming-convention */
@@ -111,9 +108,6 @@ const connectedNodes = computed<
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
),
[FloatingNodePosition.right]: getINodesFromNames(workflow.getChildNodes(rootName, 'main', 1)),
- [FloatingNodePosition.bottom]: getINodesFromNames(
- workflow.getParentNodes(rootName, 'ALL_NON_MAIN'),
- ),
[FloatingNodePosition.left]: getINodesFromNames(workflow.getParentNodes(rootName, 'main', 1)),
};
});
@@ -121,13 +115,11 @@ const connectedNodes = computed<
const connectionGroups = [
FloatingNodePosition.top,
FloatingNodePosition.right,
- FloatingNodePosition.bottom,
FloatingNodePosition.left,
];
const tooltipPositionMapper = {
[FloatingNodePosition.top]: 'bottom',
[FloatingNodePosition.right]: 'left',
- [FloatingNodePosition.bottom]: 'top',
[FloatingNodePosition.left]: 'right',
};
diff --git a/packages/editor-ui/src/components/NDVSubConnections.vue b/packages/editor-ui/src/components/NDVSubConnections.vue
new file mode 100644
index 0000000000..b2be088544
--- /dev/null
+++ b/packages/editor-ui/src/components/NDVSubConnections.vue
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+
+
+ Add {{ connection.displayName }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ node.node.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/NodeDetailsView.vue b/packages/editor-ui/src/components/NodeDetailsView.vue
index 48ef6a4df7..2f02a01675 100644
--- a/packages/editor-ui/src/components/NodeDetailsView.vue
+++ b/packages/editor-ui/src/components/NodeDetailsView.vue
@@ -45,6 +45,7 @@
:has-double-width="activeNodeType?.parameterPane === 'wide'"
:node-type="activeNodeType"
@switchSelectedNode="onSwitchSelectedNode"
+ @openConnectionNodeCreator="onOpenConnectionNodeCreator"
@close="close"
@init="onPanelsInit"
@dragstart="onDragStart"
@@ -117,6 +118,8 @@
@stopExecution="onStopExecution"
@redrawRequired="redrawRequired = true"
@activate="onWorkflowActivate"
+ @switchSelectedNode="onSwitchSelectedNode"
+ @openConnectionNodeCreator="onOpenConnectionNodeCreator"
/>
+
@@ -178,6 +185,7 @@ import type {
INodeParameters,
INodeProperties,
NodeParameterValue,
+ ConnectionTypes,
} from 'n8n-workflow';
import { NodeHelpers, NodeConnectionType, deepCopy } from 'n8n-workflow';
import type {
@@ -199,6 +207,7 @@ import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
+import NDVSubConnections from '@/components/NDVSubConnections.vue';
import { get, set, unset } from 'lodash-es';
import NodeExecuteButton from './NodeExecuteButton.vue';
@@ -223,6 +232,7 @@ export default defineComponent({
ParameterInputList,
NodeSettingsTabs,
NodeWebhooks,
+ NDVSubConnections,
NodeExecuteButton,
},
setup() {
@@ -467,6 +477,12 @@ export default defineComponent({
this.eventBus?.off('openSettings', this.openSettings);
},
methods: {
+ onSwitchSelectedNode(node: string) {
+ this.$emit('switchSelectedNode', node);
+ },
+ onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
+ this.$emit('openConnectionNodeCreator', node, connectionType);
+ },
populateHiddenIssuesSet() {
if (!this.node || !this.workflowsStore.isNodePristine(this.node.name)) return;
@@ -612,6 +628,7 @@ export default defineComponent({
},
onNodeExecute() {
this.hiddenIssuesInputs = [];
+ (this.$refs.subConnections as InstanceType)?.showNodeInputsIssues();
this.$emit('execute');
},
setValue(name: string, value: NodeParameterValue) {
diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue
index 07d7fda0a8..9afd6cec17 100644
--- a/packages/editor-ui/src/views/NodeView.vue
+++ b/packages/editor-ui/src/views/NodeView.vue
@@ -94,6 +94,7 @@
:is-production-execution-preview="isProductionExecutionPreview"
@redrawNode="redrawNode"
@switchSelectedNode="onSwitchSelectedNode"
+ @openConnectionNodeCreator="onOpenConnectionNodeCreator"
@valueChanged="valueChanged"
@stopExecution="stopExecution"
@saveKeyboardShortcut="onSaveKeyboardShortcut"
@@ -900,38 +901,7 @@ export default defineComponent({
this.registerCustomAction({
key: 'openSelectiveNodeCreator',
- action: async ({
- connectiontype,
- node,
- creatorview,
- }: {
- connectiontype: NodeConnectionType;
- node: string;
- creatorview?: string;
- }) => {
- const nodeName = node ?? this.ndvStore.activeNodeName;
- const nodeData = nodeName ? this.workflowsStore.getNodeByName(nodeName) : null;
-
- this.ndvStore.activeNodeName = null;
- await this.redrawNode(node);
- // Wait for UI to update
- setTimeout(() => {
- if (creatorview) {
- this.onToggleNodeCreator({
- createNodeActive: true,
- nodeCreatorView: creatorview,
- });
- } else if (connectiontype && nodeData) {
- this.insertNodeAfterSelected({
- index: 0,
- endpointUuid: `${nodeData.id}-input${connectiontype}0`,
- eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
- outputType: connectiontype,
- sourceId: nodeData.id,
- });
- }
- }, 0);
- },
+ action: this.openSelectiveNodeCreator,
});
this.readOnlyEnvRouteCheck();
@@ -1022,6 +992,38 @@ export default defineComponent({
sourceControlEventBus.off('pull', this.onSourceControlPull);
},
methods: {
+ async openSelectiveNodeCreator({
+ connectiontype,
+ node,
+ creatorview,
+ }: {
+ connectiontype: ConnectionTypes;
+ node: string;
+ creatorview?: string;
+ }) {
+ const nodeName = node ?? this.ndvStore.activeNodeName;
+ const nodeData = nodeName ? this.workflowsStore.getNodeByName(nodeName) : null;
+
+ this.ndvStore.activeNodeName = null;
+ await this.redrawNode(node);
+ // Wait for UI to update
+ setTimeout(() => {
+ if (creatorview) {
+ this.onToggleNodeCreator({
+ createNodeActive: true,
+ nodeCreatorView: creatorview,
+ });
+ } else if (connectiontype && nodeData) {
+ this.insertNodeAfterSelected({
+ index: 0,
+ endpointUuid: `${nodeData.id}-input${connectiontype}0`,
+ eventSource: NODE_CREATOR_OPEN_SOURCES.NOTICE_ERROR_MESSAGE,
+ outputType: connectiontype,
+ sourceId: nodeData.id,
+ });
+ }
+ });
+ },
editAllowedCheck(): boolean {
if (this.readOnlyNotification?.visible) {
return;
@@ -3966,6 +3968,12 @@ export default defineComponent({
async onSwitchSelectedNode(nodeName: string) {
this.nodeSelectedByName(nodeName, true, true);
},
+ async onOpenConnectionNodeCreator(node: string, connectionType: ConnectionTypes) {
+ await this.openSelectiveNodeCreator({
+ connectiontype: connectionType,
+ node,
+ });
+ },
async redrawNode(nodeName: string) {
// TODO: Improve later
// For now we redraw the node by simply renaming it. Can for sure be