feat: Data transformation nodes and actions in Nodes Panel (#7760)

- Split Items List node into separate nodes per action
- Review node descriptions
- New icons
- New sections in subcategories

---------

Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Deborah <deborah@starfallprojects.co.uk>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Elias Meire 2023-12-08 11:40:05 +01:00 committed by GitHub
parent 90824b50ed
commit 675ec21d33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4353 additions and 74 deletions

View file

@ -67,6 +67,6 @@ describe('Inline expression editor', () => {
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
// Resolving $parameter is slow, especially on CI runner
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^get$/);
WorkflowPage.getters.inlineExpressionEditorOutput().should('have.text', 'getAll');
});
});

View file

@ -235,11 +235,8 @@ describe('Data mapping', () => {
ndv.actions.close();
workflowPage.actions.addNodeToCanvas('Item Lists');
workflowPage.actions.openNode('Item Lists');
ndv.getters.parameterInput('operation').click();
getVisibleSelect().find('li').contains('Sort').click();
workflowPage.actions.addNodeToCanvas('Sort');
workflowPage.actions.openNode('Sort');
ndv.getters.nodeParameters().find('button').contains('Add Field To Sort By').click();

View file

@ -19,7 +19,7 @@ describe('NDV', () => {
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Item Lists');
workflowPage.actions.openNode('Sort');
ndv.getters.inputPanel().contains('6 items').should('exist');
ndv.getters.outputPanel().contains('6 items').should('exist');
@ -92,7 +92,7 @@ describe('NDV', () => {
ndv.getters.outputHoveringItem().should('have.text', '1000');
ndv.getters.parameterExpressionPreview('value').should('include.text', '1000');
ndv.actions.selectInputNode('Item Lists');
ndv.actions.selectInputNode('Sort');
ndv.actions.changeOutputRunSelector('1 of 2 (6 items)');
ndv.getters.backToCanvas().realHover(); // reset to default hover

View file

@ -16,7 +16,7 @@ describe('Resource Locator', () => {
it('should render both RLC components in google sheets', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true);
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.getters.resourceLocator('documentId').should('be.visible');
ndv.getters.resourceLocator('sheetName').should('be.visible');
ndv.getters
@ -31,7 +31,7 @@ describe('Resource Locator', () => {
it('should show appropriate error when credentials are not set', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true);
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.getters.resourceLocator('documentId').should('be.visible');
ndv.getters.resourceLocatorInput('documentId').click();
ndv.getters.resourceLocatorErrorMessage().should('contain', NO_CREDENTIALS_MESSAGE);
@ -39,7 +39,7 @@ describe('Resource Locator', () => {
it('should show appropriate error when credentials are not valid', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true);
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
workflowPage.getters.nodeCredentialsSelect().click();
// Add oAuth credentials
getVisibleSelect().find('li').last().click();
@ -54,7 +54,7 @@ describe('Resource Locator', () => {
it('should reset resource locator when dependent field is changed', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true);
workflowPage.actions.addNodeToCanvas('Google Sheets', true, true, 'Update row in sheet');
ndv.actions.setRLCValue('documentId', '123');
ndv.actions.setRLCValue('sheetName', '123');
ndv.actions.setRLCValue('documentId', '321');

View file

@ -316,7 +316,7 @@ describe('Node Creator', () => {
NDVModal.actions.close();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Item Lists', 'Summarize');
WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Summarize');
WorkflowPage.getters.canvasNodes().should('have.length', 3);
});
});
@ -410,7 +410,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.searchBar().find('input').clear().type('js');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Code');
nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Item Lists');
nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Edit Fields (Set)');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('fi');
nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Filter');

View file

@ -57,6 +57,6 @@ describe('Expression editor modal', () => {
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().contains(/^get$/);
WorkflowPage.getters.expressionModalOutput().should('have.text', 'getAll');
});
});

View file

@ -50,7 +50,6 @@
},
{
"parameters": {
"operation": "sort",
"sortFieldsUi": {
"sortField": [
{
@ -61,9 +60,9 @@
"options": {}
},
"id": "555a150c-d735-4331-b628-c1f1cfed2da1",
"name": "Item Lists",
"type": "n8n-nodes-base.itemLists",
"typeVersion": 2,
"name": "Sort",
"type": "n8n-nodes-base.sort",
"typeVersion": 1,
"position": [
-280,
580
@ -182,7 +181,7 @@
"main": [
[
{
"node": "Item Lists",
"node": "Sort",
"type": "main",
"index": 0
}
@ -216,7 +215,7 @@
]
]
},
"Item Lists": {
"Sort": {
"main": [
[
{
@ -289,4 +288,4 @@
]
}
}
}
}

View file

@ -162,7 +162,7 @@ function subcategoriesMapper(item: INodeCreateElement) {
}
function baseSubcategoriesFilter(item: INodeCreateElement): boolean {
if (item.type === 'section') return item.children.every(baseSubcategoriesFilter);
if (item.type === 'section') return true;
if (item.type !== 'node') return false;
const hasTriggerGroup = item.properties.group.includes('trigger');

View file

@ -1,6 +1,6 @@
import type { SectionCreateElement } from '@/Interface';
import { groupItemsInSections } from '../utils';
import { mockNodeCreateElement } from './utils';
import { groupItemsInSections, sortNodeCreateElements } from '../utils';
import { mockActionCreateElement, mockNodeCreateElement, mockSectionCreateElement } from './utils';
describe('NodeCreator - utils', () => {
describe('groupItemsInSections', () => {
@ -46,4 +46,20 @@ describe('NodeCreator - utils', () => {
expect(result).toEqual([node1, node2, node3]);
});
});
describe('sortNodeCreateElements', () => {
it('should sort nodes alphabetically by displayName', () => {
const node1 = mockNodeCreateElement({ key: 'newNode' }, { displayName: 'xyz' });
const node2 = mockNodeCreateElement({ key: 'popularNode' }, { displayName: 'abc' });
const node3 = mockNodeCreateElement({ key: 'otherNode' }, { displayName: 'ABC' });
expect(sortNodeCreateElements([node1, node2, node3])).toEqual([node2, node3, node1]);
});
it('should not change order for other types (sections, actions)', () => {
const node1 = mockSectionCreateElement();
const node2 = mockActionCreateElement();
const node3 = mockSectionCreateElement();
expect(sortNodeCreateElements([node1, node2, node3])).toEqual([node1, node2, node3]);
});
});
});

View file

@ -222,7 +222,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
// Sort only if non-root view
if (!stack.items) {
sortNodeCreateElements(stackItems);
stackItems = sortNodeCreateElements(stackItems);
}
updateCurrentViewStack({ baselineItems: stackItems });

View file

@ -59,7 +59,7 @@ export function subcategorizeItems(items: SimplifiedNodeType[]) {
export function sortNodeCreateElements(nodes: INodeCreateElement[]) {
return nodes.sort((a, b) => {
if (a.type !== 'node' || b.type !== 'node') return -1;
if (a.type !== 'node' || b.type !== 'node') return 0;
const displayNameA = a.properties?.displayName?.toLowerCase() || a.key;
const displayNameB = b.properties?.displayName?.toLowerCase() || b.key;
@ -101,16 +101,16 @@ export function groupItemsInSections(
type: 'section',
key: section.key,
title: section.title,
children: itemsBySection[section.key],
children: sortNodeCreateElements(itemsBySection[section.key] ?? []),
}),
)
.concat({
type: 'section',
key: 'other',
title: i18n.baseText('nodeCreator.sectionNames.other'),
children: itemsBySection.other,
children: sortNodeCreateElements(itemsBySection.other ?? []),
})
.filter((section) => section.children);
.filter((section) => section.children.length > 0);
if (result.length <= 1) {
return items;

View file

@ -10,7 +10,6 @@ import {
TRANSFORM_DATA_SUBCATEGORY,
FILES_SUBCATEGORY,
FLOWS_CONTROL_SUBCATEGORY,
HELPERS_SUBCATEGORY,
TRIGGER_NODE_CREATOR_VIEW,
EMAIL_IMAP_NODE_TYPE,
DEFAULT_SUBCATEGORY,
@ -29,6 +28,26 @@ import {
AI_CATEGORY_EMBEDDING,
AI_OTHERS_NODE_CREATOR_VIEW,
AI_UNCATEGORIZED_CATEGORY,
SET_NODE_TYPE,
CODE_NODE_TYPE,
DATETIME_NODE_TYPE,
FILTER_NODE_TYPE,
REMOVE_DUPLICATES_NODE_TYPE,
SPLIT_OUT_NODE_TYPE,
LIMIT_NODE_TYPE,
SUMMARIZE_NODE_TYPE,
AGGREGATE_NODE_TYPE,
MERGE_NODE_TYPE,
HTML_NODE_TYPE,
MARKDOWN_NODE_TYPE,
XML_NODE_TYPE,
CRYPTO_NODE_TYPE,
IF_NODE_TYPE,
SPLIT_IN_BATCHES_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
HELPERS_SUBCATEGORY,
RSS_READ_NODE_TYPE,
EMAIL_SEND_NODE_TYPE,
} from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -340,6 +359,7 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
properties: {
title: 'App Regular Nodes',
icon: 'globe',
forceIncludeNodes: [RSS_READ_NODE_TYPE, EMAIL_SEND_NODE_TYPE],
},
},
{
@ -353,20 +373,31 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [],
items: [SET_NODE_TYPE, CODE_NODE_TYPE, DATETIME_NODE_TYPE],
},
{
key: 'addOrRemove',
title: i18n.baseText('nodeCreator.sectionNames.transform.addOrRemove'),
items: [
FILTER_NODE_TYPE,
REMOVE_DUPLICATES_NODE_TYPE,
SPLIT_OUT_NODE_TYPE,
LIMIT_NODE_TYPE,
],
},
{
key: 'combine',
title: i18n.baseText('nodeCreator.sectionNames.transform.combine'),
items: [SUMMARIZE_NODE_TYPE, AGGREGATE_NODE_TYPE, MERGE_NODE_TYPE],
},
{
key: 'convert',
title: i18n.baseText('nodeCreator.sectionNames.transform.convert'),
items: [HTML_NODE_TYPE, MARKDOWN_NODE_TYPE, XML_NODE_TYPE, CRYPTO_NODE_TYPE],
},
],
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: HELPERS_SUBCATEGORY,
icon: 'toolbox',
},
},
{
type: 'subcategory',
key: FLOWS_CONTROL_SUBCATEGORY,
@ -374,6 +405,13 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
properties: {
title: FLOWS_CONTROL_SUBCATEGORY,
icon: 'code-branch',
sections: [
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [FILTER_NODE_TYPE, IF_NODE_TYPE, SPLIT_IN_BATCHES_NODE_TYPE, MERGE_NODE_TYPE],
},
],
},
},
{
@ -385,6 +423,22 @@ export function RegularView(nodes: SimplifiedNodeType[]) {
icon: 'file-alt',
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
title: HELPERS_SUBCATEGORY,
icon: 'toolbox',
sections: [
{
key: 'popular',
title: i18n.baseText('nodeCreator.sectionNames.popular'),
items: [HTTP_REQUEST_NODE_TYPE, WEBHOOK_NODE_TYPE, CODE_NODE_TYPE],
},
],
},
},
],
};

View file

@ -157,6 +157,17 @@ export const XERO_NODE_TYPE = 'n8n-nodes-base.xero';
export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk';
export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger';
export const DISCORD_NODE_TYPE = 'n8n-nodes-base.discord';
export const DATETIME_NODE_TYPE = 'n8n-nodes-base.dateTime';
export const REMOVE_DUPLICATES_NODE_TYPE = 'n8n-nodes-base.removeDuplicates';
export const SPLIT_OUT_NODE_TYPE = 'n8n-nodes-base.splitOut';
export const LIMIT_NODE_TYPE = 'n8n-nodes-base.limit';
export const SUMMARIZE_NODE_TYPE = 'n8n-nodes-base.summarize';
export const AGGREGATE_NODE_TYPE = 'n8n-nodes-base.aggregate';
export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge';
export const MARKDOWN_NODE_TYPE = 'n8n-nodes-base.markdown';
export const XML_NODE_TYPE = 'n8n-nodes-base.xml';
export const CRYPTO_NODE_TYPE = 'n8n-nodes-base.crypto';
export const RSS_READ_NODE_TYPE = 'n8n-nodes-base.rssFeedRead';
export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base';
export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1;

View file

@ -884,7 +884,7 @@
"nodeCreator.subcategoryNames.dataTransformation": "Data transformation",
"nodeCreator.subcategoryNames.files": "Files",
"nodeCreator.subcategoryNames.flow": "Flow",
"nodeCreator.subcategoryNames.helpers": "Helpers",
"nodeCreator.subcategoryNames.helpers": "Advanced",
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
"nodeCreator.subcategoryNames.agents": "Agents",
"nodeCreator.subcategoryNames.chains": "Chains",
@ -900,6 +900,9 @@
"nodeCreator.subcategoryNames.miscellaneous": "Miscellaneous",
"nodeCreator.sectionNames.popular": "Popular",
"nodeCreator.sectionNames.other": "Other",
"nodeCreator.sectionNames.transform.combine": "Combine items",
"nodeCreator.sectionNames.transform.addOrRemove": "Add or remove items",
"nodeCreator.sectionNames.transform.convert": "Convert data",
"nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger",
"nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.",
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?",

View file

@ -13,6 +13,6 @@
},
"alias": ["cpde", "Javascript", "JS", "Python", "Script", "Custom Code", "Function"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
"Core Nodes": ["Helpers", "Data Transformation"]
}
}

View file

@ -20,6 +20,6 @@
},
"alias": ["Encrypt", "SHA", "Hash"],
"subcategories": {
"Core Nodes": ["Helpers"]
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -23,6 +23,6 @@
]
},
"subcategories": {
"Core Nodes": ["Helpers", "Data Transformation"]
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -31,6 +31,7 @@ export class DateTimeV2 implements INodeType {
},
inputs: ['main'],
outputs: ['main'],
description: 'Manipulate date and time values',
properties: [
{
displayName: 'Operation',

View file

@ -23,6 +23,6 @@
]
},
"subcategories": {
"Core Nodes": ["Helpers", "Other Trigger Nodes"]
"Core Nodes": ["Other Trigger Nodes"]
}
}

View file

@ -27,8 +27,5 @@
}
]
},
"subcategories": {
"Core Nodes": ["Helpers"]
},
"alias": ["SMTP"]
}

View file

@ -19,6 +19,6 @@
]
},
"subcategories": {
"Core Nodes": ["Helpers", "Other Trigger Nodes"]
"Core Nodes": ["Other Trigger Nodes"]
}
}

