mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(n8n Form Trigger Node): Respond with File (#13507)
This commit is contained in:
@@ -157,6 +157,31 @@
|
||||
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
|
||||
{{/if}}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const binary = "{{{responseBinary}}}"
|
||||
? JSON.parse(decodeURIComponent("{{{responseBinary}}}"))
|
||||
: '';
|
||||
|
||||
const byteArray = binary.data.type === 'Buffer'
|
||||
? new Uint8Array(binary.data.data)
|
||||
: Uint8Array.from(binary.data, c => c.charCodeAt(0));
|
||||
|
||||
if (binary) {
|
||||
const blob = new Blob(
|
||||
[byteArray],
|
||||
{ type: binary.type }
|
||||
);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = binary.fileName;
|
||||
document.body.appendChild(link);
|
||||
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
});
|
||||
|
||||
fetch('', {
|
||||
method: 'POST',
|
||||
body: {}
|
||||
|
||||
@@ -153,6 +153,11 @@ const completionProperties = updateDisplayOptions(
|
||||
value: 'showText',
|
||||
description: 'Display simple text or HTML',
|
||||
},
|
||||
{
|
||||
name: 'Return Binary File',
|
||||
value: 'returnBinary',
|
||||
description: 'Return incoming binary file',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -176,7 +181,7 @@ const completionProperties = updateDisplayOptions(
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
respondWith: ['text'],
|
||||
respondWith: ['text', 'returnBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -190,7 +195,7 @@ const completionProperties = updateDisplayOptions(
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
respondWith: ['text'],
|
||||
respondWith: ['text', 'returnBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -210,6 +215,21 @@ const completionProperties = updateDisplayOptions(
|
||||
placeholder: 'e.g. Thanks for filling the form',
|
||||
description: 'The text to display on the page. Use HTML to show a customized web page.',
|
||||
},
|
||||
{
|
||||
displayName: 'Input Data Field Name',
|
||||
name: 'inputDataFieldName',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
respondWith: ['returnBinary'],
|
||||
},
|
||||
},
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
description:
|
||||
'Find the name of input field containing the binary data to return in the Input panel on the left, in the Binary tab',
|
||||
hint: 'The name of the input field containing the binary file data to be returned',
|
||||
},
|
||||
...waitTimeProperties,
|
||||
{
|
||||
displayName: 'Options',
|
||||
@@ -233,7 +253,7 @@ const completionProperties = updateDisplayOptions(
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
respondWith: ['text'],
|
||||
respondWith: ['text', 'returnBinary'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,10 +3,43 @@ import {
|
||||
type NodeTypeAndVersion,
|
||||
type IWebhookFunctions,
|
||||
type IWebhookResponseData,
|
||||
type IBinaryData,
|
||||
type IDataObject,
|
||||
OperationalError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { sanitizeCustomCss, sanitizeHtml } from './utils';
|
||||
|
||||
const getBinaryDataFromNode = (context: IWebhookFunctions, nodeName: string): IDataObject => {
|
||||
return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as IDataObject;
|
||||
};
|
||||
|
||||
export const binaryResponse = async (
|
||||
context: IWebhookFunctions,
|
||||
): Promise<{ data: string | Buffer; fileName: string; type: string }> => {
|
||||
const inputDataFieldName = context.getNodeParameter('inputDataFieldName', '') as string;
|
||||
const parentNodes = context.getParentNodes(context.getNode().name);
|
||||
const binaryNode = parentNodes.find((node) =>
|
||||
getBinaryDataFromNode(context, node?.name)?.hasOwnProperty(inputDataFieldName),
|
||||
);
|
||||
if (!binaryNode) {
|
||||
throw new OperationalError(`No binary data with field ${inputDataFieldName} found.`);
|
||||
}
|
||||
const binaryData = getBinaryDataFromNode(context, binaryNode?.name)[
|
||||
inputDataFieldName
|
||||
] as IBinaryData;
|
||||
|
||||
return {
|
||||
// If a binaryData has an id, the following field is set:
|
||||
// N8N_DEFAULT_BINARY_DATA_MODE=filesystem
|
||||
data: binaryData.id
|
||||
? await context.helpers.binaryToBuffer(await context.helpers.getBinaryStream(binaryData.id))
|
||||
: atob(binaryData.data),
|
||||
fileName: binaryData.fileName ?? 'file',
|
||||
type: binaryData.mimeType,
|
||||
};
|
||||
};
|
||||
|
||||
export const renderFormCompletion = async (
|
||||
context: IWebhookFunctions,
|
||||
res: Response,
|
||||
@@ -20,6 +53,10 @@ export const renderFormCompletion = async (
|
||||
customCss?: string;
|
||||
};
|
||||
const responseText = context.getNodeParameter('responseText', '') as string;
|
||||
const binary =
|
||||
context.getNodeParameter('respondWith', '') === 'returnBinary'
|
||||
? await binaryResponse(context)
|
||||
: '';
|
||||
|
||||
let title = options.formTitle;
|
||||
if (!title) {
|
||||
@@ -35,6 +72,7 @@ export const renderFormCompletion = async (
|
||||
formTitle: title,
|
||||
appendAttribution,
|
||||
responseText: sanitizeHtml(responseText),
|
||||
responseBinary: encodeURIComponent(JSON.stringify(binary)),
|
||||
dangerousCustomCss: sanitizeCustomCss(options.customCss),
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
@@ -232,6 +232,7 @@ describe('Form Node', () => {
|
||||
message: 'Test Message',
|
||||
redirectUrl: '',
|
||||
title: 'Test Title',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
responseText: '',
|
||||
},
|
||||
},
|
||||
@@ -246,6 +247,7 @@ describe('Form Node', () => {
|
||||
redirectUrl: '',
|
||||
title: 'Test Title',
|
||||
responseText: '<div>hey</div>',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -257,6 +259,7 @@ describe('Form Node', () => {
|
||||
formTitle: 'test',
|
||||
message: 'Test Message',
|
||||
redirectUrl: '',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
title: 'Test Title',
|
||||
responseText: 'my text over here',
|
||||
},
|
||||
@@ -434,6 +437,7 @@ describe('Form Node', () => {
|
||||
redirectUrl: 'https://n8n.io',
|
||||
responseText: '',
|
||||
title: 'Test Title',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
254
packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
Normal file
254
packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { type Response } from 'express';
|
||||
import { type MockProxy, mock } from 'jest-mock-extended';
|
||||
import { type INode, type IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { renderFormCompletion } from '../formCompletionUtils';
|
||||
|
||||
describe('formCompletionUtils', () => {
|
||||
let mockWebhookFunctions: MockProxy<IWebhookFunctions>;
|
||||
|
||||
const mockNode: INode = mock<INode>({
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
const nodeNameWithFileToDownload = 'prevNode0';
|
||||
const nodeNameWithFile = 'prevNode2';
|
||||
|
||||
const parentNodesWithAndWithoutFiles = [
|
||||
{
|
||||
name: nodeNameWithFileToDownload,
|
||||
type: '',
|
||||
typeVersion: 0,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: 'prevNode1',
|
||||
type: '',
|
||||
typeVersion: 0,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const parentNodesWithMultipleBinaryFiles = [
|
||||
{
|
||||
name: nodeNameWithFileToDownload,
|
||||
type: '',
|
||||
typeVersion: 0,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: nodeNameWithFile,
|
||||
type: '',
|
||||
typeVersion: 0,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const parentNodesWithSingleNodeFile = [
|
||||
{
|
||||
name: nodeNameWithFileToDownload,
|
||||
type: '',
|
||||
typeVersion: 0,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const parentNodesTestCases = [
|
||||
parentNodesWithAndWithoutFiles,
|
||||
parentNodesWithMultipleBinaryFiles,
|
||||
parentNodesWithSingleNodeFile,
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebhookFunctions = mock<IWebhookFunctions>();
|
||||
|
||||
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('renderFormCompletion', () => {
|
||||
const mockResponse: Response = mock<Response>({
|
||||
send: jest.fn(),
|
||||
render: jest.fn(),
|
||||
});
|
||||
|
||||
const trigger = {
|
||||
name: 'triggerNode',
|
||||
type: 'trigger',
|
||||
typeVersion: 1,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render the form completion', async () => {
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
completionTitle: 'Form Completion',
|
||||
completionMessage: 'Form has been submitted successfully',
|
||||
options: { formTitle: 'Form Title' },
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
|
||||
|
||||
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
|
||||
appendAttribution: undefined,
|
||||
formTitle: 'Form Title',
|
||||
message: 'Form has been submitted successfully',
|
||||
redirectUrl: undefined,
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
responseText: '',
|
||||
title: 'Form Completion',
|
||||
});
|
||||
});
|
||||
|
||||
it('throw an error if no binary data with the field name is found', async () => {
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
completionTitle: 'Form Completion',
|
||||
completionMessage: 'Form has been submitted successfully',
|
||||
options: { formTitle: 'Form Title' },
|
||||
respondWith: 'returnBinary',
|
||||
inputDataFieldName: 'inputData',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
mockWebhookFunctions.getParentNodes.mockReturnValueOnce([]);
|
||||
|
||||
await expect(
|
||||
renderFormCompletion(mockWebhookFunctions, mockResponse, trigger),
|
||||
).rejects.toThrowError('No binary data with field inputData found.');
|
||||
});
|
||||
|
||||
it('should render if respond with binary is set and binary mode is filesystem', async () => {
|
||||
const expectedBinaryResponse = {
|
||||
inputData: {
|
||||
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
|
||||
fileExtension: 'txt',
|
||||
fileName: 'file.txt',
|
||||
fileSize: '458 B',
|
||||
fileType: 'text',
|
||||
mimeType: 'text/plain',
|
||||
id: 555,
|
||||
},
|
||||
};
|
||||
|
||||
const buffer = Buffer.from(expectedBinaryResponse.inputData.data);
|
||||
|
||||
for (const parentNodes of parentNodesTestCases) {
|
||||
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
|
||||
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
|
||||
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
|
||||
return expectedBinaryResponse;
|
||||
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
|
||||
return { someData: {} };
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
inputDataFieldName: 'inputData',
|
||||
completionTitle: 'Form Completion',
|
||||
completionMessage: 'Form has been submitted successfully',
|
||||
options: { formTitle: 'Form Title' },
|
||||
respondWith: 'returnBinary',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
mockWebhookFunctions.helpers.getBinaryStream = jest
|
||||
.fn()
|
||||
.mockResolvedValue(Promise.resolve({}));
|
||||
|
||||
mockWebhookFunctions.helpers.binaryToBuffer = jest
|
||||
.fn()
|
||||
.mockResolvedValue(Promise.resolve(buffer));
|
||||
|
||||
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
|
||||
|
||||
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
|
||||
appendAttribution: undefined,
|
||||
formTitle: 'Form Title',
|
||||
message: 'Form has been submitted successfully',
|
||||
redirectUrl: undefined,
|
||||
responseBinary: encodeURIComponent(
|
||||
JSON.stringify({
|
||||
data: buffer,
|
||||
fileName: expectedBinaryResponse.inputData.fileName,
|
||||
type: expectedBinaryResponse.inputData.mimeType,
|
||||
}),
|
||||
),
|
||||
responseText: '',
|
||||
title: 'Form Completion',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should render if respond with binary is set and binary mode is default', async () => {
|
||||
const expectedBinaryResponse = {
|
||||
inputData: {
|
||||
data: 'IyAxLiBHbyBpbiBwb3N0Z3',
|
||||
fileExtension: 'txt',
|
||||
fileName: 'file.txt',
|
||||
fileSize: '458 B',
|
||||
fileType: 'text',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
for (const parentNodes of parentNodesTestCases) {
|
||||
mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodes);
|
||||
mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => {
|
||||
if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) {
|
||||
return expectedBinaryResponse;
|
||||
} else if (arg === `{{ $('${nodeNameWithFile}').first().binary }}`) {
|
||||
return { someData: {} };
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => {
|
||||
const params: { [key: string]: any } = {
|
||||
inputDataFieldName: 'inputData',
|
||||
completionTitle: 'Form Completion',
|
||||
completionMessage: 'Form has been submitted successfully',
|
||||
options: { formTitle: 'Form Title' },
|
||||
respondWith: 'returnBinary',
|
||||
};
|
||||
return params[parameterName];
|
||||
});
|
||||
|
||||
await renderFormCompletion(mockWebhookFunctions, mockResponse, trigger);
|
||||
|
||||
expect(mockResponse.render).toHaveBeenCalledWith('form-trigger-completion', {
|
||||
appendAttribution: undefined,
|
||||
formTitle: 'Form Title',
|
||||
message: 'Form has been submitted successfully',
|
||||
redirectUrl: undefined,
|
||||
responseBinary: encodeURIComponent(
|
||||
JSON.stringify({
|
||||
data: atob(expectedBinaryResponse.inputData.data),
|
||||
fileName: expectedBinaryResponse.inputData.fileName,
|
||||
type: expectedBinaryResponse.inputData.mimeType,
|
||||
}),
|
||||
),
|
||||
responseText: '',
|
||||
title: 'Form Completion',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user