feat(editor): Change default node names depending on node operation and resource (#15954)

This commit is contained in:
Charlie Kolb
2025-06-10 08:50:46 +02:00
committed by GitHub
parent 33f8fab791
commit c92701cbdf
21 changed files with 574 additions and 182 deletions

View File

@@ -50,7 +50,7 @@ describe('Inline expression editor', () => {
beforeEach(() => { beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openInlineExpressionEditor(); WorkflowPage.actions.openInlineExpressionEditor();
}); });
@@ -112,7 +112,7 @@ describe('Inline expression editor', () => {
WorkflowPage.actions.addNodeToCanvas('No Operation'); WorkflowPage.actions.addNodeToCanvas('No Operation');
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openInlineExpressionEditor(); WorkflowPage.actions.openInlineExpressionEditor();
}); });
@@ -150,7 +150,7 @@ describe('Inline expression editor', () => {
// Run workflow // Run workflow
ndv.actions.close(); ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' }); WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openInlineExpressionEditor(); WorkflowPage.actions.openInlineExpressionEditor();
// Previous nodes have run, input can be resolved // Previous nodes have run, input can be resolved

View File

@@ -96,7 +96,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Notion'); workflowPage.actions.openNode('Append a block');
ndv.getters.credentialInput().should('have.value', 'Credential C1').should('be.disabled'); ndv.getters.credentialInput().should('have.value', 'Credential C1').should('be.disabled');
ndv.actions.close(); ndv.actions.close();
}); });
@@ -112,7 +112,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Notion'); workflowPage.actions.openNode('Append a block');
ndv.getters ndv.getters
.credentialInput() .credentialInput()
.find('input') .find('input')
@@ -136,7 +136,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
cy.visit(workflowsPage.url); cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 2); workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardContent('Workflow W1').click(); workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.openNode('Notion'); workflowPage.actions.openNode('Append a block');
ndv.getters ndv.getters
.credentialInput() .credentialInput()
.find('input') .find('input')

View File

@@ -157,7 +157,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
expect(interception.request.query).not.to.have.property('projectId'); expect(interception.request.query).not.to.have.property('projectId');
expect(interception.request.query).to.have.property('workflowId'); expect(interception.request.query).to.have.property('workflowId');
}); });
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
@@ -182,7 +182,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload(); cy.reload();
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
@@ -211,7 +211,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload(); cy.reload();
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
workflowPage.getters.nodeCredentialsSelect().first().click(); workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect() getVisibleSelect()
.find('li') .find('li')
@@ -280,7 +280,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click(); workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click();
// Check if the credential can be changed // Check if the credential can be changed
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.canvasNodeByName('Append a block').should('be.visible').dblclick();
ndv.getters.credentialInput().find('input').should('be.enabled'); ndv.getters.credentialInput().find('input').should('be.enabled');
}); });

View File

@@ -316,7 +316,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); nodeCreatorFeature.getters.getCreatorItem('Create a credential').click();
NDVModal.actions.close(); NDVModal.actions.close();
WorkflowPage.actions.deleteNode('When clicking Execute workflow'); WorkflowPage.actions.deleteNode('When clicking Execute workflow');
WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click(); WorkflowPage.getters.canvasNodePlusEndpointByName('Create a credential').click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n');
nodeCreatorFeature.getters.getCreatorItem('n8n').click(); nodeCreatorFeature.getters.getCreatorItem('n8n').click();
nodeCreatorFeature.getters.getCategoryItem('Actions').click(); nodeCreatorFeature.getters.getCategoryItem('Actions').click();
@@ -324,7 +324,11 @@ describe('Node Creator', () => {
NDVModal.actions.close(); NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Summarize'); WorkflowPage.actions.addNodeBetweenNodes(
'Create a credential',
'Create a credential1',
'Summarize',
);
WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.canvasNodes().should('have.length', 3);
}); });
}); });

View File