View file

@ -13,6 +13,6 @@
},
"alias": ["n8n"],
"subcategories": {
"Core Nodes": ["Helpers"]
"Core Nodes": ["Helpers", "Flow"]
}
}

View file

@ -14,6 +14,6 @@
},
"alias": ["Router", "Filter", "Condition", "Logic", "Boolean", "Branch"],
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": ["Flow", "Data Transformation"]
}
}

View file

@ -16,7 +16,7 @@ export class Filter implements INodeType {
icon: 'fa:filter',
group: ['transform'],
version: 1,
description: 'Filter out incoming items based on given conditions',
description: 'Remove items matching a condition',
defaults: {
name: 'Filter',
color: '#229eff',

View file

@ -13,6 +13,6 @@
"generic": []
},
"subcategories": {
"Core Nodes": ["Helpers", "Other Trigger Nodes"]
"Core Nodes": ["Other Trigger Nodes"]
}
}

View file

@ -11,7 +11,7 @@
]
},
"subcategories": {
"Core Nodes": ["Helpers", "Data Transformation"]
"Core Nodes": ["Data Transformation"]
},
"alias": ["extract", "template", "table"]
}

View file

@ -12,6 +12,7 @@ export class ItemLists extends VersionedNodeType {
name: 'itemLists',
icon: 'file:itemLists.svg',
group: ['input'],
hidden: true,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Helper for working with lists of items and transforming arrays',
defaultVersion: 3.1,

View file

@ -11,6 +11,6 @@
]
},
"subcategories": {
"Core Nodes": ["Helpers", "Data Transformation"]
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -43,6 +43,6 @@
},
"alias": ["Join", "Concatenate", "Wait"],
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": ["Flow", "Data Transformation"]
}
}

View file

@ -37,7 +37,7 @@ const versionDescription: INodeTypeDescription = {
group: ['transform'],
version: [2, 2.1, 2.2],
subtitle: '={{$parameter["mode"]}}',
description: 'Merges data of multiple streams once data from both is available',
description: 'Merge data of two inputs once data from both is available',
defaults: {
name: 'Merge',
color: '#00bbcc',

View file

@ -17,6 +17,6 @@
},
"alias": ["Workflow", "Execution"],
"subcategories": {
"Core Nodes": ["Helpers", "Flow", "Other Trigger Nodes"]
"Core Nodes": ["Helpers", "Other Trigger Nodes"]
}
}

View file

@ -23,7 +23,7 @@ export class RenameKeys implements INodeType {
icon: 'fa:edit',
group: ['transform'],
version: 1,
description: 'Renames keys',
description: 'Update item field names',
defaults: {
name: 'Rename Keys',
color: '#772244',

View file

@ -11,6 +11,6 @@
]
},
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": ["Helpers"]
}
}

View file

@ -21,8 +21,5 @@
"url": "https://n8n.io/blog/why-i-chose-n8n-over-zapier-in-2020/"
}
]
},
"subcategories": {
"Core Nodes": ["Helpers"]
}
}

View file

@ -11,8 +11,5 @@
],
"generic": []
},
"alias": ["Time", "Scheduler", "Polling", "Cron", "Interval"],
"subcategories": {
"Core Nodes": ["Flow"]
}
"alias": ["Time", "Scheduler", "Polling", "Cron", "Interval"]
}

View file

@ -22,7 +22,7 @@ const versionDescription: INodeTypeDescription = {
icon: 'fa:pen',
group: ['input'],
version: [3, 3.1, 3.2],
description: 'Change the structure of your items',
description: 'Modify, add, or remove item fields',
subtitle: '={{$parameter["mode"]}}',
defaults: {
name: 'Edit Fields',

View file

@ -18,6 +18,6 @@
]
},
"subcategories": {
"Core Nodes": ["Flow", "Other Trigger Nodes"]
"Core Nodes": ["Other Trigger Nodes"]
}
}

View file

@ -12,6 +12,6 @@
},
"alias": ["Throw error", "Error", "Exception"],
"subcategories": {
"Core Nodes": ["Helpers"]
"Core Nodes": ["Flow"]
}
}

View file

