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

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

View File

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