first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import type { IncludeMods, SetField, SetNodeOptions } from './helpers/interfaces';
|
||||
import { INCLUDE } from './helpers/interfaces';
|
||||
import * as manual from './manual.mode';
|
||||
import * as raw from './raw.mode';
|
||||
|
||||
type Mode = 'manual' | 'raw';
|
||||
|
||||
const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Edit Fields (Set)',
|
||||
name: 'set',
|
||||
iconColor: 'blue',
|
||||
group: ['input'],
|
||||
version: [3, 3.1, 3.2, 3.3, 3.4],
|
||||
description: 'Modify, add, or remove item fields',
|
||||
subtitle: '={{$parameter["mode"]}}',
|
||||
defaults: {
|
||||
name: 'Edit Fields',
|
||||
},
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Manual Mapping',
|
||||
value: 'manual',
|
||||
description: 'Edit item fields one by one',
|
||||
action: 'Edit item fields one by one',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'raw',
|
||||
description: 'Customize item output with JSON',
|
||||
action: 'Customize item output with JSON',
|
||||
},
|
||||
],
|
||||
default: 'manual',
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate Item',
|
||||
name: 'duplicateItem',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
isNodeSetting: true,
|
||||
description: 'Whether this item should be duplicated a set number of times',
|
||||
},
|
||||
{
|
||||
displayName: 'Duplicate Item Count',
|
||||
name: 'duplicateCount',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
},
|
||||
description:
|
||||
'How many times the item should be duplicated, mainly used for testing and debugging',
|
||||
isNodeSetting: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
duplicateItem: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Item duplication is set in the node settings. This option will be ignored when the workflow runs automatically.',
|
||||
name: 'duplicateWarning',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
duplicateItem: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
...raw.description,
|
||||
...manual.description,
|
||||
{
|
||||
displayName: 'Include in Output',
|
||||
name: 'include',
|
||||
type: 'options',
|
||||
description: 'How to select the fields you want to include in your output items',
|
||||
default: 'all',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All Input Fields',
|
||||
value: INCLUDE.ALL,
|
||||
description: 'Also include all unchanged fields from the input',
|
||||
},
|
||||
{
|
||||
name: 'No Input Fields',
|
||||
value: INCLUDE.NONE,
|
||||
description: 'Include only the fields specified above',
|
||||
},
|
||||
{
|
||||
name: 'Selected Input Fields',
|
||||
value: INCLUDE.SELECTED,
|
||||
description: 'Also include the fields listed in the parameter “Fields to Include”',
|
||||
},
|
||||
{
|
||||
name: 'All Input Fields Except',
|
||||
value: INCLUDE.EXCEPT,
|
||||
description: 'Exclude the fields listed in the parameter “Fields to Exclude”',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Include Other Input Fields',
|
||||
name: 'includeOtherFields',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
"Whether to pass to the output all the input fields (along with the fields set in 'Fields to Set')",
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Fields to Include',
|
||||
name: 'include',
|
||||
type: 'options',
|
||||
description: 'How to select the fields you want to include in your output items',
|
||||
default: 'all',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
'/includeOtherFields': [false],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: INCLUDE.ALL,
|
||||
description: 'Also include all unchanged fields from the input',
|
||||
},
|
||||
{
|
||||
name: 'Selected',
|
||||
value: INCLUDE.SELECTED,
|
||||
description: 'Also include the fields listed in the parameter “Fields to Include”',
|
||||
},
|
||||
{
|
||||
name: 'All Except',
|
||||
value: INCLUDE.EXCEPT,
|
||||
description: 'Exclude the fields listed in the parameter “Fields to Exclude”',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Include',
|
||||
name: 'includeFields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldToInclude1,fieldToInclude2',
|
||||
description:
|
||||
'Comma-separated list of the field names you want to include in the output. You can drag the selected fields from the input panel.',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
include: ['selected'],
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Exclude',
|
||||
name: 'excludeFields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldToExclude1,fieldToExclude2',
|
||||
description:
|
||||
'Comma-separated list of the field names you want to exclude from the output. You can drag the selected fields from the input panel.',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
include: ['except'],
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Include',
|
||||
name: 'includeFields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldToInclude1,fieldToInclude2',
|
||||
description:
|
||||
'Comma-separated list of the field names you want to include in the output. You can drag the selected fields from the input panel.',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
include: ['selected'],
|
||||
'/includeOtherFields': [true],
|
||||
},
|
||||
hide: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Exclude',
|
||||
name: 'excludeFields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldToExclude1,fieldToExclude2',
|
||||
description:
|
||||
'Comma-separated list of the field names you want to exclude from the output. You can drag the selected fields from the input panel.',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
include: ['except'],
|
||||
'/includeOtherFields': [true],
|
||||
},
|
||||
hide: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Include Binary File',
|
||||
name: 'includeBinary',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 3.4 } }],
|
||||
},
|
||||
},
|
||||
description: 'Whether binary data should be included if present in the input item',
|
||||
},
|
||||
{
|
||||
displayName: 'Strip Binary Data',
|
||||
name: 'stripBinary',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether binary data should be stripped from the input item. Only applies when "Include Other Input Fields" is enabled.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 3.4 } }],
|
||||
'/includeOtherFields': [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Ignore Type Conversion Errors',
|
||||
name: 'ignoreConversionErrors',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ignore field type errors and apply a less strict type conversion',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/mode': ['manual'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Support Dot Notation',
|
||||
name: 'dotNotation',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
|
||||
description:
|
||||
'By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }. If that is not intended this can be deactivated, it will then set { "a.b": value } instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export class SetV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
const items = this.getInputData();
|
||||
const mode = this.getNodeParameter('mode', 0) as Mode;
|
||||
const duplicateItem = this.getNodeParameter('duplicateItem', 0, false) as boolean;
|
||||
|
||||
const setNode = { raw, manual };
|
||||
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
|
||||
const rawData: IDataObject = {};
|
||||
|
||||
if (mode === 'raw') {
|
||||
const jsonOutput = this.getNodeParameter('jsonOutput', 0, '', {
|
||||
rawExpressions: true,
|
||||
}) as string | undefined;
|
||||
|
||||
if (jsonOutput?.startsWith('=')) {
|
||||
rawData.jsonOutput = jsonOutput.replace(/^=+/, '');
|
||||
}
|
||||
} else {
|
||||
const workflowFieldsJson = this.getNodeParameter('fields.values', 0, [], {
|
||||
rawExpressions: true,
|
||||
}) as SetField[];
|
||||
|
||||
for (const entry of workflowFieldsJson) {
|
||||
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
|
||||
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const includeOtherFields = this.getNodeParameter('includeOtherFields', i, false) as boolean;
|
||||
const include = this.getNodeParameter('include', i, 'all') as IncludeMods;
|
||||
const options = this.getNodeParameter('options', i, {});
|
||||
const node = this.getNode();
|
||||
|
||||
if (node.typeVersion >= 3.3) {
|
||||
options.include = includeOtherFields ? include : 'none';
|
||||
} else {
|
||||
options.include = include;
|
||||
}
|
||||
|
||||
const newItem = await setNode[mode].execute.call(
|
||||
this,
|
||||
items[i],
|
||||
i,
|
||||
options as SetNodeOptions,
|
||||
rawData,
|
||||
node,
|
||||
);
|
||||
|
||||
if (duplicateItem && this.getMode() === 'manual') {
|
||||
const duplicateCount = this.getNodeParameter('duplicateCount', 0, 0) as number;
|
||||
for (let j = 0; j <= duplicateCount; j++) {
|
||||
returnData.push(newItem);
|
||||
}
|
||||
} else {
|
||||
returnData.push(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
/* eslint-disable n8n-nodes-base/node-param-display-name-miscased */
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
AssignmentCollectionValue,
|
||||
IBinaryData,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError, BINARY_MODE_COMBINED } from 'n8n-workflow';
|
||||
|
||||
import type { SetNodeOptions } from '../interfaces';
|
||||
import { INCLUDE } from '../interfaces';
|
||||
import { parseJsonParameter, prepareReturnItem } from '../utils';
|
||||
|
||||
describe('prepareReturnItem', () => {
|
||||
const mockNode = mock<INode>({
|
||||
name: 'Set Node',
|
||||
typeVersion: 3.4,
|
||||
});
|
||||
|
||||
const mockContext = mock<IExecuteFunctions>({
|
||||
getNodeParameter: jest.fn(),
|
||||
getWorkflowSettings: jest.fn(() => ({})),
|
||||
helpers: {
|
||||
assertBinaryData: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const baseItem: INodeExecutionData = {
|
||||
json: { existingField: 'existingValue' },
|
||||
binary: {
|
||||
existingBinary: {
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'existing.txt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseOptions: SetNodeOptions = {
|
||||
dotNotation: true,
|
||||
ignoreConversionErrors: false,
|
||||
include: INCLUDE.ALL,
|
||||
stripBinary: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(mockContext.getNodeParameter as jest.Mock).mockReturnValue('');
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('JSON assignments', () => {
|
||||
it('should handle string assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'stringField',
|
||||
value: 'test string',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
stringField: 'test string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle number assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'numberField',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
numberField: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'booleanField',
|
||||
value: true,
|
||||
type: 'boolean',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
booleanField: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'arrayField',
|
||||
value: '["item1", "item2"]',
|
||||
type: 'array',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toHaveProperty('arrayField');
|
||||
});
|
||||
|
||||
it('should handle object assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'objectField',
|
||||
value: '{"nested": "value"}',
|
||||
type: 'object',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toHaveProperty('objectField');
|
||||
});
|
||||
|
||||
it('should handle multiple JSON assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field1',
|
||||
value: 'value1',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'field2',
|
||||
value: 123,
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'field3',
|
||||
value: false,
|
||||
type: 'boolean',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
field1: 'value1',
|
||||
field2: 123,
|
||||
field3: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary assignments - mode handling', () => {
|
||||
describe('non-combined binary mode', () => {
|
||||
it('should push binary assignments to binaryValues when binaryMode is not combined', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'binarydata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'image.png',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({ binaryMode: 'default' });
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'binaryField',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toHaveProperty('binaryField');
|
||||
expect(result.binary?.binaryField).toEqual(mockBinaryData);
|
||||
});
|
||||
|
||||
it('should handle binary assignments in separate mode', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'separatedata',
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'doc.pdf',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({ binaryMode: 'separate' });
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'pdfBinary',
|
||||
value: 'pdfProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toHaveProperty('pdfBinary');
|
||||
expect(result.binary?.pdfBinary).toEqual(mockBinaryData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined binary mode', () => {
|
||||
it('should place binary only in binary when id is missing', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'combineddata',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'file.txt',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({
|
||||
binaryMode: BINARY_MODE_COMBINED,
|
||||
});
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'textBinary',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toHaveProperty('textBinary');
|
||||
expect(result.binary?.textBinary).toEqual(mockBinaryData);
|
||||
expect(result.json).not.toHaveProperty('textBinary');
|
||||
});
|
||||
|
||||
it('should place binary in json when id is present', () => {
|
||||
const mockBinaryDataWithId: IBinaryData = {
|
||||
data: 'combineddata',
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'photo.jpg',
|
||||
id: 'binary-id-123',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryDataWithId);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({
|
||||
binaryMode: BINARY_MODE_COMBINED,
|
||||
});
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'photoField',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toHaveProperty('photoField');
|
||||
expect(result.json.photoField).toEqual(mockBinaryDataWithId);
|
||||
});
|
||||
|
||||
it('should handle mixed binary assignments (with and without id)', () => {
|
||||
const mockBinaryDataNoId: IBinaryData = {
|
||||
data: 'data1',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'file1.txt',
|
||||
};
|
||||
|
||||
const mockBinaryDataWithId: IBinaryData = {
|
||||
data: 'data2',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'file2.txt',
|
||||
id: 'binary-id-456',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock)
|
||||
.mockReturnValueOnce(mockBinaryDataNoId)
|
||||
.mockReturnValueOnce(mockBinaryDataWithId);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({
|
||||
binaryMode: BINARY_MODE_COMBINED,
|
||||
});
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'binaryNoId',
|
||||
value: 'binaryProp1',
|
||||
type: 'binary',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'binaryWithId',
|
||||
value: 'binaryProp2',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toHaveProperty('binaryNoId');
|
||||
expect(result.binary?.binaryNoId).toEqual(mockBinaryDataNoId);
|
||||
expect(result.json).not.toHaveProperty('binaryNoId');
|
||||
|
||||
expect(result.json).toHaveProperty('binaryWithId');
|
||||
expect(result.json.binaryWithId).toEqual(mockBinaryDataWithId);
|
||||
});
|
||||
|
||||
it('should treat empty string id as missing id (place only in binary)', () => {
|
||||
const mockBinaryDataEmptyId: IBinaryData = {
|
||||
id: '',
|
||||
data: 'emptyiddata',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'file.txt',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryDataEmptyId);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({
|
||||
binaryMode: BINARY_MODE_COMBINED,
|
||||
});
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'emptyIdBinary',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toHaveProperty('emptyIdBinary');
|
||||
expect(result.binary?.emptyIdBinary).toEqual(mockBinaryDataEmptyId);
|
||||
expect(result.json).not.toHaveProperty('emptyIdBinary');
|
||||
});
|
||||
|
||||
it('should handle string reference in non-combined mode and resolve via assertBinaryData', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'referenceddata',
|
||||
mimeType: 'application/json',
|
||||
fileName: 'data.json',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({ binaryMode: 'default' });
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'binaryRef',
|
||||
value: 'binaryPropertyName',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(mockContext.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'binaryPropertyName');
|
||||
|
||||
expect(result.binary).toHaveProperty('binaryRef');
|
||||
expect(result.binary?.binaryRef).toEqual(mockBinaryData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed mode scenarios', () => {
|
||||
it('should handle JSON and binary assignments together in non-combined mode', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'mixeddata',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'image.png',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({ binaryMode: 'default' });
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'stringField',
|
||||
value: 'text value',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'binaryField',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'numberField',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toHaveProperty('stringField', 'text value');
|
||||
expect(result.json).toHaveProperty('numberField', 42);
|
||||
expect(result.binary).toHaveProperty('binaryField');
|
||||
});
|
||||
|
||||
it('should handle JSON and binary assignments together in combined mode with id', () => {
|
||||
const mockBinaryDataWithId: IBinaryData = {
|
||||
data: 'combineddata',
|
||||
mimeType: 'video/mp4',
|
||||
fileName: 'video.mp4',
|
||||
id: 'video-id-789',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryDataWithId);
|
||||
(mockContext.getWorkflowSettings as jest.Mock).mockReturnValue({
|
||||
binaryMode: BINARY_MODE_COMBINED,
|
||||
});
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'title',
|
||||
value: 'My Video',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'videoData',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'duration',
|
||||
value: 120,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toHaveProperty('title', 'My Video');
|
||||
expect(result.json).toHaveProperty('duration', 120);
|
||||
expect(result.json).toHaveProperty('videoData', mockBinaryDataWithId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary assignments', () => {
|
||||
it('should handle binary assignments', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'newbinarydata',
|
||||
mimeType: 'application/pdf',
|
||||
fileName: 'document.pdf',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'newBinary',
|
||||
value: 'binaryPropertyName',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toEqual({
|
||||
existingBinary: {
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'existing.txt',
|
||||
},
|
||||
newBinary: mockBinaryData,
|
||||
});
|
||||
expect(mockContext.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'binaryPropertyName');
|
||||
});
|
||||
|
||||
it('should handle multiple binary assignments', () => {
|
||||
const mockBinaryData1: IBinaryData = {
|
||||
data: 'binary1',
|
||||
mimeType: 'image/png',
|
||||
fileName: 'image1.png',
|
||||
};
|
||||
|
||||
const mockBinaryData2: IBinaryData = {
|
||||
data: 'binary2',
|
||||
mimeType: 'image/jpeg',
|
||||
fileName: 'image2.jpg',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock)
|
||||
.mockReturnValueOnce(mockBinaryData1)
|
||||
.mockReturnValueOnce(mockBinaryData2);
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'image1',
|
||||
value: 'binaryProp1',
|
||||
type: 'binary',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'image2',
|
||||
value: 'binaryProp2',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.binary).toEqual({
|
||||
existingBinary: {
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'existing.txt',
|
||||
},
|
||||
image1: mockBinaryData1,
|
||||
image2: mockBinaryData2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create binary object when item has no existing binary data', () => {
|
||||
const itemWithoutBinary: INodeExecutionData = {
|
||||
json: { field: 'value' },
|
||||
};
|
||||
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'binarydata',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'test.txt',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'newBinary',
|
||||
value: 'binaryPropertyName',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(
|
||||
mockContext,
|
||||
value,
|
||||
0,
|
||||
itemWithoutBinary,
|
||||
mockNode,
|
||||
baseOptions,
|
||||
);
|
||||
|
||||
expect(result.binary).toEqual({
|
||||
newBinary: mockBinaryData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when binary data is not found', () => {
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue('not-binary-data');
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'invalidBinary',
|
||||
value: 'nonExistentBinary',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions),
|
||||
).toThrow(NodeOperationError);
|
||||
expect(() =>
|
||||
prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions),
|
||||
).toThrow('Could not find binary data specified in field invalidBinary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed assignments', () => {
|
||||
it('should handle both JSON and binary assignments', () => {
|
||||
const mockBinaryData: IBinaryData = {
|
||||
data: 'mixedbinary',
|
||||
mimeType: 'application/json',
|
||||
fileName: 'data.json',
|
||||
};
|
||||
|
||||
(mockContext.helpers.assertBinaryData as jest.Mock).mockReturnValue(mockBinaryData);
|
||||
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'textField',
|
||||
value: 'mixed content',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'numberField',
|
||||
value: 999,
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'binaryField',
|
||||
value: 'binaryProp',
|
||||
type: 'binary',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
textField: 'mixed content',
|
||||
numberField: 999,
|
||||
});
|
||||
|
||||
expect(result.binary).toEqual({
|
||||
existingBinary: {
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'existing.txt',
|
||||
},
|
||||
binaryField: mockBinaryData,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty assignments array', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
});
|
||||
expect(result.binary).toEqual({
|
||||
existingBinary: {
|
||||
data: 'base64data',
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'existing.txt',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined assignments', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: undefined as any,
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null assignments value', () => {
|
||||
const result = prepareReturnItem(
|
||||
mockContext,
|
||||
null as any,
|
||||
0,
|
||||
baseItem,
|
||||
mockNode,
|
||||
baseOptions,
|
||||
);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle assignments with null/undefined values', () => {
|
||||
const value: AssignmentCollectionValue = {
|
||||
assignments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'nullField',
|
||||
value: null,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'undefinedField',
|
||||
value: undefined,
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareReturnItem(mockContext, value, 0, baseItem, mockNode, baseOptions);
|
||||
|
||||
expect(result.json).toEqual({
|
||||
existingField: 'existingValue',
|
||||
nullField: null,
|
||||
undefinedField: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonParameter', () => {
|
||||
const mockNode = mock<INode>({
|
||||
name: 'Set Node',
|
||||
typeVersion: 3.4,
|
||||
});
|
||||
|
||||
describe('Valid JSON input', () => {
|
||||
it('should parse valid JSON string', () => {
|
||||
const result = parseJsonParameter('{"key": "value"}', mockNode, 0);
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should parse valid JSON object directly', () => {
|
||||
const input = { key: 'value' };
|
||||
const result = parseJsonParameter(input, mockNode, 0);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should parse complex valid JSON', () => {
|
||||
const json = '{"nested": {"key": "value"}, "array": [1, 2, 3]}';
|
||||
const result = parseJsonParameter(json, mockNode, 0);
|
||||
expect(result).toEqual({ nested: { key: 'value' }, array: [1, 2, 3] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON repair', () => {
|
||||
it('should repair JSON string', () => {
|
||||
const result = parseJsonParameter('{key: "value"}', mockNode, 0);
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error cases', () => {
|
||||
it('should throw error for invalid JSON that cannot be recovered', () => {
|
||||
expect(() => parseJsonParameter('{key1: "value",, key2: "value2"}', mockNode, 0)).toThrow(
|
||||
"The 'JSON Output' in item 0 contains invalid JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
export type SetNodeOptions = {
|
||||
dotNotation?: boolean;
|
||||
ignoreConversionErrors?: boolean;
|
||||
include?: IncludeMods;
|
||||
includeBinary?: boolean;
|
||||
stripBinary?: boolean;
|
||||
};
|
||||
|
||||
export type SetField = {
|
||||
name: string;
|
||||
type: 'stringValue' | 'numberValue' | 'booleanValue' | 'arrayValue' | 'objectValue';
|
||||
stringValue?: string;
|
||||
numberValue?: number;
|
||||
booleanValue?: boolean;
|
||||
arrayValue?: string[] | string | IDataObject | IDataObject[];
|
||||
objectValue?: string | IDataObject;
|
||||
};
|
||||
|
||||
export type AssignmentSetField = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export const INCLUDE = {
|
||||
ALL: 'all',
|
||||
NONE: 'none',
|
||||
SELECTED: 'selected',
|
||||
EXCEPT: 'except',
|
||||
} as const;
|
||||
|
||||
export type IncludeMods = (typeof INCLUDE)[keyof typeof INCLUDE];
|
||||
@@ -0,0 +1,318 @@
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
import {
|
||||
ApplicationError,
|
||||
NodeOperationError,
|
||||
deepCopy,
|
||||
getValueDescription,
|
||||
jsonParse,
|
||||
validateFieldType,
|
||||
isBinaryValue,
|
||||
BINARY_MODE_COMBINED,
|
||||
} from 'n8n-workflow';
|
||||
import type {
|
||||
AssignmentCollectionValue,
|
||||
FieldType,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { SetNodeOptions } from './interfaces';
|
||||
import { INCLUDE } from './interfaces';
|
||||
import { getResolvables, sanitizeDataPathKey } from '../../../../utils/utilities';
|
||||
|
||||
const configureFieldHelper = (dotNotation?: boolean) => {
|
||||
if (dotNotation !== false) {
|
||||
return {
|
||||
set: (item: IDataObject, key: string, value: IDataObject) => {
|
||||
set(item, key, value);
|
||||
},
|
||||
get: (item: IDataObject, key: string) => {
|
||||
return get(item, key);
|
||||
},
|
||||
unset: (item: IDataObject, key: string) => {
|
||||
unset(item, key);
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
set: (item: IDataObject, key: string, value: IDataObject) => {
|
||||
item[sanitizeDataPathKey(item, key)] = value;
|
||||
},
|
||||
get: (item: IDataObject, key: string) => {
|
||||
return item[sanitizeDataPathKey(item, key)];
|
||||
},
|
||||
unset: (item: IDataObject, key: string) => {
|
||||
delete item[sanitizeDataPathKey(item, key)];
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function composeReturnItem(
|
||||
this: IExecuteFunctions | ISupplyDataFunctions,
|
||||
itemIndex: number,
|
||||
inputItem: INodeExecutionData,
|
||||
newFields: IDataObject,
|
||||
options: SetNodeOptions,
|
||||
nodeVersion: number,
|
||||
) {
|
||||
const newItem: INodeExecutionData = {
|
||||
json: {},
|
||||
pairedItem: { item: itemIndex },
|
||||
};
|
||||
|
||||
const includeBinary =
|
||||
(nodeVersion >= 3.4 && !options.stripBinary && options.include !== 'none') ||
|
||||
(nodeVersion < 3.4 && !!options.includeBinary);
|
||||
if (includeBinary && inputItem.binary !== undefined) {
|
||||
// Create a shallow copy of the binary data so that the old
|
||||
// data references which do not get changed still stay behind
|
||||
// but the incoming data does not get changed.
|
||||
newItem.binary = {};
|
||||
Object.assign(newItem.binary, inputItem.binary);
|
||||
}
|
||||
|
||||
const fieldHelper = configureFieldHelper(options.dotNotation);
|
||||
|
||||
switch (options.include) {
|
||||
case INCLUDE.ALL:
|
||||
newItem.json = deepCopy(inputItem.json);
|
||||
break;
|
||||
case INCLUDE.SELECTED:
|
||||
const includeFields = (this.getNodeParameter('includeFields', itemIndex) as string)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item);
|
||||
|
||||
for (const key of includeFields) {
|
||||
const fieldValue = fieldHelper.get(inputItem.json, key) as IDataObject;
|
||||
let keyToSet = key;
|
||||
if (options.dotNotation !== false && key.includes('.')) {
|
||||
keyToSet = key.split('.').pop() as string;
|
||||
}
|
||||
fieldHelper.set(newItem.json, keyToSet, fieldValue);
|
||||
}
|
||||
break;
|
||||
case INCLUDE.EXCEPT:
|
||||
const excludeFields = (this.getNodeParameter('excludeFields', itemIndex) as string)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item);
|
||||
|
||||
const inputData = deepCopy(inputItem.json);
|
||||
|
||||
for (const key of excludeFields) {
|
||||
fieldHelper.unset(inputData, key);
|
||||
}
|
||||
|
||||
newItem.json = inputData;
|
||||
break;
|
||||
case INCLUDE.NONE:
|
||||
break;
|
||||
default:
|
||||
throw new ApplicationError(`The include option "${options.include}" is not known!`, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of Object.keys(newFields)) {
|
||||
fieldHelper.set(newItem.json, key, newFields[key] as IDataObject);
|
||||
}
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
export const parseJsonParameter = (
|
||||
jsonData: string | IDataObject,
|
||||
node: INode,
|
||||
i: number,
|
||||
entryName?: string,
|
||||
) => {
|
||||
let returnData: IDataObject;
|
||||
const location = entryName ? `entry "${entryName}" inside 'Fields to Set'` : "'JSON Output'";
|
||||
|
||||
if (typeof jsonData === 'string') {
|
||||
try {
|
||||
returnData = jsonParse<IDataObject>(jsonData, { repairJSON: true });
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(node, `The ${location} in item ${i} contains invalid JSON`, {
|
||||
description: jsonData,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
returnData = jsonData;
|
||||
}
|
||||
|
||||
if (returnData === undefined || typeof returnData !== 'object' || Array.isArray(returnData)) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`The ${location} in item ${i} does not contain a valid JSON object`,
|
||||
);
|
||||
}
|
||||
|
||||
return returnData;
|
||||
};
|
||||
|
||||
export const validateEntry = (
|
||||
name: string,
|
||||
type: FieldType,
|
||||
value: unknown,
|
||||
node: INode,
|
||||
itemIndex: number,
|
||||
ignoreErrors = false,
|
||||
nodeVersion?: number,
|
||||
) => {
|
||||
if (nodeVersion && nodeVersion >= 3.2 && (value === undefined || value === null)) {
|
||||
return { name, value: null };
|
||||
}
|
||||
|
||||
const description = `To fix the error try to change the type for the field "${name}" or activate the option “Ignore Type Conversion Errors” to apply a less strict type validation`;
|
||||
|
||||
if (type === 'string') {
|
||||
if (nodeVersion && nodeVersion > 3 && (value === undefined || value === null)) {
|
||||
if (ignoreErrors) {
|
||||
return { name, value: null };
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`'${name}' expects a ${type} but we got ${getValueDescription(value)} [item ${itemIndex}]`,
|
||||
{ description },
|
||||
);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
value = JSON.stringify(value);
|
||||
} else {
|
||||
value = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = validateFieldType(name, value, type);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
if (ignoreErrors) {
|
||||
return { name, value: value ?? null };
|
||||
} else {
|
||||
const message = `${'errorMessage' in validationResult ? validationResult.errorMessage : 'Error'} [item ${itemIndex}]`;
|
||||
throw new NodeOperationError(node, message, {
|
||||
itemIndex,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
value: validationResult.newValue ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveRawData(
|
||||
this: IExecuteFunctions | ISupplyDataFunctions,
|
||||
rawData: string,
|
||||
i: number,
|
||||
) {
|
||||
const resolvables = getResolvables(rawData);
|
||||
let returnData: string = rawData;
|
||||
|
||||
if (resolvables.length) {
|
||||
for (const resolvable of resolvables) {
|
||||
const resolvedValue = this.evaluateExpression(`${resolvable}`, i);
|
||||
|
||||
// Use a function replacer to avoid issues with special replacement patterns like $&
|
||||
if (typeof resolvedValue === 'object' && resolvedValue !== null) {
|
||||
returnData = returnData.replace(resolvable, () => JSON.stringify(resolvedValue));
|
||||
} else {
|
||||
returnData = returnData.replace(resolvable, () => String(resolvedValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function prepareReturnItem(
|
||||
context: IExecuteFunctions | ISupplyDataFunctions,
|
||||
value: AssignmentCollectionValue,
|
||||
itemIndex: number,
|
||||
item: INodeExecutionData,
|
||||
node: INode,
|
||||
options: SetNodeOptions,
|
||||
) {
|
||||
const jsonValues: AssignmentCollectionValue['assignments'] = [];
|
||||
const binaryValues: AssignmentCollectionValue['assignments'] = [];
|
||||
|
||||
for (const assignment of value?.assignments ?? []) {
|
||||
if (assignment.type === 'binary') {
|
||||
binaryValues.push(assignment);
|
||||
} else {
|
||||
jsonValues.push(assignment);
|
||||
}
|
||||
}
|
||||
|
||||
const newData = Object.fromEntries(
|
||||
jsonValues.map((assignment) => {
|
||||
const { name, value } = validateEntry(
|
||||
assignment.name,
|
||||
assignment.type as FieldType,
|
||||
assignment.value,
|
||||
node,
|
||||
itemIndex,
|
||||
options.ignoreConversionErrors,
|
||||
node.typeVersion,
|
||||
);
|
||||
|
||||
return [name, value];
|
||||
}),
|
||||
);
|
||||
|
||||
const returnItem = composeReturnItem.call(
|
||||
context,
|
||||
itemIndex,
|
||||
item,
|
||||
newData,
|
||||
options,
|
||||
node.typeVersion,
|
||||
);
|
||||
|
||||
if (binaryValues.length) {
|
||||
if (!returnItem.binary) {
|
||||
returnItem.binary = {};
|
||||
}
|
||||
const { binaryMode } = context.getWorkflowSettings();
|
||||
|
||||
const target = binaryMode === BINARY_MODE_COMBINED ? 'json' : 'binary';
|
||||
|
||||
const fieldHelper = configureFieldHelper(options.dotNotation);
|
||||
|
||||
for (const assignment of binaryValues) {
|
||||
const name = assignment.name;
|
||||
const value = assignment.value as string;
|
||||
const binaryData = context.helpers.assertBinaryData(itemIndex, value);
|
||||
if (!isBinaryValue(binaryData)) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`Could not find binary data specified in field ${name}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
// Do not push binary data to json if entry contains raw data
|
||||
if (target === 'json' && !binaryData.id) {
|
||||
returnItem.binary[name] = binaryData;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target === 'json') {
|
||||
fieldHelper.set(returnItem.json, name, binaryData);
|
||||
continue;
|
||||
}
|
||||
|
||||
returnItem[target]![name] = binaryData;
|
||||
}
|
||||
}
|
||||
|
||||
return returnItem;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
AssignmentCollectionValue,
|
||||
FieldType,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { SetField, SetNodeOptions } from './helpers/interfaces';
|
||||
import {
|
||||
parseJsonParameter,
|
||||
validateEntry,
|
||||
composeReturnItem,
|
||||
resolveRawData,
|
||||
prepareReturnItem,
|
||||
} from './helpers/utils';
|
||||
import { updateDisplayOptions } from '../../../utils/utilities';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Fields to Set',
|
||||
name: 'fields',
|
||||
placeholder: 'Add Field',
|
||||
type: 'fixedCollection',
|
||||
description: 'Edit existing fields or add new ones to modify the output data',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
sortable: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldName',
|
||||
description:
|
||||
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'The field value type',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'String',
|
||||
value: 'stringValue',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'numberValue',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'booleanValue',
|
||||
},
|
||||
{
|
||||
name: 'Array',
|
||||
value: 'arrayValue',
|
||||
},
|
||||
{
|
||||
name: 'Object',
|
||||
value: 'objectValue',
|
||||
},
|
||||
],
|
||||
default: 'stringValue',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'stringValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['stringValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'string',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'numberValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['numberValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'number',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'booleanValue',
|
||||
type: 'options',
|
||||
default: 'true',
|
||||
options: [
|
||||
{
|
||||
name: 'True',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
name: 'False',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['booleanValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'boolean',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'arrayValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['arrayValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'array',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'objectValue',
|
||||
type: 'json',
|
||||
default: '={}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['objectValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'object',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Fields to Set',
|
||||
name: 'assignments',
|
||||
type: 'assignmentCollection',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [3, 3.1, 3.2],
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['manual'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions | ISupplyDataFunctions,
|
||||
item: INodeExecutionData,
|
||||
i: number,
|
||||
options: SetNodeOptions,
|
||||
rawFieldsData: IDataObject,
|
||||
node: INode,
|
||||
) {
|
||||
try {
|
||||
if (node.typeVersion < 3.3) {
|
||||
const fields = this.getNodeParameter('fields.values', i, []) as SetField[];
|
||||
|
||||
const newData: IDataObject = {};
|
||||
|
||||
for (const entry of fields) {
|
||||
if (
|
||||
entry.type === 'objectValue' &&
|
||||
rawFieldsData[entry.name] !== undefined &&
|
||||
entry.objectValue !== undefined &&
|
||||
entry.objectValue !== null
|
||||
) {
|
||||
entry.objectValue = parseJsonParameter(
|
||||
resolveRawData.call(this, rawFieldsData[entry.name] as string, i),
|
||||
node,
|
||||
i,
|
||||
entry.name,
|
||||
);
|
||||
}
|
||||
|
||||
const { name, value } = validateEntry(
|
||||
entry.name,
|
||||
entry.type.replace('Value', '') as FieldType,
|
||||
entry[entry.type],
|
||||
node,
|
||||
i,
|
||||
options.ignoreConversionErrors,
|
||||
node.typeVersion,
|
||||
);
|
||||
newData[name] = value;
|
||||
}
|
||||
|
||||
return composeReturnItem.call(this, i, item, newData, options, node.typeVersion);
|
||||
}
|
||||
|
||||
const assignmentCollection = this.getNodeParameter(
|
||||
'assignments',
|
||||
i,
|
||||
) as AssignmentCollectionValue;
|
||||
|
||||
return prepareReturnItem(this, assignmentCollection, i, item, node, options);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return { json: { error: (error as Error).message }, pairedItem: { item: i } };
|
||||
}
|
||||
throw new NodeOperationError(this.getNode(), error as Error, {
|
||||
itemIndex: i,
|
||||
description: error.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
INodeExecutionData,
|
||||
IExecuteFunctions,
|
||||
INodeProperties,
|
||||
IDataObject,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import type { SetNodeOptions } from './helpers/interfaces';
|
||||
import { parseJsonParameter, composeReturnItem, resolveRawData } from './helpers/utils';
|
||||
import { updateDisplayOptions } from '../../../utils/utilities';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'JSON',
|
||||
name: 'jsonOutput',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
},
|
||||
default: '{\n "my_field_1": "value",\n "my_field_2": 1\n}\n',
|
||||
validateType: 'object',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
mode: ['raw'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(
|
||||
this: IExecuteFunctions,
|
||||
item: INodeExecutionData,
|
||||
i: number,
|
||||
options: SetNodeOptions,
|
||||
rawData: IDataObject,
|
||||
node: INode,
|
||||
) {
|
||||
try {
|
||||
let newData: IDataObject;
|
||||
if (rawData.jsonOutput === undefined) {
|
||||
const json = this.getNodeParameter('jsonOutput', i) as string;
|
||||
newData = parseJsonParameter(json, node, i);
|
||||
} else {
|
||||
newData = parseJsonParameter(
|
||||
resolveRawData.call(this, rawData.jsonOutput as string, i),
|
||||
node,
|
||||
i,
|
||||
);
|
||||
}
|
||||
|
||||
return composeReturnItem.call(this, i, item, newData, options, node.typeVersion);
|
||||
} catch (error) {
|
||||
if (this.continueOnFail()) {
|
||||
return { json: { error: (error as Error).message }, pairedItem: { item: i } };
|
||||
}
|
||||
throw new NodeOperationError(node, error as Error, {
|
||||
itemIndex: i,
|
||||
description: error.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user