feat(n8n Form Node): Add read-only/custom HTML form elements (#12760)

This commit is contained in:
Dana 2025-01-22 13:05:30 +01:00 committed by GitHub
parent 1c7a38f6ba
commit ba8aa39216
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 126 additions and 7 deletions

View file

@ -371,6 +371,12 @@
</div>
{{/if}}
{{#if isHtml}}
<div class="form-group">
{{{html}}}
</div>
{{/if}}
{{#if isTextarea}}
<div class='form-group'>
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>

View file

@ -2,6 +2,12 @@ import type { INodeProperties } from 'n8n-workflow';
import { appendAttributionOption } from '../../utils/descriptions';
export const placeholder: string = `
<!-- Your custom HTML here --->
`.trimStart();
export const webhookPath: INodeProperties = {
displayName: 'Form Path',
name: 'path',
@ -36,9 +42,9 @@ export const formDescription: INodeProperties = {
};
export const formFields: INodeProperties = {
displayName: 'Form Fields',
displayName: 'Form Elements',
name: 'formFields',
placeholder: 'Add Form Field',
placeholder: 'Add Form Element',
type: 'fixedCollection',
default: { values: [{ label: '', fieldType: 'text' }] },
typeOptions: {
@ -60,12 +66,16 @@ export const formFields: INodeProperties = {
required: true,
},
{
displayName: 'Field Type',
displayName: 'Element Type',
name: 'fieldType',
type: 'options',
default: 'text',
description: 'The type of field to add to the form',
options: [
{
name: 'Custom HTML',
value: 'html',
},
{
name: 'Date',
value: 'date',
@ -109,7 +119,7 @@ export const formFields: INodeProperties = {
default: '',
displayOptions: {
hide: {
fieldType: ['dropdown', 'date', 'file'],
fieldType: ['dropdown', 'date', 'file', 'html'],
},
},
},
@ -158,6 +168,21 @@ export const formFields: INodeProperties = {
},
},
},
{
displayName: 'HTML Template',
name: 'html',
typeOptions: {
editor: 'htmlEditor',
},
type: 'string',
default: placeholder,
description: 'HTML template to render',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Multiple Files',
name: 'multipleFiles',
@ -190,6 +215,23 @@ export const formFields: INodeProperties = {
name: 'formatDate',
type: 'notice',
default: '',
displayOptions: {
show: {
fieldType: ['date'],
},
},
},
{
displayName:
'Does not accept <code>&lt;style&gt;</code> <code>&lt;script&gt;</code> or <code>&lt;input&gt;</code> tags.',
name: 'htmlTips',
type: 'notice',
default: '',
displayOptions: {
show: {
fieldType: ['html'],
},
},
},
{
displayName: 'Required Field',
@ -198,6 +240,11 @@ export const formFields: INodeProperties = {
default: false,
description:
'Whether to require the user to enter a value for this field before submitting the form',
displayOptions: {
hide: {
fieldType: ['html'],
},
},
},
],
},

View file

@ -15,6 +15,7 @@ import {
prepareFormReturnItem,
resolveRawData,
isFormConnected,
sanitizeHtml,
} from '../utils';
describe('FormTrigger, parseFormDescription', () => {
@ -42,6 +43,29 @@ describe('FormTrigger, parseFormDescription', () => {
});
});
describe('FormTrigger, sanitizeHtml', () => {
it('should remove forbidden HTML tags', () => {
const givenHtml = [
{
html: '<script>alert("hello world")</script>',
expected: '',
},
{
html: '<style>body { color: red; }</style>',
expected: '',
},
{
html: '<input type="text" value="test">',
expected: '',
},
];
givenHtml.forEach(({ html, expected }) => {
expect(sanitizeHtml(html)).toBe(expected);
});
});
});
describe('FormTrigger, formWebhook', () => {
const executeFunctions = mock<IWebhookFunctions>();
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
@ -80,6 +104,12 @@ describe('FormTrigger, formWebhook', () => {
acceptFileTypes: '.pdf,.doc',
multipleFiles: false,
},
{
fieldLabel: 'Custom HTML',
fieldType: 'html',
html: '<div>Test HTML</div>',
requiredField: false,
},
];
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
@ -134,6 +164,16 @@ describe('FormTrigger, formWebhook', () => {
multipleFiles: '',
placeholder: undefined,
},
{
id: 'field-4',
errorId: 'error-field-4',
label: 'Custom HTML',
inputRequired: '',
defaultValue: '',
placeholder: undefined,
html: '<div>Test HTML</div>',
isHtml: true,
},
],
formSubmittedText: 'Your response has been recorded',
formTitle: 'Test Form',

View file

@ -24,11 +24,16 @@ import { getResolvables } from '../../utils/utilities';
import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils';
function sanitizeHtml(text: string) {
export function sanitizeHtml(text: string) {
return sanitize(text, {
allowedTags: [
'b',
'div',
'i',
'iframe',
'img',
'video',
'source',
'em',
'strong',
'a',
@ -48,8 +53,18 @@ function sanitizeHtml(text: string) {
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt', 'width', 'height'],
video: ['*'],
iframe: ['*'],
source: ['*'],
},
transformTags: {
iframe: sanitize.simpleTransform('iframe', {
sandbox: '',
referrerpolicy: 'strict-origin-when-cross-origin',
allow: 'fullscreen; autoplay; encrypted-media',
}),
},
nonBooleanAttributes: ['*'],
});
}
@ -149,6 +164,9 @@ export function prepareFormData({
input.selectOptions = fieldOptions.map((e) => e.option);
} else if (fieldType === 'textarea') {
input.isTextarea = true;
} else if (fieldType === 'html') {
input.isHtml = true;
input.html = field.html as string;
} else {
input.isInput = true;
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
@ -409,7 +427,14 @@ export async function formWebhook(
}
const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
const formFields = (context.getNodeParameter('formFields.values', []) as FormFieldsParameter).map(
(field) => {
if (field.fieldType === 'html') {
field.html = sanitizeHtml(field.html as string);
}
return field;
},
);
const method = context.getRequestObject().method;
checkResponseModeConfiguration(context);

View file

@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{
multipleFiles?: boolean;
acceptFileTypes?: string;
formatDate?: string;
html?: string;
placeholder?: string;
}>;