Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 9 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/cli/templates/form-trigger.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'>
Expand Down
2 changes: 1 addition & 1 deletion packages/nodes-base/nodes/Form/common.descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions packages/nodes-base/nodes/Form/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type FormTriggerData = {
validForm: boolean;
formTitle: string;
formDescription?: string;
formDescriptionMetadata?: string;
formSubmittedHeader?: string;
formSubmittedText?: string;
redirectUrl?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('FormTrigger', () => {
appendAttribution: false,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [
{
defaultValue: '',
Expand Down
111 changes: 91 additions & 20 deletions packages/nodes-base/nodes/Form/test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -43,27 +82,16 @@ 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);

expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formDescriptionMetadata: 'Test Description',
formFields: [
{
defaultValue: '',
Expand Down Expand Up @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
Expand Down
39 changes: 38 additions & 1 deletion packages/nodes-base/nodes/Form/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,49 @@ 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';
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,
Expand Down Expand Up @@ -63,6 +99,7 @@ export function prepareFormData({
validForm,
formTitle,
formDescription,
formDescriptionMetadata: createDescriptionMetadata(formDescription),
formSubmittedHeader,
formSubmittedText,
n8nWebsiteLink,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes-base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes-base/utils/sendAndWait/test/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading