feat(n8n Form Trigger Node): Respond with File (#13507)

This commit is contained in:
Dana
2025-03-19 12:56:40 +01:00
committed by GitHub
parent 17fc5c148b
commit 8f46371d77
5 changed files with 344 additions and 3 deletions

View File

@@ -157,6 +157,31 @@
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a> <a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
{{/if}} {{/if}}
<script> <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('', { fetch('', {
method: 'POST', method: 'POST',
body: {} body: {}

View File

@@ -153,6 +153,11 @@ const completionProperties = updateDisplayOptions(
value: 'showText', value: 'showText',
description: 'Display simple text or HTML', 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, required: true,
displayOptions: { displayOptions: {
show: { show: {
respondWith: ['text'], respondWith: ['text', 'returnBinary'],
}, },
}, },
}, },
@@ -190,7 +195,7 @@ const completionProperties = updateDisplayOptions(
}, },
displayOptions: { displayOptions: {
show: { show: {
respondWith: ['text'], respondWith: ['text', 'returnBinary'],
}, },
}, },
}, },
@@ -210,6 +215,21 @@ const completionProperties = updateDisplayOptions(
placeholder: 'e.g. Thanks for filling the form', placeholder: 'e.g. Thanks for filling the form',
description: 'The text to display on the page. Use HTML to show a customized web page.', 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, ...waitTimeProperties,
{ {
displayName: 'Options', displayName: 'Options',
@@ -233,7 +253,7 @@ const completionProperties = updateDisplayOptions(
], ],
displayOptions: { displayOptions: {
show: { show: {
respondWith: ['text'], respondWith: ['text', 'returnBinary'],
}, },
}, },
}, },

View File

@@ -3,10 +3,43 @@ import {
type NodeTypeAndVersion, type NodeTypeAndVersion,
type IWebhookFunctions, type IWebhookFunctions,
type IWebhookResponseData, type IWebhookResponseData,
type IBinaryData,
type IDataObject,
OperationalError,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { sanitizeCustomCss, sanitizeHtml } from './utils'; 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 ( export const renderFormCompletion = async (
context: IWebhookFunctions, context: IWebhookFunctions,
res: Response, res: Response,
@@ -20,6 +53,10 @@ export const renderFormCompletion = async (
customCss?: string; customCss?: string;
}; };
const responseText = context.getNodeParameter('responseText', '') as string; const responseText = context.getNodeParameter('responseText', '') as string;
const binary =
context.getNodeParameter('respondWith', '') === 'returnBinary'
? await binaryResponse(context)
: '';
let title = options.formTitle; let title = options.formTitle;
if (!title) { if (!title) {
@@ -35,6 +72,7 @@ export const renderFormCompletion = async (
formTitle: title, formTitle: title,
appendAttribution, appendAttribution,
responseText: sanitizeHtml(responseText), responseText: sanitizeHtml(responseText),
responseBinary: encodeURIComponent(JSON.stringify(binary)),
dangerousCustomCss: sanitizeCustomCss(options.customCss), dangerousCustomCss: sanitizeCustomCss(options.customCss),
redirectUrl, redirectUrl,
}); });

View File

@@ -232,6 +232,7 @@ describe('Form Node', () => {
message: 'Test Message', message: 'Test Message',
redirectUrl: '', redirectUrl: '',
title: 'Test Title', title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
responseText: '', responseText: '',
}, },
}, },
@@ -246,6 +247,7 @@ describe('Form Node', () => {
redirectUrl: '', redirectUrl: '',
title: 'Test Title', title: 'Test Title',
responseText: '<div>hey</div>', responseText: '<div>hey</div>',
responseBinary: encodeURIComponent(JSON.stringify('')),
}, },
}, },
{ {
@@ -257,6 +259,7 @@ describe('Form Node', () => {
formTitle: 'test', formTitle: 'test',
message: 'Test Message', message: 'Test Message',
redirectUrl: '', redirectUrl: '',
responseBinary: encodeURIComponent(JSON.stringify('')),
title: 'Test Title', title: 'Test Title',
responseText: 'my text over here', responseText: 'my text over here',
}, },
@@ -434,6 +437,7 @@ describe('Form Node', () => {
redirectUrl: 'https://n8n.io', redirectUrl: 'https://n8n.io',
responseText: '', responseText: '',
title: 'Test Title', title: 'Test Title',
responseBinary: encodeURIComponent(JSON.stringify('')),
}); });
}); });
}); });

View 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',
});
}
});
});
});