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
78 changed files with 4353 additions and 74 deletions

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} })`);
}