feat: Send and wait operation - freeText and customForm response types (#12106)

This commit is contained in:
Michael Kret
2024-12-16 17:30:11 +02:00
committed by GitHub
parent 39462abe1f
commit e98c7f160b
13 changed files with 476 additions and 59 deletions

View File

@@ -2,6 +2,7 @@ import type {
FormFieldsParameter,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
INodeTypeDescription,
IWebhookFunctions,
NodeTypeAndVersion,
@@ -22,6 +23,45 @@ import { formDescription, formFields, formTitle } from '../Form/common.descripti
import { prepareFormReturnItem, renderForm, resolveRawData } from '../Form/utils';
import { type CompletionPageConfig } from './interfaces';
export const formFieldsProperties: INodeProperties[] = [
{
displayName: 'Define Form',
name: 'defineForm',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Using Fields Below',
value: 'fields',
},
{
name: 'Using JSON',
value: 'json',
},
],
default: 'fields',
},
{
displayName: 'Form Fields',
name: 'jsonOutput',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
validateType: 'form-fields',
ignoreValidationDuringExecution: true,
hint: '<a href="hhttps://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
displayOptions: {
show: {
defineForm: ['json'],
},
},
},
{ ...formFields, displayOptions: { show: { defineForm: ['fields'] } } },
];
const pageProperties = updateDisplayOptions(
{
show: {
@@ -29,42 +69,7 @@ const pageProperties = updateDisplayOptions(
},
},
[
{
displayName: 'Define Form',
name: 'defineForm',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Using Fields Below',
value: 'fields',
},
{
name: 'Using JSON',
value: 'json',
},
],
default: 'fields',
},
{
displayName: 'Form Fields',
name: 'jsonOutput',
type: 'json',
typeOptions: {
rows: 5,
},
default:
'[\n {\n "fieldLabel":"Name",\n "placeholder":"enter you name",\n "requiredField":true\n },\n {\n "fieldLabel":"Age",\n "fieldType":"number",\n "placeholder":"enter your age"\n },\n {\n "fieldLabel":"Email",\n "fieldType":"email",\n "requiredField":true\n }\n]',
validateType: 'form-fields',
ignoreValidationDuringExecution: true,
hint: '<a href="hhttps://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.form/" target="_blank">See docs</a> for field syntax',
displayOptions: {
show: {
defineForm: ['json'],
},
},
},
{ ...formFields, displayOptions: { show: { defineForm: ['fields'] } } },
...formFieldsProperties,
{
displayName: 'Options',
name: 'options',

View File

@@ -22,6 +22,7 @@ export type FormTriggerData = {
validForm: boolean;
formTitle: string;
formDescription?: string;
formSubmittedHeader?: string;
formSubmittedText?: string;
redirectUrl?: string;
n8nWebsiteLink: string;

View File

@@ -28,6 +28,7 @@ import { getResolvables } from '../../utils/utilities';
export function prepareFormData({
formTitle,
formDescription,
formSubmittedHeader,
formSubmittedText,
redirectUrl,
formFields,
@@ -49,6 +50,7 @@ export function prepareFormData({
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
formSubmittedHeader?: string;
}) {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@@ -63,6 +65,7 @@ export function prepareFormData({
validForm,
formTitle,
formDescription,
formSubmittedHeader,
formSubmittedText,
n8nWebsiteLink,
formFields: [],

View File

@@ -52,5 +52,5 @@
}
]
},
"alias": ["email"]
"alias": ["email", "human", "form", "wait"]
}

View File

@@ -88,6 +88,15 @@ const versionDescription: INodeTypeDescription = {
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
],
properties: [
{

View File

@@ -59,9 +59,9 @@ export const messageOperations: INodeProperties[] = [
action: 'Send a message',
},
{
name: 'Send and Wait for Approval',
name: 'Send and Wait for Response',
value: SEND_AND_WAIT_OPERATION,
action: 'Send a message and wait for approval',
action: 'Send message and wait for response',
},
],
default: 'send',

View File

@@ -3,6 +3,7 @@
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Communication"],
"alias": ["human", "form", "wait"],
"resources": {
"credentialDocumentation": [
{

View File

@@ -33,9 +33,9 @@ export const messageOperations: INodeProperties[] = [
action: 'Send a message',
},
{
name: 'Send and Wait for Approval',
name: 'Send and Wait for Response',
value: SEND_AND_WAIT_OPERATION,
action: 'Send a message and wait for approval',
action: 'Send message and wait for response',
},
{
name: 'Update',

View File

@@ -89,6 +89,15 @@ export class SlackV2 implements INodeType {
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
],
properties: [
{

View File

@@ -103,7 +103,7 @@ export function createEmailBody(message: string, buttons: string, instanceId?: s
<tr>
<td
style="text-align: center; padding-top: 8px; font-family: Arial, sans-serif; font-size: 14px; color: #7e8186;">
<p>${message}</p>
<p style="white-space: pre-line;">${message}</p>
</td>
</tr>
<tr>

View File

@@ -6,7 +6,6 @@ import {
getSendAndWaitConfig,
createEmail,
sendAndWaitWebhook,
MESSAGE_PREFIX,
} from '../utils';
describe('Send and Wait utils tests', () => {
@@ -159,7 +158,7 @@ describe('Send and Wait utils tests', () => {
expect(email).toEqual({
to: 'test@example.com',
subject: `${MESSAGE_PREFIX}Test subject`,
subject: 'Test subject',
body: '',
htmlBody: expect.stringContaining('Test message'),
});
@@ -208,5 +207,162 @@ describe('Send and Wait utils tests', () => {
workflowData: [[{ json: { data: { approved: false } } }]],
});
});
it('should handle freeText GET webhook', async () => {
const mockRender = jest.fn();
mockWebhookFunctions.getRequestObject.mockReturnValue({
method: 'GET',
} as any);
mockWebhookFunctions.getResponseObject.mockReturnValue({
render: mockRender,
} as any);
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
responseType: 'freeText',
message: 'Test message',
options: {},
};
return params[parameterName];
});
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
expect(result).toEqual({
noWebhookResponse: true,
});
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
testRun: false,
validForm: true,
formTitle: '',
formDescription: 'Test message',
formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
formFields: [
{
id: 'field-0',
errorId: 'error-field-0',
label: 'Response',
inputRequired: 'form-required',
defaultValue: '',
isTextarea: true,
},
],
appendAttribution: true,
buttonLabel: 'Submit',
});
});
it('should handle freeText POST webhook', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({
method: 'POST',
} as any);
mockWebhookFunctions.getBodyData.mockReturnValue({
data: {
'field-0': 'test value',
},
} as any);
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
responseType: 'freeText',
};
return params[parameterName];
});
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
expect(result.workflowData).toEqual([[{ json: { data: { text: 'test value' } } }]]);
});
it('should handle customForm GET webhook', async () => {
const mockRender = jest.fn();
mockWebhookFunctions.getRequestObject.mockReturnValue({
method: 'GET',
} as any);
mockWebhookFunctions.getResponseObject.mockReturnValue({
render: mockRender,
} as any);
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
responseType: 'customForm',
message: 'Test message',
defineForm: 'fields',
'formFields.values': [{ label: 'Field 1', fieldType: 'text', requiredField: true }],
options: {
responseFormTitle: 'Test title',
responseFormDescription: 'Test description',
responseFormButtonLabel: 'Test button',
},
};
return params[parameterName];
});
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
expect(result).toEqual({
noWebhookResponse: true,
});
expect(mockRender).toHaveBeenCalledWith('form-trigger', {
testRun: false,
validForm: true,
formTitle: 'Test title',
formDescription: 'Test description',
formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now',
n8nWebsiteLink: 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger',
formFields: [
{
id: 'field-0',
errorId: 'error-field-0',
inputRequired: 'form-required',
defaultValue: '',
isInput: true,
type: 'text',
},
],
appendAttribution: true,
buttonLabel: 'Test button',
});
});
it('should handle customForm POST webhook', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({
method: 'POST',
} as any);
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
const params: { [key: string]: any } = {
responseType: 'customForm',
defineForm: 'fields',
'formFields.values': [
{
fieldLabel: 'test 1',
fieldType: 'text',
},
],
};
return params[parameterName];
});
mockWebhookFunctions.getBodyData.mockReturnValue({
data: {
'field-0': 'test value',
},
} as any);
const result = await sendAndWaitWebhook.call(mockWebhookFunctions);
expect(result.workflowData).toEqual([[{ json: { data: { 'test 1': 'test value' } } }]]);
});
});
});

