From cecec482bb7068de5c0c9be6a08a5d4f8a3920c7 Mon Sep 17 00:00:00 2001 From: RomanDavydchuk Date: Mon, 13 Jul 2026 15:07:47 +0300 Subject: [PATCH] feat(Form Node): Support multiple files when returning binary from form ending (#33780) --- .../form-trigger-completion.handlebars | 12 +- packages/nodes-base/nodes/Form/Form.node.ts | 6 +- .../nodes/Form/test/Form.node.test.ts | 8 +- .../Form/test/formCompletionUtils.test.ts | 130 +++++++++++++++--- .../nodes/Form/utils/formCompletionUtils.ts | 73 ++++++---- 5 files changed, 173 insertions(+), 56 deletions(-) diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars index 5fff1281276..b294fe4152f 100644 --- a/packages/cli/templates/form-trigger-completion.handlebars +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -201,15 +201,15 @@ }; document.addEventListener('DOMContentLoaded', function () { - const binary = "{{{responseBinary}}}" + const binaries = "{{{responseBinary}}}" ? JSON.parse(decodeURIComponent("{{{responseBinary}}}")) - : ''; + : []; - const byteArray = binary.data.type === 'Buffer' - ? new Uint8Array(binary.data.data) - : Uint8Array.from(binary.data, c => c.charCodeAt(0)); + for (const binary of binaries) { + 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 } diff --git a/packages/nodes-base/nodes/Form/Form.node.ts b/packages/nodes-base/nodes/Form/Form.node.ts index 956c4167ae4..1d9090a287e 100644 --- a/packages/nodes-base/nodes/Form/Form.node.ts +++ b/packages/nodes-base/nodes/Form/Form.node.ts @@ -230,7 +230,7 @@ const completionProperties = updateDisplayOptions( description: 'The text to display on the page. Use HTML to show a customized web page.', }, { - displayName: 'Input Data Field Name', + displayName: 'Input Data Field Name(s)', name: 'inputDataFieldName', type: 'string', displayOptions: { @@ -241,8 +241,8 @@ const completionProperties = updateDisplayOptions( 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', + 'Find the name of input field containing the binary data to return in the Input panel on the left, in the Binary tab. You can provide multiple comma-separated field names.', + hint: 'The name of the input field containing the binary file data to be returned. You can provide multiple comma-separated field names.', }, ...waitTimeProperties, { diff --git a/packages/nodes-base/nodes/Form/test/Form.node.test.ts b/packages/nodes-base/nodes/Form/test/Form.node.test.ts index d8b7d0b3c4f..5eae9b9fd0d 100644 --- a/packages/nodes-base/nodes/Form/test/Form.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/Form.node.test.ts @@ -239,7 +239,7 @@ describe('Form Node', () => { message: 'Test Message', redirectUrl: undefined, title: 'Test Title', - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), responseText: '', dangerousCustomCss: undefined, }, @@ -255,7 +255,7 @@ describe('Form Node', () => { redirectUrl: undefined, title: 'Test Title', responseText: '
hey
', - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), dangerousCustomCss: undefined, }, }, @@ -268,7 +268,7 @@ describe('Form Node', () => { formTitle: 'test', message: 'Test Message', redirectUrl: undefined, - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), title: 'Test Title', responseText: 'my text over here', dangerousCustomCss: undefined, @@ -732,7 +732,7 @@ describe('Form Node', () => { redirectUrl: 'https://n8n.io', responseText: '', title: 'Test Title', - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), }); }); }); diff --git a/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts b/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts index 5800ff317a0..9d11297cec0 100644 --- a/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts +++ b/packages/nodes-base/nodes/Form/test/formCompletionUtils.test.ts @@ -133,7 +133,7 @@ describe('formCompletionUtils', () => { formTitle: 'Form Title', message: 'Form has been submitted successfully', redirectUrl: undefined, - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), responseText: '', title: 'Form Completion', }); @@ -220,7 +220,7 @@ describe('formCompletionUtils', () => { formTitle: 'Form Title', message: 'Safe messagebold', redirectUrl: undefined, - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), responseText: 'Response text', title: 'Form Completion', dangerousCustomCss: undefined, @@ -252,7 +252,7 @@ describe('formCompletionUtils', () => { formTitle: 'Form Title', message: `Some message${replacement}Other text`, redirectUrl: undefined, - responseBinary: encodeURIComponent(JSON.stringify('')), + responseBinary: encodeURIComponent(JSON.stringify([])), responseText: 'Response text', title: 'Form Completion', dangerousCustomCss: undefined, @@ -330,11 +330,13 @@ describe('formCompletionUtils', () => { message: 'Form has been submitted successfully', redirectUrl: undefined, responseBinary: encodeURIComponent( - JSON.stringify({ - data: buffer, - fileName: expectedBinaryResponse.inputData.fileName, - type: expectedBinaryResponse.inputData.mimeType, - }), + JSON.stringify([ + { + data: buffer, + fileName: expectedBinaryResponse.inputData.fileName, + type: expectedBinaryResponse.inputData.mimeType, + }, + ]), ), responseText: '', title: 'Form Completion', @@ -384,11 +386,13 @@ describe('formCompletionUtils', () => { 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, - }), + JSON.stringify([ + { + data: atob(expectedBinaryResponse.inputData.data), + fileName: expectedBinaryResponse.inputData.fileName, + type: expectedBinaryResponse.inputData.mimeType, + }, + ]), ), responseText: '', title: 'Form Completion', @@ -524,11 +528,103 @@ describe('formCompletionUtils', () => { const result = await binaryResponse(mockWebhookFunctions); - expect(result).toEqual({ - data: atob(expectedBinaryResponse.inputData.data), - fileName: expectedBinaryResponse.inputData.fileName, - type: expectedBinaryResponse.inputData.mimeType, + expect(result).toEqual([ + { + data: atob(expectedBinaryResponse.inputData.data), + fileName: expectedBinaryResponse.inputData.fileName, + type: expectedBinaryResponse.inputData.mimeType, + }, + ]); + }); + + it('should return multiple binary files from comma-separated field names', async () => { + const expectedBinaryResponse = { + inputData: { + data: 'Zmlyc3Q=', + fileName: 'first.txt', + mimeType: 'text/plain', + }, + otherData: { + data: 'c2Vjb25k', + fileName: 'second.txt', + mimeType: 'text/plain', + }, + }; + + mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: Record = { + inputDataFieldName: 'inputData, otherData', + }; + return params[parameterName]; }); + mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithSingleNodeFile); + mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => { + if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) { + return expectedBinaryResponse; + } + + return undefined; + }); + + const result = await binaryResponse(mockWebhookFunctions); + + expect(result).toEqual([ + { + data: atob(expectedBinaryResponse.inputData.data), + fileName: expectedBinaryResponse.inputData.fileName, + type: expectedBinaryResponse.inputData.mimeType, + }, + { + data: atob(expectedBinaryResponse.otherData.data), + fileName: expectedBinaryResponse.otherData.fileName, + type: expectedBinaryResponse.otherData.mimeType, + }, + ]); + }); + + it('should trim comma-separated field names', async () => { + const expectedBinaryResponse = { + inputData: { + data: 'Zmlyc3Q=', + fileName: 'first.txt', + mimeType: 'text/plain', + }, + otherData: { + data: 'c2Vjb25k', + fileName: 'second.txt', + mimeType: 'text/plain', + }, + }; + + mockWebhookFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: Record = { + inputDataFieldName: ' inputData , otherData ', + }; + return params[parameterName]; + }); + mockWebhookFunctions.getParentNodes.mockReturnValueOnce(parentNodesWithSingleNodeFile); + mockWebhookFunctions.evaluateExpression.mockImplementation((arg) => { + if (arg === `{{ $('${nodeNameWithFileToDownload}').first().binary }}`) { + return expectedBinaryResponse; + } + + return undefined; + }); + + const result = await binaryResponse(mockWebhookFunctions); + + expect(result).toEqual([ + { + data: atob(expectedBinaryResponse.inputData.data), + fileName: expectedBinaryResponse.inputData.fileName, + type: expectedBinaryResponse.inputData.mimeType, + }, + { + data: atob(expectedBinaryResponse.otherData.data), + fileName: expectedBinaryResponse.otherData.fileName, + type: expectedBinaryResponse.otherData.mimeType, + }, + ]); }); }); }); diff --git a/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts b/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts index 989113efc03..1e3e5a7c353 100644 --- a/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts +++ b/packages/nodes-base/nodes/Form/utils/formCompletionUtils.ts @@ -19,34 +19,55 @@ import { validateSafeRedirectUrl, } from './utils'; -const getBinaryDataFromNode = (context: IWebhookFunctions, nodeName: string): IDataObject => { - return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as IDataObject; +type BinaryResponse = { data: string | Buffer; fileName: string; type: string }; + +const getBinaryDataFromNode = ( + context: IWebhookFunctions, + nodeName: string, +): IDataObject | undefined => { + return context.evaluateExpression(`{{ $('${nodeName}').first().binary }}`) as + | IDataObject + | undefined; }; -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 - .reverse() - .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; +const getInputDataFieldNames = (inputDataFieldName: string) => { + const fieldNames = inputDataFieldName + .split(',') + .map((fieldName) => fieldName.trim()) + .filter(Boolean); - 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, - }; + return fieldNames.length ? fieldNames : [inputDataFieldName]; +}; + +export const binaryResponse = async (context: IWebhookFunctions): Promise => { + const inputDataFieldName = context.getNodeParameter('inputDataFieldName', '') as string; + const inputDataFieldNames = getInputDataFieldNames(inputDataFieldName); + const responses: BinaryResponse[] = []; + const parentNodesBinaries = context + .getParentNodes(context.getNode().name) + .reverse() + .map((node) => getBinaryDataFromNode(context, node.name) ?? {}); + + for (const fieldName of inputDataFieldNames) { + const nodeBinary = parentNodesBinaries.find((bin) => Object.hasOwn(bin, fieldName)); + if (!nodeBinary) { + throw new OperationalError(`No binary data with field ${fieldName} found.`); + } + + const binaryData = nodeBinary[fieldName] as IBinaryData; + + responses.push({ + // 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, + }); + } + + return responses; }; export const renderFormCompletion = async ( @@ -70,7 +91,7 @@ export const renderFormCompletion = async ( | 'redirect' | 'showText' | 'returnBinary'; - const binary = respondWith === 'returnBinary' ? await binaryResponse(context) : ''; + const binary = respondWith === 'returnBinary' ? await binaryResponse(context) : []; let title = options.formTitle; if (!title) {