feat(n8n Form Trigger Node): Form Improvements (#12590)

This commit is contained in:
Dana 2025-01-20 16:52:06 +01:00 committed by GitHub
parent 3434682e41
commit f167578b32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 148 additions and 23 deletions

View file

@ -2,6 +2,11 @@
<head> <head>
<meta charset='UTF-8' /> <meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' /> <meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name="description" content="{{formDescriptionMetadata}}" />
<meta property="og:title" content="{{formTitle}}" />
<meta property="og:description" content="{{formDescriptionMetadata}}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://raw.githubusercontent.com/n8n-io/n8n/80be10551eb081cb11bd8cab6c6ff89e44493d2c/assets/og_image.png?raw=true" />
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' /> <link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
<link <link
href='https://fonts.googleapis.com/css?family=Open+Sans' href='https://fonts.googleapis.com/css?family=Open+Sans'
@ -327,7 +332,7 @@
<form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate> <form class='card' action='#' method='POST' name='n8n-form' id='n8n-form' novalidate>
<div class='form-header'> <div class='form-header'>
<h1>{{formTitle}}</h1> <h1>{{formTitle}}</h1>
<p style="white-space: pre-line">{{formDescription}} </p> <p style="white-space: pre-line">{{{formDescription}}} </p>
</div> </div>
<div class='inputs-wrapper'> <div class='inputs-wrapper'>

View file

@ -29,7 +29,7 @@ export const formDescription: INodeProperties = {
default: '', default: '',
placeholder: "e.g. We'll get back to you soon", placeholder: "e.g. We'll get back to you soon",
description: description:
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.', 'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form. Accepts HTML.',
typeOptions: { typeOptions: {
rows: 2, rows: 2,
}, },

View file

@ -22,6 +22,7 @@ export type FormTriggerData = {
validForm: boolean; validForm: boolean;
formTitle: string; formTitle: string;
formDescription?: string; formDescription?: string;
formDescriptionMetadata?: string;
formSubmittedHeader?: string; formSubmittedHeader?: string;
formSubmittedText?: string; formSubmittedText?: string;
redirectUrl?: string; redirectUrl?: string;

View file

@ -50,6 +50,7 @@ describe('FormTrigger', () => {
appendAttribution: false, appendAttribution: false,
buttonLabel: 'Submit', buttonLabel: 'Submit',
formDescription: 'Test Description', formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [ formFields: [
{ {
defaultValue: '', defaultValue: '',

View file

@ -10,19 +10,58 @@ import type {
import { import {
formWebhook, formWebhook,
createDescriptionMetadata,
prepareFormData, prepareFormData,
prepareFormReturnItem, prepareFormReturnItem,
resolveRawData, resolveRawData,
isFormConnected, isFormConnected,
} from '../utils'; } from '../utils';
describe('FormTrigger, parseFormDescription', () => {
it('should remove HTML tags and truncate to 150 characters', () => {
const descriptions = [
{ description: '<p>This is a test description</p>', expected: 'This is a test description' },
{ description: 'Test description', expected: 'Test description' },
{
description:
'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and soothing song.',
expected:
'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and so',
},
{
description:
'<p>Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and soothing song.</p>',
expected:
'Beneath the golden hues of a setting sun, waves crashed against the rugged shore, carrying whispers of ancient tales etched in natures timeless and so',
},
];
descriptions.forEach(({ description, expected }) => {
expect(createDescriptionMetadata(description)).toBe(expected);
});
});
});
describe('FormTrigger, formWebhook', () => { describe('FormTrigger, formWebhook', () => {
const executeFunctions = mock<IWebhookFunctions>();
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
executeFunctions.getNodeParameter
.calledWith('formDescription')
.mockReturnValue('Test Description');
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} });
executeFunctions.getChildNodes.mockReturnValue([]);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should call response render', async () => { it('should call response render', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockRender = jest.fn(); const mockRender = jest.fn();
const formFields: FormFieldsParameter = [ const formFields: FormFieldsParameter = [
@ -43,20 +82,8 @@ describe('FormTrigger, formWebhook', () => {
}, },
]; ];
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form');
executeFunctions.getNodeParameter
.calledWith('formDescription')
.mockReturnValue('Test Description');
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any); executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} });
executeFunctions.getChildNodes.mockReturnValue([]);
await formWebhook(executeFunctions); await formWebhook(executeFunctions);
@ -64,6 +91,7 @@ describe('FormTrigger, formWebhook', () => {
appendAttribution: true, appendAttribution: true,
buttonLabel: 'Submit', buttonLabel: 'Submit',
formDescription: 'Test Description', formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [ formFields: [
{ {
defaultValue: '', defaultValue: '',
@ -117,8 +145,55 @@ describe('FormTrigger, formWebhook', () => {
}); });
}); });
it('should sanitize form descriptions', async () => {
const mockRender = jest.fn();
const formDescription = [
{ description: 'Test Description', expected: 'Test Description' },
{ description: '<i>hello</i>', expected: '<i>hello</i>' },
{ description: '<script>alert("hello world")</script>', expected: '' },
];
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
];
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any);
for (const { description, expected } of formDescription) {
executeFunctions.getNodeParameter.calledWith('formDescription').mockReturnValue(description);
await formWebhook(executeFunctions);
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
buttonLabel: 'Submit',
formDescription: expected,
formDescriptionMetadata: createDescriptionMetadata(expected),
formFields: [
{
defaultValue: '',
errorId: 'error-field-0',
id: 'field-0',
inputRequired: 'form-required',
isInput: true,
label: 'Name',
placeholder: undefined,
type: 'text',
},
],
formSubmittedText: 'Your response has been recorded',
formTitle: 'Test Form',
n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId',
testRun: true,
useResponseData: false,
validForm: true,
});
}
});
it('should return workflowData on POST request', async () => { it('should return workflowData on POST request', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockStatus = jest.fn(); const mockStatus = jest.fn();
const mockEnd = jest.fn(); const mockEnd = jest.fn();
@ -132,15 +207,9 @@ describe('FormTrigger, formWebhook', () => {
'field-1': '30', 'field-1': '30',
}; };
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({});
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
executeFunctions.getChildNodes.mockReturnValue([]);
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any); executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any); executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} }); executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
const result = await formWebhook(executeFunctions); const result = await formWebhook(executeFunctions);
@ -213,6 +282,7 @@ describe('FormTrigger, prepareFormData', () => {
validForm: true, validForm: true,
formTitle: 'Test Form', formTitle: 'Test Form',
formDescription: 'This is a test form', formDescription: 'This is a test form',
formDescriptionMetadata: 'This is a test form',
formSubmittedText: 'Thank you for your submission', formSubmittedText: 'Thank you for your submission',
n8nWebsiteLink: n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance', 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance',
@ -292,6 +362,7 @@ describe('FormTrigger, prepareFormData', () => {
validForm: true, validForm: true,
formTitle: 'Test Form', formTitle: 'Test Form',
formDescription: 'This is a test form', formDescription: 'This is a test form',
formDescriptionMetadata: 'This is a test form',
formSubmittedText: 'Your response has been recorded', formSubmittedText: 'Your response has been recorded',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
formFields: [ formFields: [

View file

@ -16,6 +16,7 @@ import {
WAIT_NODE_TYPE, WAIT_NODE_TYPE,
jsonParse, jsonParse,
} from 'n8n-workflow'; } from 'n8n-workflow';
import sanitize from 'sanitize-html';
import type { FormTriggerData, FormTriggerInput } from './interfaces'; import type { FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces'; import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
@ -23,6 +24,41 @@ import { getResolvables } from '../../utils/utilities';
import { WebhookAuthorizationError } from '../Webhook/error'; import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils'; import { validateWebhookAuthentication } from '../Webhook/utils';
function sanitizeHtml(text: string) {
return sanitize(text, {
allowedTags: [
'b',
'i',
'em',
'strong',
'a',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'u',
'sub',
'sup',
'code',
'pre',
'span',
'br',
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
},
nonBooleanAttributes: ['*'],
});
}
export function createDescriptionMetadata(description: string) {
return description === ''
? 'n8n form'
: description.replace(/^\s*\n+|<\/?[^>]+(>|$)/g, '').slice(0, 150);
}
export function prepareFormData({ export function prepareFormData({
formTitle, formTitle,
formDescription, formDescription,
@ -63,6 +99,7 @@ export function prepareFormData({
validForm, validForm,
formTitle, formTitle,
formDescription, formDescription,
formDescriptionMetadata: createDescriptionMetadata(formDescription),
formSubmittedHeader, formSubmittedHeader,
formSubmittedText, formSubmittedText,
n8nWebsiteLink, n8nWebsiteLink,
@ -380,7 +417,7 @@ export async function formWebhook(
//Show the form on GET request //Show the form on GET request
if (method === 'GET') { if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string; const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = context.getNodeParameter('formDescription', '') as string; const formDescription = sanitizeHtml(context.getNodeParameter('formDescription', '') as string);
const responseMode = context.getNodeParameter('responseMode', '') as string; const responseMode = context.getNodeParameter('responseMode', '') as string;
let formSubmittedText; let formSubmittedText;

View file

@ -841,6 +841,7 @@
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/promise-ftp": "^1.3.4", "@types/promise-ftp": "^1.3.4",
"@types/rfc2047": "^2.0.1", "@types/rfc2047": "^2.0.1",
"@types/sanitize-html": "^2.11.0",
"@types/showdown": "^1.9.4", "@types/showdown": "^1.9.4",
"@types/snowflake-sdk": "^1.6.24", "@types/snowflake-sdk": "^1.6.24",
"@types/ssh2-sftp-client": "^5.1.0", "@types/ssh2-sftp-client": "^5.1.0",
@ -906,6 +907,7 @@
"rhea": "1.0.24", "rhea": "1.0.24",
"rrule": "2.8.1", "rrule": "2.8.1",
"rss-parser": "3.13.0", "rss-parser": "3.13.0",
"sanitize-html": "2.12.1",
"semver": "7.5.4", "semver": "7.5.4",
"showdown": "2.1.0", "showdown": "2.1.0",
"simple-git": "3.17.0", "simple-git": "3.17.0",

View file

@ -240,6 +240,7 @@ describe('Send and Wait utils tests', () => {
validForm: true, validForm: true,
formTitle: '', formTitle: '',
formDescription: 'Test message', formDescription: 'Test message',
formDescriptionMetadata: 'Test message',
formSubmittedHeader: 'Got it, thanks', formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now', formSubmittedText: 'This page can be closed now',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
@ -318,6 +319,7 @@ describe('Send and Wait utils tests', () => {
validForm: true, validForm: true,
formTitle: 'Test title', formTitle: 'Test title',
formDescription: 'Test description', formDescription: 'Test description',
formDescriptionMetadata: 'Test description',
formSubmittedHeader: 'Got it, thanks', formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now', formSubmittedText: 'This page can be closed now',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger', n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',

View file

@ -1851,6 +1851,9 @@ importers:
rss-parser: rss-parser:
specifier: 3.13.0 specifier: 3.13.0
version: 3.13.0 version: 3.13.0
sanitize-html:
specifier: 2.12.1
version: 2.12.1
semver: semver:
specifier: ^7.5.4 specifier: ^7.5.4
version: 7.6.0 version: 7.6.0
@ -1936,6 +1939,9 @@ importers:
'@types/rfc2047': '@types/rfc2047':
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
'@types/sanitize-html':
specifier: ^2.11.0
version: 2.11.0
'@types/showdown': '@types/showdown':
specifier: ^1.9.4 specifier: ^1.9.4
version: 1.9.4 version: 1.9.4