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 @@ + + + + + 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