diff --git a/CHANGELOG.md b/CHANGELOG.md index 0426b51cc8970..eac93ca428d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,78 @@ +# [1.21.0](https://github.com/n8n-io/n8n/compare/n8n@1.20.0...n8n@1.21.0) (2023-12-13) + + +### Bug Fixes + +* **core:** Ensure inviter and invitee are set correctly in invite link ([#7943](https://github.com/n8n-io/n8n/issues/7943)) ([386bd61](https://github.com/n8n-io/n8n/commit/386bd619676e54e960ca0af3ff47fa3b9c16c813)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **core:** Fix user comparison in same-user subworkflow caller policy ([#7913](https://github.com/n8n-io/n8n/issues/7913)) ([92bab72](https://github.com/n8n-io/n8n/commit/92bab72cffb1083b495d211d0a31920e83e66769)) +* **core:** Perform multi-main leader check against key ID ([#7964](https://github.com/n8n-io/n8n/issues/7964)) ([1a87f70](https://github.com/n8n-io/n8n/commit/1a87f70e8404218308072ee2f35c6ba2af34c23f)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **core:** Prevent workflow history saving error from happening ([#7812](https://github.com/n8n-io/n8n/issues/7812)) ([e5581ce](https://github.com/n8n-io/n8n/commit/e5581ce8023e21d3dcf140099f3a53e5ffb4584f)) +* **editor:** Add missing string for worker in log streaming ([#7971](https://github.com/n8n-io/n8n/issues/7971)) ([148bc1d](https://github.com/n8n-io/n8n/commit/148bc1d303af3aafd73e73e11c3dd9cefd40a1dd)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Allow SSH protocol in git repository URL for environments ([#7944](https://github.com/n8n-io/n8n/issues/7944)) ([bc1c72f](https://github.com/n8n-io/n8n/commit/bc1c72f992a47a9c263aec175ca820088cf340ec)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Fix bug with node names with certain characters ([#8013](https://github.com/n8n-io/n8n/issues/8013)) ([26f0d57](https://github.com/n8n-io/n8n/commit/26f0d57f5fb71a06c92968a4997cceae62f32312)) +* **editor:** Fix Webhook URL expansion icon ([#8011](https://github.com/n8n-io/n8n/issues/8011)) ([b00b905](https://github.com/n8n-io/n8n/commit/b00b9057a42f23cd9c4bb6675a3e6134610bf81b)) +* **editor:** Prevent opening NDV search if `/` is typed in a contenteditable element ([#7968](https://github.com/n8n-io/n8n/issues/7968)) ([e8a493f](https://github.com/n8n-io/n8n/commit/e8a493f71863e6a5d2685b48a61a0d11daf5edc5)) +* **editor:** Return early in ws message handler if no 'command' keyword is found ([#7946](https://github.com/n8n-io/n8n/issues/7946)) ([5b2defc](https://github.com/n8n-io/n8n/commit/5b2defc867a0627a861bf0fb98abfd99f8efe934)) +* Ensure external hooks post workflow execute run in queue mode ([#7947](https://github.com/n8n-io/n8n/issues/7947)) ([3ba7deb](https://github.com/n8n-io/n8n/commit/3ba7deb337963d40ae70f40ffb2f4eb23cac89b7)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **FileMaker Node:** Prevent erroring on zero fields loaded ([#7955](https://github.com/n8n-io/n8n/issues/7955)) ([10ad386](https://github.com/n8n-io/n8n/commit/10ad3866048ad06d0e8455ed2c52c618ae9e5032)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* Fix issue preventing secrets from loading if the path contains - or / ([#7988](https://github.com/n8n-io/n8n/issues/7988)) ([0ac9594](https://github.com/n8n-io/n8n/commit/0ac959463f25187c5be4116a2209411afd903d87)) +* **Google Sheets Node:** Prevent erroring on zero sheet search results ([#7957](https://github.com/n8n-io/n8n/issues/7957)) ([9b877a9](https://github.com/n8n-io/n8n/commit/9b877a942787c855c3a3a011c19c5d1d30b8da67)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **Google Sheets Node:** Prevent erroring when fetching mapping columns ([#7972](https://github.com/n8n-io/n8n/issues/7972)) ([29a1066](https://github.com/n8n-io/n8n/commit/29a10668d17cdeb8b0e93c912f59c5976b6fc6c6)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **Postgres Node:** Do not include id column in upsert fields selection if it's not unique ([#7975](https://github.com/n8n-io/n8n/issues/7975)) ([435392c](https://github.com/n8n-io/n8n/commit/435392cbfe150c5e85d092686b3b7e20273421cc)) +* **Postgres Trigger Node:** Increase manual trigger timeout from 30 to 60 seconds ([#8015](https://github.com/n8n-io/n8n/issues/8015)) ([09a5729](https://github.com/n8n-io/n8n/commit/09a5729305a8072f5e98a320c85ad1c83a6946ed)) +* Restrict updating/deleting of shared but not owned credentials ([#7950](https://github.com/n8n-io/n8n/issues/7950)) ([42e828d](https://github.com/n8n-io/n8n/commit/42e828d5c655e54b6a4ec83c398c684996b9cc3e)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **Webhook Node:** Binary data handling ([#7804](https://github.com/n8n-io/n8n/issues/7804)) ([565b409](https://github.com/n8n-io/n8n/commit/565b409a82ca6173efd19f26a5f5b27a359a3b87)) +* **Webhook Node:** Do not create binary data when there is no data in the request ([#8000](https://github.com/n8n-io/n8n/issues/8000)) ([70f0755](https://github.com/n8n-io/n8n/commit/70f0755278e0a2bdb61c29623f27623b65473ab4)), closes [/github.com/n8n-io/n8n/pull/7804/files#r1422641833](https://github.com//github.com/n8n-io/n8n/pull/7804/files/issues/r1422641833) + + +### Features + +* Add config option for external secret update interval ([#7995](https://github.com/n8n-io/n8n/issues/7995)) ([b6c1c04](https://github.com/n8n-io/n8n/commit/b6c1c04b541d0944c5baac1ab021539c8f020f10)) +* AI nodes usability fixes + Summarization Chain V2 ([#7949](https://github.com/n8n-io/n8n/issues/7949)) ([dcf1286](https://github.com/n8n-io/n8n/commit/dcf12867b3c49596cd214812caee3292d2e794de)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* Data transformation nodes and actions in Nodes Panel ([#7760](https://github.com/n8n-io/n8n/issues/7760)) ([675ec21](https://github.com/n8n-io/n8n/commit/675ec21d335af2b2c9598bc2bec18194506ef71a)) +* **editor:** Add AppCues tracking for onboarding event ([#7945](https://github.com/n8n-io/n8n/issues/7945)) ([04cabaf](https://github.com/n8n-io/n8n/commit/04cabafef7acbc30cba647732e2ca8ae8a02d29a)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Add option to disable NDV in workflow previews ([#7990](https://github.com/n8n-io/n8n/issues/7990)) ([393afef](https://github.com/n8n-io/n8n/commit/393afef1747f168d5fa42be2424fd02125f1bbac)), closes [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Filter component + implement in If node ([#7490](https://github.com/n8n-io/n8n/issues/7490)) ([8a53434](https://github.com/n8n-io/n8n/commit/8a5343401dd355436120a9a424ae455e80b50da6)) +* **editor:** Show template credential setup based on feature flag ([#7989](https://github.com/n8n-io/n8n/issues/7989)) ([08ee307](https://github.com/n8n-io/n8n/commit/08ee3072093fb26b14b48e2b35d8c8d018317f13)) +* **Google Ads Node:** Update to support v15 ([#7962](https://github.com/n8n-io/n8n/issues/7962)) ([7f01269](https://github.com/n8n-io/n8n/commit/7f0126915aae514a0ab515a4baf5582da2aeb1e3)) +* Introduce advanced permissions ([#7844](https://github.com/n8n-io/n8n/issues/7844)) ([dbd62a4](https://github.com/n8n-io/n8n/commit/dbd62a4992ab8aca59e3cb50d3d970454e462238)) +* **Local File Trigger Node:** Add polling option typically good to watch network files/folders ([#7942](https://github.com/n8n-io/n8n/issues/7942)) ([2fbdfec](https://github.com/n8n-io/n8n/commit/2fbdfec0c0a3f5da64764e7821e84db30b664e49)) +* **n8n Form Trigger Node:** Improvements ([#7571](https://github.com/n8n-io/n8n/issues/7571)) ([953a58f](https://github.com/n8n-io/n8n/commit/953a58f18bfdd36fa8b526ca6213631aacab49cb)) + + + +# [1.20.0](https://github.com/n8n-io/n8n/compare/n8n@1.19.0...n8n@1.20.0) (2023-12-06) + + +### Bug Fixes + +* **AWS DynamoDB Node:** Improve error message parsing ([#7793](https://github.com/n8n-io/n8n/issues/7793)) ([5ba5ed8](https://github.com/n8n-io/n8n/commit/5ba5ed8e3c8ba2f909859bde129d92576fbda46f)) +* **core:** Allow grace period for binary data deletion after manual execution ([#7889](https://github.com/n8n-io/n8n/issues/7889)) ([61d8aeb](https://github.com/n8n-io/n8n/commit/61d8aebeaf6487269b252b353fdf16dcb67f41ff)) +* **core:** Consolidate ownership and sharing data on workflows and credentials ([#7920](https://github.com/n8n-io/n8n/issues/7920)) ([38b88b9](https://github.com/n8n-io/n8n/commit/38b88b946bab67dc1a964bb3c980a627d4a32595)) +* **core:** Fix hard deletes stopping if database query throws ([#7848](https://github.com/n8n-io/n8n/issues/7848)) ([46dd4d3](https://github.com/n8n-io/n8n/commit/46dd4d3105db3a15c81903ae81c9bbb21a45397b)) +* **core:** Make sure mfa secret and recovery codes are not returned on login ([#7936](https://github.com/n8n-io/n8n/issues/7936)) ([f5502cc](https://github.com/n8n-io/n8n/commit/f5502cc628f6b348f7fe3325b96ec9dc3360beaf)), closes [/github.com/n8n-io/n8n/pull/6994/files#diff-95a87cb029a3d26e6722df2e68132453fc254fc1f4540cbdaa95cfdbda1893deL91](https://github.com//github.com/n8n-io/n8n/pull/6994/files/issues/diff-95a87cb029a3d26e6722df2e68132453fc254fc1f4540cbdaa95cfdbda1893deL91) [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Fix deletion of last execution at execution preview ([#7883](https://github.com/n8n-io/n8n/issues/7883)) ([ce2d388](https://github.com/n8n-io/n8n/commit/ce2d388f059c0bb32d27f4b29e901d1a70083610)) +* **editor:** Replace isInstanceOwner checks with scopes where applicable ([#7858](https://github.com/n8n-io/n8n/issues/7858)) ([132d691](https://github.com/n8n-io/n8n/commit/132d691cbf983f60293c7423de0077fb7c97e0af)) +* **Google Sheets Node:** Fix issue with paired items not being set correctly ([#7862](https://github.com/n8n-io/n8n/issues/7862)) ([5207a2f](https://github.com/n8n-io/n8n/commit/5207a2fe5210e40d3b2aedd95182a18e497c72ab)) +* **Notion Node:** Fix broken Notion node parameters ([#7864](https://github.com/n8n-io/n8n/issues/7864)) ([51d1f5b](https://github.com/n8n-io/n8n/commit/51d1f5b82070542d45c3d57387343959a3f0abb2)), closes [#7791](https://github.com/n8n-io/n8n/issues/7791) + + +### Features + +* **BambooHR Node:** Add support for Only Current on company reports ([#7878](https://github.com/n8n-io/n8n/issues/7878)) ([4175801](https://github.com/n8n-io/n8n/commit/4175801c90ad4f744d1a7c331d4fb20891ed2e9e)) +* **core:** Allow admin creation ([#7837](https://github.com/n8n-io/n8n/issues/7837)) ([476806e](https://github.com/n8n-io/n8n/commit/476806ebb0f31f656992fb67aba37116f10e1475)) +* **editor:** Add sections to create node panel ([#7831](https://github.com/n8n-io/n8n/issues/7831)) ([39fa8d2](https://github.com/n8n-io/n8n/commit/39fa8d21bbee5d870b2620ec65401a5ca134c4f1)) +* **editor:** Open template credential setup from collection ([#7882](https://github.com/n8n-io/n8n/issues/7882)) ([627ddb9](https://github.com/n8n-io/n8n/commit/627ddb91fb6c00796671a1f72f59a251cd89004d)) +* **editor:** Select credentials in template setup if theres only one ([#7879](https://github.com/n8n-io/n8n/issues/7879)) ([fe3417a](https://github.com/n8n-io/n8n/commit/fe3417a615534a01cb0c7b5e8f47bc18abd5cd4d)) + + +### Performance Improvements + +* **editor:** Improve node rendering performance when opening large workflows ([#7904](https://github.com/n8n-io/n8n/issues/7904)) ([a8049a0](https://github.com/n8n-io/n8n/commit/a8049a0def21506ebf4fb1d3b69ae28ec35fdc21)), closes [#7901](https://github.com/n8n-io/n8n/issues/7901) [4#a39f9e5ba64a48b58a71d81c837e8227](https://github.com/4/issues/a39f9e5ba64a48b58a71d81c837e8227) [4#f6a177d32bde4b57ae2da0b8e454](https://github.com/4/issues/f6a177d32bde4b57ae2da0b8e454) [4#fef2d36ab02247e1a0f65a74f6fb534](https://github.com/4/issues/fef2d36ab02247e1a0f65a74f6fb534) +* **editor:** Improve performance when opening large workflows with node issues ([#7901](https://github.com/n8n-io/n8n/issues/7901)) ([4bd7ae2](https://github.com/n8n-io/n8n/commit/4bd7ae29f7c82b8817420e617a123024147c6c70)) + + + # [1.19.0](https://github.com/n8n-io/n8n/compare/n8n@1.18.0...n8n@1.19.0) (2023-11-29) diff --git a/cypress/constants.ts b/cypress/constants.ts index 5074c410807c7..7524168535114 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -41,7 +41,7 @@ export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; export const SET_NODE_NAME = 'Set'; export const EDIT_FIELDS_SET_NODE_NAME = 'Edit Fields'; -export const IF_NODE_NAME = 'IF'; +export const IF_NODE_NAME = 'If'; export const MERGE_NODE_NAME = 'Merge'; export const SWITCH_NODE_NAME = 'Switch'; export const GMAIL_NODE_NAME = 'Gmail'; diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index 27198001a8e51..1ec2abc6402b2 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -1,7 +1,5 @@ import { WorkflowPage, NDV } from '../pages'; -import { v4 as uuid } from 'uuid'; -import { getPopper, getVisiblePopper, getVisibleSelect } from '../utils'; -import { META_KEY } from '../constants'; +import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -76,12 +74,25 @@ describe('n8n Form Trigger', () => { ) .find('input') .type('Option 2'); - //add optionall submitted message - cy.get('.param-options > .button').click(); - cy.get('.indent > .parameter-item') - .find('input') + + //add optional submitted message + cy.get('.param-options').click(); + cy.contains('span', 'Text to Show') + .should('exist') + .parent() + .parent() + .next() + .children() + .children() + .children() + .children() + .children() + .children() + .children() + .first() .clear() .type('Your test form was successfully submitted'); + ndv.getters.backToCanvas().click(); workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist'); }); diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 668accd2465de..77d9fd92cfc4a 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -131,7 +131,7 @@ describe('Sharing', { disableAutoLogin: true }, () => { credentialsModal.getters.testSuccessTag().should('be.visible'); }); - it.only('should work for admin role on credentials created by others (also can share it with themselves)', () => { + it('should work for admin role on credentials created by others (also can share it with themselves)', () => { cy.signin(INSTANCE_MEMBERS[0]); cy.visit(credentialsPage.url); @@ -150,6 +150,9 @@ describe('Sharing', { disableAutoLogin: true }, () => { credentialsModal.getters.testSuccessTag().should('be.visible'); cy.get('input').should('not.have.length'); credentialsModal.actions.changeTab('Sharing'); + cy.contains( + 'You can view this credential because you have permission to read and share', + ).should('be.visible'); credentialsModal.getters.usersSelect().click(); cy.getByTestId('user-email') diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index bdc7c3b71166b..b44b9337a7512 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -1,18 +1,20 @@ import { WorkflowPage } from '../pages'; import { WorkflowExecutionsTab } from '../pages/workflow-executions-tab'; +import type { RouteHandler } from 'cypress/types/net-stubbing'; const workflowPage = new WorkflowPage(); const executionsTab = new WorkflowExecutionsTab(); +const executionsRefreshInterval = 4000; // Test suite for executions tab describe('Current Workflow Executions', () => { beforeEach(() => { workflowPage.actions.visit(); cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', `My test workflow`); - createMockExecutions(); }); it('should render executions tab correctly', () => { + createMockExecutions(); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions'); @@ -29,6 +31,45 @@ describe('Current Workflow Executions', () => { .invoke('attr', 'class') .should('match', /_active_/); }); + + it('should not redirect back to execution tab when request is not done before leaving the page', () => { + cy.intercept('GET', '/rest/executions?filter=*'); + cy.intercept('GET', '/rest/executions-current?filter=*'); + + executionsTab.actions.switchToExecutionsTab(); + executionsTab.actions.switchToEditorTab(); + cy.wait(executionsRefreshInterval); + cy.url().should('not.include', '/executions'); + executionsTab.actions.switchToExecutionsTab(); + executionsTab.actions.switchToEditorTab(); + executionsTab.actions.switchToExecutionsTab(); + executionsTab.actions.switchToEditorTab(); + executionsTab.actions.switchToExecutionsTab(); + executionsTab.actions.switchToEditorTab(); + cy.wait(executionsRefreshInterval); + cy.url().should('not.include', '/executions'); + executionsTab.actions.switchToExecutionsTab(); + cy.wait(1000); + executionsTab.actions.switchToEditorTab(); + cy.wait(executionsRefreshInterval); + cy.url().should('not.include', '/executions'); + }); + + it('should not redirect back to execution tab when slow request is not done before leaving the page', () => { + const throttleResponse: RouteHandler = (req) => { + return new Promise((resolve) => { + setTimeout(() => resolve(req.continue()), 2000); + }); + }; + + cy.intercept('GET', '/rest/executions?filter=*', throttleResponse); + cy.intercept('GET', '/rest/executions-current?filter=*', throttleResponse); + + executionsTab.actions.switchToExecutionsTab(); + executionsTab.actions.switchToEditorTab(); + cy.wait(executionsRefreshInterval); + cy.url().should('not.include', '/executions'); + }); }); const createMockExecutions = () => { diff --git a/cypress/e2e/30-if-node.cy.ts b/cypress/e2e/30-if-node.cy.ts new file mode 100644 index 0000000000000..95ed1e9a0d34d --- /dev/null +++ b/cypress/e2e/30-if-node.cy.ts @@ -0,0 +1,58 @@ +import { IF_NODE_NAME } from '../constants'; +import { WorkflowPage, NDV } from '../pages'; + +const workflowPage = new WorkflowPage(); +const ndv = new NDV(); + +const FILTER_PARAM_NAME = 'conditions'; + +describe('If Node (filter component)', () => { + beforeEach(() => { + workflowPage.actions.visit(); + }); + + it('should be able to create and delete multiple conditions', () => { + workflowPage.actions.addInitialNodeToCanvas(IF_NODE_NAME, { keepNdvOpen: true }); + + // Default state + ndv.getters.filterComponent(FILTER_PARAM_NAME).should('exist'); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1); + ndv.getters + .filterConditionOperator(FILTER_PARAM_NAME) + .find('input') + .should('have.value', 'is equal to'); + + // Add + ndv.actions.addFilterCondition(FILTER_PARAM_NAME); + ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 0).find('input').type('first left'); + ndv.getters.filterConditionLeft(FILTER_PARAM_NAME, 1).find('input').type('second left'); + ndv.actions.addFilterCondition(FILTER_PARAM_NAME); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 3); + + // Delete + ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 0); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 2); + ndv.getters + .filterConditionLeft(FILTER_PARAM_NAME, 0) + .find('input') + .should('have.value', 'second left'); + ndv.actions.removeFilterCondition(FILTER_PARAM_NAME, 1); + ndv.getters.filterConditions(FILTER_PARAM_NAME).should('have.length', 1); + }); + + it('should correctly evaluate conditions', () => { + cy.fixture('Test_workflow_filter.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + + workflowPage.actions.zoomToFit(); + workflowPage.actions.executeWorkflow(); + + workflowPage.actions.openNode('Then'); + ndv.getters.outputPanel().contains('3 items').should('exist'); + ndv.actions.close(); + + workflowPage.actions.openNode('Else'); + ndv.getters.outputPanel().contains('1 item').should('exist'); + }); +}); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 828fb27cd388e..49ff848cfee36 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -2,6 +2,7 @@ import { NodeCreator } from '../pages/features/node-creator'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { NDV } from '../pages/ndv'; import { getVisibleSelect } from '../utils'; +import { IF_NODE_NAME } from '../constants'; const nodeCreatorFeature = new NodeCreator(); const WorkflowPage = new WorkflowPageClass(); @@ -360,7 +361,7 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); @@ -368,11 +369,11 @@ describe('Node Creator', () => { nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'Edit Fields (Set)'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('i'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('IF'); - nodeCreatorFeature.getters.nodeItemName().first().should('have.text', 'IF'); + nodeCreatorFeature.getters.nodeItemName().first().should('have.text', IF_NODE_NAME); nodeCreatorFeature.getters.nodeItemName().eq(1).should('have.text', 'Switch'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('sw'); diff --git a/cypress/fixtures/Test_workflow_filter.json b/cypress/fixtures/Test_workflow_filter.json new file mode 100644 index 0000000000000..5166ead3815b4 --- /dev/null +++ b/cypress/fixtures/Test_workflow_filter.json @@ -0,0 +1,153 @@ +{ + "name": "Filter test", + "nodes": [ + { + "parameters": {}, + "id": "f332a7d1-31b4-4e78-b31e-9e8db945bf3f", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + -60, + 480 + ] + }, + { + "parameters": { + "jsCode": "return [\n {\n \"label\": \"Apple\",\n tags: [],\n meta: {foo: 'bar'}\n },\n {\n \"label\": \"Banana\",\n tags: ['exotic'],\n meta: {}\n },\n {\n \"label\": \"Pear\",\n tags: ['other'],\n meta: {}\n },\n {\n \"label\": \"Orange\",\n meta: {}\n }\n]" + }, + "id": "60697c7f-3948-4790-97ba-8aba03d02ac2", + "name": "Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 160, + 480 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "" + }, + "conditions": [ + { + "leftValue": "={{ $json.tags }}", + "rightValue": "exotic", + "operator": { + "type": "array", + "operation": "contains", + "rightType": "any" + } + }, + { + "leftValue": "={{ $json.meta }}", + "rightValue": "", + "operator": { + "type": "object", + "operation": "notEmpty", + "singleValue": true + } + }, + { + "leftValue": "={{ $json.label }}", + "rightValue": "Pea", + "operator": { + "type": "string", + "operation": "startsWith", + "rightType": "string" + } + } + ], + "combinator": "or" + }, + "options": {} + }, + "id": "7531191b-5ac3-45dc-8afb-27ae83d8f33a", + "name": "If", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 380, + 480 + ] + }, + { + "parameters": {}, + "id": "d8c614ea-0bbf-4b12-ad7d-c9ebe09ce583", + "name": "Then", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 600, + 400 + ] + }, + { + "parameters": {}, + "id": "69364770-60d2-4ef4-9f29-9570718a9a10", + "name": "Else", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [ + 600, + 580 + ] + } + ], + "pinData": {}, + "connections": { + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Then", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Else", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "a6249f48-d88f-4b80-9ed9-79555e522d48", + "id": "BWUTRs5RHxVgQ4uT", + "meta": { + "instanceId": "78577815012af39cf16dad7a787b0898c42fb7514b8a7f99b2136862c2af502c" + }, + "tags": [] +} diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 58749721a4e02..0eaa1361cfa82 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -49,9 +49,7 @@ export class NDV extends BasePage { parameterExpressionPreview: (parameterName: string) => this.getters .nodeParameters() - .find( - `[data-test-id="parameter-input-${parameterName}"] + [data-test-id="parameter-expression-preview"]`, - ), + .find(`[data-test-id="parameter-expression-preview-${parameterName}"]`), nodeNameContainer: () => cy.getByTestId('node-title-container'), nodeRenameInput: () => cy.getByTestId('node-rename-input'), executePrevious: () => cy.getByTestId('execute-previous-node'), @@ -79,6 +77,23 @@ export class NDV extends BasePage { cy.getByTestId('columns-parameter-input-options-container'), resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'), sqlEditorContainer: () => cy.getByTestId('sql-editor-container'), + filterComponent: (paramName: string) => cy.getByTestId(`filter-${paramName}`), + filterCombinator: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-combinator-select').eq(index), + filterConditions: (paramName: string) => + this.getters.filterComponent(paramName).getByTestId('filter-condition'), + filterCondition: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition').eq(index), + filterConditionLeft: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition-left').eq(index), + filterConditionRight: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-condition-right').eq(index), + filterConditionOperator: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-operator-select').eq(index), + filterConditionRemove: (paramName: string, index = 0) => + this.getters.filterComponent(paramName).getByTestId('filter-remove-condition').eq(index), + filterConditionAdd: (paramName: string) => + this.getters.filterComponent(paramName).getByTestId('filter-add-condition'), searchInput: () => cy.getByTestId('ndv-search'), pagination: () => cy.getByTestId('ndv-data-pagination'), nodeVersion: () => cy.getByTestId('node-version'), @@ -199,7 +214,6 @@ export class NDV extends BasePage { .find('span') .should('include.html', asEncodedHTML(value)); }, - refreshResourceMapperColumns: () => { this.getters.resourceMapperSelectColumn().realHover(); this.getters @@ -210,7 +224,12 @@ export class NDV extends BasePage { getVisiblePopper().find('li').last().click(); }, - + addFilterCondition: (paramName: string) => { + this.getters.filterConditionAdd(paramName).click(); + }, + removeFilterCondition: (paramName: string, index: number) => { + this.getters.filterConditionRemove(paramName, index).click(); + }, setInvalidExpression: ({ fieldName, invalidExpression, diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index 70774fa135534..eb855f026f50a 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -39,9 +39,11 @@ export class WorkflowExecutionsTab extends BasePage { }, switchToExecutionsTab: () => { this.getters.executionsTabButton().click(); + cy.url().should('include', '/executions'); }, switchToEditorTab: () => { workflowPage.getters.editorTabButton().click(); + cy.url().should('match', /\/workflow\/[^\/]+$/); }, deleteExecutionInPreview: () => { this.getters.executionPreviewDeleteButton().click(); diff --git a/package.json b/package.json index be029c3585310..a79cfe883b1e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.19.0", + "version": "1.21.0", "private": true, "homepage": "https://n8n.io", "engines": { diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index d8493b1702e9c..fc4b7c93a191b 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.2.0", + "version": "0.4.0", "scripts": { "dev": "pnpm run storybook", "build": "run-p type-check build:vite && npm run build:prepare", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 8808b82daafd4..1f27d8632d9c9 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.9.0", + "version": "0.10.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts index b5c68cde63eda..a213e8ff3b1c7 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentBinaryInputLoader/DocumentBinaryInputLoader.node.ts @@ -7,6 +7,8 @@ import { type SupplyData, } from 'n8n-workflow'; +import type { TextSplitter } from 'langchain/text_splitter'; + import { logWrapper } from '../../../utils/logWrapper'; import { N8nBinaryLoader } from '../../../utils/N8nBinaryLoader'; import { getConnectionHintNoticeField, metadataFilterField } from '../../../utils/sharedFields'; @@ -17,7 +19,6 @@ import { getConnectionHintNoticeField, metadataFilterField } from '../../../util import 'mammoth'; // for docx import 'epub2'; // for epub import 'pdf-parse'; // for pdf -import type { TextSplitter } from 'langchain/text_splitter'; export class DocumentBinaryInputLoader implements INodeType { description: INodeTypeDescription = { diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ecfb9a4112797..64319d75c2275 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "0.4.0", + "version": "0.6.0", "description": "", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index d4b163071a386..49911148b8b0d 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/cli/package.json b/packages/cli/package.json index c9fa03f16d28b..ea6c0876f2f29 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.19.0", + "version": "1.21.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 66ce5855f004b..e97e4ce0a6a83 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -14,6 +14,7 @@ import { ExternalHooks } from '@/ExternalHooks'; import { send, sendErrorResponse } from '@/ResponseHelper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; import { TestWebhooks } from '@/TestWebhooks'; +import { WaitingForms } from '@/WaitingForms'; import { WaitingWebhooks } from '@/WaitingWebhooks'; import { webhookRequestHandler } from '@/WebhookHelpers'; import { generateHostInstanceId } from './databases/utils/generators'; @@ -39,6 +40,12 @@ export abstract class AbstractServer { protected restEndpoint: string; + protected endpointForm: string; + + protected endpointFormTest: string; + + protected endpointFormWaiting: string; + protected endpointWebhook: string; protected endpointWebhookTest: string; @@ -63,6 +70,11 @@ export abstract class AbstractServer { this.sslCert = config.getEnv('ssl_cert'); this.restEndpoint = config.getEnv('endpoints.rest'); + + this.endpointForm = config.getEnv('endpoints.form'); + this.endpointFormTest = config.getEnv('endpoints.formTest'); + this.endpointFormWaiting = config.getEnv('endpoints.formWaiting'); + this.endpointWebhook = config.getEnv('endpoints.webhook'); this.endpointWebhookTest = config.getEnv('endpoints.webhookTest'); this.endpointWebhookWaiting = config.getEnv('endpoints.webhookWaiting'); @@ -165,10 +177,21 @@ export abstract class AbstractServer { // Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests if (this.webhooksEnabled) { + const activeWorkflowRunner = Container.get(ActiveWorkflowRunner); + + // Register a handler for active forms + this.app.all(`/${this.endpointForm}/:path(*)`, webhookRequestHandler(activeWorkflowRunner)); + // Register a handler for active webhooks this.app.all( `/${this.endpointWebhook}/:path(*)`, - webhookRequestHandler(Container.get(ActiveWorkflowRunner)), + webhookRequestHandler(activeWorkflowRunner), + ); + + // Register a handler for waiting forms + this.app.all( + `/${this.endpointFormWaiting}/:path/:suffix?`, + webhookRequestHandler(Container.get(WaitingForms)), ); // Register a handler for waiting webhooks @@ -181,7 +204,8 @@ export abstract class AbstractServer { if (this.testWebhooksEnabled) { const testWebhooks = Container.get(TestWebhooks); - // Register a handler for test webhooks + // Register a handler + this.app.all(`/${this.endpointFormTest}/:path(*)`, webhookRequestHandler(testWebhooks)); this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks)); // Removes a test webhook diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 1caadbee578e9..b57836d3979e4 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -356,6 +356,13 @@ export interface IInternalHooksClass { target_user_id: string[]; public_api: boolean; email_sent: boolean; + invitee_role: string; + }): Promise; + onUserRoleChange(userInviteData: { + user: User; + target_user_id: string; + public_api: boolean; + target_user_new_role: string; }): Promise; onUserReinvite(userReinviteData: { user: User; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 863a0e05bc1c3..45b5c8ed1c069 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -498,6 +498,7 @@ export class InternalHooks implements IInternalHooksClass { target_user_id: string[]; public_api: boolean; email_sent: boolean; + invitee_role: string; }): Promise { void Promise.all([ eventBus.sendAuditEvent({ @@ -507,15 +508,28 @@ export class InternalHooks implements IInternalHooksClass { targetUserId: userInviteData.target_user_id, }, }), + this.telemetry.track('User invited new user', { user_id: userInviteData.user.id, target_user_id: userInviteData.target_user_id, public_api: userInviteData.public_api, email_sent: userInviteData.email_sent, + invitee_role: userInviteData.invitee_role, }), ]); } + async onUserRoleChange(userRoleChangeData: { + user: User; + target_user_id: string; + public_api: boolean; + target_user_new_role: string; + }) { + const { user, ...rest } = userRoleChangeData; + + void this.telemetry.track('User changed role', { user_id: user.id, ...rest }); + } + async onUserReinvite(userReinviteData: { user: User; target_user_id: string; diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 4684aac2a1f2a..5213e3964e75c 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -2,7 +2,11 @@ import type { Request, Response } from 'express'; import { parse, stringify } from 'flatted'; import picocolors from 'picocolors'; -import { ErrorReporterProxy as ErrorReporter, NodeApiError } from 'n8n-workflow'; +import { + ErrorReporterProxy as ErrorReporter, + FORM_TRIGGER_PATH_IDENTIFIER, + NodeApiError, +} from 'n8n-workflow'; import { Readable } from 'node:stream'; import type { IExecutionDb, @@ -67,6 +71,20 @@ export function sendErrorResponse(res: Response, error: Error) { console.error(picocolors.red(error.httpStatusCode), error.message); } + //render custom 404 page for form triggers + const { originalUrl } = res.req; + if (error.errorCode === 404 && originalUrl) { + const basePath = originalUrl.split('/')[1]; + const isLegacyFormTrigger = originalUrl.includes(FORM_TRIGGER_PATH_IDENTIFIER); + const isFormTrigger = basePath.includes('form'); + + if (isFormTrigger || isLegacyFormTrigger) { + const isTestWebhook = basePath.includes('test'); + res.status(404); + return res.render('form-trigger-404', { isTestWebhook }); + } + } + httpStatusCode = error.httpStatusCode; if (error.errorCode) { diff --git a/packages/cli/src/WaitingForms.ts b/packages/cli/src/WaitingForms.ts new file mode 100644 index 0000000000000..0625acd7e40c4 --- /dev/null +++ b/packages/cli/src/WaitingForms.ts @@ -0,0 +1,19 @@ +import { Service } from 'typedi'; + +import type { IExecutionResponse } from '@/Interfaces'; +import { WaitingWebhooks } from '@/WaitingWebhooks'; + +@Service() +export class WaitingForms extends WaitingWebhooks { + protected override includeForms = true; + + protected override logReceivedWebhook(method: string, executionId: string) { + this.logger.debug(`Received waiting-form "${method}" for execution "${executionId}"`); + } + + protected disableNode(execution: IExecutionResponse, method?: string) { + if (method === 'POST') { + execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; + } + } +} diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index 7368c29369e34..f91ffe1267427 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -5,6 +5,7 @@ import type express from 'express'; import * as WebhookHelpers from '@/WebhookHelpers'; import { NodeTypes } from '@/NodeTypes'; import type { + IExecutionResponse, IResponseCallbackData, IWebhookManager, IWorkflowDb, @@ -19,8 +20,10 @@ import { NotFoundError } from './errors/response-errors/not-found.error'; @Service() export class WaitingWebhooks implements IWebhookManager { + protected includeForms = false; + constructor( - private readonly logger: Logger, + protected readonly logger: Logger, private readonly nodeTypes: NodeTypes, private readonly executionRepository: ExecutionRepository, private readonly ownershipService: OwnershipService, @@ -28,12 +31,21 @@ export class WaitingWebhooks implements IWebhookManager { // TODO: implement `getWebhookMethods` for CORS support + protected logReceivedWebhook(method: string, executionId: string) { + this.logger.debug(`Received waiting-webhook "${method}" for execution "${executionId}"`); + } + + protected disableNode(execution: IExecutionResponse, _method?: string) { + execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; + } + async executeWebhook( req: WaitingWebhookRequest, res: express.Response, ): Promise { const { path: executionId, suffix } = req.params; - this.logger.debug(`Received waiting-webhook "${req.method}" for execution "${executionId}"`); + + this.logReceivedWebhook(req.method, executionId); // Reset request parameters req.params = {} as WaitingWebhookRequest['params']; @@ -55,7 +67,7 @@ export class WaitingWebhooks implements IWebhookManager { // Set the node as disabled so that the data does not get executed again as it would result // in starting the wait all over again - execution.data.executionData!.nodeExecutionStack[0].node.disabled = true; + this.disableNode(execution, req.method); // Remove waitTill information else the execution would stop execution.data.waitTill = undefined; @@ -97,7 +109,8 @@ export class WaitingWebhooks implements IWebhookManager { (webhook) => webhook.httpMethod === req.method && webhook.path === (suffix ?? '') && - webhook.webhookDescription.restartWebhook === true, + webhook.webhookDescription.restartWebhook === true && + (webhook.webhookDescription.isForm || false) === this.includeForms, ); if (webhookData === undefined) { diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 2f9441c573b33..81cb48907c799 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -37,7 +37,6 @@ import { BINARY_ENCODING, createDeferredPromise, ErrorReporterProxy as ErrorReporter, - FORM_TRIGGER_PATH_IDENTIFIER, NodeHelpers, } from 'n8n-workflow'; @@ -133,16 +132,7 @@ export const webhookRequestHandler = try { response = await webhookManager.executeWebhook(req, res); } catch (error) { - if ( - error.errorCode === 404 && - (error.message as string).includes(FORM_TRIGGER_PATH_IDENTIFIER) - ) { - const isTestWebhook = req.originalUrl.includes('webhook-test'); - res.status(404); - return res.render('form-trigger-404', { isTestWebhook }); - } else { - return ResponseHelper.sendErrorResponse(res, error as Error); - } + return ResponseHelper.sendErrorResponse(res, error as Error); } // Don't respond, if already responded @@ -560,10 +550,27 @@ export async function executeWebhook( } else { // TODO: This probably needs some more changes depending on the options on the // Webhook Response node + const headers = response.headers; + let responseCode = response.statusCode; + let data = response.body as IDataObject; + + // for formTrigger node redirection has to be handled by sending redirectURL in response body + if ( + nodeType.description.name === 'formTrigger' && + headers.location && + String(responseCode).startsWith('3') + ) { + responseCode = 200; + data = { + redirectURL: headers.location, + }; + headers.location = undefined; + } + responseCallback(null, { - data: response.body as IDataObject, - headers: response.headers, - responseCode: response.statusCode, + data, + headers, + responseCode, }); } diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index f49fed11b418c..4358be529e3b8 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -963,9 +963,11 @@ export async function getBase( ): Promise { const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); + const formWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.formWaiting'); + const webhookBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhook'); - const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); + const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const variables = await WorkflowHelpers.getVariables(); @@ -974,6 +976,7 @@ export async function getBase( executeWorkflow, restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'), instanceBaseUrl: urlBaseWebhook, + formWaitingBaseUrl, webhookBaseUrl, webhookWaitingBaseUrl, webhookTestBaseUrl, diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 58aed9484d218..9f3ccfc682cfc 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -1,26 +1,18 @@ import { flags } from '@oclif/command'; -import type { INode, INodeCredentialsDetails } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import fs from 'fs'; import glob from 'fast-glob'; import { Container } from 'typedi'; -import type { EntityManager } from 'typeorm'; -import { v4 as uuid } from 'uuid'; -import * as Db from '@/Db'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type { Role } from '@db/entities/Role'; -import type { User } from '@db/entities/User'; import { disableAutoGeneratedIds } from '@db/utils/commandHelpers'; -import type { ICredentialsDb, IWorkflowToImport } from '@/Interfaces'; -import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import type { IWorkflowToImport } from '@/Interfaces'; import { BaseCommand } from '../BaseCommand'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; -import { TagService } from '@/services/tag.service'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { UserRepository } from '@db/repositories/user.repository'; -import { CredentialsRepository } from '@db/repositories/credentials.repository'; +import { ImportService } from '@/services/import.service'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; function assertHasWorkflowsToImport(workflows: unknown): asserts workflows is IWorkflowToImport[] { if (!Array.isArray(workflows)) { @@ -64,16 +56,9 @@ export class ImportWorkflowsCommand extends BaseCommand { }), }; - private ownerWorkflowRole: Role; - - private transactionManager: EntityManager; - - private tagService: TagService; - async init() { disableAutoGeneratedIds(WorkflowEntity); await super.init(); - this.tagService = Container.get(TagService); } async run(): Promise { @@ -94,12 +79,8 @@ export class ImportWorkflowsCommand extends BaseCommand { } } - await this.initOwnerWorkflowRole(); const user = flags.userId ? await this.getAssignee(flags.userId) : await this.getOwner(); - const credentials = await Container.get(CredentialsRepository).find(); - const tags = await this.tagService.getAll(); - let totalImported = 0; if (flags.separate) { @@ -116,41 +97,17 @@ export class ImportWorkflowsCommand extends BaseCommand { totalImported = files.length; this.logger.info(`Importing ${totalImported} workflows...`); - await Db.getConnection().transaction(async (transactionManager) => { - this.transactionManager = transactionManager; - - for (const file of files) { - const workflow = jsonParse( - fs.readFileSync(file, { encoding: 'utf8' }), - ); - if (!workflow.id) { - workflow.id = generateNanoId(); - } - - if (credentials.length > 0) { - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentials); - - if (!node.id) { - node.id = uuid(); - } - }); - } - - if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await this.tagService.setTagsForImport(transactionManager, workflow, tags); - } - - if (workflow.active) { - this.logger.info( - `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, - ); - workflow.active = false; - } - - await this.storeWorkflow(workflow, user); + + for (const file of files) { + const workflow = jsonParse(fs.readFileSync(file, { encoding: 'utf8' })); + if (!workflow.id) { + workflow.id = generateNanoId(); } - }); + + const _workflow = Container.get(WorkflowRepository).create(workflow); + + await Container.get(ImportService).importWorkflows([_workflow], user.id); + } this.reportSuccess(totalImported); process.exit(); @@ -160,46 +117,13 @@ export class ImportWorkflowsCommand extends BaseCommand { fs.readFileSync(flags.input, { encoding: 'utf8' }), ); + const _workflows = workflows.map((w) => Container.get(WorkflowRepository).create(w)); + assertHasWorkflowsToImport(workflows); totalImported = workflows.length; - await Db.getConnection().transaction(async (transactionManager) => { - this.transactionManager = transactionManager; - - for (const workflow of workflows) { - let oldCredentialFormat = false; - if (credentials.length > 0) { - workflow.nodes.forEach((node: INode) => { - this.transformCredentials(node, credentials); - if (!node.id) { - node.id = uuid(); - } - if (!node.credentials?.id) { - oldCredentialFormat = true; - } - }); - } - if (oldCredentialFormat) { - try { - await replaceInvalidCredentials(workflow as unknown as WorkflowEntity); - } catch (error) { - this.logger.error('Failed to replace invalid credential', error as Error); - } - } - if (Object.prototype.hasOwnProperty.call(workflow, 'tags')) { - await this.tagService.setTagsForImport(transactionManager, workflow, tags); - } - if (workflow.active) { - this.logger.info( - `Deactivating workflow "${workflow.name}" during import, remember to activate it later.`, - ); - workflow.active = false; - } - - await this.storeWorkflow(workflow, user); - } - }); + await Container.get(ImportService).importWorkflows(_workflows, user.id); this.reportSuccess(totalImported); } @@ -213,29 +137,6 @@ export class ImportWorkflowsCommand extends BaseCommand { this.logger.info(`Successfully imported ${total} ${total === 1 ? 'workflow.' : 'workflows.'}`); } - private async initOwnerWorkflowRole() { - const ownerWorkflowRole = await Container.get(RoleService).findWorkflowOwnerRole(); - - if (!ownerWorkflowRole) { - throw new ApplicationError(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`); - } - - this.ownerWorkflowRole = ownerWorkflowRole; - } - - private async storeWorkflow(workflow: object, user: User) { - const result = await this.transactionManager.upsert(WorkflowEntity, workflow, ['id']); - await this.transactionManager.upsert( - SharedWorkflow, - { - workflowId: result.identifiers[0].id as string, - userId: user.id, - roleId: this.ownerWorkflowRole.id, - }, - ['workflowId', 'userId'], - ); - } - private async getOwner() { const ownerGlobalRole = await Container.get(RoleService).findGlobalOwnerRole(); @@ -259,28 +160,4 @@ export class ImportWorkflowsCommand extends BaseCommand { return user; } - - private transformCredentials(node: INode, credentialsEntities: ICredentialsDb[]) { - if (node.credentials) { - const allNodeCredentials = Object.entries(node.credentials); - for (const [type, name] of allNodeCredentials) { - if (typeof name === 'string') { - const nodeCredentials: INodeCredentialsDetails = { - id: null, - name, - }; - - const matchingCredentials = credentialsEntities.filter( - (credentials) => credentials.name === name && credentials.type === type, - ); - - if (matchingCredentials.length === 1) { - nodeCredentials.id = matchingCredentials[0].id; - } - - node.credentials[type] = nodeCredentials; - } - } - } - } } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 0fb2b29dfaf5d..9afb4a7eeb51b 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -668,6 +668,24 @@ export const schema = { env: 'N8N_ENDPOINT_REST', doc: 'Path for rest endpoint', }, + form: { + format: String, + default: 'form', + env: 'N8N_ENDPOINT_FORM', + doc: 'Path for form endpoint', + }, + formTest: { + format: String, + default: 'form-test', + env: 'N8N_ENDPOINT_FORM_TEST', + doc: 'Path for test form endpoint', + }, + formWaiting: { + format: String, + default: 'form-waiting', + env: 'N8N_ENDPOINT_FORM_WAIT', + doc: 'Path for waiting form endpoint', + }, webhook: { format: String, default: 'webhook', diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 0ef0ae55b6a10..6e0ae6438ea47 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -391,6 +391,13 @@ export class UsersController { await this.userService.update(targetUser.id, { globalRole: roleToSet }); + void this.internalHooks.onUserRoleChange({ + user: req.user, + target_user_id: targetUser.id, + target_user_new_role: [newRole.scope, newRole.name].join(' '), + public_api: false, + }); + return { success: true }; } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 9f9297a83a349..fe7d6141ba3d0 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -81,6 +81,9 @@ export class FrontendService { } this.settings = { + endpointForm: config.getEnv('endpoints.form'), + endpointFormTest: config.getEnv('endpoints.formTest'), + endpointFormWaiting: config.getEnv('endpoints.formWaiting'), endpointWebhook: config.getEnv('endpoints.webhook'), endpointWebhookTest: config.getEnv('endpoints.webhookTest'), saveDataErrorExecution: config.getEnv('executions.saveDataOnError'), diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts new file mode 100644 index 0000000000000..9ba0715b77ecc --- /dev/null +++ b/packages/cli/src/services/import.service.ts @@ -0,0 +1,153 @@ +import { Service } from 'typedi'; +import { v4 as uuid } from 'uuid'; +import { type INode, type INodeCredentialsDetails } from 'n8n-workflow'; +import type { EntityManager } from 'typeorm'; + +import { Logger } from '@/Logger'; +import * as Db from '@/Db'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { TagRepository } from '@/databases/repositories/tag.repository'; +import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import { RoleService } from '@/services/role.service'; +import { replaceInvalidCredentials } from '@/WorkflowHelpers'; +import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping'; + +import type { TagEntity } from '@/databases/entities/TagEntity'; +import type { Role } from '@/databases/entities/Role'; +import type { ICredentialsDb } from '@/Interfaces'; + +@Service() +export class ImportService { + private dbCredentials: ICredentialsDb[] = []; + + private dbTags: TagEntity[] = []; + + private workflowOwnerRole: Role; + + constructor( + private readonly logger: Logger, + private readonly credentialsRepository: CredentialsRepository, + private readonly tagRepository: TagRepository, + private readonly roleService: RoleService, + ) {} + + async initRecords() { + this.dbCredentials = await this.credentialsRepository.find(); + this.dbTags = await this.tagRepository.find(); + this.workflowOwnerRole = await this.roleService.findWorkflowOwnerRole(); + } + + async importWorkflows(workflows: WorkflowEntity[], userId: string) { + await this.initRecords(); + + for (const workflow of workflows) { + workflow.nodes.forEach((node) => { + this.toNewCredentialFormat(node); + + if (!node.id) node.id = uuid(); + }); + + const hasInvalidCreds = workflow.nodes.some((node) => !node.credentials?.id); + + if (hasInvalidCreds) await this.replaceInvalidCreds(workflow); + } + + await Db.transaction(async (tx) => { + for (const workflow of workflows) { + if (workflow.active) { + workflow.active = false; + + this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`); + } + + const upsertResult = await tx.upsert(WorkflowEntity, workflow, ['id']); + + const workflowId = upsertResult.identifiers.at(0)?.id as string; + + await tx.upsert(SharedWorkflow, { workflowId, userId, roleId: this.workflowOwnerRole.id }, [ + 'workflowId', + 'userId', + ]); + + if (!workflow.tags?.length) continue; + + await this.setTags(tx, workflow); + + for (const tag of workflow.tags) { + await tx.upsert(WorkflowTagMapping, { tagId: tag.id, workflowId }, [ + 'tagId', + 'workflowId', + ]); + } + } + }); + } + + async replaceInvalidCreds(workflow: WorkflowEntity) { + try { + await replaceInvalidCredentials(workflow); + } catch (e) { + const error = e instanceof Error ? e : new Error(`${e}`); + this.logger.error('Failed to replace invalid credential', error); + } + } + + /** + * Convert a node's credentials from old format `{ : }` + * to new format: `{ : { id: string | null, name: } }` + */ + private toNewCredentialFormat(node: INode) { + if (!node.credentials) return; + + for (const [type, name] of Object.entries(node.credentials)) { + if (typeof name !== 'string') continue; + + const nodeCredential: INodeCredentialsDetails = { id: null, name }; + + const match = this.dbCredentials.find((c) => c.name === name && c.type === type); + + if (match) nodeCredential.id = match.id; + + node.credentials[type] = nodeCredential; + } + } + + /** + * Set tags on workflow to import while ensuring all tags exist in the database, + * either by matching incoming to existing tags or by creating them first. + */ + private async setTags(tx: EntityManager, workflow: WorkflowEntity) { + if (!workflow?.tags?.length) return; + + for (let i = 0; i < workflow.tags.length; i++) { + const importTag = workflow.tags[i]; + + if (!importTag.name) continue; + + const identicalMatch = this.dbTags.find( + (dbTag) => + dbTag.id === importTag.id && + dbTag.createdAt && + importTag.createdAt && + dbTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), + ); + + if (identicalMatch) { + workflow.tags[i] = identicalMatch; + continue; + } + + const nameMatch = this.dbTags.find((dbTag) => dbTag.name === importTag.name); + + if (nameMatch) { + workflow.tags[i] = nameMatch; + continue; + } + + const tagEntity = this.tagRepository.create(importTag); + + workflow.tags[i] = await tx.save(tagEntity); + } + } +} diff --git a/packages/cli/src/services/tag.service.ts b/packages/cli/src/services/tag.service.ts index 8d3e40ad93251..63050ea14981c 100644 --- a/packages/cli/src/services/tag.service.ts +++ b/packages/cli/src/services/tag.service.ts @@ -1,9 +1,9 @@ import { TagRepository } from '@db/repositories/tag.repository'; import { Service } from 'typedi'; import { validateEntity } from '@/GenericHelpers'; -import type { ITagToImport, ITagWithCountDb, IWorkflowToImport } from '@/Interfaces'; +import type { ITagWithCountDb } from '@/Interfaces'; import type { TagEntity } from '@db/entities/TagEntity'; -import type { EntityManager, FindManyOptions, FindOneOptions } from 'typeorm'; +import type { FindManyOptions, FindOneOptions } from 'typeorm'; import type { UpsertOptions } from 'typeorm/repository/UpsertOptions'; import { ExternalHooks } from '@/ExternalHooks'; @@ -89,69 +89,4 @@ export class TagService { return requestOrder.map((tagId) => tagMap[tagId]); } - - /** - * Set tag IDs to use existing tags, creates a new tag if no matching tag could be found - */ - async setTagsForImport( - transactionManager: EntityManager, - workflow: IWorkflowToImport, - tags: TagEntity[], - ) { - if (!this.hasTags(workflow)) return; - - const workflowTags = workflow.tags; - const tagLookupPromises = []; - for (let i = 0; i < workflowTags.length; i++) { - if (workflowTags[i]?.name) { - const lookupPromise = this.findOrCreateTag(transactionManager, workflowTags[i], tags).then( - (tag) => { - workflowTags[i] = { - id: tag.id, - name: tag.name, - }; - }, - ); - tagLookupPromises.push(lookupPromise); - } - } - - await Promise.all(tagLookupPromises); - } - - private hasTags(workflow: IWorkflowToImport) { - return 'tags' in workflow && Array.isArray(workflow.tags) && workflow.tags.length > 0; - } - - private async findOrCreateTag( - transactionManager: EntityManager, - importTag: ITagToImport, - tagsEntities: TagEntity[], - ) { - // Assume tag is identical if createdAt date is the same to preserve a changed tag name - const identicalMatch = tagsEntities.find( - (existingTag) => - existingTag.id === importTag.id && - existingTag.createdAt && - importTag.createdAt && - existingTag.createdAt.getTime() === new Date(importTag.createdAt).getTime(), - ); - if (identicalMatch) { - return identicalMatch; - } - - const nameMatch = tagsEntities.find((existingTag) => existingTag.name === importTag.name); - if (nameMatch) { - return nameMatch; - } - - const created = await this.txCreateTag(transactionManager, importTag.name); - tagsEntities.push(created); - return created; - } - - private async txCreateTag(transactionManager: EntityManager, name: string) { - const tag = this.tagRepository.create({ name: name.trim() }); - return transactionManager.save(tag); - } } diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 2dd4122f1a3ec..41037f26c27c1 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -188,7 +188,11 @@ export class UserService { return Promise.race([fetchPromise, timeoutPromise]); } - private async sendEmails(owner: User, toInviteUsers: { [key: string]: string }) { + private async sendEmails( + owner: User, + toInviteUsers: { [key: string]: string }, + role: 'member' | 'admin', + ) { const domain = getInstanceBaseUrl(); return Promise.all( @@ -225,6 +229,7 @@ export class UserService { target_user_id: Object.values(toInviteUsers), public_api: false, email_sent: result.emailSent, + invitee_role: role, // same role for all invited users }); } catch (e) { if (e instanceof Error) { @@ -294,7 +299,11 @@ export class UserService { pendingUsersToInvite.forEach(({ email, id }) => createdUsers.set(email, id)); - const usersInvited = await this.sendEmails(owner, Object.fromEntries(createdUsers)); + const usersInvited = await this.sendEmails( + owner, + Object.fromEntries(createdUsers), + toCreateUsers[0].role, // same role for all invited users + ); return { usersInvited, usersCreated: toCreateUsers.map(({ email }) => email) }; } diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts index e5243551b2098..2b5852f23b95a 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -66,7 +66,10 @@ export class WorkflowHistoryService { } async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) { - if (isWorkflowHistoryEnabled()) { + // On some update scenarios, `nodes` and `connections` are missing, such as when + // changing workflow settings or renaming. In these cases, we don't want to save + // a new version + if (isWorkflowHistoryEnabled() && workflow.nodes && workflow.connections) { try { await this.workflowHistoryRepository.insert({ authors: user.firstName + ' ' + user.lastName, diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index ea6d6704a22c0..9dc153d62e483 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -4,6 +4,7 @@ import { NodeApiError, ErrorReporterProxy as ErrorReporter, Workflow } from 'n8n import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm'; import { In, Like } from 'typeorm'; import pick from 'lodash/pick'; +import omit from 'lodash/omit'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import * as WorkflowHelpers from '@/WorkflowHelpers'; @@ -230,21 +231,9 @@ export class WorkflowsService { ); } - let onlyActiveUpdate = false; - - if ( - (Object.keys(workflow).length === 3 && - workflow.id !== undefined && - workflow.versionId !== undefined && - workflow.active !== undefined) || - (Object.keys(workflow).length === 2 && - workflow.versionId !== undefined && - workflow.active !== undefined) - ) { - // we're just updating the active status of the workflow, don't update the versionId - onlyActiveUpdate = true; - } else { - // Update the workflow's version + if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) { + // Update the workflow's version when changing properties such as + // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` workflow.versionId = uuid(); logger.verbose( `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, @@ -320,7 +309,7 @@ export class WorkflowsService { ); } - if (!onlyActiveUpdate && workflow.versionId !== shared.workflow.versionId) { + if (workflow.versionId !== shared.workflow.versionId) { await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId); } diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 15ec4a10a4c52..f89e3c0a7dafd 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -385,6 +385,10 @@ + {{#if redirectUrl}} + + {{/if}} + diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 202e0d427fcc2..d917339b5ef33 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -50,4 +50,5 @@ export { default as N8nUserStack } from './N8nUserStack'; export { default as N8nUserInfo } from './N8nUserInfo'; export { default as N8nUserSelect } from './N8nUserSelect'; export { default as N8nUsersList } from './N8nUsersList'; +export { default as N8nResizeObserver } from './ResizeObserver'; export { N8nKeyboardShortcut } from './N8nKeyboardShortcut'; diff --git a/packages/design-system/src/css/common/var.scss b/packages/design-system/src/css/common/var.scss index 12d16555b8a06..cc0687266ce00 100644 --- a/packages/design-system/src/css/common/var.scss +++ b/packages/design-system/src/css/common/var.scss @@ -399,8 +399,16 @@ $input-placeholder-color: var(--input-placeholder-color, var(--color-text-light) $input-focus-border: var(--input-focus-border-color, var(--color-secondary)); $input-border-color: var(--input-border-color, var(--border-color-base)); $input-border-style: var(--input-border-style, var(--border-style-base)); -$input-border-width: var(--border-width-base); +$input-border-width: var(--input-border-width, var(--border-width-base)); $input-border: $input-border-color $input-border-style $input-border-width; +$input-border-right-color: var( + --input-border-right-color, + var(--input-border-color, var(--border-color-base)) +); +$input-border-bottom-color: var( + --input-border-bottom-color, + var(--input-border-color, var(--border-color-base)) +); $input-font-size: var(--input-font-size, var(--font-size-s)); /// color||Color|0 @@ -411,6 +419,23 @@ $input-width: 140px; $input-height: 40px; /// borderRadius||Border|2 $input-border-radius: var(--input-border-radius, var(--border-radius-base)); +$input-border-top-left-radius: var( + --input-border-top-left-radius, + var(--input-border-radius, var(--border-radius-base)) +); +$input-border-top-right-radius: var( + --input-border-top-right-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-bottom-left-radius: var( + --input-border-bottom-left-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-bottom-right-radius: var( + --input-border-bottom-right-radius, + var(--input-border-radius, var(--border-radius-base)), +); +$input-border-radius: var(--input-border-radius, var(--border-radius-base)); $input-border-color-hover: $border-color-hover; /// color||Color|0 $input-background-color: var(--input-background-color, var(--color-foreground-xlight)); diff --git a/packages/design-system/src/css/input.scss b/packages/design-system/src/css/input.scss index cab4d87b28690..d388969e414c1 100644 --- a/packages/design-system/src/css/input.scss +++ b/packages/design-system/src/css/input.scss @@ -20,6 +20,11 @@ background-color: var.$input-background-color; background-image: none; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; + transition: var.$border-transition-base; &, @@ -108,7 +113,13 @@ background-color: var.$input-background-color; background-image: none; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; border: var.$input-border; + border-right-color: var.$input-border-right-color; + border-bottom-color: var.$input-border-bottom-color; box-sizing: border-box; color: var.$input-font-color; display: inline-block; @@ -145,6 +156,7 @@ } @include mixins.e(suffix-inner) { + display: inline-flex; pointer-events: all; } @@ -286,8 +298,14 @@ vertical-align: middle; display: table-cell; position: relative; - border: var(--border-base); + border: var.$input-border; border-radius: var.$input-border-radius; + border-top-left-radius: var.$input-border-top-left-radius; + border-top-right-radius: var.$input-border-top-right-radius; + border-bottom-left-radius: var.$input-border-bottom-left-radius; + border-bottom-right-radius: var.$input-border-bottom-right-radius; + border-right-color: var.$input-border-right-color; + border-bottom-color: var.$input-border-bottom-color; padding: 0 10px; width: 1px; white-space: nowrap; diff --git a/packages/design-system/src/css/select.scss b/packages/design-system/src/css/select.scss index 7cc8696c1a61b..f5b6f17d31ba3 100644 --- a/packages/design-system/src/css/select.scss +++ b/packages/design-system/src/css/select.scss @@ -80,6 +80,14 @@ &.is-focus .el-input__inner { border-color: var.$select-input-focus-border-color; } + + &__prefix { + left: var(--spacing-2xs); + } + + &--prefix .el-input__inner { + padding-left: 26px; + } } > .el-input { diff --git a/packages/design-system/src/plugin.ts b/packages/design-system/src/plugin.ts index 6a5ec45bb8699..f06d4cf4bb426 100644 --- a/packages/design-system/src/plugin.ts +++ b/packages/design-system/src/plugin.ts @@ -51,6 +51,7 @@ import { N8nUserInfo, N8nUserSelect, N8nUsersList, + N8nResizeObserver, N8nKeyboardShortcut, N8nUserStack, } from './components'; @@ -111,6 +112,7 @@ export const N8nPlugin: Plugin = { app.component('n8n-user-info', N8nUserInfo); app.component('n8n-users-list', N8nUsersList); app.component('n8n-user-select', N8nUserSelect); + app.component('n8n-resize-observer', N8nResizeObserver); app.component('n8n-keyboard-shortcut', N8nKeyboardShortcut); }, }; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index fdfd764a39508..e9352f25100e7 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.19.0", + "version": "1.21.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 0646ee210892d..9421837e443da 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1070,6 +1070,9 @@ export interface RootState { baseUrl: string; restEndpoint: string; defaultLocale: string; + endpointForm: string; + endpointFormTest: string; + endpointFormWaiting: string; endpointWebhook: string; endpointWebhookTest: string; pushConnectionActive: boolean; @@ -1098,6 +1101,9 @@ export interface IRootState { activeCredentialType: string | null; baseUrl: string; defaultLocale: string; + endpointForm: string; + endpointFormTest: string; + endpointFormWaiting: string; endpointWebhook: string; endpointWebhookTest: string; executionId: string | null; @@ -1216,7 +1222,7 @@ export interface NDVState { isDragging: boolean; type: string; data: string; - canDrop: boolean; + activeTargetId: string | null; stickyPosition: null | XYPosition; }; isMappingOnboarded: boolean; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts index 9dacbb8c3752c..3fa6cc8e67193 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts @@ -7,6 +7,9 @@ const defaultSettings: IN8nUISettings = { allowedModules: {}, communityNodesEnabled: false, defaultLocale: '', + endpointForm: '', + endpointFormTest: '', + endpointFormWaiting: '', endpointWebhook: '', endpointWebhookTest: '', enterprise: { diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index 37f64f5c0bb9a..5ca901e85d494 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -29,6 +29,9 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { allowedModules: {}, communityNodesEnabled: false, defaultLocale: '', + endpointForm: '', + endpointFormTest: '', + endpointFormWaiting: '', endpointWebhook: '', endpointWebhookTest: '', enterprise: { diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts index b2a1ec499535d..502221b48ff70 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts @@ -5,7 +5,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro export const executionCompletions = defineComponent({ methods: { /** - * Complete `$execution.` to `.id .mode .resumeUrl` + * Complete `$execution.` to `.id .mode .resumeUrl .resumeFormUrl` */ executionCompletions( context: CompletionContext, @@ -39,6 +39,10 @@ export const executionCompletions = defineComponent({ label: `${matcher}.resumeUrl`, info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'), }, + { + label: `${matcher}.resumeFormUrl`, + info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeFormUrl'), + }, { label: `${matcher}.customData.set("key", "value")`, info: buildLinkNode( diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 28d4b7f0ce203..b44d24a51597f 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -41,7 +41,15 @@ }) }} - +