mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-29 21:31:07 -08:00
feat(n8n Form Node): Add read-only/custom HTML form elements (#12760)
This commit is contained in:
parent
1c7a38f6ba
commit
ba8aa39216
|
@ -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>
|
||||
|
|
|
@ -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><style></code> <code><script></code> or <code><input></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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{
|
|||
multipleFiles?: boolean;
|
||||
acceptFileTypes?: string;
|
||||
formatDate?: string;
|
||||
html?: string;
|
||||
placeholder?: string;
|
||||
}>;
|
||||
|
||||
|
|
Loading…
Reference in a new issue