@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.aggregate",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.aggregate/"
}
],
"generic": []
},
"alias": ["Aggregate", "Combine", "Flatten", "Transform", "Array", "List", "Item"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,414 @@
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import set from 'lodash/set';
import {
NodeOperationError,
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type IPairedItemData,
} from 'n8n-workflow';
import { prepareFieldsArray } from '../utils/utils';
import { addBinariesToItem } from './utils';
export class Aggregate implements INodeType {
description: INodeTypeDescription = {
displayName: 'Aggregate',
name: 'aggregate',
icon: 'file:aggregate.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Combine a field from many items into a list in a single item',
defaults: {
name: 'Aggregate',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Aggregate',
name: 'aggregate',
type: 'options',
default: 'aggregateIndividualFields',
options: [
{
name: 'Individual Fields',
value: 'aggregateIndividualFields',
},
{
name: 'All Item Data (Into a Single List)',
value: 'aggregateAllItemData',
},
],
},
{
displayName: 'Fields To Aggregate',
name: 'fieldsToAggregate',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Field To Aggregate',
default: { fieldToAggregate: [{ fieldToAggregate: '', renameField: false }] },
displayOptions: {
show: {
aggregate: ['aggregateIndividualFields'],
},
},
options: [
{
displayName: '',
name: 'fieldToAggregate',
values: [
{
displayName: 'Input Field Name',
name: 'fieldToAggregate',
type: 'string',
default: '',
description: 'The name of a field in the input items to aggregate together',
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
placeholder: 'e.g. id',
hint: ' Enter the field name as text',
requiresDataPath: 'single',
},
{
displayName: 'Rename Field',
name: 'renameField',
type: 'boolean',
default: false,
description: 'Whether to give the field a different name in the output',
},
{
displayName: 'Output Field Name',
name: 'outputFieldName',
displayOptions: {
show: {
renameField: [true],
},
},
type: 'string',
default: '',
description:
'The name of the field to put the aggregated data in. Leave blank to use the input field name.',
requiresDataPath: 'single',
},
],
},
],
},
{
displayName: 'Put Output in Field',
name: 'destinationFieldName',
type: 'string',
displayOptions: {
show: {
aggregate: ['aggregateAllItemData'],
},
},
default: 'data',
description: 'The name of the output field to put the data in',
},
{
displayName: 'Include',
name: 'include',
type: 'options',
default: 'allFields',
options: [
{
name: 'All Fields',
value: 'allFields',
},
{
name: 'Specified Fields',
value: 'specifiedFields',
},
{
name: 'All Fields Except',
value: 'allFieldsExcept',
},
],
displayOptions: {
show: {
aggregate: ['aggregateAllItemData'],
},
},
},
{
displayName: 'Fields To Exclude',
name: 'fieldsToExclude',
type: 'string',
placeholder: 'e.g. email, name',
default: '',
requiresDataPath: 'multiple',
displayOptions: {
show: {
aggregate: ['aggregateAllItemData'],
include: ['allFieldsExcept'],
},
},
},
{
displayName: 'Fields To Include',
name: 'fieldsToInclude',
type: 'string',
placeholder: 'e.g. email, name',
default: '',
requiresDataPath: 'multiple',
displayOptions: {
show: {
aggregate: ['aggregateAllItemData'],
include: ['specifiedFields'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Disable Dot Notation',
name: 'disableDotNotation',
type: 'boolean',
default: false,
description:
'Whether to disallow referencing child fields using `parent.child` in the field name',
displayOptions: {
hide: {
'/aggregate': ['aggregateAllItemData'],
},
},
},
{
displayName: 'Merge Lists',
name: 'mergeLists',
type: 'boolean',
default: false,
description:
'Whether to merge the output into a single flat list (rather than a list of lists), if the field to aggregate is a list',
displayOptions: {
hide: {
'/aggregate': ['aggregateAllItemData'],
},
},
},
{
displayName: 'Include Binaries',
name: 'includeBinaries',
type: 'boolean',
default: false,
description: 'Whether to include the binary data in the new item',
},
{
displayName: 'Keep Only Unique Binaries',
name: 'keepOnlyUnique',
type: 'boolean',
default: false,
description:
'Whether to keep only unique binaries by comparing mime types, file types, file sizes and file extensions',
displayOptions: {
show: {
includeBinaries: [true],
},
},
},
{
displayName: 'Keep Missing And Null Values',
name: 'keepMissing',
type: 'boolean',
default: false,
description:
'Whether to add a null entry to the aggregated list when there is a missing or null value',
displayOptions: {
hide: {
'/aggregate': ['aggregateAllItemData'],
},
},
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
let returnData: INodeExecutionData = { json: {}, pairedItem: [] };
const items = this.getInputData();
const aggregate = this.getNodeParameter('aggregate', 0, '') as string;
if (aggregate === 'aggregateIndividualFields') {
const disableDotNotation = this.getNodeParameter(
'options.disableDotNotation',
0,
false,
) as boolean;
const mergeLists = this.getNodeParameter('options.mergeLists', 0, false) as boolean;
const fieldsToAggregate = this.getNodeParameter(
'fieldsToAggregate.fieldToAggregate',
0,
[],
) as [{ fieldToAggregate: string; renameField: boolean; outputFieldName: string }];
const keepMissing = this.getNodeParameter('options.keepMissing', 0, false) as boolean;
if (!fieldsToAggregate.length) {
throw new NodeOperationError(this.getNode(), 'No fields specified', {
description: 'Please add a field to aggregate',
});
}
const newItem: INodeExecutionData = {
json: {},
pairedItem: Array.from({ length: items.length }, (_, i) => i).map((index) => {
return {
item: index,
};
}),
};
const values: { [key: string]: any } = {};
const outputFields: string[] = [];
for (const { fieldToAggregate, outputFieldName, renameField } of fieldsToAggregate) {
const field = renameField ? outputFieldName : fieldToAggregate;
if (outputFields.includes(field)) {
throw new NodeOperationError(
this.getNode(),
`The '${field}' output field is used more than once`,
{ description: 'Please make sure each output field name is unique' },
);
} else {
outputFields.push(field);
}
const getFieldToAggregate = () =>
!disableDotNotation && fieldToAggregate.includes('.')
? fieldToAggregate.split('.').pop()
: fieldToAggregate;
const _outputFieldName = outputFieldName
? outputFieldName
: (getFieldToAggregate() as string);
if (fieldToAggregate !== '') {
values[_outputFieldName] = [];
for (let i = 0; i < items.length; i++) {
if (!disableDotNotation) {
let value = get(items[i].json, fieldToAggregate);
if (!keepMissing) {
if (Array.isArray(value)) {
value = value.filter((entry) => entry !== null);
} else if (value === null || value === undefined) {
continue;
}
}
if (Array.isArray(value) && mergeLists) {
values[_outputFieldName].push(...value);
} else {
values[_outputFieldName].push(value);
}
} else {
let value = items[i].json[fieldToAggregate];
if (!keepMissing) {
if (Array.isArray(value)) {
value = value.filter((entry) => entry !== null);
} else if (value === null || value === undefined) {
continue;
}
}
if (Array.isArray(value) && mergeLists) {
values[_outputFieldName].push(...value);
} else {
values[_outputFieldName].push(value);
}
}
}
}
}
for (const key of Object.keys(values)) {
if (!disableDotNotation) {
set(newItem.json, key, values[key]);
} else {
newItem.json[key] = values[key];
}
}
returnData = newItem;
} else {
let newItems: IDataObject[] = items.map((item) => item.json);
let pairedItem: IPairedItemData[] = [];
const destinationFieldName = this.getNodeParameter('destinationFieldName', 0) as string;
const fieldsToExclude = prepareFieldsArray(
this.getNodeParameter('fieldsToExclude', 0, '') as string,
'Fields To Exclude',
);
const fieldsToInclude = prepareFieldsArray(
this.getNodeParameter('fieldsToInclude', 0, '') as string,
'Fields To Include',
);
if (fieldsToExclude.length || fieldsToInclude.length) {
newItems = newItems.reduce((acc, item, index) => {
const newItem: IDataObject = {};
let outputFields = Object.keys(item);
if (fieldsToExclude.length) {
outputFields = outputFields.filter((key) => !fieldsToExclude.includes(key));
}
if (fieldsToInclude.length) {
outputFields = outputFields.filter((key) =>
fieldsToInclude.length ? fieldsToInclude.includes(key) : true,
);
}
outputFields.forEach((key) => {
newItem[key] = item[key];
});
if (isEmpty(newItem)) {
return acc;
}
pairedItem.push({ item: index });
return acc.concat([newItem]);
}, [] as IDataObject[]);
} else {
pairedItem = Array.from({ length: newItems.length }, (_, item) => ({
item,
}));
}
const output: INodeExecutionData = { json: { [destinationFieldName]: newItems }, pairedItem };
returnData = output;
}
const includeBinaries = this.getNodeParameter('options.includeBinaries', 0, false) as boolean;
if (includeBinaries) {
const pairedItems = (returnData.pairedItem || []) as IPairedItemData[];
const aggregatedItems = pairedItems.map((item) => {
return items[item.item];
});
const keepOnlyUnique = this.getNodeParameter('options.keepOnlyUnique', 0, false) as boolean;
addBinariesToItem(returnData, aggregatedItems, keepOnlyUnique);
}
return [[returnData]];
}
}

View file

@ -0,0 +1,14 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1147_318)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 148C32 141.373 37.3726 136 44 136L190 136C196.627 136 202 141.373 202 148L202 172C202 178.627 196.627 184 190 184L44 184C37.3726 184 32 178.627 32 172L32 148Z" fill="#FF6D5A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 244C32 237.373 37.3726 232 44 232L190 232C196.627 232 202 237.373 202 244L202 268C202 274.627 196.627 280 190 280L44 280C37.3726 280 32 274.627 32 268L32 244Z" fill="#FF6D5A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M32 340C32 333.373 37.3726 328 44 328L190 328C196.627 328 202 333.373 202 340L202 364C202 370.627 196.627 376 190 376L44 376C37.3726 376 32 370.627 32 364L32 340Z" fill="#FF6D5A"/>
<path d="M74 76C74 82.6274 79.3726 88 86 88H202.217C219.89 88 234.217 102.327 234.217 120V176C234.217 202.978 244.489 227.557 261.336 246.039C266.391 251.584 266.391 260.416 261.336 265.961C244.489 284.443 234.217 309.022 234.217 336V392C234.217 409.673 219.89 424 202.217 424H86C79.3726 424 74 429.373 74 436V460C74 466.627 79.3726 472 86 472H202.217C246.4 472 282.217 436.183 282.217 392V336C282.217 305.072 307.289 280 338.217 280V280C341.411 280 344 277.411 344 274.217V237.783C344 234.589 341.411 232 338.217 232V232C307.289 232 282.217 206.928 282.217 176V120C282.217 75.8172 246.4 40 202.217 40H86C79.3726 40 74 45.3726 74 52V76Z" fill="#FF6D5A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M376 244C376 237.373 381.373 232 388 232L500 232C506.627 232 512 237.373 512 244L512 268C512 274.627 506.627 280 500 280L388 280C381.373 280 376 274.627 376 268L376 244Z" fill="#FF6D5A"/>
</g>
<defs>
<clipPath id="clip0_1147_318">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Aggregate Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,207 @@
{
"name": "itemLists test",
"nodes": [
{
"parameters": {},
"id": "6c90bf81-0c0e-4c5f-9f0c-297f06d9668a",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-440, 260]
},
{
"parameters": {
"jsCode": "return [\n {id: 1, char: 'a'},\n {id: 2, char: 'b'},\n {id: 3, char: 'c'},\n {id: 4, char: 'd'},\n {id: 5, char: 'e'},\n];"
},
"id": "2e0011d5-c6a0-4a40-ab8c-9d011cde40d5",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [-180, 260]
},
{
"parameters": {
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "id",
"renameField": true,
"outputFieldName": "data"
}
]
},
"options": {}
},
"id": "d95ca3a3-fb43-4037-846e-b87103dec1a3",
"name": "fields aggregate and rename",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [80, 0]
},
{
"parameters": {
"aggregate": "aggregateAllItemData"
},
"id": "4c1bc7be-7611-418d-aad5-8642b1cc0781",
"name": "aggregate all fields into list",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [80, 320]
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"include": "specifiedFields",
"fieldsToInclude": ["id"]
},
"id": "951de23c-2018-437b-961e-8ae7d7fd1a82",
"name": "aggregate selected fields into list",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [80, 500]
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "output",
"include": "allFieldsExcept",
"fieldsToExclude": ["char"]
},
"id": "b62c02ee-5edb-473d-a755-7fb8700641fa",
"name": "aggregate all fields except selected into list",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [80, 700]
}
],
"pinData": {
"fields aggregate and rename": [
{
"json": {
"data": [1, 2, 3, 4, 5]
}
}
],
"aggregate all fields into list": [
{
"json": {
"data": [
{
"id": 1,
"char": "a"
},
{
"id": 2,
"char": "b"
},
{
"id": 3,
"char": "c"
},
{
"id": 4,
"char": "d"
},
{
"id": 5,
"char": "e"
}
]
}
}
],
"aggregate selected fields into list": [
{
"json": {
"data": [
{
"id": 1
},
{
"id": 2
},
{
"id": 3
},
{
"id": 4
},
{
"id": 5
}
]
}
}
],
"aggregate all fields except selected into list": [
{
"json": {
"output": [
{
"id": 1
},
{
"id": 2
},
{
"id": 3
},
{
"id": 4
},
{
"id": 5
}
]
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "fields aggregate and rename",
"type": "main",
"index": 0
},
{
"node": "aggregate all fields into list",
"type": "main",
"index": 0
},
{
"node": "aggregate selected fields into list",
"type": "main",
"index": 0
},
{
"node": "aggregate all fields except selected into list",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "9bf7c52b-b118-4dad-bfef-7db41828393b",
"id": "105",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}

View file

@ -0,0 +1,60 @@
import type { IBinaryData, INodeExecutionData } from 'n8n-workflow';
type PartialBinaryData = Omit<IBinaryData, 'data'>;
const isBinaryUniqueSetup = () => {
const binaries: PartialBinaryData[] = [];
return (binary: IBinaryData) => {
for (const existingBinary of binaries) {
if (
existingBinary.mimeType === binary.mimeType &&
existingBinary.fileType === binary.fileType &&
existingBinary.fileSize === binary.fileSize &&
existingBinary.fileExtension === binary.fileExtension
) {
return false;
}
}
binaries.push({
mimeType: binary.mimeType,
fileType: binary.fileType,
fileSize: binary.fileSize,
fileExtension: binary.fileExtension,
});
return true;
};
};
export function addBinariesToItem(
newItem: INodeExecutionData,
items: INodeExecutionData[],
uniqueOnly?: boolean,
) {
const isBinaryUnique = uniqueOnly ? isBinaryUniqueSetup() : undefined;
for (const item of items) {
if (item.binary === undefined) continue;
for (const key of Object.keys(item.binary)) {
if (!newItem.binary) newItem.binary = {};
let binaryKey = key;
const binary = item.binary[key];
if (isBinaryUnique && !isBinaryUnique(binary)) {
continue;
}
// If the binary key already exists add a suffix to it
let i = 1;
while (newItem.binary[binaryKey] !== undefined) {
binaryKey = `${key}_${i}`;
i++;
}
newItem.binary[binaryKey] = binary;
}
}
return newItem;
}

View file

@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.limit",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.limit/"
}
],
"generic": []
},
"alias": ["Limit", "Remove", "Slice", "Transform", "Array", "List", "Item"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,70 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
export class Limit implements INodeType {
description: INodeTypeDescription = {
displayName: 'Limit',
name: 'limit',
icon: 'file:limit.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Restrict the number of items',
defaults: {
name: 'Limit',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Max Items',
name: 'maxItems',
type: 'number',
typeOptions: {
minValue: 1,
},
default: 1,
description: 'If there are more items than this number, some are removed',
},
{
displayName: 'Keep',
name: 'keep',
type: 'options',
options: [
{
name: 'First Items',
value: 'firstItems',
},
{
name: 'Last Items',
value: 'lastItems',
},
],
default: 'firstItems',
description: 'When removing items, whether to keep the ones at the start or the ending',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
let returnData = items;
const maxItems = this.getNodeParameter('maxItems', 0) as number;
const keep = this.getNodeParameter('keep', 0) as string;
if (maxItems > items.length) {
return [returnData];
}
if (keep === 'firstItems') {
returnData = items.slice(0, maxItems);
} else {
returnData = items.slice(items.length - maxItems, items.length);
}
return [returnData];
}
}

View file

@ -0,0 +1,16 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1156_1031)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M512 458C512 451.373 506.627 446 500 446L432 446C425.373 446 420 451.373 420 458L420 482C420 488.627 425.373 494 432 494L500 494C506.627 494 512 488.627 512 482L512 458Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M372 458C372 451.373 366.627 446 360 446L292 446C285.373 446 280 451.373 280 458L280 482C280 488.627 285.373 494 292 494L360 494C366.627 494 372 488.627 372 482L372 458Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M232 458C232 451.373 226.627 446 220 446L152 446C145.373 446 140 451.373 140 458L140 482C140 488.627 145.373 494 152 494L220 494C226.627 494 232 488.627 232 482L232 458Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92 458C92 451.373 86.6274 446 80 446L12 446C5.37258 446 1.61231e-06 451.373 1.36164e-06 458L4.53879e-07 482C2.03209e-07 488.627 5.37259 494 12 494L80 494C86.6274 494 92 488.627 92 482L92 458Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M244 236C237.373 236 232 230.627 232 224L232 30C232 23.3726 237.373 18 244 18L268 18C274.627 18 280 23.3726 280 30L280 224C280 230.627 274.627 236 268 236L244 236Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M149.577 146.982C158.975 137.636 174.171 137.678 183.518 147.077L256 219.964L328.482 147.077C337.829 137.678 353.025 137.636 362.423 146.982C371.822 156.329 371.864 171.525 362.518 180.923L273.018 270.923C268.513 275.453 262.388 278 256 278C249.612 278 243.487 275.453 238.982 270.923L149.482 180.923C140.136 171.525 140.178 156.329 149.577 146.982Z" fill="#2FB67C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-2.1626e-06 350C-2.56072e-06 343.373 5.37258 338 12 338L500 338C506.627 338 512 343.373 512 350L512 374C512 380.627 506.627 386 500 386L12 386C5.37258 386 -3.22744e-07 380.627 -7.20868e-07 374L-2.1626e-06 350Z" fill="#2FB67C"/>
</g>
<defs>
<clipPath id="clip0_1156_1031">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Limit Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,97 @@
{
"name": "itemLists test",
"nodes": [
{
"parameters": {},
"id": "bd7af0bb-de39-44b4-ac11-eb1d22f5e8d7",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [260, 180]
},
{
"parameters": {
"jsCode": "return [\n {entry: 1},\n {entry: 2},\n {entry: 3},\n {entry: 4},\n {entry: 5},\n];"
},
"id": "21185d7a-f0c1-49a0-9c2d-f0f198ceea7e",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [520, 180]
},
{
"parameters": {
"maxItems": 1
},
"id": "7cc02cc4-1f5f-489a-81e2-4c96b3bdf221",
"name": "Item Lists limit first",
"type": "n8n-nodes-base.limit",
"typeVersion": 1,
"position": [740, 80]
},
{
"parameters": {
"keep": "lastItems",
"maxItems": 1
},
"id": "2bf79d53-7a0b-4716-aa09-55ad43d306ae",
"name": "Item Lists limit last",
"type": "n8n-nodes-base.limit",
"typeVersion": 1,
"position": [740, 300]
}
],
"pinData": {
"Item Lists limit first": [
{
"json": {
"entry": 1
}
}
],
"Item Lists limit last": [
{
"json": {
"entry": 5
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Item Lists limit first",
"type": "main",
"index": 0
},
{
"node": "Item Lists limit last",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "5036d554-1ba4-4b5f-ba9f-1de6df09e807",
"id": "105",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}

View file

@ -0,0 +1,29 @@
{
"node": "n8n-nodes-base.removeDuplicates",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.removeduplicates/"
}
],
"generic": []
},
"alias": [
"Dedupe",
"Deduplicate",
"Duplicates",
"Remove",
"Unique",
"Transform",
"Array",
"List",
"Item"
],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,262 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import lt from 'lodash/lt';
import pick from 'lodash/pick';
import {
NodeOperationError,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { prepareFieldsArray } from '../utils/utils';
import { compareItems, flattenKeys } from './utils';
export class RemoveDuplicates implements INodeType {
description: INodeTypeDescription = {
displayName: 'Remove Duplicates',
name: 'removeDuplicates',
icon: 'file:removeDuplicates.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Delete items with matching field values',
defaults: {
name: 'Remove Duplicates',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Compare',
name: 'compare',
type: 'options',
options: [
{
name: 'All Fields',
value: 'allFields',
},
{
name: 'All Fields Except',
value: 'allFieldsExcept',
},
{
name: 'Selected Fields',
value: 'selectedFields',
},
],
default: 'allFields',
description: 'The fields of the input items to compare to see if they are the same',
},
{
displayName: 'Fields To Exclude',
name: 'fieldsToExclude',
type: 'string',
placeholder: 'e.g. email, name',
requiresDataPath: 'multiple',
description: 'Fields in the input to exclude from the comparison',
default: '',
displayOptions: {
show: {
compare: ['allFieldsExcept'],
},
},
},
{
displayName: 'Fields To Compare',
name: 'fieldsToCompare',
type: 'string',
placeholder: 'e.g. email, name',
requiresDataPath: 'multiple',
description: 'Fields in the input to add to the comparison',
default: '',
displayOptions: {
show: {
compare: ['selectedFields'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
compare: ['allFieldsExcept', 'selectedFields'],
},
},
options: [
{
displayName: 'Disable Dot Notation',
name: 'disableDotNotation',
type: 'boolean',
default: false,
description:
'Whether to disallow referencing child fields using `parent.child` in the field name',
},
{
displayName: 'Remove Other Fields',
name: 'removeOtherFields',
type: 'boolean',
default: false,
description:
'Whether to remove any fields that are not being compared. If disabled, will keep the values from the first of the duplicates.',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const compare = this.getNodeParameter('compare', 0) as string;
const disableDotNotation = this.getNodeParameter(
'options.disableDotNotation',
0,
false,
) as boolean;
const removeOtherFields = this.getNodeParameter(
'options.removeOtherFields',
0,
false,
) as boolean;
let keys = disableDotNotation
? Object.keys(items[0].json)
: Object.keys(flattenKeys(items[0].json));
for (const item of items) {
for (const key of disableDotNotation
? Object.keys(item.json)
: Object.keys(flattenKeys(item.json))) {
if (!keys.includes(key)) {
keys.push(key);
}
}
}
if (compare === 'allFieldsExcept') {
const fieldsToExclude = prepareFieldsArray(
this.getNodeParameter('fieldsToExclude', 0, '') as string,
'Fields To Exclude',
);
if (!fieldsToExclude.length) {
throw new NodeOperationError(
this.getNode(),
'No fields specified. Please add a field to exclude from comparison',
);
}
if (!disableDotNotation) {
keys = Object.keys(flattenKeys(items[0].json));
}
keys = keys.filter((key) => !fieldsToExclude.includes(key));
}
if (compare === 'selectedFields') {
const fieldsToCompare = prepareFieldsArray(
this.getNodeParameter('fieldsToCompare', 0, '') as string,
'Fields To Compare',
);
if (!fieldsToCompare.length) {
throw new NodeOperationError(
this.getNode(),
'No fields specified. Please add a field to compare on',
);
}
if (!disableDotNotation) {
keys = Object.keys(flattenKeys(items[0].json));
}
keys = fieldsToCompare.map((key) => key.trim());
}
// This solution is O(nlogn)
// add original index to the items
const newItems = items.map(
(item, index) =>
({
json: { ...item.json, __INDEX: index },
pairedItem: { item: index },
}) as INodeExecutionData,
);
//sort items using the compare keys
newItems.sort((a, b) => {
let result = 0;
for (const key of keys) {
let equal;
if (!disableDotNotation) {
equal = isEqual(get(a.json, key), get(b.json, key));
} else {
equal = isEqual(a.json[key], b.json[key]);
}
if (!equal) {
let lessThan;
if (!disableDotNotation) {
lessThan = lt(get(a.json, key), get(b.json, key));
} else {
lessThan = lt(a.json[key], b.json[key]);
}
result = lessThan ? -1 : 1;
break;
}
}
return result;
});
for (const key of keys) {
let type: any = undefined;
for (const item of newItems) {
if (key === '') {
throw new NodeOperationError(this.getNode(), 'Name of field to compare is blank');
}
const value = !disableDotNotation ? get(item.json, key) : item.json[key];
if (value === undefined && disableDotNotation && key.includes('.')) {
throw new NodeOperationError(
this.getNode(),
`'${key}' field is missing from some input items`,
{
description:
"If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options",
},
);
} else if (value === undefined) {
throw new NodeOperationError(
this.getNode(),
`'${key}' field is missing from some input items`,
);
}
if (type !== undefined && value !== undefined && type !== typeof value) {
throw new NodeOperationError(this.getNode(), `'${key}' isn't always the same type`, {
description: 'The type of this field varies between items',
});
} else {
type = typeof value;
}
}
}
// collect the original indexes of items to be removed
const removedIndexes: number[] = [];
let temp = newItems[0];
for (let index = 1; index < newItems.length; index++) {
if (compareItems(newItems[index], temp, keys, disableDotNotation, this.getNode())) {
removedIndexes.push(newItems[index].json.__INDEX as unknown as number);
} else {
temp = newItems[index];
}
}
let returnData = items.filter((_, index) => !removedIndexes.includes(index));
if (removeOtherFields) {
returnData = returnData.map((item, index) => ({
json: pick(item.json, ...keys),
pairedItem: { item: index },
}));
}
return [returnData];
}
}

View file

@ -0,0 +1,15 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1156_1098)">
<path d="M134.097 111H172.926V143.508H138.16V178.143H105.652V139.444C105.652 123.735 118.387 111 134.097 111Z" fill="#54B8C9"/>
<path d="M211.755 143.508V111H289.412V143.508H211.755Z" fill="#54B8C9"/>
<path d="M328.241 143.508V111H405.899V143.508H328.241Z" fill="#54B8C9"/>
<path d="M444.728 143.508V111H483.557C499.267 111 512.002 123.735 512.002 139.444V178.143H479.494V143.508H444.728Z" fill="#54B8C9"/>
<path d="M479.494 216.746H512.002V255.444C512.002 271.154 499.267 283.889 483.557 283.889H444.728V251.381H479.494V216.746Z" fill="#54B8C9"/>
<path d="M0 244.537C0 229.329 12.735 217 28.4444 217H377.905C393.614 217 406.349 229.329 406.349 244.537V374.352C406.349 389.56 393.614 401.889 377.905 401.889H28.4444C12.735 401.889 0 389.56 0 374.352V244.537Z" fill="#54B8C9"/>
</g>
<defs>
<clipPath id="clip0_1156_1098">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Remove Duplicates Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,326 @@
{
"name": "Remove Duplicates",
"nodes": [
{
"parameters": {},
"id": "a4da10da-991f-48ab-b873-9d633a11311f",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
760,
420
]
},
{
"parameters": {
"jsCode": "return [{ id: 1, name: 'John Doe', age: 18 },{ id: 1, name: 'John Doe', age: 18 },\n { id: 1, name: 'John Doe', age: 98 },\n { id: 3, name: 'Bob Johnson', age:34 }]"
},
"id": "7ab7d5cd-0b1e-48bc-bdbd-57c91e201cf3",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
980,
420
]
},
{
"parameters": {},
"id": "c336939c-062e-475e-ba7c-8601e3662e8c",
"name": "Remove Duplicates (All Fields)",
"type": "n8n-nodes-base.removeDuplicates",
"typeVersion": 1,
"position": [
1200,
260
]
},
{
"parameters": {
"compare": "selectedFields",
"fieldsToCompare": "name",
"options": {}
},
"id": "d4343ffe-8a9e-4e34-a0a1-aa463afedd80",
"name": "Remove Duplicates (Selected Fields)",
"type": "n8n-nodes-base.removeDuplicates",
"typeVersion": 1,
"position": [
1200,
420
]
},
{
"parameters": {
"compare": "allFieldsExcept",
"fieldsToExclude": "age",
"options": {}
},
"id": "b67daea4-4545-429e-9e2a-58f2d6a7df7b",
"name": "Remove Duplicates (Except Fields)",
"type": "n8n-nodes-base.removeDuplicates",
"typeVersion": 1,
"position": [
1200,
580
]
},
{
"parameters": {},
"id": "813e690f-a83e-4a38-a64a-c3d72afcc9ba",
"name": "All Fields",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1380,
260
]
},
{
"parameters": {},
"id": "b5c5c946-2e96-451b-b9a6-78e478504d6c",
"name": "Selected Fields",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1380,
420
]
},
{
"parameters": {},
"id": "afb92bc5-beba-4b0a-aefb-b47cc708a125",
"name": "Except Fields",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1380,
580
]
},
{
"parameters": {
"compare": "allFieldsExcept",
"fieldsToExclude": "age",
"options": {
"removeOtherFields": true
}
},
"id": "f92c5533-ac29-476c-aebb-4849ddd22110",
"name": "Remove Duplicates (Remove)",
"type": "n8n-nodes-base.removeDuplicates",
"typeVersion": 1,
"position": [
1200,
760
]
},
{
"parameters": {},
"id": "1e142ab7-b32e-4f67-b5cc-5c9fb63fba89",
"name": "Remove",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
1380,
760
]
}
],
"pinData": {
"Code": [
{
"json": {
"id": 1,
"name": "John Doe",
"age": 18
}
},
{
"json": {
"id": 1,
"name": "John Doe",
"age": 18
}
},
{
"json": {
"id": 1,
"name": "John Doe",
"age": 98
}
},
{
"json": {
"id": 3,
"name": "Bob Johnson",
"age": 34
}
}
],
"All Fields": [
{
"json": {
"id": 1,
"name": "John Doe",
"age": 18
}
},
{
"json": {
"id": 1,
"name": "John Doe",
"age": 98
}
},
{
"json": {
"id": 3,
"name": "Bob Johnson",
"age": 34
}
}
],
"Selected Fields": [
{
"json": {
"id": 1,
"name": "John Doe",
"age": 18
}
},
{
"json": {
"id": 3,
"name": "Bob Johnson",
"age": 34
}
}
],
"Except Fields": [
{
"json": {
"id": 1,
"name": "John Doe",
"age": 18
}
},
{
"json": {
"id": 3,
"name": "Bob Johnson",
"age": 34
}
}
],
"Remove": [
{
"json": {
"id": 1,
"name": "John Doe"
}
},
{
"json": {
"id": 3,
"name": "Bob Johnson"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Remove Duplicates (All Fields)",
"type": "main",
"index": 0
},
{
"node": "Remove Duplicates (Selected Fields)",
"type": "main",
"index": 0
},
{
"node": "Remove Duplicates (Except Fields)",
"type": "main",
"index": 0
},
{
"node": "Remove Duplicates (Remove)",
"type": "main",
"index": 0
}
]
]
},
"Remove Duplicates (All Fields)": {
"main": [
[
{
"node": "All Fields",
"type": "main",
"index": 0
}
]
]
},
"Remove Duplicates (Selected Fields)": {
"main": [
[
{
"node": "Selected Fields",
"type": "main",
"index": 0
}
]
]
},
"Remove Duplicates (Except Fields)": {
"main": [
[
{
"node": "Except Fields",
"type": "main",
"index": 0
}
]
]
},
"Remove Duplicates (Remove)": {
"main": [
[
{
"node": "Remove",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "5bb09766-4c67-4fb4-ae53-89d8db4727e3",
"id": "74gMYOHjjPArZg4q",
"meta": {
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"tags": [
]
}

View file

@ -0,0 +1,36 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import isObject from 'lodash/isObject';
import merge from 'lodash/merge';
import reduce from 'lodash/reduce';
import type { IDataObject, INode, INodeExecutionData } from 'n8n-workflow';
export const compareItems = (
obj: INodeExecutionData,
obj2: INodeExecutionData,
keys: string[],
disableDotNotation: boolean,
_node: INode,
) => {
let result = true;
for (const key of keys) {
if (!disableDotNotation) {
if (!isEqual(get(obj.json, key), get(obj2.json, key))) {
result = false;
break;
}
} else {
if (!isEqual(obj.json[key], obj2.json[key])) {
result = false;
break;
}
}
}
return result;
};
export const flattenKeys = (obj: IDataObject, path: string[] = []): IDataObject => {
return !isObject(obj)
? { [path.join('.')]: obj }
: reduce(obj, (cum, next, key) => merge(cum, flattenKeys(next as IDataObject, [...path, key])), {}); //prettier-ignore
};

View file

@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.sort",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.sort/"
}
],
"generic": []
},
"alias": ["Sort", "Order", "Transform", "Array", "List", "Item"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,285 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import lt from 'lodash/lt';
import {
NodeOperationError,
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { shuffleArray, sortByCode } from './utils';
export class Sort implements INodeType {
description: INodeTypeDescription = {
displayName: 'Sort',
name: 'sort',
icon: 'file:sort.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Change items order',
defaults: {
name: 'Sort',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Type',
name: 'type',
type: 'options',
options: [
{
name: 'Simple',
value: 'simple',
},
{
name: 'Random',
value: 'random',
},
{
name: 'Code',
value: 'code',
},
],
default: 'simple',
description: 'The fields of the input items to compare to see if they are the same',
},
{
displayName: 'Fields To Sort By',
name: 'sortFieldsUi',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
placeholder: 'Add Field To Sort By',
options: [
{
displayName: '',
name: 'sortField',
values: [
{
displayName: 'Field Name',
name: 'fieldName',
type: 'string',
required: true,
default: '',
description: 'The field to sort by',
// eslint-disable-next-line n8n-nodes-base/node-param-placeholder-miscased-id
placeholder: 'e.g. id',
hint: ' Enter the field name as text',
requiresDataPath: 'single',
},
{
displayName: 'Order',
name: 'order',
type: 'options',
options: [
{
name: 'Ascending',
value: 'ascending',
},
{
name: 'Descending',
value: 'descending',
},
],
default: 'ascending',
description: 'The order to sort by',
},
],
},
],
default: {},
description: 'The fields of the input items to compare to see if they are the same',
displayOptions: {
show: {
type: ['simple'],
},
},
},
{
displayName: 'Code',
name: 'code',
type: 'string',
typeOptions: {
alwaysOpenEditWindow: true,
editor: 'code',
rows: 10,
},
default: `// The two items to compare are in the variables a and b
// Access the fields in a.json and b.json
// Return -1 if a should go before b
// Return 1 if b should go before a
// Return 0 if there's no difference
fieldName = 'myField';
if (a.json[fieldName] < b.json[fieldName]) {
return -1;
}
if (a.json[fieldName] > b.json[fieldName]) {
return 1;
}
return 0;`,
description: 'Javascript code to determine the order of any two items',
displayOptions: {
show: {
type: ['code'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
type: ['simple'],
},
},
options: [
{
displayName: 'Disable Dot Notation',
name: 'disableDotNotation',
type: 'boolean',
default: false,
description:
'Whether to disallow referencing child fields using `parent.child` in the field name',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
let returnData = [...items];
const type = this.getNodeParameter('type', 0) as string;
const disableDotNotation = this.getNodeParameter(
'options.disableDotNotation',
0,
false,
) as boolean;
if (type === 'random') {
shuffleArray(returnData);
return [returnData];
}
if (type === 'simple') {
const sortFieldsUi = this.getNodeParameter('sortFieldsUi', 0) as IDataObject;
const sortFields = sortFieldsUi.sortField as Array<{
fieldName: string;
order: 'ascending' | 'descending';
}>;
if (!sortFields?.length) {
throw new NodeOperationError(
this.getNode(),
'No sorting specified. Please add a field to sort by',
);
}
for (const { fieldName } of sortFields) {
let found = false;
for (const item of items) {
if (!disableDotNotation) {
if (get(item.json, fieldName) !== undefined) {
found = true;
}
} else if (item.json.hasOwnProperty(fieldName)) {
found = true;
}
}
if (!found && disableDotNotation && fieldName.includes('.')) {
throw new NodeOperationError(
this.getNode(),
`Couldn't find the field '${fieldName}' in the input data`,
{
description:
"If you're trying to use a nested field, make sure you turn off 'disable dot notation' in the node options",
},
);
} else if (!found) {
throw new NodeOperationError(
this.getNode(),
`Couldn't find the field '${fieldName}' in the input data`,
);
}
}
const sortFieldsWithDirection = sortFields.map((field) => ({
name: field.fieldName,
dir: field.order === 'ascending' ? 1 : -1,
}));
returnData.sort((a, b) => {
let result = 0;
for (const field of sortFieldsWithDirection) {
let equal;
if (!disableDotNotation) {
const _a =
typeof get(a.json, field.name) === 'string'
? (get(a.json, field.name) as string).toLowerCase()
: get(a.json, field.name);
const _b =
typeof get(b.json, field.name) === 'string'
? (get(b.json, field.name) as string).toLowerCase()
: get(b.json, field.name);
equal = isEqual(_a, _b);
} else {
const _a =
typeof a.json[field.name] === 'string'
? (a.json[field.name] as string).toLowerCase()
: a.json[field.name];
const _b =
typeof b.json[field.name] === 'string'
? (b.json[field.name] as string).toLowerCase()
: b.json[field.name];
equal = isEqual(_a, _b);
}
if (!equal) {
let lessThan;
if (!disableDotNotation) {
const _a =
typeof get(a.json, field.name) === 'string'
? (get(a.json, field.name) as string).toLowerCase()
: get(a.json, field.name);
const _b =
typeof get(b.json, field.name) === 'string'
? (get(b.json, field.name) as string).toLowerCase()
: get(b.json, field.name);
lessThan = lt(_a, _b);
} else {
const _a =
typeof a.json[field.name] === 'string'
? (a.json[field.name] as string).toLowerCase()
: a.json[field.name];
const _b =
typeof b.json[field.name] === 'string'
? (b.json[field.name] as string).toLowerCase()
: b.json[field.name];
lessThan = lt(_a, _b);
}
if (lessThan) {
result = -1 * field.dir;
} else {
result = 1 * field.dir;
}
break;
}
}
return result;
});
} else {
returnData = sortByCode.call(this, returnData);
}
return [returnData];
}
}

View file

@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 422.5C123.873 422.5 118.5 417.127 118.5 410.5L118.5 59.5C118.5 52.8726 123.873 47.5 130.5 47.5L154.5 47.5C161.127 47.5 166.5 52.8726 166.5 59.5L166.5 410.5C166.5 417.127 161.127 422.5 154.5 422.5L130.5 422.5Z" fill="#8287EB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.0768 333.482C45.4754 324.136 60.6713 324.178 70.0178 333.577L142.5 406.464L214.982 333.577C224.329 324.178 239.525 324.136 248.923 333.482C258.322 342.829 258.364 358.025 249.018 367.423L159.518 457.423C155.013 461.953 148.888 464.5 142.5 464.5C136.112 464.5 129.987 461.953 125.482 457.423L35.9822 367.423C26.6358 358.025 26.6781 342.829 36.0768 333.482Z" fill="#8287EB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M381.5 89.5C388.127 89.5 393.5 94.8726 393.5 101.5L393.5 452.5C393.5 459.127 388.127 464.5 381.5 464.5L357.5 464.5C350.873 464.5 345.5 459.127 345.5 452.5L345.5 101.5C345.5 94.8726 350.873 89.5 357.5 89.5L381.5 89.5Z" fill="#8287EB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M475.923 178.518C466.525 187.864 451.329 187.822 441.982 178.423L369.5 105.536L297.018 178.423C287.671 187.822 272.475 187.864 263.077 178.518C253.678 169.171 253.636 153.975 262.982 144.577L352.482 54.5768C356.987 50.0469 363.112 47.5 369.5 47.5C375.888 47.5 382.013 50.0469 386.518 54.5768L476.018 144.577C485.364 153.975 485.322 169.171 475.923 178.518Z" fill="#8287EB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Sort Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,213 @@
{
"name": "sort test",
"nodes": [
{
"parameters": {},
"id": "6c90bf81-0c0e-4c5f-9f0c-297f06d9668a",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-440, 300]
},
{
"parameters": {
"jsCode": "return [\n {id: 3, char: 'c'},\n {id: 4, char: 'd'},\n {id: 5, char: 'e'},\n {id: 1, char: 'a'},\n {id: 2, char: 'b'},\n];"
},
"id": "2e0011d5-c6a0-4a40-ab8c-9d011cde40d5",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [-180, 300]
},
{
"parameters": {
"sortFieldsUi": {
"sortField": [
{
"fieldName": "char",
"order": "descending"
},
{
"fieldName": "id",
"order": "descending"
}
]
},
"options": {}
},
"id": "20031848-2374-45b2-98db-69d7b8d055ad",
"name": "Item Lists1",
"type": "n8n-nodes-base.sort",
"typeVersion": 1,
"position": [80, 300]
},
{
"parameters": {
"sortFieldsUi": {
"sortField": [
{
"fieldName": "id"
}
]
},
"options": {}
},
"id": "93dd8c32-21e1-4762-a340-b3e8c6866811",
"name": "Item Lists",
"type": "n8n-nodes-base.sort",
"typeVersion": 1,
"position": [80, 120]
},
{
"parameters": {
"type": "code",
"code": "// The two items to compare are in the variables a and b\n// Access the fields in a.json and b.json\n// Return -1 if a should go before b\n// Return 1 if b should go before a\n// Return 0 if there's no difference\n\nfieldName = 'id';\n\nif (a.json[fieldName] < b.json[fieldName]) {\n\t\treturn -1;\n}\nif (a.json[fieldName] > b.json[fieldName]) {\n\t\treturn 1;\n}\nreturn 0;"
},
"id": "112c72e6-b5d9-4d6d-87fc-2621fbaa5bf7",
"name": "Item Lists2",
"type": "n8n-nodes-base.sort",
"typeVersion": 1,
"position": [80, 500]
}
],
"pinData": {
"Item Lists": [
{
"json": {
"id": 1,
"char": "a"
}
},
{
"json": {
"id": 2,
"char": "b"
}
},
{
"json": {
"id": 3,
"char": "c"
}
},
{
"json": {
"id": 4,
"char": "d"
}
},
{
"json": {
"id": 5,
"char": "e"
}
}
],
"Item Lists1": [
{
"json": {
"id": 5,
"char": "e"
}
},
{
"json": {
"id": 4,
"char": "d"
}
},
{
"json": {
"id": 3,
"char": "c"
}
},
{
"json": {
"id": 2,
"char": "b"
}
},
{
"json": {
"id": 1,
"char": "a"
}
}
],
"Item Lists2": [
{
"json": {
"id": 1,
"char": "a"
}
},
{
"json": {
"id": 2,
"char": "b"
}
},
{
"json": {
"id": 3,
"char": "c"
}
},
{
"json": {
"id": 4,
"char": "d"
}
},
{
"json": {
"id": 5,
"char": "e"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Item Lists1",
"type": "main",
"index": 0
},
{
"node": "Item Lists",
"type": "main",
"index": 0
},
{
"node": "Item Lists2",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "6f896427-a3be-44bc-898f-c1a6f58fa1e1",
"id": "105",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}

View file

@ -0,0 +1,31 @@
import { NodeVM } from '@n8n/vm2';
import { type IExecuteFunctions, type INodeExecutionData, NodeOperationError } from 'n8n-workflow';
export const shuffleArray = (array: any[]) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
};
const returnRegExp = /\breturn\b/g;
export function sortByCode(
this: IExecuteFunctions,
items: INodeExecutionData[],
): INodeExecutionData[] {
const code = this.getNodeParameter('code', 0) as string;
if (!returnRegExp.test(code)) {
throw new NodeOperationError(
this.getNode(),
"Sort code doesn't return. Please add a 'return' statement to your code",
);
}
const mode = this.getMode();
const vm = new NodeVM({
console: mode === 'manual' ? 'redirect' : 'inherit',
sandbox: { items },
});
return vm.run(`module.exports = items.sort((a, b) => { ${code} })`);
}

View file

@ -0,0 +1,19 @@
{
"node": "n8n-nodes-base.splitOut",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.splitout/"
}
],
"generic": []
},
"alias": ["Split", "Nested", "Transform", "Array", "List", "Item"],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,259 @@
import get from 'lodash/get';
import unset from 'lodash/unset';
import {
type IBinaryData,
NodeOperationError,
deepCopy,
type IDataObject,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import { prepareFieldsArray } from '../utils/utils';
export class SplitOut implements INodeType {
description: INodeTypeDescription = {
displayName: 'Split Out',
name: 'splitOut',
icon: 'file:splitOut.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Turn a list inside item(s) into separate items',
defaults: {
name: 'Split Out',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Fields To Split Out',
name: 'fieldToSplitOut',
type: 'string',
default: '',
required: true,
placeholder: 'Drag fields from the left or type their names',
description:
'The name of the input fields to break out into separate items. Separate multiple field names by commas. For binary data, use $binary.',
requiresDataPath: 'multiple',
},
{
displayName: 'Include',
name: 'include',
type: 'options',
options: [
{
name: 'No Other Fields',
value: 'noOtherFields',
},
{
name: 'All Other Fields',
value: 'allOtherFields',
},
{
name: 'Selected Other Fields',
value: 'selectedOtherFields',
},
],
default: 'noOtherFields',
description: 'Whether to copy any other fields into the new items',
},
{
displayName: 'Fields To Include',
name: 'fieldsToInclude',
type: 'string',
placeholder: 'e.g. email, name',
requiresDataPath: 'multiple',
description: 'Fields in the input items to aggregate together',
default: '',
displayOptions: {
show: {
include: ['selectedOtherFields'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Disable Dot Notation',
name: 'disableDotNotation',
type: 'boolean',
default: false,
description:
'Whether to disallow referencing child fields using `parent.child` in the field name',
},
{
displayName: 'Destination Field Name',
name: 'destinationFieldName',
type: 'string',
requiresDataPath: 'multiple',
default: '',
description: 'The field in the output under which to put the split field contents',
},
{
displayName: 'Include Binary',
name: 'includeBinary',
type: 'boolean',
default: false,
description: 'Whether to include the binary data in the new items',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
for (let i = 0; i < items.length; i++) {
const fieldsToSplitOut = (this.getNodeParameter('fieldToSplitOut', i) as string)
.split(',')
.map((field) => field.trim().replace(/^\$json\./, ''));
const options = this.getNodeParameter('options', i, {});
const disableDotNotation = options.disableDotNotation as boolean;
const destinationFields = ((options.destinationFieldName as string) || '')
.split(',')
.filter((field) => field.trim() !== '')
.map((field) => field.trim());
if (destinationFields.length && destinationFields.length !== fieldsToSplitOut.length) {
throw new NodeOperationError(
this.getNode(),
'If multiple fields to split out are given, the same number of destination fields must be given',
);
}
const include = this.getNodeParameter('include', i) as
| 'selectedOtherFields'
| 'allOtherFields'
| 'noOtherFields';
const multiSplit = fieldsToSplitOut.length > 1;
const item = { ...items[i].json };
const splited: INodeExecutionData[] = [];
for (const [entryIndex, fieldToSplitOut] of fieldsToSplitOut.entries()) {
const destinationFieldName = destinationFields[entryIndex] || '';
let entityToSplit: IDataObject[] = [];
if (fieldToSplitOut === '$binary') {
entityToSplit = Object.entries(items[i].binary || {}).map(([key, value]) => ({
[key]: value,
}));
} else {
if (!disableDotNotation) {
entityToSplit = get(item, fieldToSplitOut) as IDataObject[];
} else {
entityToSplit = item[fieldToSplitOut] as IDataObject[];
}
if (entityToSplit === undefined) {
entityToSplit = [];
}
if (typeof entityToSplit !== 'object' || entityToSplit === null) {
entityToSplit = [entityToSplit];
}
if (!Array.isArray(entityToSplit)) {
entityToSplit = Object.values(entityToSplit);
}
}
for (const [elementIndex, element] of entityToSplit.entries()) {
if (splited[elementIndex] === undefined) {
splited[elementIndex] = { json: {}, pairedItem: { item: i } };
}
const fieldName = destinationFieldName || fieldToSplitOut;
if (fieldToSplitOut === '$binary') {
if (splited[elementIndex].binary === undefined) {
splited[elementIndex].binary = {};
}
splited[elementIndex].binary![Object.keys(element)[0]] = Object.values(
element,
)[0] as IBinaryData;
continue;
}
if (typeof element === 'object' && element !== null && include === 'noOtherFields') {
if (destinationFieldName === '' && !multiSplit) {
splited[elementIndex] = {
json: { ...splited[elementIndex].json, ...element },
pairedItem: { item: i },
};
} else {
splited[elementIndex].json[fieldName] = element;
}
} else {
splited[elementIndex].json[fieldName] = element;
}
}
}
for (const splitEntry of splited) {
let newItem: INodeExecutionData = splitEntry;
if (include === 'allOtherFields') {
const itemCopy = deepCopy(item);
for (const fieldToSplitOut of fieldsToSplitOut) {
if (!disableDotNotation) {
unset(itemCopy, fieldToSplitOut);
} else {
delete itemCopy[fieldToSplitOut];
}
}
newItem.json = { ...itemCopy, ...splitEntry.json };
}
if (include === 'selectedOtherFields') {
const fieldsToInclude = prepareFieldsArray(
this.getNodeParameter('fieldsToInclude', i, '') as string,
'Fields To Include',
);
if (!fieldsToInclude.length) {
throw new NodeOperationError(this.getNode(), 'No fields specified', {
description: 'Please add a field to include',
});
}
for (const field of fieldsToInclude) {
if (!disableDotNotation) {
splitEntry.json[field] = get(item, field);
} else {
splitEntry.json[field] = item[field];
}
}
newItem = splitEntry;
}
const includeBinary = options.includeBinary as boolean;
if (includeBinary) {
if (items[i].binary && !newItem.binary) {
newItem.binary = items[i].binary;
}
}
returnData.push(newItem);
}
}
return [returnData];
}
}

View file

@ -0,0 +1,14 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1147_350)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M480 148C480 141.373 474.627 136 468 136L322 136C315.373 136 310 141.373 310 148L310 172C310 178.627 315.373 184 322 184L468 184C474.627 184 480 178.627 480 172L480 148Z" fill="#9B6DD5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M480 244C480 237.373 474.627 232 468 232L322 232C315.373 232 310 237.373 310 244L310 268C310 274.627 315.373 280 322 280L468 280C474.627 280 480 274.627 480 268L480 244Z" fill="#9B6DD5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M480 340C480 333.373 474.627 328 468 328L322 328C315.373 328 310 333.373 310 340L310 364C310 370.627 315.373 376 322 376L468 376C474.627 376 480 370.627 480 364L480 340Z" fill="#9B6DD5"/>
<path d="M438 76C438 82.6274 432.627 88 426 88H309.783C292.11 88 277.783 102.327 277.783 120V176C277.783 202.978 267.511 227.557 250.664 246.039C245.609 251.584 245.609 260.416 250.664 265.961C267.511 284.443 277.783 309.022 277.783 336V392C277.783 409.673 292.11 424 309.783 424H426C432.627 424 438 429.373 438 436V460C438 466.627 432.627 472 426 472H309.783C265.6 472 229.783 436.183 229.783 392V336C229.783 305.072 204.711 280 173.783 280V280C170.589 280 168 277.411 168 274.217V237.783C168 234.589 170.589 232 173.783 232V232C204.711 232 229.783 206.928 229.783 176V120C229.783 75.8172 265.6 40 309.783 40H426C432.627 40 438 45.3726 438 52V76Z" fill="#9B6DD5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M136 244C136 237.373 130.627 232 124 232L12 232C5.37257 232 -8.97182e-06 237.373 -8.39243e-06 244L-6.29428e-06 268C-5.71489e-06 274.627 5.37258 280 12 280L124 280C130.627 280 136 274.627 136 268L136 244Z" fill="#9B6DD5"/>
</g>
<defs>
<clipPath id="clip0_1147_350">
<rect width="512" height="512" fill="white" transform="matrix(-1 0 0 1 512 0)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Split Out Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,360 @@
{
"name": "splitOut test",
"nodes": [
{
"parameters": {},
"id": "6c90bf81-0c0e-4c5f-9f0c-297f06d9668a",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-400, 400]
},
{
"parameters": {
"jsCode": "return { \ndata:[\n {id: 3, char: 'c'},\n {id: 4, char: 'd'},\n {id: 5, char: 'e'},\n {id: 1, char: 'a'},\n {id: 2, char: 'b'},\n],\ndata2: [\n {text: 'foo'},\n],\ndata3: [\n {text: 'bar'},\n],\n};"
},
"id": "2e0011d5-c6a0-4a40-ab8c-9d011cde40d5",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [-180, 400]
},
{
"parameters": {
"fieldToSplitOut": "data",
"options": {}
},
"id": "e7eac465-8fe6-498c-9942-ebd47df537c1",
"name": "Item Lists",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [80, 160]
},
{
"parameters": {
"fieldToSplitOut": "data",
"include": "allOtherFields",
"options": {}
},
"id": "09b7fe15-dbad-4ca6-bf1e-3093139d14e5",
"name": "Item Lists1",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [80, 320]
},
{
"parameters": {
"fieldToSplitOut": "data",
"include": "selectedOtherFields",
"fieldsToInclude": ["data3"],
"options": {}
},
"id": "7ea63dc7-8141-4233-af47-9894919c7fe4",
"name": "Item Lists2",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [80, 480]
},
{
"parameters": {
"fieldToSplitOut": "data",
"options": {
"destinationFieldName": "output"
}
},
"id": "89c3c1b4-9577-480a-931f-3b34450b23cb",
"name": "Item Lists3",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [80, 660]
}
],
"pinData": {
"Item Lists": [
{
"json": {
"id": 3,
"char": "c"
}
},
{
"json": {
"id": 4,
"char": "d"
}
},
{
"json": {
"id": 5,
"char": "e"
}
},
{
"json": {
"id": 1,
"char": "a"
}
},
{
"json": {
"id": 2,
"char": "b"
}
}
],
"Item Lists1": [
{
"json": {
"data2": [
{
"text": "foo"
}
],
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 3,
"char": "c"
}
}
},
{
"json": {
"data2": [
{
"text": "foo"
}
],
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 4,
"char": "d"
}
}
},
{
"json": {
"data2": [
{
"text": "foo"
}
],
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 5,
"char": "e"
}
}
},
{
"json": {
"data2": [
{
"text": "foo"
}
],
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 1,
"char": "a"
}
}
},
{
"json": {
"data2": [
{
"text": "foo"
}
],
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 2,
"char": "b"
}
}
}
],
"Item Lists2": [
{
"json": {
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 3,
"char": "c"
}
}
},
{
"json": {
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 4,
"char": "d"
}
}
},
{
"json": {
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 5,
"char": "e"
}
}
},
{
"json": {
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 1,
"char": "a"
}
}
},
{
"json": {
"data3": [
{
"text": "bar"
}
],
"data": {
"id": 2,
"char": "b"
}
}
}
],
"Item Lists3": [
{
"json": {
"output": {
"id": 3,
"char": "c"
}
}
},
{
"json": {
"output": {
"id": 4,
"char": "d"
}
}
},
{
"json": {
"output": {
"id": 5,
"char": "e"
}
}
},
{
"json": {
"output": {
"id": 1,
"char": "a"
}
}
},
{
"json": {
"output": {
"id": 2,
"char": "b"
}
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Item Lists",
"type": "main",
"index": 0
},
{
"node": "Item Lists1",
"type": "main",
"index": 0
},
{
"node": "Item Lists2",
"type": "main",
"index": 0
},
{
"node": "Item Lists3",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "9230f580-6f41-47c9-9949-bf258fc3fa47",
"id": "105",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}

View file

@ -0,0 +1,386 @@
{
"name": "itemList split Object",
"nodes": [
{
"parameters": {},
"id": "ade46a75-ab57-48c6-886b-0c118f5ef1c6",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [520, 800]
},
{
"parameters": {
"fieldToSplitOut": "data",
"include": "selectedOtherFields",
"fieldsToInclude": ["tag"],
"options": {}
},
"id": "45e1d7a3-d6e8-4b69-a68a-1038db13be4c",
"name": "Item Lists1",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [1120, 340]
},
{
"parameters": {
"jsCode": "const data = {\n entry1: {\n id: 1,\n info: 'some info 1',\n },\n entry2: {\n id: 2,\n info: 'some info 2',\n },\n entry3: {\n id: 3,\n info: 'some info 3',\n },\n};\n\n\nconst data2 = [\n 'a', 'b', 'c'\n];\n\nconst data3 = {\n a: 1,\n b: 2,\n c: 3,\n};\n\nreturn {data, data2, data3, data4: null, tag: 'bar'};"
},
"id": "faa78fac-468d-42b8-96e9-0fb62c312da3",
"name": "Code1",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [760, 800]
},
{
"parameters": {},
"id": "5baaf321-7e89-473d-a313-7cb90b3f13b3",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1380, 340]
},
{
"parameters": {
"fieldToSplitOut": "data3",
"include": "allOtherFields",
"options": {
"destinationFieldName": "extracted"
}
},
"id": "a786bea9-eb29-4c6d-aea6-a22aee622bc6",
"name": "Item Lists",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [1120, 720]
},
{
"parameters": {},
"id": "0521a24b-c74a-48fa-ae50-48a242b97806",
"name": "No Operation, do nothing1",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1380, 720]
},
{
"parameters": {
"fieldToSplitOut": "data3",
"options": {}
},
"id": "0c1c8827-72ab-4738-918c-d529e66505c6",
"name": "Item Lists2",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [1120, 540]
},
{
"parameters": {},
"id": "4c0dca36-c2ae-4d40-8952-0e728ac93fa3",
"name": "No Operation, do nothing2",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1380, 540]
},
{
"parameters": {
"fieldToSplitOut": "data2",
"options": {}
},
"id": "b2031380-b2a8-426d-8f7a-ab072d23b979",
"name": "Item Lists3",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [1120, 920]
},
{
"parameters": {},
"id": "617f7259-beee-42f1-bba2-4e75a83fe369",
"name": "No Operation, do nothing3",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1380, 920]
},
{
"parameters": {
"fieldToSplitOut": "data4",
"include": "allOtherFields",
"options": {}
},
"id": "8909b8eb-e5a9-4436-8e62-09d8c9670ac1",
"name": "Item Lists4",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [1120, 1140],
"continueOnFail": true
},
{
"parameters": {},
"id": "a9278f90-8ad9-42dc-85b6-28bf1b6764b7",
"name": "No Operation, do nothing4",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1380, 1140]
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"tag": "bar",
"data": {
"id": 1,
"info": "some info 1"
}
}
},
{
"json": {
"tag": "bar",
"data": {
"id": 2,
"info": "some info 2"
}
}
},
{
"json": {
"tag": "bar",
"data": {
"id": 3,
"info": "some info 3"
}
}
}
],
"No Operation, do nothing2": [
{
"json": {
"data3": 1
}
},
{
"json": {
"data3": 2
}
},
{
"json": {
"data3": 3
}
}
],
"No Operation, do nothing1": [
{
"json": {
"data": {
"entry1": {
"id": 1,
"info": "some info 1"
},
"entry2": {
"id": 2,
"info": "some info 2"
},
"entry3": {
"id": 3,
"info": "some info 3"
}
},
"data2": ["a", "b", "c"],
"data4": null,
"tag": "bar",
"extracted": 1
}
},
{
"json": {
"data": {
"entry1": {
"id": 1,
"info": "some info 1"
},
"entry2": {
"id": 2,
"info": "some info 2"
},
"entry3": {
"id": 3,
"info": "some info 3"
}
},
"data2": ["a", "b", "c"],
"data4": null,
"tag": "bar",
"extracted": 2
}
},
{
"json": {
"data": {
"entry1": {
"id": 1,
"info": "some info 1"
},
"entry2": {
"id": 2,
"info": "some info 2"
},
"entry3": {
"id": 3,
"info": "some info 3"
}
},
"data2": ["a", "b", "c"],
"data4": null,
"tag": "bar",
"extracted": 3
}
}
],
"No Operation, do nothing3": [
{
"json": {
"data2": "a"
}
},
{
"json": {
"data2": "b"
}
},
{
"json": {
"data2": "c"
}
}
],
"No Operation, do nothing4": [
{
"json": {
"data": {
"entry1": {
"id": 1,
"info": "some info 1"
},
"entry2": {
"id": 2,
"info": "some info 2"
},
"entry3": {
"id": 3,
"info": "some info 3"
}
},
"data2": ["a", "b", "c"],
"data3": {
"a": 1,
"b": 2,
"c": 3
},
"data4": null,
"tag": "bar"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code1",
"type": "main",
"index": 0
}
]
]
},
"Code1": {
"main": [
[
{
"node": "Item Lists1",
"type": "main",
"index": 0
},
{
"node": "Item Lists2",
"type": "main",
"index": 0
},
{
"node": "Item Lists",
"type": "main",
"index": 0
},
{
"node": "Item Lists3",
"type": "main",
"index": 0
},
{
"node": "Item Lists4",
"type": "main",
"index": 0
}
]
]
},
"Item Lists1": {
"main": [
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
},
"Item Lists": {
"main": [
[
{
"node": "No Operation, do nothing1",
"type": "main",
"index": 0
}
]
]
},
"Item Lists2": {
"main": [
[
{
"node": "No Operation, do nothing2",
"type": "main",
"index": 0
}
]
]
},
"Item Lists3": {
"main": [
[
{
"node": "No Operation, do nothing3",
"type": "main",
"index": 0
}
]
]
},
"Item Lists4": {
"main": [
[
{
"node": "No Operation, do nothing4",
"type": "main",
"index": 0
}
]
]
}
},
"active": false
}

View file

@ -0,0 +1,32 @@
{
"node": "n8n-nodes-base.summarize",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.summarize/"
}
],
"generic": []
},
"alias": [
"Summarise",
"Summarize",
"Group",
"Pivot",
"Sum",
"Count",
"Min",
"Max",
"Transform",
"Array",
"List",
"Item"
],
"subcategories": {
"Core Nodes": ["Data Transformation"]
}
}

View file

@ -0,0 +1,350 @@
import {
NodeOperationError,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
import {
type Aggregations,
NUMERICAL_AGGREGATIONS,
type SummarizeOptions,
aggregationToArray,
checkIfFieldExists,
fieldValueGetter,
splitData,
} from './utils';
export class Summarize implements INodeType {
description: INodeTypeDescription = {
displayName: 'Summarize',
name: 'summarize',
icon: 'file:summarize.svg',
group: ['transform'],
subtitle: '',
version: 1,
description: 'Sum, count, max, etc. across items',
defaults: {
name: 'Summarize',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Fields to Summarize',
name: 'fieldsToSummarize',
type: 'fixedCollection',
placeholder: 'Add Field',
default: { values: [{ aggregation: 'count', field: '' }] },
typeOptions: {
multipleValues: true,
},
options: [
{
displayName: '',
name: 'values',
values: [
{
displayName: 'Aggregation',
name: 'aggregation',
type: 'options',
options: [
{
name: 'Append',
value: 'append',
},
{
name: 'Average',
value: 'average',
},
{
name: 'Concatenate',
value: 'concatenate',
},
{
name: 'Count',
value: 'count',
},
{
name: 'Count Unique',
value: 'countUnique',
},
{
name: 'Max',
value: 'max',
},
{
name: 'Min',
value: 'min',
},
{
name: 'Sum',
value: 'sum',
},
],
default: 'count',
description: 'How to combine the values of the field you want to summarize',
},
//field repeated to have different descriptions for different aggregations --------------------------------
{
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
description: 'The name of an input field that you want to summarize',
placeholder: 'e.g. cost',
hint: ' Enter the field name as text',
displayOptions: {
hide: {
aggregation: [...NUMERICAL_AGGREGATIONS, 'countUnique', 'count', 'max', 'min'],
},
},
requiresDataPath: 'single',
},
{
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
description:
'The name of an input field that you want to summarize. The field should contain numerical values; null, undefined, empty strings would be ignored.',
placeholder: 'e.g. cost',
hint: ' Enter the field name as text',
displayOptions: {
show: {
aggregation: NUMERICAL_AGGREGATIONS,
},
},
requiresDataPath: 'single',
},
{
displayName: 'Field',
name: 'field',
type: 'string',
default: '',
description:
'The name of an input field that you want to summarize; null, undefined, empty strings would be ignored',
placeholder: 'e.g. cost',
hint: ' Enter the field name as text',
displayOptions: {
show: {
aggregation: ['countUnique', 'count', 'max', 'min'],
},
},
requiresDataPath: 'single',
},
// ----------------------------------------------------------------------------------------------------------
{
displayName: 'Include Empty Values',
name: 'includeEmpty',
type: 'boolean',
default: false,
displayOptions: {
show: {
aggregation: ['append', 'concatenate'],
},
},
},
{
displayName: 'Separator',
name: 'separateBy',
type: 'options',
default: ',',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Comma',
value: ',',
},
{
name: 'Comma and Space',
value: ', ',
},
{
name: 'New Line',
value: '\n',
},
{
name: 'None',
value: '',
},
{
name: 'Space',
value: ' ',
},
{
name: 'Other',
value: 'other',
},
],
hint: 'What to insert between values',
displayOptions: {
show: {
aggregation: ['concatenate'],
},
},
},
{
displayName: 'Custom Separator',
name: 'customSeparator',
type: 'string',
default: '',
displayOptions: {
show: {
aggregation: ['concatenate'],
separateBy: ['other'],
},
},
},
],
},
],
},
// fieldsToSplitBy repeated to have different displayName for singleItem and separateItems -----------------------------
{
displayName: 'Fields to Split By',
name: 'fieldsToSplitBy',
type: 'string',
placeholder: 'e.g. country, city',
default: '',
description: 'The name of the input fields that you want to split the summary by',
hint: 'Enter the name of the fields as text (separated by commas)',
displayOptions: {
hide: {
'/options.outputFormat': ['singleItem'],
},
},
requiresDataPath: 'multiple',
},
{
displayName: 'Fields to Group By',
name: 'fieldsToSplitBy',
type: 'string',
placeholder: 'e.g. country, city',
default: '',
description: 'The name of the input fields that you want to split the summary by',
hint: 'Enter the name of the fields as text (separated by commas)',
displayOptions: {
show: {
'/options.outputFormat': ['singleItem'],
},
},
requiresDataPath: 'multiple',
},
// ----------------------------------------------------------------------------------------------------------
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Disable Dot Notation',
name: 'disableDotNotation',
type: 'boolean',
default: false,
description:
'Whether to disallow referencing child fields using `parent.child` in the field name',
},
{
displayName: 'Output Format',
name: 'outputFormat',
type: 'options',
default: 'separateItems',
options: [
{
name: 'Each Split in a Separate Item',
value: 'separateItems',
},
{
name: 'All Splits in a Single Item',
value: 'singleItem',
},
],
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
displayName: 'Ignore items without valid fields to group by',
name: 'skipEmptySplitFields',
type: 'boolean',
default: false,
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const newItems = items.map(({ json }, i) => ({ ...json, _itemIndex: i }));
const options = this.getNodeParameter('options', 0, {}) as SummarizeOptions;
const fieldsToSplitBy = (this.getNodeParameter('fieldsToSplitBy', 0, '') as string)
.split(',')
.map((field) => field.trim())
.filter((field) => field);
const fieldsToSummarize = this.getNodeParameter(
'fieldsToSummarize.values',
0,
[],
) as Aggregations;
if (fieldsToSummarize.filter((aggregation) => aggregation.field !== '').length === 0) {
throw new NodeOperationError(
this.getNode(),
"You need to add at least one aggregation to 'Fields to Summarize' with non empty 'Field'",
);
}
const getValue = fieldValueGetter(options.disableDotNotation);
const nodeVersion = this.getNode().typeVersion;
if (nodeVersion < 2.1) {
checkIfFieldExists.call(this, newItems, fieldsToSummarize, getValue);
}
const aggregationResult = splitData(
fieldsToSplitBy,
newItems,
fieldsToSummarize,
options,
getValue,
);
if (options.outputFormat === 'singleItem') {
const executionData: INodeExecutionData = {
json: aggregationResult,
pairedItem: newItems.map((_v, index) => ({
item: index,
})),
};
return [[executionData]];
} else {
if (!fieldsToSplitBy.length) {
const { pairedItems, ...json } = aggregationResult;
const executionData: INodeExecutionData = {
json,
pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({
item: index,
})),
};
return [[executionData]];
}
const returnData = aggregationToArray(aggregationResult, fieldsToSplitBy);
const executionData = returnData.map((item) => {
const { pairedItems, ...json } = item;
return {
json,
pairedItem: ((pairedItems as number[]) || []).map((index: number) => ({
item: index,
})),
};
});
return [executionData];
}
}
}

