diff --git a/packages/workflow/src/constants.ts b/packages/workflow/src/constants.ts index 8b6693dc1e..4fa21fe920 100644 --- a/packages/workflow/src/constants.ts +++ b/packages/workflow/src/constants.ts @@ -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'; diff --git a/packages/workflow/src/node-parameters/rename-node-utils.ts b/packages/workflow/src/node-parameters/rename-node-utils.ts new file mode 100644 index 0000000000..065407b2f3 --- /dev/null +++ b/packages/workflow/src/node-parameters/rename-node-utils.ts @@ -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); + } + } + } +} diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 68b95239f4..f44131b248 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -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 diff --git a/packages/workflow/test/node-types.ts b/packages/workflow/test/node-types.ts index fc350b5550..159891a281 100644 --- a/packages/workflow/test/node-types.ts +++ b/packages/workflow/test/node-types.ts @@ -573,6 +573,329 @@ const setNode: LoadedClass = { }, }; +const codeNode: LoadedClass = { + 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 = { + 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 = { + 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: '', + description: 'HTML elements to display on the form page', + hint: 'Does not accept <script>, <style> or <input> 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 = { 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, diff --git a/packages/workflow/test/rename-node-utils.test.ts b/packages/workflow/test/rename-node-utils.test.ts new file mode 100644 index 0000000000..d1a620d007 --- /dev/null +++ b/packages/workflow/test/rename-node-utils.test.ts @@ -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>) => + ({ + 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); + }); +}); diff --git a/packages/workflow/test/workflow.test.ts b/packages/workflow/test/workflow.test.ts index 0973220b78..76a5d510f6 100644 --- a/packages/workflow/test/workflow.test.ts +++ b/packages/workflow/test/workflow.test.ts @@ -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],