feat(n8n Form Page Node): New node (#10390)

Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Shireen Missi <94372015+ShireenMissi@users.noreply.github.com>
This commit is contained in:
Michael Kret 2024-10-17 16:59:53 +03:00 committed by GitHub
parent 86a94b5523
commit 643d66c0ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2101 additions and 229 deletions

View file

@ -16,12 +16,14 @@ describe('n8n Form Trigger', () => {
ndv.getters.parameterInput('formDescription').type('Test Form Description');
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
it('should fill up form fields', () => {
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger');
workflowPage.getters.canvasNodes().first().dblclick();
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger', {
isTrigger: true,
action: 'On new n8n Form event',
});
ndv.getters.parameterInput('formTitle').type('Test Form');
ndv.getters.parameterInput('formDescription').type('Test Form Description');
//fill up first field of type number
@ -96,6 +98,6 @@ describe('n8n Form Trigger', () => {
.type('Your test form was successfully submitted');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
});

View file

@ -15,9 +15,9 @@ import { ExternalHooks } from '@/external-hooks';
import { Logger } from '@/logging/logger.service';
import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
import { send, sendErrorResponse } from '@/response-helper';
import { WaitingForms } from '@/waiting-forms';
import { LiveWebhooks } from '@/webhooks/live-webhooks';
import { TestWebhooks } from '@/webhooks/test-webhooks';
import { WaitingForms } from '@/webhooks/waiting-forms';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler';

View file

@ -1,19 +0,0 @@
import { Service } from 'typedi';
import type { IExecutionResponse } from '@/interfaces';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
@Service()
export class WaitingForms extends WaitingWebhooks {
protected override includeForms = true;
protected override logReceivedWebhook(method: string, executionId: string) {
this.logger.debug(`Received waiting-form "${method}" for execution "${executionId}"`);
}
protected disableNode(execution: IExecutionResponse, method?: string) {
if (method === 'POST') {
execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
}
}
}

View file

@ -0,0 +1,139 @@
import axios from 'axios';
import type express from 'express';
import { FORM_NODE_TYPE, sleep, Workflow } from 'n8n-workflow';
import { Service } from 'typedi';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { IExecutionResponse } from '@/interfaces';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types';
@Service()
export class WaitingForms extends WaitingWebhooks {
protected override includeForms = true;
protected override logReceivedWebhook(method: string, executionId: string) {
this.logger.debug(`Received waiting-form "${method}" for execution "${executionId}"`);
}
protected disableNode(execution: IExecutionResponse, method?: string) {
if (method === 'POST') {
execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
}
}
private getWorkflow(execution: IExecutionResponse) {
const { workflowData } = execution;
return new Workflow({
id: workflowData.id,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: workflowData.active,
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
): Promise<IWebhookResponseCallbackData> {
const { path: executionId, suffix } = req.params;
this.logReceivedWebhook(req.method, executionId);
// Reset request parameters
req.params = {} as WaitingWebhookRequest['params'];
const execution = await this.getExecution(executionId);
if (!execution) {
throw new NotFoundError(`The execution "${executionId}" does not exist.`);
}
if (execution.data.resultData.error) {
throw new ConflictError(`The execution "${executionId}" has finished with error.`);
}
if (execution.status === 'running') {
if (this.includeForms && req.method === 'GET') {
await sleep(1000);
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const page = await axios({ url });
if (page) {
res.send(`
<script>
setTimeout(function() {
window.location.reload();
}, 1);
</script>
`);
}
return {
noWebhookResponse: true,
};
}
throw new ConflictError(`The execution "${executionId}" is running already.`);
}
let completionPage;
if (execution.finished) {
const workflow = this.getWorkflow(execution);
const parentNodes = workflow.getParentNodes(
execution.data.resultData.lastNodeExecuted as string,
);
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
const lastNode = workflow.nodes[lastNodeExecuted];
if (
!lastNode.disabled &&
lastNode.type === FORM_NODE_TYPE &&
lastNode.parameters.operation === 'completion'
) {
completionPage = lastNodeExecuted;
} else {
completionPage = Object.keys(workflow.nodes).find((nodeName) => {
const node = workflow.nodes[nodeName];
return (
parentNodes.includes(nodeName) &&
!node.disabled &&
node.type === FORM_NODE_TYPE &&
node.parameters.operation === 'completion'
);
});
}
if (!completionPage) {
res.render('form-trigger-completion', {
title: 'Form Submitted',
message: 'Your response has been recorded',
formTitle: 'Form Submitted',
});
return {
noWebhookResponse: true,
};
}
}
const targetNode = completionPage || (execution.data.resultData.lastNodeExecuted as string);
return await this.getWebhookExecutionData({
execution,
req,
res,
lastNodeExecuted: targetNode,
executionId,
suffix,
});
}
}

View file

@ -1,9 +1,11 @@
import type express from 'express';
import {
FORM_NODE_TYPE,
type INodes,
type IWorkflowBase,
NodeHelpers,
SEND_AND_WAIT_OPERATION,
WAIT_NODE_TYPE,
Workflow,
} from 'n8n-workflow';
import { Service } from 'typedi';
@ -34,7 +36,7 @@ export class WaitingWebhooks implements IWebhookManager {
constructor(
protected readonly logger: Logger,
private readonly nodeTypes: NodeTypes,
protected readonly nodeTypes: NodeTypes,
private readonly executionRepository: ExecutionRepository,
) {}
@ -58,7 +60,7 @@ export class WaitingWebhooks implements IWebhookManager {
);
}
private getWorkflow(workflowData: IWorkflowBase) {
private createWorkflow(workflowData: IWorkflowBase) {
return new Workflow({
id: workflowData.id,
name: workflowData.name,
@ -71,6 +73,13 @@ export class WaitingWebhooks implements IWebhookManager {
});
}
protected async getExecution(executionId: string) {
return await this.executionRepository.findSingleExecution(executionId, {
includeData: true,
unflattenData: true,
});
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
@ -82,17 +91,14 @@ export class WaitingWebhooks implements IWebhookManager {
// Reset request parameters
req.params = {} as WaitingWebhookRequest['params'];
const execution = await this.executionRepository.findSingleExecution(executionId, {
includeData: true,
unflattenData: true,
});
const execution = await this.getExecution(executionId);
if (!execution) {
throw new NotFoundError(`The execution "${executionId} does not exist.`);
throw new NotFoundError(`The execution "${executionId}" does not exist.`);
}
if (execution.status === 'running') {
throw new ConflictError(`The execution "${executionId} is running already.`);
throw new ConflictError(`The execution "${executionId}" is running already.`);
}
if (execution.data?.resultData?.error) {
@ -101,7 +107,7 @@ export class WaitingWebhooks implements IWebhookManager {
if (execution.finished) {
const { workflowData } = execution;
const { nodes } = this.getWorkflow(workflowData);
const { nodes } = this.createWorkflow(workflowData);
if (this.isSendAndWaitRequest(nodes, suffix)) {
res.render('send-and-wait-no-action-required', { isTestWebhook: false });
return { noWebhookResponse: true };
@ -112,6 +118,31 @@ export class WaitingWebhooks implements IWebhookManager {
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
return await this.getWebhookExecutionData({
execution,
req,
res,
lastNodeExecuted,
executionId,
suffix,
});
}
protected async getWebhookExecutionData({
execution,
req,
res,
lastNodeExecuted,
executionId,
suffix,
}: {
execution: IExecutionResponse;
req: WaitingWebhookRequest;
res: express.Response;
lastNodeExecuted: string;
executionId: string;
suffix?: string;
}): Promise<IWebhookResponseCallbackData> {
// Set the node as disabled so that the data does not get executed again as it would result
// in starting the wait all over again
this.disableNode(execution, req.method);
@ -123,7 +154,7 @@ export class WaitingWebhooks implements IWebhookManager {
execution.data.resultData.runData[lastNodeExecuted].pop();
const { workflowData } = execution;
const workflow = this.getWorkflow(workflowData);
const workflow = this.createWorkflow(workflowData);
const workflowStartNode = workflow.getNode(lastNodeExecuted);
if (workflowStartNode === null) {
@ -146,11 +177,26 @@ export class WaitingWebhooks implements IWebhookManager {
if (webhookData === undefined) {
// If no data got found it means that the execution can not be started via a webhook.
// Return 404 because we do not want to give any data if the execution exists or not.
const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`;
if (this.isSendAndWaitRequest(workflow.nodes, suffix)) {
res.render('send-and-wait-no-action-required', { isTestWebhook: false });
return { noWebhookResponse: true };
} else if (!execution.data.resultData.error && execution.status === 'waiting') {
const childNodes = workflow.getChildNodes(
execution.data.resultData.lastNodeExecuted as string,
);
const hasChildForms = childNodes.some(
(node) =>
workflow.nodes[node].type === FORM_NODE_TYPE ||
workflow.nodes[node].type === WAIT_NODE_TYPE,
);
if (hasChildForms) {
return { noWebhookResponse: true };
} else {
throw new NotFoundError(errorMessage);
}
} else {
const errorMessage = `The workflow for execution "${executionId}" does not contain a waiting webhook with a matching path/method.`;
throw new NotFoundError(errorMessage);
}
}

View file

@ -34,7 +34,10 @@ import {
BINARY_ENCODING,
createDeferredPromise,
ErrorReporterProxy as ErrorReporter,
ErrorReporterProxy,
FORM_NODE_TYPE,
NodeHelpers,
NodeOperationError,
} from 'n8n-workflow';
import { finished } from 'stream/promises';
import { Container } from 'typedi';
@ -120,7 +123,7 @@ export async function executeWebhook(
);
if (nodeType === undefined) {
const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known`;
responseCallback(new Error(errorMessage), {});
responseCallback(new ApplicationError(errorMessage), {});
throw new InternalServerError(errorMessage);
}
@ -143,14 +146,37 @@ export async function executeWebhook(
}
// Get the responseMode
const responseMode = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseMode,
executionMode,
additionalKeys,
undefined,
'onReceived',
) as WebhookResponseMode;
let responseMode;
// if this is n8n FormTrigger node, check if there is a Form node in child nodes,
// if so, set 'responseMode' to 'formPage' to redirect to URL of that Form later
if (nodeType.description.name === 'formTrigger') {
const connectedNodes = workflow.getChildNodes(workflowStartNode.name);
let hasNextPage = false;
for (const nodeName of connectedNodes) {
const node = workflow.nodes[nodeName];
if (node.type === FORM_NODE_TYPE && !node.disabled) {
hasNextPage = true;
break;
}
}
if (hasNextPage) {
responseMode = 'formPage';
}
}
if (!responseMode) {
responseMode = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseMode,
executionMode,
additionalKeys,
undefined,
'onReceived',
) as WebhookResponseMode;
}
const responseCode = workflow.expression.getSimpleParameterValue(
workflowStartNode,
webhookData.webhookDescription.responseCode as string,
@ -169,12 +195,12 @@ export async function executeWebhook(
'firstEntryJson',
);
if (!['onReceived', 'lastNode', 'responseNode'].includes(responseMode)) {
if (!['onReceived', 'lastNode', 'responseNode', 'formPage'].includes(responseMode)) {
// If the mode is not known we error. Is probably best like that instead of using
// the default that people know as early as possible (probably already testing phase)
// that something does not resolve properly.
const errorMessage = `The response mode '${responseMode}' is not valid!`;
responseCallback(new Error(errorMessage), {});
responseCallback(new ApplicationError(errorMessage), {});
throw new InternalServerError(errorMessage);
}
@ -242,8 +268,26 @@ export async function executeWebhook(
});
} catch (err) {
// Send error response to webhook caller
const errorMessage = 'Workflow Webhook Error: Workflow could not be started!';
responseCallback(new Error(errorMessage), {});
const webhookType = ['formTrigger', 'form'].includes(nodeType.description.name)
? 'Form'
: 'Webhook';
let errorMessage = `Workflow ${webhookType} Error: Workflow could not be started!`;
// if workflow started manually, show an actual error message
if (err instanceof NodeOperationError && err.type === 'manual-form-test') {
errorMessage = err.message;
}
ErrorReporterProxy.error(err, {
extra: {
nodeName: workflowStartNode.name,
nodeType: workflowStartNode.type,
nodeVersion: workflowStartNode.typeVersion,
workflowId: workflow.id,
},
});
responseCallback(new ApplicationError(errorMessage), {});
didSendResponse = true;
// Add error to execution data that it can be logged and send to Editor-UI
@ -487,6 +531,12 @@ export async function executeWebhook(
responsePromise,
);
if (responseMode === 'formPage' && !didSendResponse) {
res.redirect(`${additionalData.formWaitingBaseUrl}/${executionId}`);
process.nextTick(() => res.end());
didSendResponse = true;
}
Container.get(Logger).debug(
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
{ executionId },
@ -562,7 +612,7 @@ export async function executeWebhook(
// Return the JSON data of the first entry
if (returnData.data!.main[0]![0] === undefined) {
responseCallback(new Error('No item to return got found'), {});
responseCallback(new ApplicationError('No item to return got found'), {});
didSendResponse = true;
return undefined;
}
@ -616,13 +666,13 @@ export async function executeWebhook(
data = returnData.data!.main[0]![0];
if (data === undefined) {
responseCallback(new Error('No item was found to return'), {});
responseCallback(new ApplicationError('No item was found to return'), {});
didSendResponse = true;
return undefined;
}
if (data.binary === undefined) {
responseCallback(new Error('No binary data was found to return'), {});
responseCallback(new ApplicationError('No binary data was found to return'), {});
didSendResponse = true;
return undefined;
}
@ -637,7 +687,10 @@ export async function executeWebhook(
);
if (responseBinaryPropertyName === undefined && !didSendResponse) {
responseCallback(new Error("No 'responseBinaryPropertyName' is set"), {});
responseCallback(
new ApplicationError("No 'responseBinaryPropertyName' is set"),
{},
);
didSendResponse = true;
}
@ -646,7 +699,7 @@ export async function executeWebhook(
];
if (binaryData === undefined && !didSendResponse) {
responseCallback(
new Error(
new ApplicationError(
`The binary property '${responseBinaryPropertyName}' which should be returned does not exist`,
),
{},

View file

@ -0,0 +1,74 @@
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
<link
href='http://fonts.googleapis.com/css?family=Open+Sans'
rel='stylesheet'
type='text/css'
/>
<title>{{formTitle}}</title>
<style>
*, ::after, ::before { box-sizing: border-box; margin: 0; padding: 0; } body { font-family:
Open Sans, sans-serif; font-weight: 400; font-size: 12px; display: flex; flex-direction:
column; justify-content: start; background-color: #FBFCFE; } .container { margin: auto;
text-align: center; padding-top: 24px; width: 448px; } .card { padding: 24px;
background-color: white; border: 1px solid #DBDFE7; border-radius: 8px; box-shadow: 0px 4px
16px 0px #634DFF0F; margin-bottom: 16px; } .n8n-link a { color: #7E8186; font-weight: 600;
font-size: 12px; text-decoration: none; } .n8n-link svg { display: inline-block;
vertical-align: middle; } .header h1 { color: #525356; font-size: 20px; font-weight: 400;
padding-bottom: 8px; } .header p { color: #7E8186; font-size: 14px; font-weight: 400; }
</style>
</head>
<body>
<div class='container'>
<section>
<div class='card'>
<div class='header'>
<h1>{{title}}</h1>
<p>{{message}}</p>
</div>
</div>
{{#if appendAttribution}}
<div class='n8n-link'>
<a href={{n8nWebsiteLink}} target='_blank'>
Form automated with
<svg
width='73'
height='20'
viewBox='0 0 73 20'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
fill-rule='evenodd'
clip-rule='evenodd'
d='M40.2373 4C40.2373 6.20915 38.4464 8 36.2373 8C34.3735 8 32.8074 6.72525 32.3633 5H26.7787C25.801 5 24.9666 5.70685 24.8059 6.6712L24.6415 7.6576C24.4854 8.59415 24.0116 9.40925 23.3417 10C24.0116 10.5907 24.4854 11.4058 24.6415 12.3424L24.8059 13.3288C24.9666 14.2931 25.801 15 26.7787 15H28.3633C28.8074 13.2747 30.3735 12 32.2373 12C34.4464 12 36.2373 13.7908 36.2373 16C36.2373 18.2092 34.4464 20 32.2373 20C30.3735 20 28.8074 18.7253 28.3633 17H26.7787C24.8233 17 23.1546 15.5864 22.8331 13.6576L22.6687 12.6712C22.508 11.7069 21.6736 11 20.6959 11H19.0645C18.5652 12.64 17.0406 13.8334 15.2373 13.8334C13.434 13.8334 11.9094 12.64 11.4101 11H9.06449C8.56519 12.64 7.04059 13.8334 5.2373 13.8334C3.02817 13.8334 1.2373 12.0424 1.2373 9.83335C1.2373 7.6242 3.02817 5.83335 5.2373 5.83335C7.16069 5.83335 8.76699 7.19085 9.15039 9H11.3242C11.7076 7.19085 13.3139 5.83335 15.2373 5.83335C17.1607 5.83335 18.767 7.19085 19.1504 9H20.6959C21.6736 9 22.508 8.29315 22.6687 7.3288L22.8331 6.3424C23.1546 4.41365 24.8233 3 26.7787 3H32.3633C32.8074 1.27478 34.3735 0 36.2373 0C38.4464 0 40.2373 1.79086 40.2373 4ZM38.2373 4C38.2373 5.10455 37.3419 6 36.2373 6C35.1327 6 34.2373 5.10455 34.2373 4C34.2373 2.89543 35.1327 2 36.2373 2C37.3419 2 38.2373 2.89543 38.2373 4ZM5.2373 11.8334C6.34189 11.8334 7.23729 10.9379 7.23729 9.83335C7.23729 8.72875 6.34189 7.83335 5.2373 7.83335C4.13273 7.83335 3.2373 8.72875 3.2373 9.83335C3.2373 10.9379 4.13273 11.8334 5.2373 11.8334ZM15.2373 11.8334C16.3419 11.8334 17.2373 10.9379 17.2373 9.83335C17.2373 8.72875 16.3419 7.83335 15.2373 7.83335C14.1327 7.83335 13.2373 8.72875 13.2373 9.83335C13.2373 10.9379 14.1327 11.8334 15.2373 11.8334ZM32.2373 18C33.3419 18 34.2373 17.1045 34.2373 16C34.2373 14.8954 33.3419 14 32.2373 14C31.1327 14 30.2373 14.8954 30.2373 16C30.2373 17.1045 31.1327 18 32.2373 18Z'
fill='#EA4B71'
/>
<path
d='M44.2393 15.0007H46.3277V10.5791C46.3277 9.12704 47.2088 8.49074 48.204 8.49074C49.183 8.49074 49.9498 9.14334 49.9498 10.4812V15.0007H52.038V10.057C52.038 7.91969 50.798 6.67969 48.8567 6.67969C47.633 6.67969 46.9477 7.16914 46.4582 7.80544H46.3277L46.1482 6.84284H44.2393V15.0007Z'
fill='#101330'
/>
<path
d='M60.0318 9.50205V9.40415C60.7498 9.0452 61.4678 8.4252 61.4678 7.20155C61.4678 5.43945 60.0153 4.37891 58.0088 4.37891C55.9528 4.37891 54.4843 5.5047 54.4843 7.23415C54.4843 8.4089 55.1698 9.0452 55.9203 9.40415V9.50205C55.0883 9.79575 54.0928 10.6768 54.0928 12.1452C54.0928 13.9237 55.5613 15.1637 57.9923 15.1637C60.4233 15.1637 61.8428 13.9237 61.8428 12.1452C61.8428 10.6768 60.8638 9.81205 60.0318 9.50205ZM57.9923 5.87995C58.8083 5.87995 59.4118 6.40205 59.4118 7.2831C59.4118 8.16415 58.7918 8.6863 57.9923 8.6863C57.1928 8.6863 56.5238 8.16415 56.5238 7.2831C56.5238 6.38575 57.1603 5.87995 57.9923 5.87995ZM57.9923 13.5974C57.0458 13.5974 56.2793 12.9937 56.2793 11.9658C56.2793 11.0358 56.9153 10.3342 57.9758 10.3342C59.0203 10.3342 59.6568 11.0195 59.6568 11.9984C59.6568 12.9937 58.9223 13.5974 57.9923 13.5974Z'
fill='#101330'
/>
<path
d='M63.9639 15.0007H66.0524V10.5791C66.0524 9.12704 66.9334 8.49074 67.9289 8.49074C68.9079 8.49074 69.6744 9.14334 69.6744 10.4812V15.0007H71.7629V10.057C71.7629 7.91969 70.5229 6.67969 68.5814 6.67969C67.3579 6.67969 66.6724 7.16914 66.1829 7.80544H66.0524L65.8729 6.84284H63.9639V15.0007Z'
fill='#101330'
/>
</svg>
</a>
</div>
{{/if}}
</section>
</div>
</body>
</html>

View file

@ -315,7 +315,7 @@
<section>
{{#if testRun}}
<div class='test-notice'>
<p>This is test version of your form. Use it only for testing your Form Trigger.</p>
<p>This is test version of your form</p>
</div>
<hr>
{{/if}}
@ -428,7 +428,7 @@
d='M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z'
/>
</svg></span>
Submit form
{{ buttonLabel }}
</button>
</form>
{{else}}
@ -719,6 +719,10 @@
}
if (response.status === 200) {
if(response.redirected) {
window.location.replace(response.url);
return;
}
const redirectUrl = document.getElementById("redirectUrl");
if (redirectUrl) {
window.location.replace(redirectUrl.href);
@ -731,7 +735,7 @@
document.querySelector('#submitted-form').style.display = 'block';
document.querySelector('#submitted-header').textContent = 'Problem submitting response';
document.querySelector('#submitted-content').textContent =
'An error occurred in the workflow handling this form';
'Please try again or contact support if the problem persists';
}
return;
@ -747,6 +751,15 @@
.catch(function (error) {
console.error('Error:', error);
});
const interval = setInterval(function() {
const isSubmited = document.querySelector('#submitted-form').style.display;
if(isSubmited === 'block') {
clearInterval(interval);
return;
}
window.location.reload();
}, 2000);
}
});
</script>

View file

@ -5,9 +5,9 @@ import type SuperAgentTest from 'supertest/lib/agent';
import Container from 'typedi';
import { ExternalHooks } from '@/external-hooks';
import { WaitingForms } from '@/waiting-forms';
import { LiveWebhooks } from '@/webhooks/live-webhooks';
import { TestWebhooks } from '@/webhooks/test-webhooks';
import { WaitingForms } from '@/webhooks/waiting-forms';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
import { WebhookServer } from '@/webhooks/webhook-server';
import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types';

View file

@ -4436,6 +4436,36 @@ export function getExecuteWebhookFunctions(
);
},
getMode: () => mode,
evaluateExpression: (expression: string, evaluateItemIndex?: number) => {
const itemIndex = evaluateItemIndex === undefined ? 0 : evaluateItemIndex;
const runIndex = 0;
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (runExecutionData?.executionData !== undefined) {
executionData = runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
return workflow.expression.resolveSimpleParameterValue(
`=${expression}`,
{},
runExecutionData,
runIndex,
itemIndex,
node.name,
connectionInputData,
mode,
additionalKeys,
executionData,
);
},
getNodeParameter: (
parameterName: string,
fallbackValue?: any,

View file

@ -111,18 +111,18 @@ function destroyEditor() {
</script>
<template>
<div :class="$style.editor">
<div ref="jsonEditorRef" class="ph-no-capture json-editor"></div>
<div :class="[$style['editor-container'], $style.fillHeight]">
<div ref="jsonEditorRef" :class="['ph-no-capture', $style.fillHeight]"></div>
<slot name="suffix" />
</div>
</template>
<style lang="scss" module>
.editor {
height: 100%;
.editor-container {
position: relative;
}
& > div {
height: 100%;
}
.fillHeight {
height: 100%;
}
</style>

View file

@ -2,6 +2,7 @@
import { useStorage } from '@/composables/useStorage';
import {
CUSTOM_API_CALL_KEY,
FORM_NODE_TYPE,
LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG,
MANUAL_TRIGGER_NODE_TYPE,
NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS,
@ -339,6 +340,9 @@ const waiting = computed(() => {
if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) {
return i18n.baseText('node.theNodeIsWaitingUserInput');
}
if (node?.type === FORM_NODE_TYPE) {
return i18n.baseText('node.theNodeIsWaitingFormCall');
}
const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');

View file

@ -1142,7 +1142,7 @@ onUpdated(async () => {
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
fill-parent
fullscreen
@update:model-value="valueChangedDebounced"
/>
</div>

View file

@ -5,7 +5,7 @@ import type {
NodeParameterValue,
NodeParameterValueType,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { deepCopy, ADD_FORM_NOTICE } from 'n8n-workflow';
import { computed, defineAsyncComponent, onErrorCaptured, ref, watch } from 'vue';
import type { IUpdateInformation } from '@/Interface';
@ -19,7 +19,12 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
KEEP_AUTH_IN_NDV_FOR_NODES,
WAIT_NODE_TYPE,
} from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import {
@ -91,7 +96,20 @@ const nodeType = computed(() => {
});
const filteredParameters = computed(() => {
return props.parameters.filter((parameter: INodeProperties) => displayNodeParameter(parameter));
const parameters = props.parameters.filter((parameter: INodeProperties) =>
displayNodeParameter(parameter),
);
const activeNode = ndvStore.activeNode;
if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) {
return updateFormTriggerParameters(parameters, activeNode.name);
}
if (activeNode && activeNode.type === WAIT_NODE_TYPE && activeNode.parameters.resume === 'form') {
return updateWaitParameters(parameters, activeNode.name);
}
return parameters;
});
const filteredParameterNames = computed(() => {
@ -151,6 +169,90 @@ watch(filteredParameterNames, (newValue, oldValue) => {
}
});
function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const connectedNodes = workflow.getChildNodes(triggerName);
const hasFormPage = connectedNodes.some((nodeName) => {
const node = workflow.getNode(nodeName);
return node && node.type === FORM_NODE_TYPE;
});
if (hasFormPage) {
const triggerParameters: INodeProperties[] = [];
for (const parameter of parameters) {
if (parameter.name === 'responseMode') {
triggerParameters.push({
displayName: 'On submission, the user will be taken to the next form node',
name: 'formResponseModeNotice',
type: 'notice',
default: '',
});
continue;
}
if (parameter.name === ADD_FORM_NOTICE) continue;
if (parameter.name === 'options') {
const options = (parameter.options as INodeProperties[]).filter(
(option) => option.name !== 'respondWithOptions',
);
triggerParameters.push({
...parameter,
options,
});
continue;
}
triggerParameters.push(parameter);
}
return triggerParameters;
}
return parameters;
}
function updateWaitParameters(parameters: INodeProperties[], nodeName: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const parentNodes = workflow.getParentNodes(nodeName);
const formTriggerName = parentNodes.find(
(node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE,
);
if (!formTriggerName) return parameters;
const connectedNodes = workflow.getChildNodes(formTriggerName);
const hasFormPage = connectedNodes.some((nodeName) => {
const node = workflow.getNode(nodeName);
return node && node.type === FORM_NODE_TYPE;
});
if (hasFormPage) {
const waitNodeParameters: INodeProperties[] = [];
for (const parameter of parameters) {
if (parameter.name === 'options') {
const options = (parameter.options as INodeProperties[]).filter(
(option) => option.name !== 'respondWithOptions' && option.name !== 'webhookSuffix',
);
waitNodeParameters.push({
...parameter,
options,
});
continue;
}
waitNodeParameters.push(parameter);
}
return waitNodeParameters;
}
return parameters;
}
function onParameterBlur(parameterName: string) {
emit('parameterBlur', parameterName);
}

View file

@ -333,6 +333,7 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(workflowsStore).allNodes = [];
vi.mocked(workflowsStore).getExecution.mockResolvedValue({
finished: true,
workflowData: { nodes: [] },
} as unknown as IExecutionResponse);
vi.mocked(workflowsStore).workflowExecutionData = {
id: '123',

View file

@ -40,6 +40,7 @@ import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-wo
import type { INodeUi } from '@/Interface';
import {
CUSTOM_API_CALL_KEY,
FORM_NODE_TYPE,
STICKY_NODE_TYPE,
WAIT_NODE_TYPE,
WAIT_TIME_UNLIMITED,
@ -353,6 +354,11 @@ export function useCanvasMapping({
return acc;
}
if (node?.type === FORM_NODE_TYPE) {
acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall');
return acc;
}
const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {

View file

@ -76,6 +76,7 @@ import type { Connection } from '@vue-flow/core';
import type {
IConnection,
IConnections,
IDataObject,
INode,
INodeConnections,
INodeCredentials,
@ -1673,6 +1674,12 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
);
if (isDuplicate) {
node.webhookId = uuid();
if (node.parameters.path) {
node.parameters.path = node.webhookId as string;
} else if ((node.parameters.options as IDataObject).path) {
(node.parameters.options as IDataObject).path = node.webhookId as string;
}
}
}

View file

@ -17,7 +17,7 @@ import type {
IDataObject,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { FORM_NODE_TYPE, NodeConnectionType } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
@ -272,7 +272,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
const getTestUrl = (() => {
return (node: INode) => {
return `${rootStore.formTestUrl}/${node.parameters.path}`;
const path =
node.parameters.path ||
(node.parameters.options as IDataObject)?.path ||
node.webhookId;
return `${rootStore.formTestUrl}/${path as string}`;
};
})();
@ -373,6 +377,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
return;
}
const { lastNodeExecuted } = execution.data?.resultData || {};
const lastNode = execution.workflowData.nodes.find((node) => {
return node.name === lastNodeExecuted;
});
if (
execution.finished ||
['error', 'canceled', 'crashed', 'success'].includes(execution.status)
@ -390,24 +399,38 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
];
workflowsStore.setWorkflowExecutionRunData(execution.data);
const { lastNodeExecuted } = execution.data?.resultData || {};
const waitingNode = execution.workflowData.nodes.find((node) => {
return node.name === lastNodeExecuted;
});
if (
waitingNode &&
waitingNode.type === WAIT_NODE_TYPE &&
waitingNode.parameters.resume === 'form'
lastNode &&
(lastNode.type === FORM_NODE_TYPE ||
(lastNode.type === WAIT_NODE_TYPE && lastNode.parameters.resume === 'form'))
) {
const testUrl = getFormResumeUrl(waitingNode, executionId as string);
let testUrl = getFormResumeUrl(lastNode, executionId as string);
if (isFormShown) {
localStorage.setItem(FORM_RELOAD, testUrl);
} else {
isFormShown = true;
openPopUpWindow(testUrl);
if (options.destinationNode) {
// Check if the form trigger has starting data
// if not do not show next form as trigger would redirect to page
// otherwise there would be duplicate popup
const formTrigger = execution?.workflowData.nodes.find((node) => {
return node.type === FORM_TRIGGER_NODE_TYPE;
});
const runNodeFilter = execution?.data?.startData?.runNodeFilter || [];
if (formTrigger && !runNodeFilter.includes(formTrigger.name)) {
isFormShown = true;
}
}
if (!isFormShown) {
if (lastNode.type === FORM_NODE_TYPE) {
testUrl = `${rootStore.formWaitingUrl}/${executionId}`;
} else {
testUrl = getFormResumeUrl(lastNode, executionId as string);
}
isFormShown = true;
if (testUrl) openPopUpWindow(testUrl);
}
}
}
}

View file

@ -195,6 +195,7 @@ export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE =
export const SIMULATE_NODE_TYPE = 'n8n-nodes-base.simulate';
export const SIMULATE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.simulateTrigger';
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
export const FORM_NODE_TYPE = 'n8n-nodes-base.form';
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;

View file

@ -1106,7 +1106,9 @@
"nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On webhook call",
"nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow on receiving an HTTP request",
"nodeCreator.triggerHelperPanel.formTriggerDisplayName": "On form submission",
"nodeCreator.triggerHelperPanel.formTriggerDescription": "Runs the flow when an n8n generated webform is submitted",
"nodeCreator.triggerHelperPanel.formTriggerDescription": "Generate webforms in n8n and pass their responses to the workflow",
"nodeCreator.triggerHelperPanel.formDisplayName": "Form",
"nodeCreator.triggerHelperPanel.formDescription": "Add next form page",
"nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Trigger manually",
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly",
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",

View file

@ -3,6 +3,7 @@ import {
DEFAULT_NEW_WORKFLOW_NAME,
DUPLICATE_POSTFFIX,
ERROR_TRIGGER_NODE_TYPE,
FORM_NODE_TYPE,
MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
START_NODE_TYPE,
@ -185,7 +186,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const isWaitingExecution = computed(() => {
return allNodes.value.some(
(node) =>
(node.type === WAIT_NODE_TYPE || node.parameters.operation === SEND_AND_WAIT_OPERATION) &&
(node.type === WAIT_NODE_TYPE ||
node.type === FORM_NODE_TYPE ||
node.parameters.operation === SEND_AND_WAIT_OPERATION) &&
node.disabled !== true,
);
});

View file

@ -8,7 +8,7 @@ import {
} from 'n8n-workflow';
import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface';
import { isEmpty } from '@/utils/typesUtils';
import { FORM_TRIGGER_NODE_TYPE } from '../constants';
import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from '../constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRootStore } from '@/stores/root.store';
import { i18n } from '@/plugins/i18n';
@ -168,6 +168,12 @@ export const waitingNodeTooltip = () => {
}
}
if (lastNode?.type === FORM_NODE_TYPE) {
const message = i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission');
const resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}`;
return `${message}<a href="${resumeUrl}" target="_blank">${resumeUrl}</a>`;
}
if (lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION) {
return i18n.baseText('ndv.output.sendAndWaitWaitingApproval');
}

View file

@ -1929,6 +1929,12 @@ export default defineComponent({
).some((n) => n.webhookId === node.webhookId);
if (isDuplicate) {
node.webhookId = uuid();
if (node.parameters.path) {
node.parameters.path = node.webhookId as string;
} else if ((node.parameters.options as IDataObject).path) {
(node.parameters.options as IDataObject).path = node.webhookId as string;
}
}
}

View file

@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.form",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Core Nodes"],
"alias": ["_Form", "form", "table", "submit", "post", "page", "step", "stage", "multi"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/"
}
],
"generic": []
},
"subcategories": {
"Core Nodes": ["Helpers"]
}
}

View file

@ -0,0 +1,422 @@
import type {
FormFieldsParameter,
IExecuteFunctions,
INodeExecutionData,
INodeTypeDescription,
IWebhookFunctions,
NodeTypeAndVersion,
} from 'n8n-workflow';
import {
WAIT_TIME_UNLIMITED,
Node,
updateDisplayOptions,
NodeOperationError,
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
tryToParseJsonToFormFields,
NodeConnectionType,
WAIT_NODE_TYPE,
} from 'n8n-workflow';
import { formDescription, formFields, formTitle } from '../Form/common.descriptions';
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
const pageProperties = updateDisplayOptions(
{
show: {
operation: ['page'],
},
},
[
{
displayName: 'Define Form',
name: 'defineForm',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Using Fields Below',
value: 'fields',
},
{
name: 'Using JSON',
value: 'json',
},
],
default: 'fields',
},
{
displayName: 'Form Fields',
name: 'jsonOutput',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
validateType: 'form-fields',
ignoreValidationDuringExecution: true,
hint: '<a href="hhttps://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
displayOptions: {
show: {
defineForm: ['json'],
},
},
},
{ ...formFields, displayOptions: { show: { defineForm: ['fields'] } } },
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{ ...formTitle, required: false },
formDescription,
{
displayName: 'Button Label',
name: 'buttonLabel',
type: 'string',
default: 'Submit',
},
],
},
],
);
const completionProperties = updateDisplayOptions(
{
show: {
operation: ['completion'],
},
},
[
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'On n8n Form Submission',
name: 'respondWith',
type: 'options',
default: 'text',
options: [
{
name: 'Show Completion Screen',
value: 'text',
description: 'Show a response text to the user',
},
{
name: 'Redirect to URL',
value: 'redirect',
description: 'Redirect the user to a URL',
},
],
},
{
displayName: 'URL',
name: 'redirectUrl',
validateType: 'url',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
respondWith: ['redirect'],
},
},
},
{
displayName: 'Completion Title',
name: 'completionTitle',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
displayName: 'Completion Message',
name: 'completionMessage',
type: 'string',
default: '',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }],
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
],
);
export class Form extends Node {
nodeInputData: INodeExecutionData[] = [];
description: INodeTypeDescription = {
displayName: 'n8n Form',
name: 'form',
icon: 'file:form.svg',
group: ['input'],
version: 1,
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'Form',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
webhooks: [
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: '',
restartWebhook: true,
isFullPath: true,
isForm: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: '',
restartWebhook: true,
isFullPath: true,
isForm: true,
},
],
properties: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'An n8n Form Trigger node must be set up before this node',
name: 'triggerNotice',
type: 'notice',
default: '',
},
{
displayName: 'Page Type',
name: 'operation',
type: 'options',
default: 'page',
noDataExpression: true,
options: [
{
name: 'Next Form Page',
value: 'page',
},
{
name: 'Form Ending',
value: 'completion',
},
],
},
...pageProperties,
...completionProperties,
],
};
async webhook(context: IWebhookFunctions) {
const res = context.getResponseObject();
const operation = context.getNodeParameter('operation', '') as string;
const parentNodes = context.getParentNodes(context.getNode().name);
const trigger = parentNodes.find(
(node) => node.type === 'n8n-nodes-base.formTrigger',
) as NodeTypeAndVersion;
const mode = context.evaluateExpression(`{{ $('${trigger?.name}').first().json.formMode }}`) as
| 'test'
| 'production';
const defineForm = context.getNodeParameter('defineForm', false) as string;
let fields: FormFieldsParameter = [];
if (defineForm === 'json') {
try {
const jsonOutput = context.getNodeParameter('jsonOutput', '', {
rawExpressions: true,
}) as string;
fields = tryToParseJsonToFormFields(resolveRawData(context, jsonOutput));
} catch (error) {
throw new NodeOperationError(context.getNode(), error.message, {
description: error.message,
type: mode === 'test' ? 'manual-form-test' : undefined,
});
}
} else {
fields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
}
const method = context.getRequestObject().method;
if (operation === 'completion') {
const respondWith = context.getNodeParameter('respondWith', '') as string;
if (respondWith === 'redirect') {
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
res.redirect(redirectUrl);
return {
noWebhookResponse: true,
};
}
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
};
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 (method === 'GET') {
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.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(
`{{ $('${trigger?.name}').params.options?.useWorkflowTimezone }}`,
) as boolean;
if (useWorkflowTimezone === undefined && trigger?.typeVersion > 2) {
useWorkflowTimezone = true;
}
const returnItem = await prepareFormReturnItem(context, fields, mode, useWorkflowTimezone);
return {
webhookResponse: { status: 200 },
workflowData: [[returnItem]],
};
}
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const operation = context.getNodeParameter('operation', 0);
if (operation === 'completion') {
this.nodeInputData = context.getInputData();
}
const parentNodes = context.getParentNodes(context.getNode().name);
const hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE);
if (!hasFormTrigger) {
throw new NodeOperationError(
context.getNode(),
'Form Trigger node must be set before this node',
);
}
const childNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = childNodes.some((node) => node.type === FORM_NODE_TYPE);
if (operation === 'completion' && hasNextPage) {
throw new NodeOperationError(
context.getNode(),
'Completion has to be the last Form node in the workflow',
);
}
if (operation !== 'completion') {
const waitTill = new Date(WAIT_TIME_UNLIMITED);
await context.putExecutionToWait(waitTill);
}
return [context.getInputData()];
}
}

View file

@ -10,14 +10,15 @@ export class FormTrigger extends VersionedNodeType {
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
description: 'Runs the flow when an n8n generated webform is submitted',
defaultVersion: 2.1,
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaultVersion: 2.2,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new FormTriggerV1(baseDescription),
2: new FormTriggerV2(baseDescription),
2.1: new FormTriggerV2(baseDescription),
2.2: new FormTriggerV2(baseDescription),
};
super(nodeVersions, baseDescription);

View file

@ -1,15 +1,3 @@
export type FormField = {
fieldLabel: string;
fieldType: string;
requiredField: boolean;
fieldOptions?: { values: Array<{ option: string }> };
multiselect?: boolean;
multipleFiles?: boolean;
acceptFileTypes?: string;
formatDate?: string;
placeholder?: string;
};
export type FormTriggerInput = {
isSelect?: boolean;
isMultiSelect?: boolean;
@ -40,6 +28,7 @@ export type FormTriggerData = {
formFields: FormTriggerInput[];
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View file

@ -0,0 +1,190 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type {
IExecuteFunctions,
INode,
INodeExecutionData,
IWebhookFunctions,
NodeTypeAndVersion,
} from 'n8n-workflow';
import type { Response, Request } from 'express';
import { Form } from '../Form.node';
describe('Form Node', () => {
let form: Form;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
beforeEach(() => {
form = new Form();
mockExecuteFunctions = mock<IExecuteFunctions>();
mockWebhookFunctions = mock<IWebhookFunctions>();
});
describe('execute method', () => {
it('should throw an error if Form Trigger node is not set', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('page');
mockExecuteFunctions.getParentNodes.mockReturnValue([]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
await expect(form.execute(mockExecuteFunctions)).rejects.toThrow(
'Form Trigger node must be set before this node',
);
});
it('should put execution to wait if operation is not completion', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('page');
mockExecuteFunctions.getParentNodes.mockReturnValue([
mock<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
await form.execute(mockExecuteFunctions);
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalled();
});
it('should throw an error if completion is not the last Form node', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('completion');
mockExecuteFunctions.getParentNodes.mockReturnValue([
mock<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([
mock<NodeTypeAndVersion>({ type: 'n8n-nodes-base.form' }),
]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
await expect(form.execute(mockExecuteFunctions)).rejects.toThrow(
'Completion has to be the last Form node in the workflow',
);
});
it('should return input data for completion operation', async () => {
const inputData: INodeExecutionData[] = [{ json: { test: 'data' } }];
mockExecuteFunctions.getNodeParameter.mockReturnValue('completion');
mockExecuteFunctions.getParentNodes.mockReturnValue([
mock<NodeTypeAndVersion>({ type: 'n8n-nodes-base.formTrigger' }),
]);
mockExecuteFunctions.getChildNodes.mockReturnValue([]);
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
const result = await form.execute(mockExecuteFunctions);
expect(result).toEqual([inputData]);
});
});
describe('webhook method', () => {
it('should render form for GET request', async () => {
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'useJson') return false;
if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }];
if (paramName === 'options') {
return {
formTitle: 'Form Title',
formDescription: 'Form Description',
buttonLabel: 'Form Button',
};
}
return undefined;
});
mockWebhookFunctions.getChildNodes.mockReturnValue([]);
await form.webhook(mockWebhookFunctions);
expect(mockResponseObject.render).toHaveBeenCalledWith('form-trigger', expect.any(Object));
});
it('should return form data for POST request', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'useJson') return false;
if (paramName === 'formFields.values') return [{ fieldLabel: 'test' }];
if (paramName === 'options') {
return {
formTitle: 'Form Title',
formDescription: 'Form Description',
buttonLabel: 'Form Button',
};
}
return undefined;
});
mockWebhookFunctions.getBodyData.mockReturnValue({
data: { 'field-0': 'test value' },
files: {},
});
const result = await form.webhook(mockWebhookFunctions);
expect(result).toHaveProperty('webhookResponse');
expect(result).toHaveProperty('workflowData');
expect(result.workflowData).toEqual([
[
{
json: expect.objectContaining({
formMode: 'test',
submittedAt: expect.any(String),
test: 'test value',
}),
},
],
]);
});
it('should handle completion operation', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'useJson') return false;
if (paramName === 'jsonOutput') return '[]';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Test Title';
if (paramName === 'completionMessage') return 'Test Message';
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{ type: 'n8n-nodes-base.formTrigger', name: 'Form Trigger', typeVersion: 2.1 },
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger-completion',
expect.any(Object),
);
});
});
});

View file

@ -4,7 +4,6 @@ import { NodeOperationError, type INode } from 'n8n-workflow';
import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
import { FormTrigger } from '../FormTrigger.node';
import type { FormField } from '../interfaces';
describe('FormTrigger', () => {
beforeEach(() => {
@ -12,7 +11,7 @@ describe('FormTrigger', () => {
});
it('should render a form template with correct fields', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false },
@ -49,6 +48,7 @@ describe('FormTrigger', () => {
expect(response.render).toHaveBeenCalledWith('form-trigger', {
appendAttribution: false,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formFields: [
{
@ -115,7 +115,7 @@ describe('FormTrigger', () => {
});
it('should return workflowData on POST request', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{ fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false },
@ -205,13 +205,13 @@ describe('FormTrigger', () => {
],
}),
).rejects.toEqual(
new NodeOperationError(mock<INode>(), 'n8n Form Trigger node not correctly configured'),
new NodeOperationError(mock<INode>(), 'On form submission node not correctly configured'),
);
});
});
it('should throw on invalid webhook authentication', async () => {
const formFields: FormField[] = [
const formFields = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
@ -239,7 +239,7 @@ describe('FormTrigger', () => {
});
it('should handle files', async () => {
const formFields: FormField[] = [
const formFields = [
{
fieldLabel: 'Resume',
fieldType: 'file',

View file

@ -1,9 +1,163 @@
import type { FormField } from '../interfaces';
import { prepareFormData } from '../utils';
import { mock } from 'jest-mock-extended';
import type {
FormFieldsParameter,
INode,
IWebhookFunctions,
MultiPartFormData,
} from 'n8n-workflow';
import { DateTime } from 'luxon';
import { formWebhook, prepareFormData, prepareFormReturnItem, resolveRawData } from '../utils';
describe('FormTrigger, formWebhook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call response render', async () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockRender = jest.fn();
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
{
fieldLabel: 'Gender',
fieldType: 'select',
requiredField: true,
fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] },
},
{
fieldLabel: 'Resume',
fieldType: 'file',
requiredField: true,
acceptFileTypes: '.pdf,.doc',
multipleFiles: false,
},
];
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.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);
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
appendAttribution: true,
buttonLabel: 'Submit',
formDescription: 'Test Description',
formFields: [
{
defaultValue: '',
errorId: 'error-field-0',
id: 'field-0',
inputRequired: 'form-required',
isInput: true,
label: 'Name',
placeholder: undefined,
type: 'text',
},
{
defaultValue: '',
errorId: 'error-field-1',
id: 'field-1',
inputRequired: '',
isInput: true,
label: 'Age',
placeholder: undefined,
type: 'number',
},
{
defaultValue: '',
errorId: 'error-field-2',
id: 'field-2',
inputRequired: 'form-required',
isInput: true,
label: 'Gender',
placeholder: undefined,
type: 'select',
},
{
acceptFileTypes: '.pdf,.doc',
defaultValue: '',
errorId: 'error-field-3',
id: 'field-3',
inputRequired: 'form-required',
isFileInput: true,
label: 'Resume',
multipleFiles: '',
placeholder: undefined,
},
],
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 () => {
const executeFunctions = mock<IWebhookFunctions>();
const mockStatus = jest.fn();
const mockEnd = jest.fn();
const formFields: FormFieldsParameter = [
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
];
const bodyData = {
'field-0': 'John Doe',
'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.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any);
executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any);
executeFunctions.getMode.mockReturnValue('manual');
executeFunctions.getInstanceId.mockReturnValue('instanceId');
executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} });
const result = await formWebhook(executeFunctions);
expect(result).toEqual({
webhookResponse: { status: 200 },
workflowData: [
[
{
json: {
Name: 'John Doe',
Age: 30,
submittedAt: expect.any(String),
formMode: 'test',
},
},
],
],
});
});
});
describe('FormTrigger, prepareFormData', () => {
it('should return valid form data with given parameters', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@ -43,6 +197,7 @@ describe('FormTrigger, prepareFormData', () => {
query,
instanceId: 'test-instance',
useResponseData: true,
buttonLabel: 'Submit',
});
expect(result).toEqual({
@ -98,12 +253,13 @@ describe('FormTrigger, prepareFormData', () => {
],
useResponseData: true,
appendAttribution: true,
buttonLabel: 'Submit',
redirectUrl: 'https://example.com/thank-you',
});
});
it('should handle missing optional fields gracefully', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@ -120,6 +276,7 @@ describe('FormTrigger, prepareFormData', () => {
formFields,
testRun: true,
query: {},
buttonLabel: 'Submit',
});
expect(result).toEqual({
@ -143,11 +300,12 @@ describe('FormTrigger, prepareFormData', () => {
],
useResponseData: undefined,
appendAttribution: true,
buttonLabel: 'Submit',
});
});
it('should set redirectUrl with http if protocol is missing', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Name',
fieldType: 'text',
@ -187,7 +345,7 @@ describe('FormTrigger, prepareFormData', () => {
});
it('should correctly handle multiselect fields', () => {
const formFields: FormField[] = [
const formFields: FormFieldsParameter = [
{
fieldLabel: 'Favorite Colors',
fieldType: 'text',
@ -217,7 +375,7 @@ describe('FormTrigger, prepareFormData', () => {
]);
});
it('should correctly handle multiselect fields with unique ids', () => {
const formFields: FormField[] = [
const formFields = [
{
fieldLabel: 'Favorite Colors',
fieldType: 'text',
@ -259,3 +417,306 @@ describe('FormTrigger, prepareFormData', () => {
]);
});
});
jest.mock('luxon', () => ({
DateTime: {
fromFormat: jest.fn().mockReturnValue({
toFormat: jest.fn().mockReturnValue('formatted-date'),
}),
now: jest.fn().mockReturnValue({
setZone: jest.fn().mockReturnValue({
toISO: jest.fn().mockReturnValue('2023-04-01T12:00:00.000Z'),
}),
}),
},
}));
describe('prepareFormReturnItem', () => {
const mockContext = mock<IWebhookFunctions>({
nodeHelpers: mock({
copyBinaryFile: jest.fn().mockResolvedValue({}),
}),
});
const formNode = mock<INode>({ type: 'n8n-nodes-base.formTrigger' });
beforeEach(() => {
jest.clearAllMocks();
mockContext.getBodyData.mockReturnValue({ data: {}, files: {} });
mockContext.getTimezone.mockReturnValue('UTC');
mockContext.getNode.mockReturnValue(formNode);
mockContext.getWorkflowStaticData.mockReturnValue({});
});
it('should handle empty form submission', async () => {
const result = await prepareFormReturnItem(mockContext, [], 'test');
expect(result).toEqual({
json: {
submittedAt: '2023-04-01T12:00:00.000Z',
formMode: 'test',
},
});
});
it('should process text fields correctly', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': ' test value ' },
files: {},
});
const formFields = [{ fieldLabel: 'Text Field', fieldType: 'text' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'production');
expect(result.json['Text Field']).toBe('test value');
expect(result.json.formMode).toBe('production');
});
it('should process number fields correctly', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '42' },
files: {},
});
const formFields = [{ fieldLabel: 'Number Field', fieldType: 'number' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Number Field']).toBe(42);
});
it('should handle file uploads', async () => {
const mockFile: Partial<MultiPartFormData.File> = {
filepath: '/tmp/uploaded-file',
originalFilename: 'test.txt',
mimetype: 'text/plain',
size: 1024,
};
mockContext.getBodyData.mockReturnValue({
data: {},
files: { 'field-0': mockFile },
});
const formFields = [{ fieldLabel: 'File Upload', fieldType: 'file' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['File Upload']).toEqual({
filename: 'test.txt',
mimetype: 'text/plain',
size: 1024,
});
expect(result.binary).toBeDefined();
expect(result.binary!.File_Upload).toEqual({});
});
it('should handle multiple file uploads', async () => {
const mockFiles: Array<Partial<MultiPartFormData.File>> = [
{ filepath: '/tmp/file1', originalFilename: 'file1.txt', mimetype: 'text/plain', size: 1024 },
{ filepath: '/tmp/file2', originalFilename: 'file2.txt', mimetype: 'text/plain', size: 2048 },
];
mockContext.getBodyData.mockReturnValue({
data: {},
files: { 'field-0': mockFiles },
});
const formFields = [{ fieldLabel: 'Multiple Files', fieldType: 'file', multipleFiles: true }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Multiple Files']).toEqual([
{ filename: 'file1.txt', mimetype: 'text/plain', size: 1024 },
{ filename: 'file2.txt', mimetype: 'text/plain', size: 2048 },
]);
expect(result.binary).toBeDefined();
expect(result.binary!.Multiple_Files_0).toEqual({});
expect(result.binary!.Multiple_Files_1).toEqual({});
});
it('should format date fields', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '2023-04-01' },
files: {},
});
const formFields = [{ fieldLabel: 'Date Field', fieldType: 'date', formatDate: 'dd/MM/yyyy' }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json['Date Field']).toBe('formatted-date');
expect(DateTime.fromFormat).toHaveBeenCalledWith('2023-04-01', 'yyyy-mm-dd');
});
it('should handle multiselect fields', async () => {
mockContext.getBodyData.mockReturnValue({
data: { 'field-0': '["option1", "option2"]' },
files: {},
});
const formFields = [{ fieldLabel: 'Multiselect', fieldType: 'multiSelect', multiselect: true }];
const result = await prepareFormReturnItem(mockContext, formFields, 'test');
expect(result.json.Multiselect).toEqual(['option1', 'option2']);
});
it('should use workflow timezone when specified', async () => {
mockContext.getTimezone.mockReturnValue('America/New_York');
await prepareFormReturnItem(mockContext, [], 'test', true);
expect(mockContext.getTimezone).toHaveBeenCalled();
expect(DateTime.now().setZone).toHaveBeenCalledWith('America/New_York');
});
it('should include workflow static data for form trigger node', async () => {
const staticData = { queryParam: 'value' };
mockContext.getWorkflowStaticData.mockReturnValue(staticData);
const result = await prepareFormReturnItem(mockContext, [], 'test');
expect(result.json.formQueryParameters).toEqual(staticData);
});
});
describe('resolveRawData', () => {
const mockContext = mock<IWebhookFunctions>();
const dummyData = {
name: 'Hanna',
age: 30,
city: 'New York',
isStudent: false,
hasJob: true,
grades: {
math: 95,
science: 88,
history: 92,
},
hobbies: ['reading', 'painting', 'coding'],
address: {
street: '123 Main St',
zipCode: '10001',
country: 'USA',
},
languages: ['English', 'Spanish'],
projects: [
{ name: 'Project A', status: 'completed' },
{ name: 'Project B', status: 'in-progress' },
],
emptyArray: [],
};
beforeEach(() => {
jest.clearAllMocks();
mockContext.evaluateExpression.mockImplementation((expression: string) => {
const key = expression.replace(/[{}]/g, '').trim();
return key.split('.').reduce((obj, prop) => obj?.[prop], dummyData as any);
});
});
it('should return the input string if it does not start with "="', () => {
const input = 'Hello, world!';
expect(resolveRawData(mockContext, input)).toBe(input);
});
it('should remove leading "=" characters', () => {
const input = '=Hello, world!';
expect(resolveRawData(mockContext, input)).toBe('Hello, world!');
});
it('should resolve a single expression', () => {
const input = '=Hello, {{name}}!';
expect(resolveRawData(mockContext, input)).toBe('Hello, Hanna!');
});
it('should resolve multiple expressions', () => {
const input = '={{name}} is {{age}} years old and lives in {{city}}.';
expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old and lives in New York.');
});
it('should handle object resolutions', () => {
const input = '=Grades: {{grades}}';
expect(resolveRawData(mockContext, input)).toBe(
'Grades: {"math":95,"science":88,"history":92}',
);
});
it('should handle nested object properties', () => {
const input = "={{name}}'s math grade is {{grades.math}}.";
expect(resolveRawData(mockContext, input)).toBe("Hanna's math grade is 95.");
});
it('should handle boolean values', () => {
const input = '=Is {{name}} a student? {{isStudent}}';
expect(resolveRawData(mockContext, input)).toBe('Is Hanna a student? false');
});
it('should handle expressions with whitespace', () => {
const input = '={{ name }} is {{ age }} years old.';
expect(resolveRawData(mockContext, input)).toBe('Hanna is 30 years old.');
});
it('should return the original string if no resolvables are found', () => {
const input = '=Hello, world!';
expect(resolveRawData(mockContext, input)).toBe('Hello, world!');
});
it('should handle non-existent properties gracefully', () => {
const input = "={{name}}'s favorite color is {{favoriteColor}}.";
expect(resolveRawData(mockContext, input)).toBe("Hanna's favorite color is undefined.");
});
it('should handle mixed resolvable and non-resolvable content', () => {
const input = '={{name}} lives in {{city}} and enjoys programming.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna lives in New York and enjoys programming.',
);
});
it('should handle boolean values correctly', () => {
const input = '={{name}} is a student: {{isStudent}}. {{name}} has a job: {{hasJob}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna is a student: false. Hanna has a job: true.',
);
});
it('should handle arrays correctly', () => {
const input = "={{name}}'s hobbies are {{hobbies}}.";
expect(resolveRawData(mockContext, input)).toBe(
'Hanna\'s hobbies are ["reading","painting","coding"].',
);
});
it('should handle nested objects correctly', () => {
const input = '={{name}} lives at {{address.street}}, {{address.zipCode}}.';
expect(resolveRawData(mockContext, input)).toBe('Hanna lives at 123 Main St, 10001.');
});
it('should handle arrays of objects correctly', () => {
const input = '=Project statuses: {{projects.0.status}}, {{projects.1.status}}.';
expect(resolveRawData(mockContext, input)).toBe('Project statuses: completed, in-progress.');
});
it('should handle empty arrays correctly', () => {
const input = '=Empty array: {{emptyArray}}.';
expect(resolveRawData(mockContext, input)).toBe('Empty array: [].');
});
it('should handle a mix of different data types', () => {
const input =
'={{name}} ({{age}}) knows {{languages.length}} languages. First project: {{projects.0.name}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Hanna (30) knows 2 languages. First project: Project A.',
);
});
it('should handle nested array access', () => {
const input = '=First hobby: {{hobbies.0}}, Last hobby: {{hobbies.2}}.';
expect(resolveRawData(mockContext, input)).toBe('First hobby: reading, Last hobby: coding.');
});
it('should handle object-to-string conversion', () => {
const input = '=Address object: {{address}}.';
expect(resolveRawData(mockContext, input)).toBe(
'Address object: {"street":"123 Main St","zipCode":"10001","country":"USA"}.',
);
});
});

View file

@ -3,15 +3,27 @@ import type {
MultiPartFormData,
IDataObject,
IWebhookFunctions,
FormFieldsParameter,
NodeTypeAndVersion,
} from 'n8n-workflow';
import { NodeOperationError, jsonParse } from 'n8n-workflow';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
NodeOperationError,
WAIT_NODE_TYPE,
jsonParse,
} from 'n8n-workflow';
import type { FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils';
import { DateTime } from 'luxon';
import isbot from 'isbot';
import { WebhookAuthorizationError } from '../Webhook/error';
import { validateWebhookAuthentication } from '../Webhook/utils';
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from './interfaces';
import type { Response } from 'express';
import { getResolvables } from '../../utils/utilities';
export function prepareFormData({
formTitle,
@ -24,17 +36,19 @@ export function prepareFormData({
instanceId,
useResponseData,
appendAttribution = true,
buttonLabel,
}: {
formTitle: string;
formDescription: string;
formSubmittedText: string | undefined;
redirectUrl: string | undefined;
formFields: FormField[];
formFields: FormFieldsParameter;
testRun: boolean;
query: IDataObject;
instanceId?: string;
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
}) {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@ -54,6 +68,7 @@ export function prepareFormData({
formFields: [],
useResponseData,
appendAttribution,
buttonLabel,
};
if (redirectUrl) {
@ -138,101 +153,12 @@ const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
}
};
export async function formWebhook(
export async function prepareFormReturnItem(
context: IWebhookFunctions,
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
formFields: FormFieldsParameter,
mode: 'test' | 'production',
useWorkflowTimezone: boolean = false,
) {
const node = context.getNode();
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
try {
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
throw new WebhookAuthorizationError(403);
}
if (node.typeVersion > 1) {
await validateWebhookAuthentication(context, authProperty);
}
} catch (error) {
if (error instanceof WebhookAuthorizationError) {
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
res.status(401).send();
return { noWebhookResponse: true };
}
throw error;
}
const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormField[];
const method = context.getRequestObject().method;
checkResponseModeConfiguration(context);
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = (context.getNodeParameter('formDescription', '') as string)
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const instanceId = context.getInstanceId();
const responseMode = context.getNodeParameter('responseMode', '') as string;
let formSubmittedText;
let redirectUrl;
let appendAttribution = true;
if (options.respondWithOptions) {
const values = (options.respondWithOptions as IDataObject).values as IDataObject;
if (values.respondWith === 'text') {
formSubmittedText = values.formSubmittedText as string;
}
if (values.respondWith === 'redirect') {
redirectUrl = values.redirectUrl as string;
}
} else {
formSubmittedText = options.formSubmittedText as string;
}
if (options.appendAttribution === false) {
appendAttribution = false;
}
const useResponseData = responseMode === 'responseNode';
const query = context.getRequestObject().query as IDataObject;
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun: mode === 'test',
query,
instanceId,
useResponseData,
appendAttribution,
});
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const files = (context.getBodyData().files as IDataObject) ?? {};
@ -312,21 +238,233 @@ export async function formWebhook(
returnItem.json[field.fieldLabel] = value;
}
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
const workflowStaticData = context.getWorkflowStaticData('node');
if (
Object.keys(workflowStaticData || {}).length &&
context.getNode().type === FORM_TRIGGER_NODE_TYPE
) {
returnItem.json.formQueryParameters = workflowStaticData;
}
return returnItem;
}
export function renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
}: {
context: IWebhookFunctions;
res: Response;
formTitle: string;
formDescription: string;
formFields: FormFieldsParameter;
responseMode: string;
mode: 'test' | 'production';
formSubmittedText?: string;
redirectUrl?: string;
appendAttribution?: boolean;
buttonLabel?: string;
}) {
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
const instanceId = context.getInstanceId();
const useResponseData = responseMode === 'responseNode';
let query: IDataObject = {};
if (context.getNode().type === FORM_TRIGGER_NODE_TYPE) {
query = context.getRequestObject().query as IDataObject;
const workflowStaticData = context.getWorkflowStaticData('node');
for (const key of Object.keys(query)) {
workflowStaticData[key] = query[key];
}
} else if (context.getNode().type === FORM_NODE_TYPE) {
const parentNodes = context.getParentNodes(context.getNode().name);
const trigger = parentNodes.find(
(node) => node.type === FORM_TRIGGER_NODE_TYPE,
) as NodeTypeAndVersion;
try {
const triggerQueryParameters = context.evaluateExpression(
`{{ $('${trigger?.name}').first().json.formQueryParameters }}`,
) as IDataObject;
if (triggerQueryParameters) {
query = triggerQueryParameters;
}
} catch (error) {}
}
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
testRun: mode === 'test',
query,
instanceId,
useResponseData,
appendAttribution,
buttonLabel,
});
res.render('form-trigger', data);
}
export async function formWebhook(
context: IWebhookFunctions,
authProperty = FORM_TRIGGER_AUTHENTICATION_PROPERTY,
) {
const node = context.getNode();
const options = context.getNodeParameter('options', {}) as {
ignoreBots?: boolean;
respondWithOptions?: {
values: {
respondWith: 'text' | 'redirect';
formSubmittedText: string;
redirectUrl: string;
};
};
formSubmittedText?: string;
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
try {
if (options.ignoreBots && isbot(req.headers['user-agent'])) {
throw new WebhookAuthorizationError(403);
}
if (node.typeVersion > 1) {
await validateWebhookAuthentication(context, authProperty);
}
} catch (error) {
if (error instanceof WebhookAuthorizationError) {
res.setHeader('WWW-Authenticate', 'Basic realm="Enter credentials"');
res.status(401).send();
return { noWebhookResponse: true };
}
throw error;
}
const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormFieldsParameter;
const method = context.getRequestObject().method;
checkResponseModeConfiguration(context);
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = context.getNodeParameter('formDescription', '') as string;
const responseMode = context.getNodeParameter('responseMode', '') as string;
let formSubmittedText;
let redirectUrl;
let appendAttribution = true;
if (options.respondWithOptions) {
const values = (options.respondWithOptions as IDataObject).values as IDataObject;
if (values.respondWith === 'text') {
formSubmittedText = values.formSubmittedText as string;
}
if (values.respondWith === 'redirect') {
redirectUrl = values.redirectUrl as string;
}
} else {
formSubmittedText = options.formSubmittedText as string;
}
if (options.appendAttribution === false) {
appendAttribution = false;
}
let buttonLabel = 'Submit';
if (options.buttonLabel) {
buttonLabel = options.buttonLabel;
}
if (!redirectUrl && node.type !== FORM_TRIGGER_NODE_TYPE) {
const connectedNodes = context.getChildNodes(context.getNode().name);
const hasNextPage = connectedNodes.some(
(n) => n.type === FORM_NODE_TYPE || n.type === WAIT_NODE_TYPE,
);
if (hasNextPage) {
redirectUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}') as string;
}
}
renderForm({
context,
res,
formTitle,
formDescription,
formFields,
responseMode,
mode,
formSubmittedText,
redirectUrl,
appendAttribution,
buttonLabel,
});
return {
noWebhookResponse: true,
};
}
let { useWorkflowTimezone } = options;
if (useWorkflowTimezone === undefined && node.typeVersion > 2) {
useWorkflowTimezone = true;
}
const timezone = useWorkflowTimezone ? context.getTimezone() : 'UTC';
returnItem.json.submittedAt = DateTime.now().setZone(timezone).toISO();
returnItem.json.formMode = mode;
const webhookResponse: IDataObject = { status: 200 };
const returnItem = await prepareFormReturnItem(context, formFields, mode, useWorkflowTimezone);
return {
webhookResponse,
webhookResponse: { status: 200 },
workflowData: [[returnItem]],
};
}
export function resolveRawData(context: IWebhookFunctions, rawData: string) {
const resolvables = getResolvables(rawData);
let returnData: string = rawData;
if (returnData.startsWith('=')) {
returnData = returnData.replace(/^=+/, '');
} else {
return returnData;
}
if (resolvables.length) {
for (const resolvable of resolvables) {
const resolvedValue = context.evaluateExpression(`${resolvable}`);
if (typeof resolvedValue === 'object' && resolvedValue !== null) {
returnData = returnData.replace(resolvable, JSON.stringify(resolvedValue));
} else {
returnData = returnData.replace(resolvable, resolvedValue as string);
}
}
}
return returnData;
}

View file

@ -23,7 +23,7 @@ const descriptionV1: INodeTypeDescription = {
icon: 'file:form.svg',
group: ['trigger'],
version: 1,
description: 'Runs the flow when an n8n generated webform is submitted',
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'n8n Form Trigger',
},

View file

@ -1,4 +1,6 @@
import {
ADD_FORM_NOTICE,
type INodePropertyOptions,
NodeConnectionType,
type INodeProperties,
type INodeType,
@ -33,10 +35,10 @@ const descriptionV2: INodeTypeDescription = {
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: [2, 2.1],
description: 'Runs the flow when an n8n generated webform is submitted',
version: [2, 2.1, 2.2],
description: 'Generate webforms in n8n and pass their responses to the workflow',
defaults: {
name: 'n8n Form Trigger',
name: 'On form submission',
},
inputs: [],
@ -47,7 +49,7 @@ const descriptionV2: INodeTypeDescription = {
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: '={{$parameter["path"]}}',
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideUrl: true,
isForm: true,
},
@ -57,7 +59,7 @@ const descriptionV2: INodeTypeDescription = {
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: '={{$parameter["path"]}}',
path: '={{ $parameter["path"] || $parameter["options"]?.path || $webhookId }}',
ndvHideMethod: true,
isForm: true,
},
@ -94,11 +96,18 @@ const descriptionV2: INodeTypeDescription = {
],
default: 'none',
},
webhookPath,
{ ...webhookPath, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } },
formTitle,
formDescription,
formFields,
formRespondMode,
{ ...formRespondMode, displayOptions: { show: { '@version': [{ _cnd: { lte: 2.1 } }] } } },
{
...formRespondMode,
options: (formRespondMode.options as INodePropertyOptions[])?.filter(
(option) => option.value !== 'responseNode',
),
displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } },
},
{
displayName:
"In the 'Respond to Webhook' node, select 'Respond With JSON' and set the <strong>formSubmittedText</strong> key to display a custom response in the form, or the <strong>redirectURL</strong> key to redirect users to a URL",
@ -109,6 +118,13 @@ const descriptionV2: INodeTypeDescription = {
},
default: '',
},
// notice would be shown if no Form node was connected to trigger
{
displayName: 'Build multi-step forms by adding a form page later in your workflow',
name: ADD_FORM_NOTICE,
type: 'notice',
default: '',
},
{
displayName: 'Options',
name: 'options',
@ -117,6 +133,18 @@ const descriptionV2: INodeTypeDescription = {
default: {},
options: [
appendAttributionToForm,
{
displayName: 'Button Label',
description: 'The label of the submit button in the form',
name: 'buttonLabel',
type: 'string',
default: 'Submit',
},
{
...webhookPath,
required: false,
displayOptions: { show: { '@version': [{ _cnd: { gte: 2.2 } }] } },
},
{
...respondWithOptions,
displayOptions: {
@ -135,6 +163,7 @@ const descriptionV2: INodeTypeDescription = {
{
...useWorkflowTimezone,
default: false,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [2],
@ -144,6 +173,7 @@ const descriptionV2: INodeTypeDescription = {
{
...useWorkflowTimezone,
default: true,
description: "Whether to use the workflow timezone in 'submittedAt' field or UTC",
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],

View file

@ -503,6 +503,7 @@
"dist/nodes/Filter/Filter.node.js",
"dist/nodes/Flow/Flow.node.js",
"dist/nodes/Flow/FlowTrigger.node.js",
"dist/nodes/Form/Form.node.js",
"dist/nodes/Form/FormTrigger.node.js",
"dist/nodes/FormIo/FormIoTrigger.node.js",
"dist/nodes/Formstack/FormstackTrigger.node.js",

View file

@ -38,6 +38,7 @@ export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem';
export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge';
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
export const FORM_NODE_TYPE = 'n8n-nodes-base.form';
export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
@ -56,6 +57,8 @@ export const SCRIPTING_NODE_TYPES = [
AI_TRANSFORM_NODE_TYPE,
];
export const ADD_FORM_NOTICE = 'addFormPage';
/**
* Nodes whose parameter values may refer to other nodes without expressions.
* Their content may need to be updated when the referenced node is renamed.

View file

@ -1111,6 +1111,7 @@ export interface IWebhookFunctions extends FunctionsBaseWithRequiredKeys<'getMod
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object;
getNodeWebhookUrl: (name: string) => string | undefined;
evaluateExpression(expression: string, itemIndex?: number): NodeParameterValueType;
getParamsData(): object;
getQueryData(): object;
getRequestObject(): express.Request;
@ -2026,7 +2027,7 @@ export interface IWebhookResponseData {
}
export type WebhookResponseData = 'allEntries' | 'firstEntryJson' | 'firstEntryBinary' | 'noData';
export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode';
export type WebhookResponseMode = 'onReceived' | 'lastNode' | 'responseNode' | 'formPage';
export interface INodeTypes {
getByName(nodeType: string): INodeType | IVersionedNodeType;
@ -2584,6 +2585,18 @@ export interface ResourceMapperField {
readOnly?: boolean;
}
export type FormFieldsParameter = Array<{
fieldLabel: string;
fieldType?: string;
requiredField?: boolean;
fieldOptions?: { values: Array<{ option: string }> };
multiselect?: boolean;
multipleFiles?: boolean;
acceptFileTypes?: string;
formatDate?: string;
placeholder?: string;
}>;
export type FieldTypeMap = {
// eslint-disable-next-line id-denylist
boolean: boolean;
@ -2599,6 +2612,7 @@ export type FieldTypeMap = {
options: any;
url: string;
jwt: string;
'form-fields': FormFieldsParameter;
};
export type FieldType = keyof FieldTypeMap;

View file

@ -2,7 +2,12 @@ import isObject from 'lodash/isObject';
import { DateTime } from 'luxon';
import { ApplicationError } from './errors';
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
import type {
FieldType,
FormFieldsParameter,
INodePropertyOptions,
ValidationResult,
} from './Interfaces';
import { jsonParse } from './utils';
export const tryToParseNumber = (value: unknown): number => {
@ -148,6 +153,96 @@ export const tryToParseObject = (value: unknown): object => {
}
};
const ALLOWED_FORM_FIELDS_KEYS = [
'fieldLabel',
'fieldType',
'placeholder',
'fieldOptions',
'multiselect',
'multipleFiles',
'acceptFileTypes',
'formatDate',
'requiredField',
];
const ALLOWED_FIELD_TYPES = [
'date',
'dropdown',
'email',
'file',
'number',
'password',
'text',
'textarea',
];
export const tryToParseJsonToFormFields = (value: unknown): FormFieldsParameter => {
const fields: FormFieldsParameter = [];
try {
const rawFields = jsonParse<Array<{ [key: string]: unknown }>>(value as string, {
acceptJSObject: true,
});
for (const [index, field] of rawFields.entries()) {
for (const key of Object.keys(field)) {
if (!ALLOWED_FORM_FIELDS_KEYS.includes(key)) {
throw new ApplicationError(`Key '${key}' in field ${index} is not valid for form fields`);
}
if (
key !== 'fieldOptions' &&
!['string', 'number', 'boolean'].includes(typeof field[key])
) {
field[key] = String(field[key]);
} else if (typeof field[key] === 'string') {
field[key] = field[key].replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) {
throw new ApplicationError(
`Field type '${field[key] as string}' in field ${index} is not valid for form fields`,
);
}
if (key === 'fieldOptions') {
if (Array.isArray(field[key])) {
field[key] = { values: field[key] };
}
if (
typeof field[key] !== 'object' ||
!(field[key] as { [key: string]: unknown }).values
) {
throw new ApplicationError(
`Field dropdown in field ${index} does has no 'values' property that contain an array of options`,
);
}
for (const [optionIndex, option] of (
(field[key] as { [key: string]: unknown }).values as Array<{
[key: string]: { option: string };
}>
).entries()) {
if (Object.keys(option).length !== 1 || typeof option.option !== 'string') {
throw new ApplicationError(
`Field dropdown in field ${index} has an invalid option ${optionIndex}`,
);
}
}
}
}
fields.push(field as FormFieldsParameter[number]);
}
} catch (error) {
if (error instanceof ApplicationError) throw error;
throw new ApplicationError('Value is not valid JSON');
}
return fields;
};
export const getValueDescription = <T>(value: T): string => {
if (typeof value === 'object') {
if (value === null) return "'null'";
@ -325,6 +420,16 @@ export function validateFieldType(
};
}
}
case 'form-fields': {
try {
return { valid: true, newValue: tryToParseJsonToFormFields(value) };
} catch (e) {
return {
valid: false,
errorMessage: (e as Error).message,
};
}
}
default: {
return { valid: true, newValue: value };
}

View file

@ -1365,6 +1365,7 @@ export class WorkflowDataProxy {
$thisRunIndex: this.runIndex,
$nodeVersion: that.workflow.getNode(that.activeNodeName)?.typeVersion,
$nodeId: that.workflow.getNode(that.activeNodeName)?.id,
$webhookId: that.workflow.getNode(that.activeNodeName)?.webhookId,
};
return new Proxy(base, {

View file

@ -121,7 +121,7 @@ type JSONParseOptions<T> = { acceptJSObject?: boolean } & MutuallyExclusive<
*
* @param {string} jsonString - The JSON string to parse.
* @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both.
* @param {boolean} [options.parseJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object.
* @param {boolean} [options.acceptJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object.
* @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed.
* @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed.
* @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set.