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>
<meta charset='UTF-8' />
<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
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>
<div class='form-header'>
<h1>{{formTitle}}</h1>
<p style="white-space: pre-line">{{formDescription}} </p>
<p style="white-space: pre-line">{{{formDescription}}} </p>
</div>
<div class='inputs-wrapper'>

View file

@ -29,7 +29,7 @@ export const formDescription: INodeProperties = {
default: '',
placeholder: "e.g. We'll get back to you soon",
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: {
rows: 2,
},

View file

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

View file

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

View file

@ -10,19 +10,58 @@ import type {
import {
formWebhook,
createDescriptionMetadata,
prepareFormData,
prepareFormReturnItem,
resolveRawData,
isFormConnected,
} 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', () => {
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(() => {
jest.clearAllMocks();
});
it('should call response render', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockRender = jest.fn();
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.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);
@ -64,6 +91,7 @@ describe('FormTrigger, formWebhook', () => {
appendAttribution: true,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [
{
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 () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockStatus = jest.fn();
const mockEnd = jest.fn();
@ -132,15 +207,9 @@ describe('FormTrigger, formWebhook', () => {
'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.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
const result = await formWebhook(executeFunctions);
@ -213,6 +282,7 @@ describe('FormTrigger, prepareFormData', () => {
validForm: true,
formTitle: 'Test Form',
formDescription: 'This is a test form',
formDescriptionMetadata: 'This is a test form',
formSubmittedText: 'Thank you for your submission',
n8nWebsiteLink:
'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=test-instance',
@ -292,6 +362,7 @@ describe('FormTrigger, prepareFormData', () => {
validForm: true,
formTitle: 'Test Form',
formDescription: 'This is a test form',
formDescriptionMetadata: 'This is a test form',
formSubmittedText: 'Your response has been recorded',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
formFields: [

View file

@ -16,6 +16,7 @@ import {
WAIT_NODE_TYPE,
jsonParse,
} from 'n8n-workflow';
import sanitize from 'sanitize-html';
import type { FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
@ -23,6 +24,41 @@ import { getResolvables } from '../../utils/utilities';
import { WebhookAuthorizationError } from '../Webhook/error';
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({
formTitle,
formDescription,
@ -63,6 +99,7 @@ export function prepareFormData({
validForm,
formTitle,
formDescription,
formDescriptionMetadata: createDescriptionMetadata(formDescription),
formSubmittedHeader,
formSubmittedText,
n8nWebsiteLink,
@ -380,7 +417,7 @@ export async function formWebhook(
//Show the form on GET request
if (method === 'GET') {
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;
let formSubmittedText;

View file

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

View file

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

View file

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