View File

@@ -1,5 +1,16 @@
import { NodeOperationError, SEND_AND_WAIT_OPERATION, updateDisplayOptions } from 'n8n-workflow';
import type { INodeProperties, IExecuteFunctions, IWebhookFunctions } from 'n8n-workflow';
import {
NodeOperationError,
SEND_AND_WAIT_OPERATION,
tryToParseJsonToFormFields,
updateDisplayOptions,
} from 'n8n-workflow';
import type {
INodeProperties,
IExecuteFunctions,
IWebhookFunctions,
IDataObject,
FormFieldsParameter,
} from 'n8n-workflow';
import type { IEmail } from './interfaces';
import { escapeHtml } from '../utilities';
import {
@@ -8,6 +19,8 @@ import {
BUTTON_STYLE_SECONDARY,
createEmailBody,
} from './email-templates';
import { prepareFormData, prepareFormReturnItem, resolveRawData } from '../../nodes/Form/utils';
import { formFieldsProperties } from '../../nodes/Form/Form.node';
type SendAndWaitConfig = {
title: string;
@@ -16,7 +29,14 @@ type SendAndWaitConfig = {
options: Array<{ label: string; value: string; style: string }>;
};
export const MESSAGE_PREFIX = 'ACTION REQUIRED: ';
type FormResponseTypeOptions = {
messageButtonLabel?: string;
responseFormTitle?: string;
responseFormDescription?: string;
responseFormButtonLabel?: string;
};
const INPUT_FIELD_IDENTIFIER = 'field-0';
// Operation Properties ----------------------------------------------------------
export function getSendAndWaitProperties(
@@ -57,9 +77,32 @@ export function getSendAndWaitProperties(
default: '',
required: true,
typeOptions: {
rows: 5,
rows: 4,
},
},
{
displayName: 'Response Type',
name: 'responseType',
type: 'options',
default: 'approval',
options: [
{
name: 'Approval',
value: 'approval',
description: 'User can approve/disapprove from within the message',
},
{
name: 'Free Text',
value: 'freeText',
description: 'User can submit a response via a form',
},
{
name: 'Custom Form',
value: 'customForm',
description: 'User can submit a response via a custom form',
},
],
},
{
displayName: 'Approval Options',
name: 'approvalOptions',
@@ -134,15 +177,61 @@ export function getSendAndWaitProperties(
],
},
],
displayOptions: {
show: {
responseType: ['approval'],
},
},
},
...updateDisplayOptions(
{
show: {
responseType: ['customForm'],
},
},
formFieldsProperties,
),
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Message Button Label',
name: 'messageButtonLabel',
type: 'string',
default: 'Respond',
},
{
displayName: 'Response Form Title',
name: 'responseFormTitle',
description: 'Title of the form that the user can access to provide their response',
type: 'string',
default: '',
},
{
displayName: 'Response Form Description',
name: 'responseFormDescription',
description: 'Description of the form that the user can access to provide their response',
type: 'string',
default: '',
},
{
displayName: 'Response Form Button Label',
name: 'responseFormButtonLabel',
type: 'string',
default: 'Submit',
},
],
displayOptions: {
show: {
responseType: ['freeText', 'customForm'],
},
},
},
...additionalProperties,
{
displayName:
'Use the wait node for more complex approval flows. <a href="https://docs.n8n.io/nodes/n8n-nodes-base.wait" target="_blank">More info</a>',
name: 'useWaitNotice',
type: 'notice',
default: '',
},
];
return updateDisplayOptions(
@@ -157,7 +246,136 @@ export function getSendAndWaitProperties(
}
// Webhook Function --------------------------------------------------------------
const getFormResponseCustomizations = (context: IWebhookFunctions) => {
const message = context.getNodeParameter('message', '') as string;
const options = context.getNodeParameter('options', {}) as FormResponseTypeOptions;
let formTitle = '';
if (options.responseFormTitle) {
formTitle = options.responseFormTitle;
}
let formDescription = message;
if (options.responseFormDescription) {
formDescription = options.responseFormDescription;
}
formDescription = formDescription.replace(/\\n/g, '\n').replace(/<br>/g, '\n');
let buttonLabel = 'Submit';
if (options.responseFormButtonLabel) {
buttonLabel = options.responseFormButtonLabel;
}
return {
formTitle,
formDescription,
buttonLabel,
};
};
export async function sendAndWaitWebhook(this: IWebhookFunctions) {
const method = this.getRequestObject().method;
const res = this.getResponseObject();
const responseType = this.getNodeParameter('responseType', 'approval') as
| 'approval'
| 'freeText'
| 'customForm';
if (responseType === 'freeText') {
if (method === 'GET') {
const { formTitle, formDescription, buttonLabel } = getFormResponseCustomizations(this);
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now',
buttonLabel,
redirectUrl: undefined,
formFields: [
{
fieldLabel: 'Response',
fieldType: 'textarea',
requiredField: true,
},
],
testRun: false,
query: {},
});
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
if (method === 'POST') {
const data = this.getBodyData().data as IDataObject;
return {
webhookResponse: ACTION_RECORDED_PAGE,
workflowData: [[{ json: { data: { text: data[INPUT_FIELD_IDENTIFIER] } } }]],
};
}
}
if (responseType === 'customForm') {
const defineForm = this.getNodeParameter('defineForm', 'fields') as 'fields' | 'json';
let fields: FormFieldsParameter = [];
if (defineForm === 'json') {
try {
const jsonOutput = this.getNodeParameter('jsonOutput', '', {
rawExpressions: true,
}) as string;
fields = tryToParseJsonToFormFields(resolveRawData(this, jsonOutput));
} catch (error) {
throw new NodeOperationError(this.getNode(), error.message, {
description: error.message,
});
}
} else {
fields = this.getNodeParameter('formFields.values', []) as FormFieldsParameter;
}
if (method === 'GET') {
const { formTitle, formDescription, buttonLabel } = getFormResponseCustomizations(this);
const data = prepareFormData({
formTitle,
formDescription,
formSubmittedHeader: 'Got it, thanks',
formSubmittedText: 'This page can be closed now',
buttonLabel,
redirectUrl: undefined,
formFields: fields,
testRun: false,
query: {},
});
res.render('form-trigger', data);
return {
noWebhookResponse: true,
};
}
if (method === 'POST') {
const returnItem = await prepareFormReturnItem(this, fields, 'production', true);
const json = returnItem.json as IDataObject;
delete json.submittedAt;
delete json.formMode;
returnItem.json = { data: json };
return {
webhookResponse: ACTION_RECORDED_PAGE,
workflowData: [[returnItem]],
};
}
}
const query = this.getRequestObject().query as { approved: 'false' | 'true' };
const approved = query.approved === 'true';
return {
@@ -168,7 +386,9 @@ export async function sendAndWaitWebhook(this: IWebhookFunctions) {
// Send and Wait Config -----------------------------------------------------------
export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitConfig {
const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim());
const message = escapeHtml((context.getNodeParameter('message', 0, '') as string).trim())
.replace(/\\n/g, '\n')
.replace(/<br>/g, '\n');
const subject = escapeHtml(context.getNodeParameter('subject', 0, '') as string);
const resumeUrl = context.evaluateExpression('{{ $execution?.resumeUrl }}', 0) as string;
const nodeId = context.evaluateExpression('{{ $nodeId }}', 0) as string;
@@ -187,7 +407,16 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
options: [],
};
if (approvalOptions.approvalType === 'double') {
const responseType = context.getNodeParameter('responseType', 0, 'approval') as string;
if (responseType === 'freeText' || responseType === 'customForm') {
const label = context.getNodeParameter('options.messageButtonLabel', 0, 'Respond') as string;
config.options.push({
label,
value: 'true',
style: 'primary',
});
} else if (approvalOptions.approvalType === 'double') {
const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve');
const buttonApprovalStyle = approvalOptions.buttonApprovalStyle || 'primary';
const disapproveLabel = escapeHtml(approvalOptions.disapproveLabel || 'Disapprove');
@@ -245,7 +474,7 @@ export function createEmail(context: IExecuteFunctions) {
const email: IEmail = {
to,
subject: `${MESSAGE_PREFIX}${config.title}`,
subject: config.title,
body: '',
htmlBody: createEmailBody(config.message, buttons.join('\n'), instanceId),
};