feat(n8n Form Node): Respond with Text (#12979)

This commit is contained in:
Dana
2025-02-04 18:35:25 +01:00
committed by GitHub
parent ddc40ef7de
commit 182fc150be
6 changed files with 276 additions and 175 deletions

View File

@@ -26,6 +26,9 @@
</head> </head>
<body> <body>
{{#if responseText}}
{{{responseText}}}
{{else}}
<div class='container'> <div class='container'>
<section> <section>
<div class='card'> <div class='card'>
@@ -69,6 +72,7 @@
{{/if}} {{/if}}
</section> </section>
</div> </div>
{{/if}}
<script> <script>
fetch('', { method: 'POST', body: {}, }).catch(() => {}); fetch('', { method: 'POST', body: {}, }).catch(() => {});
</script> </script>

View File

@@ -5,6 +5,7 @@ import type {
INodeProperties, INodeProperties,
INodeTypeDescription, INodeTypeDescription,
IWebhookFunctions, IWebhookFunctions,
IWebhookResponseData,
NodeTypeAndVersion, NodeTypeAndVersion,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
@@ -15,12 +16,13 @@ import {
FORM_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE,
tryToParseJsonToFormFields, tryToParseJsonToFormFields,
NodeConnectionType, NodeConnectionType,
WAIT_NODE_TYPE,
WAIT_INDEFINITELY, WAIT_INDEFINITELY,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { renderFormCompletion } from './formCompletionUtils';
import { renderFormNode } from './formNodeUtils';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions'; import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils'; import { prepareFormReturnItem, resolveRawData } from '../Form/utils';
export const formFieldsProperties: INodeProperties[] = [ export const formFieldsProperties: INodeProperties[] = [
{ {
@@ -113,6 +115,11 @@ const completionProperties = updateDisplayOptions(
value: 'redirect', value: 'redirect',
description: 'Redirect the user to a URL', description: 'Redirect the user to a URL',
}, },
{
name: 'Show Text',
value: 'showText',
description: 'Display simple text or HTML',
},
], ],
}, },
{ {
@@ -154,6 +161,22 @@ const completionProperties = updateDisplayOptions(
}, },
}, },
}, },
{
displayName: 'Text',
name: 'responseText',
type: 'string',
displayOptions: {
show: {
respondWith: ['showText'],
},
},
typeOptions: {
rows: 2,
},
default: '',
placeholder: 'e.g. Thanks for filling the form',
description: 'The text to display on the page. Use HTML to show a customized web page.',
},
{ {
displayName: 'Options', displayName: 'Options',
name: 'options', name: 'options',
@@ -235,7 +258,7 @@ export class Form extends Node {
], ],
}; };
async webhook(context: IWebhookFunctions) { async webhook(context: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = context.getResponseObject(); const res = context.getResponseObject();
const operation = context.getNodeParameter('operation', '') as string; const operation = context.getNodeParameter('operation', '') as string;
@@ -280,36 +303,7 @@ export class Form extends Node {
const method = context.getRequestObject().method; const method = context.getRequestObject().method;
if (operation === 'completion' && method === 'GET') { if (operation === 'completion' && method === 'GET') {
const completionTitle = context.getNodeParameter('completionTitle', '') as string; return await renderFormCompletion(context, res, trigger);
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
const options = context.getNodeParameter('options', {}) as { formTitle: string };
if (redirectUrl) {
res.send(
`<html><head><meta http-equiv="refresh" content="0; url=${redirectUrl}"></head></html>`,
);
return { noWebhookResponse: true };
}
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`,
) as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
res.render('form-trigger-completion', {
title: completionTitle,
message: completionMessage,
formTitle: title,
appendAttribution,
});
return { noWebhookResponse: true };
} }
if (operation === 'completion' && method === 'POST') { if (operation === 'completion' && method === 'POST') {
@@ -319,68 +313,7 @@ export class Form extends Node {
} }
if (method === 'GET') { if (method === 'GET') {
const options = context.getNodeParameter('options', {}) as { return await renderFormNode(context, res, trigger, fields, mode);
formTitle: string;
formDescription: string;
buttonLabel: string;
};
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formTitle }}`,
) as string;
}
let description = options.formDescription;
if (!description) {
description = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formDescription }}`,
) as string;
}
let buttonLabel = options.buttonLabel;
if (!buttonLabel) {
buttonLabel =
(context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.buttonLabel }}`,
) as string) || 'Submit';
}
const responseMode = 'onReceived';
let redirectUrl;
const connectedNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = connectedNodes.some(
(node) => !node.disabled && (node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE),
);
if (hasNextPage) {
redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
renderForm({
context,
res,
formTitle: title,
formDescription: description,
formFields: fields,
responseMode,
mode,
redirectUrl,
appendAttribution,
buttonLabel,
});
return {
noWebhookResponse: true,
};
} }
let useWorkflowTimezone = context.evaluateExpression( let useWorkflowTimezone = context.evaluateExpression(

View File

@@ -0,0 +1,45 @@
import { type Response } from 'express';
import {
type NodeTypeAndVersion,
type IWebhookFunctions,
type IWebhookResponseData,
} from 'n8n-workflow';
import { sanitizeHtml } from './utils';
export const renderFormCompletion = async (
context: IWebhookFunctions,
res: Response,
trigger: NodeTypeAndVersion,
): Promise<IWebhookResponseData> => {
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
const options = context.getNodeParameter('options', {}) as { formTitle: string };
const responseText = context.getNodeParameter('responseText', '') as string;
if (redirectUrl) {
res.send(
`<html><head><meta http-equiv="refresh" content="0; url=${redirectUrl}"></head></html>`,
);
return { noWebhookResponse: true };
}
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(`{{ $('${trigger?.name}').params.formTitle }}`) as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
res.render('form-trigger-completion', {
title: completionTitle,
message: completionMessage,
formTitle: title,
appendAttribution,
responseText: sanitizeHtml(responseText),
});
return { noWebhookResponse: true };
};

View File

@@ -0,0 +1,80 @@
import { type Response } from 'express';
import {
type NodeTypeAndVersion,
type IWebhookFunctions,
FORM_NODE_TYPE,
WAIT_NODE_TYPE,
type FormFieldsParameter,
type IWebhookResponseData,
} from 'n8n-workflow';
import { renderForm } from './utils';
export const renderFormNode = async (
context: IWebhookFunctions,
res: Response,
trigger: NodeTypeAndVersion,
fields: FormFieldsParameter,
mode: 'test' | 'production',
): Promise<IWebhookResponseData> => {
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
formDescription: string;
buttonLabel: string;
};
let title = options.formTitle;
if (!title) {
title = context.evaluateExpression(`{{ $('${trigger?.name}').params.formTitle }}`) as string;
}
let description = options.formDescription;
if (!description) {
description = context.evaluateExpression(
`{{ $('${trigger?.name}').params.formDescription }}`,
) as string;
}
let buttonLabel = options.buttonLabel;
if (!buttonLabel) {
buttonLabel =
(context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.buttonLabel }}`,
) as string) || 'Submit';
}
const responseMode = 'onReceived';
let redirectUrl;
const connectedNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = connectedNodes.some(
(node) => !node.disabled && (node.type === FORM_NODE_TYPE || node.type === WAIT_NODE_TYPE),
);
if (hasNextPage) {
redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
}
const appendAttribution = context.evaluateExpression(
`{{ $('${trigger?.name}').params.options?.appendAttribution === false ? false : true }}`,
) as boolean;
renderForm({
context,
res,
formTitle: title,
formDescription: description,
formFields: fields,
responseMode,
mode,
redirectUrl,
appendAttribution,
buttonLabel,
});
return {
noWebhookResponse: true,
};
};

View File

@@ -217,6 +217,46 @@ describe('Form Node', () => {
}); });
it('should handle completion operation and render completion page', async () => { it('should handle completion operation and render completion page', async () => {
const formExpected = [
{
formParam: {
responseText: '',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
responseText: '',
},
},
{
formParam: {
responseText: '<div>hey</div><script>alert("hi")</script>',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
responseText: '<div>hey</div>',
},
},
{
formParam: {
responseText: 'my text over here',
},
expected: {
appendAttribution: 'test',
formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
responseText: 'my text over here',
},
},
];
for (const { formParam, expected } of formExpected) {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request); mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => { mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion'; if (paramName === 'operation') return 'completion';
@@ -227,6 +267,7 @@ describe('Form Node', () => {
if (paramName === 'completionMessage') return 'Test Message'; if (paramName === 'completionMessage') return 'Test Message';
if (paramName === 'redirectUrl') return ''; if (paramName === 'redirectUrl') return '';
if (paramName === 'formFields.values') return []; if (paramName === 'formFields.values') return [];
if (paramName === 'responseText') return formParam.responseText;
return {}; return {};
}); });
mockWebhookFunctions.getParentNodes.mockReturnValue([ mockWebhookFunctions.getParentNodes.mockReturnValue([
@@ -253,11 +294,9 @@ describe('Form Node', () => {
expect(result).toEqual({ noWebhookResponse: true }); expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', { expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger-completion', {
appendAttribution: 'test', ...expected,
formTitle: 'test',
message: 'Test Message',
title: 'Test Title',
}); });
}
}); });
it('should handle completion operation and redirect', async () => { it('should handle completion operation and redirect', async () => {

View File

@@ -187,7 +187,7 @@ export function prepareFormData({
return formData; return formData;
} }
const checkResponseModeConfiguration = (context: IWebhookFunctions) => { const validateResponseModeConfiguration = (context: IWebhookFunctions) => {
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string; const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
const connectedNodes = context.getChildNodes(context.getNode().name); const connectedNodes = context.getChildNodes(context.getNode().name);
@@ -456,7 +456,7 @@ export async function formWebhook(
); );
const method = context.getRequestObject().method; const method = context.getRequestObject().method;
checkResponseModeConfiguration(context); validateResponseModeConfiguration(context);
//Show the form on GET request //Show the form on GET request
if (method === 'GET') { if (method === 'GET') {