mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -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 { WorkflowPage, NDV } from '../pages';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { getVisibleSelect } from '../utils';
|
||||||
import { getPopper, getVisiblePopper, getVisibleSelect } from '../utils';
|
|
||||||
import { META_KEY } from '../constants';
|
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
|
@ -76,12 +74,25 @@ describe('n8n Form Trigger', () => {
|
||||||
)
|
)
|
||||||
.find('input')
|
.find('input')
|
||||||
.type('Option 2');
|
.type('Option 2');
|
||||||
//add optionall submitted message
|
|
||||||
cy.get('.param-options > .button').click();
|
//add optional submitted message
|
||||||
cy.get('.indent > .parameter-item')
|
cy.get('.param-options').click();
|
||||||
.find('input')
|
cy.contains('span', 'Text to Show')
|
||||||
|
.should('exist')
|
||||||
|
.parent()
|
||||||
|
.parent()
|
||||||
|
.next()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.children()
|
||||||
|
.first()
|
||||||
.clear()
|
.clear()
|
||||||
.type('Your test form was successfully submitted');
|
.type('Your test form was successfully submitted');
|
||||||
|
|
||||||
ndv.getters.backToCanvas().click();
|
ndv.getters.backToCanvas().click();
|
||||||
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
|
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { ExternalHooks } from '@/ExternalHooks';
|
||||||
import { send, sendErrorResponse } from '@/ResponseHelper';
|
import { send, sendErrorResponse } from '@/ResponseHelper';
|
||||||
import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
|
import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares';
|
||||||
import { TestWebhooks } from '@/TestWebhooks';
|
import { TestWebhooks } from '@/TestWebhooks';
|
||||||
|
import { WaitingForms } from '@/WaitingForms';
|
||||||
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
||||||
import { webhookRequestHandler } from '@/WebhookHelpers';
|
import { webhookRequestHandler } from '@/WebhookHelpers';
|
||||||
import { generateHostInstanceId } from './databases/utils/generators';
|
import { generateHostInstanceId } from './databases/utils/generators';
|
||||||
|
@ -39,6 +40,12 @@ export abstract class AbstractServer {
|
||||||
|
|
||||||
protected restEndpoint: string;
|
protected restEndpoint: string;
|
||||||
|
|
||||||
|
protected endpointForm: string;
|
||||||
|
|
||||||
|
protected endpointFormTest: string;
|
||||||
|
|
||||||
|
protected endpointFormWaiting: string;
|
||||||
|
|
||||||
protected endpointWebhook: string;
|
protected endpointWebhook: string;
|
||||||
|
|
||||||
protected endpointWebhookTest: string;
|
protected endpointWebhookTest: string;
|
||||||
|
@ -63,6 +70,11 @@ export abstract class AbstractServer {
|
||||||
this.sslCert = config.getEnv('ssl_cert');
|
this.sslCert = config.getEnv('ssl_cert');
|
||||||
|
|
||||||
this.restEndpoint = config.getEnv('endpoints.rest');
|
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.endpointWebhook = config.getEnv('endpoints.webhook');
|
||||||
this.endpointWebhookTest = config.getEnv('endpoints.webhookTest');
|
this.endpointWebhookTest = config.getEnv('endpoints.webhookTest');
|
||||||
this.endpointWebhookWaiting = config.getEnv('endpoints.webhookWaiting');
|
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
|
// Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests
|
||||||
if (this.webhooksEnabled) {
|
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
|
// Register a handler for active webhooks
|
||||||
this.app.all(
|
this.app.all(
|
||||||
`/${this.endpointWebhook}/:path(*)`,
|
`/${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
|
// Register a handler for waiting webhooks
|
||||||
|
@ -181,7 +204,8 @@ export abstract class AbstractServer {
|
||||||
if (this.testWebhooksEnabled) {
|
if (this.testWebhooksEnabled) {
|
||||||
const testWebhooks = Container.get(TestWebhooks);
|
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));
|
this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks));
|
||||||
|
|
||||||
// Removes a test webhook
|
// Removes a test webhook
|
||||||
|
|
|
@ -2,7 +2,11 @@
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { parse, stringify } from 'flatted';
|
import { parse, stringify } from 'flatted';
|
||||||
import picocolors from 'picocolors';
|
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 { Readable } from 'node:stream';
|
||||||
import type {
|
import type {
|
||||||
IExecutionDb,
|
IExecutionDb,
|
||||||
|
@ -67,6 +71,20 @@ export function sendErrorResponse(res: Response, error: Error) {
|
||||||
console.error(picocolors.red(error.httpStatusCode), error.message);
|
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;
|
httpStatusCode = error.httpStatusCode;
|
||||||
|
|
||||||
if (error.errorCode) {
|
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 * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import type {
|
import type {
|
||||||
|
IExecutionResponse,
|
||||||
IResponseCallbackData,
|
IResponseCallbackData,
|
||||||
IWebhookManager,
|
IWebhookManager,
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
|
@ -19,8 +20,10 @@ import { NotFoundError } from './errors/response-errors/not-found.error';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class WaitingWebhooks implements IWebhookManager {
|
export class WaitingWebhooks implements IWebhookManager {
|
||||||
|
protected includeForms = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
protected readonly logger: Logger,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
private readonly executionRepository: ExecutionRepository,
|
private readonly executionRepository: ExecutionRepository,
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
|
@ -28,12 +31,21 @@ export class WaitingWebhooks implements IWebhookManager {
|
||||||
|
|
||||||
// TODO: implement `getWebhookMethods` for CORS support
|
// 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(
|
async executeWebhook(
|
||||||
req: WaitingWebhookRequest,
|
req: WaitingWebhookRequest,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
): Promise<IResponseCallbackData> {
|
): Promise<IResponseCallbackData> {
|
||||||
const { path: executionId, suffix } = req.params;
|
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
|
// Reset request parameters
|
||||||
req.params = {} as WaitingWebhookRequest['params'];
|
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
|
// 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
|
// 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
|
// Remove waitTill information else the execution would stop
|
||||||
execution.data.waitTill = undefined;
|
execution.data.waitTill = undefined;
|
||||||
|
@ -97,7 +109,8 @@ export class WaitingWebhooks implements IWebhookManager {
|
||||||
(webhook) =>
|
(webhook) =>
|
||||||
webhook.httpMethod === req.method &&
|
webhook.httpMethod === req.method &&
|
||||||
webhook.path === (suffix ?? '') &&
|
webhook.path === (suffix ?? '') &&
|
||||||
webhook.webhookDescription.restartWebhook === true,
|
webhook.webhookDescription.restartWebhook === true &&
|
||||||
|
(webhook.webhookDescription.isForm || false) === this.includeForms,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (webhookData === undefined) {
|
if (webhookData === undefined) {
|
||||||
|
|
|
@ -37,7 +37,6 @@ import {
|
||||||
BINARY_ENCODING,
|
BINARY_ENCODING,
|
||||||
createDeferredPromise,
|
createDeferredPromise,
|
||||||
ErrorReporterProxy as ErrorReporter,
|
ErrorReporterProxy as ErrorReporter,
|
||||||
FORM_TRIGGER_PATH_IDENTIFIER,
|
|
||||||
NodeHelpers,
|
NodeHelpers,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -133,16 +132,7 @@ export const webhookRequestHandler =
|
||||||
try {
|
try {
|
||||||
response = await webhookManager.executeWebhook(req, res);
|
response = await webhookManager.executeWebhook(req, res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
return ResponseHelper.sendErrorResponse(res, error as Error);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't respond, if already responded
|
// Don't respond, if already responded
|
||||||
|
@ -560,10 +550,27 @@ export async function executeWebhook(
|
||||||
} else {
|
} else {
|
||||||
// TODO: This probably needs some more changes depending on the options on the
|
// TODO: This probably needs some more changes depending on the options on the
|
||||||
// Webhook Response node
|
// 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, {
|
responseCallback(null, {
|
||||||
data: response.body as IDataObject,
|
data,
|
||||||
headers: response.headers,
|
headers,
|
||||||
responseCode: response.statusCode,
|
responseCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -963,9 +963,11 @@ export async function getBase(
|
||||||
): Promise<IWorkflowExecuteAdditionalData> {
|
): Promise<IWorkflowExecuteAdditionalData> {
|
||||||
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
|
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
|
||||||
|
|
||||||
|
const formWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.formWaiting');
|
||||||
|
|
||||||
const webhookBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhook');
|
const webhookBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhook');
|
||||||
const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting');
|
|
||||||
const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest');
|
const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest');
|
||||||
|
const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting');
|
||||||
|
|
||||||
const variables = await WorkflowHelpers.getVariables();
|
const variables = await WorkflowHelpers.getVariables();
|
||||||
|
|
||||||
|
@ -974,6 +976,7 @@ export async function getBase(
|
||||||
executeWorkflow,
|
executeWorkflow,
|
||||||
restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'),
|
restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'),
|
||||||
instanceBaseUrl: urlBaseWebhook,
|
instanceBaseUrl: urlBaseWebhook,
|
||||||
|
formWaitingBaseUrl,
|
||||||
webhookBaseUrl,
|
webhookBaseUrl,
|
||||||
webhookWaitingBaseUrl,
|
webhookWaitingBaseUrl,
|
||||||
webhookTestBaseUrl,
|
webhookTestBaseUrl,
|
||||||
|
|
|
@ -668,6 +668,24 @@ export const schema = {
|
||||||
env: 'N8N_ENDPOINT_REST',
|
env: 'N8N_ENDPOINT_REST',
|
||||||
doc: 'Path for rest endpoint',
|
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: {
|
webhook: {
|
||||||
format: String,
|
format: String,
|
||||||
default: 'webhook',
|
default: 'webhook',
|
||||||
|
|
|
@ -81,6 +81,9 @@ export class FrontendService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.settings = {
|
this.settings = {
|
||||||
|
endpointForm: config.getEnv('endpoints.form'),
|
||||||
|
endpointFormTest: config.getEnv('endpoints.formTest'),
|
||||||
|
endpointFormWaiting: config.getEnv('endpoints.formWaiting'),
|
||||||
endpointWebhook: config.getEnv('endpoints.webhook'),
|
endpointWebhook: config.getEnv('endpoints.webhook'),
|
||||||
endpointWebhookTest: config.getEnv('endpoints.webhookTest'),
|
endpointWebhookTest: config.getEnv('endpoints.webhookTest'),
|
||||||
saveDataErrorExecution: config.getEnv('executions.saveDataOnError'),
|
saveDataErrorExecution: config.getEnv('executions.saveDataOnError'),
|
||||||
|
|
|
@ -385,6 +385,10 @@
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{#if redirectUrl}}
|
||||||
|
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
|
||||||
|
{{/if}}
|
||||||
|
<input id="useResponseData" style="display: none;" value={{useResponseData}} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
@ -483,19 +487,42 @@
|
||||||
document.querySelector('#submit-btn').disabled = true;
|
document.querySelector('#submit-btn').disabled = true;
|
||||||
document.querySelector('#submit-btn').style.cursor = 'not-allowed';
|
document.querySelector('#submit-btn').style.cursor = 'not-allowed';
|
||||||
document.querySelector('#submit-btn span').style.display = 'inline-block';
|
document.querySelector('#submit-btn span').style.display = 'inline-block';
|
||||||
fetch('#', {
|
fetch('', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
.then(async function (response) {
|
.then(async function (response) {
|
||||||
const data = await response.json();
|
const useResponseData = document.getElementById("useResponseData").value;
|
||||||
data.status = response.status;
|
|
||||||
return data;
|
if (useResponseData === "true") {
|
||||||
})
|
const text = await response.text();
|
||||||
.then(function (data) {
|
let json;
|
||||||
if (data.status === 200) {
|
|
||||||
form.style.display = 'none';
|
try{
|
||||||
document.querySelector('#submitted-form').style.display = 'block';
|
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 {
|
} else {
|
||||||
form.style.display = 'none';
|
form.style.display = 'none';
|
||||||
document.querySelector('#submitted-form').style.display = 'block';
|
document.querySelector('#submitted-form').style.display = 'block';
|
||||||
|
@ -503,6 +530,8 @@
|
||||||
document.querySelector('#submitted-content').textContent =
|
document.querySelector('#submitted-content').textContent =
|
||||||
'An error occurred in the workflow handling this form';
|
'An error occurred in the workflow handling this form';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { ExternalHooks } from '@/ExternalHooks';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { TestWebhooks } from '@/TestWebhooks';
|
import { TestWebhooks } from '@/TestWebhooks';
|
||||||
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
import { WaitingWebhooks } from '@/WaitingWebhooks';
|
||||||
|
import { WaitingForms } from '@/WaitingForms';
|
||||||
import type { IResponseCallbackData } from '@/Interfaces';
|
import type { IResponseCallbackData } from '@/Interfaces';
|
||||||
|
|
||||||
import { mockInstance } from '../shared/mocking';
|
import { mockInstance } from '../shared/mocking';
|
||||||
|
@ -24,6 +25,7 @@ describe('WebhookServer', () => {
|
||||||
const activeWorkflowRunner = mockInstance(ActiveWorkflowRunner);
|
const activeWorkflowRunner = mockInstance(ActiveWorkflowRunner);
|
||||||
const testWebhooks = mockInstance(TestWebhooks);
|
const testWebhooks = mockInstance(TestWebhooks);
|
||||||
mockInstance(WaitingWebhooks);
|
mockInstance(WaitingWebhooks);
|
||||||
|
mockInstance(WaitingForms);
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const server = new (class extends AbstractServer {
|
const server = new (class extends AbstractServer {
|
||||||
|
@ -36,8 +38,9 @@ describe('WebhookServer', () => {
|
||||||
const tests = [
|
const tests = [
|
||||||
['webhook', activeWorkflowRunner],
|
['webhook', activeWorkflowRunner],
|
||||||
['webhookTest', testWebhooks],
|
['webhookTest', testWebhooks],
|
||||||
// TODO: enable webhookWaiting after CORS support is added
|
// TODO: enable webhookWaiting & waitingForms after CORS support is added
|
||||||
// ['webhookWaiting', waitingWebhooks],
|
// ['webhookWaiting', waitingWebhooks],
|
||||||
|
// ['formWaiting', waitingForms],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [key, manager] of tests) {
|
for (const [key, manager] of tests) {
|
||||||
|
|
|
@ -1791,11 +1791,13 @@ export function getAdditionalKeys(
|
||||||
): IWorkflowDataProxyAdditionalKeys {
|
): IWorkflowDataProxyAdditionalKeys {
|
||||||
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
|
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
|
||||||
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
||||||
|
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
|
||||||
return {
|
return {
|
||||||
$execution: {
|
$execution: {
|
||||||
id: executionId,
|
id: executionId,
|
||||||
mode: mode === 'manual' ? 'test' : 'production',
|
mode: mode === 'manual' ? 'test' : 'production',
|
||||||
resumeUrl,
|
resumeUrl,
|
||||||
|
resumeFormUrl,
|
||||||
customData: runExecutionData
|
customData: runExecutionData
|
||||||
? {
|
? {
|
||||||
set(key: string, value: string): void {
|
set(key: string, value: string): void {
|
||||||
|
|
|
@ -1069,6 +1069,9 @@ export interface RootState {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
restEndpoint: string;
|
restEndpoint: string;
|
||||||
defaultLocale: string;
|
defaultLocale: string;
|
||||||
|
endpointForm: string;
|
||||||
|
endpointFormTest: string;
|
||||||
|
endpointFormWaiting: string;
|
||||||
endpointWebhook: string;
|
endpointWebhook: string;
|
||||||
endpointWebhookTest: string;
|
endpointWebhookTest: string;
|
||||||
pushConnectionActive: boolean;
|
pushConnectionActive: boolean;
|
||||||
|
@ -1097,6 +1100,9 @@ export interface IRootState {
|
||||||
activeCredentialType: string | null;
|
activeCredentialType: string | null;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
defaultLocale: string;
|
defaultLocale: string;
|
||||||
|
endpointForm: string;
|
||||||
|
endpointFormTest: string;
|
||||||
|
endpointFormWaiting: string;
|
||||||
endpointWebhook: string;
|
endpointWebhook: string;
|
||||||
endpointWebhookTest: string;
|
endpointWebhookTest: string;
|
||||||
executionId: string | null;
|
executionId: string | null;
|
||||||
|
|
|
@ -7,6 +7,9 @@ const defaultSettings: IN8nUISettings = {
|
||||||
allowedModules: {},
|
allowedModules: {},
|
||||||
communityNodesEnabled: false,
|
communityNodesEnabled: false,
|
||||||
defaultLocale: '',
|
defaultLocale: '',
|
||||||
|
endpointForm: '',
|
||||||
|
endpointFormTest: '',
|
||||||
|
endpointFormWaiting: '',
|
||||||
endpointWebhook: '',
|
endpointWebhook: '',
|
||||||
endpointWebhookTest: '',
|
endpointWebhookTest: '',
|
||||||
enterprise: {
|
enterprise: {
|
||||||
|
|
|
@ -29,6 +29,9 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = {
|
||||||
allowedModules: {},
|
allowedModules: {},
|
||||||
communityNodesEnabled: false,
|
communityNodesEnabled: false,
|
||||||
defaultLocale: '',
|
defaultLocale: '',
|
||||||
|
endpointForm: '',
|
||||||
|
endpointFormTest: '',
|
||||||
|
endpointFormWaiting: '',
|
||||||
endpointWebhook: '',
|
endpointWebhook: '',
|
||||||
endpointWebhookTest: '',
|
endpointWebhookTest: '',
|
||||||
enterprise: {
|
enterprise: {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro
|
||||||
export const executionCompletions = defineComponent({
|
export const executionCompletions = defineComponent({
|
||||||
methods: {
|
methods: {
|
||||||
/**
|
/**
|
||||||
* Complete `$execution.` to `.id .mode .resumeUrl`
|
* Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl`
|
||||||
*/
|
*/
|
||||||
executionCompletions(
|
executionCompletions(
|
||||||
context: CompletionContext,
|
context: CompletionContext,
|
||||||
|
@ -39,6 +39,10 @@ export const executionCompletions = defineComponent({
|
||||||
label: `${matcher}.resumeUrl`,
|
label: `${matcher}.resumeUrl`,
|
||||||
info: this.$locale.baseText('codeNodeEditor.completer.$execution.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")`,
|
label: `${matcher}.customData.set("key", "value")`,
|
||||||
info: buildLinkNode(
|
info: buildLinkNode(
|
||||||
|
|
|
@ -211,7 +211,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
.url-field-full-width {
|
.url-field-full-width {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
margin: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-selection {
|
.url-selection {
|
||||||
|
|
|
@ -499,6 +499,7 @@ export default defineComponent({
|
||||||
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
mode: 'test',
|
mode: 'test',
|
||||||
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
|
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
},
|
},
|
||||||
|
|
||||||
// deprecated
|
// deprecated
|
||||||
|
|
|
@ -187,6 +187,7 @@ export function resolveParameter(
|
||||||
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
mode: 'test',
|
mode: 'test',
|
||||||
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
|
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
||||||
},
|
},
|
||||||
$vars: useEnvironmentsStore().variablesAsObject,
|
$vars: useEnvironmentsStore().variablesAsObject,
|
||||||
|
|
||||||
|
@ -794,12 +795,16 @@ export const workflowHelpers = defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
getWebhookUrl(webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
|
getWebhookUrl(webhookData: IWebhookDescription, node: INode, showUrlFor?: string): string {
|
||||||
if (webhookData.restartWebhook === true) {
|
const { isForm, restartWebhook } = webhookData;
|
||||||
return '$execution.resumeUrl';
|
if (restartWebhook === true) {
|
||||||
|
return isForm ? '$execution.resumeFormUrl' : '$execution.resumeUrl';
|
||||||
}
|
}
|
||||||
let baseUrl = this.rootStore.getWebhookUrl;
|
|
||||||
|
let baseUrl;
|
||||||
if (showUrlFor === 'test') {
|
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;
|
const workflowId = this.workflowsStore.workflowId;
|
||||||
|
|
|
@ -2,7 +2,13 @@ import type { IExecutionPushResponse, IExecutionResponse, IStartRunData } from '
|
||||||
import { mapStores } from 'pinia';
|
import { mapStores } from 'pinia';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
import type { IRunData, IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow';
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
IRunData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskData,
|
||||||
|
IWorkflowBase,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeHelpers,
|
NodeHelpers,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
@ -14,11 +20,11 @@ import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||||
|
|
||||||
|
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
|
||||||
import { useTitleChange } from '@/composables/useTitleChange';
|
import { useTitleChange } from '@/composables/useTitleChange';
|
||||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { FORM_TRIGGER_NODE_TYPE } from '@/constants';
|
|
||||||
import { openPopUpWindow } from '@/utils/executionUtils';
|
import { openPopUpWindow } from '@/utils/executionUtils';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
|
||||||
|
@ -273,24 +279,54 @@ export const workflowRun = defineComponent({
|
||||||
|
|
||||||
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
|
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
|
||||||
|
|
||||||
if (runWorkflowApiResponse.waitingForWebhook) {
|
for (const node of workflowData.nodes) {
|
||||||
for (const node of workflowData.nodes) {
|
if (![FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE].includes(node.type)) {
|
||||||
if (node.type !== FORM_TRIGGER_NODE_TYPE) {
|
continue;
|
||||||
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 (
|
if (
|
||||||
options.destinationNode &&
|
node.type === WAIT_NODE_TYPE &&
|
||||||
options.destinationNode !== node.name &&
|
node.parameters.resume === 'form' &&
|
||||||
!directParentNodes.includes(node.name)
|
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) {
|
if (testUrl) openPopUpWindow(testUrl);
|
||||||
const testUrl = `${this.rootStore.getWebhookTestUrl}/${node.webhookId}/${FORM_TRIGGER_PATH_IDENTIFIER}`;
|
|
||||||
openPopUpWindow(testUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -366,6 +366,7 @@ export class I18nClass {
|
||||||
'$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
|
'$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
|
||||||
'$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'),
|
'$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'),
|
||||||
'$execution.resumeUrl': this.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
|
'$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.active': this.baseText('codeNodeEditor.completer.$workflow.active'),
|
||||||
'$workflow.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
|
'$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.id": "The ID of the current execution",
|
||||||
"codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'",
|
"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.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.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.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>",
|
"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'
|
? 'rest'
|
||||||
: window.REST_ENDPOINT,
|
: window.REST_ENDPOINT,
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
|
endpointForm: 'form',
|
||||||
|
endpointFormTest: 'form-test',
|
||||||
|
endpointFormWaiting: 'form-waiting',
|
||||||
endpointWebhook: 'webhook',
|
endpointWebhook: 'webhook',
|
||||||
endpointWebhookTest: 'webhook-test',
|
endpointWebhookTest: 'webhook-test',
|
||||||
pushConnectionActive: true,
|
pushConnectionActive: true,
|
||||||
|
@ -34,6 +37,18 @@ export const useRootStore = defineStore(STORES.ROOT, {
|
||||||
return this.baseUrl;
|
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 {
|
getWebhookUrl(): string {
|
||||||
return `${this.urlBaseWebhook}${this.endpointWebhook}`;
|
return `${this.urlBaseWebhook}${this.endpointWebhook}`;
|
||||||
},
|
},
|
||||||
|
@ -71,6 +86,15 @@ export const useRootStore = defineStore(STORES.ROOT, {
|
||||||
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
|
const url = urlBaseEditor.endsWith('/') ? urlBaseEditor : `${urlBaseEditor}/`;
|
||||||
this.urlBaseEditor = url;
|
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 {
|
setEndpointWebhook(endpointWebhook: string): void {
|
||||||
this.endpointWebhook = endpointWebhook;
|
this.endpointWebhook = endpointWebhook;
|
||||||
},
|
},
|
||||||
|
|
|
@ -263,6 +263,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
|
|
||||||
rootStore.setUrlBaseWebhook(settings.urlBaseWebhook);
|
rootStore.setUrlBaseWebhook(settings.urlBaseWebhook);
|
||||||
rootStore.setUrlBaseEditor(settings.urlBaseEditor);
|
rootStore.setUrlBaseEditor(settings.urlBaseEditor);
|
||||||
|
rootStore.setEndpointForm(settings.endpointForm);
|
||||||
|
rootStore.setEndpointFormTest(settings.endpointFormTest);
|
||||||
|
rootStore.setEndpointFormWaiting(settings.endpointFormWaiting);
|
||||||
rootStore.setEndpointWebhook(settings.endpointWebhook);
|
rootStore.setEndpointWebhook(settings.endpointWebhook);
|
||||||
rootStore.setEndpointWebhookTest(settings.endpointWebhookTest);
|
rootStore.setEndpointWebhookTest(settings.endpointWebhookTest);
|
||||||
rootStore.setTimezone(settings.timezone);
|
rootStore.setTimezone(settings.timezone);
|
||||||
|
|
|
@ -196,6 +196,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
||||||
return {};
|
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[] {
|
allNodes(): INodeUi[] {
|
||||||
return this.workflow.nodes;
|
return this.workflow.nodes;
|
||||||
},
|
},
|
||||||
|
|
|
@ -225,6 +225,7 @@ import {
|
||||||
STICKY_NODE_TYPE,
|
STICKY_NODE_TYPE,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
WEBHOOK_NODE_TYPE,
|
WEBHOOK_NODE_TYPE,
|
||||||
|
FORM_TRIGGER_NODE_TYPE,
|
||||||
TRIGGER_NODE_CREATOR_VIEW,
|
TRIGGER_NODE_CREATOR_VIEW,
|
||||||
EnterpriseEditionFeature,
|
EnterpriseEditionFeature,
|
||||||
REGULAR_NODE_CREATOR_VIEW,
|
REGULAR_NODE_CREATOR_VIEW,
|
||||||
|
@ -3946,7 +3947,10 @@ export default defineComponent({
|
||||||
node.parameters = nodeParameters !== null ? nodeParameters : {};
|
node.parameters = nodeParameters !== null ? nodeParameters : {};
|
||||||
|
|
||||||
// if it's a webhook and the path is empty set the UUID as the default path
|
// 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;
|
node.parameters.path = node.webhookId as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,290 +1,24 @@
|
||||||
import type {
|
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
|
||||||
IDataObject,
|
import { VersionedNodeType } from 'n8n-workflow';
|
||||||
INodeType,
|
import { FormTriggerV1 } from './v1/FormTriggerV1.node';
|
||||||
INodeTypeDescription,
|
import { FormTriggerV2 } from './v2/FormTriggerV2.node';
|
||||||
IWebhookResponseData,
|
|
||||||
IWebhookFunctions,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { FORM_TRIGGER_PATH_IDENTIFIER, jsonParse } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import type { FormField } from './interfaces';
|
export class FormTrigger extends VersionedNodeType {
|
||||||
import { prepareFormData } from './utils';
|
constructor() {
|
||||||
|
const baseDescription: INodeTypeBaseDescription = {
|
||||||
export class FormTrigger implements INodeType {
|
displayName: 'n8n Form Trigger',
|
||||||
description: INodeTypeDescription = {
|
name: 'formTrigger',
|
||||||
displayName: 'n8n Form Trigger',
|
icon: 'file:form.svg',
|
||||||
name: 'formTrigger',
|
group: ['trigger'],
|
||||||
icon: 'file:form.svg',
|
description: 'Runs the flow when an n8n generated webform is submitted',
|
||||||
group: ['trigger'],
|
defaultVersion: 2,
|
||||||
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)],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
formTitle: string;
|
||||||
formDescription?: string;
|
formDescription?: string;
|
||||||
formSubmittedText?: string;
|
formSubmittedText?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
n8nWebsiteLink: string;
|
n8nWebsiteLink: string;
|
||||||
formFields: FormTriggerInput[];
|
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';
|
import type { FormField, FormTriggerData, FormTriggerInput } from './interfaces';
|
||||||
|
|
||||||
export const prepareFormData = (
|
export const prepareFormData = (
|
||||||
formTitle: string,
|
formTitle: string,
|
||||||
formDescription: string,
|
formDescription: string,
|
||||||
formSubmittedText: string | undefined,
|
formSubmittedText: string | undefined,
|
||||||
|
redirectUrl: string | undefined,
|
||||||
formFields: FormField[],
|
formFields: FormField[],
|
||||||
testRun: boolean,
|
testRun: boolean,
|
||||||
instanceId?: string,
|
instanceId?: string,
|
||||||
|
useResponseData?: boolean,
|
||||||
) => {
|
) => {
|
||||||
const validForm = formFields.length > 0;
|
const validForm = formFields.length > 0;
|
||||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||||
|
@ -25,8 +27,16 @@ export const prepareFormData = (
|
||||||
formSubmittedText,
|
formSubmittedText,
|
||||||
n8nWebsiteLink,
|
n8nWebsiteLink,
|
||||||
formFields: [],
|
formFields: [],
|
||||||
|
useResponseData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (redirectUrl) {
|
||||||
|
if (!redirectUrl.includes('://')) {
|
||||||
|
redirectUrl = `http://${redirectUrl}`;
|
||||||
|
}
|
||||||
|
formData.redirectUrl = redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (!validForm) {
|
if (!validForm) {
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
@ -64,3 +74,83 @@ export const prepareFormData = (
|
||||||
|
|
||||||
return formData;
|
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',
|
name: 'No Data',
|
||||||
value: 'noData',
|
value: 'noData',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Redirect',
|
||||||
|
value: 'redirect',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Text',
|
name: 'Text',
|
||||||
value: 'text',
|
value: 'text',
|
||||||
|
@ -66,6 +70,21 @@ export class RespondToWebhook implements INodeType {
|
||||||
},
|
},
|
||||||
default: '',
|
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',
|
displayName: 'Response Body',
|
||||||
name: 'responseBody',
|
name: 'responseBody',
|
||||||
|
@ -202,6 +221,7 @@ export class RespondToWebhook implements INodeType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statusCode = (options.responseCode as number) || 200;
|
||||||
let responseBody: IN8nHttpResponse | Readable;
|
let responseBody: IN8nHttpResponse | Readable;
|
||||||
if (respondWith === 'json') {
|
if (respondWith === 'json') {
|
||||||
const responseBodyParameter = this.getNodeParameter('responseBody', 0) as string;
|
const responseBodyParameter = this.getNodeParameter('responseBody', 0) as string;
|
||||||
|
@ -250,6 +270,9 @@ export class RespondToWebhook implements INodeType {
|
||||||
if (!headers['content-type']) {
|
if (!headers['content-type']) {
|
||||||
headers['content-type'] = binaryData.mimeType;
|
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') {
|
} else if (respondWith !== 'noData') {
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
this.getNode(),
|
this.getNode(),
|
||||||
|
@ -260,7 +283,7 @@ export class RespondToWebhook implements INodeType {
|
||||||
const response: IN8nHttpFullResponse = {
|
const response: IN8nHttpFullResponse = {
|
||||||
body: responseBody,
|
body: responseBody,
|
||||||
headers,
|
headers,
|
||||||
statusCode: (options.responseCode as number) || 200,
|
statusCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sendResponse(response);
|
this.sendResponse(response);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
IDisplayOptions,
|
IDisplayOptions,
|
||||||
|
IWebhookFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { WAIT_TIME_UNLIMITED } from 'n8n-workflow';
|
import { WAIT_TIME_UNLIMITED } from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -18,14 +19,168 @@ import {
|
||||||
responseDataProperty,
|
responseDataProperty,
|
||||||
responseModeProperty,
|
responseModeProperty,
|
||||||
} from '../Webhook/description';
|
} 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';
|
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 = {
|
const displayOnWebhook: IDisplayOptions = {
|
||||||
show: {
|
show: {
|
||||||
resume: ['webhook'],
|
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 {
|
export class Wait extends Webhook {
|
||||||
authPropertyName = 'incomingAuthentication';
|
authPropertyName = 'incomingAuthentication';
|
||||||
|
|
||||||
|
@ -47,9 +202,28 @@ export class Wait extends Webhook {
|
||||||
{
|
{
|
||||||
...defaultWebhookDescription,
|
...defaultWebhookDescription,
|
||||||
responseData: '={{$parameter["responseData"]}}',
|
responseData: '={{$parameter["responseData"]}}',
|
||||||
path: '={{$parameter["options"]["webhookSuffix"] || ""}}',
|
path: webhookPath,
|
||||||
restartWebhook: true,
|
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: [
|
properties: [
|
||||||
{
|
{
|
||||||
|
@ -72,6 +246,11 @@ export class Wait extends Webhook {
|
||||||
value: 'webhook',
|
value: 'webhook',
|
||||||
description: 'Waits for a webhook call before continuing',
|
description: 'Waits for a webhook call before continuing',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'On Form Submited',
|
||||||
|
value: 'form',
|
||||||
|
description: 'Waits for a form submission before continuing',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default: 'timeInterval',
|
default: 'timeInterval',
|
||||||
description: 'Determines the waiting mode to use before the workflow continues',
|
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:
|
displayName:
|
||||||
|
@ -161,160 +340,67 @@ export class Wait extends Webhook {
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...httpMethodsProperty,
|
displayName:
|
||||||
displayOptions: displayOnWebhook,
|
'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>',
|
||||||
description: 'The HTTP method of the Webhook call',
|
name: 'formNotice',
|
||||||
},
|
type: 'notice',
|
||||||
{
|
displayOptions: displayOnFormSubmission,
|
||||||
...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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
default: '',
|
default: '',
|
||||||
description: 'Continue execution after the specified date and time',
|
|
||||||
},
|
},
|
||||||
|
...onFormSubmitProperties,
|
||||||
|
...onWebhookCallProperties,
|
||||||
|
...waitTimeProperties,
|
||||||
{
|
{
|
||||||
...optionsProperty,
|
...optionsProperty,
|
||||||
displayOptions: displayOnWebhook,
|
displayOptions: displayOnWebhook,
|
||||||
options: [
|
options: [...(optionsProperty.options as INodeProperties[]), webhookSuffix],
|
||||||
...(optionsProperty.options as INodeProperties[]),
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Webhook Suffix',
|
displayName: 'Options',
|
||||||
name: 'webhookSuffix',
|
name: 'options',
|
||||||
type: 'string',
|
type: 'collection',
|
||||||
default: '',
|
placeholder: 'Add Option',
|
||||||
placeholder: 'webhook',
|
default: {},
|
||||||
description:
|
displayOptions: {
|
||||||
'This suffix path will be appended to the restart URL. Helpful when using multiple wait nodes. Note: Does not support expressions.',
|
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[][]> {
|
async execute(context: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
const resume = context.getNodeParameter('resume', 0) as string;
|
const resume = context.getNodeParameter('resume', 0) as string;
|
||||||
|
|
||||||
if (resume === 'webhook') {
|
if (['webhook', 'form'].includes(resume)) {
|
||||||
return this.handleWebhookResume(context);
|
return this.configureAndPutToWait(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
let waitTill: Date;
|
let waitTill: Date;
|
||||||
|
@ -357,16 +443,17 @@ export class Wait extends Webhook {
|
||||||
return this.putToWait(context, waitTill);
|
return this.putToWait(context, waitTill);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleWebhookResume(context: IExecuteFunctions) {
|
private async configureAndPutToWait(context: IExecuteFunctions) {
|
||||||
let waitTill = new Date(WAIT_TIME_UNLIMITED);
|
let waitTill = new Date(WAIT_TIME_UNLIMITED);
|
||||||
|
|
||||||
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
|
const limitWaitTime = context.getNodeParameter('limitWaitTime', 0);
|
||||||
|
|
||||||
if (limitWaitTime === true) {
|
if (limitWaitTime === true) {
|
||||||
const limitType = context.getNodeParameter('limitType', 0);
|
const limitType = context.getNodeParameter('limitType', 0);
|
||||||
|
|
||||||
if (limitType === 'afterTimeInterval') {
|
if (limitType === 'afterTimeInterval') {
|
||||||
let waitAmount = context.getNodeParameter('resumeAmount', 0) as number;
|
let waitAmount = context.getNodeParameter('resumeAmount', 0) as number;
|
||||||
const resumeUnit = context.getNodeParameter('resumeUnit', 0);
|
const resumeUnit = context.getNodeParameter('resumeUnit', 0);
|
||||||
|
|
||||||
if (resumeUnit === 'minutes') {
|
if (resumeUnit === 'minutes') {
|
||||||
waitAmount *= 60;
|
waitAmount *= 60;
|
||||||
}
|
}
|
||||||
|
@ -378,7 +465,6 @@ export class Wait extends Webhook {
|
||||||
}
|
}
|
||||||
|
|
||||||
waitAmount *= 1000;
|
waitAmount *= 1000;
|
||||||
|
|
||||||
waitTill = new Date(new Date().getTime() + waitAmount);
|
waitTill = new Date(new Date().getTime() + waitAmount);
|
||||||
} else {
|
} else {
|
||||||
waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string);
|
waitTill = new Date(context.getNodeParameter('maxDateAndTime', 0) as string);
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { INodeProperties, INodeTypeDescription, IWebhookDescription } from
|
||||||
|
|
||||||
export const defaultWebhookDescription: IWebhookDescription = {
|
export const defaultWebhookDescription: IWebhookDescription = {
|
||||||
name: 'default',
|
name: 'default',
|
||||||
httpMethod: '={{$parameter["httpMethod"]}}',
|
httpMethod: '={{$parameter["httpMethod"] || "GET"}}',
|
||||||
isFullPath: true,
|
isFullPath: true,
|
||||||
responseCode: '={{$parameter["responseCode"]}}',
|
responseCode: '={{$parameter["responseCode"]}}',
|
||||||
responseMode: '={{$parameter["responseMode"]}}',
|
responseMode: '={{$parameter["responseMode"]}}',
|
||||||
|
|
|
@ -1674,6 +1674,8 @@ export interface IWebhookDescription {
|
||||||
responseMode?: WebhookResponseMode | string;
|
responseMode?: WebhookResponseMode | string;
|
||||||
responseData?: WebhookResponseData | string;
|
responseData?: WebhookResponseData | string;
|
||||||
restartWebhook?: boolean;
|
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
|
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
|
ndvHideMethod?: boolean; // If true the method will not be displayed in the editor
|
||||||
}
|
}
|
||||||
|
@ -1920,6 +1922,7 @@ export interface IWorkflowExecuteAdditionalData {
|
||||||
instanceBaseUrl: string;
|
instanceBaseUrl: string;
|
||||||
setExecutionStatus?: (status: ExecutionStatus) => void;
|
setExecutionStatus?: (status: ExecutionStatus) => void;
|
||||||
sendDataToUI?: (type: string, data: IDataObject | IDataObject[]) => void;
|
sendDataToUI?: (type: string, data: IDataObject | IDataObject[]) => void;
|
||||||
|
formWaitingBaseUrl: string;
|
||||||
webhookBaseUrl: string;
|
webhookBaseUrl: string;
|
||||||
webhookWaitingBaseUrl: string;
|
webhookWaitingBaseUrl: string;
|
||||||
webhookTestBaseUrl: string;
|
webhookTestBaseUrl: string;
|
||||||
|
@ -2209,7 +2212,8 @@ export type FieldType =
|
||||||
| 'time'
|
| 'time'
|
||||||
| 'array'
|
| 'array'
|
||||||
| 'object'
|
| 'object'
|
||||||
| 'options';
|
| 'options'
|
||||||
|
| 'url';
|
||||||
|
|
||||||
export type ValidationResult = {
|
export type ValidationResult = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
|
@ -2305,6 +2309,9 @@ export interface IPublicApiSettings {
|
||||||
export type ExpressionEvaluatorType = 'tmpl' | 'tournament';
|
export type ExpressionEvaluatorType = 'tmpl' | 'tournament';
|
||||||
|
|
||||||
export interface IN8nUISettings {
|
export interface IN8nUISettings {
|
||||||
|
endpointForm: string;
|
||||||
|
endpointFormTest: string;
|
||||||
|
endpointFormWaiting: string;
|
||||||
endpointWebhook: string;
|
endpointWebhook: string;
|
||||||
endpointWebhookTest: string;
|
endpointWebhookTest: string;
|
||||||
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;
|
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<{
|
type ValidateFieldTypeOptions = Partial<{
|
||||||
valueOptions: INodePropertyOptions[];
|
valueOptions: INodePropertyOptions[];
|
||||||
strict: boolean;
|
strict: boolean;
|
||||||
|
@ -225,6 +238,13 @@ export const validateFieldType = (
|
||||||
}
|
}
|
||||||
return { valid: true, newValue: value };
|
return { valid: true, newValue: value };
|
||||||
}
|
}
|
||||||
|
case 'url': {
|
||||||
|
try {
|
||||||
|
return { valid: true, newValue: tryToParseUrl(value) };
|
||||||
|
} catch (e) {
|
||||||
|
return { valid: false, errorMessage: defaultErrorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return { valid: true, newValue: value };
|
return { valid: true, newValue: value };
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue