mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -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>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if isHtml}}
|
||||||
|
<div class="form-group">
|
||||||
|
{{{html}}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if isTextarea}}
|
{{#if isTextarea}}
|
||||||
<div class='form-group'>
|
<div class='form-group'>
|
||||||
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
<label class='form-label {{inputRequired}}' for='{{id}}'>{{label}}</label>
|
||||||
|
|
|
@ -2,6 +2,12 @@ import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
import { appendAttributionOption } from '../../utils/descriptions';
|
import { appendAttributionOption } from '../../utils/descriptions';
|
||||||
|
|
||||||
|
export const placeholder: string = `
|
||||||
|
<!-- Your custom HTML here --->
|
||||||
|
|
||||||
|
|
||||||
|
`.trimStart();
|
||||||
|
|
||||||
export const webhookPath: INodeProperties = {
|
export const webhookPath: INodeProperties = {
|
||||||
displayName: 'Form Path',
|
displayName: 'Form Path',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
|
@ -36,9 +42,9 @@ export const formDescription: INodeProperties = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formFields: INodeProperties = {
|
export const formFields: INodeProperties = {
|
||||||
displayName: 'Form Fields',
|
displayName: 'Form Elements',
|
||||||
name: 'formFields',
|
name: 'formFields',
|
||||||
placeholder: 'Add Form Field',
|
placeholder: 'Add Form Element',
|
||||||
type: 'fixedCollection',
|
type: 'fixedCollection',
|
||||||
default: { values: [{ label: '', fieldType: 'text' }] },
|
default: { values: [{ label: '', fieldType: 'text' }] },
|
||||||
typeOptions: {
|
typeOptions: {
|
||||||
|
@ -60,12 +66,16 @@ export const formFields: INodeProperties = {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Field Type',
|
displayName: 'Element Type',
|
||||||
name: 'fieldType',
|
name: 'fieldType',
|
||||||
type: 'options',
|
type: 'options',
|
||||||
default: 'text',
|
default: 'text',
|
||||||
description: 'The type of field to add to the form',
|
description: 'The type of field to add to the form',
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
name: 'Custom HTML',
|
||||||
|
value: 'html',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Date',
|
name: 'Date',
|
||||||
value: 'date',
|
value: 'date',
|
||||||
|
@ -109,7 +119,7 @@ export const formFields: INodeProperties = {
|
||||||
default: '',
|
default: '',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
hide: {
|
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',
|
displayName: 'Multiple Files',
|
||||||
name: 'multipleFiles',
|
name: 'multipleFiles',
|
||||||
|
@ -190,6 +215,23 @@ export const formFields: INodeProperties = {
|
||||||
name: 'formatDate',
|
name: 'formatDate',
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
default: '',
|
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',
|
displayName: 'Required Field',
|
||||||
|
@ -198,6 +240,11 @@ export const formFields: INodeProperties = {
|
||||||
default: false,
|
default: false,
|
||||||
description:
|
description:
|
||||||
'Whether to require the user to enter a value for this field before submitting the form',
|
'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,
|
prepareFormReturnItem,
|
||||||
resolveRawData,
|
resolveRawData,
|
||||||
isFormConnected,
|
isFormConnected,
|
||||||
|
sanitizeHtml,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
describe('FormTrigger, parseFormDescription', () => {
|
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', () => {
|
describe('FormTrigger, formWebhook', () => {
|
||||||
const executeFunctions = mock<IWebhookFunctions>();
|
const executeFunctions = mock<IWebhookFunctions>();
|
||||||
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any);
|
||||||
|
@ -80,6 +104,12 @@ describe('FormTrigger, formWebhook', () => {
|
||||||
acceptFileTypes: '.pdf,.doc',
|
acceptFileTypes: '.pdf,.doc',
|
||||||
multipleFiles: false,
|
multipleFiles: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldLabel: 'Custom HTML',
|
||||||
|
fieldType: 'html',
|
||||||
|
html: '<div>Test HTML</div>',
|
||||||
|
requiredField: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields);
|
||||||
|
@ -134,6 +164,16 @@ describe('FormTrigger, formWebhook', () => {
|
||||||
multipleFiles: '',
|
multipleFiles: '',
|
||||||
placeholder: undefined,
|
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',
|
formSubmittedText: 'Your response has been recorded',
|
||||||
formTitle: 'Test Form',
|
formTitle: 'Test Form',
|
||||||
|
|
|
@ -24,11 +24,16 @@ 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) {
|
export function sanitizeHtml(text: string) {
|
||||||
return sanitize(text, {
|
return sanitize(text, {
|
||||||
allowedTags: [
|
allowedTags: [
|
||||||
'b',
|
'b',
|
||||||
|
'div',
|
||||||
'i',
|
'i',
|
||||||
|
'iframe',
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
'source',
|
||||||
'em',
|
'em',
|
||||||
'strong',
|
'strong',
|
||||||
'a',
|
'a',
|
||||||
|
@ -48,8 +53,18 @@ function sanitizeHtml(text: string) {
|
||||||
],
|
],
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
a: ['href', 'target', 'rel'],
|
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);
|
input.selectOptions = fieldOptions.map((e) => e.option);
|
||||||
} else if (fieldType === 'textarea') {
|
} else if (fieldType === 'textarea') {
|
||||||
input.isTextarea = true;
|
input.isTextarea = true;
|
||||||
|
} else if (fieldType === 'html') {
|
||||||
|
input.isHtml = true;
|
||||||
|
input.html = field.html as string;
|
||||||
} else {
|
} else {
|
||||||
input.isInput = true;
|
input.isInput = true;
|
||||||
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
input.type = fieldType as 'text' | 'number' | 'date' | 'email';
|
||||||
|
@ -409,7 +427,14 @@ export async function formWebhook(
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = context.getMode() === 'manual' ? 'test' : 'production';
|
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;
|
const method = context.getRequestObject().method;
|
||||||
|
|
||||||
checkResponseModeConfiguration(context);
|
checkResponseModeConfiguration(context);
|
||||||
|
|
|
@ -2684,6 +2684,7 @@ export type FormFieldsParameter = Array<{
|
||||||
multipleFiles?: boolean;
|
multipleFiles?: boolean;
|
||||||
acceptFileTypes?: string;
|
acceptFileTypes?: string;
|
||||||
formatDate?: string;
|
formatDate?: string;
|
||||||
|
html?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue