Merge remote-tracking branch 'origin/master' into fix-CAT-337-put-parent-workflow-to-wait

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-06 10:54:05 +01:00
commit f3e63ee724
No known key found for this signature in database
107 changed files with 2671 additions and 1199 deletions

View file

@ -1,3 +1,48 @@
# [1.71.0](https://github.com/n8n-io/n8n/compare/n8n@1.70.0...n8n@1.71.0) (2024-12-04)
### Bug Fixes
* **core:** Fix push for waiting executions ([#11984](https://github.com/n8n-io/n8n/issues/11984)) ([8d71307](https://github.com/n8n-io/n8n/commit/8d71307da0398e7e39bf53e8e1cfa21ac1ceaf69))
* **core:** Improve header parameter parsing on http client responses ([#11953](https://github.com/n8n-io/n8n/issues/11953)) ([41e9e39](https://github.com/n8n-io/n8n/commit/41e9e39b5b53ecd9d8d1b385df65a26ecb9bccd8))
* **core:** Opt-out from optimizations if `$item` is used ([#12036](https://github.com/n8n-io/n8n/issues/12036)) ([872535a](https://github.com/n8n-io/n8n/commit/872535a40c85dcfad3a4b27c57c026ae003f562f))
* **core:** Use the configured timezone in task runner ([#12032](https://github.com/n8n-io/n8n/issues/12032)) ([2e6845a](https://github.com/n8n-io/n8n/commit/2e6845afcbc30dff73c3f3f15f21278cab397387))
* **core:** Validate node name when creating `NodeOperationErrror` ([#11999](https://github.com/n8n-io/n8n/issues/11999)) ([e68c9da](https://github.com/n8n-io/n8n/commit/e68c9da30c31cd5f994cb01ce759192562bfbd40))
* **editor:** Add execution concurrency info and paywall ([#11847](https://github.com/n8n-io/n8n/issues/11847)) ([57d3269](https://github.com/n8n-io/n8n/commit/57d3269e400ee4e7e3636614870ebdfdb0aa8c1d))
* **editor:** Fix bug causing connection lines to disappear when hovering stickies ([#11950](https://github.com/n8n-io/n8n/issues/11950)) ([439a1cc](https://github.com/n8n-io/n8n/commit/439a1cc4f39243e91715b21a84b8e7266ce872cd))
* **editor:** Fix canvas keybindings using splitter keys such as zooming using `+` key ([#12022](https://github.com/n8n-io/n8n/issues/12022)) ([6af9c82](https://github.com/n8n-io/n8n/commit/6af9c82af6020e99d61e442ee9c2d40761baf027))
* **editor:** Fix community check ([#11979](https://github.com/n8n-io/n8n/issues/11979)) ([af0398a](https://github.com/n8n-io/n8n/commit/af0398a5e3a8987c01c7112e6f689b35e1ef92fe))
* **editor:** Fix copy/paste keyboard events in canvas chat ([#12004](https://github.com/n8n-io/n8n/issues/12004)) ([967340a](https://github.com/n8n-io/n8n/commit/967340a2938a79c89319121bf57a8d654f88e06c))
* **editor:** Fix node showing as successful if errors exists on subsequent runs ([#12019](https://github.com/n8n-io/n8n/issues/12019)) ([8616b17](https://github.com/n8n-io/n8n/commit/8616b17cc6c305da69bbb54fd56ab7cb34213f7c))
* **editor:** Fix pin data showing up in production executions on new canvas ([#11951](https://github.com/n8n-io/n8n/issues/11951)) ([5f6f8a1](https://github.com/n8n-io/n8n/commit/5f6f8a1bddfd76b586c08da821e8b59070f449fc))
* **editor:** Handle source control initialization to prevent UI form crashing ([#11776](https://github.com/n8n-io/n8n/issues/11776)) ([6be8e86](https://github.com/n8n-io/n8n/commit/6be8e86c45bd64d000bc95d2ef2d68220e930c02))
* **editor:** Implement dirty nodes for partial executions ([#11739](https://github.com/n8n-io/n8n/issues/11739)) ([b8da4ff](https://github.com/n8n-io/n8n/commit/b8da4ff9edb0fbb0093c4c41fe11f8e67b696ca3))
* **editor:** Resolve going back from Settings ([#11958](https://github.com/n8n-io/n8n/issues/11958)) ([d74423c](https://github.com/n8n-io/n8n/commit/d74423c75198d38d0d99a1879051b5e964ecae74))
* **editor:** Unify executions card label color ([#11949](https://github.com/n8n-io/n8n/issues/11949)) ([fc79718](https://github.com/n8n-io/n8n/commit/fc797188d63e87df34b3a153eb4a0d0b7361b3f5))
* **editor:** Use optional chaining for all members in execution data when using the debug feature ([#12024](https://github.com/n8n-io/n8n/issues/12024)) ([67aa0c9](https://github.com/n8n-io/n8n/commit/67aa0c9107bda16b1cb6d273e17c3cde77035f51))
* **GraphQL Node:** Throw error if GraphQL variables are not objects or strings ([#11904](https://github.com/n8n-io/n8n/issues/11904)) ([85f30b2](https://github.com/n8n-io/n8n/commit/85f30b27ae282da58a25186d13ff17196dcd7d9c))
* **HTTP Request Node:** Use iconv-lite to decode http responses, to support more encoding types ([#11930](https://github.com/n8n-io/n8n/issues/11930)) ([461b39c](https://github.com/n8n-io/n8n/commit/461b39c5df5dd446cb8ceef469b204c7c5111229))
* Load workflows with unconnected Switch outputs ([#12020](https://github.com/n8n-io/n8n/issues/12020)) ([abc851c](https://github.com/n8n-io/n8n/commit/abc851c0cff298607a0dc2f2882aa17136898f45))
* **n8n Form Node:** Use https to load google fonts ([#11948](https://github.com/n8n-io/n8n/issues/11948)) ([eccd924](https://github.com/n8n-io/n8n/commit/eccd924f5e8dbe59e37099d1a6fbe8866fef55bf))
* **Telegram Trigger Node:** Fix header secret check ([#12018](https://github.com/n8n-io/n8n/issues/12018)) ([f16de4d](https://github.com/n8n-io/n8n/commit/f16de4db01c0496205635a3203a44098e7908453))
* **Webflow Node:** Fix issue with pagination in v2 node ([#11934](https://github.com/n8n-io/n8n/issues/11934)) ([1eb94bc](https://github.com/n8n-io/n8n/commit/1eb94bcaf54d9e581856ce0b87253e1c28fa68e2))
* **Webflow Node:** Fix issue with publishing items ([#11982](https://github.com/n8n-io/n8n/issues/11982)) ([0a8a57e](https://github.com/n8n-io/n8n/commit/0a8a57e4ec8081ab1a53f36d686b3d5dcaae2476))
### Features
* **AI Transform Node:** Node Prompt improvements ([#11611](https://github.com/n8n-io/n8n/issues/11611)) ([40a7445](https://github.com/n8n-io/n8n/commit/40a7445f0873af2cdbd10b12bd691c07a43e27cc))
* **Code Node:** Warning if pairedItem absent or could not be auto mapped ([#11737](https://github.com/n8n-io/n8n/issues/11737)) ([3a5bd12](https://github.com/n8n-io/n8n/commit/3a5bd129459272cbac960ae2754db3028943f87e))
* **editor:** Canvas chat UI & UX improvements ([#11924](https://github.com/n8n-io/n8n/issues/11924)) ([1e25774](https://github.com/n8n-io/n8n/commit/1e25774541461c86da5c4af8efec792e2814eeb1))
* **editor:** Persist user's preferred display modes on localStorage ([#11929](https://github.com/n8n-io/n8n/issues/11929)) ([bd69316](https://github.com/n8n-io/n8n/commit/bd693162b86a21c90880bab2c2e67aab733095ff))
### Performance Improvements
* **editor:** Virtualize SchemaView ([#11694](https://github.com/n8n-io/n8n/issues/11694)) ([9c6def9](https://github.com/n8n-io/n8n/commit/9c6def91975764522fa52cdf21e9cb5bdb4d721d))
# [1.70.0](https://github.com/n8n-io/n8n/compare/n8n@1.69.0...n8n@1.70.0) (2024-11-27)

View file

@ -76,6 +76,10 @@ export function getCanvasNodes() {
);
}
export function getCanvasNodeByName(nodeName: string) {
return getCanvasNodes().filter(`:contains(${nodeName})`);
}
export function getSaveButton() {
return cy.getByTestId('workflow-save-button');
}
@ -194,3 +198,8 @@ export function pasteWorkflow(workflow: object) {
export function clickZoomToFit() {
getZoomToFitButton().click();
}
export function deleteNode(name: string) {
getCanvasNodeByName(name).first().click();
cy.get('body').type('{del}');
}

View file

@ -68,16 +68,28 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
mainSidebar.actions.signout();
});
it('Should be able to disable MFA in account with MFA code ', () => {
it('Should be able to disable MFA in account with MFA code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
const loginToken = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken);
const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
const disableToken = generateOTPToken(user.mfaSecret);
personalSettingsPage.actions.disableMfa(disableToken);
personalSettingsPage.getters.enableMfaButton().should('exist');
mainSidebar.actions.signout();
});
it('Should be able to disable MFA in account with recovery code', () => {
const { email, password } = user;
signinPage.actions.loginWithEmailAndPassword(email, password);
personalSettingsPage.actions.enableMfa();
mainSidebar.actions.signout();
const mfaCode = generateOTPToken(user.mfaSecret);
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
personalSettingsPage.actions.disableMfa(user.mfaRecoveryCodes[0]);
personalSettingsPage.getters.enableMfaButton().should('exist');
mainSidebar.actions.signout();
});
});

View file

@ -0,0 +1,17 @@
import {
deleteNode,
getCanvasNodes,
navigateToNewWorkflowPage,
pasteWorkflow,
} from '../composables/workflow';
import Workflow from '../fixtures/Switch_node_with_null_connection.json';
describe('ADO-2929 can load Switch nodes', () => {
it('can load workflows with Switch nodes with null at connection index', () => {
navigateToNewWorkflowPage();
pasteWorkflow(Workflow);
getCanvasNodes().should('have.length', 3);
deleteNode('Switch');
getCanvasNodes().should('have.length', 2);
});
});

View file

@ -0,0 +1,85 @@
{
"nodes": [
{
"parameters": {},
"id": "418350b8-b402-4d3b-93ba-3794d36c1ad5",
"name": "When clicking \"Test workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [440, 380]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"leftValue": "",
"rightValue": "",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
{},
{}
]
},
"options": {}
},
"id": "b67ad46f-6b0d-4ff4-b2d2-dfbde44e287c",
"name": "Switch",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [660, 380]
},
{
"parameters": {
"options": {}
},
"id": "24731c11-e2a4-4854-81a6-277ce72e8a93",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [840, 480]
}
],
"connections": {
"When clicking \"Test workflow\"": {
"main": [
[
{
"node": "Switch",
"type": "main",
"index": 0
}
]
]
},
"Switch": {
"main": [
null,
null,
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.6.0-rc
ARG LAUNCHER_VERSION=0.7.0-rc
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary
RUN \

View file

@ -24,7 +24,7 @@ RUN set -eux; \
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.6.0-rc
ARG LAUNCHER_VERSION=0.7.0-rc
COPY n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary
RUN \

View file

@ -7,13 +7,14 @@
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
"allowed-env": [
"PATH",
"GENERIC_TIMEZONE",
"N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI",
"N8N_RUNNERS_TASK_BROKER_URI",
"N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_RUNNERS_SERVER_ENABLED",
"N8N_RUNNERS_SERVER_HOST",
"N8N_RUNNERS_SERVER_PORT",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS",

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.70.0",
"version": "1.71.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -80,7 +80,7 @@
"tslib": "^2.6.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2",
"vue-tsc": "^2.1.6",
"vue-tsc": "^2.1.10",
"ws": ">=8.17.1"
},
"patchedDependencies": {
@ -90,7 +90,7 @@
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
"vue-tsc@2.1.6": "patches/vue-tsc@2.1.6.patch"
"vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.8.0",
"version": "0.9.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
"version": "0.31.0",
"version": "0.32.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",
@ -46,11 +46,12 @@
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"@n8n/storybook": "workspace:*",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:frontend",
"unplugin-icons": "^0.19.0",
"vite": "catalog:frontend",
"vitest": "catalog:frontend",
"vite-plugin-dts": "^4.2.3",
"vite-plugin-dts": "^4.3.0",
"vue-tsc": "catalog:frontend"
},
"files": [

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.20.0",
"version": "1.21.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -7,5 +7,5 @@ export type FrontendBetaFeatures = 'canvas_v2';
export class FrontendConfig {
/** Which UI experiments to enable. Separate multiple values with a comma `,` */
@Env('N8N_UI_BETA_FEATURES')
betaFeatures: StringArray<FrontendBetaFeatures> = [];
betaFeatures: StringArray<FrontendBetaFeatures> = ['canvas_v2'];
}

View file

@ -24,7 +24,7 @@ export class TaskRunnersConfig {
authToken: string = '';
/** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_PORT')
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5679;
/** IP address task runners server should listen on */

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/imap",
"version": "0.7.0",
"version": "0.8.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.1.0",
"version": "1.2.0",
"description": "Converts JSON schema objects into Zod schemas",
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.70.0",
"version": "1.71.0",
"description": "",
"main": "index.js",
"scripts": {

View file

@ -3,19 +3,19 @@
"private": true,
"version": "0.0.1",
"devDependencies": {
"@chromatic-com/storybook": "^2.0.2",
"@storybook/addon-a11y": "^8.3.5",
"@storybook/addon-actions": "^8.3.5",
"@storybook/addon-docs": "^8.3.5",
"@storybook/addon-essentials": "^8.3.5",
"@storybook/addon-interactions": "^8.3.5",
"@storybook/addon-links": "^8.3.5",
"@storybook/addon-themes": "^8.3.5",
"@storybook/blocks": "^8.3.5",
"@storybook/test": "^8.3.5",
"@storybook/vue3": "^8.3.5",
"@storybook/vue3-vite": "^8.3.5",
"chromatic": "^11.10.2",
"storybook": "^8.3.5"
"@chromatic-com/storybook": "^3.2.2",
"@storybook/addon-a11y": "^8.4.6",
"@storybook/addon-actions": "^8.4.6",
"@storybook/addon-docs": "^8.4.6",
"@storybook/addon-essentials": "^8.4.6",
"@storybook/addon-interactions": "^8.4.6",
"@storybook/addon-links": "^8.4.6",
"@storybook/addon-themes": "^8.4.6",
"@storybook/blocks": "^8.4.6",
"@storybook/test": "^8.4.6",
"@storybook/vue3": "^8.4.6",
"@storybook/vue3-vite": "^8.4.6",
"chromatic": "^11.20.0",
"storybook": "^8.4.6"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/task-runner",
"version": "1.8.0",
"version": "1.9.0",
"scripts": {
"clean": "rimraf dist .turbo",
"start": "node dist/start.js",

View file

@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
@Config
class HealthcheckServerConfig {
@Env('N8N_RUNNERS_SERVER_ENABLED')
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
enabled: boolean = false;
@Env('N8N_RUNNERS_SERVER_HOST')
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
host: string = '127.0.0.1';
@Env('N8N_RUNNERS_SERVER_PORT')
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
port: number = 5681;
}
@Config
export class BaseRunnerConfig {
@Env('N8N_RUNNERS_N8N_URI')
n8nUri: string = '127.0.0.1:5679';
@Env('N8N_RUNNERS_TASK_BROKER_URI')
taskBrokerUri: string = 'http://127.0.0.1:5679';
@Env('N8N_RUNNERS_GRANT_TOKEN')
grantToken: string = '';
@ -34,6 +34,9 @@ export class BaseRunnerConfig {
@Env('N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT')
idleTimeout: number = 0;
@Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York';
@Nested
healthcheckServer!: HealthcheckServerConfig;
}

View file

@ -1,5 +1,5 @@
import { DateTime } from 'luxon';
import type { CodeExecutionMode, IDataObject } from 'n8n-workflow';
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
import fs from 'node:fs';
import { builtinModules } from 'node:module';
@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
...defaultConfig.baseRunnerConfig,
grantToken: 'grantToken',
maxConcurrency: 1,
n8nUri: 'localhost',
taskBrokerUri: 'http://localhost',
...baseRunnerOpts,
},
jsRunnerConfig: {
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
});
it("should not expose task runner's env variables even if no env state is received", async () => {
process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679';
process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679';
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
@ -326,6 +326,43 @@ describe('JsTaskRunner', () => {
});
});
describe('timezone', () => {
it('should use the specified timezone in the workflow', async () => {
const taskData = newDataRequestResponse(inputItems.map(wrapIntoJson), {});
taskData.workflow.settings = {
timezone: 'Europe/Helsinki',
};
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $now.toSeconds() }',
nodeMode: 'runOnceForAllItems',
}),
taskData,
});
const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds();
expect(outcome.result[0].json.val).toBeCloseTo(helsinkiTimeNow, 1);
});
it('should use the default timezone', async () => {
setGlobalState({
defaultTimezone: 'Europe/Helsinki',
});
const outcome = await execTaskWithParams({
task: newTaskWithSettings({
code: 'return { val: $now.toSeconds() }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {}),
});
const helsinkiTimeNow = DateTime.now().setZone('Europe/Helsinki').toSeconds();
expect(outcome.result[0].json.val).toBeCloseTo(helsinkiTimeNow, 1);
});
});
it('should allow access to Node.js Buffers', async () => {
const outcomeAll = await execTaskWithParams({
task: newTaskWithSettings({

View file

@ -0,0 +1,89 @@
import { WebSocket } from 'ws';
import { TaskRunner } from '@/task-runner';
class TestRunner extends TaskRunner {}
jest.mock('ws');
describe('TestRunner', () => {
describe('constructor', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should correctly construct WebSocket URI with provided taskBrokerUri', () => {
const runner = new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'http://localhost:8080',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
});
expect(WebSocket).toHaveBeenCalledWith(
`ws://localhost:8080/runners/_ws?id=${runner.id}`,
expect.objectContaining({
headers: {
authorization: 'Bearer test-token',
},
maxPayload: 1024,
}),
);
});
it('should handle different taskBrokerUri formats correctly', () => {
const runner = new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'https://example.com:3000/path',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
});
expect(WebSocket).toHaveBeenCalledWith(
`ws://example.com:3000/runners/_ws?id=${runner.id}`,
expect.objectContaining({
headers: {
authorization: 'Bearer test-token',
},
maxPayload: 1024,
}),
);
});
it('should throw an error if taskBrokerUri is invalid', () => {
expect(
() =>
new TestRunner({
taskType: 'test-task',
maxConcurrency: 5,
idleTimeout: 60,
grantToken: 'test-token',
maxPayloadSize: 1024,
taskBrokerUri: 'not-a-valid-uri',
timezone: 'America/New_York',
healthcheckServer: {
enabled: false,
host: 'localhost',
port: 8081,
},
}),
).toThrowError(/Invalid URL/);
});
});
});

View file

@ -17,7 +17,7 @@ describe('BuiltInsParser', () => {
const parseAndExpectOk = (code: string) => {
const result = parser.parseUsedBuiltIns(code);
if (!result.ok) {
fail(result.error);
throw result.error;
}
return result.result;
@ -151,6 +151,13 @@ describe('BuiltInsParser', () => {
});
});
describe('$item', () => {
it('should require all nodes and input when $item is used', () => {
const state = parseAndExpectOk('$item("0").$node["my node"].json["title"]');
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
});
});
describe('ECMAScript syntax', () => {
describe('ES2020', () => {
it('should parse optional chaining', () => {

View file

@ -125,6 +125,11 @@ export class BuiltInsParser {
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
if (node.name === '$env') {
state.markEnvAsNeeded();
} else if (node.name === '$item') {
// $item is legacy syntax that is basically an alias for WorkflowDataProxy
// and allows accessing any data. We need to support it for backwards
// compatibility, but we're not gonna implement any optimizations
state.markNeedsAllNodes();
} else if (
node.name === '$input' ||
node.name === '$json' ||

View file

@ -1,4 +1,4 @@
import { ensureError } from 'n8n-workflow';
import { ensureError, setGlobalState } from 'n8n-workflow';
import Container from 'typedi';
import { MainConfig } from './config/main-config';
@ -44,6 +44,10 @@ function createSignalHandler(signal: string) {
void (async function start() {
const config = Container.get(MainConfig);
setGlobalState({
defaultTimezone: config.baseRunnerConfig.timezone,
});
if (config.sentryConfig.sentryDsn) {
const { ErrorReporter } = await import('@/error-reporter');
errorReporter = new ErrorReporter(config.sentryConfig);

View file

@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
this.maxConcurrency = opts.maxConcurrency;
this.idleTimeout = opts.idleTimeout;
const wsUrl = `ws://${opts.n8nUri}/runners/_ws?id=${this.id}`;
const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`;
this.ws = new WebSocket(wsUrl, {
headers: {
authorization: `Bearer ${opts.grantToken}`,
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
) {
console.error(
`Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`,
`Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`,
);
process.exit(1);
} else {
console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`);
console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`);
console.error('Details:', event.message || 'Unknown error');
}
});

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "1.70.0",
"version": "1.71.0",
"description": "n8n Workflow Automation Tool",
"main": "dist/index",
"types": "dist/index.d.ts",

View file

@ -1,4 +1,23 @@
import { WorkflowHooks, type ExecutionError, type IWorkflowExecuteHooks } from 'n8n-workflow';
import { mock } from 'jest-mock-extended';
import { DirectedGraph, WorkflowExecute } from 'n8n-core';
import * as core from 'n8n-core';
import type {
IExecuteData,
INode,
IRun,
ITaskData,
IWaitingForExecution,
IWaitingForExecutionSource,
IWorkflowExecutionDataProcess,
StartNodeData,
} from 'n8n-workflow';
import {
Workflow,
WorkflowHooks,
type ExecutionError,
type IWorkflowExecuteHooks,
} from 'n8n-workflow';
import PCancelable from 'p-cancelable';
import Container from 'typedi';
import { ActiveExecutions } from '@/active-executions';
@ -6,6 +25,7 @@ import config from '@/config';
import type { User } from '@/databases/entities/user';
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
import { Telemetry } from '@/telemetry';
import { PermissionChecker } from '@/user-management/permission-checker';
import { WorkflowRunner } from '@/workflow-runner';
import { mockInstance } from '@test/mocking';
import { createExecution } from '@test-integration/db/executions';
@ -43,61 +63,138 @@ afterAll(() => {
beforeEach(async () => {
await testDb.truncate(['Workflow', 'SharedWorkflow']);
jest.clearAllMocks();
});
test('processError should return early in Bull stalled edge case', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
config.set('executions.mode', 'queue');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
describe('processError', () => {
test('processError should return early in Bull stalled edge case', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
config.set('executions.mode', 'queue');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should return early if the error is `ExecutionNotFoundError`', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'success', finished: true }, workflow);
await runner.processError(
new ExecutionNotFoundError(execution.id),
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should process error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
await Container.get(ActiveExecutions).add(
{ executionMode: 'webhook', workflowData: workflow },
execution.id,
);
config.set('executions.mode', 'regular');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
});
});
test('processError should return early if the error is `ExecutionNotFoundError`', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'success', finished: true }, workflow);
await runner.processError(
new ExecutionNotFoundError(execution.id),
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
describe('run', () => {
it('uses recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom with data is sent', async () => {
// ARRANGE
const activeExecutions = Container.get(ActiveExecutions);
jest.spyOn(activeExecutions, 'add').mockResolvedValue('1');
jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce();
const permissionChecker = Container.get(PermissionChecker);
jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce();
test('processError should process error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(
{
status: 'success',
finished: true,
},
workflow,
);
await Container.get(ActiveExecutions).add(
{ executionMode: 'webhook', workflowData: workflow },
execution.id,
);
config.set('executions.mode', 'regular');
await runner.processError(
new Error('test') as ExecutionError,
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce(
new PCancelable(() => {
return mock<IRun>();
}),
);
jest.spyOn(Workflow.prototype, 'getNode').mockReturnValueOnce(mock<INode>());
jest.spyOn(DirectedGraph, 'fromWorkflow').mockReturnValueOnce(new DirectedGraph());
const recreateNodeExecutionStackSpy = jest
.spyOn(core, 'recreateNodeExecutionStack')
.mockReturnValueOnce({
nodeExecutionStack: mock<IExecuteData[]>(),
waitingExecution: mock<IWaitingForExecution>(),
waitingExecutionSource: mock<IWaitingForExecutionSource>(),
});
const data = mock<IWorkflowExecutionDataProcess>({
triggerToStartFrom: { name: 'trigger', data: mock<ITaskData>() },
workflowData: { nodes: [] },
executionData: undefined,
startNodes: [mock<StartNodeData>()],
destinationNode: undefined,
});
// ACT
await runner.run(data);
// ASSERT
expect(recreateNodeExecutionStackSpy).toHaveBeenCalled();
});
it('does not use recreateNodeExecutionStack to create a partial execution if a triggerToStartFrom without data is sent', async () => {
// ARRANGE
const activeExecutions = Container.get(ActiveExecutions);
jest.spyOn(activeExecutions, 'add').mockResolvedValue('1');
jest.spyOn(activeExecutions, 'attachWorkflowExecution').mockReturnValueOnce();
const permissionChecker = Container.get(PermissionChecker);
jest.spyOn(permissionChecker, 'check').mockResolvedValueOnce();
jest.spyOn(WorkflowExecute.prototype, 'processRunExecutionData').mockReturnValueOnce(
new PCancelable(() => {
return mock<IRun>();
}),
);
const recreateNodeExecutionStackSpy = jest.spyOn(core, 'recreateNodeExecutionStack');
const data = mock<IWorkflowExecutionDataProcess>({
triggerToStartFrom: { name: 'trigger', data: undefined },
workflowData: { nodes: [] },
executionData: undefined,
startNodes: [mock<StartNodeData>()],
destinationNode: undefined,
});
// ACT
await runner.run(data);
// ASSERT
expect(recreateNodeExecutionStackSpy).not.toHaveBeenCalled();
});
});

View file

@ -86,13 +86,24 @@ export class MFAController {
@Post('/disable', { rateLimit: true })
async disableMFA(req: MFA.Disable) {
const { id: userId } = req.user;
const { mfaCode = null } = req.body;
if (typeof mfaCode !== 'string' || !mfaCode) {
throw new BadRequestError('Token is required to disable MFA feature');
const { mfaCode, mfaRecoveryCode } = req.body;
const mfaCodeDefined = mfaCode && typeof mfaCode === 'string';
const mfaRecoveryCodeDefined = mfaRecoveryCode && typeof mfaRecoveryCode === 'string';
if (!mfaCodeDefined === !mfaRecoveryCodeDefined) {
throw new BadRequestError(
'Either MFA code or recovery code is required to disable MFA feature',
);
}
await this.mfaService.disableMfa(userId, mfaCode);
if (mfaCodeDefined) {
await this.mfaService.disableMfaWithMfaCode(userId, mfaCode);
} else if (mfaRecoveryCodeDefined) {
await this.mfaService.disableMfaWithRecoveryCode(userId, mfaRecoveryCode);
}
}
@Post('/verify', { rateLimit: true })

View file

@ -38,7 +38,7 @@ export class PurgeInvalidWorkflowConnections1675940580449 implements Irreversibl
// It filters out all connections that are connected to a node that cannot receive input
outputConnection.forEach((outputConnectionItem, outputConnectionItemIdx) => {
outputConnection[outputConnectionItemIdx] = outputConnectionItem.filter(
outputConnection[outputConnectionItemIdx] = (outputConnectionItem ?? []).filter(
(outgoingConnections) =>
!nodesThatCannotReceiveInput.includes(outgoingConnections.node),
);

View file

@ -0,0 +1,7 @@
import { ForbiddenError } from './forbidden.error';
export class InvalidMfaRecoveryCodeError extends ForbiddenError {
constructor(hint?: string) {
super('Invalid MFA recovery code', hint);
}
}

View file

@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
import { InvalidMfaRecoveryCodeError } from '@/errors/response-errors/invalid-mfa-recovery-code-error';
import { TOTPService } from './totp.service';
@ -85,12 +86,27 @@ export class MfaService {
return await this.authUserRepository.save(user);
}
async disableMfa(userId: string, mfaCode: string) {
async disableMfaWithMfaCode(userId: string, mfaCode: string) {
const isValidToken = await this.validateMfa(userId, mfaCode, undefined);
if (!isValidToken) {
throw new InvalidMfaCodeError();
}
await this.disableMfaForUser(userId);
}
async disableMfaWithRecoveryCode(userId: string, recoveryCode: string) {
const isValidToken = await this.validateMfa(userId, undefined, recoveryCode);
if (!isValidToken) {
throw new InvalidMfaRecoveryCodeError();
}
await this.disableMfaForUser(userId);
}
private async disableMfaForUser(userId: string) {
await this.authUserRepository.update(userId, {
mfaEnabled: false,
mfaSecret: null,

View file

@ -318,7 +318,7 @@ export type LoginRequest = AuthlessRequest<
export declare namespace MFA {
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Activate = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Disable = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
type Disable = AuthenticatedRequest<{}, {}, { mfaCode?: string; mfaRecoveryCode?: string }, {}>;
type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
type ValidateRecoveryCode = AuthenticatedRequest<
{},

View file

@ -76,6 +76,7 @@ describe('TaskRunnerProcess', () => {
'N8N_VERSION',
'ENVIRONMENT',
'DEPLOYMENT_NAME',
'GENERIC_TIMEZONE',
])('should propagate %s from env as is', async (envVar) => {
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
process.env[envVar] = 'custom value';

View file

@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
private readonly passthroughEnvVars = [
'PATH',
'GENERIC_TIMEZONE',
'NODE_FUNCTION_ALLOW_BUILTIN',
'NODE_FUNCTION_ALLOW_EXTERNAL',
'N8N_SENTRY_DSN',
@ -94,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
const grantToken = await this.authService.createGrantToken();
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`;
this.process = this.startNode(grantToken, n8nUri);
const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
this.process = this.startNode(grantToken, taskBrokerUri);
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
this.monitorProcess(this.process);
}
startNode(grantToken: string, n8nUri: string) {
startNode(grantToken: string, taskBrokerUri: string) {
const startScript = require.resolve('@n8n/task-runner/start');
return spawn('node', [startScript], {
env: this.getProcessEnvVars(grantToken, n8nUri),
env: this.getProcessEnvVars(grantToken, taskBrokerUri),
});
}
@ -158,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
}
}
private getProcessEnvVars(grantToken: string, n8nUri: string) {
private getProcessEnvVars(grantToken: string, taskBrokerUri: string) {
const envVars: Record<string, string> = {
N8N_RUNNERS_GRANT_TOKEN: grantToken,
N8N_RUNNERS_N8N_URI: n8nUri,
N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri,
N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(),
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
...this.getPassthroughEnvVars(),

View file

@ -119,7 +119,7 @@ export class InstanceRiskReporter implements RiskReporter {
node: WorkflowEntity['nodes'][number];
workflow: WorkflowEntity;
}) {
const childNodeNames = workflow.connections[node.name]?.main[0].map((i) => i.node);
const childNodeNames = workflow.connections[node.name]?.main[0]?.map((i) => i.node);
if (!childNodeNames) return false;

View file

@ -1,6 +1,11 @@
import type * as express from 'express';
import { mock } from 'jest-mock-extended';
import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow';
import type { ITaskData } from 'n8n-workflow';
import {
type IWebhookData,
type IWorkflowExecuteAdditionalData,
type Workflow,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { generateNanoId } from '@/databases/utils/generators';
@ -43,12 +48,16 @@ describe('TestWebhooks', () => {
jest.useFakeTimers();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('needsWebhook()', () => {
const args: Parameters<typeof testWebhooks.needsWebhook> = [
const args: Parameters<typeof testWebhooks.needsWebhook>[0] = {
userId,
workflowEntity,
mock<IWorkflowExecuteAdditionalData>(),
];
additionalData: mock<IWorkflowExecuteAdditionalData>(),
};
test('if webhook is needed, should register then create webhook and return true', async () => {
const workflow = mock<Workflow>();
@ -56,7 +65,7 @@ describe('TestWebhooks', () => {
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const needsWebhook = await testWebhooks.needsWebhook(...args);
const needsWebhook = await testWebhooks.needsWebhook(args);
const [registerOrder] = registrations.register.mock.invocationCallOrder;
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
@ -72,7 +81,7 @@ describe('TestWebhooks', () => {
jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg));
registrations.getAllRegistrations.mockResolvedValue([]);
const needsWebhook = testWebhooks.needsWebhook(...args);
const needsWebhook = testWebhooks.needsWebhook(args);
await expect(needsWebhook).rejects.toThrowError(msg);
});
@ -81,10 +90,55 @@ describe('TestWebhooks', () => {
webhook.webhookDescription.restartWebhook = true;
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const result = await testWebhooks.needsWebhook(...args);
const result = await testWebhooks.needsWebhook(args);
expect(result).toBe(false);
});
test('returns false if a triggerToStartFrom with triggerData is given', async () => {
const workflow = mock<Workflow>();
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
const needsWebhook = await testWebhooks.needsWebhook({
...args,
triggerToStartFrom: {
name: 'trigger',
data: mock<ITaskData>(),
},
});
expect(needsWebhook).toBe(false);
});
test('returns true, registers and then creates webhook if triggerToStartFrom is given with no triggerData', async () => {
// ARRANGE
const workflow = mock<Workflow>();
const webhook2 = mock<IWebhookData>({
node: 'trigger',
httpMethod,
path,
workflowId: workflowEntity.id,
userId,
});
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook, webhook2]);
// ACT
const needsWebhook = await testWebhooks.needsWebhook({
...args,
triggerToStartFrom: { name: 'trigger' },
});
// ASSERT
const [registerOrder] = registrations.register.mock.invocationCallOrder;
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
expect(registerOrder).toBeLessThan(createOrder);
expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node);
expect(workflow.createWebhookIfNotExists.mock.calls[0][0].node).toBe(webhook2.node);
expect(needsWebhook).toBe(true);
});
});
describe('executeWebhook()', () => {

View file

@ -23,6 +23,7 @@ import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrati
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import type { WorkflowRequest } from '@/workflows/workflow.request';
import type {
IWebhookResponseCallbackData,
@ -218,25 +219,48 @@ export class TestWebhooks implements IWebhookManager {
* Return whether activating a workflow requires listening for webhook calls.
* For every webhook call to listen for, also activate the webhook.
*/
async needsWebhook(
userId: string,
workflowEntity: IWorkflowDb,
additionalData: IWorkflowExecuteAdditionalData,
runData?: IRunData,
pushRef?: string,
destinationNode?: string,
) {
async needsWebhook(options: {
userId: string;
workflowEntity: IWorkflowDb;
additionalData: IWorkflowExecuteAdditionalData;
runData?: IRunData;
pushRef?: string;
destinationNode?: string;
triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom'];
}) {
const {
userId,
workflowEntity,
additionalData,
runData,
pushRef,
destinationNode,
triggerToStartFrom,
} = options;
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
const workflow = this.toWorkflow(workflowEntity);
const webhooks = WebhookHelpers.getWorkflowWebhooks(
let webhooks = WebhookHelpers.getWorkflowWebhooks(
workflow,
additionalData,
destinationNode,
true,
);
// If we have a preferred trigger with data, we don't have to listen for a
// webhook.
if (triggerToStartFrom?.data) {
return false;
}
// If we have a preferred trigger without data we only want to listen for
// that trigger, not the other ones.
if (triggerToStartFrom) {
webhooks = webhooks.filter((w) => w.node === triggerToStartFrom.name);
}
if (!webhooks.some((w) => w.webhookDescription.restartWebhook !== true)) {
return false; // no webhooks found to start a workflow
}

View file

@ -2,7 +2,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { InstanceSettings, WorkflowExecute } from 'n8n-core';
import * as a from 'assert/strict';
import {
DirectedGraph,
InstanceSettings,
WorkflowExecute,
filterDisabledNodes,
recreateNodeExecutionStack,
} from 'n8n-core';
import type {
ExecutionError,
IDeferredPromise,
@ -12,6 +19,7 @@ import type {
WorkflowExecuteMode,
WorkflowHooks,
IWorkflowExecutionDataProcess,
IRunExecutionData,
} from 'n8n-workflow';
import {
ErrorReporterProxy as ErrorReporter,
@ -203,6 +211,7 @@ export class WorkflowRunner {
}
/** Run the workflow in current process */
// eslint-disable-next-line complexity
private async runMainProcess(
executionId: string,
data: IWorkflowExecutionDataProcess,
@ -286,12 +295,50 @@ export class WorkflowRunner {
data.executionData,
);
workflowExecution = workflowExecute.processRunExecutionData(workflow);
} else if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) {
this.logger.debug(
`Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`,
{ executionId },
);
const startNodes = data.startNodes.map((data) => {
const node = workflow.getNode(data.name);
a.ok(node, `Could not find a node named "${data.name}" in the workflow.`);
return node;
});
const runData = { [data.triggerToStartFrom.name]: [data.triggerToStartFrom.data] };
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
recreateNodeExecutionStack(
filterDisabledNodes(DirectedGraph.fromWorkflow(workflow)),
new Set(startNodes),
runData,
data.pinData ?? {},
);
const executionData: IRunExecutionData = {
resultData: { runData, pinData },
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack,
waitingExecution,
waitingExecutionSource,
},
};
const workflowExecute = new WorkflowExecute(additionalData, 'manual', executionData);
workflowExecution = workflowExecute.processRunExecutionData(workflow);
} else if (
data.runData === undefined ||
data.startNodes === undefined ||
data.startNodes.length === 0
) {
// Full Execution
// TODO: When the old partial execution logic is removed this block can
// be removed and the previous one can be merged into
// `workflowExecute.runPartialWorkflow2`.
// Partial executions then require either a destination node from which
// everything else can be derived, or a triggerToStartFrom with
// triggerData.
this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, {
executionId,
});

View file

@ -95,6 +95,7 @@ export class WorkflowExecutionService {
startNodes,
destinationNode,
dirtyNodeNames,
triggerToStartFrom,
}: WorkflowRequest.ManualRunPayload,
user: User,
pushRef?: string,
@ -117,14 +118,15 @@ export class WorkflowExecutionService {
) {
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
const needsWebhook = await this.testWebhooks.needsWebhook(
user.id,
workflowData,
const needsWebhook = await this.testWebhooks.needsWebhook({
userId: user.id,
workflowEntity: workflowData,
additionalData,
runData,
pushRef,
destinationNode,
);
triggerToStartFrom,
});
if (needsWebhook) return { waitingForWebhook: true };
}
@ -144,6 +146,7 @@ export class WorkflowExecutionService {
userId: user.id,
partialExecutionVersion: partialExecutionVersion ?? '0',
dirtyNodeNames,
triggerToStartFrom,
};
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];

View file

@ -1,4 +1,11 @@
import type { INode, IConnections, IWorkflowSettings, IRunData, StartNodeData } from 'n8n-workflow';
import type {
INode,
IConnections,
IWorkflowSettings,
IRunData,
StartNodeData,
ITaskData,
} from 'n8n-workflow';
import type { IWorkflowDb } from '@/interfaces';
import type { AuthenticatedRequest, ListQuery } from '@/requests';
@ -23,6 +30,10 @@ export declare namespace WorkflowRequest {
startNodes?: StartNodeData[];
destinationNode?: string;
dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
};
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;

View file

@ -184,7 +184,19 @@ describe('Disable MFA setup', () => {
expect(dbUser.mfaRecoveryCodes.length).toBe(0);
});
test('POST /disable should fail if invalid mfaCode is given', async () => {
test('POST /disable should fail if invalid MFA recovery code is given', async () => {
const { user } = await createUserWithMfaEnabled();
await testServer
.authAgentFor(user)
.post('/mfa/disable')
.send({
mfaRecoveryCode: 'invalid token',
})
.expect(403);
});
test('POST /disable should fail if invalid MFA code is given', async () => {
const { user } = await createUserWithMfaEnabled();
await testServer
@ -195,6 +207,12 @@ describe('Disable MFA setup', () => {
})
.expect(403);
});
test('POST /disable should fail if neither MFA code nor recovery code is sent', async () => {
const { user } = await createUserWithMfaEnabled();
await testServer.authAgentFor(user).post('/mfa/disable').send({ anotherParam: '' }).expect(400);
});
});
describe('Change password with MFA enabled', () => {

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "1.70.0",
"version": "1.71.0",
"description": "Core functionality of n8n",
"main": "dist/index",
"types": "dist/index.d.ts",

View file

@ -448,7 +448,7 @@ export class DirectedGraph {
for (const [outputType, outputs] of Object.entries(iConnection)) {
for (const [outputIndex, conns] of outputs.entries()) {
for (const conn of conns) {
for (const conn of conns ?? []) {
// TODO: What's with the input type?
const { node: toNodeName, type: _inputType, index: inputIndex } = conn;
const to = workflow.getNode(toNodeName);

View file

@ -42,6 +42,28 @@ describe('DirectedGraph', () => {
);
});
// ┌─────┐ ┌─────┐──► null
// │node1├───►│node2| ┌─────┐
// └─────┘ └─────┘──►│node3|
// └─────┘
//
test('linear workflow with null connections', () => {
// ARRANGE
const node1 = createNodeData({ name: 'Node1' });
const node2 = createNodeData({ name: 'Node2' });
const node3 = createNodeData({ name: 'Node3' });
// ACT
const graph = new DirectedGraph()
.addNodes(node1, node2, node3)
.addConnections({ from: node1, to: node2 }, { from: node2, to: node3, outputIndex: 1 });
// ASSERT
expect(DirectedGraph.fromWorkflow(graph.toWorkflow({ ...defaultWorkflowParameter }))).toEqual(
graph,
);
});
describe('getChildren', () => {
// ┌─────┐ ┌─────┐ ┌─────┐
// │node1├───►│node2├──►│node3│

View file

@ -36,6 +36,7 @@ import type {
CloseFunction,
StartNodeData,
NodeExecutionHint,
NodeInputConnections,
} from 'n8n-workflow';
import {
LoggerProxy as Logger,
@ -208,6 +209,9 @@ export class WorkflowExecute {
// Get the data of the incoming connections
incomingSourceData = { main: [] };
for (const connections of incomingNodeConnections.main) {
if (!connections) {
continue;
}
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
connection = connections[inputIndex];
@ -249,6 +253,9 @@ export class WorkflowExecute {
incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode];
if (incomingNodeConnections !== undefined) {
for (const connections of incomingNodeConnections.main) {
if (!connections) {
continue;
}
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
connection = connections[inputIndex];
@ -642,7 +649,7 @@ export class WorkflowExecute {
}
for (const connectionDataCheck of workflow.connectionsBySourceNode[parentNodeName].main[
outputIndexParent
]) {
] ?? []) {
checkOutputNodes.push(connectionDataCheck.node);
}
}
@ -661,7 +668,7 @@ export class WorkflowExecute {
) {
for (const inputData of workflow.connectionsByDestinationNode[connectionData.node].main[
inputIndex
]) {
] ?? []) {
if (inputData.node === parentNodeName) {
// Is the node we come from so its data will be available for sure
continue;
@ -681,7 +688,7 @@ export class WorkflowExecute {
if (
!this.incomingConnectionIsEmpty(
this.runExecutionData.resultData.runData,
workflow.connectionsByDestinationNode[inputData.node].main[0],
workflow.connectionsByDestinationNode[inputData.node].main[0] ?? [],
runIndex,
)
) {
@ -770,7 +777,7 @@ export class WorkflowExecute {
} else if (
this.incomingConnectionIsEmpty(
this.runExecutionData.resultData.runData,
workflow.connectionsByDestinationNode[nodeToAdd].main[0],
workflow.connectionsByDestinationNode[nodeToAdd].main[0] ?? [],
runIndex,
)
) {
@ -1066,7 +1073,7 @@ export class WorkflowExecute {
if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) {
// Check if the node has incoming connections
if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) {
let inputConnections: IConnection[][];
let inputConnections: NodeInputConnections;
let connectionIndex: number;
// eslint-disable-next-line prefer-const
@ -1586,7 +1593,7 @@ export class WorkflowExecute {
// Iterate over all the different connections of this output
for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[
outputIndex
]) {
] ?? []) {
if (!workflow.nodes.hasOwnProperty(connectionData.node)) {
throw new ApplicationError('Destination node not found', {
extra: {

View file

@ -21,3 +21,4 @@ export { BinaryData } from './BinaryData/types';
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
export * from './ExecutionMetadata';
export * from './node-execution-context';
export * from './PartialExecutionUtils';

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "1.60.0",
"version": "1.61.0",
"main": "src/main.ts",
"import": "src/main.ts",
"scripts": {
@ -27,7 +27,7 @@
"@types/markdown-it-emoji": "^2.0.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/sanitize-html": "^2.11.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:frontend",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",

View file

@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
it('default slot should trigger first level', async () => {
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] },
props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] },
global: {
plugins: [router],
},
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
},
],
},
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
},
],
},
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
},
],
},
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
await userEvent.click(getByTestId('navigation-submenu-item'));
expect(emitted('itemClick')).toStrictEqual([
[{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }],
[{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }],
]);
expect(emitted('select')).toStrictEqual([['bbb']]);
expect(emitted('select')).toStrictEqual([['nested']]);
});
it('should open first level on click', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
});
it('should toggle nested level on mouseenter / mouseleave', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested' }],
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
expect(getByText('nested')).not.toBeVisible();
await userEvent.hover(getByTestId('navigation-submenu'));
await waitFor(() => expect(getByText('nested')).toBeVisible());
await userEvent.pointer([
{ target: getByTestId('navigation-submenu') },
{ target: getByTestId('test-trigger') },
]);
await waitFor(() => expect(getByText('nested')).not.toBeVisible());
});
});

View file

@ -29,7 +29,7 @@ defineProps<{
}>();
const menuRef = ref<typeof ElMenu | null>(null);
const menuIndex = ref('-1');
const ROOT_MENU_INDEX = '-1';
const emit = defineEmits<{
itemClick: [item: MenuItemRegistered];
@ -37,7 +37,18 @@ const emit = defineEmits<{
}>();
const close = () => {
menuRef.value?.close(menuIndex.value);
menuRef.value?.close(ROOT_MENU_INDEX);
};
const menuTrigger = ref<'click' | 'hover'>('click');
const onOpen = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'hover';
};
const onClose = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'click';
};
defineExpose({
@ -50,14 +61,16 @@ defineExpose({
ref="menuRef"
mode="horizontal"
unique-opened
menu-trigger="click"
:menu-trigger="menuTrigger"
:ellipsis="false"
:class="$style.dropdown"
@select="emit('select', $event)"
@keyup.escape="close"
@open="onOpen"
@close="onClose"
>
<ElSubMenu
:index="menuIndex"
:index="ROOT_MENU_INDEX"
:class="$style.trigger"
:popper-offset="-10"
:popper-class="$style.submenu"
@ -70,10 +83,15 @@ defineExpose({
<template v-for="item in menu" :key="item.id">
<template v-if="item.submenu">
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu">
<ElSubMenu
:popper-class="$style.nestedSubmenu"
:index="item.id"
:popper-offset="-10"
data-test-id="navigation-submenu"
>
<template #title>{{ item.title }}</template>
<template v-for="subitem in item.submenu" :key="subitem.id">
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
<ElMenuItem
data-test-id="navigation-submenu-item"
:index="subitem.id"
@ -82,18 +100,20 @@ defineExpose({
>
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
{{ subitem.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem>
</ConditionalRouterLink>
</template>
</ElSubMenu>
</template>
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
<ElMenuItem
:index="item.id"
:disabled="item.disabled"
data-test-id="navigation-menu-item"
>
{{ item.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem>
</ConditionalRouterLink>
</template>
@ -125,17 +145,25 @@ defineExpose({
}
}
.nestedSubmenu {
:global(.el-menu) {
max-height: 450px;
overflow: auto;
}
}
.submenu {
padding: 5px 0 !important;
:global(.el-menu--horizontal .el-menu .el-menu-item),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
color: var(--color-text-dark);
background-color: var(--color-menu-background);
}
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
background-color: var(--color-foreground-base);
background-color: var(--color-menu-hover-background);
}
:global(.el-popper) {

View file

@ -462,6 +462,10 @@
--color-configurable-node-name: var(--color-text-dark);
--color-secondary-link: var(--prim-color-secondary-tint-200);
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
--color-menu-background: var(--prim-gray-740);
--color-menu-hover-background: var(--prim-gray-670);
--color-menu-active-background: var(--prim-gray-670);
}
body[data-theme='dark'] {

View file

@ -533,6 +533,11 @@
--color-secondary-link: var(--color-secondary);
--color-secondary-link-hover: var(--color-secondary-shade-1);
// Menu
--color-menu-background: var(--prim-gray-0);
--color-menu-hover-background: var(--prim-gray-120);
--color-menu-active-background: var(--prim-gray-120);
// Generated Color Shades from 50 to 950
// Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') {

View file

@ -1,12 +1,13 @@
{
"name": "n8n-editor-ui",
"version": "1.70.0",
"version": "1.71.0",
"description": "Workflow Editor UI for n8n",
"main": "index.js",
"scripts": {
"clean": "rimraf dist .turbo",
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
"typecheck": "vue-tsc --noEmit",
"typecheck:watch": "vue-tsc --watch --noEmit",
"dev": "pnpm serve",
"lint": "eslint src --ext .js,.ts,.vue --quiet",
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
@ -94,6 +95,7 @@
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0",
"@types/uuid": "catalog:",
"@vitejs/plugin-vue": "catalog:frontend",
"@vitest/coverage-v8": "catalog:frontend",
"miragejs": "^0.1.48",
"unplugin-icons": "^0.19.0",

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { v4 as uuid } from 'uuid';
import LoadingView from '@/views/LoadingView.vue';
import BannerStack from '@/components/banners/BannerStack.vue';
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
@ -17,6 +18,11 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useStyles } from './composables/useStyles';
// Polyfill crypto.randomUUID
if (!('randomUUID' in crypto)) {
Object.defineProperty(crypto, 'randomUUID', { value: uuid });
}
const route = useRoute();
const rootStore = useRootStore();
const assistantStore = useAssistantStore();

View file

@ -46,6 +46,7 @@ import type {
StartNodeData,
IPersonalizationSurveyAnswersV4,
AnnotationVote,
ITaskData,
} from 'n8n-workflow';
import type {
@ -201,6 +202,10 @@ export interface IStartRunData {
destinationNode?: string;
runData?: IRunData;
dirtyNodeNames?: string[];
triggerToStartFrom?: {
name: string;
data?: ITaskData;
};
}
export interface ITableData {
@ -1589,7 +1594,6 @@ export type ApiKey = {
};
export type InputPanel = {
displayMode: IRunDataDisplayMode;
nodeName?: string;
run?: number;
branch?: number;
@ -1600,7 +1604,6 @@ export type InputPanel = {
export type OutputPanel = {
branch?: number;
displayMode: IRunDataDisplayMode;
data: {
isEmpty: boolean;
};

View file

@ -26,7 +26,8 @@ export async function verifyMfaCode(
}
export type DisableMfaParams = {
mfaCode: string;
mfaCode?: string;
mfaRecoveryCode?: string;
};
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {

View file

@ -256,7 +256,7 @@ export function useChatMessaging({
];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => (i ?? []).length > 0)?.[0];
if (!memoryConnection) return [];

View file

@ -176,7 +176,7 @@ function useJsonFieldCompletions() {
if (activeNode) {
const workflow = workflowsStore.getCurrentWorkflow();
const input = workflow.connectionsByDestinationNode[activeNode.name];
return input.main[0][0].node;
return input.main[0] ? input.main[0][0].node : null;
}
} catch (e) {
console.error(e);

View file

@ -86,7 +86,7 @@ function getMultipleNodesText(nodeName: string): string {
props.workflow.connectionsByDestinationNode[activeNode.value.name].main || [];
// Collect indexes of connected nodes
const connectedInputIndexes = activeNodeConnections.reduce((acc: number[], node, index) => {
if (node[0] && node[0].node === nodeName) return [...acc, index];
if (node?.[0] && node[0].node === nodeName) return [...acc, index];
return acc;
}, []);

View file

@ -191,30 +191,32 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
actions.push({
id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION,
...(nodeViewVersion.value === '2'
? nodeViewSwitcherDiscovered.value || isNewUser.value
? {}
: {
badge: locale.baseText('menuActions.badge.new'),
}
: nodeViewSwitcherDiscovered.value
? {
badge: locale.baseText('menuActions.badge.beta'),
badgeProps: {
theme: 'tertiary',
},
}
: {
badge: locale.baseText('menuActions.badge.new'),
}),
label:
nodeViewVersion.value === '2'
? locale.baseText('menuActions.switchToOldNodeViewVersion')
: locale.baseText('menuActions.switchToNewNodeViewVersion'),
disabled: !onWorkflowPage.value,
});
if (settingsStore.isCanvasV2Enabled) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION,
...(nodeViewVersion.value === '2'
? nodeViewSwitcherDiscovered.value || isNewUser.value
? {}
: {
badge: locale.baseText('menuActions.badge.new'),
}
: nodeViewSwitcherDiscovered.value
? {
badge: locale.baseText('menuActions.badge.beta'),
badgeProps: {
theme: 'tertiary',
},
}
: {
badge: locale.baseText('menuActions.badge.new'),
}),
label:
nodeViewVersion.value === '2'
? locale.baseText('menuActions.switchToOldNodeViewVersion')
: locale.baseText('menuActions.switchToNewNodeViewVersion'),
disabled: !onWorkflowPage.value,
});
}
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
actions.push({

View file

@ -291,7 +291,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
}
};
const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation();
const {
menu,
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
projectsLimitReachedMessage,
} = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
:class="['clickable', $style.sideMenuCollapseButton]"
@click="toggleCollapse"
>
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
<N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
</div>
<div :class="$style.logo">
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
@select="handleMenuSelect"
>
<N8nIconButton icon="plus" type="secondary" outline />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<template #header>
<ProjectNavigation
:collapsed="isCollapsed"
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div :class="$style.giftContainer">
<GiftNotificationIcon />
</div>
<n8n-text
<N8nText
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
color="text-base"
>
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
nextVersions.length > 1 ? 's' : ''
}}
</n8n-text>
</N8nText>
</div>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
</div>
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div ref="user" :class="$style.userArea">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<ElDropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar
<N8nAvatar
:first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName"
size="small"
/>
</div>
<template v-if="isCollapsed" #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="settings">
<ElDropdownMenu>
<ElDropdownItem command="settings">
{{ i18n.baseText('settings') }}
</el-dropdown-item>
<el-dropdown-item command="logout">
</ElDropdownItem>
<ElDropdownItem command="logout">
{{ i18n.baseText('auth.signout') }}
</el-dropdown-item>
</el-dropdown-menu>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</el-dropdown>
</ElDropdown>
</div>
<div
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
>
<n8n-text size="small" :bold="true" color="text-dark">{{
<N8nText size="small" :bold="true" color="text-dark">{{
usersStore.currentUser?.fullName
}}</n8n-text>
}}</N8nText>
</div>
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
<n8n-action-dropdown
<N8nActionDropdown
:items="userMenuItems"
placement="top-start"
data-test-id="user-menu"
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</div>
</div>
</template>
</n8n-menu>
</N8nMenu>
</div>
</template>

View file

@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
watch(
() => props.isReadOnly,
(isReadOnly) => {
if (isReadOnly) {
// Patch fix, see https://linear.app/n8n/issue/ADO-2974/resource-mapper-values-are-emptied-when-refreshing-the-columns
if (isReadOnly && props.parameter.disabledOptions !== undefined) {
valueChanged({ name: props.path, value: props.parameter.default });
}
},

View file

@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects';
import { useRoute } from 'vue-router';
import * as router from 'vue-router';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { VIEWS } from '@/constants';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
},
});
let route: ReturnType<typeof useRoute>;
let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
describe('ProjectHeader', () => {
beforeEach(() => {
createTestingPinia();
route = useRoute();
route = router.useRoute();
projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
});
it('should not render creation button in setting page', async () => {
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent({
global: {
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, type Ref, ref } from 'vue';
import { useRoute } from 'vue-router';
import { N8nNavigationDropdown } from 'n8n-design-system';
import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@/composables/useI18n';
import { ProjectTypes } from '@/types/projects.types';
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
const route = useRoute();
const i18n = useI18n();
@ -47,9 +48,8 @@ const showSettings = computed(
projectsStore.currentProject?.type === ProjectTypes.Team,
);
const { menu, handleSelect } = useGlobalEntityCreation(
computed(() => !Boolean(projectsStore.currentProject)),
);
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
const createLabel = computed(() => {
if (!projectsStore.currentProject) {
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</slot>
</N8nText>
</div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
</div>
<div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" />
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
</N8nNavigationDropdown>
</div>
</div>
</template>
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
min-height: 64px;
}
.headerActions {
margin-left: auto;
}
.icon {
border: 1px solid var(--color-foreground-light);
padding: 6px;

View file

@ -6,6 +6,7 @@ import { useI18n } from '@/composables/useI18n';
import { promptMfaCodeBus } from '@/event-bus';
import type { IFormInputs } from '@/Interface';
import { createFormEventBus } from 'n8n-design-system';
import { validate as validateUuid } from 'uuid';
const i18n = useI18n();
@ -14,11 +15,11 @@ const readyToSubmit = ref(false);
const formFields: IFormInputs = [
{
name: 'mfaCode',
name: 'mfaCodeOrMfaRecoveryCode',
initialValue: '',
properties: {
label: i18n.baseText('mfa.code.input.label'),
placeholder: i18n.baseText('mfa.code.input.placeholder'),
label: i18n.baseText('mfa.code.recovery.input.label'),
placeholder: i18n.baseText('mfa.code.recovery.input.placeholder'),
focusInitially: true,
capitalize: true,
required: true,
@ -26,9 +27,15 @@ const formFields: IFormInputs = [
},
];
function onSubmit(values: { mfaCode: string }) {
function onSubmit(values: { mfaCodeOrMfaRecoveryCode: string }) {
if (validateUuid(values.mfaCodeOrMfaRecoveryCode)) {
promptMfaCodeBus.emit('close', {
mfaRecoveryCode: values.mfaCodeOrMfaRecoveryCode,
});
return;
}
promptMfaCodeBus.emit('close', {
mfaCode: values.mfaCode,
mfaCode: values.mfaCodeOrMfaRecoveryCode,
});
}
@ -43,7 +50,7 @@ function onFormReady(isReady: boolean) {
<template>
<Modal
width="460px"
width="500px"
height="300px"
max-height="640px"
:title="i18n.baseText('mfa.prompt.code.modal.title')"

View file

@ -406,9 +406,7 @@ describe('RunData', () => {
initialState: {
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
[STORES.NDV]: {
output: {
displayMode,
},
outputPanelDisplayMode: displayMode,
activeNodeName: 'Test Node',
},
[STORES.WORKFLOWS]: {

View file

@ -205,8 +205,8 @@ const keyMap = computed(() => ({
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)),
ctrl_a: () => addSelectedNodes(graphNodes.value),
'+|=': async () => await onZoomIn(),
'-|_': async () => await onZoomOut(),
'shift_+|+|=': async () => await onZoomIn(),
'shift+_|-|_': async () => await onZoomOut(),
0: async () => await onResetZoom(),
1: async () => await onFitView(),
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
@ -215,7 +215,6 @@ const keyMap = computed(() => ({
ArrowRight: emitWithLastSelectedNode(selectRightNode),
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
// @TODO implement arrow key shortcuts to modify selection
...(props.readOnly
? {}

View file

@ -1,7 +1,7 @@
import type { Ref } from 'vue';
import { ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
import type { Workflow } from 'n8n-workflow';
import type { Workflow, INode, NodeApiError } from 'n8n-workflow';
import { setActivePinia } from 'pinia';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
@ -23,6 +23,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
import { MarkerType } from '@vue-flow/core';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { mock } from 'vitest-mock-extended';
beforeEach(() => {
const pinia = createTestingPinia({
@ -237,7 +238,7 @@ describe('useCanvasMapping', () => {
expect(
mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output][
NodeConnectionType.Main
][0][0],
][0]?.[0],
).toEqual(
expect.objectContaining({
node: setNode.name,
@ -252,7 +253,7 @@ describe('useCanvasMapping', () => {
expect(
mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input][
NodeConnectionType.Main
][0][0],
][0]?.[0],
).toEqual(
expect.objectContaining({
node: manualTriggerNode.name,
@ -678,6 +679,442 @@ describe('useCanvasMapping', () => {
});
});
});
describe('nodeIssuesById', () => {
it('should return empty array when node has no issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([]);
});
it('should handle execution errors', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
const errorMessage = 'Test error message';
const errorDescription = 'Test error description';
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: errorMessage,
description: errorDescription,
}),
},
],
};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([`${errorMessage} (${errorDescription})`]);
});
it('should handle execution error without description', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
const errorMessage = 'Test error message';
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: errorMessage,
description: null,
}),
},
],
};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([errorMessage]);
});
it('should handle multiple execution errors', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: 'Error 1',
description: 'Description 1',
}),
},
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: 'Error 2',
description: 'Description 2',
}),
},
],
};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Error 1 (Description 1)',
'Error 2 (Description 2)',
]);
});
it('should handle node issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({
name: 'Test Node',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Node Type "n8n-nodes-base.set" is not known.',
]);
});
it('should combine execution errors and node issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({
name: 'Test Node',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: 'Execution error',
description: 'Error description',
}),
},
],
};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node.id]).toEqual([
'Execution error (Error description)',
'Node Type "n8n-nodes-base.set" is not known.',
]);
});
it('should handle multiple nodes with different issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node1 = createTestNode({
name: 'Node 1',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const node2 = createTestNode({ name: 'Node 2' });
const nodes = [node1, node2];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Node 2': [
{
startTime: 0,
executionTime: 0,
source: [],
error: mock<NodeApiError>({
message: 'Execution error',
description: 'Error description',
}),
},
],
};
const { nodeIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeIssuesById.value[node1.id]).toEqual([
'Node Type "n8n-nodes-base.set" is not known.',
]);
expect(nodeIssuesById.value[node2.id]).toEqual(['Execution error (Error description)']);
});
});
describe('nodeHasIssuesById', () => {
it('should return false when node has no issues or errors', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
workflowsStore.pinDataByNodeName.mockReturnValue(undefined);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(false);
});
it('should return true when execution status is crashed', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
executionStatus: 'crashed',
},
],
};
workflowsStore.pinDataByNodeName.mockReturnValue(undefined);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(true);
});
it('should return true when execution status is error', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({ name: 'Test Node' });
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
executionStatus: 'error',
},
],
};
workflowsStore.pinDataByNodeName.mockReturnValue(undefined);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(true);
});
it('should return false when node has pinned data regardless of issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({
name: 'Test Node',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
workflowsStore.pinDataByNodeName.mockReturnValue([{ json: {} }]);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(false);
});
it('should return true when node has issues and no pinned data', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({
name: 'Test Node',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {};
workflowsStore.pinDataByNodeName.mockReturnValue(undefined);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(true);
});
it('should return true for execution errors even with other issues', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node = createTestNode({
name: 'Test Node',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const nodes = [node];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Test Node': [
{
startTime: 0,
executionTime: 0,
source: [],
executionStatus: 'error',
error: mock<NodeApiError>({
message: 'Execution error',
description: 'Error description',
}),
},
],
};
workflowsStore.pinDataByNodeName.mockReturnValue(undefined);
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node.id]).toBe(true);
});
it('should handle multiple nodes with different issue states', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const node1 = createTestNode({
name: 'Node 1',
issues: {
typeUnknown: true,
},
} as Partial<INode>);
const node2 = createTestNode({ name: 'Node 2' });
const node3 = createTestNode({ name: 'Node 3' });
const nodes = [node1, node2, node3];
const connections = {};
const workflowObject = createTestWorkflowObject({ nodes, connections });
workflowsStore.getWorkflowRunData = {
'Node 2': [
{
startTime: 0,
executionTime: 0,
source: [],
executionStatus: 'error',
},
],
'Node 3': [
{
startTime: 0,
executionTime: 0,
source: [],
executionStatus: 'success',
},
],
};
workflowsStore.pinDataByNodeName.mockImplementation((nodeName: string) => {
return nodeName === 'Node 1' ? [{ json: {} }] : undefined;
});
const { nodeHasIssuesById } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(nodeHasIssuesById.value[node1.id]).toBe(false); // Has issues but also pinned data
expect(nodeHasIssuesById.value[node2.id]).toBe(true); // Has error status
expect(nodeHasIssuesById.value[node3.id]).toBe(false); // No issues
});
});
});
describe('connections', () => {

View file

@ -374,7 +374,7 @@ export function useCanvasMapping({
} else if (nodePinnedDataById.value[node.id]) {
acc[node.id] = false;
} else {
acc[node.id] = Object.keys(node?.issues ?? {}).length > 0;
acc[node.id] = nodeIssuesById.value[node.id].length > 0;
}
return acc;
@ -647,6 +647,8 @@ export function useCanvasMapping({
return {
additionalNodePropertiesById,
nodeExecutionRunDataOutputMapById,
nodeIssuesById,
nodeHasIssuesById,
connections: mappedConnections,
nodes: mappedNodes,
};

View file

@ -802,6 +802,82 @@ describe('useCanvasOperations', () => {
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id);
});
it('should handle nodes with null connections for unconnected indexes', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.nodeTypes = {
[SET_NODE_TYPE]: { 1: mockNodeTypeDescription({ name: SET_NODE_TYPE }) },
};
const nodes = [
createTestNode({
id: 'input',
type: SET_NODE_TYPE,
position: [10, 20],
name: 'Input Node',
}),
createTestNode({
id: 'middle',
type: SET_NODE_TYPE,
position: [10, 20],
name: 'Middle Node',
}),
createTestNode({
id: 'output',
type: SET_NODE_TYPE,
position: [10, 20],
name: 'Output Node',
}),
];
workflowsStore.getNodeByName = vi
.fn()
.mockImplementation((name: string) => nodes.find((node) => node.name === name));
workflowsStore.workflow.nodes = nodes;
workflowsStore.workflow.connections = {
[nodes[0].name]: {
main: [
null,
[
{
node: nodes[1].name,
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
[nodes[1].name]: {
main: [
// null here to simulate no connection at index
null,
[
{
node: nodes[2].name,
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
};
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject);
workflowsStore.incomingConnectionsByNodeName.mockReturnValue({});
workflowsStore.getNodeById.mockReturnValue(nodes[1]);
const { deleteNode } = useCanvasOperations({ router });
deleteNode(nodes[1].id);
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id);
expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id);
});
});
describe('revertDeleteNode', () => {

View file

@ -1149,11 +1149,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
for (const type of Object.keys(connections[nodeName])) {
for (const index of Object.keys(connections[nodeName][type])) {
for (const connectionIndex of Object.keys(
connections[nodeName][type][parseInt(index, 10)],
)) {
const connectionData =
connections[nodeName][type][parseInt(index, 10)][parseInt(connectionIndex, 10)];
const connectionsToDelete = connections[nodeName][type][parseInt(index, 10)] ?? [];
for (const connectionIndex of Object.keys(connectionsToDelete)) {
const connectionData = connectionsToDelete[parseInt(connectionIndex, 10)];
if (!connectionData) {
continue;
}
@ -1490,13 +1488,14 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
sourceIndex++
) {
const nodeSourceConnections = [];
if (currentConnections[sourceNode][type][sourceIndex]) {
const connectionsToCheck = currentConnections[sourceNode][type][sourceIndex];
if (connectionsToCheck) {
for (
connectionIndex = 0;
connectionIndex < currentConnections[sourceNode][type][sourceIndex].length;
connectionIndex < connectionsToCheck.length;
connectionIndex++
) {
connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex];
connectionData = connectionsToCheck[connectionIndex];
if (!createNodeNames.includes(connectionData.node)) {
// Node does not get created so skip input connection
continue;
@ -1814,7 +1813,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
for (const [type, typeConnections] of Object.entries(connections)) {
const validConnections = typeConnections.map((sourceConnections) =>
sourceConnections.filter((connection) => includeNodeNames.has(connection.node)),
(sourceConnections ?? []).filter((connection) => includeNodeNames.has(connection.node)),
);
if (validConnections.length) {

View file

@ -1,14 +1,84 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionDebugging } from './useExecutionDebugging';
import type { INodeUi, IExecutionResponse } from '@/Interface';
import type { Workflow } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
vi.mock('@/composables/useToast', () => {
const showToast = vi.fn();
return {
useToast: () => ({
showToast,
}),
};
});
let executionDebugging: ReturnType<typeof useExecutionDebugging>;
let toast: ReturnType<typeof useToast>;
describe('useExecutionDebugging()', () => {
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia();
executionDebugging = useExecutionDebugging();
toast = useToast();
});
it('should not throw when runData node is an empty array', async () => {
const mockExecution = {
data: {
resultData: {
runData: {
testNode: [],
},
},
},
} as unknown as IExecutionResponse;
const workflowStore = mockedStore(useWorkflowsStore);
workflowStore.getNodes.mockReturnValue([{ name: 'testNode' }] as INodeUi[]);
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
workflowStore.getCurrentWorkflow.mockReturnValue({
pinData: {},
getParentNodes: vi.fn().mockReturnValue([]),
} as unknown as Workflow);
await expect(executionDebugging.applyExecutionData('1')).resolves.not.toThrowError();
});
it('should show missing nodes warning toast', async () => {
const mockExecution = {
data: {
resultData: {
runData: {
testNode: [
{
data: {},
},
],
},
},
},
} as unknown as IExecutionResponse;
const workflowStore = mockedStore(useWorkflowsStore);
workflowStore.getNodes.mockReturnValue([{ name: 'testNode2' }] as INodeUi[]);
workflowStore.getExecution.mockResolvedValueOnce(mockExecution);
workflowStore.getCurrentWorkflow.mockReturnValue({
pinData: {},
getParentNodes: vi.fn().mockReturnValue([]),
} as unknown as Workflow);
await executionDebugging.applyExecutionData('1');
expect(workflowStore.setWorkflowExecutionData).toHaveBeenCalledWith(mockExecution);
expect(toast.showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }));
expect(toast.showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' }));
});
it('should applyExecutionData', async () => {
setActivePinia(createTestingPinia());
const mockExecution = {
data: {
resultData: {
@ -31,10 +101,9 @@ describe('useExecutionDebugging()', () => {
getParentNodes: vi.fn().mockReturnValue([]),
} as unknown as Workflow);
const { applyExecutionData } = useExecutionDebugging();
await applyExecutionData('1');
await executionDebugging.applyExecutionData('1');
expect(workflowStore.setWorkflowExecutionData).toHaveBeenCalledWith(mockExecution);
expect(toast.showToast).toHaveBeenCalledTimes(1);
});
});

View file

@ -108,7 +108,7 @@ export const useExecutionDebugging = () => {
let pinnings = 0;
pinnableNodes.forEach((node: INodeUi) => {
const nodeData = runData[node.name]?.[0].data?.main?.[0];
const nodeData = runData[node.name]?.[0]?.data?.main?.[0];
if (nodeData) {
pinnings++;
workflowsStore.pinData({

View file

@ -7,6 +7,9 @@ import type router from 'vue-router';
import { flushPromises } from '@vue/test-utils';
import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import type { CloudPlanState } from '@/Interface';
import { VIEWS } from '@/constants';
import type { Project, ProjectListItem } from '@/types/projects.types';
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
describe('global', () => {
it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
const personalProjectId = 'personal-project';
projectsStore.isTeamProjectFeatureEnabled = true;
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
expect(redirect.goToUpgrade).toHaveBeenCalled();
});
});
it('should show plan and limit according to deployment type', () => {
const settingsStore = mockedStore(useSettingsStore);
const cloudPlanStore = mockedStore(useCloudPlanStore);
cloudPlanStore.currentPlanData = { displayName: 'Pro' } as CloudPlanState['data'];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true;
projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.',
);
settingsStore.isCloudDeployment = false;
expect(projectsLimitReachedMessage.value).toContain(
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
);
});
});

View file

@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
import { sortByProperty } from '@/utils/sortUtils';
import { useToast } from '@/composables/useToast';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { getResourcePermissions } from '@/permissions';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
) => {
const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
{
id: CREATE_PROJECT_ID,
title: 'Project',
disabled: !projectsStore.canCreateProjects,
},
];
});
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
};
return { menu, handleSelect };
const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
planName: cloudPlanStore.currentPlanData?.displayName ?? '',
limit: projectsStore.teamProjectsLimit,
},
});
}
return i18n.baseText('projects.create.limitReached.self');
});
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage };
};

View file

@ -110,6 +110,18 @@ describe('useKeybindings', () => {
expect(handler).toHaveBeenCalled();
});
it('should normalize shortcut strings containing splitting key correctly', async () => {
const handler = vi.fn();
const keymap = ref({ 'ctrl_+': handler });
useKeybindings(keymap);
const event = new KeyboardEvent('keydown', { key: '+', ctrlKey: true });
document.dispatchEvent(event);
expect(handler).toHaveBeenCalled();
});
it('should normalize shortcut string alternatives correctly', async () => {
const handler = vi.fn();
const keymap = ref({ 'a|b': handler });

View file

@ -38,14 +38,30 @@ export const useKeybindings = (
),
);
function normalizeShortcutString(shortcut: string) {
return shortcut
.split(/[+_-]/)
function shortcutPartsToString(parts: string[]) {
return parts
.map((key) => key.toLowerCase())
.sort((a, b) => a.localeCompare(b))
.join('+');
}
function normalizeShortcutString(shortcut: string) {
if (shortcut.length === 1) {
return shortcut.toLowerCase();
}
const splitChars = ['+', '_', '-'];
const splitCharsRegEx = splitChars.reduce((acc, char) => {
if (shortcut.startsWith(char) || shortcut.endsWith(char)) {
return acc;
}
return char + acc;
}, '');
return shortcutPartsToString(shortcut.split(new RegExp(`[${splitCharsRegEx}]`)));
}
function toShortcutString(event: KeyboardEvent) {
const { shiftKey, altKey } = event;
const ctrlKey = isCtrlKeyPressed(event);
@ -64,7 +80,7 @@ export const useKeybindings = (
modifiers.push('alt');
}
return normalizeShortcutString([...modifiers, ...keys].join('+'));
return shortcutPartsToString([...modifiers, ...keys]);
}
function onKeyDown(event: KeyboardEvent) {

View file

@ -16,6 +16,11 @@ describe('useNodeViewVersionSwitcher', () => {
const initialState = {
[STORES.WORKFLOWS]: {},
[STORES.NDV]: {},
[STORES.SETTINGS]: {
settings: {
betaFeatures: ['canvas_v2'],
},
},
};
beforeEach(() => {

View file

@ -3,15 +3,18 @@ import { useLocalStorage } from '@vueuse/core';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useSettingsStore } from '@/stores/settings.store';
export function useNodeViewVersionSwitcher() {
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const isNewUser = computed(() => workflowsStore.activeWorkflows.length === 0);
const nodeViewVersion = useLocalStorage('NodeView.version', '2');
const defaultVersion = settingsStore.isCanvasV2Enabled ? '2' : '1';
const nodeViewVersion = useLocalStorage('NodeView.version', defaultVersion);
const nodeViewVersionMigrated = useLocalStorage('NodeView.migrated', false);
function setNodeViewSwitcherDropdownOpened(visible: boolean) {
@ -36,6 +39,10 @@ export function useNodeViewVersionSwitcher() {
function switchNodeViewVersion() {
const toVersion = nodeViewVersion.value === '2' ? '1' : '2';
if (!nodeViewVersionMigrated.value) {
nodeViewVersionMigrated.value = true;
}
telemetry.track('User switched canvas version', {
to_version: toVersion,
});
@ -49,7 +56,6 @@ export function useNodeViewVersionSwitcher() {
}
switchNodeViewVersion();
nodeViewVersionMigrated.value = true;
}
return {

View file

@ -8,6 +8,7 @@ import {
type IRunData,
type Workflow,
type IExecuteData,
type ITaskData,
} from 'n8n-workflow';
import { useRootStore } from '@/stores/root.store';
@ -20,6 +21,7 @@ import { useToast } from './useToast';
import { useI18n } from '@/composables/useI18n';
import { useLocalStorage } from '@vueuse/core';
import { ref } from 'vue';
import { mock } from 'vitest-mock-extended';
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue({
@ -325,6 +327,34 @@ describe('useRunWorkflow({ router })', () => {
);
});
it('should send triggerToStartFrom if triggerNode and nodeData are passed in', async () => {
// ARRANGE
const composable = useRunWorkflow({ router });
const triggerNode = 'Chat Trigger';
const nodeData = mock<ITaskData>();
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(
mock<Workflow>({ getChildNodes: vi.fn().mockReturnValue([]) }),
);
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(
mock<IWorkflowData>({ nodes: [] }),
);
const { runWorkflow } = composable;
// ACT
await runWorkflow({ triggerNode, nodeData });
// ASSERT
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
triggerToStartFrom: {
name: triggerNode,
data: nodeData,
},
}),
);
});
it('does not use the original run data if `PartialExecution.version` is set to 0', async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };

View file

@ -150,6 +150,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
let { runData: newRunData } = consolidatedData;
let executedNode: string | undefined;
let triggerToStartFrom: IStartRunData['triggerToStartFrom'];
if (
startNodeNames.length === 0 &&
'destinationNode' in options &&
@ -157,14 +158,16 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
) {
executedNode = options.destinationNode;
startNodeNames.push(options.destinationNode);
} else if ('triggerNode' in options && 'nodeData' in options) {
} else if (options.triggerNode && options.nodeData) {
startNodeNames.push(
...workflow.getChildNodes(options.triggerNode as string, NodeConnectionType.Main, 1),
...workflow.getChildNodes(options.triggerNode, NodeConnectionType.Main, 1),
);
newRunData = {
[options.triggerNode as string]: [options.nodeData],
} as IRunData;
newRunData = { [options.triggerNode]: [options.nodeData] };
executedNode = options.triggerNode;
triggerToStartFrom = {
name: options.triggerNode,
data: options.nodeData,
};
}
// If the destination node is specified, check if it is a chat node or has a chat parent
@ -258,6 +261,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// data to use and what to ignore.
runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData,
startNodes,
triggerToStartFrom,
};
if ('destinationNode' in options) {
startRunData.destinationNode = options.destinationNode;

View file

@ -413,7 +413,7 @@ export function executeData(
mainConnections: for (const mainConnections of workflow.connectionsByDestinationNode[
currentNode
].main) {
for (const connection of mainConnections) {
for (const connection of mainConnections ?? []) {
if (
connection.type === NodeConnectionType.Main &&
connection.node === parentNodeName

View file

@ -437,6 +437,8 @@ export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME';
export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_BUTTON';
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const HIRING_BANNER = `

View file

@ -3,7 +3,8 @@ import { createEventBus } from 'n8n-design-system/utils';
export const mfaEventBus = createEventBus();
export interface MfaModalClosedEventPayload {
mfaCode: string;
mfaCode?: string;
mfaRecoveryCode?: string;
}
export interface MfaModalEvents {

View file

@ -2563,6 +2563,7 @@
"projects.error.title": "Project error",
"projects.create.limit": "{num} project | {num} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows",
"projects.create.limitReached.link": "View plans",
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
@ -2596,7 +2597,9 @@
"mfa.button.back": "Back",
"mfa.code.input.label": "Two-factor code",
"mfa.code.input.placeholder": "e.g. 123456",
"mfa.recovery.input.label": "Recovery Code",
"mfa.code.recovery.input.label": "Two-factor code or recovery code",
"mfa.code.recovery.input.placeholder": "e.g. 123456 or c79f9c02-7b2e-44...",
"mfa.recovery.input.label": "Recovery code",
"mfa.recovery.input.placeholder": "e.g c79f9c02-7b2e-44...",
"mfa.code.invalid": "This code is invalid, try again or",
"mfa.recovery.invalid": "This code is invalid or was already used, try again",
@ -2620,7 +2623,7 @@
"mfa.setup.step2.toast.setupFinished.message": "Two-factor authentication enabled",
"mfa.setup.step2.toast.setupFinished.error.message": "Error enabling two-factor authentication",
"mfa.setup.step2.toast.tokenExpired.error.message": "MFA token expired. Close the modal and enable MFA again",
"mfa.prompt.code.modal.title": "Two-factor code required",
"mfa.prompt.code.modal.title": "Two-factor code or recovery code required",
"settings.personal.mfa.section.title": "Two-factor authentication (2FA)",
"settings.personal.personalisation": "Personalisation",
"settings.personal.theme": "Theme",

View file

@ -14,6 +14,8 @@ const App = {
};
const renderComponent = createComponentRenderer(App);
let settingsStore: ReturnType<typeof useSettingsStore>;
describe('router', () => {
let server: ReturnType<typeof setupServer>;
const initializeAuthenticatedFeaturesSpy = vi.spyOn(init, 'initializeAuthenticatedFeatures');
@ -28,6 +30,7 @@ describe('router', () => {
});
beforeEach(() => {
settingsStore = useSettingsStore();
initializeAuthenticatedFeaturesSpy.mockImplementation(async () => await Promise.resolve());
});
@ -114,7 +117,6 @@ describe('router', () => {
])(
'should resolve %s to %s with %s user permissions',
async (path, name, scopes) => {
const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
settingsStore.settings.communityNodesEnabled = true;
@ -126,4 +128,13 @@ describe('router', () => {
},
10000,
);
test.each([
[VIEWS.PERSONAL_SETTINGS, true],
[VIEWS.USAGE, false],
])('should redirect Settings to %s', async (name, hideUsagePage) => {
settingsStore.settings.hideUsagePage = hideUsagePage;
await router.push('/settings');
expect(router.currentRoute.value.name).toBe(name);
});
});

View file

@ -255,7 +255,6 @@ export const routes: RouteRecordRaw[] = [
},
{
path: '/workflow/:name/evaluation',
name: VIEWS.TEST_DEFINITION,
meta: {
keepWorkflowAlive: true,
middleware: ['authenticated'],
@ -483,7 +482,13 @@ export const routes: RouteRecordRaw[] = [
name: VIEWS.SETTINGS,
component: SettingsView,
props: true,
redirect: { name: VIEWS.USAGE },
redirect: () => {
const settingsStore = useSettingsStore();
if (settingsStore.settings.hideUsagePage) {
return { name: VIEWS.PERSONAL_SETTINGS };
}
return { name: VIEWS.USAGE };
},
children: [
{
path: 'usage',

View file

@ -1,3 +1,4 @@
import { useLocalStorage } from '@vueuse/core';
import type {
Draggable,
InputPanel,
@ -13,6 +14,8 @@ import { useStorage } from '@/composables/useStorage';
import {
LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED,
LOCAL_STORAGE_MAPPING_IS_ONBOARDED,
LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE,
LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE,
LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED,
STORES,
} from '@/constants';
@ -44,7 +47,6 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
});
const pushRef = ref('');
const input = ref<InputPanel>({
displayMode: 'schema',
nodeName: undefined,
run: undefined,
branch: undefined,
@ -52,8 +54,11 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
isEmpty: true,
},
});
const inputPanelDisplayMode = useLocalStorage<IRunDataDisplayMode>(
LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE,
'schema',
);
const output = ref<OutputPanel>({
displayMode: 'table',
branch: undefined,
data: {
isEmpty: true,
@ -63,6 +68,10 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
value: '',
},
});
const outputPanelDisplayMode = useLocalStorage<IRunDataDisplayMode>(
LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE,
'table',
);
const focusedMappableInput = ref('');
const focusedInputPath = ref('');
const mappingTelemetry = ref<Record<string, string | number | boolean>>({});
@ -125,10 +134,6 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
return ndvInputDataWithPinnedData.value.length > 0;
});
const inputPanelDisplayMode = computed(() => input.value.displayMode);
const outputPanelDisplayMode = computed(() => output.value.displayMode);
const isDraggableDragging = computed(() => draggable.value.isDragging);
const draggableType = computed(() => draggable.value.type);
@ -151,7 +156,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
if (!activeNodeConections || activeNodeConections.length < 2) return returnData;
for (const [index, connection] of activeNodeConections.entries()) {
for (const node of connection) {
for (const node of connection ?? []) {
if (!returnData[node.node]) {
returnData[node.node] = [];
}
@ -242,9 +247,9 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
mode: IRunDataDisplayMode;
}): void => {
if (params.pane === 'input') {
input.value.displayMode = params.mode;
inputPanelDisplayMode.value = params.mode;
} else {
output.value.displayMode = params.mode;
outputPanelDisplayMode.value = params.mode;
}
};

View file

@ -331,10 +331,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}
};
const disableMfa = async (mfaCode: string) => {
await mfaApi.disableMfa(rootStore.restApiContext, {
mfaCode,
});
const disableMfa = async (data: mfaApi.DisableMfaParams) => {
await mfaApi.disableMfa(rootStore.restApiContext, data);
if (currentUser.value) {
currentUser.value.mfaEnabled = false;

View file

@ -891,23 +891,28 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
let propertyName: keyof IConnection;
let connectionExists = false;
connectionLoop: for (const existingConnection of workflow.value.connections[sourceData.node][
sourceData.type
][sourceData.index]) {
for (propertyName of checkProperties) {
if (existingConnection[propertyName] !== destinationData[propertyName]) {
continue connectionLoop;
const nodeConnections = workflow.value.connections[sourceData.node][sourceData.type];
const connectionsToCheck = nodeConnections[sourceData.index];
if (connectionsToCheck) {
connectionLoop: for (const existingConnection of connectionsToCheck) {
for (propertyName of checkProperties) {
if (existingConnection[propertyName] !== destinationData[propertyName]) {
continue connectionLoop;
}
}
connectionExists = true;
break;
}
connectionExists = true;
break;
}
// Add the new connection if it does not exist already
if (!connectionExists) {
workflow.value.connections[sourceData.node][sourceData.type][sourceData.index].push(
destinationData,
);
nodeConnections[sourceData.index] = nodeConnections[sourceData.index] ?? [];
const connections = nodeConnections[sourceData.index];
if (connections) {
connections.push(destinationData);
}
}
}
@ -934,6 +939,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const connections =
workflow.value.connections[sourceData.node][sourceData.type][sourceData.index];
if (!connections) {
return;
}
for (const index in connections) {
if (
connections[index].node === destinationData.node &&
@ -979,23 +988,19 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
for (type of Object.keys(workflow.value.connections[sourceNode])) {
for (sourceIndex of Object.keys(workflow.value.connections[sourceNode][type])) {
indexesToRemove.length = 0;
for (connectionIndex of Object.keys(
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)],
)) {
connectionData =
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)][
parseInt(connectionIndex, 10)
];
if (connectionData.node === node.name) {
indexesToRemove.push(connectionIndex);
const connectionsToRemove =
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)];
if (connectionsToRemove) {
for (connectionIndex of Object.keys(connectionsToRemove)) {
connectionData = connectionsToRemove[parseInt(connectionIndex, 10)];
if (connectionData.node === node.name) {
indexesToRemove.push(connectionIndex);
}
}
indexesToRemove.forEach((index) => {
connectionsToRemove.splice(parseInt(index, 10), 1);
});
}
indexesToRemove.forEach((index) => {
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)].splice(
parseInt(index, 10),
1,
);
});
}
}
}

View file

@ -9,7 +9,7 @@ import type { IExecutionFlattedResponse, IExecutionResponse, IRestApiContext } f
const getBrowserId = () => {
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
if (!browserId && 'randomUUID' in crypto) {
if (!browserId) {
browserId = crypto.randomUUID();
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
}

View file

@ -56,7 +56,7 @@ describe('updateDynamicConnections', () => {
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
expect(updatedConnections?.TestNode.main).toHaveLength(2);
expect(updatedConnections?.TestNode.main[1][0].node).toEqual('Node3');
expect(updatedConnections?.TestNode.main[1]?.[0].node).toEqual('Node3');
});
it('should handle fallbackOutput === "extra" and all rules removed', () => {
@ -82,7 +82,7 @@ describe('updateDynamicConnections', () => {
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
expect(updatedConnections?.TestNode.main).toHaveLength(1);
expect(updatedConnections?.TestNode.main[0][0].node).toEqual('Node3');
expect(updatedConnections?.TestNode.main[0]?.[0].node).toEqual('Node3');
});
it('should add a new connection when a rule is added', () => {
@ -137,7 +137,7 @@ describe('updateDynamicConnections', () => {
expect(updatedConnections?.TestNode.main).toHaveLength(4);
expect(updatedConnections?.TestNode.main[2]).toEqual([]);
expect(updatedConnections?.TestNode.main[3][0].node).toEqual('Node3');
expect(updatedConnections?.TestNode.main[3]?.[0].node).toEqual('Node3');
});
it('should return null if no conditions are met', () => {

View file

@ -77,7 +77,7 @@ export function updateDynamicConnections(
}
} else if (parameterData.name === 'parameters.rules.values') {
const curentRulesvalues = (node.parameters?.rules as { values: IDataObject[] })?.values;
let lastConnection: IConnection[] | undefined = undefined;
let lastConnection: IConnection[] | null | undefined = undefined;
if (
fallbackOutput === 'extra' &&
connections[node.name].main.length === curentRulesvalues.length + 1

View file

@ -35,7 +35,9 @@ const initialState = {
},
};
const renderComponent = createComponentRenderer(CredentialsView);
const renderComponent = createComponentRenderer(CredentialsView, {
global: { stubs: { ProjectHeader: true } },
});
let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => {

View file

@ -272,13 +272,13 @@ async function initializeData() {
promises.push(externalSecretsStore.fetchAllSecrets());
}
if (nodeTypesStore.allNodeTypes.length === 0) {
promises.push(nodeTypesStore.getNodeTypes());
}
return promises;
})();
if (nodeTypesStore.allNodeTypes.length === 0) {
loadPromises.push(nodeTypesStore.getNodeTypes());
}
try {
await Promise.all(loadPromises);
} catch (error) {
@ -291,7 +291,7 @@ async function initializeData() {
}
}
async function initializeRoute() {
async function initializeRoute(force = false) {
// In case the workflow got saved we do not have to run init
// as only the route changed but all the needed data is already loaded
if (route.params.action === 'workflowSave') {
@ -300,6 +300,7 @@ async function initializeRoute() {
}
const isAlreadyInitialized =
!force &&
initializedWorkflowId.value &&
[NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value);
@ -1489,8 +1490,10 @@ function unregisterCustomActions() {
watch(
() => route.name,
async () => {
await initializeRoute();
async (newRouteName, oldRouteName) => {
// it's navigating from and existing workflow to a new workflow
const force = newRouteName === VIEWS.NEW_WORKFLOW && oldRouteName === VIEWS.WORKFLOW;
await initializeRoute(force);
},
);

View file

@ -1394,7 +1394,11 @@ export default defineComponent({
lastSelectedNode.name,
);
if (connections.main === undefined || connections.main.length === 0) {
if (
connections.main === undefined ||
connections.main.length === 0 ||
!connections.main[0]
) {
return;
}
@ -1428,7 +1432,11 @@ export default defineComponent({
const connections = workflow.connectionsByDestinationNode[lastSelectedNode.name];
if (connections.main === undefined || connections.main.length === 0) {
if (
connections.main === undefined ||
connections.main.length === 0 ||
!connections.main[0]
) {
return;
}
@ -1460,7 +1468,11 @@ export default defineComponent({
return;
}
const parentNode = connections.main[0][0].node;
const parentNode = connections.main[0]?.[0].node;
if (!parentNode) {
return;
}
const connectionsParent = this.workflowsStore.outgoingConnectionsByNodeName(parentNode);
if (!Array.isArray(connectionsParent.main) || !connectionsParent.main.length) {
@ -1472,7 +1484,7 @@ export default defineComponent({
let lastCheckedNodePosition = e.key === 'ArrowUp' ? -99999999 : 99999999;
let nextSelectNode: string | null = null;
for (const ouputConnections of connectionsParent.main) {
for (const ouputConnection of ouputConnections) {
for (const ouputConnection of ouputConnections ?? []) {
if (ouputConnection.node === lastSelectedNode.name) {
// Ignore current node
continue;
@ -3877,13 +3889,10 @@ export default defineComponent({
sourceIndex++
) {
const nodeSourceConnections = [];
if (currentConnections[sourceNode][type][sourceIndex]) {
for (
connectionIndex = 0;
connectionIndex < currentConnections[sourceNode][type][sourceIndex].length;
connectionIndex++
) {
connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex];
const connections = currentConnections[sourceNode][type][sourceIndex];
if (connections) {
for (connectionIndex = 0; connectionIndex < connections.length; connectionIndex++) {
connectionData = connections[connectionIndex];
if (!createNodeNames.includes(connectionData.node)) {
// Node does not get created so skip input connection
continue;
@ -4013,14 +4022,17 @@ export default defineComponent({
for (type of Object.keys(connections)) {
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
connectionToKeep = [];
for (
connectionIndex = 0;
connectionIndex < connections[type][sourceIndex].length;
connectionIndex++
) {
connectionData = connections[type][sourceIndex][connectionIndex];
if (exportNodeNames.indexOf(connectionData.node) !== -1) {
connectionToKeep.push(connectionData);
const connectionsToCheck = connections[type][sourceIndex];
if (connectionsToCheck) {
for (
connectionIndex = 0;
connectionIndex < connectionsToCheck.length;
connectionIndex++
) {
connectionData = connectionsToCheck[connectionIndex];
if (exportNodeNames.indexOf(connectionData.node) !== -1) {
connectionToKeep.push(connectionData);
}
}
}

View file

@ -226,7 +226,7 @@ async function disableMfa(payload: MfaModalEvents['closed']) {
}
try {
await usersStore.disableMfa(payload.mfaCode);
await usersStore.disableMfa(payload);
showToast({
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),

View file

@ -50,7 +50,7 @@ const router = createRouter({
name: VIEWS.SETTINGS,
component: SettingsView,
props: true,
redirect: { name: VIEWS.USAGE },
redirect: { name: VIEWS.PERSONAL_SETTINGS },
children: settingsRouteChildren,
},
],

View file

@ -1,6 +1,6 @@
{
"name": "n8n-node-dev",
"version": "1.70.0",
"version": "1.71.0",
"description": "CLI to simplify n8n credentials/node development",
"main": "dist/src/index",
"types": "dist/src/index.d.ts",

View file

@ -1,6 +1,6 @@
{
"name": "n8n-nodes-base",
"version": "1.70.0",
"version": "1.71.0",
"description": "Base nodes of n8n",
"main": "index.js",
"scripts": {

Some files were not shown because too many files have changed in this diff Show more