mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
feat(n8n Form Trigger Node): Form Improvements (#12590)
This commit is contained in:
parent
3434682e41
commit
f167578b32
|
@ -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'>
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: '',
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue