fix(editor): Support renaming node in HTML parameters (#16315)

This commit is contained in:
Charlie Kolb
2025-06-13 15:44:21 +02:00
committed by GitHub
parent aa273745ec
commit 88e3c90e71
6 changed files with 539 additions and 1 deletions

View File

@@ -42,6 +42,8 @@ export const FORM_NODE_TYPE = 'n8n-nodes-base.form';
export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
export const HTML_NODE_TYPE = 'n8n-nodes-base.html';
export const MAILGUN_NODE_TYPE = 'n8n-nodes-base.mailgun';
export const STARTING_NODE_TYPES = [
MANUAL_TRIGGER_NODE_TYPE,
@@ -70,6 +72,11 @@ export const NODES_WITH_RENAMABLE_CONTENT = new Set([
FUNCTION_ITEM_NODE_TYPE,
AI_TRANSFORM_NODE_TYPE,
]);
export const NODES_WITH_RENAMABLE_FORM_HTML_CONTENT = new Set([FORM_NODE_TYPE]);
export const NODES_WITH_RENAMEABLE_TOPLEVEL_HTML_CONTENT = new Set([
MAILGUN_NODE_TYPE,
HTML_NODE_TYPE,
]);
//@n8n/n8n-nodes-langchain
export const MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualChatTrigger';

View File

@@ -0,0 +1,29 @@
import type { INode, NodeParameterValueType } from '@/interfaces';
export function renameFormFields(
node: INode,
renameField: (v: NodeParameterValueType) => NodeParameterValueType,
): void {
const formFields = node.parameters?.formFields;
const values =
formFields &&
typeof formFields === 'object' &&
'values' in formFields &&
typeof formFields.values === 'object' &&
// TypeScript thinks this is `Array.values` and gets very confused here
// eslint-disable-next-line @typescript-eslint/unbound-method
Array.isArray(formFields.values)
? // eslint-disable-next-line @typescript-eslint/unbound-method
(formFields.values ?? [])
: [];
for (const formFieldValue of values) {
if (!formFieldValue || typeof formFieldValue !== 'object') continue;
if ('fieldType' in formFieldValue && formFieldValue.fieldType === 'html') {
if ('html' in formFieldValue) {
formFieldValue.html = renameField(formFieldValue.html);
}
}
}
}

View File

@@ -5,6 +5,8 @@
import {
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
NODES_WITH_RENAMABLE_CONTENT,
NODES_WITH_RENAMABLE_FORM_HTML_CONTENT,
NODES_WITH_RENAMEABLE_TOPLEVEL_HTML_CONTENT,
STARTING_NODE_TYPES,
} from './constants';
import { UserError } from './errors';
@@ -32,6 +34,7 @@ import type {
} from './interfaces';
import { NodeConnectionTypes } from './interfaces';
import * as NodeHelpers from './node-helpers';
import { renameFormFields } from './node-parameters/rename-node-utils';
import { applyAccessPatterns } from './node-reference-parser-utils';
import * as ObservableObject from './observable-object';
@@ -427,6 +430,21 @@ export class Workflow {
{ hasRenamableContent: true },
);
}
if (NODES_WITH_RENAMEABLE_TOPLEVEL_HTML_CONTENT.has(node.type)) {
node.parameters.html = this.renameNodeInParameterValue(
node.parameters.html,
currentName,
newName,
{ hasRenamableContent: true },
);
}
if (NODES_WITH_RENAMABLE_FORM_HTML_CONTENT.has(node.type)) {
renameFormFields(node, (p) =>
this.renameNodeInParameterValue(p, currentName, newName, {
hasRenamableContent: true,
}),
);
}
}
// Change all source connections

View File