@@ -24,14 +24,14 @@ describe('Editors', () => {
.type('SELECT * FROM `testTable`', { delay: TYPING_DELAY }) .type('SELECT * FROM `testTable`', { delay: TYPING_DELAY })
.type('{esc}'); .type('{esc}');
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.openNode('Postgres'); workflowPage.actions.openNode('Execute a SQL query');
ndv.getters ndv.getters
.sqlEditorContainer() .sqlEditorContainer()
.find('.cm-content') .find('.cm-content')
.type('{end} LIMIT 10', { delay: TYPING_DELAY }) .type('{end} LIMIT 10', { delay: TYPING_DELAY })
.type('{esc}'); .type('{esc}');
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.openNode('Postgres'); workflowPage.actions.openNode('Execute a SQL query');
ndv.getters.sqlEditorContainer().should('contain', 'SELECT * FROM `testTable` LIMIT 10'); ndv.getters.sqlEditorContainer().should('contain', 'SELECT * FROM `testTable` LIMIT 10');
}); });
@@ -45,7 +45,7 @@ describe('Editors', () => {
ndv.actions.setPinnedData([{ table: 'test_table' }]); ndv.actions.setPinnedData([{ table: 'test_table' }]);
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.openNode('MySQL'); workflowPage.actions.openNode('Execute a SQL query');
ndv.getters ndv.getters
.sqlEditorContainer() .sqlEditorContainer()
.find('.cm-content') .find('.cm-content')
@@ -86,7 +86,7 @@ describe('Editors', () => {
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved(); workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('Postgres'); workflowPage.actions.openNode('Execute a SQL query');
ndv.actions.close(); ndv.actions.close();
// Workflow should still be saved // Workflow should still be saved
workflowPage.getters.isWorkflowSaved(); workflowPage.getters.isWorkflowSaved();
@@ -100,7 +100,7 @@ describe('Editors', () => {
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.isWorkflowSaved(); workflowPage.getters.isWorkflowSaved();
workflowPage.actions.openNode('Postgres'); workflowPage.actions.openNode('Execute a SQL query');
ndv.getters ndv.getters
.sqlEditorContainer() .sqlEditorContainer()
.click() .click()
@@ -131,14 +131,14 @@ describe('Editors', () => {
.paste('SELECT * FROM `secondTable`'); .paste('SELECT * FROM `secondTable`');
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.openNode('Postgres'); workflowPage.actions.openNode('Execute a SQL query');
ndv.actions.clickFloatingNode('Postgres1'); ndv.actions.clickFloatingNode('Execute a SQL query1');
ndv.getters ndv.getters
.sqlEditorContainer() .sqlEditorContainer()
.find('.cm-content') .find('.cm-content')
.should('have.text', 'SELECT * FROM `secondTable`'); .should('have.text', 'SELECT * FROM `secondTable`');
ndv.actions.clickFloatingNode('Postgres'); ndv.actions.clickFloatingNode('Execute a SQL query');
ndv.getters ndv.getters
.sqlEditorContainer() .sqlEditorContainer()
.find('.cm-content') .find('.cm-content')

View File

@@ -364,7 +364,7 @@ describe('AI Assistant Credential Help', () => {
}).as('chatRequest'); }).as('chatRequest');
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME); wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
wf.actions.openNode('Gmail'); wf.actions.openNode('Add label to message');
openCredentialSelect(); openCredentialSelect();
clickCreateNewCredential(); clickCreateNewCredential();
aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.visible'); aiAssistant.getters.credentialEditAssistantButton().find('button').should('be.visible');

View File

@@ -86,18 +86,11 @@ describe('NDV', () => {
cy.get('[class*=hasIssues]').should('have.length', 1); cy.get('[class*=hasIssues]').should('have.length', 1);
}); });
it('should show validation errors only after blur or re-opening of NDV', () => { it('should show validation errors only after blur or re-opening of NDV of a node with operation and resource', () => {
workflowPage.actions.addNodeToCanvas('Manual'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Search records'); workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Search records');
ndv.getters.container().should('be.visible'); ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0);
ndv.getters.parameterInput('table').find('input').eq(1).focus().blur();
ndv.getters.parameterInput('base').find('input').eq(1).focus().blur();
cy.get('.has-issues').should('have.length', 2); cy.get('.has-issues').should('have.length', 2);
ndv.getters.backToCanvas().click();
workflowPage.actions.openNode('Airtable');
cy.get('.has-issues').should('have.length', 2);
cy.get('[class*=hasIssues]').should('have.length', 1);
}); });
// Correctly failing in V2 - node issues are only shows after execution // Correctly failing in V2 - node issues are only shows after execution

View File

@@ -17,7 +17,7 @@ describe('Expression editor modal', () => {
beforeEach(() => { beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openExpressionEditorModal(); WorkflowPage.actions.openExpressionEditorModal();
}); });
@@ -34,7 +34,7 @@ describe('Expression editor modal', () => {
beforeEach(() => { beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openExpressionEditorModal(); WorkflowPage.actions.openExpressionEditorModal();
}); });
@@ -90,7 +90,7 @@ describe('Expression editor modal', () => {
WorkflowPage.actions.addNodeToCanvas('No Operation'); WorkflowPage.actions.addNodeToCanvas('No Operation');
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openExpressionEditorModal(); WorkflowPage.actions.openExpressionEditorModal();
}); });
@@ -125,7 +125,7 @@ describe('Expression editor modal', () => {
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
ndv.actions.close(); ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' }); WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Get many items');
WorkflowPage.actions.openExpressionEditorModal(); WorkflowPage.actions.openExpressionEditorModal();
// Previous nodes have run, input can be resolved // Previous nodes have run, input can be resolved

View File

@@ -217,7 +217,7 @@ export class WorkflowPage extends BasePage {
cy.get('body').then((body) => { cy.get('body').then((body) => {
if (body.find('[data-test-id=node-creator]').length > 0) { if (body.find('[data-test-id=node-creator]').length > 0) {
if (action) { if (action) {
cy.contains(action).click(); cy.get('[data-keyboard-nav-type="action"]').contains(action).click();
} else { } else {
// Select the first action // Select the first action
if (body.find('[data-keyboard-nav-type="action"]').length > 0) { if (body.find('[data-keyboard-nav-type="action"]').length > 0) {

View File

@@ -562,6 +562,11 @@ describe('ManualExecutionService', () => {
return null; return null;
}), }),
getTriggerNodes: jest.fn().mockReturnValue([determinedStartNode]), getTriggerNodes: jest.fn().mockReturnValue([determinedStartNode]),
nodeTypes: {
getByNameAndVersion: jest
.fn()
.mockReturnValue({ description: { name: '', outputs: [] } }),
},
}); });
jest jest

View File

@@ -6,10 +6,9 @@ import {
filterDisabledNodes, filterDisabledNodes,
recreateNodeExecutionStack, recreateNodeExecutionStack,
WorkflowExecute, WorkflowExecute,
isTool,
rewireGraph, rewireGraph,
} from 'n8n-core'; } from 'n8n-core';
import { MANUAL_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import { MANUAL_TRIGGER_NODE_TYPE, NodeHelpers } from 'n8n-workflow';
import type { import type {
IExecuteData, IExecuteData,
IPinData, IPinData,
@@ -136,8 +135,12 @@ export class ManualExecutionService {
`Could not find a node named "${data.destinationNode}" in the workflow.`, `Could not find a node named "${data.destinationNode}" in the workflow.`,
); );
const destinationNodeType = workflow.nodeTypes.getByNameAndVersion(
destinationNode.type,
destinationNode.typeVersion,
);
// Rewire graph to be able to execute the destination tool node // Rewire graph to be able to execute the destination tool node
if (isTool(destinationNode, workflow.nodeTypes)) { if (NodeHelpers.isTool(destinationNodeType.description, destinationNode.parameters)) {
const graph = rewireGraph( const graph = rewireGraph(
destinationNode, destinationNode,
DirectedGraph.fromWorkflow(workflow), DirectedGraph.fromWorkflow(workflow),

View File

@@ -1,102 +0,0 @@
import { mock } from 'jest-mock-extended';
import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow';
import { isTool } from '../is-tool';
describe('isTool', () => {
const mockNode = mock<INode>({
id: '1',
type: 'n8n-nodes-base.openAi',
typeVersion: 1,
parameters: {},
});
const mockNodeTypes = mock<INodeTypes>();
it('should return true for a node with AiTool output', () => {
mockNodeTypes.getByNameAndVersion.mockReturnValue({
description: {
outputs: [NodeConnectionTypes.AiTool],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
name: '',
group: [],
description: '',
},
});
const result = isTool(mockNode, mockNodeTypes);
expect(result).toBe(true);
});
it('should return true for a node with AiTool output in NodeOutputConfiguration', () => {
mockNodeTypes.getByNameAndVersion.mockReturnValue({
description: {
outputs: [{ type: NodeConnectionTypes.AiTool }, { type: NodeConnectionTypes.Main }],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
name: '',
group: [],
description: '',
},
});
const result = isTool(mockNode, mockNodeTypes);
expect(result).toBe(true);
});
it('returns true for a vectore store node in retrieve-as-tool mode', () => {
mockNode.type = 'n8n-nodes-base.vectorStore';
mockNode.parameters = { mode: 'retrieve-as-tool' };
mockNodeTypes.getByNameAndVersion.mockReturnValue({
description: {
outputs: [NodeConnectionTypes.Main],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
name: '',
group: [],
description: '',
},
});
const result = isTool(mockNode, mockNodeTypes);
expect(result).toBe(true);
});
it('returns false for node with no AiTool output', () => {
mockNode.type = 'n8n-nodes-base.someTool';
mockNode.parameters = {};
mockNodeTypes.getByNameAndVersion.mockReturnValue({
description: {
outputs: [NodeConnectionTypes.Main],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
name: '',
group: [],
description: '',
},
});
const result = isTool(mockNode, mockNodeTypes);
expect(result).toBe(false);
});
});

View File

@@ -6,6 +6,5 @@ export { recreateNodeExecutionStack } from './recreate-node-execution-stack';
export { cleanRunData } from './clean-run-data'; export { cleanRunData } from './clean-run-data';
export { handleCycles } from './handle-cycles'; export { handleCycles } from './handle-cycles';
export { filterDisabledNodes } from './filter-disabled-nodes'; export { filterDisabledNodes } from './filter-disabled-nodes';
export { isTool } from './is-tool';
export { rewireGraph } from './rewire-graph'; export { rewireGraph } from './rewire-graph';
export { getNextExecutionIndex } from './run-data-utils'; export { getNextExecutionIndex } from './run-data-utils';

View File

@@ -1,22 +0,0 @@
import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow';
export function isTool(node: INode, nodeTypes: INodeTypes) {
const type = nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
// Check if node is a vector store in retrieve-as-tool mode
if (node.type.includes('vectorStore')) {
const mode = node.parameters?.mode;
return mode === 'retrieve-as-tool';
}
// Check for other tool nodes
for (const output of type.description.outputs) {
if (typeof output === 'string') {
return output === NodeConnectionTypes.AiTool;
} else if (output?.type && output.type === NodeConnectionTypes.AiTool) {
return true;
}
}
return false;
}

View File

@@ -72,7 +72,6 @@ import {
handleCycles, handleCycles,
filterDisabledNodes, filterDisabledNodes,
rewireGraph, rewireGraph,
isTool,
getNextExecutionIndex, getNextExecutionIndex,
} from './partial-execution-utils'; } from './partial-execution-utils';
import { TOOL_EXECUTOR_NODE_NAME } from './partial-execution-utils/rewire-graph'; import { TOOL_EXECUTOR_NODE_NAME } from './partial-execution-utils/rewire-graph';
@@ -368,8 +367,12 @@ export class WorkflowExecute {
let graph = DirectedGraph.fromWorkflow(workflow); let graph = DirectedGraph.fromWorkflow(workflow);
const destinationNodeType = workflow.nodeTypes.getByNameAndVersion(
destination.type,
destination.typeVersion,
);
// Partial execution of nodes as tools // Partial execution of nodes as tools
if (isTool(destination, workflow.nodeTypes)) { if (NodeHelpers.isTool(destinationNodeType.description, destination.parameters)) {
graph = rewireGraph(destination, graph, agentRequest); graph = rewireGraph(destination, graph, agentRequest);
workflow = graph.toWorkflow({ ...workflow }); workflow = graph.toWorkflow({ ...workflow });
// Rewire destination node to the virtual agent // Rewire destination node to the virtual agent

View File

@@ -2,6 +2,7 @@ import { computed } from 'vue';
import { import {
CHAIN_LLM_LANGCHAIN_NODE_TYPE, CHAIN_LLM_LANGCHAIN_NODE_TYPE,
NodeConnectionTypes, NodeConnectionTypes,
NodeHelpers,
type IDataObject, type IDataObject,
type INodeParameters, type INodeParameters,
} from 'n8n-workflow'; } from 'n8n-workflow';
@@ -43,11 +44,14 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { sortNodeCreateElements, transformNodeType } from '../utils'; import { sortNodeCreateElements, transformNodeType } from '../utils';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import findLast from 'lodash/findLast';
export const useActions = () => { export const useActions = () => {
const nodeCreatorStore = useNodeCreatorStore(); const nodeCreatorStore = useNodeCreatorStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n(); const i18n = useI18n();
const canvasOperations = useCanvasOperations();
const singleNodeOpenSources = [ const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT, NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION, NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
@@ -305,7 +309,7 @@ export const useActions = () => {
return { nodes, connections }; return { nodes, connections };
} }
// Hook into addNode action to set the last node parameters & track the action selected // Hook into addNode action to set the last node parameters, adjust default name and track the action selected
function setAddedNodeActionParameters( function setAddedNodeActionParameters(
action: IUpdateInformation, action: IUpdateInformation,
telemetry?: Telemetry, telemetry?: Telemetry,
@@ -313,10 +317,25 @@ export const useActions = () => {
) { ) {
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore(); const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
const storeWatcher = onWorkflowStoreAction( const storeWatcher = onWorkflowStoreAction(
({ name, after, store: { setLastNodeParameters }, args }) => { ({ name, after, store: { setLastNodeParameters, allNodes }, args }) => {
if (name !== 'addNode' || args[0].type !== action.key) return; if (name !== 'addNode' || args[0].type !== action.key) return;
after(() => { after(() => {
const node = findLast(allNodes, (n) => n.type === action.key);
const nodeType = node && nodeTypesStore.getNodeType(node.type, node.typeVersion);
const wasDefaultName =
nodeType && NodeHelpers.isDefaultNodeName(node.name, nodeType, node.parameters ?? {});
setLastNodeParameters(action); setLastNodeParameters(action);
// We update the default name here based on the chosen resource and operation
if (wasDefaultName) {
const newName = NodeHelpers.makeNodeName(node.parameters, nodeType);
// Account for unique-ified nodes with `<name><digit>`
if (!node.name.startsWith(newName)) {
// setTimeout to allow remaining events trigger by node addition to finish
setTimeout(async () => await canvasOperations.renameNode(node.name, newName));
}
}
if (telemetry) trackActionSelected(action, telemetry, rootView); if (telemetry) trackActionSelected(action, telemetry, rootView);
// Unsubscribe from the store watcher // Unsubscribe from the store watcher
storeWatcher(); storeWatcher();

View File

@@ -53,6 +53,7 @@ import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { updateDynamicConnections } from '@/utils/nodeSettingsUtils'; import { updateDynamicConnections } from '@/utils/nodeSettingsUtils';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue'; import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -96,6 +97,7 @@ const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const i18n = useI18n(); const i18n = useI18n();
const canvasOperations = useCanvasOperations();
const nodeValid = ref(true); const nodeValid = ref(true);
const openPanel = ref<'params' | 'settings'>('params'); const openPanel = ref<'params' | 'settings'>('params');
@@ -579,6 +581,15 @@ const valueChanged = (parameterData: IUpdateInformation) => {
} }
} }
if (NodeHelpers.isDefaultNodeName(_node.name, nodeType, node.value?.parameters ?? {})) {
const newName = NodeHelpers.makeNodeName(nodeParameters ?? {}, nodeType);
// Account for unique-ified nodes with `<name><digit>`
if (!_node.name.startsWith(newName)) {
// We need a timeout here to support events reacting to the valueChange based on node names
setTimeout(async () => await canvasOperations.renameNode(_node.name, newName));
}
}
for (const key of Object.keys(nodeParameters as object)) { for (const key of Object.keys(nodeParameters as object)) {
if (nodeParameters && nodeParameters[key] !== null && nodeParameters[key] !== undefined) { if (nodeParameters && nodeParameters[key] !== null && nodeParameters[key] !== undefined) {
setValue(`parameters.${key}`, nodeParameters[key] as string); setValue(`parameters.${key}`, nodeParameters[key] as string);

View File

@@ -910,7 +910,10 @@ export function useCanvasOperations() {
options: { viewport?: ViewportBoundaries; forcePosition?: boolean } = {}, options: { viewport?: ViewportBoundaries; forcePosition?: boolean } = {},
) { ) {
const id = node.id ?? nodeHelpers.assignNodeId(node as INodeUi); const id = node.id ?? nodeHelpers.assignNodeId(node as INodeUi);
const name = node.name ?? (nodeTypeDescription.defaults.name as string); const name =
node.name ??
nodeHelpers.getDefaultNodeName(node) ??
(nodeTypeDescription.defaults.name as string);
const type = nodeTypeDescription.name; const type = nodeTypeDescription.name;
const typeVersion = node.typeVersion; const typeVersion = node.typeVersion;
const position = const position =

View File

@@ -29,6 +29,7 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { import type {
AddedNode,
ICredentialsResponse, ICredentialsResponse,
INodeUi, INodeUi,
INodeUpdatePropertiesInformation, INodeUpdatePropertiesInformation,
@@ -986,6 +987,21 @@ export function useNodeHelpers() {
return nodeIssues; return nodeIssues;
} }
function getDefaultNodeName(node: AddedNode | INode) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType === null) return null;
const parameters = NodeHelpers.getNodeParameters(
nodeType?.properties,
node.parameters ?? {},
true,
false,
node.typeVersion ? { typeVersion: node.typeVersion } : null,
nodeType,
);
return NodeHelpers.makeNodeName(parameters ?? {}, nodeType);
}
return { return {
hasProxyAuth, hasProxyAuth,
isCustomApiCallSelected, isCustomApiCallSelected,
@@ -1019,5 +1035,6 @@ export function useNodeHelpers() {
isSingleExecution, isSingleExecution,
getNodeHints, getNodeHints,
nodeIssuesToString, nodeIssuesToString,
getDefaultNodeName,
}; };
} }

View File

@@ -1570,6 +1570,35 @@ export function isNodeWithWorkflowSelector(node: INode) {
return [EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE].includes(node.type); return [EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE].includes(node.type);
} }
/**
* @returns An object containing either the resolved operation's action if available,
* else the resource and operation if both exist.
* If neither can be resolved, returns an empty object.
*/
function resolveResourceAndOperation(
nodeParameters: INodeParameters,
nodeTypeDescription: INodeTypeDescription,
) {
const resource = nodeParameters.resource as string;
const operation = nodeParameters.operation as string;
const nodeTypeOperation = nodeTypeDescription.properties.find(
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource),
);
if (nodeTypeOperation?.options && isINodePropertyOptionsList(nodeTypeOperation.options)) {
const foundOperation = nodeTypeOperation.options.find((option) => option.value === operation);
if (foundOperation?.action) {
return { action: foundOperation.action };
}
}
if (resource && operation) {
return { operation, resource };
} else {
return {};
}
}
/** /**
* Generates a human-readable description for a node based on its parameters and type definition. * Generates a human-readable description for a node based on its parameters and type definition.
* *
@@ -1585,28 +1614,93 @@ export function makeDescription(
nodeParameters: INodeParameters, nodeParameters: INodeParameters,
nodeTypeDescription: INodeTypeDescription, nodeTypeDescription: INodeTypeDescription,
): string { ): string {
let description = ''; const { action, operation, resource } = resolveResourceAndOperation(
const resource = nodeParameters.resource as string; nodeParameters,
const operation = nodeParameters.operation as string; nodeTypeDescription,
const nodeTypeOperation = nodeTypeDescription.properties.find(
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource),
); );
if (nodeTypeOperation?.options && isINodePropertyOptionsList(nodeTypeOperation.options)) { if (action) {
const foundOperation = nodeTypeOperation.options.find((option) => option.value === operation); return `${action} in ${nodeTypeDescription.defaults.name}`;
if (foundOperation?.action) { }
description = `${foundOperation.action} in ${nodeTypeDescription.defaults.name}`;
return description; if (resource && operation) {
return `${operation} ${resource} in ${nodeTypeDescription.defaults.name}`;
}
return nodeTypeDescription.description;
}
export function isTool(
nodeTypeDescription: INodeTypeDescription,
parameters: INodeParameters,
): boolean {
// Check if node is a vector store in retrieve-as-tool mode
if (nodeTypeDescription.name.includes('vectorStore')) {
const mode = parameters.mode;
return mode === 'retrieve-as-tool';
}
// Check for other tool nodes
for (const output of nodeTypeDescription.outputs) {
if (typeof output === 'string') {
return output === NodeConnectionTypes.AiTool;
} else if (output?.type && output.type === NodeConnectionTypes.AiTool) {
return true;
} }
} }
if (!description && resource && operation) { return false;
description = `${operation} ${resource} in ${nodeTypeDescription.defaults.name}`; }
} else {
description = nodeTypeDescription.description; /**
* Generates a resource and operation aware node name.
*
* Appends `in {nodeTypeDisplayName}` if nodeType is a tool
*
* 1. "{action}" if the operation has a defined action
* 2. "{operation} {resource}" if resource and operation exist
* 3. The node type's defaults.name field or displayName as a fallback
*/
export function makeNodeName(
nodeParameters: INodeParameters,
nodeTypeDescription: INodeTypeDescription,
): string {
const { action, operation, resource } = resolveResourceAndOperation(
nodeParameters,
nodeTypeDescription,
);
const postfix = isTool(nodeTypeDescription, nodeParameters)
? ` in ${nodeTypeDescription.defaults.name}`
: '';
if (action) {
return `${action}${postfix}`;
} }
return description; if (resource && operation) {
const operationProper = operation[0].toUpperCase() + operation.slice(1);
return `${operationProper} ${resource}${postfix}`;
}
return nodeTypeDescription.defaults.name ?? nodeTypeDescription.displayName;
}
/**
* Returns true if the node name is of format `<defaultNodeName>\d*` , which includes auto-renamed nodes
*/
export function isDefaultNodeName(
name: string,
nodeType: INodeTypeDescription,
parameters: INodeParameters,
): boolean {
const legacyDefaultName = nodeType.defaults.name ?? nodeType.displayName;
const currentDefaultName = makeNodeName(parameters, nodeType);
for (const defaultName of [legacyDefaultName, currentDefaultName]) {
if (name.startsWith(defaultName) && /^\d*$/.test(name.slice(defaultName.length))) return true;
}
return false;
} }
/** /**

View File

@@ -18,6 +18,9 @@ import {
makeDescription, makeDescription,
getUpdatedToolDescription, getUpdatedToolDescription,
getToolDescriptionForNode, getToolDescriptionForNode,
isDefaultNodeName,
makeNodeName,
isTool,
} from '@/node-helpers'; } from '@/node-helpers';
import type { Workflow } from '@/workflow'; import type { Workflow } from '@/workflow';
@@ -5246,4 +5249,366 @@ describe('NodeHelpers', () => {
expect(result).toBe('This is the default node description'); expect(result).toBe('This is the default node description');
}); });
}); });
describe('isDefaultNodeName', () => {
let mockNodeTypeDescription: INodeTypeDescription;
beforeEach(() => {
// Arrange a basic mock node type description
mockNodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
icon: 'fa:test',
group: ['transform'],
version: 1,
description: 'This is a test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
usableAsTool: true,
};
});
it.each([
['Create a new user', true],
['Test Node', true],
['Test Node1', true],
['Create a new user5', true],
['Create a new user in Test Node5', false],
['Create a new user 5', false],
['Update user', false],
['Update user5', false],
['TestNode', false],
])('should detect default names for input %s', (input, expected) => {
// Arrange
const name = input;
mockNodeTypeDescription.properties = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Create',
value: 'create',
action: 'Create a new user',
},
{
name: 'Update',
value: 'update',
action: 'Update a new user',
},
],
default: 'create',
},
];
const parameters: INodeParameters = {
descriptionType: 'manual',
resource: 'user',
operation: 'create',
};
// Act
const result = isDefaultNodeName(name, mockNodeTypeDescription, parameters);
// Assert
expect(result).toBe(expected);
});
it('should detect default names for tool node types', () => {
// Arrange
const name = 'Create user in Test Node';
mockNodeTypeDescription.outputs = [NodeConnectionTypes.AiTool];
const parameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
// Act
const result = isDefaultNodeName(name, mockNodeTypeDescription, parameters);
// Assert
expect(result).toBe(true);
});
it('should detect non-default names for tool node types', () => {
// Arrange
// The default for tools would include ` in Test Node`
const name = 'Create user';
mockNodeTypeDescription.outputs = [NodeConnectionTypes.AiTool];
const parameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
// Act
const result = isDefaultNodeName(name, mockNodeTypeDescription, parameters);
// Assert
expect(result).toBe(false);
});
});
describe('makeNodeName', () => {
let mockNodeTypeDescription: INodeTypeDescription;
beforeEach(() => {
// Arrange a basic mock node type description
mockNodeTypeDescription = {
displayName: 'Test Node',
name: 'testNode',
icon: 'fa:test',
group: ['transform'],
version: 1,
description: 'This is a test node',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
};
});
test('should return action-based name when action is available', () => {
// Arrange
const nodeParameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
mockNodeTypeDescription.properties = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Create',
value: 'create',
action: 'Create a new user',
},
],
default: 'create',
},
];
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Create a new user');
});
test('should return resource-operation-based name when action is not available', () => {
// Arrange
const nodeParameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
mockNodeTypeDescription.properties = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Create',
value: 'create',
// No action property
},
],
default: 'create',
},
];
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Create user');
});
test('should return default name when resource or operation is missing', () => {
// Arrange
const nodeParameters: INodeParameters = {
// No resource or operation
};
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Test Node');
});
test('should handle case where nodeTypeOperation is not found', () => {
// Arrange
const nodeParameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
mockNodeTypeDescription.properties = [
// No matching operation property
];
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Create user');
});
test('should handle case where options are not a list of INodePropertyOptions', () => {
// Arrange
const nodeParameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
mockNodeTypeDescription.properties = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['user'],
},
},
// Options are not INodePropertyOptions[]
options: [
//@ts-expect-error
{},
],
default: 'create',
},
];
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Create user');
});
test('should handle case where node is a tool', () => {
// Arrange
const nodeParameters: INodeParameters = {
resource: 'user',
operation: 'create',
};
mockNodeTypeDescription.outputs = [NodeConnectionTypes.AiTool];
mockNodeTypeDescription.properties = [
// No matching operation property
];
// Act
const result = makeNodeName(nodeParameters, mockNodeTypeDescription);
// Assert
expect(result).toBe('Create user in Test Node');
});
});
describe('isTool', () => {
it('should return true for a node with AiTool output', () => {
const description = {
outputs: [NodeConnectionTypes.AiTool],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
group: [],
description: '',
name: 'n8n-nodes-base.someTool',
};
const parameters = {};
const result = isTool(description, parameters);
expect(result).toBe(true);
});
it('should return true for a node with AiTool output in NodeOutputConfiguration', () => {
const description = {
outputs: [{ type: NodeConnectionTypes.AiTool }, { type: NodeConnectionTypes.Main }],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
group: [],
description: '',
name: 'n8n-nodes-base.someTool',
};
const parameters = {};
const result = isTool(description, parameters);
expect(result).toBe(true);
});
it('returns true for a vector store node in retrieve-as-tool mode', () => {
const description = {
outputs: [NodeConnectionTypes.Main],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
description: '',
group: [],
name: 'n8n-nodes-base.vectorStore',
};
const parameters = { mode: 'retrieve-as-tool' };
const result = isTool(description, parameters);
expect(result).toBe(true);
});
it('returns false for node with no AiTool output', () => {
const description = {
outputs: [NodeConnectionTypes.Main],
version: 0,
defaults: {
name: '',
color: '',
},
inputs: [NodeConnectionTypes.Main],
properties: [],
displayName: '',
group: [],
description: '',
name: 'n8n-nodes-base.someTool',
};
const parameters = { mode: 'retrieve-as-tool' };
const result = isTool(description, parameters);
expect(result).toBe(false);
});
});
}); });