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

@@ -1570,6 +1570,35 @@ export function isNodeWithWorkflowSelector(node: INode) {
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.
*
@@ -1585,28 +1614,93 @@ export function makeDescription(
nodeParameters: INodeParameters,
nodeTypeDescription: INodeTypeDescription,
): string {
let description = '';
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),
const { action, operation, resource } = resolveResourceAndOperation(
nodeParameters,
nodeTypeDescription,
);
if (nodeTypeOperation?.options && isINodePropertyOptionsList(nodeTypeOperation.options)) {
const foundOperation = nodeTypeOperation.options.find((option) => option.value === operation);
if (foundOperation?.action) {
description = `${foundOperation.action} in ${nodeTypeDescription.defaults.name}`;
return description;
if (action) {
return `${action} in ${nodeTypeDescription.defaults.name}`;
}
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) {
description = `${operation} ${resource} in ${nodeTypeDescription.defaults.name}`;
} else {
description = nodeTypeDescription.description;
return false;
}
/**
* 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,
getUpdatedToolDescription,
getToolDescriptionForNode,
isDefaultNodeName,
makeNodeName,
isTool,
} from '@/node-helpers';
import type { Workflow } from '@/workflow';
@@ -5246,4 +5249,366 @@ describe('NodeHelpers', () => {
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);
});
});
});