feat(n8n Form Trigger Node): Improvements (#7571)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Michael Kret 2023-12-13 17:00:51 +02:00 committed by GitHub
parent 26f0d57f5f
commit 953a58f18b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1163 additions and 496 deletions

View file

@ -1,7 +1,5 @@
import { WorkflowPage, NDV } from '../pages';
import { v4 as uuid } from 'uuid';
import { getPopper, getVisiblePopper, getVisibleSelect } from '../utils';
import { META_KEY } from '../constants';
import { getVisibleSelect } from '../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -76,12 +74,25 @@ describe('n8n Form Trigger', () => {
)
.find('input')
.type('Option 2');
//add optionall submitted message
cy.get('.param-options > .button').click();
cy.get('.indent > .parameter-item')
.find('input')
//add optional submitted message
cy.get('.param-options').click();
cy.contains('span', 'Text to Show')
.should('exist')
.parent()
.parent()
.next()
.children()
.children()
.children()
.children()
.children()
.children()
.children()
.first()
.clear()
.type('Your test form was successfully submitted');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
});

View file

@ -14,6 +14,7 @@ import { ExternalHooks } from '@/ExternalHooks';
import { send, sendErrorResponse } from '@/ResponseHelper';
import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
import { TestWebhooks } from '@/TestWebhooks';
import { WaitingForms } from '@/WaitingForms';
import { WaitingWebhooks } from '@/WaitingWebhooks';
import { webhookRequestHandler } from '@/WebhookHelpers';
import { generateHostInstanceId } from './databases/utils/generators';
@ -39,6 +40,12 @@ export abstract class AbstractServer {
protected restEndpoint: string;
protected endpointForm: string;
protected endpointFormTest: string;
protected endpointFormWaiting: string;
protected endpointWebhook: string;
protected endpointWebhookTest: string;
@ -63,6 +70,11 @@ export abstract class AbstractServer {
this.sslCert = config.getEnv('ssl_cert');
this.restEndpoint = config.getEnv('endpoints.rest');
this.endpointForm = config.getEnv('endpoints.form');
this.endpointFormTest = config.getEnv('endpoints.formTest');
this.endpointFormWaiting = config.getEnv('endpoints.formWaiting');
this.endpointWebhook = config.getEnv('endpoints.webhook');
this.endpointWebhookTest = config.getEnv('endpoints.webhookTest');
this.endpointWebhookWaiting = config.getEnv('endpoints.webhookWaiting');
@ -165,10 +177,21 @@ export abstract class AbstractServer {
// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
if (this.webhooksEnabled) {
const activeWorkflowRunner = Container.get(ActiveWorkflowRunner);
// Register a handler for active forms
this.app.all(`/${this.endpointForm}/:path(*)`, webhookRequestHandler(activeWorkflowRunner));
// Register a handler for active webhooks
this.app.all(
`/${this.endpointWebhook}/:path(*)`,
webhookRequestHandler(Container.get(ActiveWorkflowRunner)),
webhookRequestHandler(activeWorkflowRunner),
);
// Register a handler for waiting forms
this.app.all(
`/${this.endpointFormWaiting}/:path/:suffix?`,
webhookRequestHandler(Container.get(WaitingForms)),
);
// Register a handler for waiting webhooks
@ -181,7 +204,8 @@ export abstract class AbstractServer {
if (this.testWebhooksEnabled) {
const testWebhooks = Container.get(TestWebhooks);
// Register a handler for test webhooks
// Register a handler
this.app.all(`/${this.endpointFormTest}/:path(*)`, webhookRequestHandler(testWebhooks));
this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks));
// Removes a test webhook

View file

@ -2,7 +2,11 @@
import type { Request, Response } from 'express';
import { parse, stringify } from 'flatted';
import picocolors from 'picocolors';
import { ErrorReporterProxy as ErrorReporter, NodeApiError } from 'n8n-workflow';
import {
ErrorReporterProxy as ErrorReporter,
FORM_TRIGGER_PATH_IDENTIFIER,
NodeApiError,
} from 'n8n-workflow';
import { Readable } from 'node:stream';
import type {
IExecutionDb,
@ -67,6 +71,20 @@ export function sendErrorResponse(res: Response, error: Error) {
console.error(picocolors.red(error.httpStatusCode), error.message);
}
//render custom 404 page for form triggers
const { originalUrl } = res.req;
if (error.errorCode === 404 && originalUrl) {
const basePath = originalUrl.split('/')[1];
const isLegacyFormTrigger = originalUrl.includes(FORM_TRIGGER_PATH_IDENTIFIER);
const isFormTrigger = basePath.includes('form');
if (isFormTrigger || isLegacyFormTrigger) {
const isTestWebhook = basePath.includes('test');
res.status(404);
return res.render('form-trigger-404', { isTestWebhook });
}
}
httpStatusCode = error.httpStatusCode;
if (error.errorCode) {

View file

@ -0,0 +1,19 @@
import { Service } from 'typedi';
import type { IExecutionResponse } from '@/Interfaces';
import { WaitingWebhooks } from '@/WaitingWebhooks';
@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

@ -5,6 +5,7 @@ import type express from 'express';
import * as WebhookHelpers from '@/WebhookHelpers';
import { NodeTypes } from '@/NodeTypes';
import type {
IExecutionResponse,
IResponseCallbackData,
IWebhookManager,
IWorkflowDb,
@ -19,8 +20,10 @@ import { NotFoundError } from './errors/response-errors/not-found.error';
@Service()
export class WaitingWebhooks implements IWebhookManager {
protected includeForms = false;
constructor(
private readonly logger: Logger,
protected readonly logger: Logger,
private readonly nodeTypes: NodeTypes,
private readonly executionRepository: ExecutionRepository,
private readonly ownershipService: OwnershipService,
@ -28,12 +31,21 @@ export class WaitingWebhooks implements IWebhookManager {
// TODO: implement `getWebhookMethods` for CORS support
protected logReceivedWebhook(method: string, executionId: string) {
this.logger.debug(`Received waiting-webhook "${method}" for execution "${executionId}"`);
}
protected disableNode(execution: IExecutionResponse, _method?: string) {
execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
): Promise<IResponseCallbackData> {
const { path: executionId, suffix } = req.params;
this.logger.debug(`Received waiting-webhook "${req.method}" for execution "${executionId}"`);
this.logReceivedWebhook(req.method, executionId);
// Reset request parameters
req.params = {} as WaitingWebhookRequest['params'];
@ -55,7 +67,7 @@ export class WaitingWebhooks implements IWebhookManager {
// 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
execution.data.executionData!.nodeExecutionStack[0].node.disabled = true;
this.disableNode(execution, req.method);
// Remove waitTill information else the execution would stop
execution.data.waitTill = undefined;
@ -97,7 +109,8 @@ export class WaitingWebhooks implements IWebhookManager {
(webhook) =>
webhook.httpMethod === req.method &&
webhook.path === (suffix ?? '') &&
webhook.webhookDescription.restartWebhook === true,
webhook.webhookDescription.restartWebhook === true &&
(webhook.webhookDescription.isForm || false) === this.includeForms,
);
if (webhookData === undefined) {

View file

@ -37,7 +37,6 @@ import {
BINARY_ENCODING,
createDeferredPromise,
ErrorReporterProxy as ErrorReporter,
FORM_TRIGGER_PATH_IDENTIFIER,
NodeHelpers,
} from 'n8n-workflow';
@ -133,16 +132,7 @@ export const webhookRequestHandler =
try {
response = await webhookManager.executeWebhook(req, res);
} catch (error) {
if (
error.errorCode === 404 &&
(error.message as string).includes(FORM_TRIGGER_PATH_IDENTIFIER)
) {
const isTestWebhook = req.originalUrl.includes('webhook-test');
res.status(404);
return res.render('form-trigger-404', { isTestWebhook });
} else {
return ResponseHelper.sendErrorResponse(res, error as Error);
}
return ResponseHelper.sendErrorResponse(res, error as Error);
}
// Don't respond, if already responded
@ -560,10 +550,27 @@ export async function executeWebhook(
} else {
// TODO: This probably needs some more changes depending on the options on the
// Webhook Response node
const headers = response.headers;
let responseCode = response.statusCode;
let data = response.body as IDataObject;
// for formTrigger node redirection has to be handled by sending redirectURL in response body
if (
nodeType.description.name === 'formTrigger' &&
headers.location &&
String(responseCode).startsWith('3')
) {
responseCode = 200;
data = {
redirectURL: headers.location,
};
headers.location = undefined;
}
responseCallback(null, {
data: response.body as IDataObject,
headers: response.headers,
responseCode: response.statusCode,
data,
headers,
responseCode,
});
}

View file

@ -963,9 +963,11 @@ export async function getBase(
): Promise<IWorkflowExecuteAdditionalData> {
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
const formWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.formWaiting');
const webhookBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhook');
const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting');
const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest');
const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting');
const variables = await WorkflowHelpers.getVariables();
@ -974,6 +976,7 @@ export async function getBase(
executeWorkflow,
restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'),
instanceBaseUrl: urlBaseWebhook,
formWaitingBaseUrl,
webhookBaseUrl,
webhookWaitingBaseUrl,
webhookTestBaseUrl,

View file

@ -668,6 +668,24 @@ export const schema = {
env: 'N8N_ENDPOINT_REST',
doc: 'Path for rest endpoint',
},
form: {
format: String,
default: 'form',
env: 'N8N_ENDPOINT_FORM',
doc: 'Path for form endpoint',
},
formTest: {
format: String,
default: 'form-test',
env: 'N8N_ENDPOINT_FORM_TEST',
doc: 'Path for test form endpoint',
},
formWaiting: {
format: String,
default: 'form-waiting',
env: 'N8N_ENDPOINT_FORM_WAIT',
doc: 'Path for waiting form endpoint',
},
webhook: {
format: String,
default: 'webhook',

View file

@ -81,6 +81,9 @@ export class FrontendService {
}
this.settings = {
endpointForm: config.getEnv('endpoints.form'),
endpointFormTest: config.getEnv('endpoints.formTest'),
endpointFormWaiting: config.getEnv('endpoints.formWaiting'),
endpointWebhook: config.getEnv('endpoints.webhook'),
endpointWebhookTest: config.getEnv('endpoints.webhookTest'),
saveDataErrorExecution: config.getEnv('executions.saveDataOnError'),

View file

@ -385,6 +385,10 @@
</svg>
</a>
</div>
{{#if redirectUrl}}
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
{{/if}}
<input id="useResponseData" style="display: none;" value={{useResponseData}} />
</section>
</div>
<script>
@ -483,19 +487,42 @@
document.querySelector('#submit-btn').disabled = true;
document.querySelector('#submit-btn').style.cursor = 'not-allowed';
document.querySelector('#submit-btn span').style.display = 'inline-block';
fetch('#', {
fetch('', {
method: 'POST',
body: formData,
})
.then(async function (response) {
const data = await response.json();
data.status = response.status;
return data;
})
.then(function (data) {
if (data.status === 200) {
form.style.display = 'none';
document.querySelector('#submitted-form').style.display = 'block';
const useResponseData = document.getElementById("useResponseData").value;
if (useResponseData === "true") {
const text = await response.text();
let json;
try{
json = JSON.parse(text);
} catch (e) {}
if (json?.redirectURL) {
const url = json.redirectURL.includes("://") ? json.redirectURL : "https://" + json.redirectURL;
window.location.replace(url);
} else if (json?.formSubmittedText) {
form.style.display = 'none';
document.querySelector('#submitted-form').style.display = 'block';
document.querySelector('#submitted-content').textContent = json.formSubmittedText;
} else {
document.body.innerHTML = text;
}
return;
}
if (response.status === 200) {
const redirectUrl = document.getElementById("redirectUrl");
if (redirectUrl) {
window.location.replace(redirectUrl.href);
} else {
form.style.display = 'none';
document.querySelector('#submitted-form').style.display = 'block';
}
} else {
form.style.display = 'none';
document.querySelector('#submitted-form').style.display = 'block';
@ -503,6 +530,8 @@
document.querySelector('#submitted-content').textContent =
'An error occurred in the workflow handling this form';
}
return;
})
.catch(function (error) {
console.error('Error:', error);

View file

@ -9,6 +9,7 @@ import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
import { TestWebhooks } from '@/TestWebhooks';
import { WaitingWebhooks } from '@/WaitingWebhooks';
import { WaitingForms } from '@/WaitingForms';
import type { IResponseCallbackData } from '@/Interfaces';
import { mockInstance } from '../shared/mocking';
@ -24,6 +25,7 @@ describe('WebhookServer', () => {
const activeWorkflowRunner = mockInstance(ActiveWorkflowRunner);
const testWebhooks = mockInstance(TestWebhooks);
mockInstance(WaitingWebhooks);
mockInstance(WaitingForms);
beforeAll(async () => {
const server = new (class extends AbstractServer {
@ -36,8 +38,9 @@ describe('WebhookServer', () => {
const tests = [
['webhook', activeWorkflowRunner],
['webhookTest', testWebhooks],
// TODO: enable webhookWaiting after CORS support is added
// TODO: enable webhookWaiting & waitingForms after CORS support is added
// ['webhookWaiting', waitingWebhooks],
// ['formWaiting', waitingForms],
] as const;
for (const [key, manager] of tests) {

View file

@ -1791,11 +1791,13 @@ export function getAdditionalKeys(
): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
return {
$execution: {
id: executionId,
mode: mode === 'manual' ? 'test' : 'production',
resumeUrl,
resumeFormUrl,
customData: runExecutionData
? {
set(key: string, value: string): void {

View file

@ -1069,6 +1069,9 @@ export interface RootState {
baseUrl: string;
restEndpoint: string;
defaultLocale: string;
endpointForm: string;
endpointFormTest: string;
endpointFormWaiting: string;
endpointWebhook: string;
endpointWebhookTest: string;
pushConnectionActive: boolean;
@ -1097,6 +1100,9 @@ export interface IRootState {
activeCredentialType: string | null;
baseUrl: string;
defaultLocale: string;
endpointForm: string;
endpointFormTest: string;
endpointFormWaiting: string;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;

View file

@ -7,6 +7,9 @@ const defaultSettings: IN8nUISettings = {
allowedModules: {},
communityNodesEnabled: false,
defaultLocale: '',
endpointForm: '',
endpointFormTest: '',
endpointFormWaiting: '',
endpointWebhook: '',
endpointWebhookTest: '',
enterprise: {

View file

@ -29,6 +29,9 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
allowedModules: {},
communityNodesEnabled: false,
defaultLocale: '',
endpointForm: '',
endpointFormTest: '',
endpointFormWaiting: '',
endpointWebhook: '',
endpointWebhookTest: '',
enterprise: {

View file

@ -5,7 +5,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro
export const executionCompletions = defineComponent({
methods: {
/**
* Complete `$execution.` to `.id .mode .resumeUrl`
* Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl`
*/
executionCompletions(
context: CompletionContext,
@ -39,6 +39,10 @@ export const executionCompletions = defineComponent({
label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
},
{
label: `${matcher}.resumeFormUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'),
},
{
label: `${matcher}.customData.set("key", "value")`,
info: buildLinkNode(

View file

@ -211,7 +211,7 @@ export default defineComponent({
}
.url-field-full-width {
display: inline-block;
width: 100%;
margin: 5px 10px;
}
.url-selection {

View file

@ -499,6 +499,7 @@ export default defineComponent({
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated

View file

@ -187,6 +187,7 @@ export function resolveParameter(
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
$vars: useEnvironmentsStore().variablesAsObject,
@ -794,12 +795,16 @@ export const workflowHelpers = defineComponent({
},
getWebhookUrl(webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
if (webhookData.restartWebhook === true) {
return '$execution.resumeUrl';
const { isForm, restartWebhook } = webhookData;
if (restartWebhook === true) {
return isForm ? '$execution.resumeFormUrl' : '$execution.resumeUrl';
}
let baseUrl = this.rootStore.getWebhookUrl;
let baseUrl;
if (showUrlFor === 'test') {
baseUrl = this.rootStore.getWebhookTestUrl;
baseUrl = isForm ? this.rootStore.getFormTestUrl : this.rootStore.getWebhookTestUrl;
} else {
baseUrl = isForm ? this.rootStore.getFormUrl : this.rootStore.getWebhookUrl;
}
const workflowId = this.workflowsStore.workflowId;

View file

@ -2,7 +2,13 @@ import type { IExecutionPushResponse, IExecutionResponse, IStartRunData } from '
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import type { IRunData, IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
import type {
IDataObject,
IRunData,
IRunExecutionData,
ITaskData,
IWorkflowBase,
} from 'n8n-workflow';
import {
NodeHelpers,
NodeConnectionType,
@ -14,11 +20,11 @@ import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { useTitleChange } from '@/composables/useTitleChange';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { FORM_TRIGGER_NODE_TYPE } from '@/constants';
import { openPopUpWindow } from '@/utils/executionUtils';
import { useExternalHooks } from '@/composables/useExternalHooks';
@ -273,24 +279,54 @@ export const workflowRun = defineComponent({
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
if (runWorkflowApiResponse.waitingForWebhook) {
for (const node of workflowData.nodes) {
if (node.type !== FORM_TRIGGER_NODE_TYPE) {
continue;
for (const node of workflowData.nodes) {
if (![FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE].includes(node.type)) {
continue;
}
if (
options.destinationNode &&
options.destinationNode !== node.name &&
!directParentNodes.includes(node.name)
) {
continue;
}
if (node.name === options.destinationNode || !node.disabled) {
let testUrl = '';
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion === 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${this.rootStore.getWebhookTestUrl}/${webhookPath}/${FORM_TRIGGER_PATH_IDENTIFIER}`;
}
if (node.type === FORM_TRIGGER_NODE_TYPE && node.typeVersion > 1) {
const webhookPath = (node.parameters.path as string) || node.webhookId;
testUrl = `${this.rootStore.getFormTestUrl}/${webhookPath}`;
}
if (
options.destinationNode &&
options.destinationNode !== node.name &&
!directParentNodes.includes(node.name)
node.type === WAIT_NODE_TYPE &&
node.parameters.resume === 'form' &&
runWorkflowApiResponse.executionId
) {
continue;
const workflowTriggerNodes = workflow.getTriggerNodes().map((node) => node.name);
const showForm =
options.destinationNode === node.name ||
directParentNodes.includes(node.name) ||
workflowTriggerNodes.some((triggerNode) =>
this.workflowsStore.isNodeInOutgoingNodeConnections(triggerNode, node.name),
);
if (!showForm) continue;
const { webhookSuffix } = (node.parameters.options || {}) as IDataObject;
const suffix = webhookSuffix ? `/${webhookSuffix}` : '';
testUrl = `${this.rootStore.getFormWaitingUrl}/${runWorkflowApiResponse.executionId}${suffix}`;
}
if (node.name === options.destinationNode || !node.disabled) {
const testUrl = `${this.rootStore.getWebhookTestUrl}/${node.webhookId}/${FORM_TRIGGER_PATH_IDENTIFIER}`;
openPopUpWindow(testUrl);
}
if (testUrl) openPopUpWindow(testUrl);
}
}

View file

@ -366,6 +366,7 @@ export class I18nClass {
'$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
'$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'),
'$execution.resumeUrl': this.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
'$execution.resumeFormUrl': this.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'),
'$workflow.active': this.baseText('codeNodeEditor.completer.$workflow.active'),
'$workflow.id': this.baseText('codeNodeEditor.completer.$workflow.id'),

View file

@ -168,6 +168,7 @@
"codeNodeEditor.completer.$execution.id": "The ID of the current execution",
"codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'",
"codeNodeEditor.completer.$execution.resumeUrl": "Used when using the 'wait' node to wait for a webhook. The webhook to call to resume execution",
"codeNodeEditor.completer.$execution.resumeFormUrl": "Used when using the 'wait' node to wait for a form submission. The url of form submitting which will resume execution",
"codeNodeEditor.completer.$execution.customData.set()": "Set custom data for the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$execution.customData.get()": "Get custom data set in the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$execution.customData.setAll()": "Set multiple custom data key/value pairs with an object for the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",

View file

@ -14,6 +14,9 @@ export const useRootStore = defineStore(STORES.ROOT, {
? 'rest'
: window.REST_ENDPOINT,
defaultLocale: 'en',
endpointForm: 'form',
endpointFormTest: 'form-test',
endpointFormWaiting: 'form-waiting',
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
pushConnectionActive: true,
@ -34,6 +37,18 @@ export const useRootStore = defineStore(STORES.ROOT, {
return this.baseUrl;
},
getFormUrl(): string {
return `${this.urlBaseWebhook}${this.endpointForm}`;
},
getFormTestUrl(): string {
return `${this.urlBaseEditor}${this.endpointFormTest}`;
},
getFormWaitingUrl(): string {
return `${this.baseUrl}${this.endpointFormWaiting}`;
},
getWebhookUrl(): string {
return `${this.urlBaseWebhook}${this.endpointWebhook}`;
},
@ -71,6 +86,15 @@ export const useRootStore = defineStore(STORES.ROOT, {
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
this.urlBaseEditor = url;
},
setEndpointForm(endpointForm: string): void {
this.endpointForm = endpointForm;
},
setEndpointFormTest(endpointFormTest: string): void {
this.endpointFormTest = endpointFormTest;
},
setEndpointFormWaiting(endpointFormWaiting: string): void {
this.endpointFormWaiting = endpointFormWaiting;
},
setEndpointWebhook(endpointWebhook: string): void {
this.endpointWebhook = endpointWebhook;
},

View file

@ -263,6 +263,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
rootStore.setUrlBaseWebhook(settings.urlBaseWebhook);
rootStore.setUrlBaseEditor(settings.urlBaseEditor);
rootStore.setEndpointForm(settings.endpointForm);
rootStore.setEndpointFormTest(settings.endpointFormTest);
rootStore.setEndpointFormWaiting(settings.endpointFormWaiting);
rootStore.setEndpointWebhook(settings.endpointWebhook);
rootStore.setEndpointWebhookTest(settings.endpointWebhookTest);
rootStore.setTimezone(settings.timezone);

View file

@ -196,6 +196,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
return {};
};
},
isNodeInOutgoingNodeConnections() {
return (firstNode: string, secondNode: string): boolean => {
const firstNodeConnections = this.outgoingConnectionsByNodeName(firstNode);
if (!firstNodeConnections || !firstNodeConnections.main || !firstNodeConnections.main[0])
return false;
const connections = firstNodeConnections.main[0];
if (connections.some((node) => node.node === secondNode)) return true;
return connections.some((node) =>
this.isNodeInOutgoingNodeConnections(node.node, secondNode),
);
};
},
allNodes(): INodeUi[] {
return this.workflow.nodes;
},

View file

@ -225,6 +225,7 @@ import {
STICKY_NODE_TYPE,
VIEWS,
WEBHOOK_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
TRIGGER_NODE_CREATOR_VIEW,
EnterpriseEditionFeature,
REGULAR_NODE_CREATOR_VIEW,
@ -3946,7 +3947,10 @@ export default defineComponent({
node.parameters = nodeParameters !== null ? nodeParameters : {};
// if it's a webhook and the path is empty set the UUID as the default path
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
if (
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(node.type) &&
node.parameters.path === ''
) {
node.parameters.path = node.webhookId as string;
}
}

View file

@ -1,290 +1,24 @@
import type {
IDataObject,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
IWebhookFunctions,
} from 'n8n-workflow';
import { FORM_TRIGGER_PATH_IDENTIFIER, jsonParse } from 'n8n-workflow';
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { FormTriggerV1 } from './v1/FormTriggerV1.node';
import { FormTriggerV2 } from './v2/FormTriggerV2.node';
import type { FormField } from './interfaces';
import { prepareFormData } from './utils';
export class FormTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: 1,
description: 'Runs the flow when an n8n generated webform is submitted',
defaults: {
name: 'n8n Form Trigger',
},
inputs: [],
outputs: ['main'],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
path: FORM_TRIGGER_PATH_IDENTIFIER,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
path: FORM_TRIGGER_PATH_IDENTIFIER,
ndvHideMethod: true,
},
],
eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: {
header: 'Pull in a test form submission',
executionsHelp: {
inactive:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Test Step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. <a data-key=\"activate\">Activate</a> the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
active:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Test Step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. <a data-key=\"activate\">Activate</a> the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
},
activationHint: {
active:
"This node will also trigger automatically on new form submissions (but those executions won't show up here).",
inactive:
'<a data-key="activate">Activate</a> this workflow to have it also run automatically for new form submissions created via the Production URL.',
},
},
properties: [
{
displayName: 'Form Title',
name: 'formTitle',
type: 'string',
default: '',
placeholder: 'e.g. Contact us',
required: true,
description: 'Shown at the top of the form',
},
{
displayName: 'Form Description',
name: 'formDescription',
type: 'string',
default: '',
placeholder: "e.g. We'll get back to you soon",
description:
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.',
},
{
displayName: 'Form Fields',
name: 'formFields',
placeholder: 'Add Form Field',
type: 'fixedCollection',
default: { values: [{ label: '', fieldType: 'text' }] },
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Field Label',
name: 'fieldLabel',
type: 'string',
default: '',
placeholder: 'e.g. What is your name?',
description: 'Label appears above the input field',
required: true,
},
{
displayName: 'Field Type',
name: 'fieldType',
type: 'options',
default: 'text',
description: 'The type of field to add to the form',
options: [
{
name: 'Date',
value: 'date',
},
{
name: 'Dropdown List',
value: 'dropdown',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Password',
value: 'password',
},
{
name: 'Text',
value: 'text',
},
{
name: 'Textarea',
value: 'textarea',
},
],
required: true,
},
{
displayName: 'Field Options',
name: 'fieldOptions',
placeholder: 'Add Field Option',
description: 'List of options that can be selected from the dropdown',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Option',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Multiple Choice',
name: 'multiselect',
type: 'boolean',
default: false,
description:
'Whether to allow the user to select multiple options from the dropdown list',
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
},
{
displayName: 'Required Field',
name: 'requiredField',
type: 'boolean',
default: false,
description:
'Whether to require the user to enter a value for this field before submitting the form',
},
],
},
],
},
{
displayName: 'Respond When',
name: 'responseMode',
type: 'options',
options: [
{
name: 'Form Is Submitted',
value: 'onReceived',
description: 'As soon as this node receives the form submission',
},
{
name: 'Workflow Finishes',
value: 'lastNode',
description: 'When the last node of the workflow is executed',
},
],
default: 'onReceived',
description: 'When to respond to the form submission',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Form Submitted Text',
name: 'formSubmittedText',
description: 'The text displayed to users after they filled the form',
type: 'string',
default: 'Your response has been recorded',
},
],
},
],
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const webhookName = this.getWebhookName();
const mode = this.getMode() === 'manual' ? 'test' : 'production';
const formFields = this.getNodeParameter('formFields.values', []) as FormField[];
//Show the form on GET request
if (webhookName === 'setup') {
const formTitle = this.getNodeParameter('formTitle', '') as string;
const formDescription = this.getNodeParameter('formDescription', '') as string;
const instanceId = this.getInstanceId();
const { formSubmittedText } = this.getNodeParameter('options', {}) as IDataObject;
const data = prepareFormData(
formTitle,
formDescription,
formSubmittedText as string,
formFields,
mode === 'test',
instanceId,
);
const res = this.getResponseObject();
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
const bodyData = (this.getBodyData().data as IDataObject) ?? {};
const returnData: IDataObject = {};
for (const [index, field] of formFields.entries()) {
const key = `field-${index}`;
let value = bodyData[key] ?? null;
if (value === null) returnData[field.fieldLabel] = null;
if (field.fieldType === 'number') {
value = Number(value);
}
if (field.fieldType === 'text') {
value = String(value).trim();
}
if (field.multiselect && typeof value === 'string') {
value = jsonParse(value);
}
returnData[field.fieldLabel] = value;
}
returnData.submittedAt = new Date().toISOString();
returnData.formMode = mode;
const webhookResponse: IDataObject = { status: 200 };
return {
webhookResponse,
workflowData: [this.helpers.returnJsonArray(returnData)],
export class FormTrigger extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
description: 'Runs the flow when an n8n generated webform is submitted',
defaultVersion: 2,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new FormTriggerV1(baseDescription),
2: new FormTriggerV2(baseDescription),
};
super(nodeVersions, baseDescription);
}
}

View file

@ -0,0 +1,252 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import type { INodeProperties } from 'n8n-workflow';
export const webhookPath: INodeProperties = {
displayName: 'Form Path',
name: 'path',
type: 'string',
default: '',
placeholder: 'webhook',
required: true,
description: "The final segment of the form's URL, both for test and production",
};
export const formTitle: INodeProperties = {
displayName: 'Form Title',
name: 'formTitle',
type: 'string',
default: '',
placeholder: 'e.g. Contact us',
required: true,
description: 'Shown at the top of the form',
};
export const formDescription: INodeProperties = {
displayName: 'Form Description',
name: 'formDescription',
type: 'string',
default: '',
placeholder: "e.g. We'll get back to you soon",
description:
'Shown underneath the Form Title. Can be used to prompt the user on how to complete the form.',
};
export const formFields: INodeProperties = {
displayName: 'Form Fields',
name: 'formFields',
placeholder: 'Add Form Field',
type: 'fixedCollection',
default: { values: [{ label: '', fieldType: 'text' }] },
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Field Label',
name: 'fieldLabel',
type: 'string',
default: '',
placeholder: 'e.g. What is your name?',
description: 'Label appears above the input field',
required: true,
},
{
displayName: 'Field Type',
name: 'fieldType',
type: 'options',
default: 'text',
description: 'The type of field to add to the form',
options: [
{
name: 'Date',
value: 'date',
},
{
name: 'Dropdown List',
value: 'dropdown',
},
{
name: 'Number',
value: 'number',
},
{
name: 'Password',
value: 'password',
},
{
name: 'Text',
value: 'text',
},
{
name: 'Textarea',
value: 'textarea',
},
],
required: true,
},
{
displayName: 'Field Options',
name: 'fieldOptions',
placeholder: 'Add Field Option',
description: 'List of options that can be selected from the dropdown',
type: 'fixedCollection',
default: { values: [{ option: '' }] },
required: true,
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
typeOptions: {
multipleValues: true,
sortable: true,
},
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Option',
name: 'option',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Multiple Choice',
name: 'multiselect',
type: 'boolean',
default: false,
description:
'Whether to allow the user to select multiple options from the dropdown list',
displayOptions: {
show: {
fieldType: ['dropdown'],
},
},
},
{
displayName: 'Required Field',
name: 'requiredField',
type: 'boolean',
default: false,
description:
'Whether to require the user to enter a value for this field before submitting the form',
},
],
},
],
};
export const formRespondMode: INodeProperties = {
displayName: 'Respond When',
name: 'responseMode',
type: 'options',
options: [
{
name: 'Form Is Submitted',
value: 'onReceived',
description: 'As soon as this node receives the form submission',
},
{
name: 'Workflow Finishes',
value: 'lastNode',
description: 'When the last node of the workflow is executed',
},
{
name: "Using 'Respond to Webhook' Node",
value: 'responseNode',
description: "When the 'Respond to Webhook' node is executed",
},
],
default: 'onReceived',
description: 'When to respond to the form submission',
};
export const formTriggerPanel = {
header: 'Pull in a test form submission',
executionsHelp: {
inactive:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Test Step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. <a data-key=\"activate\">Activate</a> the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
active:
"Form Trigger has two modes: test and production. <br /> <br /> <b>Use test mode while you build your workflow</b>. Click the 'Test Step' button, then fill out the test form that opens in a popup tab. The executions will show up in the editor.<br /> <br /> <b>Use production mode to run your workflow automatically</b>. <a data-key=\"activate\">Activate</a> the workflow, then make requests to the production URL. Then every time there's a form submission via the Production Form URL, the workflow will execute. These executions will show up in the executions list, but not in the editor.",
},
activationHint: {
active:
"This node will also trigger automatically on new form submissions (but those executions won't show up here).",
inactive:
'<a data-key="activate">Activate</a> this workflow to have it also run automatically for new form submissions created via the Production URL.',
},
};
export const respondWithOptions: INodeProperties = {
displayName: 'Form Response',
name: 'respondWithOptions',
type: 'fixedCollection',
placeholder: 'Add Option',
default: { values: { respondWith: 'text' } },
options: [
{
displayName: 'Values',
name: 'values',
values: [
{
displayName: 'Respond With',
name: 'respondWith',
type: 'options',
default: 'text',
options: [
{
name: 'Form Submitted Text',
value: 'text',
description: 'Show a response text to the user',
},
{
name: 'Redirect URL',
value: 'redirect',
description: 'Redirect the user to a URL',
},
],
},
{
displayName: 'Text to Show',
name: 'formSubmittedText',
description:
"The text displayed to users after they fill the form. Leave it empty if don't want to show any additional text.",
type: 'string',
default: 'Your response has been recorded',
displayOptions: {
show: {
respondWith: ['text'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'URL to Redirect to',
name: 'redirectUrl',
description:
'The URL to redirect users to after they fill the form. Must be a valid URL.',
type: 'string',
default: '',
validateType: 'url',
placeholder: 'e.g. http://www.n8n.io',
displayOptions: {
show: {
respondWith: ['redirect'],
},
},
},
],
},
],
};

View file

@ -26,6 +26,8 @@ export type FormTriggerData = {
formTitle: string;
formDescription?: string;
formSubmittedText?: string;
redirectUrl?: string;
n8nWebsiteLink: string;
formFields: FormTriggerInput[];
useResponseData?: boolean;
};

View file

@ -1,13 +1,15 @@
import type { IDataObject } from 'n8n-workflow';
import { jsonParse, type IDataObject, type IWebhookFunctions } from 'n8n-workflow';
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
export const prepareFormData = (
formTitle: string,
formDescription: string,
formSubmittedText: string | undefined,
redirectUrl: string | undefined,
formFields: FormField[],
testRun: boolean,
instanceId?: string,
useResponseData?: boolean,
) => {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@ -25,8 +27,16 @@ export const prepareFormData = (
formSubmittedText,
n8nWebsiteLink,
formFields: [],
useResponseData,
};
if (redirectUrl) {
if (!redirectUrl.includes('://')) {
redirectUrl = `http://${redirectUrl}`;
}
formData.redirectUrl = redirectUrl;
}
if (!validForm) {
return formData;
}
@ -64,3 +74,83 @@ export const prepareFormData = (
return formData;
};
export async function formWebhook(context: IWebhookFunctions) {
const mode = context.getMode() === 'manual' ? 'test' : 'production';
const formFields = context.getNodeParameter('formFields.values', []) as FormField[];
const method = context.getRequestObject().method;
//Show the form on GET request
if (method === 'GET') {
const formTitle = context.getNodeParameter('formTitle', '') as string;
const formDescription = context.getNodeParameter('formDescription', '') as string;
const instanceId = context.getInstanceId();
const responseMode = context.getNodeParameter('responseMode', '') as string;
const options = context.getNodeParameter('options', {}) as IDataObject;
let formSubmittedText;
let redirectUrl;
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;
}
const useResponseData = responseMode === 'responseNode';
const data = prepareFormData(
formTitle,
formDescription,
formSubmittedText,
redirectUrl,
formFields,
mode === 'test',
instanceId,
useResponseData,
);
const res = context.getResponseObject();
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
const bodyData = (context.getBodyData().data as IDataObject) ?? {};
const returnData: IDataObject = {};
for (const [index, field] of formFields.entries()) {
const key = `field-${index}`;
let value = bodyData[key] ?? null;
if (value === null) returnData[field.fieldLabel] = null;
if (field.fieldType === 'number') {
value = Number(value);
}
if (field.fieldType === 'text') {
value = String(value).trim();
}
if (field.multiselect && typeof value === 'string') {
value = jsonParse(value);
}
returnData[field.fieldLabel] = value;
}
returnData.submittedAt = new Date().toISOString();
returnData.formMode = mode;
const webhookResponse: IDataObject = { status: 200 };
return {
webhookResponse,
workflowData: [context.helpers.returnJsonArray(returnData)],
};
}

View file

@ -0,0 +1,98 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import {
FORM_TRIGGER_PATH_IDENTIFIER,
type INodeType,
type INodeTypeBaseDescription,
type INodeTypeDescription,
type IWebhookFunctions,
} from 'n8n-workflow';
import {
formDescription,
formFields,
formRespondMode,
formTitle,
formTriggerPanel,
webhookPath,
} from '../common.descriptions';
import { formWebhook } from '../utils';
const descriptionV1: INodeTypeDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: 1,
description: 'Runs the flow when an n8n generated webform is submitted',
defaults: {
name: 'n8n Form Trigger',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ['main'],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: `={{$parameter["path"]}}/${FORM_TRIGGER_PATH_IDENTIFIER}`,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: `={{$parameter["path"]}}/${FORM_TRIGGER_PATH_IDENTIFIER}`,
ndvHideMethod: true,
},
],
eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: formTriggerPanel,
properties: [
webhookPath,
formTitle,
formDescription,
formFields,
formRespondMode,
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
hide: {
responseMode: ['responseNode'],
},
},
options: [
{
displayName: 'Form Submitted Text',
name: 'formSubmittedText',
description: 'The text displayed to users after they filled the form',
type: 'string',
default: 'Your response has been recorded',
},
],
},
],
};
export class FormTriggerV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...descriptionV1,
};
}
async webhook(this: IWebhookFunctions) {
return formWebhook(this);
}
}

View file

@ -0,0 +1,102 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
import {
type INodeType,
type INodeTypeBaseDescription,
type INodeTypeDescription,
type IWebhookFunctions,
} from 'n8n-workflow';
import { formWebhook } from '../utils';
import {
formDescription,
formFields,
formRespondMode,
formTitle,
formTriggerPanel,
respondWithOptions,
webhookPath,
} from '../common.descriptions';
const descriptionV2: INodeTypeDescription = {
displayName: 'n8n Form Trigger',
name: 'formTrigger',
icon: 'file:form.svg',
group: ['trigger'],
version: 2,
description: 'Runs the flow when an n8n generated webform is submitted',
defaults: {
name: 'n8n Form Trigger',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ['main'],
webhooks: [
{
name: 'setup',
httpMethod: 'GET',
responseMode: 'onReceived',
isFullPath: true,
path: '={{$parameter["path"]}}',
ndvHideUrl: true,
isForm: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
isFullPath: true,
path: '={{$parameter["path"]}}',
ndvHideMethod: true,
isForm: true,
},
],
eventTriggerDescription: 'Waiting for you to submit the form',
activationMessage: 'You can now make calls to your production Form URL.',
triggerPanel: formTriggerPanel,
properties: [
webhookPath,
formTitle,
formDescription,
formFields,
formRespondMode,
{
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",
name: 'formNotice',
type: 'notice',
displayOptions: {
show: { responseMode: ['responseNode'] },
},
default: '',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
hide: {
responseMode: ['responseNode'],
},
},
options: [respondWithOptions],
},
],
};
export class FormTriggerV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...descriptionV2,
};
}
async webhook(this: IWebhookFunctions) {
return formWebhook(this);
}
}

View file

@ -46,6 +46,10 @@ export class RespondToWebhook implements INodeType {
name: 'No Data',
value: 'noData',
},
{
name: 'Redirect',
value: 'redirect',
},
{
name: 'Text',
value: 'text',
@ -66,6 +70,21 @@ export class RespondToWebhook implements INodeType {
},
default: '',
},
{
displayName: 'Redirect URL',
name: 'redirectURL',
type: 'string',
required: true,
displayOptions: {
show: {
respondWith: ['redirect'],
},
},
default: '',
placeholder: 'e.g. http://www.n8n.io',
description: 'The URL to redirect to',
validateType: 'url',
},
{
displayName: 'Response Body',
name: 'responseBody',
@ -202,6 +221,7 @@ export class RespondToWebhook implements INodeType {
}
}
let statusCode = (options.responseCode as number) || 200;
let responseBody: IN8nHttpResponse | Readable;
if (respondWith === 'json') {
const responseBodyParameter = this.getNodeParameter('responseBody', 0) as string;
@ -250,6 +270,9 @@ export class RespondToWebhook implements INodeType {
if (!headers['content-type']) {
headers['content-type'] = binaryData.mimeType;
}
} else if (respondWith == 'redirect') {
headers.location = this.getNodeParameter('redirectURL', 0) as string;
statusCode = (options.responseCode as number) ?? 307;
} else if (respondWith !== 'noData') {
throw new NodeOperationError(
this.getNode(),
@ -260,7 +283,7 @@ export class RespondToWebhook implements INodeType {
const response: IN8nHttpFullResponse = {
body: responseBody,
headers,
statusCode: (options.responseCode as number) || 200,
statusCode,
};
this.sendResponse(response);

View file

@ -4,6 +4,7 @@ import type {
INodeTypeDescription,
INodeProperties,
IDisplayOptions,
IWebhookFunctions,
} from 'n8n-workflow';
import { WAIT_TIME_UNLIMITED } from 'n8n-workflow';
@ -18,14 +19,168 @@ import {
responseDataProperty,
responseModeProperty,
} from '../Webhook/description';
import {
formDescription,
formFields,
respondWithOptions,
formRespondMode,
formTitle,
} from '../Form/common.descriptions';
import { formWebhook } from '../Form/utils';
import { updateDisplayOptions } from '../../utils/utilities';
import { Webhook } from '../Webhook/Webhook.node';
const waitTimeProperties: INodeProperties[] = [
{
displayName: 'Limit Wait Time',
name: 'limitWaitTime',
type: 'boolean',
default: false,
description:
'Whether the workflow will automatically resume execution after the specified limit type',
displayOptions: {
show: {
resume: ['webhook', 'form'],
},
},
},
{
displayName: 'Limit Type',
name: 'limitType',
type: 'options',
default: 'afterTimeInterval',
description:
'Sets the condition for the execution to resume. Can be a specified date or after some time.',
displayOptions: {
show: {
limitWaitTime: [true],
resume: ['webhook', 'form'],
},
},
options: [
{
name: 'After Time Interval',
description: 'Waits for a certain amount of time',
value: 'afterTimeInterval',
},
{
name: 'At Specified Time',
description: 'Waits until the set date and time to continue',
value: 'atSpecifiedTime',
},
],
},
{
displayName: 'Amount',
name: 'resumeAmount',
type: 'number',
displayOptions: {
show: {
limitType: ['afterTimeInterval'],
limitWaitTime: [true],
resume: ['webhook', 'form'],
},
},
typeOptions: {
minValue: 0,
numberPrecision: 2,
},
default: 1,
description: 'The time to wait',
},
{
displayName: 'Unit',
name: 'resumeUnit',
type: 'options',
displayOptions: {
show: {
limitType: ['afterTimeInterval'],
limitWaitTime: [true],
resume: ['webhook', 'form'],
},
},
options: [
{
name: 'Seconds',
value: 'seconds',
},
{
name: 'Minutes',
value: 'minutes',
},
{
name: 'Hours',
value: 'hours',
},
{
name: 'Days',
value: 'days',
},
],
default: 'hours',
description: 'Unit of the interval value',
},
{
displayName: 'Max Date and Time',
name: 'maxDateAndTime',
type: 'dateTime',
displayOptions: {
show: {
limitType: ['atSpecifiedTime'],
limitWaitTime: [true],
resume: ['webhook', 'form'],
},
},
default: '',
description: 'Continue execution after the specified date and time',
},
];
const webhookSuffix: INodeProperties = {
displayName: 'Webhook Suffix',
name: 'webhookSuffix',
type: 'string',
default: '',
placeholder: 'webhook',
noDataExpression: true,
description:
'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes.',
};
const displayOnWebhook: IDisplayOptions = {
show: {
resume: ['webhook'],
},
};
const displayOnFormSubmission = {
show: {
resume: ['form'],
},
};
const onFormSubmitProperties = updateDisplayOptions(displayOnFormSubmission, [
formTitle,
formDescription,
formFields,
formRespondMode,
]);
const onWebhookCallProperties = updateDisplayOptions(displayOnWebhook, [
{
...httpMethodsProperty,
description: 'The HTTP method of the Webhook call',
},
responseCodeProperty,
responseModeProperty,
responseDataProperty,
responseBinaryPropertyNameProperty,
]);
const webhookPath = '={{$parameter["options"]["webhookSuffix"] || ""}}';
export class Wait extends Webhook {
authPropertyName = 'incomingAuthentication';
@ -47,9 +202,28 @@ export class Wait extends Webhook {
{
...defaultWebhookDescription,
responseData: '={{$parameter["responseData"]}}',
path: '={{$parameter["options"]["webhookSuffix"] || ""}}',
path: webhookPath,
restartWebhook: true,
},
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: webhookPath,
restartWebhook: true,
isFullPath: true,
isForm: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: '={{$parameter["responseMode"]}}',
responseData: '={{$parameter["responseMode"] === "lastNode" ? "noData" : undefined}}',
path: webhookPath,
restartWebhook: true,
isFullPath: true,
isForm: true,
},
],
properties: [
{
@ -72,6 +246,11 @@ export class Wait extends Webhook {
value: 'webhook',
description: 'Waits for a webhook call before continuing',
},
{
name: 'On Form Submited',
value: 'form',
description: 'Waits for a form submission before continuing',
},
],
default: 'timeInterval',
description: 'Determines the waiting mode to use before the workflow continues',
@ -150,7 +329,7 @@ export class Wait extends Webhook {
},
// ----------------------------------
// resume:webhook
// resume:webhook & form
// ----------------------------------
{
displayName:
@ -161,160 +340,67 @@ export class Wait extends Webhook {
default: '',
},
{
...httpMethodsProperty,
displayOptions: displayOnWebhook,
description: 'The HTTP method of the Webhook call',
},
{
...responseCodeProperty,
displayOptions: displayOnWebhook,
},
{
...responseModeProperty,
displayOptions: displayOnWebhook,
},
{
...responseDataProperty,
displayOptions: {
show: {
...responseDataProperty.displayOptions?.show,
...displayOnWebhook.show,
},
},
},
{
...responseBinaryPropertyNameProperty,
displayOptions: {
show: {
...responseBinaryPropertyNameProperty.displayOptions?.show,
...displayOnWebhook.show,
},
},
},
{
displayName: 'Limit Wait Time',
name: 'limitWaitTime',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description:
'If no webhook call is received, the workflow will automatically resume execution after the specified limit type',
displayOptions: displayOnWebhook,
},
{
displayName: 'Limit Type',
name: 'limitType',
type: 'options',
default: 'afterTimeInterval',
description:
'Sets the condition for the execution to resume. Can be a specified date or after some time.',
displayOptions: {
show: {
limitWaitTime: [true],
...displayOnWebhook.show,
},
},
options: [
{
name: 'After Time Interval',
description: 'Waits for a certain amount of time',
value: 'afterTimeInterval',
},
{
name: 'At Specified Time',
description: 'Waits until the set date and time to continue',
value: 'atSpecifiedTime',
},
],
},
{
displayName: 'Amount',
name: 'resumeAmount',
type: 'number',
displayOptions: {
show: {
limitType: ['afterTimeInterval'],
limitWaitTime: [true],
...displayOnWebhook.show,
},
},
typeOptions: {
minValue: 0,
numberPrecision: 2,
},
default: 1,
description: 'The time to wait',
},
{
displayName: 'Unit',
name: 'resumeUnit',
type: 'options',
displayOptions: {
show: {
limitType: ['afterTimeInterval'],
limitWaitTime: [true],
...displayOnWebhook.show,
},
},
options: [
{
name: 'Seconds',
value: 'seconds',
},
{
name: 'Minutes',
value: 'minutes',
},
{
name: 'Hours',
value: 'hours',
},
{
name: 'Days',
value: 'days',
},
],
default: 'hours',
description: 'Unit of the interval value',
},
{
displayName: 'Max Date and Time',
name: 'maxDateAndTime',
type: 'dateTime',
displayOptions: {
show: {
limitType: ['atSpecifiedTime'],
limitWaitTime: [true],
...displayOnWebhook.show,
},
},
displayName:
'The form url will be generated at run time. It can be referenced with the <strong>$execution.resumeFormUrl</strong> variable. Send it somewhere before getting to this node. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.wait" target="_blank">More info</a>',
name: 'formNotice',
type: 'notice',
displayOptions: displayOnFormSubmission,
default: '',
description: 'Continue execution after the specified date and time',
},
...onFormSubmitProperties,
...onWebhookCallProperties,
...waitTimeProperties,
{
...optionsProperty,
displayOptions: displayOnWebhook,
options: [
...(optionsProperty.options as INodeProperties[]),
{
displayName: 'Webhook Suffix',
name: 'webhookSuffix',
type: 'string',
default: '',
placeholder: 'webhook',
description:
'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes. Note: Does not support expressions.',
options: [...(optionsProperty.options as INodeProperties[]), webhookSuffix],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resume: ['form'],
},
],
hide: {
responseMode: ['responseNode'],
},
},
options: [respondWithOptions, webhookSuffix],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
displayOptions: {
show: {
resume: ['form'],
},
hide: {
responseMode: ['onReceived', 'lastNode'],
},
},
options: [webhookSuffix],
},
],
};
async webhook(context: IWebhookFunctions) {
const resume = context.getNodeParameter('resume', 0) as string;
if (resume === 'form') return formWebhook(context);
return super.webhook(context);
}
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const resume = context.getNodeParameter('resume', 0) as string;
if (resume === 'webhook') {
return this.handleWebhookResume(context);
if (['webhook', 'form'].includes(resume)) {
return this.configureAndPutToWait(context);
}
let waitTill: Date;
@ -357,16 +443,17 @@ export class Wait extends Webhook {
return this.putToWait(context, waitTill);
}
private async handleWebhookResume(context: IExecuteFunctions) {
private async configureAndPutToWait(context: IExecuteFunctions) {
let waitTill = new Date(WAIT_TIME_UNLIMITED);
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
if (limitWaitTime === true) {
const limitType = context.getNodeParameter('limitType', 0);
if (limitType === 'afterTimeInterval') {
let waitAmount = context.getNodeParameter('resumeAmount', 0) as number;
const resumeUnit = context.getNodeParameter('resumeUnit', 0);
if (resumeUnit === 'minutes') {
waitAmount *= 60;
}
@ -378,7 +465,6 @@ export class Wait extends Webhook {
}
waitAmount *= 1000;
waitTill = new Date(new Date().getTime() + waitAmount);
} else {
waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string);

View file

@ -2,7 +2,7 @@ import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from
export const defaultWebhookDescription: IWebhookDescription = {
name: 'default',
httpMethod: '={{$parameter["httpMethod"]}}',
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
isFullPath: true,
responseCode: '={{$parameter["responseCode"]}}',
responseMode: '={{$parameter["responseMode"]}}',

View file

@ -1674,6 +1674,8 @@ export interface IWebhookDescription {
responseMode?: WebhookResponseMode | string;
responseData?: WebhookResponseData | string;
restartWebhook?: boolean;
isForm?: boolean;
hasLifecycleMethods?: boolean; // set automatically by generate-ui-types
ndvHideUrl?: boolean; // If true the webhook will not be displayed in the editor
ndvHideMethod?: boolean; // If true the method will not be displayed in the editor
}
@ -1920,6 +1922,7 @@ export interface IWorkflowExecuteAdditionalData {
instanceBaseUrl: string;
setExecutionStatus?: (status: ExecutionStatus) => void;
sendDataToUI?: (type: string, data: IDataObject | IDataObject[]) => void;
formWaitingBaseUrl: string;
webhookBaseUrl: string;
webhookWaitingBaseUrl: string;
webhookTestBaseUrl: string;
@ -2209,7 +2212,8 @@ export type FieldType =
| 'time'
| 'array'
| 'object'
| 'options';
| 'options'
| 'url';
export type ValidationResult = {
valid: boolean;
@ -2305,6 +2309,9 @@ export interface IPublicApiSettings {
export type ExpressionEvaluatorType = 'tmpl' | 'tournament';
export interface IN8nUISettings {
endpointForm: string;
endpointFormTest: string;
endpointFormWaiting: string;
endpointWebhook: string;
endpointWebhookTest: string;
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;

View file

@ -122,6 +122,19 @@ export const tryToParseObject = (value: unknown): object => {
}
};
export const tryToParseUrl = (value: unknown): string => {
if (typeof value === 'string' && !value.includes('://')) {
value = `http://${value}`;
}
const urlPattern = /^(https?|ftp|file):\/\/\S+|www\.\S+/;
if (!urlPattern.test(String(value))) {
throw new ApplicationError(`The value "${String(value)}" is not a valid url.`, {
extra: { value },
});
}
return String(value);
};
type ValidateFieldTypeOptions = Partial<{
valueOptions: INodePropertyOptions[];
strict: boolean;
@ -225,6 +238,13 @@ export const validateFieldType = (
}
return { valid: true, newValue: value };
}
case 'url': {
try {
return { valid: true, newValue: tryToParseUrl(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
default: {
return { valid: true, newValue: value };
}