mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
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:
parent
26f0d57f5f
commit
953a58f18b
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
19
packages/cli/src/WaitingForms.ts
Normal file
19
packages/cli/src/WaitingForms.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -7,6 +7,9 @@ const defaultSettings: IN8nUISettings = {
|
|||
allowedModules: {},
|
||||
communityNodesEnabled: false,
|
||||
defaultLocale: '',
|
||||
endpointForm: '',
|
||||
endpointFormTest: '',
|
||||
endpointFormWaiting: '',
|
||||
endpointWebhook: '',
|
||||
endpointWebhookTest: '',
|
||||
enterprise: {
|
||||
|
|
|
@ -29,6 +29,9 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
|||
allowedModules: {},
|
||||
communityNodesEnabled: false,
|
||||
defaultLocale: '',
|
||||
endpointForm: '',
|
||||
endpointFormTest: '',
|
||||
endpointFormWaiting: '',
|
||||
endpointWebhook: '',
|
||||
endpointWebhookTest: '',
|
||||
enterprise: {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -211,7 +211,7 @@ export default defineComponent({
|
|||
}
|
||||
.url-field-full-width {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin: 5px 10px;
|
||||
}
|
||||
|
||||
.url-selection {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>",
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
252
packages/nodes-base/nodes/Form/common.descriptions.ts
Normal file
252
packages/nodes-base/nodes/Form/common.descriptions.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
|
@ -26,6 +26,8 @@ export type FormTriggerData = {
|
|||
formTitle: string;
|
||||
formDescription?: string;
|
||||
formSubmittedText?: string;
|
||||
redirectUrl?: string;
|
||||
n8nWebsiteLink: string;
|
||||
formFields: FormTriggerInput[];
|
||||
useResponseData?: boolean;
|
||||
};
|
||||
|
|
|
@ -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)],
|
||||
};
|
||||
}
|
||||
|
|
98
packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts
Normal file
98
packages/nodes-base/nodes/Form/v1/FormTriggerV1.node.ts
Normal 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);
|
||||
}
|
||||
}
|
102
packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts
Normal file
102
packages/nodes-base/nodes/Form/v2/FormTriggerV2.node.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"]}}',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue