mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-30 22:02:03 -08:00
feat(n8n Form Trigger Node): Form Improvements (#12590)
This commit is contained in:
parent
3434682e41
commit
f167578b32
|
@ -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'>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -22,6 +22,7 @@ export type FormTriggerData = {
|
|||
validForm: boolean;
|
||||
formTitle: string;
|
||||
formDescription?: string;
|
||||
formDescriptionMetadata?: string;
|
||||
formSubmittedHeader?: string;
|
||||
formSubmittedText?: string;
|
||||
redirectUrl?: string;
|
||||
|
|
|
@ -50,6 +50,7 @@ describe('FormTrigger', () => {
|
|||
appendAttribution: false,
|
||||
buttonLabel: 'Submit',
|
||||
formDescription: 'Test Description',
|
||||
formDescriptionMetadata: 'Test Description',
|
||||
formFields: [
|
||||
{
|
||||
defaultValue: '',
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue