From 823b589e0994fb066a44f2302d615c7bb25ab74c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 Dec 2023 12:26:24 +0100 Subject: [PATCH 01/16] :rocket: Release 1.20.0 (#7940) # [1.20.0](https://github.com/n8n-io/n8n/compare/n8n@1.19.0...n8n@1.20.0) (2023-12-06) ### Bug Fixes * **AWS DynamoDB Node:** Improve error message parsing ([#7793](https://github.com/n8n-io/n8n/issues/7793)) ([5ba5ed8](https://github.com/n8n-io/n8n/commit/5ba5ed8e3c8ba2f909859bde129d92576fbda46f)) * **core:** Allow grace period for binary data deletion after manual execution ([#7889](https://github.com/n8n-io/n8n/issues/7889)) ([61d8aeb](https://github.com/n8n-io/n8n/commit/61d8aebeaf6487269b252b353fdf16dcb67f41ff)) * **core:** Consolidate ownership and sharing data on workflows and credentials ([#7920](https://github.com/n8n-io/n8n/issues/7920)) ([38b88b9](https://github.com/n8n-io/n8n/commit/38b88b946bab67dc1a964bb3c980a627d4a32595)) * **core:** Fix hard deletes stopping if database query throws ([#7848](https://github.com/n8n-io/n8n/issues/7848)) ([46dd4d3](https://github.com/n8n-io/n8n/commit/46dd4d3105db3a15c81903ae81c9bbb21a45397b)) * **core:** Make sure mfa secret and recovery codes are not returned on login ([#7936](https://github.com/n8n-io/n8n/issues/7936)) ([f5502cc](https://github.com/n8n-io/n8n/commit/f5502cc628f6b348f7fe3325b96ec9dc3360beaf)) * **editor:** Fix deletion of last execution at execution preview ([#7883](https://github.com/n8n-io/n8n/issues/7883)) ([ce2d388](https://github.com/n8n-io/n8n/commit/ce2d388f059c0bb32d27f4b29e901d1a70083610)) * **editor:** Replace isInstanceOwner checks with scopes where applicable ([#7858](https://github.com/n8n-io/n8n/issues/7858)) ([132d691](https://github.com/n8n-io/n8n/commit/132d691cbf983f60293c7423de0077fb7c97e0af)) * **Google Sheets Node:** Fix issue with paired items not being set correctly ([#7862](https://github.com/n8n-io/n8n/issues/7862)) ([5207a2f](https://github.com/n8n-io/n8n/commit/5207a2fe5210e40d3b2aedd95182a18e497c72ab)) * **Notion Node:** Fix broken Notion node parameters ([#7864](https://github.com/n8n-io/n8n/issues/7864)) ([51d1f5b](https://github.com/n8n-io/n8n/commit/51d1f5b82070542d45c3d57387343959a3f0abb2)), closes [#7791](https://github.com/n8n-io/n8n/issues/7791) ### Features * **BambooHR Node:** Add support for Only Current on company reports ([#7878](https://github.com/n8n-io/n8n/issues/7878)) ([4175801](https://github.com/n8n-io/n8n/commit/4175801c90ad4f744d1a7c331d4fb20891ed2e9e)) * **core:** Allow admin creation ([#7837](https://github.com/n8n-io/n8n/issues/7837)) ([476806e](https://github.com/n8n-io/n8n/commit/476806ebb0f31f656992fb67aba37116f10e1475)) * **editor:** Add sections to create node panel ([#7831](https://github.com/n8n-io/n8n/issues/7831)) ([39fa8d2](https://github.com/n8n-io/n8n/commit/39fa8d21bbee5d870b2620ec65401a5ca134c4f1)) * **editor:** Open template credential setup from collection ([#7882](https://github.com/n8n-io/n8n/issues/7882)) ([627ddb9](https://github.com/n8n-io/n8n/commit/627ddb91fb6c00796671a1f72f59a251cd89004d)) * **editor:** Select credentials in template setup if theres only one ([#7879](https://github.com/n8n-io/n8n/issues/7879)) ([fe3417a](https://github.com/n8n-io/n8n/commit/fe3417a615534a01cb0c7b5e8f47bc18abd5cd4d)) ### Performance Improvements * **editor:** Improve node rendering performance when opening large workflows ([#7904](https://github.com/n8n-io/n8n/issues/7904)) ([a8049a0](https://github.com/n8n-io/n8n/commit/a8049a0def21506ebf4fb1d3b69ae28ec35fdc21)) * **editor:** Improve performance when opening large workflows with node issues ([#7901](https://github.com/n8n-io/n8n/issues/7901)) ([4bd7ae2](https://github.com/n8n-io/n8n/commit/4bd7ae29f7c82b8817420e617a123024147c6c70)) Co-authored-by: ivov --- CHANGELOG.md | 32 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/chat/package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/@n8n/permissions/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/editor-ui/package.json | 2 +- packages/node-dev/package.json | 2 +- packages/nodes-base/package.json | 2 +- packages/workflow/package.json | 2 +- 11 files changed, 42 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0426b51cc8970..b9c5534639c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +# [1.20.0](https://github.com/n8n-io/n8n/compare/n8n@1.19.0...n8n@1.20.0) (2023-12-06) + + +### Bug Fixes + +* **AWS DynamoDB Node:** Improve error message parsing ([#7793](https://github.com/n8n-io/n8n/issues/7793)) ([5ba5ed8](https://github.com/n8n-io/n8n/commit/5ba5ed8e3c8ba2f909859bde129d92576fbda46f)) +* **core:** Allow grace period for binary data deletion after manual execution ([#7889](https://github.com/n8n-io/n8n/issues/7889)) ([61d8aeb](https://github.com/n8n-io/n8n/commit/61d8aebeaf6487269b252b353fdf16dcb67f41ff)) +* **core:** Consolidate ownership and sharing data on workflows and credentials ([#7920](https://github.com/n8n-io/n8n/issues/7920)) ([38b88b9](https://github.com/n8n-io/n8n/commit/38b88b946bab67dc1a964bb3c980a627d4a32595)) +* **core:** Fix hard deletes stopping if database query throws ([#7848](https://github.com/n8n-io/n8n/issues/7848)) ([46dd4d3](https://github.com/n8n-io/n8n/commit/46dd4d3105db3a15c81903ae81c9bbb21a45397b)) +* **core:** Make sure mfa secret and recovery codes are not returned on login ([#7936](https://github.com/n8n-io/n8n/issues/7936)) ([f5502cc](https://github.com/n8n-io/n8n/commit/f5502cc628f6b348f7fe3325b96ec9dc3360beaf)), closes [/github.com/n8n-io/n8n/pull/6994/files#diff-95a87cb029a3d26e6722df2e68132453fc254fc1f4540cbdaa95cfdbda1893deL91](https://github.com//github.com/n8n-io/n8n/pull/6994/files/issues/diff-95a87cb029a3d26e6722df2e68132453fc254fc1f4540cbdaa95cfdbda1893deL91) [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Fix deletion of last execution at execution preview ([#7883](https://github.com/n8n-io/n8n/issues/7883)) ([ce2d388](https://github.com/n8n-io/n8n/commit/ce2d388f059c0bb32d27f4b29e901d1a70083610)) +* **editor:** Replace isInstanceOwner checks with scopes where applicable ([#7858](https://github.com/n8n-io/n8n/issues/7858)) ([132d691](https://github.com/n8n-io/n8n/commit/132d691cbf983f60293c7423de0077fb7c97e0af)) +* **Google Sheets Node:** Fix issue with paired items not being set correctly ([#7862](https://github.com/n8n-io/n8n/issues/7862)) ([5207a2f](https://github.com/n8n-io/n8n/commit/5207a2fe5210e40d3b2aedd95182a18e497c72ab)) +* **Notion Node:** Fix broken Notion node parameters ([#7864](https://github.com/n8n-io/n8n/issues/7864)) ([51d1f5b](https://github.com/n8n-io/n8n/commit/51d1f5b82070542d45c3d57387343959a3f0abb2)), closes [#7791](https://github.com/n8n-io/n8n/issues/7791) + + +### Features + +* **BambooHR Node:** Add support for Only Current on company reports ([#7878](https://github.com/n8n-io/n8n/issues/7878)) ([4175801](https://github.com/n8n-io/n8n/commit/4175801c90ad4f744d1a7c331d4fb20891ed2e9e)) +* **core:** Allow admin creation ([#7837](https://github.com/n8n-io/n8n/issues/7837)) ([476806e](https://github.com/n8n-io/n8n/commit/476806ebb0f31f656992fb67aba37116f10e1475)) +* **editor:** Add sections to create node panel ([#7831](https://github.com/n8n-io/n8n/issues/7831)) ([39fa8d2](https://github.com/n8n-io/n8n/commit/39fa8d21bbee5d870b2620ec65401a5ca134c4f1)) +* **editor:** Open template credential setup from collection ([#7882](https://github.com/n8n-io/n8n/issues/7882)) ([627ddb9](https://github.com/n8n-io/n8n/commit/627ddb91fb6c00796671a1f72f59a251cd89004d)) +* **editor:** Select credentials in template setup if theres only one ([#7879](https://github.com/n8n-io/n8n/issues/7879)) ([fe3417a](https://github.com/n8n-io/n8n/commit/fe3417a615534a01cb0c7b5e8f47bc18abd5cd4d)) + + +### Performance Improvements + +* **editor:** Improve node rendering performance when opening large workflows ([#7904](https://github.com/n8n-io/n8n/issues/7904)) ([a8049a0](https://github.com/n8n-io/n8n/commit/a8049a0def21506ebf4fb1d3b69ae28ec35fdc21)), closes [#7901](https://github.com/n8n-io/n8n/issues/7901) [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Improve performance when opening large workflows with node issues ([#7901](https://github.com/n8n-io/n8n/issues/7901)) ([4bd7ae2](https://github.com/n8n-io/n8n/commit/4bd7ae29f7c82b8817420e617a123024147c6c70)) + + + # [1.19.0](https://github.com/n8n-io/n8n/compare/n8n@1.18.0...n8n@1.19.0) (2023-11-29) diff --git a/package.json b/package.json index a9af524f36d09..d740c8ff48afa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.19.0", + "version": "1.20.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 4dd311b927ce1..9e0b59dcdc063 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "dev": "npm run storybook", "build": "run-p type-check build:vite && npm run build:prepare", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ecfb9a4112797..791a4fde284a4 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "0.4.0", + "version": "0.5.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index d4b163071a386..49911148b8b0d 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/cli/package.json b/packages/cli/package.json index 07fd8c0b55b21..3424197c0e05e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.19.0", + "version": "1.20.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/core/package.json b/packages/core/package.json index bd47754098414..07ad1a0dc3458 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.19.0", + "version": "1.20.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index fdfd764a39508..9f3383f130ff1 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.19.0", + "version": "1.20.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 6edde0e707014..3a56661e0083f 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.19.0", + "version": "1.20.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fe74e3e4fc646..a86a2597a989c 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.19.0", + "version": "1.20.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 9c7a0268217d3..fed32bd60f46b 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.19.0", + "version": "1.20.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 70f0755278e0a2bdb61c29623f27623b65473ab4 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 13 Dec 2023 05:13:48 +0200 Subject: [PATCH 02/16] fix(Webhook Node): Do not create binary data when there is no data in the request (#8000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://linear.app/n8n/issue/NODE-980/do-not-create-binary-data-for-webhooks-when-there-is-no-data-in-the related: https://github.com/n8n-io/n8n/pull/7804/files#r1422641833 --------- Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .../nodes-base/nodes/Webhook/Webhook.node.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/nodes-base/nodes/Webhook/Webhook.node.ts b/packages/nodes-base/nodes/Webhook/Webhook.node.ts index 69feb42d15409..e2f42d25ae326 100644 --- a/packages/nodes-base/nodes/Webhook/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook/Webhook.node.ts @@ -1,6 +1,7 @@ /* eslint-disable n8n-nodes-base/node-execute-block-wrong-error-thrown */ import { pipeline } from 'stream/promises'; import { createWriteStream } from 'fs'; +import { stat } from 'fs/promises'; import type { IWebhookFunctions, ICredentialDataDecryptedObject, @@ -216,7 +217,6 @@ export class Webhook extends Node { const { data, files } = req.body; const returnItem: INodeExecutionData = { - binary: {}, json: { headers: req.headers, params: req.params, @@ -225,6 +225,10 @@ export class Webhook extends Node { }, }; + if (files?.length) { + returnItem.binary = {}; + } + let count = 0; for (const key of Object.keys(files)) { @@ -274,7 +278,6 @@ export class Webhook extends Node { await pipeline(req, createWriteStream(binaryFile.path)); const returnItem: INodeExecutionData = { - binary: {}, json: { headers: req.headers, params: req.params, @@ -283,20 +286,18 @@ export class Webhook extends Node { }, }; - const binaryPropertyName = (options.binaryPropertyName || 'data') as string; - const fileName = req.contentDisposition?.filename ?? uuid(); - const binaryData = await context.nodeHelpers.copyBinaryFile( - binaryFile.path, - fileName, - req.contentType ?? 'application/octet-stream', - ); - - if (!binaryData.data) { - return { workflowData: [[returnItem]] }; + const stats = await stat(binaryFile.path); + if (stats.size) { + const binaryPropertyName = (options.binaryPropertyName ?? 'data') as string; + const fileName = req.contentDisposition?.filename ?? uuid(); + const binaryData = await context.nodeHelpers.copyBinaryFile( + binaryFile.path, + fileName, + req.contentType ?? 'application/octet-stream', + ); + returnItem.binary = { [binaryPropertyName]: binaryData }; } - returnItem.binary![binaryPropertyName] = binaryData; - return { workflowData: [[returnItem]] }; } catch (error) { throw new NodeOperationError(context.getNode(), error as Error); From 7b5d0a9546af3fe39be4e0411531f017f4a4946e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Dec 2023 10:00:21 +0100 Subject: [PATCH 03/16] refactor(core): Introduce import service (no-changelog) (#8001) Consolidate import logic into import service. Also fixes: - https://linear.app/n8n/issue/PAY-1086 - https://github.com/n8n-io/n8n/issues/7881 - https://community.n8n.io/t/cli-workflow-imports-failing-after-upgrade-to-v1-18-0/33780 - https://linear.app/n8n/issue/PAY-221 - https://github.com/n8n-io/n8n/issues/5477 - https://community.n8n.io/t/export-workflows-with-tags-got-created/6161 --- packages/cli/src/commands/import/workflow.ts | 155 ++------------- packages/cli/src/services/import.service.ts | 153 +++++++++++++++ packages/cli/src/services/tag.service.ts | 69 +------ .../test/integration/import.service.test.ts | 185 ++++++++++++++++++ 4 files changed, 356 insertions(+), 206 deletions(-) create mode 100644 packages/cli/src/services/import.service.ts create mode 100644 packages/cli/test/integration/import.service.test.ts diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 58aed9484d218..9f3ccfc682cfc 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -1,26 +1,18 @@ import { flags } from '@oclif/command'; -import type { INode, INodeCredentialsDetails } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import fs from 'fs'; import glob from 'fast-glob'; import { Container } from 'typedi'; -import type { EntityManager } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import * as Db from '@/Db'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { Role } from '@db/entities/Role'; -import type { User } from '@db/entities/User'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; -import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; -import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import type { IWorkflowToImport } from '@/Interfaces'; import { BaseCommand } from '../BaseCommand'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; -import { TagService } from '@/services/tag.service'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { ImportService } from '@/services/import.service'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -64,16 +56,9 @@ export class ImportWorkflowsCommand extends BaseCommand { }), }; - private ownerWorkflowRole: Role; - - private transactionManager: EntityManager; - - private tagService: TagService; - async init() { disableAutoGeneratedIds(WorkflowEntity); await super.init(); - this.tagService = Container.get(TagService); } async run(): Promise { @@ -94,12 +79,8 @@ export class ImportWorkflowsCommand extends BaseCommand { } } - await this.initOwnerWorkflowRole(); const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); - const credentials = await Container.get(CredentialsRepository).find(); - const tags = await this.tagService.getAll(); - let totalImported = 0; if (flags.separate) { @@ -116,41 +97,17 @@ export class ImportWorkflowsCommand extends BaseCommand { totalImported = files.length; this.logger.info(`Importing ${totalImported} workflows...`); - await Db.getConnection().transaction(async (transactionManager) => { - this.transactionManager = transactionManager; - - for (const file of files) { - const workflow = jsonParse( - fs.readFileSync(file, { encoding: 'utf8' }), - ); - if (!workflow.id) { - workflow.id = generateNanoId(); - } - - if (credentials.length > 0) { - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentials); - - if (!node.id) { - node.id = uuid(); - } - }); - } - - if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await this.tagService.setTagsForImport(transactionManager, workflow, tags); - } - - if (workflow.active) { - this.logger.info( - `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, - ); - workflow.active = false; - } - - await this.storeWorkflow(workflow, user); + + for (const file of files) { + const workflow = jsonParse(fs.readFileSync(file, { encoding: 'utf8' })); + if (!workflow.id) { + workflow.id = generateNanoId(); } - }); + + const _workflow = Container.get(WorkflowRepository).create(workflow); + + await Container.get(ImportService).importWorkflows([_workflow], user.id); + } this.reportSuccess(totalImported); process.exit(); @@ -160,46 +117,13 @@ export class ImportWorkflowsCommand extends BaseCommand { fs.readFileSync(flags.input, { encoding: 'utf8' }), ); + const _workflows = workflows.map((w) => Container.get(WorkflowRepository).create(w)); + assertHasWorkflowsToImport(workflows); totalImported = workflows.length; - await Db.getConnection().transaction(async (transactionManager) => { - this.transactionManager = transactionManager; - - for (const workflow of workflows) { - let oldCredentialFormat = false; - if (credentials.length > 0) { - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentials); - if (!node.id) { - node.id = uuid(); - } - if (!node.credentials?.id) { - oldCredentialFormat = true; - } - }); - } - if (oldCredentialFormat) { - try { - await replaceInvalidCredentials(workflow as unknown as WorkflowEntity); - } catch (error) { - this.logger.error('Failed to replace invalid credential', error as Error); - } - } - if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await this.tagService.setTagsForImport(transactionManager, workflow, tags); - } - if (workflow.active) { - this.logger.info( - `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, - ); - workflow.active = false; - } - - await this.storeWorkflow(workflow, user); - } - }); + await Container.get(ImportService).importWorkflows(_workflows, user.id); this.reportSuccess(totalImported); } @@ -213,29 +137,6 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async initOwnerWorkflowRole() { - const ownerWorkflowRole = await Container.get(RoleService).findWorkflowOwnerRole(); - - if (!ownerWorkflowRole) { - throw new ApplicationError(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`); - } - - this.ownerWorkflowRole = ownerWorkflowRole; - } - - private async storeWorkflow(workflow: object, user: User) { - const result = await this.transactionManager.upsert(WorkflowEntity, workflow, ['id']); - await this.transactionManager.upsert( - SharedWorkflow, - { - workflowId: result.identifiers[0].id as string, - userId: user.id, - roleId: this.ownerWorkflowRole.id, - }, - ['workflowId', 'userId'], - ); - } - private async getOwner() { const ownerGlobalRole = await Container.get(RoleService).findGlobalOwnerRole(); @@ -259,28 +160,4 @@ export class ImportWorkflowsCommand extends BaseCommand { return user; } - - private transformCredentials(node: INode, credentialsEntities: ICredentialsDb[]) { - if (node.credentials) { - const allNodeCredentials = Object.entries(node.credentials); - for (const [type, name] of allNodeCredentials) { - if (typeof name === 'string') { - const nodeCredentials: INodeCredentialsDetails = { - id: null, - name, - }; - - const matchingCredentials = credentialsEntities.filter( - (credentials) => credentials.name === name && credentials.type === type, - ); - - if (matchingCredentials.length === 1) { - nodeCredentials.id = matchingCredentials[0].id; - } - - node.credentials[type] = nodeCredentials; - } - } - } - } } diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts new file mode 100644 index 0000000000000..9ba0715b77ecc --- /dev/null +++ b/packages/cli/src/services/import.service.ts @@ -0,0 +1,153 @@ +import { Service } from 'typedi'; +import { v4 as uuid } from 'uuid'; +import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; +import type { EntityManager } from 'typeorm'; + +import { Logger } from '@/Logger'; +import * as Db from '@/Db'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { TagRepository } from '@/databases/repositories/tag.repository'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import { RoleService } from '@/services/role.service'; +import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; + +import type { TagEntity } from '@/databases/entities/TagEntity'; +import type { Role } from '@/databases/entities/Role'; +import type { ICredentialsDb } from '@/Interfaces'; + +@Service() +export class ImportService { + private dbCredentials: ICredentialsDb[] = []; + + private dbTags: TagEntity[] = []; + + private workflowOwnerRole: Role; + + constructor( + private readonly logger: Logger, + private readonly credentialsRepository: CredentialsRepository, + private readonly tagRepository: TagRepository, + private readonly roleService: RoleService, + ) {} + + async initRecords() { + this.dbCredentials = await this.credentialsRepository.find(); + this.dbTags = await this.tagRepository.find(); + this.workflowOwnerRole = await this.roleService.findWorkflowOwnerRole(); + } + + async importWorkflows(workflows: WorkflowEntity[], userId: string) { + await this.initRecords(); + + for (const workflow of workflows) { + workflow.nodes.forEach((node) => { + this.toNewCredentialFormat(node); + + if (!node.id) node.id = uuid(); + }); + + const hasInvalidCreds = workflow.nodes.some((node) => !node.credentials?.id); + + if (hasInvalidCreds) await this.replaceInvalidCreds(workflow); + } + + await Db.transaction(async (tx) => { + for (const workflow of workflows) { + if (workflow.active) { + workflow.active = false; + + this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`); + } + + const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); + + const workflowId = upsertResult.identifiers.at(0)?.id as string; + + await tx.upsert(SharedWorkflow, { workflowId, userId, roleId: this.workflowOwnerRole.id }, [ + 'workflowId', + 'userId', + ]); + + if (!workflow.tags?.length) continue; + + await this.setTags(tx, workflow); + + for (const tag of workflow.tags) { + await tx.upsert(WorkflowTagMapping, { tagId: tag.id, workflowId }, [ + 'tagId', + 'workflowId', + ]); + } + } + }); + } + + async replaceInvalidCreds(workflow: WorkflowEntity) { + try { + await replaceInvalidCredentials(workflow); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + this.logger.error('Failed to replace invalid credential', error); + } + } + + /** + * Convert a node's credentials from old format `{ : }` + * to new format: `{ : { id: string | null, name: } }` + */ + private toNewCredentialFormat(node: INode) { + if (!node.credentials) return; + + for (const [type, name] of Object.entries(node.credentials)) { + if (typeof name !== 'string') continue; + + const nodeCredential: INodeCredentialsDetails = { id: null, name }; + + const match = this.dbCredentials.find((c) => c.name === name && c.type === type); + + if (match) nodeCredential.id = match.id; + + node.credentials[type] = nodeCredential; + } + } + + /** + * Set tags on workflow to import while ensuring all tags exist in the database, + * either by matching incoming to existing tags or by creating them first. + */ + private async setTags(tx: EntityManager, workflow: WorkflowEntity) { + if (!workflow?.tags?.length) return; + + for (let i = 0; i < workflow.tags.length; i++) { + const importTag = workflow.tags[i]; + + if (!importTag.name) continue; + + const identicalMatch = this.dbTags.find( + (dbTag) => + dbTag.id === importTag.id && + dbTag.createdAt && + importTag.createdAt && + dbTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), + ); + + if (identicalMatch) { + workflow.tags[i] = identicalMatch; + continue; + } + + const nameMatch = this.dbTags.find((dbTag) => dbTag.name === importTag.name); + + if (nameMatch) { + workflow.tags[i] = nameMatch; + continue; + } + + const tagEntity = this.tagRepository.create(importTag); + + workflow.tags[i] = await tx.save(tagEntity); + } + } +} diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 8d3e40ad93251..63050ea14981c 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -1,9 +1,9 @@ import { TagRepository } from '@db/repositories/tag.repository'; import { Service } from 'typedi'; import { validateEntity } from '@/GenericHelpers'; -import type { ITagToImport, ITagWithCountDb, IWorkflowToImport } from '@/Interfaces'; +import type { ITagWithCountDb } from '@/Interfaces'; import type { TagEntity } from '@db/entities/TagEntity'; -import type { EntityManager, FindManyOptions, FindOneOptions } from 'typeorm'; +import type { FindManyOptions, FindOneOptions } from 'typeorm'; import type { UpsertOptions } from 'typeorm/repository/UpsertOptions'; import { ExternalHooks } from '@/ExternalHooks'; @@ -89,69 +89,4 @@ export class TagService { return requestOrder.map((tagId) => tagMap[tagId]); } - - /** - * Set tag IDs to use existing tags, creates a new tag if no matching tag could be found - */ - async setTagsForImport( - transactionManager: EntityManager, - workflow: IWorkflowToImport, - tags: TagEntity[], - ) { - if (!this.hasTags(workflow)) return; - - const workflowTags = workflow.tags; - const tagLookupPromises = []; - for (let i = 0; i < workflowTags.length; i++) { - if (workflowTags[i]?.name) { - const lookupPromise = this.findOrCreateTag(transactionManager, workflowTags[i], tags).then( - (tag) => { - workflowTags[i] = { - id: tag.id, - name: tag.name, - }; - }, - ); - tagLookupPromises.push(lookupPromise); - } - } - - await Promise.all(tagLookupPromises); - } - - private hasTags(workflow: IWorkflowToImport) { - return 'tags' in workflow && Array.isArray(workflow.tags) && workflow.tags.length > 0; - } - - private async findOrCreateTag( - transactionManager: EntityManager, - importTag: ITagToImport, - tagsEntities: TagEntity[], - ) { - // Assume tag is identical if createdAt date is the same to preserve a changed tag name - const identicalMatch = tagsEntities.find( - (existingTag) => - existingTag.id === importTag.id && - existingTag.createdAt && - importTag.createdAt && - existingTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), - ); - if (identicalMatch) { - return identicalMatch; - } - - const nameMatch = tagsEntities.find((existingTag) => existingTag.name === importTag.name); - if (nameMatch) { - return nameMatch; - } - - const created = await this.txCreateTag(transactionManager, importTag.name); - tagsEntities.push(created); - return created; - } - - private async txCreateTag(transactionManager: EntityManager, name: string) { - const tag = this.tagRepository.create({ name: name.trim() }); - return transactionManager.save(tag); - } } diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts new file mode 100644 index 0000000000000..07b7f1a600478 --- /dev/null +++ b/packages/cli/test/integration/import.service.test.ts @@ -0,0 +1,185 @@ +import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import { v4 as uuid } from 'uuid'; +import type { INode } from 'n8n-workflow'; + +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { TagRepository } from '@/databases/repositories/tag.repository'; +import { ImportService } from '@/services/import.service'; +import { RoleService } from '@/services/role.service'; +import { TagEntity } from '@/databases/entities/TagEntity'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; + +import * as testDb from './shared/testDb'; +import { mockInstance } from '../shared/mocking'; +import { createOwner } from './shared/db/users'; +import { createWorkflow, getWorkflowById } from './shared/db/workflows'; + +import type { User } from '@db/entities/User'; + +describe('ImportService', () => { + let importService: ImportService; + let tagRepository: TagRepository; + let owner: User; + + beforeAll(async () => { + await testDb.init(); + + owner = await createOwner(); + + tagRepository = Container.get(TagRepository); + + const credentialsRepository = mockInstance(CredentialsRepository); + + credentialsRepository.find.mockResolvedValue([]); + + importService = new ImportService( + mock(), + credentialsRepository, + tagRepository, + Container.get(RoleService), + ); + }); + + afterEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag', 'WorkflowTagMapping']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + test('should import credless and tagless workflow', async () => { + const workflowToImport = await createWorkflow(); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await getWorkflowById(workflowToImport.id); + + if (!dbWorkflow) fail('Expected to find workflow'); + + expect(dbWorkflow.id).toBe(workflowToImport.id); + }); + + test('should make user owner of imported workflow', async () => { + const workflowToImport = await createWorkflow(); + + await importService.importWorkflows([workflowToImport], owner.id); + + const workflowOwnerRole = await Container.get(RoleService).findWorkflowOwnerRole(); + + const dbSharing = await Container.get(SharedWorkflowRepository).findOneOrFail({ + where: { workflowId: workflowToImport.id, userId: owner.id, roleId: workflowOwnerRole.id }, + }); + + expect(dbSharing.userId).toBe(owner.id); + }); + + test('should deactivate imported workflow if active', async () => { + const workflowToImport = await createWorkflow({ active: true }); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await getWorkflowById(workflowToImport.id); + + if (!dbWorkflow) fail('Expected to find workflow'); + + expect(dbWorkflow.active).toBe(false); + }); + + test('should leave intact new-format credentials', async () => { + const credential = { + n8nApi: { id: '123', name: 'n8n API' }, + }; + + const nodes: INode[] = [ + { + id: uuid(), + name: 'n8n', + parameters: {}, + position: [0, 0], + type: 'n8n-nodes-base.n8n', + typeVersion: 1, + credentials: credential, + }, + ]; + + const workflowToImport = await createWorkflow({ nodes }); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await getWorkflowById(workflowToImport.id); + + if (!dbWorkflow) fail('Expected to find workflow'); + + expect(dbWorkflow.nodes.at(0)?.credentials).toMatchObject(credential); + }); + + test('should set tag by identical match', async () => { + const tag = Object.assign(new TagEntity(), { + id: '123', + createdAt: new Date(), + name: 'Test', + }); + + await tagRepository.save(tag); // tag stored + + const workflowToImport = await createWorkflow({ tags: [tag] }); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ + where: { id: workflowToImport.id }, + relations: ['tags'], + }); + + expect(dbWorkflow.tags).toStrictEqual([tag]); // workflow tagged + + const dbTags = await tagRepository.find(); + + expect(dbTags).toStrictEqual([tag]); // tag matched + }); + + test('should set tag by name match', async () => { + const tag = Object.assign(new TagEntity(), { name: 'Test' }); + + await tagRepository.save(tag); // tag stored + + const workflowToImport = await createWorkflow({ tags: [tag] }); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ + where: { id: workflowToImport.id }, + relations: ['tags'], + }); + + expect(dbWorkflow.tags).toStrictEqual([tag]); // workflow tagged + + const dbTags = await tagRepository.find(); + + expect(dbTags).toStrictEqual([tag]); // tag matched + }); + + test('should set tag by creating if no match', async () => { + const tag = Object.assign(new TagEntity(), { name: 'Test' }); // tag not stored + + const workflowToImport = await createWorkflow({ tags: [tag] }); + + await importService.importWorkflows([workflowToImport], owner.id); + + const dbWorkflow = await Container.get(WorkflowRepository).findOneOrFail({ + where: { id: workflowToImport.id }, + relations: ['tags'], + }); + + if (!dbWorkflow.tags) fail('No tags found on workflow'); + + expect(dbWorkflow.tags.at(0)?.name).toBe(tag.name); // workflow tagged + + const dbTag = await tagRepository.findOneOrFail({ where: { name: tag.name } }); + + expect(dbTag.name).toBe(tag.name); // tag created + }); +}); From 8f364087c91633126ee17806a0a0d4c930e83337 Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Wed, 13 Dec 2023 12:18:29 +0100 Subject: [PATCH 04/16] docs(editor): Change to Summarize icon and Advanced section description (no-changelog) (#8008) ## Summary - Change the description of the Advanced" section - Replace the icon of Summarize ## Related tickets and issues https://linear.app/n8n/issue/NODE-984/data-transformations-menu-minor-fixes ## Review / Merge checklist - [ ] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests. --- .../src/plugins/i18n/locales/en.json | 2 +- .../nodes/Transform/Summarize/summarize.svg | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 8b2687f8debf6..8d574d2920ce7 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -868,7 +868,7 @@ "nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data, run JavaScript code, etc.", "nodeCreator.subcategoryDescriptions.files": "CSV, XLS, XML, text, images, etc.", "nodeCreator.subcategoryDescriptions.flow": "IF, Switch, Wait, Compare and Merge data, etc.", - "nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API Calls), date and time, scrape HTML, RSS, SSH, etc.", + "nodeCreator.subcategoryDescriptions.helpers": "Code, HTTP Requests (API Calls), Webhook, and other helpers", "nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, etc.", "nodeCreator.subcategoryDescriptions.agents": "Autonomous entities that interact and make decisions.", "nodeCreator.subcategoryDescriptions.chains": "Structured assemblies for specific tasks.", diff --git a/packages/nodes-base/nodes/Transform/Summarize/summarize.svg b/packages/nodes-base/nodes/Transform/Summarize/summarize.svg index e6db4919f392a..1fc0fe0a8ff45 100644 --- a/packages/nodes-base/nodes/Transform/Summarize/summarize.svg +++ b/packages/nodes-base/nodes/Transform/Summarize/summarize.svg @@ -1,13 +1,10 @@ - - - - - - - - - - - + + + + + + + + From a70a5076eee4d5967931000e7f102626eaa53c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 13 Dec 2023 12:22:11 +0100 Subject: [PATCH 05/16] refactor(core): Add telemetry for RBAC roles (#7969) Add telemetry for RBAC roles, see [requirements](https://linear.app/n8n/issue/PAY-1067/add-telemetry-events-for-adding-and-assigning-admin-users#comment-184619fe). --- packages/cli/src/Interfaces.ts | 7 +++++++ packages/cli/src/InternalHooks.ts | 14 ++++++++++++++ packages/cli/src/controllers/users.controller.ts | 7 +++++++ packages/cli/src/services/user.service.ts | 13 +++++++++++-- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 1caadbee578e9..b57836d3979e4 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -356,6 +356,13 @@ export interface IInternalHooksClass { target_user_id: string[]; public_api: boolean; email_sent: boolean; + invitee_role: string; + }): Promise; + onUserRoleChange(userInviteData: { + user: User; + target_user_id: string; + public_api: boolean; + target_user_new_role: string; }): Promise; onUserReinvite(userReinviteData: { user: User; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 863a0e05bc1c3..45b5c8ed1c069 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -498,6 +498,7 @@ export class InternalHooks implements IInternalHooksClass { target_user_id: string[]; public_api: boolean; email_sent: boolean; + invitee_role: string; }): Promise { void Promise.all([ eventBus.sendAuditEvent({ @@ -507,15 +508,28 @@ export class InternalHooks implements IInternalHooksClass { targetUserId: userInviteData.target_user_id, }, }), + this.telemetry.track('User invited new user', { user_id: userInviteData.user.id, target_user_id: userInviteData.target_user_id, public_api: userInviteData.public_api, email_sent: userInviteData.email_sent, + invitee_role: userInviteData.invitee_role, }), ]); } + async onUserRoleChange(userRoleChangeData: { + user: User; + target_user_id: string; + public_api: boolean; + target_user_new_role: string; + }) { + const { user, ...rest } = userRoleChangeData; + + void this.telemetry.track('User changed role', { user_id: user.id, ...rest }); + } + async onUserReinvite(userReinviteData: { user: User; target_user_id: string; diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 0ef0ae55b6a10..6e0ae6438ea47 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -391,6 +391,13 @@ export class UsersController { await this.userService.update(targetUser.id, { globalRole: roleToSet }); + void this.internalHooks.onUserRoleChange({ + user: req.user, + target_user_id: targetUser.id, + target_user_new_role: [newRole.scope, newRole.name].join(' '), + public_api: false, + }); + return { success: true }; } } diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 2dd4122f1a3ec..41037f26c27c1 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -188,7 +188,11 @@ export class UserService { return Promise.race([fetchPromise, timeoutPromise]); } - private async sendEmails(owner: User, toInviteUsers: { [key: string]: string }) { + private async sendEmails( + owner: User, + toInviteUsers: { [key: string]: string }, + role: 'member' | 'admin', + ) { const domain = getInstanceBaseUrl(); return Promise.all( @@ -225,6 +229,7 @@ export class UserService { target_user_id: Object.values(toInviteUsers), public_api: false, email_sent: result.emailSent, + invitee_role: role, // same role for all invited users }); } catch (e) { if (e instanceof Error) { @@ -294,7 +299,11 @@ export class UserService { pendingUsersToInvite.forEach(({ email, id }) => createdUsers.set(email, id)); - const usersInvited = await this.sendEmails(owner, Object.fromEntries(createdUsers)); + const usersInvited = await this.sendEmails( + owner, + Object.fromEntries(createdUsers), + toCreateUsers[0].role, // same role for all invited users + ); return { usersInvited, usersCreated: toCreateUsers.map(({ email }) => email) }; } From 9229055f7bc5bece20b51e279571f8dd6d4a5b1f Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 13 Dec 2023 11:32:00 +0000 Subject: [PATCH 06/16] docs: Add aliases to compare datasets node (#8010) ## Summary Make the Compare Datasets node findable when typing "sync" or "syncing" in the Nodes panel https://linear.app/n8n/issue/NODE-991/make-the-compare-datasets-node-finable-when-typing-sync-or-syncing-in --- .../nodes-base/nodes/CompareDatasets/CompareDatasets.node.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/CompareDatasets/CompareDatasets.node.json b/packages/nodes-base/nodes/CompareDatasets/CompareDatasets.node.json index 4d821972fb32f..516b8928c6825 100644 --- a/packages/nodes-base/nodes/CompareDatasets/CompareDatasets.node.json +++ b/packages/nodes-base/nodes/CompareDatasets/CompareDatasets.node.json @@ -41,7 +41,7 @@ } ] }, - "alias": ["Join", "Concatenate", "Compare", "Dataset", "Split"], + "alias": ["Join", "Concatenate", "Compare", "Dataset", "Split", "Sync", "Syncing"], "subcategories": { "Core Nodes": ["Flow"] } From b00b9057a42f23cd9c4bb6675a3e6134610bf81b Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 13 Dec 2023 12:40:44 +0100 Subject: [PATCH 07/16] fix(editor): Fix Webhook URL expansion icon (#8011) Changes the rotation of the icon. New collapsed: ![Screenshot from 2023-12-13 12-06-12](https://github.com/n8n-io/n8n/assets/6249596/2991a660-d6b4-4272-88cf-6a2356d02a7e) New expanded: ![Screenshot from 2023-12-13 12-06-22](https://github.com/n8n-io/n8n/assets/6249596/0766db9e-b868-49a8-9009-dfff4ab0b13b) --- packages/editor-ui/src/components/NodeWebhooks.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue index 653dad73b966b..d218d4e0783fd 100644 --- a/packages/editor-ui/src/components/NodeWebhooks.vue +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -6,7 +6,7 @@ @click="isMinimized = !isMinimized" :title="isMinimized ? baseText.clickToDisplay : baseText.clickToHide" > - + {{ baseText.toggleTitle }} @@ -231,10 +231,10 @@ export default defineComponent({ transition-property: transform; } .expanded .minimize-button { - -webkit-transform: rotate(180deg); - -moz-transform: rotate(180deg); - -o-transform: rotate(180deg); - transform: rotate(180deg); + -webkit-transform: rotate(90deg); + -moz-transform: rotate(90deg); + -o-transform: rotate(90deg); + transform: rotate(90deg); } .webhook-url { From e5581ce8023e21d3dcf140099f3a53e5ffb4584f Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 13 Dec 2023 11:41:06 +0000 Subject: [PATCH 08/16] fix(core): Prevent workflow history saving error from happening (#7812) When performing actions such as renaming a workflow or updating its settings, n8n errors with "Failed to save workflow version" in the console although the saving process was successful. We are now correctly checking whether `nodes` and `connections` exist and only then save a snapshot. Github issue / Community forum post (link here to close automatically): --- .../workflowHistory.service.ee.ts | 5 +- .../cli/src/workflows/workflows.services.ts | 21 +- .../cli/test/unit/WorkflowHelpers.test.ts | 2 +- .../unit/services/ownership.service.test.ts | 258 ++++++++---------- .../workflowHistory.service.ee.test.ts | 114 ++++++++ packages/cli/test/unit/shared/mockObjects.ts | 42 +++ 6 files changed, 287 insertions(+), 155 deletions(-) create mode 100644 packages/cli/test/unit/services/workflowHistory.service.ee.test.ts create mode 100644 packages/cli/test/unit/shared/mockObjects.ts diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index e5243551b2098..2b5852f23b95a 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -66,7 +66,10 @@ export class WorkflowHistoryService { } async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) { - if (isWorkflowHistoryEnabled()) { + // On some update scenarios, `nodes` and `connections` are missing, such as when + // changing workflow settings or renaming. In these cases, we don't want to save + // a new version + if (isWorkflowHistoryEnabled() && workflow.nodes && workflow.connections) { try { await this.workflowHistoryRepository.insert({ authors: user.firstName + ' ' + user.lastName, diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index ea6d6704a22c0..9dc153d62e483 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -4,6 +4,7 @@ import { NodeApiError, ErrorReporterProxy as ErrorReporter, Workflow } from 'n8n import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm'; import { In, Like } from 'typeorm'; import pick from 'lodash/pick'; +import omit from 'lodash/omit'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import * as WorkflowHelpers from '@/WorkflowHelpers'; @@ -230,21 +231,9 @@ export class WorkflowsService { ); } - let onlyActiveUpdate = false; - - if ( - (Object.keys(workflow).length === 3 && - workflow.id !== undefined && - workflow.versionId !== undefined && - workflow.active !== undefined) || - (Object.keys(workflow).length === 2 && - workflow.versionId !== undefined && - workflow.active !== undefined) - ) { - // we're just updating the active status of the workflow, don't update the versionId - onlyActiveUpdate = true; - } else { - // Update the workflow's version + if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) { + // Update the workflow's version when changing properties such as + // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` workflow.versionId = uuid(); logger.verbose( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, @@ -320,7 +309,7 @@ export class WorkflowsService { ); } - if (!onlyActiveUpdate && workflow.versionId !== shared.workflow.versionId) { + if (workflow.versionId !== shared.workflow.versionId) { await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId); } diff --git a/packages/cli/test/unit/WorkflowHelpers.test.ts b/packages/cli/test/unit/WorkflowHelpers.test.ts index dde5cdcd60850..54f8b6e912ce7 100644 --- a/packages/cli/test/unit/WorkflowHelpers.test.ts +++ b/packages/cli/test/unit/WorkflowHelpers.test.ts @@ -193,7 +193,7 @@ function generateCredentialEntity(credentialId: string) { return credentialEntity; } -function getWorkflow(options?: { +export function getWorkflow(options?: { addNodeWithoutCreds?: boolean; addNodeWithOneCred?: boolean; addNodeWithTwoCreds?: boolean; diff --git a/packages/cli/test/unit/services/ownership.service.test.ts b/packages/cli/test/unit/services/ownership.service.test.ts index 983ca278ab588..20be942c2f128 100644 --- a/packages/cli/test/unit/services/ownership.service.test.ts +++ b/packages/cli/test/unit/services/ownership.service.test.ts @@ -4,51 +4,18 @@ import { Role } from '@db/entities/Role'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { User } from '@db/entities/User'; import { RoleService } from '@/services/role.service'; -import { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; import { mockInstance } from '../../shared/mocking'; -import { - randomCredentialPayload, - randomEmail, - randomInteger, - randomName, -} from '../../integration/shared/random'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { mock } from 'jest-mock-extended'; - -const wfOwnerRole = () => - Object.assign(new Role(), { - scope: 'workflow', - name: 'owner', - id: randomInteger(), - }); - -const mockCredRole = (name: 'owner' | 'editor'): Role => - Object.assign(new Role(), { - scope: 'credentials', - name, - id: randomInteger(), - }); - -const mockInstanceOwnerRole = () => - Object.assign(new Role(), { - scope: 'global', - name: 'owner', - id: randomInteger(), - }); - -const mockCredential = (): CredentialsEntity => - Object.assign(new CredentialsEntity(), randomCredentialPayload()); - -const mockUser = (attributes?: Partial): User => - Object.assign(new User(), { - id: randomInteger(), - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), - ...attributes, - }); +import { + mockCredRole, + mockCredential, + mockUser, + mockInstanceOwnerRole, + wfOwnerRole, +} from '../shared/mockObjects'; describe('OwnershipService', () => { const roleService = mockInstance(RoleService); @@ -66,132 +33,149 @@ describe('OwnershipService', () => { jest.clearAllMocks(); }); - describe('getWorkflowOwner()', () => { - test('should retrieve a workflow owner', async () => { - roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); - - const mockOwner = new User(); - const mockNonOwner = new User(); - - const sharedWorkflow = Object.assign(new SharedWorkflow(), { - role: new Role(), - user: mockOwner, - }); - - sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); + describe('OwnershipService', () => { + const roleService = mockInstance(RoleService); + const userRepository = mockInstance(UserRepository); + const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); - const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); + const ownershipService = new OwnershipService( + mock(), + userRepository, + roleService, + sharedWorkflowRepository, + ); - expect(returnedOwner).toBe(mockOwner); - expect(returnedOwner).not.toBe(mockNonOwner); + beforeEach(() => { + jest.clearAllMocks(); }); - test('should throw if no workflow owner role found', async () => { - roleService.findWorkflowOwnerRole.mockRejectedValueOnce(new Error()); + describe('getWorkflowOwner()', () => { + test('should retrieve a workflow owner', async () => { + roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); - }); - - test('should throw if no workflow owner found', async () => { - roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); + const mockOwner = new User(); + const mockNonOwner = new User(); - sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); + const sharedWorkflow = Object.assign(new SharedWorkflow(), { + role: new Role(), + user: mockOwner, + }); - await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); - }); - }); + sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow); - describe('addOwnedByAndSharedWith()', () => { - test('should add `ownedBy` and `sharedWith` to credential', async () => { - const owner = mockUser(); - const editor = mockUser(); + const returnedOwner = await ownershipService.getWorkflowOwnerCached('some-workflow-id'); - const credential = mockCredential(); - - credential.shared = [ - { role: mockCredRole('owner'), user: owner }, - { role: mockCredRole('editor'), user: editor }, - ] as SharedCredentials[]; + expect(returnedOwner).toBe(mockOwner); + expect(returnedOwner).not.toBe(mockNonOwner); + }); - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + test('should throw if no workflow owner role found', async () => { + roleService.findWorkflowOwnerRole.mockRejectedValueOnce(new Error()); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); }); - expect(sharedWith).toStrictEqual([ - { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, - }, - ]); - }); - - test('should add `ownedBy` and `sharedWith` to workflow', async () => { - const owner = mockUser(); - const editor = mockUser(); + test('should throw if no workflow owner found', async () => { + roleService.findWorkflowOwnerRole.mockResolvedValueOnce(wfOwnerRole()); - const workflow = new WorkflowEntity(); + sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error()); - workflow.shared = [ - { role: mockCredRole('owner'), user: owner }, - { role: mockCredRole('editor'), user: editor }, - ] as SharedWorkflow[]; + await expect(ownershipService.getWorkflowOwnerCached('some-workflow-id')).rejects.toThrow(); + }); + }); - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); + describe('addOwnedByAndSharedWith()', () => { + test('should add `ownedBy` and `sharedWith` to credential', async () => { + const owner = mockUser(); + const editor = mockUser(); + + const credential = mockCredential(); + + credential.shared = [ + { role: mockCredRole('owner'), user: owner }, + { role: mockCredRole('editor'), user: editor }, + ] as SharedCredentials[]; + + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + + expect(sharedWith).toStrictEqual([ + { + id: editor.id, + email: editor.email, + firstName: editor.firstName, + lastName: editor.lastName, + }, + ]); + }); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + test('should add `ownedBy` and `sharedWith` to workflow', async () => { + const owner = mockUser(); + const editor = mockUser(); + + const workflow = new WorkflowEntity(); + + workflow.shared = [ + { role: mockCredRole('owner'), user: owner }, + { role: mockCredRole('editor'), user: editor }, + ] as SharedWorkflow[]; + + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(workflow); + + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + + expect(sharedWith).toStrictEqual([ + { + id: editor.id, + email: editor.email, + firstName: editor.firstName, + lastName: editor.lastName, + }, + ]); }); - expect(sharedWith).toStrictEqual([ - { - id: editor.id, - email: editor.email, - firstName: editor.firstName, - lastName: editor.lastName, - }, - ]); - }); + test('should produce an empty sharedWith if no sharee', async () => { + const owner = mockUser(); - test('should produce an empty sharedWith if no sharee', async () => { - const owner = mockUser(); + const credential = mockCredential(); - const credential = mockCredential(); + credential.shared = [{ role: mockCredRole('owner'), user: owner }] as SharedCredentials[]; - credential.shared = [{ role: mockCredRole('owner'), user: owner }] as SharedCredentials[]; + const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); - const { ownedBy, sharedWith } = ownershipService.addOwnedByAndSharedWith(credential); + expect(ownedBy).toStrictEqual({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); - expect(ownedBy).toStrictEqual({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, + expect(sharedWith).toHaveLength(0); }); - - expect(sharedWith).toHaveLength(0); }); - }); - describe('getInstanceOwner()', () => { - test('should find owner using global owner role ID', async () => { - const instanceOwnerRole = mockInstanceOwnerRole(); - roleService.findGlobalOwnerRole.mockResolvedValue(instanceOwnerRole); + describe('getInstanceOwner()', () => { + test('should find owner using global owner role ID', async () => { + const instanceOwnerRole = mockInstanceOwnerRole(); + roleService.findGlobalOwnerRole.mockResolvedValue(instanceOwnerRole); - await ownershipService.getInstanceOwner(); + await ownershipService.getInstanceOwner(); - expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ - where: { globalRoleId: instanceOwnerRole.id }, - relations: ['globalRole'], + expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ + where: { globalRoleId: instanceOwnerRole.id }, + relations: ['globalRole'], + }); }); }); }); diff --git a/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts b/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts new file mode 100644 index 0000000000000..7a623188e6a83 --- /dev/null +++ b/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts @@ -0,0 +1,114 @@ +import { User } from '@db/entities/User'; +import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; +import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; +import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; +import { mockInstance } from '../../shared/mocking'; +import { Logger } from '@/Logger'; +import { getWorkflow } from '../WorkflowHelpers.test'; +import { mockClear } from 'jest-mock-extended'; + +const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); +const logger = mockInstance(Logger); +const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); +const workflowHistoryService = new WorkflowHistoryService( + logger, + workflowHistoryRepository, + sharedWorkflowRepository, +); +const testUser = Object.assign(new User(), { + id: '1234', + password: 'passwordHash', + mfaEnabled: false, + firstName: 'John', + lastName: 'Doe', +}); +let isWorkflowHistoryEnabled = true; + +jest.mock('@/workflows/workflowHistory/workflowHistoryHelper.ee', () => { + return { + isWorkflowHistoryEnabled: jest.fn(() => isWorkflowHistoryEnabled), + }; +}); + +describe('WorkflowHistoryService', () => { + beforeEach(() => { + mockClear(workflowHistoryRepository.insert); + }); + + describe('saveVersion', () => { + it('should save a new version when workflow history is enabled and nodes and connections are present', async () => { + // Arrange + isWorkflowHistoryEnabled = true; + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const workflowId = '123'; + workflow.connections = {}; + workflow.id = workflowId; + workflow.versionId = '456'; + + // Act + await workflowHistoryService.saveVersion(testUser, workflow, workflowId); + + // Assert + expect(workflowHistoryRepository.insert).toHaveBeenCalledWith({ + authors: 'John Doe', + connections: {}, + nodes: workflow.nodes, + versionId: workflow.versionId, + workflowId, + }); + }); + + it('should not save a new version when workflow history is disabled', async () => { + // Arrange + isWorkflowHistoryEnabled = false; + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const workflowId = '123'; + workflow.connections = {}; + workflow.id = workflowId; + workflow.versionId = '456'; + + // Act + await workflowHistoryService.saveVersion(testUser, workflow, workflowId); + + // Assert + expect(workflowHistoryRepository.insert).not.toHaveBeenCalled(); + }); + + it('should not save a new version when nodes or connections are missing', async () => { + // Arrange + isWorkflowHistoryEnabled = true; + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const workflowId = '123'; + workflow.id = workflowId; + workflow.versionId = '456'; + // Nodes are set but connections is empty + + // Act + await workflowHistoryService.saveVersion(testUser, workflow, workflowId); + + // Assert + expect(workflowHistoryRepository.insert).not.toHaveBeenCalled(); + }); + + it('should log an error when failed to save workflow history version', async () => { + // Arrange + isWorkflowHistoryEnabled = true; + const workflow = getWorkflow({ addNodeWithoutCreds: true }); + const workflowId = '123'; + workflow.connections = {}; + workflow.id = workflowId; + workflow.versionId = '456'; + workflowHistoryRepository.insert.mockRejectedValueOnce(new Error('Test error')); + + // Act + await workflowHistoryService.saveVersion(testUser, workflow, workflowId); + + // Assert + expect(workflowHistoryRepository.insert).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to save workflow history version for workflow 123', + expect.any(Error), + ); + }); + }); +}); diff --git a/packages/cli/test/unit/shared/mockObjects.ts b/packages/cli/test/unit/shared/mockObjects.ts new file mode 100644 index 0000000000000..dee4d97150266 --- /dev/null +++ b/packages/cli/test/unit/shared/mockObjects.ts @@ -0,0 +1,42 @@ +import { User } from '@db/entities/User'; +import { Role } from '@db/entities/Role'; +import { CredentialsEntity } from '@db/entities/CredentialsEntity'; + +import { + randomCredentialPayload, + randomEmail, + randomInteger, + randomName, +} from '../../integration/shared/random'; + +export const wfOwnerRole = () => + Object.assign(new Role(), { + scope: 'workflow', + name: 'owner', + id: randomInteger(), + }); + +export const mockCredRole = (name: 'owner' | 'editor'): Role => + Object.assign(new Role(), { + scope: 'credentials', + name, + id: randomInteger(), + }); + +export const mockCredential = (): CredentialsEntity => + Object.assign(new CredentialsEntity(), randomCredentialPayload()); + +export const mockUser = (): User => + Object.assign(new User(), { + id: randomInteger(), + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + }); + +export const mockInstanceOwnerRole = () => + Object.assign(new Role(), { + scope: 'global', + name: 'owner', + id: randomInteger(), + }); From 09a5729305a8072f5e98a320c85ad1c83a6946ed Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 13 Dec 2023 13:04:16 +0000 Subject: [PATCH 09/16] fix(Postgres Trigger Node): Increase manual trigger timeout from 30 to 60 seconds (#8015) --- packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts index b58c8f270837b..e5a2891cb4c91 100644 --- a/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts +++ b/packages/nodes-base/nodes/Postgres/PostgresTrigger.node.ts @@ -307,7 +307,7 @@ export class PostgresTrigger implements INodeType { })(), ), ); - }, 30000); + }, 60000); connection.client.on('notification', async (data: IDataObject) => { if (data.payload) { try { From 8a5343401dd355436120a9a424ae455e80b50da6 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 13 Dec 2023 14:45:22 +0100 Subject: [PATCH 10/16] feat(editor): Filter component + implement in If node (#7490) New Filter component + implementation in If node (v2) image --------- Co-authored-by: Giulio Andreini Co-authored-by: Michael Kret --- cypress/constants.ts | 2 +- cypress/e2e/30-if-node.cy.ts | 58 + cypress/e2e/4-node-creator.cy.ts | 7 +- cypress/fixtures/Test_workflow_filter.json | 153 +++ cypress/pages/ndv.ts | 29 +- .../DocumentBinaryInputLoader.node.ts | 3 +- packages/core/src/ExtractValue.ts | 52 +- packages/core/src/NodeExecuteFunctions.ts | 20 +- .../components/N8nInputLabel/InputLabel.vue | 17 +- .../ResizeObserver/ResizeObserver.vue | 45 +- .../design-system/src/components/index.ts | 1 + .../design-system/src/css/common/var.scss | 27 +- packages/design-system/src/css/input.scss | 20 +- packages/design-system/src/css/select.scss | 8 + packages/design-system/src/plugin.ts | 2 + packages/editor-ui/src/Interface.ts | 2 +- .../src/components/DraggableTarget.vue | 9 +- .../components/ExpressionParameterInput.vue | 26 +- .../FilterConditions/CombinatorSelect.vue | 48 + .../components/FilterConditions/Condition.vue | 451 ++++++++ .../FilterConditions/FilterConditions.vue | 228 ++++ .../FilterConditions/OperatorSelect.vue | 137 +++ .../components/FilterConditions/constants.ts | 272 +++++ .../src/components/FilterConditions/types.ts | 13 + .../components/FixedCollectionParameter.vue | 121 +- .../InlineExpressionEditor/theme.ts | 12 +- .../src/components/ParameterInput.vue | 5 + .../src/components/ParameterInputFull.vue | 63 +- .../src/components/ParameterInputList.vue | 74 +- .../src/components/ParameterInputWrapper.vue | 44 +- .../ResourceLocator/ResourceLocator.vue | 2 +- .../ResourceMapper/MappingFields.vue | 11 +- .../__tests__/FilterConditions.test.ts | 250 ++++ .../src/plugins/i18n/locales/en.json | 46 + packages/editor-ui/src/stores/ndv.store.ts | 12 +- packages/nodes-base/nodes/If/If.node.ts | 502 +------- packages/nodes-base/nodes/If/V1/IfV1.node.ts | 486 ++++++++ packages/nodes-base/nodes/If/V2/IfV2.node.ts | 111 ++ .../nodes/If/test/{ => v1}/IF.boolean.json | 0 .../nodes/If/test/{ => v1}/IF.date-time.json | 0 .../nodes/If/test/{ => v1}/IF.number.json | 0 .../nodes/If/test/{ => v1}/IF.string.json | 0 .../nodes/If/test/{ => v1}/If.node.test.ts | 0 .../nodes/If/test/v2/IfV2.date-time.json | 165 +++ .../nodes/If/test/v2/IfV2.node.test.ts | 5 + .../nodes/If/test/v2/IfV2.number.json | 165 +++ .../nodes/If/test/v2/IfV2.other.json | 182 +++ .../nodes/If/test/v2/IfV2.string.json | 178 +++ packages/workflow/src/Interfaces.ts | 50 + packages/workflow/src/NodeHelpers.ts | 213 +--- .../src/NodeParameters/FilterParameter.ts | 345 ++++++ packages/workflow/src/TypeValidation.ts | 232 ++++ packages/workflow/src/index.ts | 3 + packages/workflow/src/type-guards.ts | 7 + .../workflow/test/FilterParameter.test.ts | 1012 +++++++++++++++++ packages/workflow/test/TypeValidation.test.ts | 42 +- 56 files changed, 5064 insertions(+), 904 deletions(-) create mode 100644 cypress/e2e/30-if-node.cy.ts create mode 100644 cypress/fixtures/Test_workflow_filter.json create mode 100644 packages/editor-ui/src/components/FilterConditions/CombinatorSelect.vue create mode 100644 packages/editor-ui/src/components/FilterConditions/Condition.vue create mode 100644 packages/editor-ui/src/components/FilterConditions/FilterConditions.vue create mode 100644 packages/editor-ui/src/components/FilterConditions/OperatorSelect.vue create mode 100644 packages/editor-ui/src/components/FilterConditions/constants.ts create mode 100644 packages/editor-ui/src/components/FilterConditions/types.ts create mode 100644 packages/editor-ui/src/components/__tests__/FilterConditions.test.ts create mode 100644 packages/nodes-base/nodes/If/V1/IfV1.node.ts create mode 100644 packages/nodes-base/nodes/If/V2/IfV2.node.ts rename packages/nodes-base/nodes/If/test/{ => v1}/IF.boolean.json (100%) rename packages/nodes-base/nodes/If/test/{ => v1}/IF.date-time.json (100%) rename packages/nodes-base/nodes/If/test/{ => v1}/IF.number.json (100%) rename packages/nodes-base/nodes/If/test/{ => v1}/IF.string.json (100%) rename packages/nodes-base/nodes/If/test/{ => v1}/If.node.test.ts (100%) create mode 100644 packages/nodes-base/nodes/If/test/v2/IfV2.date-time.json create mode 100644 packages/nodes-base/nodes/If/test/v2/IfV2.node.test.ts create mode 100644 packages/nodes-base/nodes/If/test/v2/IfV2.number.json create mode 100644 packages/nodes-base/nodes/If/test/v2/IfV2.other.json create mode 100644 packages/nodes-base/nodes/If/test/v2/IfV2.string.json create mode 100644 packages/workflow/src/NodeParameters/FilterParameter.ts create mode 100644 packages/workflow/src/TypeValidation.ts create mode 100644 packages/workflow/test/FilterParameter.test.ts diff --git a/cypress/constants.ts b/cypress/constants.ts index 5074c410807c7..7524168535114 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -41,7 +41,7 @@ export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; export const SET_NODE_NAME = 'Set'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; -export const IF_NODE_NAME = 'IF'; +export const IF_NODE_NAME = 'If'; export const MERGE_NODE_NAME = 'Merge'; export const SWITCH_NODE_NAME = 'Switch'; export const GMAIL_NODE_NAME = 'Gmail'; diff --git a/cypress/e2e/30-if-node.cy.ts b/cypress/e2e/30-if-node.cy.ts new file mode 100644 index 0000000000000..95ed1e9a0d34d --- /dev/null +++ b/cypress/e2e/30-if-node.cy.ts @@ -0,0 +1,58 @@ +import { IF_NODE_NAME } from '../constants'; +import { WorkflowPage, NDV } from '../pages'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); + +const FILTER_PARAM_NAME = 'conditions'; + +describe('If Node (filter component)', () => { + beforeEach(() => { + workflowPage.actions.visit(); + }); + + it('should be able to create and delete multiple conditions', () => { + workflowPage.actions.addInitialNodeToCanvas(IF_NODE_NAME, { keepNdvOpen: true }); + + // Default state + ndv.getters.filterComponent(FILTER_PARAM_NAME).should('exist'); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1); + ndv.getters + .filterConditionOperator(FILTER_PARAM_NAME) + .find('input') + .should('have.value', 'is equal to'); + + // Add + ndv.actions.addFilterCondition(FILTER_PARAM_NAME); + ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 0).find('input').type('first left'); + ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 1).find('input').type('second left'); + ndv.actions.addFilterCondition(FILTER_PARAM_NAME); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 3); + + // Delete + ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 0); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 2); + ndv.getters + .filterConditionLeft(FILTER_PARAM_NAME, 0) + .find('input') + .should('have.value', 'second left'); + ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 1); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1); + }); + + it('should correctly evaluate conditions', () => { + cy.fixture('Test_workflow_filter.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + + workflowPage.actions.openNode('Then'); + ndv.getters.outputPanel().contains('3 items').should('exist'); + ndv.actions.close(); + + workflowPage.actions.openNode('Else'); + ndv.getters.outputPanel().contains('1 item').should('exist'); + }); +}); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 828fb27cd388e..49ff848cfee36 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -2,6 +2,7 @@ import { NodeCreator } from '../pages/features/node-creator'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { NDV } from '../pages/ndv'; import { getVisibleSelect } from '../utils'; +import { IF_NODE_NAME } from '../constants'; const nodeCreatorFeature = new NodeCreator(); const WorkflowPage = new WorkflowPageClass(); @@ -360,7 +361,7 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); @@ -368,11 +369,11 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('IF'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); diff --git a/cypress/fixtures/Test_workflow_filter.json b/cypress/fixtures/Test_workflow_filter.json new file mode 100644 index 0000000000000..5166ead3815b4 --- /dev/null +++ b/cypress/fixtures/Test_workflow_filter.json @@ -0,0 +1,153 @@ +{ + "name": "Filter test", + "nodes": [ + { + "parameters": {}, + "id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -60, + 480 + ] + }, + { + "parameters": { + "jsCode": "return [\n {\n \"label\": \"Apple\",\n tags: [],\n meta: {foo: 'bar'}\n },\n {\n \"label\": \"Banana\",\n tags: ['exotic'],\n meta: {}\n },\n {\n \"label\": \"Pear\",\n tags: ['other'],\n meta: {}\n },\n {\n \"label\": \"Orange\",\n meta: {}\n }\n]" + }, + "id": "60697c7f-3948-4790-97ba-8aba03d02ac2", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 160, + 480 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "" + }, + "conditions": [ + { + "leftValue": "={{ $json.tags }}", + "rightValue": "exotic", + "operator": { + "type": "array", + "operation": "contains", + "rightType": "any" + } + }, + { + "leftValue": "={{ $json.meta }}", + "rightValue": "", + "operator": { + "type": "object", + "operation": "notEmpty", + "singleValue": true + } + }, + { + "leftValue": "={{ $json.label }}", + "rightValue": "Pea", + "operator": { + "type": "string", + "operation": "startsWith", + "rightType": "string" + } + } + ], + "combinator": "or" + }, + "options": {} + }, + "id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a", + "name": "If", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 380, + 480 + ] + }, + { + "parameters": {}, + "id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583", + "name": "Then", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 600, + 400 + ] + }, + { + "parameters": {}, + "id": "69364770-60d2-4ef4-9f29-9570718a9a10", + "name": "Else", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 600, + 580 + ] + } + ], + "pinData": {}, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Then", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Else", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "a6249f48-d88f-4b80-9ed9-79555e522d48", + "id": "BWUTRs5RHxVgQ4uT", + "meta": { + "instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c" + }, + "tags": [] +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 58749721a4e02..0eaa1361cfa82 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -49,9 +49,7 @@ export class NDV extends BasePage { parameterExpressionPreview: (parameterName: string) => this.getters .nodeParameters() - .find( - `[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`, - ), + .find(`[data-test-id="parameter-expression-preview-${parameterName}"]`), nodeNameContainer: () => cy.getByTestId('node-title-container'), nodeRenameInput: () => cy.getByTestId('node-rename-input'), executePrevious: () => cy.getByTestId('execute-previous-node'), @@ -79,6 +77,23 @@ export class NDV extends BasePage { cy.getByTestId('columns-parameter-input-options-container'), resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'), sqlEditorContainer: () => cy.getByTestId('sql-editor-container'), + filterComponent: (paramName: string) => cy.getByTestId(`filter-${paramName}`), + filterCombinator: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-combinator-select').eq(index), + filterConditions: (paramName: string) => + this.getters.filterComponent(paramName).getByTestId('filter-condition'), + filterCondition: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition').eq(index), + filterConditionLeft: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition-left').eq(index), + filterConditionRight: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition-right').eq(index), + filterConditionOperator: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-operator-select').eq(index), + filterConditionRemove: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-remove-condition').eq(index), + filterConditionAdd: (paramName: string) => + this.getters.filterComponent(paramName).getByTestId('filter-add-condition'), searchInput: () => cy.getByTestId('ndv-search'), pagination: () => cy.getByTestId('ndv-data-pagination'), nodeVersion: () => cy.getByTestId('node-version'), @@ -199,7 +214,6 @@ export class NDV extends BasePage { .find('span') .should('include.html', asEncodedHTML(value)); }, - refreshResourceMapperColumns: () => { this.getters.resourceMapperSelectColumn().realHover(); this.getters @@ -210,7 +224,12 @@ export class NDV extends BasePage { getVisiblePopper().find('li').last().click(); }, - + addFilterCondition: (paramName: string) => { + this.getters.filterConditionAdd(paramName).click(); + }, + removeFilterCondition: (paramName: string, index: number) => { + this.getters.filterConditionRemove(paramName, index).click(); + }, setInvalidExpression: ({ fieldName, invalidExpression, diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts index b5c68cde63eda..a213e8ff3b1c7 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts @@ -7,6 +7,8 @@ import { type SupplyData, } from 'n8n-workflow'; +import type { TextSplitter } from 'langchain/text_splitter'; + import { logWrapper } from '../../../utils/logWrapper'; import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields'; @@ -17,7 +19,6 @@ import { getConnectionHintNoticeField, metadataFilterField } from '../../../util import 'mammoth'; // for docx import 'epub2'; // for epub import 'pdf-parse'; // for pdf -import type { TextSplitter } from 'langchain/text_splitter'; export class DocumentBinaryInputLoader implements INodeType { description: INodeTypeDescription = { diff --git a/packages/core/src/ExtractValue.ts b/packages/core/src/ExtractValue.ts index 61833b1502a16..17959e37e3ea4 100644 --- a/packages/core/src/ExtractValue.ts +++ b/packages/core/src/ExtractValue.ts @@ -1,18 +1,19 @@ -import type { - INode, - INodeParameters, - INodeProperties, - INodePropertyCollection, - INodePropertyOptions, - INodeType, - NodeParameterValueType, -} from 'n8n-workflow'; +import get from 'lodash/get'; import { - NodeOperationError, - NodeHelpers, + ApplicationError, LoggerProxy, + NodeHelpers, + NodeOperationError, WorkflowOperationError, - ApplicationError, + executeFilter, + isFilterValue, + type INode, + type INodeParameters, + type INodeProperties, + type INodePropertyCollection, + type INodePropertyOptions, + type INodeType, + type NodeParameterValueType, } from 'n8n-workflow'; function findPropertyFromParameterName( @@ -123,6 +124,26 @@ function extractValueRLC( return executeRegexExtractValue(value.value, regex, parameterName, property.displayName); } +function extractValueFilter( + value: NodeParameterValueType | object, + property: INodeProperties, + parameterName: string, + itemIndex: number, +): NodeParameterValueType | object { + if (!isFilterValue(value)) { + return value; + } + + if (property.extractValue?.type) { + throw new ApplicationError( + `Property "${parameterName}" has an invalid extractValue type. Filter parameters only support extractValue: true`, + { extra: { parameter: parameterName } }, + ); + } + + return executeFilter(value, { itemIndex }); +} + function extractValueOther( value: NodeParameterValueType | object, property: INodeProperties | INodePropertyCollection, @@ -162,6 +183,7 @@ export function extractValue( parameterName: string, node: INode, nodeType: INodeType, + itemIndex = 0, ): NodeParameterValueType | object { let property: INodePropertyOptions | INodeProperties | INodePropertyCollection; try { @@ -174,10 +196,12 @@ export function extractValue( if (property.type === 'resourceLocator') { return extractValueRLC(value, property, parameterName); + } else if (property.type === 'filter') { + return extractValueFilter(value, property, parameterName, itemIndex); } return extractValueOther(value, property, parameterName); } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - throw new NodeOperationError(node, error); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment + throw new NodeOperationError(node, error, { description: get(error, 'description') }); } } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 6ff2bbd1bd54b..71ec3a697bb2c 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -2041,12 +2041,9 @@ const validateResourceMapperValue = ( } if (schemaEntry?.type) { - const validationResult = validateFieldType( - key, - resolvedValue, - schemaEntry.type, - schemaEntry.options, - ); + const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, { + valueOptions: schemaEntry.options, + }); if (!validationResult.valid) { return { ...validationResult, fieldName: key }; } else { @@ -2107,12 +2104,9 @@ const validateCollection = ( for (const key of Object.keys(value)) { if (!validationMap[key]) continue; - const fieldValidationResult = validateFieldType( - key, - value[key], - validationMap[key].type, - validationMap[key].options, - ); + const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, { + valueOptions: validationMap[key].options, + }); if (!fieldValidationResult.valid) { throw new ExpressionError( @@ -2270,7 +2264,7 @@ export function getNodeParameter( // This is outside the try/catch because it throws errors with proper messages if (options?.extractValue) { - returnData = extractValue(returnData, parameterName, node, nodeType); + returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex); } // Validate parameter value if it has a schema defined(RMC) or validateType defined diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue index f029f16722a9f..11278557c3364 100644 --- a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue +++ b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue @@ -190,21 +190,20 @@ export default defineComponent({ opacity: 1; } -.heading { - display: flex; -} - .overflow { overflow-x: hidden; overflow-y: clip; } -.small { - margin-bottom: var(--spacing-5xs); -} +.heading { + display: flex; -.medium { - margin-bottom: var(--spacing-2xs); + &.small { + margin-bottom: var(--spacing-5xs); + } + &.medium { + margin-bottom: var(--spacing-2xs); + } } .underline { diff --git a/packages/design-system/src/components/ResizeObserver/ResizeObserver.vue b/packages/design-system/src/components/ResizeObserver/ResizeObserver.vue index 8262bcf577b35..9f07369f77cfc 100644 --- a/packages/design-system/src/components/ResizeObserver/ResizeObserver.vue +++ b/packages/design-system/src/components/ResizeObserver/ResizeObserver.vue @@ -38,40 +38,49 @@ export default defineComponent({ return; } - const unsortedBreakpoints = [...(this.breakpoints || [])] as Array<{ - width: number; - bp: string; - }>; + const root = this.$refs.root as HTMLDivElement; - const bps = unsortedBreakpoints.sort((a, b) => a.width - b.width); + if (!root) { + return; + } + + this.bp = this.getBreakpointFromWidth(root.offsetWidth); const observer = new ResizeObserver((entries) => { entries.forEach((entry) => { // We wrap it in requestAnimationFrame to avoid this error - ResizeObserver loop limit exceeded requestAnimationFrame(() => { - const newWidth = entry.contentRect.width; - let newBP = 'default'; - for (let i = 0; i < bps.length; i++) { - if (newWidth < bps[i].width) { - newBP = bps[i].bp; - break; - } - } - this.bp = newBP; + this.bp = this.getBreakpointFromWidth(entry.contentRect.width); }); }); }); this.observer = observer; - - if (this.$refs.root) { - observer.observe(this.$refs.root as HTMLDivElement); - } + observer.observe(root); }, beforeUnmount() { if (this.enabled) { this.observer?.disconnect(); } }, + methods: { + getBreakpointFromWidth(width: number): string { + let newBP = 'default'; + const unsortedBreakpoints = [...(this.breakpoints || [])] as Array<{ + width: number; + bp: string; + }>; + + const bps = unsortedBreakpoints.sort((a, b) => a.width - b.width); + for (let i = 0; i < bps.length; i++) { + if (width < bps[i].width) { + newBP = bps[i].bp; + break; + } + } + + return newBP; + }, + }, }); diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 202e0d427fcc2..d917339b5ef33 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -50,4 +50,5 @@ export { default as N8nUserStack } from './N8nUserStack'; export { default as N8nUserInfo } from './N8nUserInfo'; export { default as N8nUserSelect } from './N8nUserSelect'; export { default as N8nUsersList } from './N8nUsersList'; +export { default as N8nResizeObserver } from './ResizeObserver'; export { N8nKeyboardShortcut } from './N8nKeyboardShortcut'; diff --git a/packages/design-system/src/css/common/var.scss b/packages/design-system/src/css/common/var.scss index 12d16555b8a06..cc0687266ce00 100644 --- a/packages/design-system/src/css/common/var.scss +++ b/packages/design-system/src/css/common/var.scss @@ -399,8 +399,16 @@ $input-placeholder-color: var(--input-placeholder-color, var(--color-text-light) $input-focus-border: var(--input-focus-border-color, var(--color-secondary)); $input-border-color: var(--input-border-color, var(--border-color-base)); $input-border-style: var(--input-border-style, var(--border-style-base)); -$input-border-width: var(--border-width-base); +$input-border-width: var(--input-border-width, var(--border-width-base)); $input-border: $input-border-color $input-border-style $input-border-width; +$input-border-right-color: var( + --input-border-right-color, + var(--input-border-color, var(--border-color-base)) +); +$input-border-bottom-color: var( + --input-border-bottom-color, + var(--input-border-color, var(--border-color-base)) +); $input-font-size: var(--input-font-size, var(--font-size-s)); /// color||Color|0 @@ -411,6 +419,23 @@ $input-width: 140px; $input-height: 40px; /// borderRadius||Border|2 $input-border-radius: var(--input-border-radius, var(--border-radius-base)); +$input-border-top-left-radius: var( + --input-border-top-left-radius, + var(--input-border-radius, var(--border-radius-base)) +); +$input-border-top-right-radius: var( + --input-border-top-right-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-bottom-left-radius: var( + --input-border-bottom-left-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-bottom-right-radius: var( + --input-border-bottom-right-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-radius: var(--input-border-radius, var(--border-radius-base)); $input-border-color-hover: $border-color-hover; /// color||Color|0 $input-background-color: var(--input-background-color, var(--color-foreground-xlight)); diff --git a/packages/design-system/src/css/input.scss b/packages/design-system/src/css/input.scss index cab4d87b28690..d388969e414c1 100644 --- a/packages/design-system/src/css/input.scss +++ b/packages/design-system/src/css/input.scss @@ -20,6 +20,11 @@ background-color: var.$input-background-color; background-image: none; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; + transition: var.$border-transition-base; &, @@ -108,7 +113,13 @@ background-color: var.$input-background-color; background-image: none; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; border: var.$input-border; + border-right-color: var.$input-border-right-color; + border-bottom-color: var.$input-border-bottom-color; box-sizing: border-box; color: var.$input-font-color; display: inline-block; @@ -145,6 +156,7 @@ } @include mixins.e(suffix-inner) { + display: inline-flex; pointer-events: all; } @@ -286,8 +298,14 @@ vertical-align: middle; display: table-cell; position: relative; - border: var(--border-base); + border: var.$input-border; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; + border-right-color: var.$input-border-right-color; + border-bottom-color: var.$input-border-bottom-color; padding: 0 10px; width: 1px; white-space: nowrap; diff --git a/packages/design-system/src/css/select.scss b/packages/design-system/src/css/select.scss index 7cc8696c1a61b..f5b6f17d31ba3 100644 --- a/packages/design-system/src/css/select.scss +++ b/packages/design-system/src/css/select.scss @@ -80,6 +80,14 @@ &.is-focus .el-input__inner { border-color: var.$select-input-focus-border-color; } + + &__prefix { + left: var(--spacing-2xs); + } + + &--prefix .el-input__inner { + padding-left: 26px; + } } > .el-input { diff --git a/packages/design-system/src/plugin.ts b/packages/design-system/src/plugin.ts index 6a5ec45bb8699..f06d4cf4bb426 100644 --- a/packages/design-system/src/plugin.ts +++ b/packages/design-system/src/plugin.ts @@ -51,6 +51,7 @@ import { N8nUserInfo, N8nUserSelect, N8nUsersList, + N8nResizeObserver, N8nKeyboardShortcut, N8nUserStack, } from './components'; @@ -111,6 +112,7 @@ export const N8nPlugin: Plugin = { app.component('n8n-user-info', N8nUserInfo); app.component('n8n-users-list', N8nUsersList); app.component('n8n-user-select', N8nUserSelect); + app.component('n8n-resize-observer', N8nResizeObserver); app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut); }, }; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 31c593cf8b929..07599535239ae 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1215,7 +1215,7 @@ export interface NDVState { isDragging: boolean; type: string; data: string; - canDrop: boolean; + activeTargetId: string | null; stickyPosition: null | XYPosition; }; isMappingOnboarded: boolean; diff --git a/packages/editor-ui/src/components/DraggableTarget.vue b/packages/editor-ui/src/components/DraggableTarget.vue index 3418f604398ba..a36b4aa9ff639 100644 --- a/packages/editor-ui/src/components/DraggableTarget.vue +++ b/packages/editor-ui/src/components/DraggableTarget.vue @@ -9,6 +9,7 @@ import { defineComponent } from 'vue'; import type { PropType } from 'vue'; import { mapStores } from 'pinia'; import { useNDVStore } from '@/stores/ndv.store'; +import { v4 as uuid } from 'uuid'; export default defineComponent({ props: { @@ -29,6 +30,7 @@ export default defineComponent({ data() { return { hovering: false, + id: uuid(), }; }, mounted() { @@ -83,7 +85,12 @@ export default defineComponent({ }, watch: { activeDrop(active) { - this.ndvStore.setDraggableCanDrop(active); + if (active) { + this.ndvStore.setDraggableTargetId(this.id); + } else if (this.ndvStore.draggable.activeTargetId === this.id) { + // Only clear active target if it is this one + this.ndvStore.setDraggableTargetId(null); + } }, }, }); diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index edc093af53446..b3b555fb4569e 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -5,20 +5,14 @@ @keydown.tab="onBlur" >
-
+
+import { useI18n } from '@/composables/useI18n'; +import type { FilterTypeCombinator } from 'n8n-workflow'; + +interface Props { + options: FilterTypeCombinator[]; + selected: FilterTypeCombinator; + readOnly: boolean; +} + +defineProps(); + +const emit = defineEmits<{ + (event: 'combinatorChange', value: FilterTypeCombinator): void; +}>(); + +const i18n = useI18n(); + +const onCombinatorChange = (combinator: FilterTypeCombinator): void => { + emit('combinatorChange', combinator); +}; + + + + + diff --git a/packages/editor-ui/src/components/FilterConditions/Condition.vue b/packages/editor-ui/src/components/FilterConditions/Condition.vue new file mode 100644 index 0000000000000..a95371c7c0468 --- /dev/null +++ b/packages/editor-ui/src/components/FilterConditions/Condition.vue @@ -0,0 +1,451 @@ + + + + + diff --git a/packages/editor-ui/src/components/FilterConditions/FilterConditions.vue b/packages/editor-ui/src/components/FilterConditions/FilterConditions.vue new file mode 100644 index 0000000000000..df1d885be8c8a --- /dev/null +++ b/packages/editor-ui/src/components/FilterConditions/FilterConditions.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/packages/editor-ui/src/components/FilterConditions/OperatorSelect.vue b/packages/editor-ui/src/components/FilterConditions/OperatorSelect.vue new file mode 100644 index 0000000000000..432c1991ec20f --- /dev/null +++ b/packages/editor-ui/src/components/FilterConditions/OperatorSelect.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/editor-ui/src/components/FilterConditions/constants.ts b/packages/editor-ui/src/components/FilterConditions/constants.ts new file mode 100644 index 0000000000000..578d74c71a362 --- /dev/null +++ b/packages/editor-ui/src/components/FilterConditions/constants.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { FilterConditionValue, FilterOptionsValue } from 'n8n-workflow'; +import type { FilterOperator, FilterOperatorGroup } from './types'; + +export const DEFAULT_MAX_CONDITIONS = 10; + +export const DEFAULT_FILTER_OPTIONS: FilterOptionsValue = { + caseSensitive: true, + leftValue: '', + typeValidation: 'strict', +}; + +export const OPERATORS_BY_ID = { + 'string:exists': { + type: 'string', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'string:notExists': { + type: 'string', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'string:equals': { type: 'string', operation: 'equals', name: 'filter.operator.equals' }, + 'string:notEquals': { type: 'string', operation: 'notEquals', name: 'filter.operator.notEquals' }, + 'string:contains': { type: 'string', operation: 'contains', name: 'filter.operator.contains' }, + 'string:notContains': { + type: 'string', + operation: 'notContains', + name: 'filter.operator.notContains', + }, + 'string:startsWith': { + type: 'string', + operation: 'startsWith', + name: 'filter.operator.startsWith', + }, + 'string:notStartsWith': { + type: 'string', + operation: 'notStartsWith', + name: 'filter.operator.notStartsWith', + }, + 'string:endsWith': { type: 'string', operation: 'endsWith', name: 'filter.operator.endsWith' }, + 'string:notEndsWith': { + type: 'string', + operation: 'notEndsWith', + name: 'filter.operator.notEndsWith', + }, + 'string:regex': { type: 'string', operation: 'regex', name: 'filter.operator.regex' }, + 'string:notRegex': { type: 'string', operation: 'notRegex', name: 'filter.operator.notRegex' }, + 'number:exists': { + type: 'number', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'number:notExists': { + type: 'number', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'number:equals': { type: 'number', operation: 'equals', name: 'filter.operator.equals' }, + 'number:notEquals': { type: 'number', operation: 'notEquals', name: 'filter.operator.notEquals' }, + 'number:gt': { type: 'number', operation: 'gt', name: 'filter.operator.gt' }, + 'number:lt': { type: 'number', operation: 'lt', name: 'filter.operator.lt' }, + 'number:gte': { type: 'number', operation: 'gte', name: 'filter.operator.gte' }, + 'number:lte': { type: 'number', operation: 'lte', name: 'filter.operator.lte' }, + 'dateTime:exists': { + type: 'dateTime', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'dateTime:notExists': { + type: 'dateTime', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'dateTime:equals': { type: 'dateTime', operation: 'equals', name: 'filter.operator.equals' }, + 'dateTime:notEquals': { + type: 'dateTime', + operation: 'notEquals', + name: 'filter.operator.notEquals', + }, + 'dateTime:after': { type: 'dateTime', operation: 'after', name: 'filter.operator.after' }, + 'dateTime:before': { type: 'dateTime', operation: 'before', name: 'filter.operator.before' }, + 'dateTime:afterOrEquals': { + type: 'dateTime', + operation: 'afterOrEquals', + name: 'filter.operator.afterOrEquals', + }, + 'dateTime:beforeOrEquals': { + type: 'dateTime', + operation: 'beforeOrEquals', + name: 'filter.operator.beforeOrEquals', + }, + 'boolean:exists': { + type: 'boolean', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'boolean:notExists': { + type: 'boolean', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'boolean:true': { + type: 'boolean', + operation: 'true', + name: 'filter.operator.true', + singleValue: true, + }, + 'boolean:false': { + type: 'boolean', + operation: 'false', + name: 'filter.operator.false', + singleValue: true, + }, + 'boolean:equals': { type: 'boolean', operation: 'equals', name: 'filter.operator.equals' }, + 'boolean:notEquals': { + type: 'boolean', + operation: 'notEquals', + name: 'filter.operator.notEquals', + }, + 'array:exists': { + type: 'array', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'array:notExists': { + type: 'array', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'array:empty': { + type: 'array', + operation: 'empty', + name: 'filter.operator.empty', + singleValue: true, + }, + 'array:notEmpty': { + type: 'array', + operation: 'notEmpty', + name: 'filter.operator.notEmpty', + singleValue: true, + }, + 'array:contains': { + type: 'array', + operation: 'contains', + name: 'filter.operator.contains', + rightType: 'any', + }, + 'array:notContains': { + type: 'array', + operation: 'notContains', + name: 'filter.operator.notContains', + rightType: 'any', + }, + 'array:lengthEquals': { + type: 'array', + operation: 'lengthEquals', + name: 'filter.operator.lengthEquals', + rightType: 'number', + }, + 'array:lengthNotEquals': { + type: 'array', + operation: 'lengthNotEquals', + name: 'filter.operator.lengthNotEquals', + rightType: 'number', + }, + 'array:lengthGt': { + type: 'array', + operation: 'lengthGt', + name: 'filter.operator.lengthGt', + rightType: 'number', + }, + 'array:lengthLt': { + type: 'array', + operation: 'lengthLt', + name: 'filter.operator.lengthLt', + rightType: 'number', + }, + 'array:lengthGte': { + type: 'array', + operation: 'lengthGte', + name: 'filter.operator.lengthGte', + rightType: 'number', + }, + 'array:lengthLte': { + type: 'array', + operation: 'lengthLte', + name: 'filter.operator.lengthLte', + rightType: 'number', + }, + 'object:exists': { + type: 'object', + operation: 'exists', + name: 'filter.operator.exists', + singleValue: true, + }, + 'object:notExists': { + type: 'object', + operation: 'notExists', + name: 'filter.operator.notExists', + singleValue: true, + }, + 'object:empty': { + type: 'object', + operation: 'empty', + name: 'filter.operator.empty', + singleValue: true, + }, + 'object:notEmpty': { + type: 'object', + operation: 'notEmpty', + name: 'filter.operator.notEmpty', + singleValue: true, + }, +} as const satisfies Record; + +export const OPERATORS = Object.values(OPERATORS_BY_ID); + +export type FilterOperatorId = keyof typeof OPERATORS_BY_ID; + +export const DEFAULT_OPERATOR_VALUE: FilterConditionValue['operator'] = + OPERATORS_BY_ID['string:equals']; + +export const OPERATOR_GROUPS: FilterOperatorGroup[] = [ + { + id: 'string', + name: 'filter.operatorGroup.string', + icon: 'font', + children: OPERATORS.filter((operator) => operator.type === 'string'), + }, + { + id: 'number', + name: 'filter.operatorGroup.number', + icon: 'hashtag', + children: OPERATORS.filter((operator) => operator.type === 'number'), + }, + { + id: 'dateTime', + name: 'filter.operatorGroup.date', + icon: 'calendar', + children: OPERATORS.filter((operator) => operator.type === 'dateTime'), + }, + { + id: 'boolean', + name: 'filter.operatorGroup.boolean', + icon: 'check-square', + children: OPERATORS.filter((operator) => operator.type === 'boolean'), + }, + { + id: 'array', + name: 'filter.operatorGroup.array', + icon: 'list', + children: OPERATORS.filter((operator) => operator.type === 'array'), + }, + { + id: 'object', + name: 'filter.operatorGroup.object', + icon: 'cube', + children: OPERATORS.filter((operator) => operator.type === 'object'), + }, +]; diff --git a/packages/editor-ui/src/components/FilterConditions/types.ts b/packages/editor-ui/src/components/FilterConditions/types.ts new file mode 100644 index 0000000000000..d23d4eff65713 --- /dev/null +++ b/packages/editor-ui/src/components/FilterConditions/types.ts @@ -0,0 +1,13 @@ +import type { BaseTextKey } from '@/plugins/i18n'; +import type { FilterOperatorValue } from 'n8n-workflow'; + +export interface FilterOperator extends FilterOperatorValue { + name: BaseTextKey; +} + +export interface FilterOperatorGroup { + id: string; + name: BaseTextKey; + icon?: string; + children: FilterOperator[]; +} diff --git a/packages/editor-ui/src/components/FixedCollectionParameter.vue b/packages/editor-ui/src/components/FixedCollectionParameter.vue index 3ce36f6f5b484..b9090eb0060c7 100644 --- a/packages/editor-ui/src/components/FixedCollectionParameter.vue +++ b/packages/editor-ui/src/components/FixedCollectionParameter.vue @@ -28,28 +28,32 @@ :class="index ? 'border-top-dashed parameter-item-wrapper ' : 'parameter-item-wrapper'" >
- -
- - -
+ > + +
- + >
-
+
.parameter-item-wrapper > .delete-option { - display: block; + opacity: 1; } .parameter-item { @@ -411,11 +413,4 @@ export default defineComponent({ .no-items-exist { margin: var(--spacing-xs) 0; } - -.sort-icon { - display: flex; - flex-direction: column; - margin-left: 1px; - margin-top: 0.5em; -} diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts index 3a92747e195e1..f6dd6aed2640d 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts +++ b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts @@ -27,9 +27,17 @@ export const inputTheme = ({ isSingleLine } = { isSingleLine: false }) => { borderWidth: 'var(--border-width-base)', borderStyle: 'var(--input-border-style, var(--border-style-base))', borderColor: 'var(--input-border-color, var(--border-color-base))', + borderRightColor: + 'var(--input-border-right-color,var(--input-border-color, var(--border-color-base)))', + borderBottomColor: + 'var(--input-border-bottom-color,var(--input-border-color, var(--border-color-base)))', borderRadius: 'var(--input-border-radius, var(--border-radius-base))', - borderTopLeftRadius: '0', - borderBottomLeftRadius: '0', + borderTopLeftRadius: 0, + borderTopRightRadius: + 'var(--input-border-top-right-radius, var(--input-border-radius, var(--border-radius-base)))', + borderBottomLeftRadius: 0, + borderBottomRightRadius: + 'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))', backgroundColor: 'white', }, '.cm-scroller': { diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index b7f7908e6a79f..60c6a83986f1d 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -45,6 +45,7 @@ :modelValue="expressionDisplayValue" :title="displayTitle" :isReadOnly="isReadOnly" + :isSingleLine="isSingleLine" :path="path" :additional-expression-data="additionalExpressionData" :class="{ 'ph-no-capture': shouldRedactValue }" @@ -209,6 +210,7 @@ v-model="tempValue" ref="inputField" type="datetime" + valueFormat="YYYY-MM-DDTHH:mm:ss" :size="inputSize" :modelValue="displayValue" :title="displayTitle" @@ -447,6 +449,9 @@ export default defineComponent({ isReadOnly: { type: Boolean, }, + isSingleLine: { + type: Boolean, + }, parameter: { type: Object as PropType, }, diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 7895765316e0f..3cc25907cf363 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -1,16 +1,17 @@