View file

@ -0,0 +1,13 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1142_3440)">
<rect width="100" height="100" rx="16" fill="#FF9922"/>
<rect y="148" width="100" height="364" rx="16" fill="#FF9922"/>
<rect x="148" width="364" height="100" rx="16" fill="#FF9922"/>
<path d="M385.242 151.995C390.012 146.663 398.357 146.663 403.127 151.995L461.288 216.998C468.205 224.728 462.718 237 452.346 237H418V346C418 385.764 385.764 418 346 418H238V452.346C238 462.718 225.728 468.205 217.998 461.288L152.995 403.127C147.663 398.357 147.663 390.012 152.995 385.242L217.998 327.081C225.728 320.164 238 325.651 238 336.024V370H346C359.255 370 370 359.255 370 346V237H336.023C325.651 237 320.164 224.728 327.081 216.998L385.242 151.995Z" fill="#FF9922"/>
</g>
<defs>
<clipPath id="clip0_1142_3440">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 916 B

View file

@ -0,0 +1,5 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
const workflows = getWorkflowFilenames(__dirname);
describe('Test Summarize Node', () => testWorkflows(workflows));

View file

@ -0,0 +1,296 @@
{
"name": "summarize test",
"nodes": [
{
"parameters": {},
"id": "6c90bf81-0c0e-4c5f-9f0c-297f06d9668a",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-400, 420]
},
{
"parameters": {
"jsCode": "return [\n {\n category: 'red',\n text: 'foo',\n char: 'a',\n value: 1,\n },\n {\n category: 'blue',\n text: 'spam',\n char: 'b',\n value: 2,\n },\n {\n category: 'green',\n text: 'bar',\n char: 'c',\n value: 3,\n },\n {\n category: 'red',\n text: 'foo',\n char: 'a',\n value: 4,\n },\n {\n category: 'red',\n text: 'bar',\n char: 'a',\n value: 5,\n },\n {\n category: 'blue',\n text: 'foo',\n char: 'a',\n value: 6,\n },\n {\n category: 'blue',\n text: 'foo',\n char: 'a',\n value: 7,\n },\n];"
},
"id": "2e0011d5-c6a0-4a40-ab8c-9d011cde40d5",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [-180, 420]
},
{
"parameters": {
"fieldsToSummarize": {
"values": [
{
"aggregation": "append",
"field": "char"
},
{
"field": "char"
},
{
"aggregation": "countUnique",
"field": "char"
},
{
"aggregation": "concatenate",
"field": "char",
"separateBy": ", "
}
]
},
"fieldsToSplitBy": "category, text",
"options": {}
},
"id": "1dedf668-b766-4283-9efd-90db28404f0b",
"name": "Item Lists",
"type": "n8n-nodes-base.summarize",
"typeVersion": 1,
"position": [40, 220]
},
{
"parameters": {
"fieldsToSummarize": {
"values": [
{
"aggregation": "append",
"field": "char"
},
{
"field": "char"
},
{
"aggregation": "countUnique",
"field": "char"
},
{
"aggregation": "concatenate",
"field": "char",
"separateBy": ", "
}
]
},
"fieldsToSplitBy": "category, text",
"options": {
"outputFormat": "singleItem"
}
},
"id": "8fd0f819-226c-4b29-87c7-b724dd72605c",
"name": "Item Lists1",
"type": "n8n-nodes-base.summarize",
"typeVersion": 1,
"position": [40, 420]
},
{
"parameters": {
"fieldsToSummarize": {
"values": [
{
"aggregation": "average",
"field": "value"
},
{
"aggregation": "max",
"field": "value"
},
{
"aggregation": "min",
"field": "value"
},
{
"aggregation": "max",
"field": "value"
},
{
"aggregation": "sum",
"field": "value"
},
{
"aggregation": "append",
"field": "value"
}
]
},
"fieldsToSplitBy": "category",
"options": {}
},
"id": "33e0367d-42d9-4f82-8fc8-8e2019aa3734",
"name": "Item Lists2",
"type": "n8n-nodes-base.summarize",
"typeVersion": 1,
"position": [40, 620]
}
],
"pinData": {
"Item Lists": [
{
"json": {
"category": "red",
"text": "foo",
"appended_char": ["a", "a"],
"count_char": 2,
"unique_count_char": 1,
"concatenated_char": "a, a"
}
},
{
"json": {
"category": "red",
"text": "bar",
"appended_char": ["a"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "a"
}
},
{
"json": {
"category": "blue",
"text": "spam",
"appended_char": ["b"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "b"
}
},
{
"json": {
"category": "blue",
"text": "foo",
"appended_char": ["a", "a"],
"count_char": 2,
"unique_count_char": 1,
"concatenated_char": "a, a"
}
},
{
"json": {
"category": "green",
"text": "bar",
"appended_char": ["c"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "c"
}
}
],
"Item Lists1": [
{
"json": {
"red": {
"foo": {
"appended_char": ["a", "a"],
"count_char": 2,
"unique_count_char": 1,
"concatenated_char": "a, a"
},
"bar": {
"appended_char": ["a"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "a"
}
},
"blue": {
"spam": {
"appended_char": ["b"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "b"
},
"foo": {
"appended_char": ["a", "a"],
"count_char": 2,
"unique_count_char": 1,
"concatenated_char": "a, a"
}
},
"green": {
"bar": {
"appended_char": ["c"],
"count_char": 1,
"unique_count_char": 1,
"concatenated_char": "c"
}
}
}
}
],
"Item Lists2": [
{
"json": {
"category": "red",
"average_value": 3.3333333333333335,
"max_value": 5,
"min_value": 1,
"sum_value": 10,
"appended_value": [1, 4, 5]
}
},
{
"json": {
"category": "blue",
"average_value": 5,
"max_value": 7,
"min_value": 2,
"sum_value": 15,
"appended_value": [2, 6, 7]
}
},
{
"json": {
"category": "green",
"average_value": 3,
"max_value": 3,
"min_value": 3,
"sum_value": 3,
"appended_value": [3]
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Item Lists",
"type": "main",
"index": 0
},
{
"node": "Item Lists1",
"type": "main",
"index": 0
},
{
"node": "Item Lists2",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "bee0d911-844d-4fe6-bd52-a1716dd74dd8",
"id": "105",
"meta": {
"instanceId": "36203ea1ce3cef713fa25999bd9874ae26b9e4c2c3a90a365f2882a154d031d0"
},
"tags": []
}

View file

@ -0,0 +1,288 @@
import get from 'lodash/get';
import {
type IDataObject,
type GenericValue,
type IExecuteFunctions,
NodeOperationError,
} from 'n8n-workflow';
type AggregationType =
| 'append'
| 'average'
| 'concatenate'
| 'count'
| 'countUnique'
| 'max'
| 'min'
| 'sum';
export type Aggregation = {
aggregation: AggregationType;
field: string;
includeEmpty?: boolean;
separateBy?: string;
customSeparator?: string;
};
export type Aggregations = Aggregation[];
const AggregationDisplayNames = {
append: 'appended_',
average: 'average_',
concatenate: 'concatenated_',
count: 'count_',
countUnique: 'unique_count_',
max: 'max_',
min: 'min_',
sum: 'sum_',
};
export const NUMERICAL_AGGREGATIONS = ['average', 'sum'];
export type SummarizeOptions = {
disableDotNotation?: boolean;
outputFormat?: 'separateItems' | 'singleItem';
skipEmptySplitFields?: boolean;
};
export type ValueGetterFn = (
item: IDataObject,
field: string,
) => IDataObject | IDataObject[] | GenericValue | GenericValue[];
function isEmpty<T>(value: T) {
return value === undefined || value === null || value === '';
}
function parseReturnData(returnData: IDataObject) {
const regexBrackets = /[\]\["]/g;
const regexSpaces = /[ .]/g;
for (const key of Object.keys(returnData)) {
if (key.match(regexBrackets)) {
const newKey = key.replace(regexBrackets, '');
returnData[newKey] = returnData[key];
delete returnData[key];
}
}
for (const key of Object.keys(returnData)) {
if (key.match(regexSpaces)) {
const newKey = key.replace(regexSpaces, '_');
returnData[newKey] = returnData[key];
delete returnData[key];
}
}
}
function parseFieldName(fieldName: string[]) {
const regexBrackets = /[\]\["]/g;
const regexSpaces = /[ .]/g;
fieldName = fieldName.map((field) => {
field = field.replace(regexBrackets, '');
field = field.replace(regexSpaces, '_');
return field;
});
return fieldName;
}
export const fieldValueGetter = (disableDotNotation?: boolean) => {
if (disableDotNotation) {
return (item: IDataObject, field: string) => item[field];
} else {
return (item: IDataObject, field: string) => get(item, field);
}
};
export function checkIfFieldExists(
this: IExecuteFunctions,
items: IDataObject[],
aggregations: Aggregations,
getValue: ValueGetterFn,
) {
for (const aggregation of aggregations) {
if (aggregation.field === '') {
continue;
}
const exist = items.some((item) => getValue(item, aggregation.field) !== undefined);
if (!exist) {
throw new NodeOperationError(
this.getNode(),
`The field '${aggregation.field}' does not exist in any items`,
);
}
}
}
function aggregate(items: IDataObject[], entry: Aggregation, getValue: ValueGetterFn) {
const { aggregation, field } = entry;
let data = [...items];
if (NUMERICAL_AGGREGATIONS.includes(aggregation)) {
data = data.filter(
(item) => typeof getValue(item, field) === 'number' && !isEmpty(getValue(item, field)),
);
}
switch (aggregation) {
//combine operations
case 'append':
if (!entry.includeEmpty) {
data = data.filter((item) => !isEmpty(getValue(item, field)));
}
return data.map((item) => getValue(item, field));
case 'concatenate':
const separateBy = entry.separateBy === 'other' ? entry.customSeparator : entry.separateBy;
if (!entry.includeEmpty) {
data = data.filter((item) => !isEmpty(getValue(item, field)));
}
return data
.map((item) => {
let value = getValue(item, field);
if (typeof value === 'object') {
value = JSON.stringify(value);
}
if (typeof value === 'undefined') {
value = 'undefined';
}
return value;
})
.join(separateBy);
//numerical operations
case 'average':
return (
data.reduce((acc, item) => {
return acc + (getValue(item, field) as number);
}, 0) / data.length
);
case 'sum':
return data.reduce((acc, item) => {
return acc + (getValue(item, field) as number);
}, 0);
//comparison operations
case 'min':
let min;
for (const item of data) {
const value = getValue(item, field);
if (value !== undefined && value !== null && value !== '') {
if (min === undefined || value < min) {
min = value;
}
}
}
return min !== undefined ? min : null;
case 'max':
let max;
for (const item of data) {
const value = getValue(item, field);
if (value !== undefined && value !== null && value !== '') {
if (max === undefined || value > max) {
max = value;
}
}
}
return max !== undefined ? max : null;
//count operations
case 'countUnique':
return new Set(data.map((item) => getValue(item, field)).filter((item) => !isEmpty(item)))
.size;
default:
//count by default
return data.filter((item) => !isEmpty(getValue(item, field))).length;
}
}
function aggregateData(
data: IDataObject[],
fieldsToSummarize: Aggregations,
options: SummarizeOptions,
getValue: ValueGetterFn,
) {
const returnData = fieldsToSummarize.reduce((acc, aggregation) => {
acc[`${AggregationDisplayNames[aggregation.aggregation]}${aggregation.field}`] = aggregate(
data,
aggregation,
getValue,
);
return acc;
}, {} as IDataObject);
parseReturnData(returnData);
if (options.outputFormat === 'singleItem') {
parseReturnData(returnData);
return returnData;
} else {
return { ...returnData, pairedItems: data.map((item) => item._itemIndex as number) };
}
}
export function splitData(
splitKeys: string[],
data: IDataObject[],
fieldsToSummarize: Aggregations,
options: SummarizeOptions,
getValue: ValueGetterFn,
) {
if (!splitKeys || splitKeys.length === 0) {
return aggregateData(data, fieldsToSummarize, options, getValue);
}
const [firstSplitKey, ...restSplitKeys] = splitKeys;
const groupedData = data.reduce((acc, item) => {
let keyValuee = getValue(item, firstSplitKey) as string;
if (typeof keyValuee === 'object') {
keyValuee = JSON.stringify(keyValuee);
}
if (options.skipEmptySplitFields && typeof keyValuee !== 'number' && !keyValuee) {
return acc;
}
if (acc[keyValuee] === undefined) {
acc[keyValuee] = [item];
} else {
(acc[keyValuee] as IDataObject[]).push(item);
}
return acc;
}, {} as IDataObject);
return Object.keys(groupedData).reduce((acc, key) => {
const value = groupedData[key] as IDataObject[];
acc[key] = splitData(restSplitKeys, value, fieldsToSummarize, options, getValue);
return acc;
}, {} as IDataObject);
}
export function aggregationToArray(
aggregationResult: IDataObject,
fieldsToSplitBy: string[],
previousStage: IDataObject = {},
) {
const returnData: IDataObject[] = [];
fieldsToSplitBy = parseFieldName(fieldsToSplitBy);
const splitFieldName = fieldsToSplitBy[0];
const isNext = fieldsToSplitBy[1];
if (isNext === undefined) {
for (const fieldName of Object.keys(aggregationResult)) {
returnData.push({
...previousStage,
[splitFieldName]: fieldName,
...(aggregationResult[fieldName] as IDataObject),
});
}
return returnData;
} else {
for (const key of Object.keys(aggregationResult)) {
returnData.push(
...aggregationToArray(aggregationResult[key] as IDataObject, fieldsToSplitBy.slice(1), {
...previousStage,
[splitFieldName]: key,
}),
);
}
return returnData;
}
}

View file

@ -0,0 +1,14 @@
export const prepareFieldsArray = (fields: string | string[], fieldName = 'Fields') => {
if (typeof fields === 'string') {
return fields
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '');
}
if (Array.isArray(fields)) {
return fields;
}
throw new Error(
`The \'${fieldName}\' parameter must be a string of fields separated by commas or an array of strings.`,
);
};

View file

@ -23,6 +23,6 @@
},
"alias": ["pause", "sleep", "delay", "timeout"],
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": ["Helpers", "Flow"]
}
}

View file

@ -83,6 +83,6 @@
},
"alias": ["HTTP", "API", "Build", "WH"],
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": ["Helpers"]
}
}

View file

@ -784,7 +784,13 @@
"dist/nodes/Zendesk/ZendeskTrigger.node.js",
"dist/nodes/Zoho/ZohoCrm.node.js",
"dist/nodes/Zoom/Zoom.node.js",
"dist/nodes/Zulip/Zulip.node.js"
"dist/nodes/Zulip/Zulip.node.js",
"dist/nodes/Transform/Aggregate/Aggregate.node.js",
"dist/nodes/Transform/Limit/Limit.node.js",
"dist/nodes/Transform/RemoveDuplicates/RemoveDuplicates.node.js",
"dist/nodes/Transform/SplitOut/SplitOut.node.js",
"dist/nodes/Transform/Sort/Sort.node.js",
"dist/nodes/Transform/Summarize/Summarize.node.js"
]
},
"devDependencies": {

View file

@ -1767,6 +1767,7 @@ export function getVersionedNodeTypeAll(object: IVersionedNodeType | INodeType):
Object.values(object.nodeVersions)
.map((element) => {
element.description.name = object.description.name;
element.description.codex = object.description.codex;
return element;
})
.reverse(),