@@ -573,6 +573,329 @@ const setNode: LoadedClass<INodeType> = {
},
};
const codeNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'Code',
name: 'code',
group: ['input'],
version: 1,
description: 'Code node',
defaults: {
name: 'Code',
color: '#0000FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'Code',
name: 'jsCode',
type: 'string',
default: '// placeholder',
},
],
},
},
};
const htmlNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'HTML',
name: 'html',
group: ['input'],
version: 1,
description: 'HTML node',
defaults: {
name: 'HTML',
color: '#0000FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'HTML',
name: 'html',
type: 'string',
default: '// placeholder',
},
],
},
},
};
const formNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: {
displayName: 'Form',
name: 'form',
group: ['input'],
version: 1,
description: 'Form node',
defaults: {
name: 'Form',
color: '#0000FF',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName: 'Form Elements',
name: 'formFields',
placeholder: 'Add Form Element',
type: 'fixedCollection',
default: {},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Field Name',
name: 'fieldLabel',
type: 'string',
default: '',
placeholder: 'e.g. What is your name?',
description: 'Label that appears above the input field',
required: true,
displayOptions: {
hide: {
fieldType: ['hiddenField', 'html'],
},
},
},
{
displayName: 'Element Type',
name: 'fieldType',
type: 'options',
default: 'text',
description: 'The type of field to add to the form',
options: [
{
name: 'Custom HTML',
value: 'html',
},
{
name: 'Date',
value: 'date',
},
{
name: 'Dropdown List',
value: 'dropdown',
},
{
name: 'Email',
value: 'email',
},
{
name: 'File',
value: 'file',
},
{
name: 'Hidden Field',
value: 'hiddenField',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Password',
value: 'password',
},
{
name: 'Text',
value: 'text',
},
{
name: 'Textarea',
value: 'textarea',
},
],
required: true,
},
{
displayName: 'Element Name',
name: 'elementName',
type: 'string',
default: '',
placeholder: 'e.g. content-section',
description: 'Optional field. It can be used to include the html in the output.',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Placeholder',
name: 'placeholder',
description: 'Sample text to display inside the field',
type: 'string',
default: '',
displayOptions: {
hide: {
fieldType: ['dropdown', 'date', 'file', 'html', 'hiddenField'],
},
},
},
{
displayName: 'Field Name',
name: 'fieldName',
description:
'The name of the field, used in input attributes and referenced by the workflow',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['hiddenField'],
},
},
},
{
displayName: 'Field Value',
name: 'fieldValue',
description:
'Input value can be set here or will be passed as a query parameter via Field Name if no value is set',
type: 'string',
default: '',
displayOptions: {
show: {
fieldType: ['hiddenField'],
},
},
},
{
displayName: 'Field Options',
name: 'fieldOptions',
placeholder: 'Add Field Option',
description: 'List of options that can be selected from the dropdown',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Option',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Multiple Choice',
name: 'multiselect',
type: 'boolean',
default: false,
description:
'Whether to allow the user to select multiple options from the dropdown list',
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
},
{
displayName: 'HTML',
name: 'html',
typeOptions: {
editor: 'htmlEditor',
},
type: 'string',
noDataExpression: true,
default: '<!-- Your custom HTML here --->',
description: 'HTML elements to display on the form page',
hint: 'Does not accept <code>&lt;script&gt;</code>, <code>&lt;style&gt;</code> or <code>&lt;input&gt;</code> tags',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Multiple Files',
name: 'multipleFiles',
type: 'boolean',
default: true,
description:
'Whether to allow the user to select multiple files from the file input or just one',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName: 'Accepted File Types',
name: 'acceptFileTypes',
type: 'string',
default: '',
description: 'Comma-separated list of allowed file extensions',
hint: 'Leave empty to allow all file types',
placeholder: 'e.g. .jpg, .png',
displayOptions: {
show: {
fieldType: ['file'],
},
},
},
{
displayName:
"The displayed date is formatted based on the locale of the user's browser",
name: 'formatDate',
type: 'notice',
default: '',
displayOptions: {
show: {
fieldType: ['date'],
},
},
},
{
displayName: 'Required Field',
name: 'requiredField',
type: 'boolean',
default: false,
description:
'Whether to require the user to enter a value for this field before submitting the form',
displayOptions: {
hide: {
fieldType: ['html', 'hiddenField'],
},
},
},
],
},
],
},
],
},
},
};
const manualTriggerNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
@@ -1005,6 +1328,9 @@ export class NodeTypes implements INodeTypes {
nodeTypes: INodeTypeData = {
'n8n-nodes-base.stickyNote': stickyNode,
'n8n-nodes-base.set': setNode,
'n8n-nodes-base.code': codeNode,
'n8n-nodes-base.html': htmlNode,
'n8n-nodes-base.form': formNode,
'test.googleSheets': googleSheetsNode,
'test.set': setNode,
'n8n-nodes-base.executeWorkflow': executeWorkflowNode,

View File

@@ -0,0 +1,54 @@
import { mockFn } from 'jest-mock-extended';
import type { INode } from '@/index';
import { renameFormFields } from '@/node-parameters/rename-node-utils';
const makeNode = (formFieldValues: Array<Record<string, unknown>>) =>
({
parameters: {
formFields: {
values: formFieldValues,
},
},
}) as unknown as INode;
const mockMapping = mockFn();
describe('renameFormFields', () => {
beforeEach(() => {
mockMapping.mockReset();
});
it.each([
{ parameters: {} },
{ parameters: { otherField: null } },
{ parameters: { formFields: 'a' } },
{ parameters: { formFields: { values: 3 } } },
{ parameters: { formFields: { values: { newKey: true } } } },
{ parameters: { formFields: { values: [] } } },
{ parameters: { formFields: { values: [{ fieldType: 'json' }] } } },
{ parameters: { formFields: { values: [{ fieldType: 'html' }] } } },
] as unknown as INode[])('should not modify %s without formFields.values parameters', (node) => {
renameFormFields(node, mockMapping);
expect(mockMapping).not.toBeCalled();
});
it('should rename fields based on the provided mapping', () => {
const node = makeNode([{ fieldType: 'html', html: 'some text' }]);
renameFormFields(node, mockMapping);
expect(mockMapping).toBeCalledWith('some text');
});
it('should rename multiple fields', () => {
const node = makeNode([
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
{ fieldType: 'html', html: 'some text' },
]);
renameFormFields(node, mockMapping);
expect(mockMapping).toBeCalledTimes(5);
});
});

View File

@@ -22,6 +22,7 @@ import * as Helpers from './helpers';
interface StubNode {
name: string;
parameters: INodeParameters;
type?: string;
}
describe('Workflow', () => {
@@ -988,6 +989,109 @@ describe('Workflow', () => {
},
},
},
{
description: 'rename node with jsCode parameter',
input: {
currentName: 'Node1',
newName: 'Node1New',
nodes: [
{
name: 'Node1',
type: 'n8n-nodes-base.code',
parameters: {
jsCode: '$("Node1").params',
},
},
],
connections: {},
},
output: {
nodes: [
{
name: 'Node1New',
type: 'n8n-nodes-base.code',
parameters: {
jsCode: '$("Node1New").params',
},
},
],
connections: {},
},
},
{
description: 'rename node with html parameter',
input: {
currentName: 'Node1',
newName: 'Node1New',
nodes: [
{
name: 'Node1',
type: 'n8n-nodes-base.html',
parameters: {
html: '$("Node1").params',
},
},
],
connections: {},
},
output: {
nodes: [
{
name: 'Node1New',
type: 'n8n-nodes-base.html',
parameters: {
html: '$("Node1New").params',
},
},
],
connections: {},
},
},
{
description: 'rename form node with html parameter',
input: {
currentName: 'Node1',
newName: 'Node1New',
nodes: [
{
name: 'Node1',
type: 'n8n-nodes-base.form',
parameters: {
formFields: {
values: [
{
fieldType: 'html',
html: '$("Node1").params',
elementName: '$("Node1").params',
},
],
},
},
},
],
connections: {},
},
output: {
nodes: [
{
name: 'Node1New',
type: 'n8n-nodes-base.form',
parameters: {
formFields: {
values: [
{
fieldType: 'html',
html: '$("Node1New").params',
elementName: '$("Node1").params',
},
],
},
},
},
],
connections: {},
},
},
// This does just a basic test if "renameNodeInParameterValue" gets used. More complex
// tests with different formats and levels are in the separate tests for the function
// "renameNodeInParameterValue"
@@ -1042,7 +1146,7 @@ describe('Workflow', () => {
return {
name: stubData.name,
parameters: stubData.parameters,
type: 'test.set',
type: stubData.type ?? 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [100, 100],