mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into fix-CAT-337-put-parent-workflow-to-wait
This commit is contained in:
commit
f3e63ee724
45
CHANGELOG.md
45
CHANGELOG.md
|
@ -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)
|
# [1.70.0](https://github.com/n8n-io/n8n/compare/n8n@1.69.0...n8n@1.70.0) (2024-11-27)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -76,6 +76,10 @@ export function getCanvasNodes() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCanvasNodeByName(nodeName: string) {
|
||||||
|
return getCanvasNodes().filter(`:contains(${nodeName})`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getSaveButton() {
|
export function getSaveButton() {
|
||||||
return cy.getByTestId('workflow-save-button');
|
return cy.getByTestId('workflow-save-button');
|
||||||
}
|
}
|
||||||
|
@ -194,3 +198,8 @@ export function pasteWorkflow(workflow: object) {
|
||||||
export function clickZoomToFit() {
|
export function clickZoomToFit() {
|
||||||
getZoomToFitButton().click();
|
getZoomToFitButton().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteNode(name: string) {
|
||||||
|
getCanvasNodeByName(name).first().click();
|
||||||
|
cy.get('body').type('{del}');
|
||||||
|
}
|
||||||
|
|
|
@ -73,11 +73,23 @@ describe('Two-factor authentication', { disableAutoLogin: true }, () => {
|
||||||
signinPage.actions.loginWithEmailAndPassword(email, password);
|
signinPage.actions.loginWithEmailAndPassword(email, password);
|
||||||
personalSettingsPage.actions.enableMfa();
|
personalSettingsPage.actions.enableMfa();
|
||||||
mainSidebar.actions.signout();
|
mainSidebar.actions.signout();
|
||||||
const loginToken = generateOTPToken(user.mfaSecret);
|
const mfaCode = generateOTPToken(user.mfaSecret);
|
||||||
mfaLoginPage.actions.loginWithMfaCode(email, password, loginToken);
|
mfaLoginPage.actions.loginWithMfaCode(email, password, mfaCode);
|
||||||
const disableToken = generateOTPToken(user.mfaSecret);
|
const disableToken = generateOTPToken(user.mfaSecret);
|
||||||
personalSettingsPage.actions.disableMfa(disableToken);
|
personalSettingsPage.actions.disableMfa(disableToken);
|
||||||
personalSettingsPage.getters.enableMfaButton().should('exist');
|
personalSettingsPage.getters.enableMfaButton().should('exist');
|
||||||
mainSidebar.actions.signout();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
85
cypress/fixtures/Switch_node_with_null_connection.json
Normal file
85
cypress/fixtures/Switch_node_with_null_connection.json
Normal 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": {}
|
||||||
|
}
|
|
@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
|
||||||
|
|
||||||
# Setup the Task Runner Launcher
|
# Setup the Task Runner Launcher
|
||||||
ARG TARGETPLATFORM
|
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
|
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
|
||||||
# Download, verify, then extract the launcher binary
|
# Download, verify, then extract the launcher binary
|
||||||
RUN \
|
RUN \
|
||||||
|
|
|
@ -24,7 +24,7 @@ RUN set -eux; \
|
||||||
|
|
||||||
# Setup the Task Runner Launcher
|
# Setup the Task Runner Launcher
|
||||||
ARG TARGETPLATFORM
|
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
|
COPY n8n-task-runners.json /etc/n8n-task-runners.json
|
||||||
# Download, verify, then extract the launcher binary
|
# Download, verify, then extract the launcher binary
|
||||||
RUN \
|
RUN \
|
||||||
|
|
|
@ -7,13 +7,14 @@
|
||||||
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
|
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
|
||||||
"allowed-env": [
|
"allowed-env": [
|
||||||
"PATH",
|
"PATH",
|
||||||
|
"GENERIC_TIMEZONE",
|
||||||
"N8N_RUNNERS_GRANT_TOKEN",
|
"N8N_RUNNERS_GRANT_TOKEN",
|
||||||
"N8N_RUNNERS_N8N_URI",
|
"N8N_RUNNERS_TASK_BROKER_URI",
|
||||||
"N8N_RUNNERS_MAX_PAYLOAD",
|
"N8N_RUNNERS_MAX_PAYLOAD",
|
||||||
"N8N_RUNNERS_MAX_CONCURRENCY",
|
"N8N_RUNNERS_MAX_CONCURRENCY",
|
||||||
"N8N_RUNNERS_SERVER_ENABLED",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
|
||||||
"N8N_RUNNERS_SERVER_HOST",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
|
||||||
"N8N_RUNNERS_SERVER_PORT",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
|
||||||
"NODE_FUNCTION_ALLOW_BUILTIN",
|
"NODE_FUNCTION_ALLOW_BUILTIN",
|
||||||
"NODE_FUNCTION_ALLOW_EXTERNAL",
|
"NODE_FUNCTION_ALLOW_EXTERNAL",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.15",
|
"node": ">=20.15",
|
||||||
|
@ -80,7 +80,7 @@
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vue-tsc": "^2.1.6",
|
"vue-tsc": "^2.1.10",
|
||||||
"ws": ">=8.17.1"
|
"ws": ">=8.17.1"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
@ -90,7 +90,7 @@
|
||||||
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
|
"@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/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
|
||||||
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat",
|
"name": "@n8n/chat",
|
||||||
"version": "0.31.0",
|
"version": "0.32.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm run storybook",
|
"dev": "pnpm run storybook",
|
||||||
"build": "pnpm build:vite && pnpm build:bundle",
|
"build": "pnpm build:vite && pnpm build:bundle",
|
||||||
|
@ -46,11 +46,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/mdi": "^1.1.54",
|
"@iconify-json/mdi": "^1.1.54",
|
||||||
"@n8n/storybook": "workspace:*",
|
"@n8n/storybook": "workspace:*",
|
||||||
|
"@vitejs/plugin-vue": "catalog:frontend",
|
||||||
"@vitest/coverage-v8": "catalog:frontend",
|
"@vitest/coverage-v8": "catalog:frontend",
|
||||||
"unplugin-icons": "^0.19.0",
|
"unplugin-icons": "^0.19.0",
|
||||||
"vite": "catalog:frontend",
|
"vite": "catalog:frontend",
|
||||||
"vitest": "catalog:frontend",
|
"vitest": "catalog:frontend",
|
||||||
"vite-plugin-dts": "^4.2.3",
|
"vite-plugin-dts": "^4.3.0",
|
||||||
"vue-tsc": "catalog:frontend"
|
"vue-tsc": "catalog:frontend"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "1.20.0",
|
"version": "1.21.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -7,5 +7,5 @@ export type FrontendBetaFeatures = 'canvas_v2';
|
||||||
export class FrontendConfig {
|
export class FrontendConfig {
|
||||||
/** Which UI experiments to enable. Separate multiple values with a comma `,` */
|
/** Which UI experiments to enable. Separate multiple values with a comma `,` */
|
||||||
@Env('N8N_UI_BETA_FEATURES')
|
@Env('N8N_UI_BETA_FEATURES')
|
||||||
betaFeatures: StringArray<FrontendBetaFeatures> = [];
|
betaFeatures: StringArray<FrontendBetaFeatures> = ['canvas_v2'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ export class TaskRunnersConfig {
|
||||||
authToken: string = '';
|
authToken: string = '';
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners server should listen on */
|
||||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||||
port: number = 5679;
|
port: number = 5679;
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners server should listen on */
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/imap",
|
"name": "@n8n/imap",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/json-schema-to-zod",
|
"name": "@n8n/json-schema-to-zod",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "Converts JSON schema objects into Zod schemas",
|
"description": "Converts JSON schema objects into Zod schemas",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
"main": "./dist/cjs/index.js",
|
"main": "./dist/cjs/index.js",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -3,19 +3,19 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^2.0.2",
|
"@chromatic-com/storybook": "^3.2.2",
|
||||||
"@storybook/addon-a11y": "^8.3.5",
|
"@storybook/addon-a11y": "^8.4.6",
|
||||||
"@storybook/addon-actions": "^8.3.5",
|
"@storybook/addon-actions": "^8.4.6",
|
||||||
"@storybook/addon-docs": "^8.3.5",
|
"@storybook/addon-docs": "^8.4.6",
|
||||||
"@storybook/addon-essentials": "^8.3.5",
|
"@storybook/addon-essentials": "^8.4.6",
|
||||||
"@storybook/addon-interactions": "^8.3.5",
|
"@storybook/addon-interactions": "^8.4.6",
|
||||||
"@storybook/addon-links": "^8.3.5",
|
"@storybook/addon-links": "^8.4.6",
|
||||||
"@storybook/addon-themes": "^8.3.5",
|
"@storybook/addon-themes": "^8.4.6",
|
||||||
"@storybook/blocks": "^8.3.5",
|
"@storybook/blocks": "^8.4.6",
|
||||||
"@storybook/test": "^8.3.5",
|
"@storybook/test": "^8.4.6",
|
||||||
"@storybook/vue3": "^8.3.5",
|
"@storybook/vue3": "^8.4.6",
|
||||||
"@storybook/vue3-vite": "^8.3.5",
|
"@storybook/vue3-vite": "^8.4.6",
|
||||||
"chromatic": "^11.10.2",
|
"chromatic": "^11.20.0",
|
||||||
"storybook": "^8.3.5"
|
"storybook": "^8.4.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/task-runner",
|
"name": "@n8n/task-runner",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"start": "node dist/start.js",
|
"start": "node dist/start.js",
|
||||||
|
|
|
@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
class HealthcheckServerConfig {
|
class HealthcheckServerConfig {
|
||||||
@Env('N8N_RUNNERS_SERVER_ENABLED')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
|
||||||
enabled: boolean = false;
|
enabled: boolean = false;
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_SERVER_HOST')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
|
||||||
host: string = '127.0.0.1';
|
host: string = '127.0.0.1';
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||||
port: number = 5681;
|
port: number = 5681;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
export class BaseRunnerConfig {
|
export class BaseRunnerConfig {
|
||||||
@Env('N8N_RUNNERS_N8N_URI')
|
@Env('N8N_RUNNERS_TASK_BROKER_URI')
|
||||||
n8nUri: string = '127.0.0.1:5679';
|
taskBrokerUri: string = 'http://127.0.0.1:5679';
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_GRANT_TOKEN')
|
@Env('N8N_RUNNERS_GRANT_TOKEN')
|
||||||
grantToken: string = '';
|
grantToken: string = '';
|
||||||
|
@ -34,6 +34,9 @@ export class BaseRunnerConfig {
|
||||||
@Env('N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT')
|
@Env('N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT')
|
||||||
idleTimeout: number = 0;
|
idleTimeout: number = 0;
|
||||||
|
|
||||||
|
@Env('GENERIC_TIMEZONE')
|
||||||
|
timezone: string = 'America/New_York';
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
healthcheckServer!: HealthcheckServerConfig;
|
healthcheckServer!: HealthcheckServerConfig;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { DateTime } from 'luxon';
|
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 fs from 'node:fs';
|
||||||
import { builtinModules } from 'node:module';
|
import { builtinModules } from 'node:module';
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
|
||||||
...defaultConfig.baseRunnerConfig,
|
...defaultConfig.baseRunnerConfig,
|
||||||
grantToken: 'grantToken',
|
grantToken: 'grantToken',
|
||||||
maxConcurrency: 1,
|
maxConcurrency: 1,
|
||||||
n8nUri: 'localhost',
|
taskBrokerUri: 'http://localhost',
|
||||||
...baseRunnerOpts,
|
...baseRunnerOpts,
|
||||||
},
|
},
|
||||||
jsRunnerConfig: {
|
jsRunnerConfig: {
|
||||||
|
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not expose task runner's env variables even if no env state is received", async () => {
|
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({
|
const outcome = await execTaskWithParams({
|
||||||
task: newTaskWithSettings({
|
task: newTaskWithSettings({
|
||||||
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
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 () => {
|
it('should allow access to Node.js Buffers', async () => {
|
||||||
const outcomeAll = await execTaskWithParams({
|
const outcomeAll = await execTaskWithParams({
|
||||||
task: newTaskWithSettings({
|
task: newTaskWithSettings({
|
||||||
|
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -17,7 +17,7 @@ describe('BuiltInsParser', () => {
|
||||||
const parseAndExpectOk = (code: string) => {
|
const parseAndExpectOk = (code: string) => {
|
||||||
const result = parser.parseUsedBuiltIns(code);
|
const result = parser.parseUsedBuiltIns(code);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
fail(result.error);
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.result;
|
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('ECMAScript syntax', () => {
|
||||||
describe('ES2020', () => {
|
describe('ES2020', () => {
|
||||||
it('should parse optional chaining', () => {
|
it('should parse optional chaining', () => {
|
||||||
|
|
|
@ -125,6 +125,11 @@ export class BuiltInsParser {
|
||||||
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
||||||
if (node.name === '$env') {
|
if (node.name === '$env') {
|
||||||
state.markEnvAsNeeded();
|
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 (
|
} else if (
|
||||||
node.name === '$input' ||
|
node.name === '$input' ||
|
||||||
node.name === '$json' ||
|
node.name === '$json' ||
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ensureError } from 'n8n-workflow';
|
import { ensureError, setGlobalState } from 'n8n-workflow';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import { MainConfig } from './config/main-config';
|
import { MainConfig } from './config/main-config';
|
||||||
|
@ -44,6 +44,10 @@ function createSignalHandler(signal: string) {
|
||||||
void (async function start() {
|
void (async function start() {
|
||||||
const config = Container.get(MainConfig);
|
const config = Container.get(MainConfig);
|
||||||
|
|
||||||
|
setGlobalState({
|
||||||
|
defaultTimezone: config.baseRunnerConfig.timezone,
|
||||||
|
});
|
||||||
|
|
||||||
if (config.sentryConfig.sentryDsn) {
|
if (config.sentryConfig.sentryDsn) {
|
||||||
const { ErrorReporter } = await import('@/error-reporter');
|
const { ErrorReporter } = await import('@/error-reporter');
|
||||||
errorReporter = new ErrorReporter(config.sentryConfig);
|
errorReporter = new ErrorReporter(config.sentryConfig);
|
||||||
|
|
|
@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
|
||||||
this.maxConcurrency = opts.maxConcurrency;
|
this.maxConcurrency = opts.maxConcurrency;
|
||||||
this.idleTimeout = opts.idleTimeout;
|
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, {
|
this.ws = new WebSocket(wsUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${opts.grantToken}`,
|
authorization: `Bearer ${opts.grantToken}`,
|
||||||
|
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
|
||||||
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
||||||
) {
|
) {
|
||||||
console.error(
|
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);
|
process.exit(1);
|
||||||
} else {
|
} 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');
|
console.error('Details:', event.message || 'Unknown error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -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 Container from 'typedi';
|
||||||
|
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
|
@ -6,6 +25,7 @@ import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
|
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
|
import { PermissionChecker } from '@/user-management/permission-checker';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { createExecution } from '@test-integration/db/executions';
|
import { createExecution } from '@test-integration/db/executions';
|
||||||
|
@ -43,8 +63,10 @@ afterAll(() => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await testDb.truncate(['Workflow', 'SharedWorkflow']);
|
await testDb.truncate(['Workflow', 'SharedWorkflow']);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('processError', () => {
|
||||||
test('processError should return early in Bull stalled edge case', async () => {
|
test('processError should return early in Bull stalled edge case', async () => {
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
const execution = await createExecution(
|
const execution = await createExecution(
|
||||||
|
@ -101,3 +123,78 @@ test('processError should process error', async () => {
|
||||||
);
|
);
|
||||||
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
|
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -86,13 +86,24 @@ export class MFAController {
|
||||||
@Post('/disable', { rateLimit: true })
|
@Post('/disable', { rateLimit: true })
|
||||||
async disableMFA(req: MFA.Disable) {
|
async disableMFA(req: MFA.Disable) {
|
||||||
const { id: userId } = req.user;
|
const { id: userId } = req.user;
|
||||||
const { mfaCode = null } = req.body;
|
|
||||||
|
|
||||||
if (typeof mfaCode !== 'string' || !mfaCode) {
|
const { mfaCode, mfaRecoveryCode } = req.body;
|
||||||
throw new BadRequestError('Token is required to disable MFA feature');
|
|
||||||
|
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 })
|
@Post('/verify', { rateLimit: true })
|
||||||
|
|
|
@ -38,7 +38,7 @@ export class PurgeInvalidWorkflowConnections1675940580449 implements Irreversibl
|
||||||
|
|
||||||
// It filters out all connections that are connected to a node that cannot receive input
|
// It filters out all connections that are connected to a node that cannot receive input
|
||||||
outputConnection.forEach((outputConnectionItem, outputConnectionItemIdx) => {
|
outputConnection.forEach((outputConnectionItem, outputConnectionItemIdx) => {
|
||||||
outputConnection[outputConnectionItemIdx] = outputConnectionItem.filter(
|
outputConnection[outputConnectionItemIdx] = (outputConnectionItem ?? []).filter(
|
||||||
(outgoingConnections) =>
|
(outgoingConnections) =>
|
||||||
!nodesThatCannotReceiveInput.includes(outgoingConnections.node),
|
!nodesThatCannotReceiveInput.includes(outgoingConnections.node),
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ForbiddenError } from './forbidden.error';
|
||||||
|
|
||||||
|
export class InvalidMfaRecoveryCodeError extends ForbiddenError {
|
||||||
|
constructor(hint?: string) {
|
||||||
|
super('Invalid MFA recovery code', hint);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
import { AuthUserRepository } from '@/databases/repositories/auth-user.repository';
|
||||||
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
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';
|
import { TOTPService } from './totp.service';
|
||||||
|
|
||||||
|
@ -85,12 +86,27 @@ export class MfaService {
|
||||||
return await this.authUserRepository.save(user);
|
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);
|
const isValidToken = await this.validateMfa(userId, mfaCode, undefined);
|
||||||
|
|
||||||
if (!isValidToken) {
|
if (!isValidToken) {
|
||||||
throw new InvalidMfaCodeError();
|
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, {
|
await this.authUserRepository.update(userId, {
|
||||||
mfaEnabled: false,
|
mfaEnabled: false,
|
||||||
mfaSecret: null,
|
mfaSecret: null,
|
||||||
|
|
|
@ -318,7 +318,7 @@ export type LoginRequest = AuthlessRequest<
|
||||||
export declare namespace MFA {
|
export declare namespace MFA {
|
||||||
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
type Verify = AuthenticatedRequest<{}, {}, { mfaCode: string }, {}>;
|
||||||
type Activate = 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 Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>;
|
||||||
type ValidateRecoveryCode = AuthenticatedRequest<
|
type ValidateRecoveryCode = AuthenticatedRequest<
|
||||||
{},
|
{},
|
||||||
|
|
|
@ -76,6 +76,7 @@ describe('TaskRunnerProcess', () => {
|
||||||
'N8N_VERSION',
|
'N8N_VERSION',
|
||||||
'ENVIRONMENT',
|
'ENVIRONMENT',
|
||||||
'DEPLOYMENT_NAME',
|
'DEPLOYMENT_NAME',
|
||||||
|
'GENERIC_TIMEZONE',
|
||||||
])('should propagate %s from env as is', async (envVar) => {
|
])('should propagate %s from env as is', async (envVar) => {
|
||||||
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
|
jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken');
|
||||||
process.env[envVar] = 'custom value';
|
process.env[envVar] = 'custom value';
|
||||||
|
|
|
@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
|
|
||||||
private readonly passthroughEnvVars = [
|
private readonly passthroughEnvVars = [
|
||||||
'PATH',
|
'PATH',
|
||||||
|
'GENERIC_TIMEZONE',
|
||||||
'NODE_FUNCTION_ALLOW_BUILTIN',
|
'NODE_FUNCTION_ALLOW_BUILTIN',
|
||||||
'NODE_FUNCTION_ALLOW_EXTERNAL',
|
'NODE_FUNCTION_ALLOW_EXTERNAL',
|
||||||
'N8N_SENTRY_DSN',
|
'N8N_SENTRY_DSN',
|
||||||
|
@ -94,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
|
|
||||||
const grantToken = await this.authService.createGrantToken();
|
const grantToken = await this.authService.createGrantToken();
|
||||||
|
|
||||||
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`;
|
const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
|
||||||
this.process = this.startNode(grantToken, n8nUri);
|
this.process = this.startNode(grantToken, taskBrokerUri);
|
||||||
|
|
||||||
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
|
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
|
||||||
|
|
||||||
this.monitorProcess(this.process);
|
this.monitorProcess(this.process);
|
||||||
}
|
}
|
||||||
|
|
||||||
startNode(grantToken: string, n8nUri: string) {
|
startNode(grantToken: string, taskBrokerUri: string) {
|
||||||
const startScript = require.resolve('@n8n/task-runner/start');
|
const startScript = require.resolve('@n8n/task-runner/start');
|
||||||
|
|
||||||
return spawn('node', [startScript], {
|
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> = {
|
const envVars: Record<string, string> = {
|
||||||
N8N_RUNNERS_GRANT_TOKEN: grantToken,
|
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_PAYLOAD: this.runnerConfig.maxPayload.toString(),
|
||||||
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
|
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
|
||||||
...this.getPassthroughEnvVars(),
|
...this.getPassthroughEnvVars(),
|
||||||
|
|
|
@ -119,7 +119,7 @@ export class InstanceRiskReporter implements RiskReporter {
|
||||||
node: WorkflowEntity['nodes'][number];
|
node: WorkflowEntity['nodes'][number];
|
||||||
workflow: WorkflowEntity;
|
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;
|
if (!childNodeNames) return false;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import type * as express from 'express';
|
import type * as express from 'express';
|
||||||
import { mock } from 'jest-mock-extended';
|
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 { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { generateNanoId } from '@/databases/utils/generators';
|
import { generateNanoId } from '@/databases/utils/generators';
|
||||||
|
@ -43,12 +48,16 @@ describe('TestWebhooks', () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('needsWebhook()', () => {
|
describe('needsWebhook()', () => {
|
||||||
const args: Parameters<typeof testWebhooks.needsWebhook> = [
|
const args: Parameters<typeof testWebhooks.needsWebhook>[0] = {
|
||||||
userId,
|
userId,
|
||||||
workflowEntity,
|
workflowEntity,
|
||||||
mock<IWorkflowExecuteAdditionalData>(),
|
additionalData: mock<IWorkflowExecuteAdditionalData>(),
|
||||||
];
|
};
|
||||||
|
|
||||||
test('if webhook is needed, should register then create webhook and return true', async () => {
|
test('if webhook is needed, should register then create webhook and return true', async () => {
|
||||||
const workflow = mock<Workflow>();
|
const workflow = mock<Workflow>();
|
||||||
|
@ -56,7 +65,7 @@ describe('TestWebhooks', () => {
|
||||||
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
|
jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow);
|
||||||
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
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 [registerOrder] = registrations.register.mock.invocationCallOrder;
|
||||||
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
|
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
|
||||||
|
@ -72,7 +81,7 @@ describe('TestWebhooks', () => {
|
||||||
jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg));
|
jest.spyOn(registrations, 'register').mockRejectedValueOnce(new Error(msg));
|
||||||
registrations.getAllRegistrations.mockResolvedValue([]);
|
registrations.getAllRegistrations.mockResolvedValue([]);
|
||||||
|
|
||||||
const needsWebhook = testWebhooks.needsWebhook(...args);
|
const needsWebhook = testWebhooks.needsWebhook(args);
|
||||||
|
|
||||||
await expect(needsWebhook).rejects.toThrowError(msg);
|
await expect(needsWebhook).rejects.toThrowError(msg);
|
||||||
});
|
});
|
||||||
|
@ -81,10 +90,55 @@ describe('TestWebhooks', () => {
|
||||||
webhook.webhookDescription.restartWebhook = true;
|
webhook.webhookDescription.restartWebhook = true;
|
||||||
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
jest.spyOn(WebhookHelpers, 'getWorkflowWebhooks').mockReturnValue([webhook]);
|
||||||
|
|
||||||
const result = await testWebhooks.needsWebhook(...args);
|
const result = await testWebhooks.needsWebhook(args);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
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()', () => {
|
describe('executeWebhook()', () => {
|
||||||
|
|
|
@ -23,6 +23,7 @@ import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrati
|
||||||
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service';
|
||||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
|
import type { WorkflowRequest } from '@/workflows/workflow.request';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IWebhookResponseCallbackData,
|
IWebhookResponseCallbackData,
|
||||||
|
@ -218,25 +219,48 @@ export class TestWebhooks implements IWebhookManager {
|
||||||
* Return whether activating a workflow requires listening for webhook calls.
|
* Return whether activating a workflow requires listening for webhook calls.
|
||||||
* For every webhook call to listen for, also activate the webhook.
|
* For every webhook call to listen for, also activate the webhook.
|
||||||
*/
|
*/
|
||||||
async needsWebhook(
|
async needsWebhook(options: {
|
||||||
userId: string,
|
userId: string;
|
||||||
workflowEntity: IWorkflowDb,
|
workflowEntity: IWorkflowDb;
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData;
|
||||||
runData?: IRunData,
|
runData?: IRunData;
|
||||||
pushRef?: string,
|
pushRef?: string;
|
||||||
destinationNode?: string,
|
destinationNode?: string;
|
||||||
) {
|
triggerToStartFrom?: WorkflowRequest.ManualRunPayload['triggerToStartFrom'];
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
workflowEntity,
|
||||||
|
additionalData,
|
||||||
|
runData,
|
||||||
|
pushRef,
|
||||||
|
destinationNode,
|
||||||
|
triggerToStartFrom,
|
||||||
|
} = options;
|
||||||
|
|
||||||
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
|
if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity);
|
||||||
|
|
||||||
const workflow = this.toWorkflow(workflowEntity);
|
const workflow = this.toWorkflow(workflowEntity);
|
||||||
|
|
||||||
const webhooks = WebhookHelpers.getWorkflowWebhooks(
|
let webhooks = WebhookHelpers.getWorkflowWebhooks(
|
||||||
workflow,
|
workflow,
|
||||||
additionalData,
|
additionalData,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
true,
|
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)) {
|
if (!webhooks.some((w) => w.webhookDescription.restartWebhook !== true)) {
|
||||||
return false; // no webhooks found to start a workflow
|
return false; // no webhooks found to start a workflow
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,14 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
/* eslint-disable @typescript-eslint/no-shadow */
|
/* eslint-disable @typescript-eslint/no-shadow */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* 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 {
|
import type {
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
IDeferredPromise,
|
IDeferredPromise,
|
||||||
|
@ -12,6 +19,7 @@ import type {
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
WorkflowHooks,
|
WorkflowHooks,
|
||||||
IWorkflowExecutionDataProcess,
|
IWorkflowExecutionDataProcess,
|
||||||
|
IRunExecutionData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
ErrorReporterProxy as ErrorReporter,
|
ErrorReporterProxy as ErrorReporter,
|
||||||
|
@ -203,6 +211,7 @@ export class WorkflowRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run the workflow in current process */
|
/** Run the workflow in current process */
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
private async runMainProcess(
|
private async runMainProcess(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
data: IWorkflowExecutionDataProcess,
|
data: IWorkflowExecutionDataProcess,
|
||||||
|
@ -286,12 +295,50 @@ export class WorkflowRunner {
|
||||||
data.executionData,
|
data.executionData,
|
||||||
);
|
);
|
||||||
workflowExecution = workflowExecute.processRunExecutionData(workflow);
|
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 (
|
} else if (
|
||||||
data.runData === undefined ||
|
data.runData === undefined ||
|
||||||
data.startNodes === undefined ||
|
data.startNodes === undefined ||
|
||||||
data.startNodes.length === 0
|
data.startNodes.length === 0
|
||||||
) {
|
) {
|
||||||
// Full Execution
|
// 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.`, {
|
this.logger.debug(`Execution ID ${executionId} will run executing all nodes.`, {
|
||||||
executionId,
|
executionId,
|
||||||
});
|
});
|
||||||
|
|
|
@ -95,6 +95,7 @@ export class WorkflowExecutionService {
|
||||||
startNodes,
|
startNodes,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
dirtyNodeNames,
|
dirtyNodeNames,
|
||||||
|
triggerToStartFrom,
|
||||||
}: WorkflowRequest.ManualRunPayload,
|
}: WorkflowRequest.ManualRunPayload,
|
||||||
user: User,
|
user: User,
|
||||||
pushRef?: string,
|
pushRef?: string,
|
||||||
|
@ -117,14 +118,15 @@ export class WorkflowExecutionService {
|
||||||
) {
|
) {
|
||||||
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
|
const additionalData = await WorkflowExecuteAdditionalData.getBase(user.id);
|
||||||
|
|
||||||
const needsWebhook = await this.testWebhooks.needsWebhook(
|
const needsWebhook = await this.testWebhooks.needsWebhook({
|
||||||
user.id,
|
userId: user.id,
|
||||||
workflowData,
|
workflowEntity: workflowData,
|
||||||
additionalData,
|
additionalData,
|
||||||
runData,
|
runData,
|
||||||
pushRef,
|
pushRef,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
);
|
triggerToStartFrom,
|
||||||
|
});
|
||||||
|
|
||||||
if (needsWebhook) return { waitingForWebhook: true };
|
if (needsWebhook) return { waitingForWebhook: true };
|
||||||
}
|
}
|
||||||
|
@ -144,6 +146,7 @@ export class WorkflowExecutionService {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
partialExecutionVersion: partialExecutionVersion ?? '0',
|
partialExecutionVersion: partialExecutionVersion ?? '0',
|
||||||
dirtyNodeNames,
|
dirtyNodeNames,
|
||||||
|
triggerToStartFrom,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
||||||
|
|
|
@ -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 { IWorkflowDb } from '@/interfaces';
|
||||||
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
||||||
|
@ -23,6 +30,10 @@ export declare namespace WorkflowRequest {
|
||||||
startNodes?: StartNodeData[];
|
startNodes?: StartNodeData[];
|
||||||
destinationNode?: string;
|
destinationNode?: string;
|
||||||
dirtyNodeNames?: string[];
|
dirtyNodeNames?: string[];
|
||||||
|
triggerToStartFrom?: {
|
||||||
|
name: string;
|
||||||
|
data?: ITaskData;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
||||||
|
|
|
@ -184,7 +184,19 @@ describe('Disable MFA setup', () => {
|
||||||
expect(dbUser.mfaRecoveryCodes.length).toBe(0);
|
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();
|
const { user } = await createUserWithMfaEnabled();
|
||||||
|
|
||||||
await testServer
|
await testServer
|
||||||
|
@ -195,6 +207,12 @@ describe('Disable MFA setup', () => {
|
||||||
})
|
})
|
||||||
.expect(403);
|
.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', () => {
|
describe('Change password with MFA enabled', () => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-core",
|
"name": "n8n-core",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "Core functionality of n8n",
|
"description": "Core functionality of n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
@ -448,7 +448,7 @@ export class DirectedGraph {
|
||||||
|
|
||||||
for (const [outputType, outputs] of Object.entries(iConnection)) {
|
for (const [outputType, outputs] of Object.entries(iConnection)) {
|
||||||
for (const [outputIndex, conns] of outputs.entries()) {
|
for (const [outputIndex, conns] of outputs.entries()) {
|
||||||
for (const conn of conns) {
|
for (const conn of conns ?? []) {
|
||||||
// TODO: What's with the input type?
|
// TODO: What's with the input type?
|
||||||
const { node: toNodeName, type: _inputType, index: inputIndex } = conn;
|
const { node: toNodeName, type: _inputType, index: inputIndex } = conn;
|
||||||
const to = workflow.getNode(toNodeName);
|
const to = workflow.getNode(toNodeName);
|
||||||
|
|
|
@ -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', () => {
|
describe('getChildren', () => {
|
||||||
// ┌─────┐ ┌─────┐ ┌─────┐
|
// ┌─────┐ ┌─────┐ ┌─────┐
|
||||||
// │node1├───►│node2├──►│node3│
|
// │node1├───►│node2├──►│node3│
|
||||||
|
|
|
@ -36,6 +36,7 @@ import type {
|
||||||
CloseFunction,
|
CloseFunction,
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
NodeExecutionHint,
|
NodeExecutionHint,
|
||||||
|
NodeInputConnections,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
|
@ -208,6 +209,9 @@ export class WorkflowExecute {
|
||||||
// Get the data of the incoming connections
|
// Get the data of the incoming connections
|
||||||
incomingSourceData = { main: [] };
|
incomingSourceData = { main: [] };
|
||||||
for (const connections of incomingNodeConnections.main) {
|
for (const connections of incomingNodeConnections.main) {
|
||||||
|
if (!connections) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
|
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
|
||||||
connection = connections[inputIndex];
|
connection = connections[inputIndex];
|
||||||
|
|
||||||
|
@ -249,6 +253,9 @@ export class WorkflowExecute {
|
||||||
incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode];
|
incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode];
|
||||||
if (incomingNodeConnections !== undefined) {
|
if (incomingNodeConnections !== undefined) {
|
||||||
for (const connections of incomingNodeConnections.main) {
|
for (const connections of incomingNodeConnections.main) {
|
||||||
|
if (!connections) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
|
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
|
||||||
connection = connections[inputIndex];
|
connection = connections[inputIndex];
|
||||||
|
|
||||||
|
@ -642,7 +649,7 @@ export class WorkflowExecute {
|
||||||
}
|
}
|
||||||
for (const connectionDataCheck of workflow.connectionsBySourceNode[parentNodeName].main[
|
for (const connectionDataCheck of workflow.connectionsBySourceNode[parentNodeName].main[
|
||||||
outputIndexParent
|
outputIndexParent
|
||||||
]) {
|
] ?? []) {
|
||||||
checkOutputNodes.push(connectionDataCheck.node);
|
checkOutputNodes.push(connectionDataCheck.node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -661,7 +668,7 @@ export class WorkflowExecute {
|
||||||
) {
|
) {
|
||||||
for (const inputData of workflow.connectionsByDestinationNode[connectionData.node].main[
|
for (const inputData of workflow.connectionsByDestinationNode[connectionData.node].main[
|
||||||
inputIndex
|
inputIndex
|
||||||
]) {
|
] ?? []) {
|
||||||
if (inputData.node === parentNodeName) {
|
if (inputData.node === parentNodeName) {
|
||||||
// Is the node we come from so its data will be available for sure
|
// Is the node we come from so its data will be available for sure
|
||||||
continue;
|
continue;
|
||||||
|
@ -681,7 +688,7 @@ export class WorkflowExecute {
|
||||||
if (
|
if (
|
||||||
!this.incomingConnectionIsEmpty(
|
!this.incomingConnectionIsEmpty(
|
||||||
this.runExecutionData.resultData.runData,
|
this.runExecutionData.resultData.runData,
|
||||||
workflow.connectionsByDestinationNode[inputData.node].main[0],
|
workflow.connectionsByDestinationNode[inputData.node].main[0] ?? [],
|
||||||
runIndex,
|
runIndex,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -770,7 +777,7 @@ export class WorkflowExecute {
|
||||||
} else if (
|
} else if (
|
||||||
this.incomingConnectionIsEmpty(
|
this.incomingConnectionIsEmpty(
|
||||||
this.runExecutionData.resultData.runData,
|
this.runExecutionData.resultData.runData,
|
||||||
workflow.connectionsByDestinationNode[nodeToAdd].main[0],
|
workflow.connectionsByDestinationNode[nodeToAdd].main[0] ?? [],
|
||||||
runIndex,
|
runIndex,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -1066,7 +1073,7 @@ export class WorkflowExecute {
|
||||||
if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) {
|
if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) {
|
||||||
// Check if the node has incoming connections
|
// Check if the node has incoming connections
|
||||||
if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) {
|
if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) {
|
||||||
let inputConnections: IConnection[][];
|
let inputConnections: NodeInputConnections;
|
||||||
let connectionIndex: number;
|
let connectionIndex: number;
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
|
@ -1586,7 +1593,7 @@ export class WorkflowExecute {
|
||||||
// Iterate over all the different connections of this output
|
// Iterate over all the different connections of this output
|
||||||
for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[
|
for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[
|
||||||
outputIndex
|
outputIndex
|
||||||
]) {
|
] ?? []) {
|
||||||
if (!workflow.nodes.hasOwnProperty(connectionData.node)) {
|
if (!workflow.nodes.hasOwnProperty(connectionData.node)) {
|
||||||
throw new ApplicationError('Destination node not found', {
|
throw new ApplicationError('Destination node not found', {
|
||||||
extra: {
|
extra: {
|
||||||
|
|
|
@ -21,3 +21,4 @@ export { BinaryData } from './BinaryData/types';
|
||||||
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
|
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
|
||||||
export * from './ExecutionMetadata';
|
export * from './ExecutionMetadata';
|
||||||
export * from './node-execution-context';
|
export * from './node-execution-context';
|
||||||
|
export * from './PartialExecutionUtils';
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-design-system",
|
"name": "n8n-design-system",
|
||||||
"version": "1.60.0",
|
"version": "1.61.0",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"import": "src/main.ts",
|
"import": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
"@types/markdown-it-emoji": "^2.0.2",
|
"@types/markdown-it-emoji": "^2.0.2",
|
||||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||||
"@types/sanitize-html": "^2.11.0",
|
"@types/sanitize-html": "^2.11.0",
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"@vitejs/plugin-vue": "catalog:frontend",
|
||||||
"@vitest/coverage-v8": "catalog:frontend",
|
"@vitest/coverage-v8": "catalog:frontend",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
|
|
@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
it('default slot should trigger first level', async () => {
|
it('default slot should trigger first level', async () => {
|
||||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||||
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
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: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router],
|
||||||
},
|
},
|
||||||
|
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
|
||||||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||||
|
|
||||||
expect(emitted('itemClick')).toStrictEqual([
|
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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -29,7 +29,7 @@ defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const menuRef = ref<typeof ElMenu | null>(null);
|
const menuRef = ref<typeof ElMenu | null>(null);
|
||||||
const menuIndex = ref('-1');
|
const ROOT_MENU_INDEX = '-1';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
itemClick: [item: MenuItemRegistered];
|
itemClick: [item: MenuItemRegistered];
|
||||||
|
@ -37,7 +37,18 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const close = () => {
|
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({
|
defineExpose({
|
||||||
|
@ -50,14 +61,16 @@ defineExpose({
|
||||||
ref="menuRef"
|
ref="menuRef"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
unique-opened
|
unique-opened
|
||||||
menu-trigger="click"
|
:menu-trigger="menuTrigger"
|
||||||
:ellipsis="false"
|
:ellipsis="false"
|
||||||
:class="$style.dropdown"
|
:class="$style.dropdown"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@keyup.escape="close"
|
@keyup.escape="close"
|
||||||
|
@open="onOpen"
|
||||||
|
@close="onClose"
|
||||||
>
|
>
|
||||||
<ElSubMenu
|
<ElSubMenu
|
||||||
:index="menuIndex"
|
:index="ROOT_MENU_INDEX"
|
||||||
:class="$style.trigger"
|
:class="$style.trigger"
|
||||||
:popper-offset="-10"
|
:popper-offset="-10"
|
||||||
:popper-class="$style.submenu"
|
:popper-class="$style.submenu"
|
||||||
|
@ -70,10 +83,15 @@ defineExpose({
|
||||||
|
|
||||||
<template v-for="item in menu" :key="item.id">
|
<template v-for="item in menu" :key="item.id">
|
||||||
<template v-if="item.submenu">
|
<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 #title>{{ item.title }}</template>
|
||||||
<template v-for="subitem in item.submenu" :key="subitem.id">
|
<template v-for="subitem in item.submenu" :key="subitem.id">
|
||||||
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
|
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
data-test-id="navigation-submenu-item"
|
data-test-id="navigation-submenu-item"
|
||||||
:index="subitem.id"
|
:index="subitem.id"
|
||||||
|
@ -82,18 +100,20 @@ defineExpose({
|
||||||
>
|
>
|
||||||
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
||||||
{{ subitem.title }}
|
{{ subitem.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
</template>
|
</template>
|
||||||
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
|
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
:index="item.id"
|
:index="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
data-test-id="navigation-menu-item"
|
data-test-id="navigation-menu-item"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
@ -125,17 +145,25 @@ defineExpose({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nestedSubmenu {
|
||||||
|
:global(.el-menu) {
|
||||||
|
max-height: 450px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
padding: 5px 0 !important;
|
padding: 5px 0 !important;
|
||||||
|
|
||||||
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
||||||
color: var(--color-text-dark);
|
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-menu-item:not(.is-disabled):hover),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title: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) {
|
:global(.el-popper) {
|
||||||
|
|
|
@ -462,6 +462,10 @@
|
||||||
--color-configurable-node-name: var(--color-text-dark);
|
--color-configurable-node-name: var(--color-text-dark);
|
||||||
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
||||||
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
|
--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'] {
|
body[data-theme='dark'] {
|
||||||
|
|
|
@ -533,6 +533,11 @@
|
||||||
--color-secondary-link: var(--color-secondary);
|
--color-secondary-link: var(--color-secondary);
|
||||||
--color-secondary-link-hover: var(--color-secondary-shade-1);
|
--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
|
// Generated Color Shades from 50 to 950
|
||||||
// Not yet used in design system
|
// Not yet used in design system
|
||||||
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-editor-ui",
|
"name": "n8n-editor-ui",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "Workflow Editor UI for n8n",
|
"description": "Workflow Editor UI for n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
|
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"typecheck:watch": "vue-tsc --watch --noEmit",
|
||||||
"dev": "pnpm serve",
|
"dev": "pnpm serve",
|
||||||
"lint": "eslint src --ext .js,.ts,.vue --quiet",
|
"lint": "eslint src --ext .js,.ts,.vue --quiet",
|
||||||
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
|
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
|
||||||
|
@ -94,6 +95,7 @@
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
"@types/luxon": "^3.2.0",
|
"@types/luxon": "^3.2.0",
|
||||||
"@types/uuid": "catalog:",
|
"@types/uuid": "catalog:",
|
||||||
|
"@vitejs/plugin-vue": "catalog:frontend",
|
||||||
"@vitest/coverage-v8": "catalog:frontend",
|
"@vitest/coverage-v8": "catalog:frontend",
|
||||||
"miragejs": "^0.1.48",
|
"miragejs": "^0.1.48",
|
||||||
"unplugin-icons": "^0.19.0",
|
"unplugin-icons": "^0.19.0",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
import LoadingView from '@/views/LoadingView.vue';
|
import LoadingView from '@/views/LoadingView.vue';
|
||||||
import BannerStack from '@/components/banners/BannerStack.vue';
|
import BannerStack from '@/components/banners/BannerStack.vue';
|
||||||
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
|
import AskAssistantChat from '@/components/AskAssistant/AskAssistantChat.vue';
|
||||||
|
@ -17,6 +18,11 @@ import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||||
import { useStyles } from './composables/useStyles';
|
import { useStyles } from './composables/useStyles';
|
||||||
|
|
||||||
|
// Polyfill crypto.randomUUID
|
||||||
|
if (!('randomUUID' in crypto)) {
|
||||||
|
Object.defineProperty(crypto, 'randomUUID', { value: uuid });
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
|
|
|
@ -46,6 +46,7 @@ import type {
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
IPersonalizationSurveyAnswersV4,
|
IPersonalizationSurveyAnswersV4,
|
||||||
AnnotationVote,
|
AnnotationVote,
|
||||||
|
ITaskData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -201,6 +202,10 @@ export interface IStartRunData {
|
||||||
destinationNode?: string;
|
destinationNode?: string;
|
||||||
runData?: IRunData;
|
runData?: IRunData;
|
||||||
dirtyNodeNames?: string[];
|
dirtyNodeNames?: string[];
|
||||||
|
triggerToStartFrom?: {
|
||||||
|
name: string;
|
||||||
|
data?: ITaskData;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITableData {
|
export interface ITableData {
|
||||||
|
@ -1589,7 +1594,6 @@ export type ApiKey = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InputPanel = {
|
export type InputPanel = {
|
||||||
displayMode: IRunDataDisplayMode;
|
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
run?: number;
|
run?: number;
|
||||||
branch?: number;
|
branch?: number;
|
||||||
|
@ -1600,7 +1604,6 @@ export type InputPanel = {
|
||||||
|
|
||||||
export type OutputPanel = {
|
export type OutputPanel = {
|
||||||
branch?: number;
|
branch?: number;
|
||||||
displayMode: IRunDataDisplayMode;
|
|
||||||
data: {
|
data: {
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
};
|
};
|
||||||
|
|
|
@ -26,7 +26,8 @@ export async function verifyMfaCode(
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisableMfaParams = {
|
export type DisableMfaParams = {
|
||||||
mfaCode: string;
|
mfaCode?: string;
|
||||||
|
mfaRecoveryCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise<void> {
|
||||||
|
|
|
@ -256,7 +256,7 @@ export function useChatMessaging({
|
||||||
];
|
];
|
||||||
if (!connectedMemoryInputs) return [];
|
if (!connectedMemoryInputs) return [];
|
||||||
|
|
||||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => (i ?? []).length > 0)?.[0];
|
||||||
|
|
||||||
if (!memoryConnection) return [];
|
if (!memoryConnection) return [];
|
||||||
|
|
||||||
|
|
|
@ -176,7 +176,7 @@ function useJsonFieldCompletions() {
|
||||||
if (activeNode) {
|
if (activeNode) {
|
||||||
const workflow = workflowsStore.getCurrentWorkflow();
|
const workflow = workflowsStore.getCurrentWorkflow();
|
||||||
const input = workflow.connectionsByDestinationNode[activeNode.name];
|
const input = workflow.connectionsByDestinationNode[activeNode.name];
|
||||||
return input.main[0][0].node;
|
return input.main[0] ? input.main[0][0].node : null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
@ -86,7 +86,7 @@ function getMultipleNodesText(nodeName: string): string {
|
||||||
props.workflow.connectionsByDestinationNode[activeNode.value.name].main || [];
|
props.workflow.connectionsByDestinationNode[activeNode.value.name].main || [];
|
||||||
// Collect indexes of connected nodes
|
// Collect indexes of connected nodes
|
||||||
const connectedInputIndexes = activeNodeConnections.reduce((acc: number[], node, index) => {
|
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;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
@ -191,6 +191,7 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (settingsStore.isCanvasV2Enabled) {
|
||||||
actions.push({
|
actions.push({
|
||||||
id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION,
|
id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION,
|
||||||
...(nodeViewVersion.value === '2'
|
...(nodeViewVersion.value === '2'
|
||||||
|
@ -215,6 +216,7 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||||
: locale.baseText('menuActions.switchToNewNodeViewVersion'),
|
: locale.baseText('menuActions.switchToNewNodeViewVersion'),
|
||||||
disabled: !onWorkflowPage.value,
|
disabled: !onWorkflowPage.value,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||||
actions.push({
|
actions.push({
|
||||||
|
|
|
@ -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>, () => {
|
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
createBtn.value?.close();
|
createBtn.value?.close();
|
||||||
});
|
});
|
||||||
|
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
:class="['clickable', $style.sideMenuCollapseButton]"
|
:class="['clickable', $style.sideMenuCollapseButton]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
<N8nIcon 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-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.logo">
|
<div :class="$style.logo">
|
||||||
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
||||||
|
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<N8nIconButton icon="plus" type="secondary" outline />
|
<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>
|
</N8nNavigationDropdown>
|
||||||
</div>
|
</div>
|
||||||
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectNavigation
|
<ProjectNavigation
|
||||||
:collapsed="isCollapsed"
|
:collapsed="isCollapsed"
|
||||||
|
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div :class="$style.giftContainer">
|
<div :class="$style.giftContainer">
|
||||||
<GiftNotificationIcon />
|
<GiftNotificationIcon />
|
||||||
</div>
|
</div>
|
||||||
<n8n-text
|
<N8nText
|
||||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
||||||
color="text-base"
|
color="text-base"
|
||||||
>
|
>
|
||||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
||||||
nextVersions.length > 1 ? 's' : ''
|
nextVersions.length > 1 ? 's' : ''
|
||||||
}}
|
}}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div ref="user" :class="$style.userArea">
|
<div ref="user" :class="$style.userArea">
|
||||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
<!-- 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 }">
|
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||||
<n8n-avatar
|
<N8nAvatar
|
||||||
:first-name="usersStore.currentUser?.firstName"
|
:first-name="usersStore.currentUser?.firstName"
|
||||||
:last-name="usersStore.currentUser?.lastName"
|
:last-name="usersStore.currentUser?.lastName"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isCollapsed" #dropdown>
|
<template v-if="isCollapsed" #dropdown>
|
||||||
<el-dropdown-menu>
|
<ElDropdownMenu>
|
||||||
<el-dropdown-item command="settings">
|
<ElDropdownItem command="settings">
|
||||||
{{ i18n.baseText('settings') }}
|
{{ i18n.baseText('settings') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
<el-dropdown-item command="logout">
|
<ElDropdownItem command="logout">
|
||||||
{{ i18n.baseText('auth.signout') }}
|
{{ i18n.baseText('auth.signout') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
</el-dropdown-menu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</ElDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
|
: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
|
usersStore.currentUser?.fullName
|
||||||
}}</n8n-text>
|
}}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
||||||
<n8n-action-dropdown
|
<N8nActionDropdown
|
||||||
:items="userMenuItems"
|
:items="userMenuItems"
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
data-test-id="user-menu"
|
data-test-id="user-menu"
|
||||||
|
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n8n-menu>
|
</N8nMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
|
||||||
watch(
|
watch(
|
||||||
() => props.isReadOnly,
|
() => props.isReadOnly,
|
||||||
(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 });
|
valueChanged({ name: props.path, value: props.parameter.default });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { createTestProject } from '@/__tests__/data/projects';
|
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 ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
const actual = await vi.importActual('vue-router');
|
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>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
|
|
||||||
describe('ProjectHeader', () => {
|
describe('ProjectHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
route = useRoute();
|
route = router.useRoute();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
projectsStore.teamProjectsLimit = -1;
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
|
||||||
|
|
||||||
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, type Ref, ref } from 'vue';
|
import { computed, type Ref, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
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 { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -47,9 +48,8 @@ const showSettings = computed(
|
||||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { menu, handleSelect } = useGlobalEntityCreation(
|
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
|
||||||
computed(() => !Boolean(projectsStore.currentProject)),
|
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
|
||||||
);
|
|
||||||
|
|
||||||
const createLabel = computed(() => {
|
const createLabel = computed(() => {
|
||||||
if (!projectsStore.currentProject) {
|
if (!projectsStore.currentProject) {
|
||||||
|
@ -82,9 +82,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</slot>
|
</slot>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
||||||
<div :class="$style.actions">
|
|
||||||
<ProjectTabs :show-settings="showSettings" />
|
|
||||||
<N8nNavigationDropdown
|
<N8nNavigationDropdown
|
||||||
ref="createBtn"
|
ref="createBtn"
|
||||||
data-test-id="resource-add"
|
data-test-id="resource-add"
|
||||||
|
@ -92,9 +90,29 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
@select="handleSelect"
|
@select="handleSelect"
|
||||||
>
|
>
|
||||||
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
|
<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>
|
</N8nNavigationDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div :class="$style.actions">
|
||||||
|
<ProjectTabs :show-settings="showSettings" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
border: 1px solid var(--color-foreground-light);
|
border: 1px solid var(--color-foreground-light);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { promptMfaCodeBus } from '@/event-bus';
|
import { promptMfaCodeBus } from '@/event-bus';
|
||||||
import type { IFormInputs } from '@/Interface';
|
import type { IFormInputs } from '@/Interface';
|
||||||
import { createFormEventBus } from 'n8n-design-system';
|
import { createFormEventBus } from 'n8n-design-system';
|
||||||
|
import { validate as validateUuid } from 'uuid';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
@ -14,11 +15,11 @@ const readyToSubmit = ref(false);
|
||||||
|
|
||||||
const formFields: IFormInputs = [
|
const formFields: IFormInputs = [
|
||||||
{
|
{
|
||||||
name: 'mfaCode',
|
name: 'mfaCodeOrMfaRecoveryCode',
|
||||||
initialValue: '',
|
initialValue: '',
|
||||||
properties: {
|
properties: {
|
||||||
label: i18n.baseText('mfa.code.input.label'),
|
label: i18n.baseText('mfa.code.recovery.input.label'),
|
||||||
placeholder: i18n.baseText('mfa.code.input.placeholder'),
|
placeholder: i18n.baseText('mfa.code.recovery.input.placeholder'),
|
||||||
focusInitially: true,
|
focusInitially: true,
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
required: 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', {
|
promptMfaCodeBus.emit('close', {
|
||||||
mfaCode: values.mfaCode,
|
mfaRecoveryCode: values.mfaCodeOrMfaRecoveryCode,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptMfaCodeBus.emit('close', {
|
||||||
|
mfaCode: values.mfaCodeOrMfaRecoveryCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +50,7 @@ function onFormReady(isReady: boolean) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
width="460px"
|
width="500px"
|
||||||
height="300px"
|
height="300px"
|
||||||
max-height="640px"
|
max-height="640px"
|
||||||
:title="i18n.baseText('mfa.prompt.code.modal.title')"
|
:title="i18n.baseText('mfa.prompt.code.modal.title')"
|
||||||
|
|
|
@ -406,9 +406,7 @@ describe('RunData', () => {
|
||||||
initialState: {
|
initialState: {
|
||||||
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
||||||
[STORES.NDV]: {
|
[STORES.NDV]: {
|
||||||
output: {
|
outputPanelDisplayMode: displayMode,
|
||||||
displayMode,
|
|
||||||
},
|
|
||||||
activeNodeName: 'Test Node',
|
activeNodeName: 'Test Node',
|
||||||
},
|
},
|
||||||
[STORES.WORKFLOWS]: {
|
[STORES.WORKFLOWS]: {
|
||||||
|
|
|
@ -205,8 +205,8 @@ const keyMap = computed(() => ({
|
||||||
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
|
ctrl_c: emitWithSelectedNodes((ids) => emit('copy:nodes', ids)),
|
||||||
enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)),
|
enter: emitWithLastSelectedNode((id) => onSetNodeActive(id)),
|
||||||
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
ctrl_a: () => addSelectedNodes(graphNodes.value),
|
||||||
'+|=': async () => await onZoomIn(),
|
'shift_+|+|=': async () => await onZoomIn(),
|
||||||
'-|_': async () => await onZoomOut(),
|
'shift+_|-|_': async () => await onZoomOut(),
|
||||||
0: async () => await onResetZoom(),
|
0: async () => await onResetZoom(),
|
||||||
1: async () => await onFitView(),
|
1: async () => await onFitView(),
|
||||||
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
|
ArrowUp: emitWithLastSelectedNode(selectUpperSiblingNode),
|
||||||
|
@ -215,7 +215,6 @@ const keyMap = computed(() => ({
|
||||||
ArrowRight: emitWithLastSelectedNode(selectRightNode),
|
ArrowRight: emitWithLastSelectedNode(selectRightNode),
|
||||||
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
|
shift_ArrowLeft: emitWithLastSelectedNode(selectUpstreamNodes),
|
||||||
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
|
shift_ArrowRight: emitWithLastSelectedNode(selectDownstreamNodes),
|
||||||
// @TODO implement arrow key shortcuts to modify selection
|
|
||||||
|
|
||||||
...(props.readOnly
|
...(props.readOnly
|
||||||
? {}
|
? {}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import type { Workflow } from 'n8n-workflow';
|
import type { Workflow, INode, NodeApiError } from 'n8n-workflow';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
|
|
||||||
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
import { useCanvasMapping } from '@/composables/useCanvasMapping';
|
||||||
|
@ -23,6 +23,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
|
||||||
import { MarkerType } from '@vue-flow/core';
|
import { MarkerType } from '@vue-flow/core';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const pinia = createTestingPinia({
|
const pinia = createTestingPinia({
|
||||||
|
@ -237,7 +238,7 @@ describe('useCanvasMapping', () => {
|
||||||
expect(
|
expect(
|
||||||
mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output][
|
mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output][
|
||||||
NodeConnectionType.Main
|
NodeConnectionType.Main
|
||||||
][0][0],
|
][0]?.[0],
|
||||||
).toEqual(
|
).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
node: setNode.name,
|
node: setNode.name,
|
||||||
|
@ -252,7 +253,7 @@ describe('useCanvasMapping', () => {
|
||||||
expect(
|
expect(
|
||||||
mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input][
|
mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input][
|
||||||
NodeConnectionType.Main
|
NodeConnectionType.Main
|
||||||
][0][0],
|
][0]?.[0],
|
||||||
).toEqual(
|
).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
node: manualTriggerNode.name,
|
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', () => {
|
describe('connections', () => {
|
||||||
|
|
|
@ -374,7 +374,7 @@ export function useCanvasMapping({
|
||||||
} else if (nodePinnedDataById.value[node.id]) {
|
} else if (nodePinnedDataById.value[node.id]) {
|
||||||
acc[node.id] = false;
|
acc[node.id] = false;
|
||||||
} else {
|
} else {
|
||||||
acc[node.id] = Object.keys(node?.issues ?? {}).length > 0;
|
acc[node.id] = nodeIssuesById.value[node.id].length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
|
@ -647,6 +647,8 @@ export function useCanvasMapping({
|
||||||
return {
|
return {
|
||||||
additionalNodePropertiesById,
|
additionalNodePropertiesById,
|
||||||
nodeExecutionRunDataOutputMapById,
|
nodeExecutionRunDataOutputMapById,
|
||||||
|
nodeIssuesById,
|
||||||
|
nodeHasIssuesById,
|
||||||
connections: mappedConnections,
|
connections: mappedConnections,
|
||||||
nodes: mappedNodes,
|
nodes: mappedNodes,
|
||||||
};
|
};
|
||||||
|
|
|
@ -802,6 +802,82 @@ describe('useCanvasOperations', () => {
|
||||||
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id);
|
expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id);
|
||||||
expect(workflowsStore.removeNodeById).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', () => {
|
describe('revertDeleteNode', () => {
|
||||||
|
|
|
@ -1149,11 +1149,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
|
|
||||||
for (const type of Object.keys(connections[nodeName])) {
|
for (const type of Object.keys(connections[nodeName])) {
|
||||||
for (const index of Object.keys(connections[nodeName][type])) {
|
for (const index of Object.keys(connections[nodeName][type])) {
|
||||||
for (const connectionIndex of Object.keys(
|
const connectionsToDelete = connections[nodeName][type][parseInt(index, 10)] ?? [];
|
||||||
connections[nodeName][type][parseInt(index, 10)],
|
for (const connectionIndex of Object.keys(connectionsToDelete)) {
|
||||||
)) {
|
const connectionData = connectionsToDelete[parseInt(connectionIndex, 10)];
|
||||||
const connectionData =
|
|
||||||
connections[nodeName][type][parseInt(index, 10)][parseInt(connectionIndex, 10)];
|
|
||||||
if (!connectionData) {
|
if (!connectionData) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -1490,13 +1488,14 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
sourceIndex++
|
sourceIndex++
|
||||||
) {
|
) {
|
||||||
const nodeSourceConnections = [];
|
const nodeSourceConnections = [];
|
||||||
if (currentConnections[sourceNode][type][sourceIndex]) {
|
const connectionsToCheck = currentConnections[sourceNode][type][sourceIndex];
|
||||||
|
if (connectionsToCheck) {
|
||||||
for (
|
for (
|
||||||
connectionIndex = 0;
|
connectionIndex = 0;
|
||||||
connectionIndex < currentConnections[sourceNode][type][sourceIndex].length;
|
connectionIndex < connectionsToCheck.length;
|
||||||
connectionIndex++
|
connectionIndex++
|
||||||
) {
|
) {
|
||||||
connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex];
|
connectionData = connectionsToCheck[connectionIndex];
|
||||||
if (!createNodeNames.includes(connectionData.node)) {
|
if (!createNodeNames.includes(connectionData.node)) {
|
||||||
// Node does not get created so skip input connection
|
// Node does not get created so skip input connection
|
||||||
continue;
|
continue;
|
||||||
|
@ -1814,7 +1813,7 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
|
|
||||||
for (const [type, typeConnections] of Object.entries(connections)) {
|
for (const [type, typeConnections] of Object.entries(connections)) {
|
||||||
const validConnections = typeConnections.map((sourceConnections) =>
|
const validConnections = typeConnections.map((sourceConnections) =>
|
||||||
sourceConnections.filter((connection) => includeNodeNames.has(connection.node)),
|
(sourceConnections ?? []).filter((connection) => includeNodeNames.has(connection.node)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validConnections.length) {
|
if (validConnections.length) {
|
||||||
|
|
|
@ -1,14 +1,84 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useExecutionDebugging } from './useExecutionDebugging';
|
import { useExecutionDebugging } from './useExecutionDebugging';
|
||||||
import type { INodeUi, IExecutionResponse } from '@/Interface';
|
import type { INodeUi, IExecutionResponse } from '@/Interface';
|
||||||
import type { Workflow } from 'n8n-workflow';
|
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()', () => {
|
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 () => {
|
it('should applyExecutionData', async () => {
|
||||||
setActivePinia(createTestingPinia());
|
|
||||||
const mockExecution = {
|
const mockExecution = {
|
||||||
data: {
|
data: {
|
||||||
resultData: {
|
resultData: {
|
||||||
|
@ -31,10 +101,9 @@ describe('useExecutionDebugging()', () => {
|
||||||
getParentNodes: vi.fn().mockReturnValue([]),
|
getParentNodes: vi.fn().mockReturnValue([]),
|
||||||
} as unknown as Workflow);
|
} as unknown as Workflow);
|
||||||
|
|
||||||
const { applyExecutionData } = useExecutionDebugging();
|
await executionDebugging.applyExecutionData('1');
|
||||||
|
|
||||||
await applyExecutionData('1');
|
|
||||||
|
|
||||||
expect(workflowStore.setWorkflowExecutionData).toHaveBeenCalledWith(mockExecution);
|
expect(workflowStore.setWorkflowExecutionData).toHaveBeenCalledWith(mockExecution);
|
||||||
|
expect(toast.showToast).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -108,7 +108,7 @@ export const useExecutionDebugging = () => {
|
||||||
let pinnings = 0;
|
let pinnings = 0;
|
||||||
|
|
||||||
pinnableNodes.forEach((node: INodeUi) => {
|
pinnableNodes.forEach((node: INodeUi) => {
|
||||||
const nodeData = runData[node.name]?.[0].data?.main?.[0];
|
const nodeData = runData[node.name]?.[0]?.data?.main?.[0];
|
||||||
if (nodeData) {
|
if (nodeData) {
|
||||||
pinnings++;
|
pinnings++;
|
||||||
workflowsStore.pinData({
|
workflowsStore.pinData({
|
||||||
|
|
|
@ -7,6 +7,9 @@ import type router from 'vue-router';
|
||||||
import { flushPromises } from '@vue/test-utils';
|
import { flushPromises } from '@vue/test-utils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
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 { VIEWS } from '@/constants';
|
||||||
import type { Project, ProjectListItem } from '@/types/projects.types';
|
import type { Project, ProjectListItem } from '@/types/projects.types';
|
||||||
|
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
|
||||||
describe('global', () => {
|
describe('global', () => {
|
||||||
it('should show personal + all team projects', () => {
|
it('should show personal + all team projects', () => {
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
|
||||||
const personalProjectId = 'personal-project';
|
const personalProjectId = 'personal-project';
|
||||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||||
|
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
|
||||||
expect(redirect.goToUpgrade).toHaveBeenCalled();
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { sortByProperty } from '@/utils/sortUtils';
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
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 { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
|
||||||
) => {
|
) => {
|
||||||
const CREATE_PROJECT_ID = 'create-project';
|
const CREATE_PROJECT_ID = 'create-project';
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
|
||||||
{
|
{
|
||||||
id: CREATE_PROJECT_ID,
|
id: CREATE_PROJECT_ID,
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
|
disabled: !projectsStore.canCreateProjects,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
|
||||||
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
|
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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -110,6 +110,18 @@ describe('useKeybindings', () => {
|
||||||
expect(handler).toHaveBeenCalled();
|
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 () => {
|
it('should normalize shortcut string alternatives correctly', async () => {
|
||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const keymap = ref({ 'a|b': handler });
|
const keymap = ref({ 'a|b': handler });
|
||||||
|
|
|
@ -38,14 +38,30 @@ export const useKeybindings = (
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function normalizeShortcutString(shortcut: string) {
|
function shortcutPartsToString(parts: string[]) {
|
||||||
return shortcut
|
return parts
|
||||||
.split(/[+_-]/)
|
|
||||||
.map((key) => key.toLowerCase())
|
.map((key) => key.toLowerCase())
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
.join('+');
|
.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) {
|
function toShortcutString(event: KeyboardEvent) {
|
||||||
const { shiftKey, altKey } = event;
|
const { shiftKey, altKey } = event;
|
||||||
const ctrlKey = isCtrlKeyPressed(event);
|
const ctrlKey = isCtrlKeyPressed(event);
|
||||||
|
@ -64,7 +80,7 @@ export const useKeybindings = (
|
||||||
modifiers.push('alt');
|
modifiers.push('alt');
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeShortcutString([...modifiers, ...keys].join('+'));
|
return shortcutPartsToString([...modifiers, ...keys]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
|
|
@ -16,6 +16,11 @@ describe('useNodeViewVersionSwitcher', () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
[STORES.WORKFLOWS]: {},
|
[STORES.WORKFLOWS]: {},
|
||||||
[STORES.NDV]: {},
|
[STORES.NDV]: {},
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: {
|
||||||
|
betaFeatures: ['canvas_v2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -3,15 +3,18 @@ import { useLocalStorage } from '@vueuse/core';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
export function useNodeViewVersionSwitcher() {
|
export function useNodeViewVersionSwitcher() {
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
const isNewUser = computed(() => workflowsStore.activeWorkflows.length === 0);
|
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);
|
const nodeViewVersionMigrated = useLocalStorage('NodeView.migrated', false);
|
||||||
|
|
||||||
function setNodeViewSwitcherDropdownOpened(visible: boolean) {
|
function setNodeViewSwitcherDropdownOpened(visible: boolean) {
|
||||||
|
@ -36,6 +39,10 @@ export function useNodeViewVersionSwitcher() {
|
||||||
function switchNodeViewVersion() {
|
function switchNodeViewVersion() {
|
||||||
const toVersion = nodeViewVersion.value === '2' ? '1' : '2';
|
const toVersion = nodeViewVersion.value === '2' ? '1' : '2';
|
||||||
|
|
||||||
|
if (!nodeViewVersionMigrated.value) {
|
||||||
|
nodeViewVersionMigrated.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
telemetry.track('User switched canvas version', {
|
telemetry.track('User switched canvas version', {
|
||||||
to_version: toVersion,
|
to_version: toVersion,
|
||||||
});
|
});
|
||||||
|
@ -49,7 +56,6 @@ export function useNodeViewVersionSwitcher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
switchNodeViewVersion();
|
switchNodeViewVersion();
|
||||||
nodeViewVersionMigrated.value = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type IRunData,
|
type IRunData,
|
||||||
type Workflow,
|
type Workflow,
|
||||||
type IExecuteData,
|
type IExecuteData,
|
||||||
|
type ITaskData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
@ -20,6 +21,7 @@ import { useToast } from './useToast';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => ({
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
useWorkflowsStore: vi.fn().mockReturnValue({
|
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 () => {
|
it('does not use the original run data if `PartialExecution.version` is set to 0', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
|
|
|
@ -150,6 +150,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
|
|
||||||
let { runData: newRunData } = consolidatedData;
|
let { runData: newRunData } = consolidatedData;
|
||||||
let executedNode: string | undefined;
|
let executedNode: string | undefined;
|
||||||
|
let triggerToStartFrom: IStartRunData['triggerToStartFrom'];
|
||||||
if (
|
if (
|
||||||
startNodeNames.length === 0 &&
|
startNodeNames.length === 0 &&
|
||||||
'destinationNode' in options &&
|
'destinationNode' in options &&
|
||||||
|
@ -157,14 +158,16 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
) {
|
) {
|
||||||
executedNode = options.destinationNode;
|
executedNode = options.destinationNode;
|
||||||
startNodeNames.push(options.destinationNode);
|
startNodeNames.push(options.destinationNode);
|
||||||
} else if ('triggerNode' in options && 'nodeData' in options) {
|
} else if (options.triggerNode && options.nodeData) {
|
||||||
startNodeNames.push(
|
startNodeNames.push(
|
||||||
...workflow.getChildNodes(options.triggerNode as string, NodeConnectionType.Main, 1),
|
...workflow.getChildNodes(options.triggerNode, NodeConnectionType.Main, 1),
|
||||||
);
|
);
|
||||||
newRunData = {
|
newRunData = { [options.triggerNode]: [options.nodeData] };
|
||||||
[options.triggerNode as string]: [options.nodeData],
|
|
||||||
} as IRunData;
|
|
||||||
executedNode = options.triggerNode;
|
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
|
// 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.
|
// data to use and what to ignore.
|
||||||
runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData,
|
runData: partialExecutionVersion.value === 1 ? (runData ?? undefined) : newRunData,
|
||||||
startNodes,
|
startNodes,
|
||||||
|
triggerToStartFrom,
|
||||||
};
|
};
|
||||||
if ('destinationNode' in options) {
|
if ('destinationNode' in options) {
|
||||||
startRunData.destinationNode = options.destinationNode;
|
startRunData.destinationNode = options.destinationNode;
|
||||||
|
|
|
@ -413,7 +413,7 @@ export function executeData(
|
||||||
mainConnections: for (const mainConnections of workflow.connectionsByDestinationNode[
|
mainConnections: for (const mainConnections of workflow.connectionsByDestinationNode[
|
||||||
currentNode
|
currentNode
|
||||||
].main) {
|
].main) {
|
||||||
for (const connection of mainConnections) {
|
for (const connection of mainConnections ?? []) {
|
||||||
if (
|
if (
|
||||||
connection.type === NodeConnectionType.Main &&
|
connection.type === NodeConnectionType.Main &&
|
||||||
connection.node === parentNodeName
|
connection.node === parentNodeName
|
||||||
|
|
|
@ -437,6 +437,8 @@ export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
|
||||||
export const LOCAL_STORAGE_THEME = 'N8N_THEME';
|
export const LOCAL_STORAGE_THEME = 'N8N_THEME';
|
||||||
export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
|
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_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 BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
|
|
||||||
export const HIRING_BANNER = `
|
export const HIRING_BANNER = `
|
||||||
|
|
|
@ -3,7 +3,8 @@ import { createEventBus } from 'n8n-design-system/utils';
|
||||||
export const mfaEventBus = createEventBus();
|
export const mfaEventBus = createEventBus();
|
||||||
|
|
||||||
export interface MfaModalClosedEventPayload {
|
export interface MfaModalClosedEventPayload {
|
||||||
mfaCode: string;
|
mfaCode?: string;
|
||||||
|
mfaRecoveryCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MfaModalEvents {
|
export interface MfaModalEvents {
|
||||||
|
|
|
@ -2563,6 +2563,7 @@
|
||||||
"projects.error.title": "Project error",
|
"projects.error.title": "Project error",
|
||||||
"projects.create.limit": "{num} project | {num} projects",
|
"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": "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.create.limitReached.link": "View plans",
|
||||||
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
|
"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}",
|
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
|
||||||
|
@ -2596,7 +2597,9 @@
|
||||||
"mfa.button.back": "Back",
|
"mfa.button.back": "Back",
|
||||||
"mfa.code.input.label": "Two-factor code",
|
"mfa.code.input.label": "Two-factor code",
|
||||||
"mfa.code.input.placeholder": "e.g. 123456",
|
"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.recovery.input.placeholder": "e.g c79f9c02-7b2e-44...",
|
||||||
"mfa.code.invalid": "This code is invalid, try again or",
|
"mfa.code.invalid": "This code is invalid, try again or",
|
||||||
"mfa.recovery.invalid": "This code is invalid or was already used, try again",
|
"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.message": "Two-factor authentication enabled",
|
||||||
"mfa.setup.step2.toast.setupFinished.error.message": "Error enabling two-factor authentication",
|
"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.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.mfa.section.title": "Two-factor authentication (2FA)",
|
||||||
"settings.personal.personalisation": "Personalisation",
|
"settings.personal.personalisation": "Personalisation",
|
||||||
"settings.personal.theme": "Theme",
|
"settings.personal.theme": "Theme",
|
||||||
|
|
|
@ -14,6 +14,8 @@ const App = {
|
||||||
};
|
};
|
||||||
const renderComponent = createComponentRenderer(App);
|
const renderComponent = createComponentRenderer(App);
|
||||||
|
|
||||||
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
|
|
||||||
describe('router', () => {
|
describe('router', () => {
|
||||||
let server: ReturnType<typeof setupServer>;
|
let server: ReturnType<typeof setupServer>;
|
||||||
const initializeAuthenticatedFeaturesSpy = vi.spyOn(init, 'initializeAuthenticatedFeatures');
|
const initializeAuthenticatedFeaturesSpy = vi.spyOn(init, 'initializeAuthenticatedFeatures');
|
||||||
|
@ -28,6 +30,7 @@ describe('router', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
settingsStore = useSettingsStore();
|
||||||
initializeAuthenticatedFeaturesSpy.mockImplementation(async () => await Promise.resolve());
|
initializeAuthenticatedFeaturesSpy.mockImplementation(async () => await Promise.resolve());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -114,7 +117,6 @@ describe('router', () => {
|
||||||
])(
|
])(
|
||||||
'should resolve %s to %s with %s user permissions',
|
'should resolve %s to %s with %s user permissions',
|
||||||
async (path, name, scopes) => {
|
async (path, name, scopes) => {
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
const rbacStore = useRBACStore();
|
const rbacStore = useRBACStore();
|
||||||
|
|
||||||
settingsStore.settings.communityNodesEnabled = true;
|
settingsStore.settings.communityNodesEnabled = true;
|
||||||
|
@ -126,4 +128,13 @@ describe('router', () => {
|
||||||
},
|
},
|
||||||
10000,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -255,7 +255,6 @@ export const routes: RouteRecordRaw[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/workflow/:name/evaluation',
|
path: '/workflow/:name/evaluation',
|
||||||
name: VIEWS.TEST_DEFINITION,
|
|
||||||
meta: {
|
meta: {
|
||||||
keepWorkflowAlive: true,
|
keepWorkflowAlive: true,
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
|
@ -483,7 +482,13 @@ export const routes: RouteRecordRaw[] = [
|
||||||
name: VIEWS.SETTINGS,
|
name: VIEWS.SETTINGS,
|
||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
props: true,
|
props: true,
|
||||||
redirect: { name: VIEWS.USAGE },
|
redirect: () => {
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
if (settingsStore.settings.hideUsagePage) {
|
||||||
|
return { name: VIEWS.PERSONAL_SETTINGS };
|
||||||
|
}
|
||||||
|
return { name: VIEWS.USAGE };
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: 'usage',
|
path: 'usage',
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import type {
|
import type {
|
||||||
Draggable,
|
Draggable,
|
||||||
InputPanel,
|
InputPanel,
|
||||||
|
@ -13,6 +14,8 @@ import { useStorage } from '@/composables/useStorage';
|
||||||
import {
|
import {
|
||||||
LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED,
|
LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED,
|
||||||
LOCAL_STORAGE_MAPPING_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,
|
LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED,
|
||||||
STORES,
|
STORES,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
@ -44,7 +47,6 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
});
|
});
|
||||||
const pushRef = ref('');
|
const pushRef = ref('');
|
||||||
const input = ref<InputPanel>({
|
const input = ref<InputPanel>({
|
||||||
displayMode: 'schema',
|
|
||||||
nodeName: undefined,
|
nodeName: undefined,
|
||||||
run: undefined,
|
run: undefined,
|
||||||
branch: undefined,
|
branch: undefined,
|
||||||
|
@ -52,8 +54,11 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
isEmpty: true,
|
isEmpty: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const inputPanelDisplayMode = useLocalStorage<IRunDataDisplayMode>(
|
||||||
|
LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE,
|
||||||
|
'schema',
|
||||||
|
);
|
||||||
const output = ref<OutputPanel>({
|
const output = ref<OutputPanel>({
|
||||||
displayMode: 'table',
|
|
||||||
branch: undefined,
|
branch: undefined,
|
||||||
data: {
|
data: {
|
||||||
isEmpty: true,
|
isEmpty: true,
|
||||||
|
@ -63,6 +68,10 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
value: '',
|
value: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const outputPanelDisplayMode = useLocalStorage<IRunDataDisplayMode>(
|
||||||
|
LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE,
|
||||||
|
'table',
|
||||||
|
);
|
||||||
const focusedMappableInput = ref('');
|
const focusedMappableInput = ref('');
|
||||||
const focusedInputPath = ref('');
|
const focusedInputPath = ref('');
|
||||||
const mappingTelemetry = ref<Record<string, string | number | boolean>>({});
|
const mappingTelemetry = ref<Record<string, string | number | boolean>>({});
|
||||||
|
@ -125,10 +134,6 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
return ndvInputDataWithPinnedData.value.length > 0;
|
return ndvInputDataWithPinnedData.value.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputPanelDisplayMode = computed(() => input.value.displayMode);
|
|
||||||
|
|
||||||
const outputPanelDisplayMode = computed(() => output.value.displayMode);
|
|
||||||
|
|
||||||
const isDraggableDragging = computed(() => draggable.value.isDragging);
|
const isDraggableDragging = computed(() => draggable.value.isDragging);
|
||||||
|
|
||||||
const draggableType = computed(() => draggable.value.type);
|
const draggableType = computed(() => draggable.value.type);
|
||||||
|
@ -151,7 +156,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
if (!activeNodeConections || activeNodeConections.length < 2) return returnData;
|
if (!activeNodeConections || activeNodeConections.length < 2) return returnData;
|
||||||
|
|
||||||
for (const [index, connection] of activeNodeConections.entries()) {
|
for (const [index, connection] of activeNodeConections.entries()) {
|
||||||
for (const node of connection) {
|
for (const node of connection ?? []) {
|
||||||
if (!returnData[node.node]) {
|
if (!returnData[node.node]) {
|
||||||
returnData[node.node] = [];
|
returnData[node.node] = [];
|
||||||
}
|
}
|
||||||
|
@ -242,9 +247,9 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
|
||||||
mode: IRunDataDisplayMode;
|
mode: IRunDataDisplayMode;
|
||||||
}): void => {
|
}): void => {
|
||||||
if (params.pane === 'input') {
|
if (params.pane === 'input') {
|
||||||
input.value.displayMode = params.mode;
|
inputPanelDisplayMode.value = params.mode;
|
||||||
} else {
|
} else {
|
||||||
output.value.displayMode = params.mode;
|
outputPanelDisplayMode.value = params.mode;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -331,10 +331,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableMfa = async (mfaCode: string) => {
|
const disableMfa = async (data: mfaApi.DisableMfaParams) => {
|
||||||
await mfaApi.disableMfa(rootStore.restApiContext, {
|
await mfaApi.disableMfa(rootStore.restApiContext, data);
|
||||||
mfaCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
currentUser.value.mfaEnabled = false;
|
currentUser.value.mfaEnabled = false;
|
||||||
|
|
|
@ -891,9 +891,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
let propertyName: keyof IConnection;
|
let propertyName: keyof IConnection;
|
||||||
let connectionExists = false;
|
let connectionExists = false;
|
||||||
|
|
||||||
connectionLoop: for (const existingConnection of workflow.value.connections[sourceData.node][
|
const nodeConnections = workflow.value.connections[sourceData.node][sourceData.type];
|
||||||
sourceData.type
|
const connectionsToCheck = nodeConnections[sourceData.index];
|
||||||
][sourceData.index]) {
|
|
||||||
|
if (connectionsToCheck) {
|
||||||
|
connectionLoop: for (const existingConnection of connectionsToCheck) {
|
||||||
for (propertyName of checkProperties) {
|
for (propertyName of checkProperties) {
|
||||||
if (existingConnection[propertyName] !== destinationData[propertyName]) {
|
if (existingConnection[propertyName] !== destinationData[propertyName]) {
|
||||||
continue connectionLoop;
|
continue connectionLoop;
|
||||||
|
@ -902,12 +904,15 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
connectionExists = true;
|
connectionExists = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add the new connection if it does not exist already
|
// Add the new connection if it does not exist already
|
||||||
if (!connectionExists) {
|
if (!connectionExists) {
|
||||||
workflow.value.connections[sourceData.node][sourceData.type][sourceData.index].push(
|
nodeConnections[sourceData.index] = nodeConnections[sourceData.index] ?? [];
|
||||||
destinationData,
|
const connections = nodeConnections[sourceData.index];
|
||||||
);
|
if (connections) {
|
||||||
|
connections.push(destinationData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -934,6 +939,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
|
|
||||||
const connections =
|
const connections =
|
||||||
workflow.value.connections[sourceData.node][sourceData.type][sourceData.index];
|
workflow.value.connections[sourceData.node][sourceData.type][sourceData.index];
|
||||||
|
if (!connections) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const index in connections) {
|
for (const index in connections) {
|
||||||
if (
|
if (
|
||||||
connections[index].node === destinationData.node &&
|
connections[index].node === destinationData.node &&
|
||||||
|
@ -979,27 +988,23 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
for (type of Object.keys(workflow.value.connections[sourceNode])) {
|
for (type of Object.keys(workflow.value.connections[sourceNode])) {
|
||||||
for (sourceIndex of Object.keys(workflow.value.connections[sourceNode][type])) {
|
for (sourceIndex of Object.keys(workflow.value.connections[sourceNode][type])) {
|
||||||
indexesToRemove.length = 0;
|
indexesToRemove.length = 0;
|
||||||
for (connectionIndex of Object.keys(
|
const connectionsToRemove =
|
||||||
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)],
|
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)];
|
||||||
)) {
|
if (connectionsToRemove) {
|
||||||
connectionData =
|
for (connectionIndex of Object.keys(connectionsToRemove)) {
|
||||||
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)][
|
connectionData = connectionsToRemove[parseInt(connectionIndex, 10)];
|
||||||
parseInt(connectionIndex, 10)
|
|
||||||
];
|
|
||||||
if (connectionData.node === node.name) {
|
if (connectionData.node === node.name) {
|
||||||
indexesToRemove.push(connectionIndex);
|
indexesToRemove.push(connectionIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
indexesToRemove.forEach((index) => {
|
indexesToRemove.forEach((index) => {
|
||||||
workflow.value.connections[sourceNode][type][parseInt(sourceIndex, 10)].splice(
|
connectionsToRemove.splice(parseInt(index, 10), 1);
|
||||||
parseInt(index, 10),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renameNodeSelectedAndExecution(nameData: { old: string; new: string }): void {
|
function renameNodeSelectedAndExecution(nameData: { old: string; new: string }): void {
|
||||||
uiStore.stateIsDirty = true;
|
uiStore.stateIsDirty = true;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import type { IExecutionFlattedResponse, IExecutionResponse, IRestApiContext } f
|
||||||
|
|
||||||
const getBrowserId = () => {
|
const getBrowserId = () => {
|
||||||
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
|
let browserId = localStorage.getItem(BROWSER_ID_STORAGE_KEY);
|
||||||
if (!browserId && 'randomUUID' in crypto) {
|
if (!browserId) {
|
||||||
browserId = crypto.randomUUID();
|
browserId = crypto.randomUUID();
|
||||||
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
|
localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ describe('updateDynamicConnections', () => {
|
||||||
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
expect(updatedConnections?.TestNode.main).toHaveLength(2);
|
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', () => {
|
it('should handle fallbackOutput === "extra" and all rules removed', () => {
|
||||||
|
@ -82,7 +82,7 @@ describe('updateDynamicConnections', () => {
|
||||||
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
expect(updatedConnections?.TestNode.main).toHaveLength(1);
|
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', () => {
|
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).toHaveLength(4);
|
||||||
expect(updatedConnections?.TestNode.main[2]).toEqual([]);
|
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', () => {
|
it('should return null if no conditions are met', () => {
|
||||||
|
|
|
@ -77,7 +77,7 @@ export function updateDynamicConnections(
|
||||||
}
|
}
|
||||||
} else if (parameterData.name === 'parameters.rules.values') {
|
} else if (parameterData.name === 'parameters.rules.values') {
|
||||||
const curentRulesvalues = (node.parameters?.rules as { values: IDataObject[] })?.values;
|
const curentRulesvalues = (node.parameters?.rules as { values: IDataObject[] })?.values;
|
||||||
let lastConnection: IConnection[] | undefined = undefined;
|
let lastConnection: IConnection[] | null | undefined = undefined;
|
||||||
if (
|
if (
|
||||||
fallbackOutput === 'extra' &&
|
fallbackOutput === 'extra' &&
|
||||||
connections[node.name].main.length === curentRulesvalues.length + 1
|
connections[node.name].main.length === curentRulesvalues.length + 1
|
||||||
|
|
|
@ -35,7 +35,9 @@ const initialState = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialsView);
|
const renderComponent = createComponentRenderer(CredentialsView, {
|
||||||
|
global: { stubs: { ProjectHeader: true } },
|
||||||
|
});
|
||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
|
|
||||||
describe('CredentialsView', () => {
|
describe('CredentialsView', () => {
|
||||||
|
|
|
@ -272,13 +272,13 @@ async function initializeData() {
|
||||||
promises.push(externalSecretsStore.fetchAllSecrets());
|
promises.push(externalSecretsStore.fetchAllSecrets());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeTypesStore.allNodeTypes.length === 0) {
|
|
||||||
promises.push(nodeTypesStore.getNodeTypes());
|
|
||||||
}
|
|
||||||
|
|
||||||
return promises;
|
return promises;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
if (nodeTypesStore.allNodeTypes.length === 0) {
|
||||||
|
loadPromises.push(nodeTypesStore.getNodeTypes());
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
} catch (error) {
|
} 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
|
// 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
|
// as only the route changed but all the needed data is already loaded
|
||||||
if (route.params.action === 'workflowSave') {
|
if (route.params.action === 'workflowSave') {
|
||||||
|
@ -300,6 +300,7 @@ async function initializeRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyInitialized =
|
const isAlreadyInitialized =
|
||||||
|
!force &&
|
||||||
initializedWorkflowId.value &&
|
initializedWorkflowId.value &&
|
||||||
[NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value);
|
[NEW_WORKFLOW_ID, workflowId.value].includes(initializedWorkflowId.value);
|
||||||
|
|
||||||
|
@ -1489,8 +1490,10 @@ function unregisterCustomActions() {
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.name,
|
() => route.name,
|
||||||
async () => {
|
async (newRouteName, oldRouteName) => {
|
||||||
await initializeRoute();
|
// it's navigating from and existing workflow to a new workflow
|
||||||
|
const force = newRouteName === VIEWS.NEW_WORKFLOW && oldRouteName === VIEWS.WORKFLOW;
|
||||||
|
await initializeRoute(force);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1394,7 +1394,11 @@ export default defineComponent({
|
||||||
lastSelectedNode.name,
|
lastSelectedNode.name,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (connections.main === undefined || connections.main.length === 0) {
|
if (
|
||||||
|
connections.main === undefined ||
|
||||||
|
connections.main.length === 0 ||
|
||||||
|
!connections.main[0]
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1428,7 +1432,11 @@ export default defineComponent({
|
||||||
|
|
||||||
const connections = workflow.connectionsByDestinationNode[lastSelectedNode.name];
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1460,7 +1468,11 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentNode = connections.main[0][0].node;
|
const parentNode = connections.main[0]?.[0].node;
|
||||||
|
if (!parentNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const connectionsParent = this.workflowsStore.outgoingConnectionsByNodeName(parentNode);
|
const connectionsParent = this.workflowsStore.outgoingConnectionsByNodeName(parentNode);
|
||||||
|
|
||||||
if (!Array.isArray(connectionsParent.main) || !connectionsParent.main.length) {
|
if (!Array.isArray(connectionsParent.main) || !connectionsParent.main.length) {
|
||||||
|
@ -1472,7 +1484,7 @@ export default defineComponent({
|
||||||
let lastCheckedNodePosition = e.key === 'ArrowUp' ? -99999999 : 99999999;
|
let lastCheckedNodePosition = e.key === 'ArrowUp' ? -99999999 : 99999999;
|
||||||
let nextSelectNode: string | null = null;
|
let nextSelectNode: string | null = null;
|
||||||
for (const ouputConnections of connectionsParent.main) {
|
for (const ouputConnections of connectionsParent.main) {
|
||||||
for (const ouputConnection of ouputConnections) {
|
for (const ouputConnection of ouputConnections ?? []) {
|
||||||
if (ouputConnection.node === lastSelectedNode.name) {
|
if (ouputConnection.node === lastSelectedNode.name) {
|
||||||
// Ignore current node
|
// Ignore current node
|
||||||
continue;
|
continue;
|
||||||
|
@ -3877,13 +3889,10 @@ export default defineComponent({
|
||||||
sourceIndex++
|
sourceIndex++
|
||||||
) {
|
) {
|
||||||
const nodeSourceConnections = [];
|
const nodeSourceConnections = [];
|
||||||
if (currentConnections[sourceNode][type][sourceIndex]) {
|
const connections = currentConnections[sourceNode][type][sourceIndex];
|
||||||
for (
|
if (connections) {
|
||||||
connectionIndex = 0;
|
for (connectionIndex = 0; connectionIndex < connections.length; connectionIndex++) {
|
||||||
connectionIndex < currentConnections[sourceNode][type][sourceIndex].length;
|
connectionData = connections[connectionIndex];
|
||||||
connectionIndex++
|
|
||||||
) {
|
|
||||||
connectionData = currentConnections[sourceNode][type][sourceIndex][connectionIndex];
|
|
||||||
if (!createNodeNames.includes(connectionData.node)) {
|
if (!createNodeNames.includes(connectionData.node)) {
|
||||||
// Node does not get created so skip input connection
|
// Node does not get created so skip input connection
|
||||||
continue;
|
continue;
|
||||||
|
@ -4013,16 +4022,19 @@ export default defineComponent({
|
||||||
for (type of Object.keys(connections)) {
|
for (type of Object.keys(connections)) {
|
||||||
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
|
for (sourceIndex = 0; sourceIndex < connections[type].length; sourceIndex++) {
|
||||||
connectionToKeep = [];
|
connectionToKeep = [];
|
||||||
|
const connectionsToCheck = connections[type][sourceIndex];
|
||||||
|
if (connectionsToCheck) {
|
||||||
for (
|
for (
|
||||||
connectionIndex = 0;
|
connectionIndex = 0;
|
||||||
connectionIndex < connections[type][sourceIndex].length;
|
connectionIndex < connectionsToCheck.length;
|
||||||
connectionIndex++
|
connectionIndex++
|
||||||
) {
|
) {
|
||||||
connectionData = connections[type][sourceIndex][connectionIndex];
|
connectionData = connectionsToCheck[connectionIndex];
|
||||||
if (exportNodeNames.indexOf(connectionData.node) !== -1) {
|
if (exportNodeNames.indexOf(connectionData.node) !== -1) {
|
||||||
connectionToKeep.push(connectionData);
|
connectionToKeep.push(connectionData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (connectionToKeep.length) {
|
if (connectionToKeep.length) {
|
||||||
if (!typeConnections.hasOwnProperty(type)) {
|
if (!typeConnections.hasOwnProperty(type)) {
|
||||||
|
|
|
@ -226,7 +226,7 @@ async function disableMfa(payload: MfaModalEvents['closed']) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await usersStore.disableMfa(payload.mfaCode);
|
await usersStore.disableMfa(payload);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
||||||
|
|
|
@ -50,7 +50,7 @@ const router = createRouter({
|
||||||
name: VIEWS.SETTINGS,
|
name: VIEWS.SETTINGS,
|
||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
props: true,
|
props: true,
|
||||||
redirect: { name: VIEWS.USAGE },
|
redirect: { name: VIEWS.PERSONAL_SETTINGS },
|
||||||
children: settingsRouteChildren,
|
children: settingsRouteChildren,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-node-dev",
|
"name": "n8n-node-dev",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "CLI to simplify n8n credentials/node development",
|
"description": "CLI to simplify n8n credentials/node development",
|
||||||
"main": "dist/src/index",
|
"main": "dist/src/index",
|
||||||
"types": "dist/src/index.d.ts",
|
"types": "dist/src/index.d.ts",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-nodes-base",
|
"name": "n8n-nodes-base",
|
||||||
"version": "1.70.0",
|
"version": "1.71.0",
|
||||||
"description": "Base nodes of n8n",
|
"description": "Base nodes of n8n",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue